diff --git a/.github/scripts/build-sea-binary.test.ts b/.github/scripts/build-sea-binary.test.ts index 5fb3e9aaa..26fc29ab6 100644 --- a/.github/scripts/build-sea-binary.test.ts +++ b/.github/scripts/build-sea-binary.test.ts @@ -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", ); }); }); diff --git a/.github/scripts/build-sea-binary.ts b/.github/scripts/build-sea-binary.ts index c28c9ca88..29efc1187 100644 --- a/.github/scripts/build-sea-binary.ts +++ b/.github/scripts/build-sea-binary.ts @@ -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; @@ -25,12 +26,13 @@ const PLATFORM_LABELS: Record = { 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; } @@ -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"); @@ -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; @@ -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); } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a16a1d01c..d64dfa351 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/README.md b/README.md index 06290bd2d..6d30e65f3 100644 --- a/README.md +++ b/README.md @@ -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/` 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"`. diff --git a/packages/document-cli/README.md b/packages/document-cli/README.md index bd88761b2..8d1b926cc 100644 --- a/packages/document-cli/README.md +++ b/packages/document-cli/README.md @@ -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 diff --git a/packages/document-mcp/README.md b/packages/document-mcp/README.md index c67f1cf94..7ce1026b0 100644 --- a/packages/document-mcp/README.md +++ b/packages/document-mcp/README.md @@ -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 diff --git a/packages/document-rest/README.md b/packages/document-rest/README.md index 0ecea3a90..ae4afd815 100644 --- a/packages/document-rest/README.md +++ b/packages/document-rest/README.md @@ -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