Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions .github/scripts/build-sea-binary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,18 @@ describe("mainFormatFor", () => {
});

describe("binaryFileName", () => {
it("qualifies every platform with a distinct, human-readable label, appending .exe only on win32", () => {
expect(binaryFileName("document-rest", "win32")).toBe(
"document-rest-windows.exe",
it("qualifies every (platform, arch) pair with a distinct, human-readable name, appending .exe only on win32", () => {
expect(binaryFileName("document-rest", "win32", "x64")).toBe(
"document-rest-windows-x64.exe",
);
expect(binaryFileName("document-rest", "darwin")).toBe(
"document-rest-macos",
expect(binaryFileName("document-rest", "darwin", "arm64")).toBe(
"document-rest-macos-arm64",
);
expect(binaryFileName("document-rest", "linux")).toBe(
"document-rest-linux",
expect(binaryFileName("document-rest", "darwin", "x64")).toBe(
"document-rest-macos-x64",
);
expect(binaryFileName("document-rest", "linux", "x64")).toBe(
"document-rest-linux-x64",
);
});
});
Expand Down
23 changes: 18 additions & 5 deletions .github/scripts/build-sea-binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join } from "node:path";
import { pathToFileURL } from "node:url";

export type SeaPlatform = "darwin" | "linux" | "win32";
export type SeaArch = "arm64" | "x64";

export interface SeaBuildPaths {
readonly packageDir: string;
Expand All @@ -25,12 +26,13 @@ const PLATFORM_LABELS: Record<SeaPlatform, string> = {
win32: "windows",
};

/** The platform-qualified on-disk file name for a binary -- every platform gets its own distinct name, not only win32's `.exe`: three release legs (one per platform in the CI matrix) upload to the same GitHub Release via `gh release upload --clobber`, so an unqualified name shared between two platforms silently loses one binary to the other's upload rather than erroring -- confirmed directly the first time a real backfill ran with only win32 disambiguated (ExaDev/documents.js, 2026-09-11): the release ended up with exactly one unsuffixed `document-cli` asset, and there was no way to tell afterward whether it was the Linux or macOS build, because the losing upload left no trace at all. */
/** The platform-and-architecture-qualified on-disk file name for a binary -- every (platform, arch) pair gets its own distinct name, not only win32's `.exe`: every release leg in the CI matrix uploads to the same GitHub Release via `gh release upload --clobber`, so an unqualified name shared between two legs silently loses one binary to the other's upload rather than erroring -- confirmed directly, twice, the first two times this actually ran (ExaDev/documents.js, 2026-09-11): first with only win32 disambiguated (the macOS and Linux legs shared one name), then again once a second macOS architecture (Intel, alongside Apple Silicon) joined the matrix -- both `darwin`, so a platform-only name collides identically between two legs that differ only in `process.arch`. Both times the release ended up with fewer binaries than legs, and no trace of which one the surviving asset actually was. */
export function binaryFileName(
binaryName: string,
platform: SeaPlatform,
arch: SeaArch,
): string {
const suffixed = `${binaryName}-${PLATFORM_LABELS[platform]}`;
const suffixed = `${binaryName}-${PLATFORM_LABELS[platform]}-${arch}`;
return platform === "win32" ? `${suffixed}.exe` : suffixed;
}

Expand All @@ -50,14 +52,15 @@ function run(command: string, args: readonly string[]): void {
execFileSync(command, args, { stdio: ["inherit", 2, "inherit"] });
}

/** Builds the SEA binary for `paths.packageDir`, targeting whichever platform this process is currently running under. sea-config.json lives beside the final binary in `paths.outputDir`, which the caller is responsible for pointing at a package's own gitignored dist-sea/ (see tsdown.sea.shared.ts's own comment on why that directory is never published). */
/** Builds the SEA binary for `paths.packageDir`, targeting whichever platform and architecture this process is currently running under. sea-config.json lives beside the final binary in `paths.outputDir`, which the caller is responsible for pointing at a package's own gitignored dist-sea/ (see tsdown.sea.shared.ts's own comment on why that directory is never published). */
export function buildSeaBinary(
paths: SeaBuildPaths,
platform: SeaPlatform,
arch: SeaArch,
): string {
mkdirSync(paths.outputDir, { recursive: true });

const fileName = binaryFileName(paths.binaryName, platform);
const fileName = binaryFileName(paths.binaryName, platform, arch);
const binaryPath = join(paths.outputDir, fileName);
const configPath = join(paths.outputDir, "sea-config.json");

Expand Down Expand Up @@ -94,6 +97,16 @@ function currentPlatform(): SeaPlatform {
);
}

function currentArch(): SeaArch {
const { arch } = process;
if (arch === "arm64" || arch === "x64") {
return arch;
}
throw new Error(
`No Node SEA binary target is defined for architecture "${arch}".`,
);
}

const BUNDLE_BASENAME = "sea-entry";
const BUNDLE_EXTENSIONS = [".cjs", ".mjs"] as const;

Expand Down Expand Up @@ -135,7 +148,7 @@ function parseArgs(argv: readonly string[]): SeaBuildPaths {

function main(): void {
const paths = parseArgs(process.argv.slice(2));
const binaryPath = buildSeaBinary(paths, currentPlatform());
const binaryPath = buildSeaBinary(paths, currentPlatform(), currentArch());
console.log(binaryPath);
}

Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -374,10 +374,10 @@ jobs:
NPM_ALIASES=$(echo "$NPM_ALIASES" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" --arg alias "$ALIAS" \
'. + [{name: $name, version: $version, tag: $tag, alias: $alias}]')
done < <(jq -r --arg name "$NAME" '.[$name].aliases // [] | .[]' .github/release-republish.json)
# The only three packages this workspace builds a Node SEA (single-executable application) binary for -- see packages/document-cli, document-mcp, document-rest's own tsdown.config.ts. A hardcoded literal set, not a config-file entry like the alias/GitHub-Packages maps above: those vary per package and grow independently, where this is a small, closed set unlikely to grow the same way.
# The only three packages this workspace builds a Node SEA (single-executable application) binary for -- see packages/document-cli, document-mcp, document-rest's own tsdown.config.ts. A hardcoded literal set, not a config-file entry like the alias/GitHub-Packages maps above: those vary per package and grow independently, where this is a small, closed set unlikely to grow the same way. Two of the four legs are both macOS (macos-latest tracks the newest Apple Silicon image; macos-26-intel is GitHub's current standard, non-large-runner Intel image, since macos-latest stopped meaning Intel some time ago) -- build-sea-binary.ts's own binaryFileName disambiguates them by process.arch, not just by OS, for exactly this reason.
case "$NAME" in
document-cli | document-mcp | document-rest)
for OS in ubuntu-latest macos-latest windows-latest; do
for OS in ubuntu-latest macos-latest macos-26-intel windows-latest; do
SEA=$(echo "$SEA" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" --arg os "$OS" \
'. + [{name: $name, version: $version, tag: $tag, os: $os}]')
done
Expand Down Expand Up @@ -480,10 +480,10 @@ jobs:
NPM_ALIASES=$(echo "$NPM_ALIASES" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" --arg alias "$ALIAS" \
'. + [{name: $name, version: $version, tag: $tag, alias: $alias}]')
done < <(jq -r --arg name "$NAME" '.[$name].aliases // [] | .[]' .github/release-republish.json)
# Mirrors the release job's own identical case statement -- see that step's own comment on why this is a hardcoded literal set rather than a release-republish.json entry.
# Mirrors the release job's own identical case statement -- see that step's own comment on why this is a hardcoded literal set rather than a release-republish.json entry, and on why two of the four legs are both macOS.
case "$NAME" in
document-cli | document-mcp | document-rest)
for OS in ubuntu-latest macos-latest windows-latest; do
for OS in ubuntu-latest macos-latest macos-26-intel windows-latest; do
SEA=$(echo "$SEA" | jq -c --arg name "$NAME" --arg version "$VERSION" --arg tag "$TAG" --arg os "$OS" \
'. + [{name: $name, version: $version, tag: $tag, os: $os}]')
done
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ Release configuration is **only** at the root. The orchestrator sets `tagFormat`

**Post-release republishing and attestation, restored:** the separate repositories' own per-package pipelines also republished each package under one or more alternate npm names (and, for several packages, under a `@exadev/<name>` scope to GitHub Packages), and signed an SPDX SBOM plus a build-provenance attestation against every release tarball. The orchestrator itself has no equivalent step, so three post-release jobs in the same CI workflow now provide it (previously [#732](https://github.com/ExaDev/documents.js/issues/732)): when the release job finishes, it diffs the `name@version` tags the orchestrator created and fans out over exactly the packages that released, each leg checking out its package's own release tag so a queued next release can never shift the tree under it. One matrix republishes each package that `.github/release-republish.json` maps to GitHub Packages (`npm pkg set` of the scoped name and a `publishConfig.registry` override at publish time — never a second package.json, so the mirror cannot drift from the real metadata); one matrix republishes under each alternate npm name the same map lists (`document-bytes`, `mrkdwn.js`, `pdf-codec.js`/`pdf-parser.js`, the five `document-schema.js` aliases, `js.documents`, `doculi`) via OIDC trusted publishing; one generates an SPDX SBOM (`pnpm sbom --sbom-format spdx --prod`), packs the shipped tarball, attests SBOM and build provenance against it with `actions/attest`, and attaches the raw SBOM to the package's GitHub Release. Nothing depends on these jobs, so a failure there can never block the release or the Pages deploy, and every leg skips as already-done on re-runs.

**SEA binaries:** a fourth post-release matrix builds a Node [single-executable application](https://nodejs.org/api/single-executable-applications.html) binary for `document-cli`, `document-mcp`, and `document-rest` — the three packages whose own `tsdown.config.ts` produces a fully-bundled, dependency-free entry point for it (see each package's own README for what the resulting binary covers) — across Linux, macOS, and Windows, and attaches each one to that release's GitHub Release assets. Node's own SEA tooling copies whichever `node` binary builds it, so the job installs a second, newer Node (beyond the workspace's own pinned version) immediately before injection, specifically to get the native ESM `mainFormat` support `document-cli`'s Ink-driven bundle needs; `.github/scripts/build-sea-binary.ts` holds the platform-specific injection and signing steps (an ad-hoc `codesign` on macOS, a `signtool` designature on Windows) themselves. Like the three jobs above, nothing depends on it and a failure there can never block the release.
**SEA binaries:** a fourth post-release matrix builds a Node [single-executable application](https://nodejs.org/api/single-executable-applications.html) binary for `document-cli`, `document-mcp`, and `document-rest` — the three packages whose own `tsdown.config.ts` produces a fully-bundled, dependency-free entry point for it (see each package's own README for what the resulting binary covers) — for Linux (x64), Windows (x64), and macOS on both Apple Silicon and Intel, and attaches each one to that release's GitHub Release assets under a name qualified by both OS and architecture (`document-cli-macos-arm64`, `document-cli-macos-x64`, and so on), since two of the four build legs are both macOS and would otherwise collide on upload. Node's own `--build-sea` (Node 25.5.0+) copies whichever `node` binary builds it, so the job installs a second, newer Node (beyond the workspace's own pinned version) immediately before that step, specifically to get the native ESM `mainFormat` support `document-cli`'s Ink-driven bundle needs; `.github/scripts/build-sea-binary.ts` holds the platform-specific ad-hoc `codesign` signing step macOS needs (Linux and Windows ship unsigned). Like the three jobs above, nothing depends on it and a failure there can never block the release.

If a release job is itself cancelled by its own timeout after the per-package publish work has already completed but before it collects the released tags into those four matrices, a plain re-run cannot recover it: the collection step diffs tags against a before/after snapshot taken within that same run, so a later run sees every already-existing tag as pre-existing, not new. `ci.yml`'s `workflow_dispatch` trigger takes an optional `backfill_tags` input (comma- or newline-separated `name@version` tags) for exactly this case: a `collect-backfill-matrix` job builds the identical four matrices directly from the given tags, without re-running the release itself, and the four post-release jobs consume whichever of it or the normal release job produced output. Trigger it with `gh workflow run ci.yml --field backfill_tags="pkg-a@1.2.3,pkg-b@4.5.6"`.

Expand Down
2 changes: 1 addition & 1 deletion packages/document-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ npm i -g doculi

Both names install the exact same package and the exact same binary — `package.json`'s `bin` field declares both `document-cli` and `doculi` pointing at the one built entry point unconditionally, so there is no "real" name and an alias; pick whichever you find easier to type. Unlike a sibling's second _npm package name_ (`documents.js`'s own `js.documents` — see that package's README, and note the older per-repo pipeline's GitHub Packages republish this pattern used to mirror is no longer running, per [ExaDev/documents.js#732](https://github.com/ExaDev/documents.js/issues/732)), this is one package with two `bin` entries: a second name for the same build, not a second build, and unaffected by that gap.

Every release also attaches a Node [single-executable application](https://nodejs.org/api/single-executable-applications.html) build for Linux, macOS, and Windows to that release's own GitHub Release assets — a standalone binary with the entire package and its dependencies embedded, needing no Node.js install or `npm i` at all. It covers every subcommand except the TUI, which needs a real terminal session rather than a spawned subprocess and stays npm-only; running `tui` against the binary prints a message pointing back at the npm-installed package instead of attempting to launch it. Download the asset matching your platform from the package's tag on the [Releases page](https://github.com/ExaDev/documents.js/releases) and run it directly (`chmod +x` on Linux/macOS first).
Every release also attaches a Node [single-executable application](https://nodejs.org/api/single-executable-applications.html) build for Linux, Windows, and macOS (both Apple Silicon and Intel) to that release's own GitHub Release assets — a standalone binary with the entire package and its dependencies embedded, needing no Node.js install or `npm i` at all. It covers every subcommand except the TUI, which needs a real terminal session rather than a spawned subprocess and stays npm-only; running `tui` against the binary prints a message pointing back at the npm-installed package instead of attempting to launch it. Download the asset matching your platform from the package's tag on the [Releases page](https://github.com/ExaDev/documents.js/releases) and run it directly (`chmod +x` on Linux/macOS first).

## Usage

Expand Down
2 changes: 1 addition & 1 deletion packages/document-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ Then add the server URL (e.g., `https://your-host:3000/mcp`) as a connector in C

### Standalone binary

Every release also attaches a Node [single-executable application](https://nodejs.org/api/single-executable-applications.html) build for Linux, macOS, and Windows to that release's own GitHub Release assets — the entire server and its dependencies embedded in one file, needing no Node.js install or `npx` at all. It supports both `stdio` and `--transport http` exactly as above; point an MCP client's `command` at the downloaded binary directly instead of `npx`/`node`. Download the asset matching your platform from the package's tag on the [Releases page](https://github.com/ExaDev/documents.js/releases) and run it directly (`chmod +x` on Linux/macOS first).
Every release also attaches a Node [single-executable application](https://nodejs.org/api/single-executable-applications.html) build for Linux, Windows, and macOS (both Apple Silicon and Intel) to that release's own GitHub Release assets — the entire server and its dependencies embedded in one file, needing no Node.js install or `npx` at all. It supports both `stdio` and `--transport http` exactly as above; point an MCP client's `command` at the downloaded binary directly instead of `npx`/`node`. Download the asset matching your platform from the package's tag on the [Releases page](https://github.com/ExaDev/documents.js/releases) and run it directly (`chmod +x` on Linux/macOS first).

### Development

Expand Down
2 changes: 1 addition & 1 deletion packages/document-rest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ This binds a plain `node:http` listener to `127.0.0.1` (loopback only) on the gi

### Standalone binary

Every release also attaches a Node [single-executable application](https://nodejs.org/api/single-executable-applications.html) build for Linux, macOS, and Windows to that release's own GitHub Release assets — the entire server and its dependencies embedded in one file, needing no Node.js install or `npx` at all. For the "a caller with no Node runtime of its own" case this package exists for in the first place, this removes the last Node dependency too: download the asset matching your platform from the package's tag on the [Releases page](https://github.com/ExaDev/documents.js/releases), run it directly (`chmod +x` on Linux/macOS first), and it takes the identical `--port` flag.
Every release also attaches a Node [single-executable application](https://nodejs.org/api/single-executable-applications.html) build for Linux, Windows, and macOS (both Apple Silicon and Intel) to that release's own GitHub Release assets — the entire server and its dependencies embedded in one file, needing no Node.js install or `npx` at all. For the "a caller with no Node runtime of its own" case this package exists for in the first place, this removes the last Node dependency too: download the asset matching your platform from the package's tag on the [Releases page](https://github.com/ExaDev/documents.js/releases), run it directly (`chmod +x` on Linux/macOS first), and it takes the identical `--port` flag.

## API

Expand Down