diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index abcbe99..01fa159 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -47,6 +47,19 @@ jobs: with: tag-name: ${{ needs.release-please.outputs.tag-name }} + # Only stable releases get binaries. A prerelease publishes to npm without + # cutting a GitHub release, so there would be nothing to attach them to. The + # build itself lives in `sea.yml`, which pull requests run too. + publish-binaries: + name: Attach binaries + needs: release-please + if: needs.release-please.outputs.release-created == 'true' + permissions: + contents: write + uses: ./.github/workflows/sea.yml + with: + tag-name: ${{ needs.release-please.outputs.tag-name }} + publish-prerelease: name: Publish prerelease # Skip the version-bump merges release-please pushes onto main — those are diff --git a/.github/workflows/sea.yml b/.github/workflows/sea.yml new file mode 100644 index 0000000..bd519ef --- /dev/null +++ b/.github/workflows/sea.yml @@ -0,0 +1,105 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + +# The single executables, built one way for two callers: a pull request that +# touches the build proves every platform still produces a working binary, and +# `release-please.yml` calls this with a tag to attach them to the release. One +# file, so the check and the release cannot drift apart. + +name: Single executable + +on: + # Not a required check, so a path filter is safe here — unlike `CI / Gate`, + # a skipped run blocks nothing. + pull_request: + branches: + - main + - dev + paths: + - src/** + - scripts/sea-build.ts + - vite.config.ts + - vite.sea.config.ts + - package.json + - pnpm-lock.yaml + - .nvmrc + - .github/workflows/sea.yml + workflow_call: + inputs: + tag-name: + description: Release tag to build from and attach the archives to. + required: true + type: string + +permissions: + contents: read + +jobs: + # One job per platform, and it has to be that way: a single executable is the + # target platform's own node binary with a blob injected into it, so there is + # nothing to cross-compile from. `macos-15-intel` is the Intel label that + # replaced the retired `macos-13`. + build: + name: ${{ matrix.target }} + permissions: + # `gh release upload` writes into the release; a pull request never + # reaches that step, and a caller can only grant what it holds. + contents: write + strategy: + # Every platform reports, rather than the first failure hiding the rest. + fail-fast: false + matrix: + include: + - target: darwin-arm64 + runner: macos-latest + - target: darwin-x64 + runner: macos-15-intel + - target: linux-x64 + runner: ubuntu-latest + - target: linux-arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v7 + with: + # On release, the tag rather than the branch tip: the artifact has to + # carry the version that was released, and `--version` is inlined at + # build time. Empty on a pull request, which checks out its own head. + ref: ${{ inputs.tag-name }} + + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 + + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: pnpm + + - run: pnpm install --frozen-lockfile + + # `build` first: it empties `dist` to write the npm artifact, which would + # take the binary with it the other way round. `sea:build` runs the binary + # before packaging it, so a broken one fails here. + - run: pnpm build + - run: pnpm sea:build + + # What the README tells people to run, run as written: the archive name it + # derives from `uname` must be the one this platform produced, the + # checksum must verify, and the extracted binary must work with no node + # on the PATH. + - name: Install it the way the README does + working-directory: dist/release + run: | + NAME="gitignore-sync_$(uname -s | tr A-Z a-z)_$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tar.gz" + shasum -a 256 -c "${NAME}.sha256" + mkdir unpacked + tar -xzf "${NAME}" -C unpacked + env -i PATH=/usr/bin:/bin ./unpacked/gitignore-sync info + env -i PATH=/usr/bin:/bin ./unpacked/gitignore-sync list > /dev/null + + - name: Attach the archive and its checksum + if: inputs.tag-name + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release upload "${{ inputs.tag-name }}" + dist/release/*.tar.gz dist/release/*.tar.gz.sha256 + --clobber diff --git a/AGENTS.md b/AGENTS.md index 835ebbd..d00a740 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ Retyping a change is exactly how the two drift; one reflowed line or reworded cl | `pnpm check` | `lint` + `format` + `typecheck` + `check:policy` + `test` + `build` — the CI gate | | `pnpm check:policy` | Proves the two agent policy files ban the same commands | | `pnpm templates:lock`| Re-writes `tests/templates.lock.json` after adding a template version | +| `pnpm sea:build` | After `pnpm build`: a single executable for this platform → `dist/release/` | | `pnpm check:fix` | Auto-fix lint + format | | `pnpm skills:update`| Update project-scoped agent skills via the skills.sh CLI | | `pnpm taze` | Interactive dependency upgrade check | @@ -109,7 +110,7 @@ templates → Record // data, versioned with the binar `parse`, `render` and `reconcile` touch **no filesystem**. That puts the whole hard part — marker parsing, free zone, rescuing stray lines, dedup — behind a string-in/string-out seam. **Test that seam with fixture pairs under `tests/fixtures/` (`.in` / `.out`), never with temporary directories.** Adding a pair is the cheapest way to pin a behaviour; `tests/fixtures.test.ts` picks it up automatically and asserts idempotency on top. -`src/io.ts` and `src/detect.ts` are the only modules that read or write files. The commands (`init`, `edit`, `add`, `remove`, `sync`, `check`, `list`, `info`, `audit`) stay thin and compose the pure parts. `info` is the odd one out: it describes the *installation* rather than the file, so a `linked / dev build` is never mistaken for the published release. `check` is the CI gate: dry run, reports drift and duplicates, exits non-zero on deviation. +`src/io.ts` and `src/detect.ts` are the only modules that read or write files. The commands (`init`, `edit`, `add`, `remove`, `sync`, `check`, `list`, `info`, `audit`) stay thin and compose the pure parts. `info` is the odd one out: it describes the *installation* rather than the file, so a `linked / dev build` is never mistaken for the published release, and a downloaded binary reports itself as a `single executable`. `check` is the CI gate: dry run, reports drift and duplicates, exits non-zero on deviation. `edit`, `add` and `remove` are thin verbs over one header edit — they rewrite the `# stacks:` line and re-render, nothing more. They exist because discovering "open the file, change one line, run sync" from an error message is not a workflow. @@ -232,12 +233,14 @@ Four template rules that are not style: - **`agents` carries working files, not configuration.** Across the estate **126 files** under `.claude/`, `.codex/` and `.opencode/` are committed — `settings.json`, skills, `default.rules`, agent definitions. Only `settings.local.json` and `worktrees/` describe one machine, and only those two are in `agents@v1`. `agents@v2` adds `.codex/config.toml`: Laravel Boost writes it with absolute paths of the machine it ran on, and both repos that commit one (`app`, `gildstone`) committed exactly that — the file, not the directory, so `.codex/rules/` stays committable. `.claude/worktrees/` is included because it accumulates for real and the one repo that ignores it today does so in `.git/info/exclude` — private knowledge that belongs where the team can see it. - **`node_modules` belongs to the `node` stack, not to `core`** — a pure Go repo then correctly does not get it. That it lands almost everywhere in practice is a consequence of the estate, not a reason to blur the model. -### Two surfaces, one tool +### Three surfaces, one tool `action.yml` in this repo is a **composite** action wrapping `npx @kirchdev/gitignore-sync check`, because the exit code is the whole result — it adds a job summary and a `status` output, nothing else. `kirchDev/coverage-report` bundles its CLI into `dist/index.js` instead, and should: that action writes sticky comments and check runs of its own. Copying its shape here would ship the same code twice and give two things to keep in step. Most repos need neither. `kirchDev/workflows`' `_ci-check.yml` derives its task list from `package.json`, so adding `gitignore-sync check` to a repo's `check` script is picked up with no workflow change at all. +The third surface is for machines without Node: every stable release carries a **Node single executable** per platform — `darwin` and `linux`, `arm64` and `x64` — built by `sea.yml` — on every pull request that touches the build, for all four platforms, and called by `release-please.yml` to attach them to the release, so the check and the release cannot drift ([ADR-0010](docs/99.adr/0010-ship-standalone-binaries-as-node-single-executables.md)). It is the same bundle with node inside it, not a second runtime: `bun build --compile` would have been one command, but it would ship a binary running on a runtime the test suite never runs on. Three things in it are not cosmetic: the entry is CommonJS (`vite.sea.config.ts`), because the embedder runs the main script as CJS; it must stay one file, because the embedder's `require` loads builtins only; and darwin needs `--macho-segment-name NODE_SEA` plus an ad-hoc re-sign. `scripts/sea-build.ts` checks all of it by running the binary before packaging. Archive names carry no version, so `releases/latest/download/…` always resolves. Windows gets no binary; npm is the path there. + ## Architecture / conventions - **Node 24, pnpm 11.** Pinned via `.nvmrc`, `engines`, and `packageManager`. Not Bun — the sibling `forgemap` declares `engines.node >= 24` and this repo follows it. `pnpm-workspace.yaml` enforces `minimumReleaseAge=4320` (3-day cooldown), isolated node-linker. Package-manager enforcement carries no key on purpose: pnpm 11 replaced `packageManagerStrict`/`packageManagerStrictVersion` with `pmOnFail`, whose default `download` already errors on a foreign package manager and fetches the pinned pnpm version — every other value only weakens it, so leave it unset. @@ -246,11 +249,11 @@ Most repos need neither. `kirchDev/workflows`' `_ci-check.yml` derives its task - **Published as `@kirchdev/gitignore-sync`**, scoped like `@kirchdev/coverage-report`. The `bin` entries stay unscoped (`gitignore-sync`, `gis`) and so does release-please's `package-name` — the scope belongs to the registry, not to what a person types or what a tag is called. - **`pnpm link --global .` edits `pnpm-workspace.yaml`**, adding an `overrides:` block pointing at the local checkout. That file is committed, so the block must never be: it would make every clone try to link a package that exists on one machine. `pnpm unlink` reverts it; `pnpm link` is only for trying the CLI locally. - **The CLI's `--version` is injected at build time** by vite's `define` from `package.json`'s `version`, which release-please owns. Never hand-copy a version literal. -- **Runtime deps stay to the sibling handful**: `citty`, `consola`, `pathe`. `c12` and `defu` are deliberately absent — the config lives in the `.gitignore`, so there is no config file to load or merge. +- **Runtime deps stay to the sibling handful**: `citty`, `consola`, `pathe`. `c12` and `defu` are deliberately absent — the config lives in the `.gitignore`, so there is no config file to load or merge. `postject`, which injects the single executable's blob, is a dev dependency only: the binary carries nothing of it. - **Husky hooks** (`.husky/pre-commit`, `.husky/commit-msg`) run `lint-staged` and `commitlint`. `lint-staged.config.ts` excludes `README.md`, `CLAUDE.md`, and `AGENTS.md` (free-form prose) and `pnpm-lock.yaml`. - **Conventional Commits enforced** via `@commitlint/config-conventional`. Don't `--no-verify` unless explicitly asked. - **release-please** with `release-type: node`, `include-v-in-tag: true`. Files: `release-please-config.json`, `.release-please-manifest.json`, `.github/workflows/release-please.yml`. -- **Workflows** use `actions/checkout@v7`, `actions/setup-node@v7`, `pnpm/action-setup@v6`, `github/codeql-action/{init,analyze}@v4`. Keep these pinned to major versions; Dependabot bumps them monthly. +- **Workflows** use `actions/checkout@v7`, `actions/setup-node@v7`, `github/codeql-action/{init,analyze}@v4` — GitHub's own actions, pinned to major versions. A third-party action is pinned to a commit SHA with its version as a comment (`pnpm/action-setup@ # v6.0.10`), exactly like the `kirchDev/workflows` refs: CodeQL flags a movable tag on one, since its owner can repoint it. Dependabot bumps both forms monthly. - **Bitwarden secret ids in workflows are vault references, not credentials.** The actual secret is `BWS_ACCESS_TOKEN`, a GitHub secret that never appears in a workflow file. Each owner authenticates as a different machine account and reads only its own `-ci` mirror, so this repo — a `kirchDev` repo — must carry the `kirchDev` id (`df8b447a-ffd5-4009-9cc3-b49b014f6978`), not `TitusKirch`'s. A mismatched id resolves for nobody, silently, until someone dispatches the workflow. ## AI & skills diff --git a/CLAUDE.md b/CLAUDE.md index 835ebbd..d00a740 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ Retyping a change is exactly how the two drift; one reflowed line or reworded cl | `pnpm check` | `lint` + `format` + `typecheck` + `check:policy` + `test` + `build` — the CI gate | | `pnpm check:policy` | Proves the two agent policy files ban the same commands | | `pnpm templates:lock`| Re-writes `tests/templates.lock.json` after adding a template version | +| `pnpm sea:build` | After `pnpm build`: a single executable for this platform → `dist/release/` | | `pnpm check:fix` | Auto-fix lint + format | | `pnpm skills:update`| Update project-scoped agent skills via the skills.sh CLI | | `pnpm taze` | Interactive dependency upgrade check | @@ -109,7 +110,7 @@ templates → Record // data, versioned with the binar `parse`, `render` and `reconcile` touch **no filesystem**. That puts the whole hard part — marker parsing, free zone, rescuing stray lines, dedup — behind a string-in/string-out seam. **Test that seam with fixture pairs under `tests/fixtures/` (`.in` / `.out`), never with temporary directories.** Adding a pair is the cheapest way to pin a behaviour; `tests/fixtures.test.ts` picks it up automatically and asserts idempotency on top. -`src/io.ts` and `src/detect.ts` are the only modules that read or write files. The commands (`init`, `edit`, `add`, `remove`, `sync`, `check`, `list`, `info`, `audit`) stay thin and compose the pure parts. `info` is the odd one out: it describes the *installation* rather than the file, so a `linked / dev build` is never mistaken for the published release. `check` is the CI gate: dry run, reports drift and duplicates, exits non-zero on deviation. +`src/io.ts` and `src/detect.ts` are the only modules that read or write files. The commands (`init`, `edit`, `add`, `remove`, `sync`, `check`, `list`, `info`, `audit`) stay thin and compose the pure parts. `info` is the odd one out: it describes the *installation* rather than the file, so a `linked / dev build` is never mistaken for the published release, and a downloaded binary reports itself as a `single executable`. `check` is the CI gate: dry run, reports drift and duplicates, exits non-zero on deviation. `edit`, `add` and `remove` are thin verbs over one header edit — they rewrite the `# stacks:` line and re-render, nothing more. They exist because discovering "open the file, change one line, run sync" from an error message is not a workflow. @@ -232,12 +233,14 @@ Four template rules that are not style: - **`agents` carries working files, not configuration.** Across the estate **126 files** under `.claude/`, `.codex/` and `.opencode/` are committed — `settings.json`, skills, `default.rules`, agent definitions. Only `settings.local.json` and `worktrees/` describe one machine, and only those two are in `agents@v1`. `agents@v2` adds `.codex/config.toml`: Laravel Boost writes it with absolute paths of the machine it ran on, and both repos that commit one (`app`, `gildstone`) committed exactly that — the file, not the directory, so `.codex/rules/` stays committable. `.claude/worktrees/` is included because it accumulates for real and the one repo that ignores it today does so in `.git/info/exclude` — private knowledge that belongs where the team can see it. - **`node_modules` belongs to the `node` stack, not to `core`** — a pure Go repo then correctly does not get it. That it lands almost everywhere in practice is a consequence of the estate, not a reason to blur the model. -### Two surfaces, one tool +### Three surfaces, one tool `action.yml` in this repo is a **composite** action wrapping `npx @kirchdev/gitignore-sync check`, because the exit code is the whole result — it adds a job summary and a `status` output, nothing else. `kirchDev/coverage-report` bundles its CLI into `dist/index.js` instead, and should: that action writes sticky comments and check runs of its own. Copying its shape here would ship the same code twice and give two things to keep in step. Most repos need neither. `kirchDev/workflows`' `_ci-check.yml` derives its task list from `package.json`, so adding `gitignore-sync check` to a repo's `check` script is picked up with no workflow change at all. +The third surface is for machines without Node: every stable release carries a **Node single executable** per platform — `darwin` and `linux`, `arm64` and `x64` — built by `sea.yml` — on every pull request that touches the build, for all four platforms, and called by `release-please.yml` to attach them to the release, so the check and the release cannot drift ([ADR-0010](docs/99.adr/0010-ship-standalone-binaries-as-node-single-executables.md)). It is the same bundle with node inside it, not a second runtime: `bun build --compile` would have been one command, but it would ship a binary running on a runtime the test suite never runs on. Three things in it are not cosmetic: the entry is CommonJS (`vite.sea.config.ts`), because the embedder runs the main script as CJS; it must stay one file, because the embedder's `require` loads builtins only; and darwin needs `--macho-segment-name NODE_SEA` plus an ad-hoc re-sign. `scripts/sea-build.ts` checks all of it by running the binary before packaging. Archive names carry no version, so `releases/latest/download/…` always resolves. Windows gets no binary; npm is the path there. + ## Architecture / conventions - **Node 24, pnpm 11.** Pinned via `.nvmrc`, `engines`, and `packageManager`. Not Bun — the sibling `forgemap` declares `engines.node >= 24` and this repo follows it. `pnpm-workspace.yaml` enforces `minimumReleaseAge=4320` (3-day cooldown), isolated node-linker. Package-manager enforcement carries no key on purpose: pnpm 11 replaced `packageManagerStrict`/`packageManagerStrictVersion` with `pmOnFail`, whose default `download` already errors on a foreign package manager and fetches the pinned pnpm version — every other value only weakens it, so leave it unset. @@ -246,11 +249,11 @@ Most repos need neither. `kirchDev/workflows`' `_ci-check.yml` derives its task - **Published as `@kirchdev/gitignore-sync`**, scoped like `@kirchdev/coverage-report`. The `bin` entries stay unscoped (`gitignore-sync`, `gis`) and so does release-please's `package-name` — the scope belongs to the registry, not to what a person types or what a tag is called. - **`pnpm link --global .` edits `pnpm-workspace.yaml`**, adding an `overrides:` block pointing at the local checkout. That file is committed, so the block must never be: it would make every clone try to link a package that exists on one machine. `pnpm unlink` reverts it; `pnpm link` is only for trying the CLI locally. - **The CLI's `--version` is injected at build time** by vite's `define` from `package.json`'s `version`, which release-please owns. Never hand-copy a version literal. -- **Runtime deps stay to the sibling handful**: `citty`, `consola`, `pathe`. `c12` and `defu` are deliberately absent — the config lives in the `.gitignore`, so there is no config file to load or merge. +- **Runtime deps stay to the sibling handful**: `citty`, `consola`, `pathe`. `c12` and `defu` are deliberately absent — the config lives in the `.gitignore`, so there is no config file to load or merge. `postject`, which injects the single executable's blob, is a dev dependency only: the binary carries nothing of it. - **Husky hooks** (`.husky/pre-commit`, `.husky/commit-msg`) run `lint-staged` and `commitlint`. `lint-staged.config.ts` excludes `README.md`, `CLAUDE.md`, and `AGENTS.md` (free-form prose) and `pnpm-lock.yaml`. - **Conventional Commits enforced** via `@commitlint/config-conventional`. Don't `--no-verify` unless explicitly asked. - **release-please** with `release-type: node`, `include-v-in-tag: true`. Files: `release-please-config.json`, `.release-please-manifest.json`, `.github/workflows/release-please.yml`. -- **Workflows** use `actions/checkout@v7`, `actions/setup-node@v7`, `pnpm/action-setup@v6`, `github/codeql-action/{init,analyze}@v4`. Keep these pinned to major versions; Dependabot bumps them monthly. +- **Workflows** use `actions/checkout@v7`, `actions/setup-node@v7`, `github/codeql-action/{init,analyze}@v4` — GitHub's own actions, pinned to major versions. A third-party action is pinned to a commit SHA with its version as a comment (`pnpm/action-setup@ # v6.0.10`), exactly like the `kirchDev/workflows` refs: CodeQL flags a movable tag on one, since its owner can repoint it. Dependabot bumps both forms monthly. - **Bitwarden secret ids in workflows are vault references, not credentials.** The actual secret is `BWS_ACCESS_TOKEN`, a GitHub secret that never appears in a workflow file. Each owner authenticates as a different machine account and reads only its own `-ci` mirror, so this repo — a `kirchDev` repo — must carry the `kirchDev` id (`df8b447a-ffd5-4009-9cc3-b49b014f6978`), not `TitusKirch`'s. A mismatched id resolves for nobody, silently, until someone dispatches the workflow. ## AI & skills diff --git a/README.md b/README.md index c412954..f6fa408 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,24 @@ pnpm add -g @kirchdev/gitignore-sync # npm i -g / yarn global add / bun add -g Or run it without installing: `npx @kirchdev/gitignore-sync `. The binary is also available as `gis`. +For a machine with no Node on it, every release also carries a standalone binary with the runtime inside it, for `darwin_arm64`, `darwin_x64`, `linux_x64` and `linux_arm64`. Download it, verify it, and put it on your `PATH`: + +```bash +NAME="gitignore-sync_$(uname -s | tr A-Z a-z)_$(uname -m | sed 's/x86_64/x64/;s/aarch64/arm64/').tar.gz" +curl -fsSLO "https://github.com/kirchDev/gitignore-sync/releases/latest/download/${NAME}" +curl -fsSLO "https://github.com/kirchDev/gitignore-sync/releases/latest/download/${NAME}.sha256" +shasum -a 256 -c "${NAME}.sha256" # or: sha256sum -c +tar -xzf "${NAME}" +mkdir -p ~/.local/bin && mv gitignore-sync ~/.local/bin/ # or any directory on your PATH +gitignore-sync info # reports "single executable" +``` + +> [!NOTE] +> The archive holds one file and installs nothing else, so any directory on your `PATH` works, and `gis` works as a name too (`ln -s ~/.local/bin/gitignore-sync ~/.local/bin/gis`). To update, run the same lines again; to uninstall, delete the file. Swap `latest/download` for `download/vX.Y.Z` to pin a release. Windows has no binary; use the npm package there. + +> [!TIP] +> The macOS binaries are signed ad hoc, not notarised. Downloaded with `curl` as above they run as they are; one downloaded in a browser is quarantined first — clear it with `xattr -d com.apple.quarantine gitignore-sync`. + ## 🚀 Quick start ```bash diff --git a/docs/99.adr/0010-ship-standalone-binaries-as-node-single-executables.md b/docs/99.adr/0010-ship-standalone-binaries-as-node-single-executables.md new file mode 100644 index 0000000..f7e217f --- /dev/null +++ b/docs/99.adr/0010-ship-standalone-binaries-as-node-single-executables.md @@ -0,0 +1,73 @@ +--- +title: Ship standalone binaries as Node single executables +description: Every stable release carries a Node single executable per platform, built from the npm bundle on a runner of that platform, so the CLI runs on a machine without Node on the same runtime the tests use. +status: accepted +date: 2026-10-15 +--- + +# ADR-0010 — Ship standalone binaries as Node single executables + +## Context + +The CLI shipped as an npm package and as a composite GitHub Action, and both +need Node on the machine. A request came in to run it where neither Node nor a +package manager is installed. The bundle was already one file with three +small runtime dependencies, so nothing in the code stood in the way of +packaging it with a runtime. + +Two ways to do that were tried. `bun build --compile` produced a working +binary in one command and could target every platform from one machine. +Node's own single executable applications (SEA) needed more: a preparation +blob, `postject` to inject it into a copy of the node binary, a CommonJS +entry, and a re-sign on macOS. At the time of this decision, SEA on Node 24 +was still behind `--experimental-sea-config`. + +The project had settled on Node rather than Bun as its runtime: the tests, +the npm package and the action all run on Node 24. + +## Decision + +- Every stable release carries a single executable per platform — `darwin` + and `linux`, `arm64` and `x64` — built with Node SEA, not with Bun. +- Each one is built on a runner of its own platform, after the npm build, and + embeds the node that ran the build, which `.nvmrc` pins. One workflow does + it for two callers: a pull request touching the build runs it for every + platform, and the release calls it with the tag to attach the archives. + After packaging, it installs the archive the way the README tells people to + and runs it with no node on the `PATH`. +- The SEA bundle is a separate vite build: CommonJS, everything but node's + builtins inlined, one file. `scripts/sea-build.ts` refuses a bundle that + split, re-signs ad hoc on macOS, and runs `--version` and `list` out of the + binary before packaging it. +- Archives carry no version in their name, so + `releases/latest/download/` always resolves; a SHA-256 checksum file + sits beside each one. +- `info` reports a single executable as its own build kind, so a downloaded + binary is never mistaken for an installed package or an unknown one. +- Windows gets no binary; the npm package covers it. + +## Consequences + +- The CLI runs on a machine without Node, on the same runtime the test suite + exercises, so a binary cannot behave differently from the npm package + because of the runtime underneath it. +- Each binary is about the size of node itself, and the release takes four + extra runner jobs. +- `postject` joins the dev dependencies; nothing of it ships. +- macOS binaries are signed ad hoc only, not notarised, so a browser download + may still need clearing from quarantine. +- Code that reaches for `node_modules` at runtime, or a dynamic import that + splits the bundle, breaks the binary; the build script catches the split, + and the inlined dependencies keep the rest honest. + +## Alternatives considered + +- **`bun build --compile`.** Rejected: one command and cross-compiled, but + the binary would run on a runtime the test suite never runs on, against a + project that had chosen Node for exactly that reason. +- **`deno compile`.** Rejected for the same reason, with a less complete Node + compatibility layer on top. +- **`pkg` or `nexe`.** Rejected: both had stopped being maintained, and Node's + own mechanism had replaced them. +- **No binaries, npm only.** Rejected: it leaves the request unanswered for + machines where installing Node is the thing someone wants to avoid. diff --git a/docs/99.adr/index.md b/docs/99.adr/index.md index 3572985..c82710a 100644 --- a/docs/99.adr/index.md +++ b/docs/99.adr/index.md @@ -22,3 +22,4 @@ to it. | [ADR-0007](/adr/0007-classify-nested-gitignores-before-acting-on-them) | Classify nested .gitignore files before acting on them | accepted | 2026-09-01 | | [ADR-0008](/adr/0008-ship-the-ci-gate-as-a-composite-action) | Ship the CI gate as a composite action | accepted | 2026-09-01 | | [ADR-0009](/adr/0009-accept-stacks-on-the-word-of-the-tool-that-writes-the-files) | Accept stacks on the word of the tool that writes the files | accepted | 2026-10-15 | +| [ADR-0010](/adr/0010-ship-standalone-binaries-as-node-single-executables) | Ship standalone binaries as Node single executables | accepted | 2026-10-15 | diff --git a/package.json b/package.json index 6962615..5c86887 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "scripts": { "build": "vite build", "dev": "vite build --watch", + "sea:bundle": "vite build -c vite.sea.config.ts", + "sea:build": "pnpm sea:bundle && node scripts/sea-build.ts", "link": "pnpm build && pnpm link --global .", "unlink": "pnpm uninstall -g @kirchdev/gitignore-sync && git checkout pnpm-workspace.yaml", "lint": "oxlint . --deny-warnings", @@ -73,6 +75,7 @@ "lint-staged": "^17.6.0", "oxfmt": "0.72.0", "oxlint": "1.87.0", + "postject": "1.0.0-alpha.6", "taze": "^21.3.0", "typescript": "^7.0.2", "vite": "^8.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98ea740..58b8a60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: oxlint: specifier: 1.87.0 version: 1.87.0 + postject: + specifier: 1.0.0-alpha.6 + version: 1.0.0-alpha.6 taze: specifier: ^21.3.0 version: 21.3.0 @@ -910,6 +913,10 @@ packages: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -1229,6 +1236,11 @@ packages: resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} engines: {node: ^10 || ^12 || >=14} + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -1921,6 +1933,8 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + commander@9.5.0: {} + consola@3.4.2: {} conventional-changelog-angular@9.4.0: @@ -2192,6 +2206,10 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.2 + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + quansync@1.0.0: {} require-from-string@2.0.2: {} diff --git a/scripts/sea-build.ts b/scripts/sea-build.ts new file mode 100644 index 0000000..0a88fdc --- /dev/null +++ b/scripts/sea-build.ts @@ -0,0 +1,138 @@ +#!/usr/bin/env node + +/** + * Packages the CLI as a single executable for the platform this runs on. + * + * The mechanism is node's own: `--experimental-sea-config` writes a + * preparation blob, postject injects it into a copy of the node binary, and + * the result is a file with no runtime to install next to it. + * + * Two details are not cosmetic, and cost a SIGSEGV each when missed: + * + * - **`--macho-segment-name NODE_SEA` on darwin.** Without it the injection + * reports success, the binary runs, and it segfaults before reaching the + * entry point. + * - **A CommonJS entry.** Node's embedder runs the main script as CJS, so an + * ESM bundle dies with "Cannot use import statement outside a module". + * `dist/sea/entry.cjs` exists for that alone — see `vite.sea.config.ts`. + * + * The embedded runtime is whatever node runs this script, so a release carries + * the version CI resolved from `.nvmrc` instead of a second pin that could + * drift from it. + * + * There is no cross-compilation to be had: a single executable *is* the target + * platform's node binary with a blob inside it. The release workflow runs this + * once per platform, on a runner of that platform. + */ + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + copyFileSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync +} from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const seaDir = join(root, 'dist', 'sea'); +const outDir = join(root, 'dist', 'release'); +const binName = 'gitignore-sync'; + +/** postject's default, and the string node looks for inside the binary. */ +const FUSE = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2'; + +const run = (file: string, args: string[]): void => { + execFileSync(file, args, { stdio: 'inherit' }); +}; + +const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { + version: string; +}; + +const { platform, arch } = process; +if (platform !== 'darwin' && platform !== 'linux') { + console.error(`No single executable is built for ${platform}.`); + process.exit(1); +} +if (arch !== 'arm64' && arch !== 'x64') { + console.error(`No single executable is built for ${arch}.`); + process.exit(1); +} + +// The embedder can only run one script: anything vite split off the entry would +// be missing at runtime, and only on the command that reaches for it. +const emitted = readdirSync(seaDir).filter( + (name) => !['entry.cjs', 'sea-config.json', 'prep.blob'].includes(name) +); +if (emitted.length > 0) { + console.error(`The SEA bundle is not one file: ${emitted.join(', ')}.`); + process.exit(1); +} + +mkdirSync(outDir, { recursive: true }); + +const config = join(seaDir, 'sea-config.json'); +const blob = join(seaDir, 'prep.blob'); +writeFileSync( + config, + `${JSON.stringify( + { + main: join(seaDir, 'entry.cjs'), + output: blob, + disableExperimentalSEAWarning: true + }, + null, + 2 + )}\n`, + 'utf8' +); +run(process.execPath, ['--experimental-sea-config', config]); + +const binary = join(outDir, binName); +copyFileSync(process.execPath, binary); + +const inject = [binary, 'NODE_SEA_BLOB', blob, '--sentinel-fuse', FUSE]; +if (platform === 'darwin') inject.push('--macho-segment-name', 'NODE_SEA'); +run(join(root, 'node_modules', '.bin', 'postject'), inject); + +// Injecting bytes invalidates the signature the node binary shipped with, and +// macOS refuses to run an arm64 binary whose signature no longer matches. +// Ad-hoc is enough for a tarball: nothing here is notarised or distributed +// through the App Store. +if (platform === 'darwin') { + run('codesign', ['--remove-signature', binary]); + run('codesign', ['--sign', '-', binary]); +} + +// The binary has to answer before it is packaged. `--version` is the check that +// would have caught both mistakes in the comment at the top; `list` goes past +// the argument parser into a command and the bundled templates. +const reported = execFileSync(binary, ['--version'], { + encoding: 'utf8' +}).trim(); +if (reported !== pkg.version) { + console.error(`The binary reports ${reported}, expected ${pkg.version}.`); + process.exit(1); +} +execFileSync(binary, ['list'], { stdio: 'ignore' }); + +// No version in the name: the tag already carries it, and a stable name is what +// lets `releases/latest/download/` always point at the newest binary. +const archive = `${binName}_${platform}_${arch}.tar.gz`; +run('tar', ['-czf', join(outDir, archive), '-C', outDir, binName]); + +const sha = createHash('sha256') + .update(readFileSync(join(outDir, archive))) + .digest('hex'); +writeFileSync( + join(outDir, `${archive}.sha256`), + `${sha} ${archive}\n`, + 'utf8' +); + +console.log(`Wrote ${archive}`); +console.log(`sha256 ${sha}`); diff --git a/src/commands/info.ts b/src/commands/info.ts index 603abe1..69deff5 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { isSea } from 'node:sea'; import { defineCommand } from 'citty'; import { colors } from 'consola/utils'; import { dirname, join, resolve } from 'pathe'; @@ -18,8 +19,11 @@ const PACKAGE_NAME = '@kirchdev/gitignore-sync'; /** What a person types, and what the report is headed with. */ const BIN_NAME = 'gitignore-sync'; -/** `linked` = runs from a git work tree; `release` = an installed package. */ -type BuildKind = 'linked' | 'release' | 'unknown'; +/** + * `linked` = runs from a git work tree; `release` = an installed package; + * `sea` = the single executable, which has no package.json to be found. + */ +type BuildKind = 'linked' | 'release' | 'sea' | 'unknown'; type Info = { version: string; @@ -78,6 +82,18 @@ function findPackageRoot( * neither can be established, report `unknown` rather than guessing. */ function detectBuild(resolved: string | null): Info['build'] { + // Asked first, because the question the rest of this function answers has no + // answer here: a single executable is one file holding the runtime, the + // bundle and the templates, so there is no package.json above it to walk up + // to. Without this branch a downloaded release binary reports `unknown`, + // which reads like a defect rather than an ordinary way to install it. + if (isSea()) { + return { + kind: 'sea', + packageRoot: null, + reason: 'single executable, nothing installed beside it' + }; + } if (!resolved) { return { kind: 'unknown', @@ -154,6 +170,7 @@ function inspectRepository(dir: string): Info['repository'] { const BUILD_LABELS: Record = { linked: 'linked / dev build', release: 'installed release', + sea: 'single executable', unknown: 'unknown' }; diff --git a/tests/info.test.ts b/tests/info.test.ts index 0827e26..3fb33c2 100644 --- a/tests/info.test.ts +++ b/tests/info.test.ts @@ -1,7 +1,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { __test } from '../src/commands/info.ts'; const { detectBuild, findPackageRoot, resolveBinary } = __test; @@ -68,4 +68,22 @@ describe('info', () => { it('reports unknown when nothing can be resolved', () => { expect(detectBuild(null).kind).toBe('unknown'); }); + + // `isSea()` is false under the test runner by construction, so the branch is + // reached by swapping the module rather than by building a binary the size of + // node itself in the suite. The real artifact is checked by `scripts/sea-build.ts`, which + // runs `--version` out of the binary before packaging it. + it('calls a single executable its own build, not an unknown one', async () => { + vi.resetModules(); + vi.doMock('node:sea', () => ({ isSea: () => true })); + try { + const { __test: sea } = await import('../src/commands/info.ts'); + const build = sea.detectBuild('/usr/local/bin/gitignore-sync'); + expect(build.kind).toBe('sea'); + expect(build.packageRoot).toBeNull(); + } finally { + vi.doUnmock('node:sea'); + vi.resetModules(); + } + }); }); diff --git a/vite.config.ts b/vite.config.ts index e9d4879..d752c5d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,13 @@ import { readFileSync } from 'node:fs'; import { builtinModules } from 'node:module'; import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; // From vitest/config, not vite: vitest 4 no longer augments vite's UserConfig // with the `test` key, so vite's own defineConfig rejects it. import { defineConfig } from 'vitest/config'; -const root = dirname(new URL(import.meta.url).pathname); +// fileURLToPath, not `.pathname`: the latter keeps `%20` in a path with spaces. +const root = dirname(fileURLToPath(import.meta.url)); const nodeBuiltins = new Set([ ...builtinModules, diff --git a/vite.sea.config.ts b/vite.sea.config.ts new file mode 100644 index 0000000..d8e2de9 --- /dev/null +++ b/vite.sea.config.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; + +// The entry for the single executable, and nothing else. The npm artifact is +// still `vite.config.ts`'s job; this one exists because a SEA needs two things +// that bundle cannot give it: +// +// - **CommonJS.** Node's embedder runs a single executable's main script as +// CJS. An ESM entry fails at startup with "Cannot use import statement +// outside a module", which is why `dist/bin/gitignore-sync.mjs` cannot be +// injected directly. +// - **No externals but node's builtins.** A single executable carries no +// `node_modules`, so citty, consola and pathe have to be inlined rather +// than resolved at runtime. +// - **One file.** The embedder's `require` loads builtins only, so a chunk +// split off the entry could never be loaded. The bundle has no dynamic +// import to split on, and `scripts/sea-build.ts` refuses to package one +// that does. +// +// The target and the inlined version match the npm build, so `--version` reads +// the same out of either artifact. + +// fileURLToPath, not `.pathname`: the latter keeps `%20` in a path with spaces. +const root = dirname(fileURLToPath(import.meta.url)); + +const nodeBuiltins = new Set([ + ...builtinModules, + ...builtinModules.map((m) => `node:${m}`) +]); + +const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + version: string; +}; + +export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(pkg.version) + }, + build: { + target: 'node24', + outDir: 'dist/sea', + // `vite.config.ts` writes `dist/bin` and empties `dist` to do it. Emptying + // again here would delete the npm artifact, and the release builds both. + emptyOutDir: false, + sourcemap: false, + minify: false, + lib: { + entry: { + entry: resolve(root, 'src/bin/gitignore-sync.ts') + }, + formats: ['cjs'] + }, + rollupOptions: { + external: (id) => nodeBuiltins.has(id), + output: { + entryFileNames: '[name].cjs', + chunkFileNames: 'chunks/[name]-[hash].cjs' + } + } + } +});