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
134 changes: 132 additions & 2 deletions .github/workflows/ci.yml

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,15 +173,17 @@ Release configuration is **only** at the root. The orchestrator sets `tagFormat`

**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, Windows, and macOS, each on both its standard architecture and its less common one (x64 and arm64 for Linux and Windows; Apple Silicon and Intel for macOS), 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-linux-x64`, and so on), since three of the six build legs share an OS with another leg and would otherwise collide on upload. Every non-standard-architecture leg (`macos-26-intel`, `ubuntu-24.04-arm`, `windows-11-arm`) is still a standard GitHub-hosted runner, not a paid "larger runner". 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"`.
**Container images:** a fifth and sixth post-release job pair build a multi-arch (`linux/amd64` + `linux/arm64`) container image for `document-mcp` and `document-rest` only — the two packages that run as long-lived servers rather than a one-shot CLI/TUI (`document-cli` is deliberately excluded) — and publish it to GitHub Container Registry as `ghcr.io/exadev/document-mcp` and `ghcr.io/exadev/documents.js` respectively (the latter matching the package family's own project name rather than its npm package name `document-rest`). Each image wraps the identical Linux SEA binary the fourth matrix already built and uploaded as a release asset — downloaded via `gh release download`, never rebuilt a second time — on a [distroless](https://github.com/GoogleContainerTools/distroless) `cc-debian12` base, since the SEA binaries are glibc-linked, not musl, and cannot run on an Alpine or `FROM scratch` base; a small multi-stage build step extracts the one library (`libatomic.so.1`) the SEA binary needs that `cc-debian12` doesn't ship on its own. Each architecture builds natively on its own runner (`ubuntu-latest` for amd64, `ubuntu-24.04-arm` for arm64) rather than cross-compiling under QEMU: `build-container-images` builds and pushes each architecture under an arch-suffixed tag (`:1.2.0-amd64`, `:1.2.0-arm64`, left in the registry rather than deleted), and `publish-container-manifests` merges the two into one multi-arch manifest under the real version tag via `docker buildx imagetools create`, plus a floating `latest` tag for a genuine new release (never for a backfill run, so a historical catch-up can never regress `latest` to an older version). A brand-new GHCR package defaults to private visibility on creation regardless of this repository's own visibility — a user with org package admin needs to flip it to public once per image after its first publish (github.com → Organization ExaDev → Packages → `documents.js`/`document-mcp` → Package settings → Change visibility). Like the fourth matrix, nothing depends on this pair and a failure here 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 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 matrices directly from the given tags, without re-running the release itself, and the 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"`.

Every alias name's trusted publisher is registered against this repository and workflow (trusted publishing is registered per package name, so each alias needed its own one-time registration; all eleven landed 2026-09-10 and the backfill run [ExaDev/documents.js#34449796133](https://github.com/ExaDev/documents.js/actions/runs/34449796133) republished every alias at its package's then-current version — registry-confirmed). Until an alias is registered, its publish leg fails with the exact one-time registration instructions in its annotation — a deliberate signal rather than a graceful skip, because a silent skip let every alias stay stranded from the monorepo migration until a manual audit found them; the failure still cannot block the release or the Pages deploy (nothing depends on the post-release jobs), it only makes the outstanding registration visible the moment a release runs. The legacy GitHub Packages mirror keeps its graceful skip (a legacy mirror package's Actions-access list still names only its archived standalone repository — grant this repository Write on the package's settings page once, and the next release mirrors automatically); the registration is: sign in to [npmjs.com](https://www.npmjs.com) as a user with access to the package's settings → **Packages** → the alias (e.g. `document-bytes`) → **Settings** → **Trusted publisher** → select **GitHub Actions**, then enter Organization `ExaDev`, Repository `documents.js`, Workflow filename `ci.yml` (filename only — it must exist in `.github/workflows/`), leave the environment empty, and allow the `npm publish` action ([npm's trusted-publishers documentation](https://docs.npmjs.com/trusted-publishers) has the canonical form). The first release after that publishes under the alias automatically. The formerly stranded aliases' individual threads are [ExaDev/documents.js#727](https://github.com/ExaDev/documents.js/issues/727), [#728](https://github.com/ExaDev/documents.js/issues/728), [#729](https://github.com/ExaDev/documents.js/issues/729), [#730](https://github.com/ExaDev/documents.js/issues/730), [#731](https://github.com/ExaDev/documents.js/issues/731), and [#770](https://github.com/ExaDev/documents.js/issues/770), each carrying its registration evidence.

`commitlint.config.ts` derives its allowed commit types from `release-workspace.config.ts`'s own `releaseRules`, preserving the invariant each package's own config was built around: a conventional-commit type cannot trigger a release without also being accepted by commit-message validation, or the reverse.

## CI

`.github/workflows/ci.yml` holds one job per task — Commitlint, Lint, Typecheck, Test, Test (workerd), Smoke test, E2E test — each running that task once across the workspace through turbo, alongside Knip, Dependency versions, Audit, and `npm registration` (see Releases above for what that last one guards against), followed by Release, its four post-release republish/attestation/SEA-binary matrices (see Releases above), and the web UI's Pages deploy on `main`. Mutation testing runs in its own `.github/workflows/mutation.yml` (sharded, incrementally cached, serialised through one concurrency queue so it can never starve these jobs of runners), reporting one aggregate "Mutation testing result" check the repository ruleset can require once every package's run is green behind its threshold. On a pull request every turbo task runs with `--affected`, restricting work to the packages the branch changed and their dependents; on `main` the full workspace runs, so the caches later runs restore from are complete and the release gate covers everything. Each job restores turbo's cache keyed by task, so an unchanged package costs a cache replay rather than a rebuild. The Typecheck job additionally runs `attw --pack` across every published package after building it, checking that each package's declared types resolve under every module resolution mode — the web UI is excluded, since it publishes nothing and exposes no types.
`.github/workflows/ci.yml` holds one job per task — Commitlint, Lint, Typecheck, Test, Test (workerd), Smoke test, E2E test — each running that task once across the workspace through turbo, alongside Knip, Dependency versions, Audit, and `npm registration` (see Releases above for what that last one guards against), followed by Release, its post-release republish/attestation/SEA-binary/container-image matrices (see Releases above), and the web UI's Pages deploy on `main`. Mutation testing runs in its own `.github/workflows/mutation.yml` (sharded, incrementally cached, serialised through one concurrency queue so it can never starve these jobs of runners), reporting one aggregate "Mutation testing result" check the repository ruleset can require once every package's run is green behind its threshold. On a pull request every turbo task runs with `--affected`, restricting work to the packages the branch changed and their dependents; on `main` the full workspace runs, so the caches later runs restore from are complete and the release gate covers everything. Each job restores turbo's cache keyed by task, so an unchanged package costs a cache replay rather than a rebuild. The Typecheck job additionally runs `attw --pack` across every published package after building it, checking that each package's declared types resolve under every module resolution mode — the web UI is excluded, since it publishes nothing and exposes no types.

Dependabot covers the root manifest and every package's, batching minor and patch updates into one pull request and leaving majors individual; `.github/workflows/dependabot-auto-merge.yml` auto-merges the former once CI is green. The cross-repository `sibling-released` dispatch the separate repositories used to propagate version bumps between themselves is gone: the orchestrator does that inside a single run now, in dependency order, without a pull request per bump.

Expand Down
25 changes: 25 additions & 0 deletions packages/document-mcp/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Only used to obtain libatomic.so.1: cc-debian12 below doesn't ship it, but the SEA binary's own Node runtime dynamically links against it (confirmed by direct verification against document-rest's identical Dockerfile -- the binary otherwise fails at startup with "libatomic.so.1: cannot open shared object file"). Nothing else from this stage is ever copied forward -- no pnpm, no Node, no apt cache.
FROM debian:bookworm-slim AS libatomic
RUN apt-get update && apt-get install --no-install-recommends -y libatomic1 \
&& rm -rf /var/lib/apt/lists/* \
&& cp -P /usr/lib/*/libatomic.so.1* /usr/lib/

# Distroless, not a general-purpose base like debian:bookworm-slim: this image never needs a shell, package manager, or any other OS tooling at runtime -- only the dynamic linker and libraries the SEA binary actually links against. cc-debian12 already carries glibc, libgcc, and libstdc++ (Node's own official distribution ships a separate -musl build alongside the default linux-x64/linux-arm64 tarball, confirming the default -- and therefore this binary -- is glibc-linked, and V8 is C++, requiring libstdc++); libatomic above is the one runtime dependency it's missing. The :nonroot tag runs as a built-in non-root user (UID 65532) with no groupadd/useradd step needed.
FROM gcr.io/distroless/cc-debian12:nonroot

COPY --from=libatomic /usr/lib/libatomic.so.1* /usr/lib/
# cc-debian12's own glibc searches its own arch-specific multiarch directory by default, not bare /usr/lib -- rather than hardcode that directory's name (aarch64-linux-gnu vs x86_64-linux-gnu, yet another arch-naming split like Docker's amd64/arm64 vs Node's x64/arm64 elsewhere in this project's own CI), LD_LIBRARY_PATH makes the linker check /usr/lib too, which every architecture's build of this image copies libatomic.so.1 into identically.
ENV LD_LIBRARY_PATH=/usr/lib

# The Linux SEA binary this exact CI leg already downloaded from the release (see build-container-images in .github/workflows/ci.yml), not built here -- this image runs no pnpm install/build of its own. Already executable: distroless has no shell to chmod it with, so the CI job sets the execute bit on the runner's own filesystem before `docker build` runs, and COPY preserves that permission bit.
COPY dist-sea/document-mcp /usr/local/bin/document-mcp

EXPOSE 3000

LABEL org.opencontainers.image.source="https://github.com/ExaDev/documents.js"
LABEL org.opencontainers.image.description="document-mcp: an MCP server over documents.js's document-conversion, .odb, metadata, and font tooling (--transport http only in this image)"
LABEL org.opencontainers.image.licenses="MIT"

# --transport http is fixed: stdio mode is exec'd directly by an MCP client as a subprocess, which a container has no role in. --host 0.0.0.0 for the identical reason as document-rest's own Dockerfile -- the binary defaults to loopback-only, unreachable from outside a container regardless of `-p`. --port stays a CMD default so `docker run -p 3000:3000 ... --port 3000` still works, and any other --port a caller passes as trailing docker run args overrides it.
ENTRYPOINT ["/usr/local/bin/document-mcp", "--transport", "http", "--host", "0.0.0.0"]
CMD ["--port", "3000"]
10 changes: 10 additions & 0 deletions packages/document-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,16 @@ Then add the server URL (e.g., `https://your-host:3000/mcp`) as a connector in C

Every release also attaches a Node [single-executable application](https://nodejs.org/api/single-executable-applications.html) build for Linux (x64 and arm64), Windows (x64 and arm64), and macOS (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).

### Container image

Every release also publishes a multi-arch (`linux/amd64` + `linux/arm64`) container image to GitHub Container Registry, wrapping the identical standalone `--transport http` binary above on a minimal [distroless](https://github.com/GoogleContainerTools/distroless) base rather than a Node install:

```sh
docker run -p 3000:3000 ghcr.io/exadev/document-mcp:VERSION --port 3000
```

The image always runs in `--transport http` mode — `stdio` mode is exec'd directly by an MCP client as a subprocess, which a container has no role in — and binds to `0.0.0.0` inside the container regardless of `--port`, so `-p <host>:<container>` reaches `/mcp` directly. Replace `VERSION` with the package's own exact release version; `latest` also tracks the newest release, matching `document-rest`'s identical image. See [Remote transport](#remote-transport-http) above for this listener's own lack of authentication.

### Development

Requires Node.js `>=20` and pnpm `11.6.0` (pinned via `packageManager` in `package.json`).
Expand Down
26 changes: 26 additions & 0 deletions packages/document-mcp/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,30 @@ describe("main", () => {
process.argv = ["node", "bin.js", "--transport"];
await expect(main()).rejects.toThrow(/--transport requires a value/);
});

it("binds the given --host instead of the loopback default", async () => {
process.argv = [
"node",
"bin.js",
"--transport",
"http",
"--host",
"0.0.0.0",
"--port",
"0",
];
server = await main();
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("document-mcp listening on http://0.0.0.0:"),
);
const address = server?.address();
if (
address === null ||
address === undefined ||
typeof address === "string"
) {
throw new Error("expected a TCP address");
}
expect(address.address).toBe("0.0.0.0");
});
});
5 changes: 3 additions & 2 deletions packages/document-mcp/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,16 @@ export async function main(): Promise<Server | undefined> {
if (transport === "http") {
const portArg = readFlag(args, "port");
const port = portArg === undefined ? DEFAULT_HTTP_PORT : parsePort(portArg);
const httpServer = await serveHttp(port);
const host = readFlag(args, "host") ?? "127.0.0.1";
const httpServer = await serveHttp(port, host);
const address = httpServer.address();
if (address === null || typeof address === "string") {
throw new Error(
"Expected the HTTP server to bind a TCP address, not a pipe or Unix socket",
);
}
console.error(
`document-mcp listening on http://127.0.0.1:${String(address.port)}${MCP_HTTP_PATH}`,
`document-mcp listening on http://${host}:${String(address.port)}${MCP_HTTP_PATH}`,
);
return httpServer;
}
Expand Down
Loading