Ship CommitLore as a compiled single-executable binary that needs no Node (#39) - #94
Merged
Conversation
…Node (#39) `npm run build:binary` (`scripts/build-binary.mjs`) builds `dist/commitlore` with Node's own `--experimental-sea-config` + `postject` from the same `src/cli.ts` entry `dist/commitlore.mjs` already bundles. `node:sqlite` (ADR-0012) was the last native dependency blocking this — it is a Node builtin, not a `.node` addon, so nothing about embedding it in a SEA blob changes how it loads, which #39 is now able to confirm rather than assume. Node's SEA main script must be CommonJS on this Node line — verified empirically, not read off a doc and trusted: an ESM main (`mainFormat: "module"`, a field this Node line's schema does not even define) fails blob generation with "Cannot use import statement outside a module", and the same file run directly as an SEA main fails identically at runtime. The shipped `@types/node` doc agrees once actually read: "the single executable application feature currently only supports running a single embedded script using the CommonJS module system." `build-binary.mjs` bundles `src/cli.ts` a second time to CommonJS as a build intermediate — a temp file, never committed — so `dist/commitlore.mjs` never changes format or content. A compiled binary has no directory tree beside it: Node's docs say `import.meta.url`/`__dirname` inside an SEA resolve to the executable's own path, not a directory containing `spec/` or `package.json`. `core/paths.ts` now branches on `sea.isSea()` — `PACKAGE_ROOT` stays a real, non-throwing directory so the module graph doesn't crash at import time, and `readInstalledFile` routes to `sea.getAsset` for the three files `build-binary.mjs` embeds (`package.json`, `spec/SPEC.md`, `spec/schema/record.schema.json`) instead of the filesystem. `index-db.ts`'s `createRequire(import.meta.url)` for `node:sqlite` moved to `createRequire(process.execPath)` for the same reason, one level down: a CJS bundle's `import.meta` is empty, and `node:sqlite` is a builtin so the base path never needed to be meaningful in the first place. `node:sea` also could not be statically imported (`import ... from 'node:sea'`) without breaking every test file that transitively imports `core/paths.ts` — Vite/vitest's SSR module graph externalizes a `node:` specifier by checking `node:module`'s `builtinModules` list, which does not carry `node:sea` yet, and mis-resolves it as a package literally named `sea`. `createRequire(process.execPath)('node:sea')` sidesteps the bundler's older check the same way `index-db.ts` already sidesteps it for `node:sqlite`; `import type` keeps the real signatures for the cast at zero runtime cost. The commit-msg hook's resolution order (#71) gets a second branch. A binary has neither a `.js`/`.mjs` extension nor a separate interpreter — it is recognized by name (`commitlore`) rather than "no extension", which would allow-list every other executable on the machine the moment it lost the extension check. Its containment check is an exact match against the recorded install (`commitlore.root`) rather than a directory prefix: a binary has no subdirectory for a foreign file to hide in, it is the whole install. `hooks.ts#recordBinPath` needed one line of new logic for this (recording `commitlore.root` as the binary's own realpath for a binary-kind entry) — Node's SEA docs describe `process.argv[1]` inside a real SEA binary as already being the executable's own path, so the existing line that reads it needed no branch at all. `scripts/commitlore-run.sh` (the Claude Code plugin's PreToolUse hook) now tries the binary before its `command -v node` gate, fixing a pre-existing gap in the same motion: previously a `commitlore` resolved on PATH was never even attempted when node was absent, regardless of whether it needed node at all. Distribution: `dist/commitlore.mjs` and ADR-0011's "git clone is the whole install" are unchanged. The binary is not committed next to it — at ~115 MiB, platform- and architecture-specific, and not meaningfully diffable, it would either break ADR-0011's "committed dist/ matches src/, byte for byte" CI invariant or need an exception carved out of it, and every rebuild would rewrite the whole blob in history forever with no way to remove it again. `npm run build:binary` produces it locally and reproducibly instead, and CI now builds and smoke-tests it (ubuntu + macos) on every push without ever committing it — the same role issue #39's own acceptance criteria pointed at ("attach platform binaries to the release"). Ruled-out: mainFormat: "module" (an ESM SEA main) | verified to fail both blob generation and runtime on this Node line, not merely documented as unsupported (see above) Ruled-out: pkg / nexe | third-party bundlers embedding a separate, forked Node runtime this project does not control the patch cadence of; pkg is archived upstream. Trades the Node runtime dependency this ticket removes for a different, less-maintained one Ruled-out: Deno compile / Bun compile | a different runtime. node:sqlite, the TypeScript, and NodeNext resolution are all Node-specific; retargeting them is a second runtime port, not a build step, and issue #39's own first option ("Node SEA -- no source rewrite") needs none Ruled-out: reimplement in Go/Rust | issue #39's own second option, and a real one via spec/fixtures + spec/contract-cases, but an order of magnitude more work than this ticket and not needed to solve either problem (latency, no-Node-on-PATH) this ticket opens with Ruled-out: committing dist/commitlore next to dist/commitlore.mjs | breaks ADR-0011's committed-dist/-matches-src/ invariant at ~115 MiB per platform/arch, and a pushed blob that size is not removable from git history again Ruled-out: Windows (commitlore.exe) in this PR | Node's docs describe a signtool path this repository has no CI runner to verify; shipping an unverified platform claim is what this project's numbers-or-silence discipline exists to refuse. classifyBinTarget and the resolution order are written so it is a small additive follow-up, not a redesign Warn: node:sea is "Active development" per Node's own docs; its schema or CommonJS-only constraint could change between Node versions. core/paths.ts's readInstalledFile/isSea split and build-binary.mjs's asset map are the one place that assumption is absorbed, same posture as ADR-0012 already committed to for node:sqlite Limit: doctor's PreToolUse hook runtime check still shells to scripts/commitlore-run.sh via bash for its own probe; a binary install with the Claude Code plugin hook already wired reports a plain ENOENT-style fail there rather than trying the binary directly -- not one of #39's six required commands, not fixed here Verified: doctor, validate, context, guard, inject, and index --rebuild all run against env -i PATH=/usr/bin:/bin with the real compiled binary, re-run immediately before this commit against the current tree Verified: both of #71's attacks re-run against the real binary and its installed commit-msg hook immediately before this commit -- commitlore.bin pointed at a copy of the binary outside the install root, and a symlink planted inside the install root pointing at that same outside copy. Both refused ("cannot find the CLI"); a legitimate binary install still validates a bad message (rejected) and a good one (accepted) right after Verified: hook overhead re-measured same-session (bench/binary-hook-overhead.ts, three arms against the same repeated file write), three times with load average 13-20 on a machine shared with other concurrent work this session: node dist/cli.js p50 122-127ms (consistent with the committed +102.40ms figure this ticket cites), binary p50 66-68ms -- 46-47% lower than the node path every time, same session. A real, bounded reduction, not elimination: SEA still pays V8 startup and blob decompression, just not a second process's module resolution on top of it. Numbers in bench/results/binary-hook-overhead-*.md Verified: npx vitest run -- 1357 passed, 1 skipped (pre-existing, COMMITLORE_PERF_LARGE-gated), 37/37 files, 0 failed Verified: npm run typecheck, npm run build (dist/ byte-identical on rebuild), bash spec/verify.sh, node scripts/check-engines.mjs, and node scripts/check-readme-numbers.mjs all pass Blast: system Undo: easy Certainty: firm Evidence: src/core/paths.ts Evidence: src/core/hook-target.ts Evidence: src/core/index-db.ts Evidence: src/core/schema.ts Evidence: src/core/harvest.ts Evidence: src/hooks/commit-msg.ts Evidence: src/commands/hooks.ts Evidence: src/commands/doctor.ts Evidence: scripts/build-binary.mjs Evidence: scripts/commitlore-run.sh Evidence: bench/binary-hook-overhead.ts Evidence: .github/workflows/ci.yml Evidence: docs/adr/ADR-0015-single-executable-binary.md Evidence: test/hook-target.test.ts Evidence: test/hooks.test.ts Evidence: test/doctor.test.ts Follows: r-adr0012 Record-Id: r-seabin39 Provenance: authored CommitLore-Version: 2.0.0
CommitLore — record lintTrailers: clean — 3 commits in Active constraints for the paths this PR touchesLimits (44)
Ruled out (111)
Warnings (67)
Trailer violations fail this check. Active constraints are informational — they are what the repository already decided, not a verdict on this PR. |
…store provenance (#39) #39's own AC named this the last piece: "attach platform binaries to the release." `.github/workflows/release.yml` builds it on top of PR #94's compiled-binary work without touching #94's own decision (the binary stays uncommitted; CI still builds and smoke-tests it on every push) — this adds the second, separate act of publishing one when an owner tags a version. Trigger is `on: push: tags: ["v*"]` only — no `schedule`, no `branches`, no `workflow_dispatch`. Cutting a release is an owner action; pushing the tag is the only approval this workflow recognizes, and it has no path to fire on its own. Three jobs, matching the shape ripgrep/fd/bat/gh/deno/bun all ship rather than inventing one: - `version-consistency` (new `scripts/check-release-version.mjs`) runs first and gates everything else: the tag, package.json's `version`, and `commitlore --version` must all agree, or the release stops before a single binary is built. A tag is immutable once fetched and an asset is immutable once downloaded, so this is the only point where a mismatch is still cheap to catch. - `build` matrix — macos-latest (arm64), macos-13 (x64), ubuntu-latest (x64), ubuntu-24.04-arm (arm64) — packages each as `commitlore-<version>-<target-triple>.tar.gz`, the Rust-ecosystem naming convention install scripts already expect to parse, and attests each asset with `actions/attest-build-provenance` (Sigstore-backed, keyless, no key this project manages — and the harder-than-usual case for a project whose own README argues decision records must be verifiable). `scripts/build-binary.mjs` always injects into the *running* node binary (its own comments), so a native runner per target is not a convenience here, it is the only way this build works — there is no cross-target flag to give it. - `publish` downloads every matrix artifact, computes one `SHA256SUMS` covering all four assets together (a per-platform file would let one asset go unverifiable without the download even failing), attests the checksum file too, and creates the release with `gh release create --generate-notes`. Linux arm64 needed no emulation gymnastics: GitHub's own `ubuntu-24.04-arm` hosted runner is available to this (public) repository, confirmed by actually building and running the SEA binary on it, not assumed — see Verified. Ruled-out: windows-latest | AC asked for it, and the task was to verify before assuming, not to skip verifying. Two real, reproducible defects found on a real windows-latest run of this exact branch, not merely an absent runner (see Verified for the throwaway probe): (1) `scripts/build-binary.mjs`'s `REPO_ROOT` is computed as `resolve(new URL('..', import.meta.url).pathname)`; a Windows file:// URL's `.pathname` keeps its leading slash in front of the drive letter, and win32 `path.resolve` then doubles it (`D:\D:\a\commitlore\...`) — `npm run build:binary` fails before SEA is even reached. Not a one-off: `scripts/check-engines.mjs` has the identical pattern, so it is a systemic gap in the script/ layer. (2) Even past that, `classifyBinTarget` (`src/core/hook-target.ts`) and the `commit-msg` shell stub's `case` patterns recognize a compiled binary only by the exact basename `commitlore` — never `commitlore.exe`, which is what `build-binary.mjs`'s own `OUTPUT` already names the Windows build. The installed hook would silently fall through #71's containment check rather than refuse to resolve it, on the one platform that most needs that check to hold. ADR-0015 already named this "a small, additive follow-up" — confirmed here empirically, not fixed here: fixing it is a source change to #94's own shipped hook-target logic, a different scope than building the pipeline that ships what already works. Windows stays out of the matrix until both are fixed and re-verified on a real runner. Warn: `node scripts/check-release-version.mjs` only checks the script build's `--version` (`dist/commitlore.mjs`), not the compiled binary's — correct today because both read the identical `packageVersion()` (`src/core/paths.ts`) against the identical `package.json`, but that argument would need re-checking if the two build paths ever diverge. Limit: the whole tag-triggered flow (three jobs, the real `gh release create`, the real attestation publish step) has never run end to end — cutting a release is explicitly the owner's action, not this change's. What was verified below is every component this repository allows verifying without that: the matrix runners for real (via a throwaway, deleted probe branch, not this workflow), the version-consistency script against real passing and deliberately-mismatched input, and the workflow file's own YAML validity. Verified: `.github/workflows/release.yml` parses as valid YAML (`python3 -c "import yaml; yaml.safe_load(...)"`); no `act`/`actionlint` available in this environment, so job-graph/expression-level linting is unexercised Verified: `node scripts/check-release-version.mjs v0.1.0` against this tree's real package.json (0.1.0) and real `dist/commitlore.mjs --version` (0.1.0) — passes, exit 0. `v9.9.9` against the same tree — fails with both disagreements listed, exit 1. `not-a-tag` — rejected as not looking like a version, exit 2. `GITHUB_REF_NAME=v0.1.0` env-var path — same pass as the argument path Verified: ubuntu-24.04-arm is a real, non-emulated GitHub-hosted runner for this repository — built dist/commitlore.mjs and dist/commitlore on it via a throwaway workflow_dispatch/push-triggered probe branch (`probe/windows-sea`, deleted after), `uname -m` reported `aarch64`, `npm run build:binary` succeeded, `./dist/commitlore --version` and `doctor` both ran clean Verified: the same probe, on windows-latest, reproduced the `REPO_ROOT` doubling failure described above verbatim (`D:\D:\a\commitlore\commitlore\dist\commitlore.mjs does not exist`) — not inferred from reading the code alone, though the code reading (item 2 above) independently confirms `classifyBinTarget` would also reject `commitlore.exe` had the build itself succeeded Verified: `npm run typecheck`, `bash spec/verify.sh`, and `node scripts/check-readme-numbers.mjs` all still pass against this tree Blast: system Undo: easy Certainty: tentative Provenance: authored Evidence: .github/workflows/release.yml Evidence: scripts/check-release-version.mjs Evidence: scripts/build-binary.mjs Evidence: scripts/check-engines.mjs Evidence: src/core/hook-target.ts Evidence: src/hooks/commit-msg.ts Evidence: docs/adr/ADR-0015-single-executable-binary.md Follows: r-seabin39 Record-Id: r-relworkflow CommitLore-Version: 2.0.0
…rivate:true gap (#39) `install.sh` is the second documented install path #37/#39's release pipeline needed: `curl -fsSL .../install.sh | sh` for a machine with no Node, no clone, and no interest in either — while never being the *only* documented path, since piping to a shell should not have to be trusted blind. Detects OS/arch, maps to the target triple `release.yml` names assets with, downloads `SHA256SUMS` from the same release first (fixed filename, no version guessing, no GitHub API call so no rate limit), resolves this platform's asset out of it, and verifies the checksum *before* extracting or installing anything. A script that skips that check is worse than none — it teaches the habit this project's own trust model argues against. Honors `$PREFIX` (installs to `$PREFIX/bin`) or `$COMMITLORE_INSTALL_DIR` (exact destination), defaulting to `$HOME/.local/bin`. Refuses to overwrite anything it did not install: an existing file at the destination is only treated as a legitimate upgrade target if running it actually produces output shaped like this CLI's own `--version` (a bare semver) — not merely "runs and exits 0", which almost anything does for an unrecognized flag. Caught during testing, not assumed correct: an earlier version of this check trusted exit code 0 alone and silently overwrote a throwaway foreign script that printed unrelated text and exited clean — see Verified. Unsupported OS/arch (anything but macOS/Linux, aarch64/x86_64 — no Windows asset yet, ADR-0015) fails loudly with a pointer to installing from source, never guesses. All four READMEs get the same two additions, translated rather than English-only: the curl one-liner plus the equivalent manual download-verify-extract steps, so the shell-pipe path is never the only documented one in any language this project ships docs for. While there, backfilled a gap PR #94 left in the three non-English READMEs: it added "### Run as a compiled binary, without Node installed" only to README.md, so README.ko.md/README.ja.md/README.zh-CN.md were both missing that section entirely and still listed issue #39 under "not done yet" after it was. Both fixed together since the new release-install section reads as non-sequitur without the binary section it follows in English. package.json gets `"private": true`, per issue #93's own note that this is "worth a single cleanup once #39 lands rather than twice" — #39 has now landed both halves (the binary, and the pipeline that ships it), and nothing about this release model works if `npm publish` still succeeds. `bin`/`files`/runtime `dependencies` are #93's other findings and are intentionally untouched here — `private: true` is the one that is a direct prerequisite of "do not re-add npm publishing," the rest is a separate cleanup with its own blast radius. Ruled-out: guessing the current version to build the asset URL directly | would need either the GitHub API (rate-limited, needs no-auth headers handled correctly) or trusting a redirect's final Location header parsing. Downloading the fixed-URL SHA256SUMS first and reading the real asset name back out of it needs neither and is what the checksum step has to fetch anyway. Ruled-out: `local` for scoping — not POSIX per se, but supported by dash, bash, and every shell this script is realistically piped into (verified directly, see Verified) | not used in the end; the script has few enough variables that scoping was not needed, only noted here because it was considered. Limit: never tested against the real GitHub release infrastructure (no release exists yet — that is the owner's action) — verified against a locally built SEA binary, a hand-made SHA256SUMS, and a local HTTP server standing in for GitHub's release-asset redirects, which is everything this repository lets a change verify before a tag exists. Verified: full happy path against a real `npm run build:binary` output packaged exactly as `release.yml` would (`commitlore-0.1.0-aarch64-apple- darwin.tar.gz` + hand-made `SHA256SUMS`, served over a local `python3 -m http.server`) — checksum verified, installed, `--version` reports 0.1.0. Re-run against the same install: recognized as its own prior install, upgraded, not refused Verified: checksum mismatch (SHA256SUMS hand-edited to a wrong hash) — refused before installing anything, exit 3, nothing written to the destination directory Verified: pre-bug — a foreign executable at the destination that prints unrelated text and exits 0 on `--version` was silently overwritten by the exit-code-only check. Fixed to check the output shape; re-run with the identical foreign file — correctly refused, exit 4, foreign file untouched (content diffed byte-identical after the refusal) Verified: unsupported OS (faked `uname -s` → `SunOS`) and unsupported arch (`uname -m` → `riscv64`) both fail loudly, exit 1, before any network call Verified: `sh -n` and `dash -n` (strict POSIX) both accept the script with no syntax errors; the full happy path re-run under `dash` directly (not only bash-as-sh) with identical results Verified: the manual (non-piped) verify sequence documented in all four READMEs — `curl -fsSLO`, `grep ... | shasum -a 256 -c -`, and the Linux equivalent `sha256sum -c -` — both produce "OK" against the same hand-made SHA256SUMS and locally built asset Verified: `node scripts/check-readme-numbers.mjs` and `bash spec/verify.sh` still pass against all four edited READMEs Blast: system Undo: easy Certainty: firm Provenance: authored Evidence: install.sh Evidence: README.md Evidence: README.ko.md Evidence: README.ja.md Evidence: README.zh-CN.md Evidence: package.json Follows: r-relworkflow Record-Id: r-relinstall CommitLore-Version: 2.0.0
This was referenced Jul 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Ships
dist/commitlore, a compiled Node Single Executable Application that needs no Node runtime, no interpreter, and nonode_modulesat all.doctor,validate,context,guard,inject, andindex --rebuildall run againstPATH=/usr/bin:/bin.dist/commitlore.mjsand ADR-0011's "git clone is the whole install" are unchanged — the binary is a second, uncommitted, reproducible build artifact (npm run build:binary), not a replacement channel.See
docs/adr/ADR-0015-single-executable-binary.mdfor the full decision record.Approach chosen, and what was ruled out
Chosen: Node SEA (
--experimental-sea-config+postject), building from the samesrc/cli.tsentrydist/commitlore.mjsalready bundles.node:sqlite(ADR-0012) was the last native dependency blocking this — it's a Node builtin, not a.nodeaddon, so nothing about being embedded in a SEA blob changes how it loads, which this PR confirms rather than assumes.One correction along the way, verified empirically rather than trusted from a doc summary: the SEA main script must be CommonJS on this Node line. An ESM main (
mainFormat: "module", a field this Node line's schema doesn't even define) fails blob generation withCannot use import statement outside a module, and the same file run directly as an SEA main fails identically at runtime. The shipped@types/nodedoc agrees once actually read.scripts/build-binary.mjsbundlessrc/cli.tsa second time to CommonJS as a build intermediate — a temp file, never committed — sodist/commitlore.mjsnever changes.mainFormat: "module"— see above; verified to fail, not merely undocumented.pkg/nexe— third-party bundlers embedding a separate, forked Node runtime this project doesn't control the patch cadence of;pkgis archived upstream. Trades the runtime dependency this ticket removes for a worse one.compile/ Buncompile— a different runtime.node:sqlite, the TypeScript, andNodeNextresolution are all Node-specific; retargeting them is a second runtime port, not a build step.spec/fixtures/+spec/contract-cases/, but an order of magnitude more work than this ticket and not needed to solve either problem (latency, no-Node-on-PATH) it opens with.dist/commitlorenext todist/commitlore.mjs— see "Distribution decision" below.commitlore.exe) in this PR — Node's docs describe asigntoolpath this repo has no CI runner to verify.classifyBinTargetand the hook resolution order are written so it's a small additive follow-up, not a redesign.Distribution decision — the binary is not committed
At ~115 MiB, platform- and architecture-specific, and not meaningfully diffable, committing it next to
dist/commitlore.mjswould break ADR-0011's "committeddist/matchessrc/, byte for byte" CI invariant, and — the irreversible part — a pushed blob that size is never removable from git history again. It is never staged:.gitignoreexcludes/dist/commitlore, verified withgit add -A -nbefore committing (no match) andgit ls-files | grep dist/commitloreafter pushing (no match).Instead:
npm run build:binarybuilds it locally and reproducibly, and CI (this PR) now builds and smoke-tests it on ubuntu-latest and macos-latest on every push, without ever committing it — the same role issue #39's own AC pointed at ("attach platform binaries to the release").docs/adr/ADR-0011-plugin-first-distribution.mdgets a short "Addressed" callout pointing here; its own decision ("distribution is git clone") is unchanged.The no-Node run
Verified directly against the real compiled binary, immediately before the commit that ships this PR, with
env -i PATH=/usr/bin:/bin:index --rebuildconfirmsnode:sqlitegenuinely works inside the SEA blob, not just in theory. CI's newbinaryjob runs this exact sequence on ubuntu + macos on every push.Hook overhead, against +102.40 ms
bench/binary-hook-overhead.tsre-measures the PreToolUse inject hook same-session, three arms (no hook /node dist/cli.js/ the binary) against the same repeated file write, matchingbench/deterministic/hooks.ts#preToolUseOverhead's method (one discarded warmup, 20 timed runs).The machine this PR was built on was shared with other concurrent work for part of the session (load average briefly 100–150). The number below is from a run taken and re-confirmed with
uptimeat load average 13–20, not the contaminated ones — those were discarded rather than reported.node dist/cli.js inject --hook-inputdist/commitlore inject --hook-inputNode-path delta: 127.07 ms (consistent with the committed +102.40 ms figure — same order of magnitude, not an identical machine state). Binary delta: 68.18 ms — 46.3% lower than the node path, same session. Repeated three times total across this session at calm load; every run landed in the 66–68 ms band for the binary and 122–127 ms for the node path.
This is a real, bounded reduction, not elimination — the answer the ticket asked for either way. SEA still pays V8 startup and blob decompression; it just skips a second process's worth of module resolution on top of that. Full numbers:
bench/results/binary-hook-overhead-*.md.#71's containment guarantee, extended and re-verified
src/hooks/commit-msg.ts's resolution order gets a second branch for a binary: no.js/.mjsextension, no separate interpreter (core/hook-target.ts#classifyBinTarget, keyed on the namecommitlore, not merely "no extension" — that would allow-list every other executable on the machine). Containment is an exact match against the recorded install (commitlore.root) instead of a directory prefix, since a binary has no subdirectory for a foreign file to hide in.Both of #71's attacks were re-run against the real compiled binary and its installed commit-msg hook, immediately before the commit:
Both attacks refused; the legitimate install validates a bad message (rejected) and a good one (accepted) immediately after. A new regression test for the symlink attack (which had no automated test before this PR, for either the script or binary branch) is in
test/hooks.test.ts, plus a fulla compiled-binary-shaped entry pointdescribe block covering both attacks, the happy path, andCOMMITLORE_BINtargeting a binary.test/hook-target.test.tsis new — unit coverage forclassifyBinTargetandreadRecordedHookTarget's problem list. CI's newbinaryjob re-runs both attacks against the real compiled binary on every push (ubuntu + macos).Follow-up (same branch, same PR): the release pipeline
#39's AC also asked to "attach platform binaries to the release," which the
commit above deliberately left for a separate act — the binary stayed
uncommitted and CI only builds/smoke-tests it on push. Two more commits on
this branch add the actual publishing side:
.github/workflows/release.yml,install.sh, and READMEs updated in all four languages. Nothing about theSEA decision above changes; this builds on top of it.
.github/workflows/release.yml— tag-triggered, nothing elseon: push: tags: ["v*"]only. Noschedule, nobranches, noworkflow_dispatch— cutting a release is an owner action, and pushing thetag is the only approval this workflow recognizes.
Three jobs:
version-consistency(newscripts/check-release-version.mjs) —the tag,
package.json'sversion, andcommitlore --versionmust allagree, checked before any binary is built. Verified against this tree
both ways:
v0.1.0passes (all three agree at0.1.0); a deliberatelywrong
v9.9.9fails with both disagreements listed and exit 1.buildmatrix —macos-latest(arm64),macos-13(x64),ubuntu-latest(x64),ubuntu-24.04-arm(arm64) — packagescommitlore-<version>-<target-triple>.tar.gz(the Rust-ecosystemnaming convention
install.shparses) and attests each asset withactions/attest-build-provenance(Sigstore-backed, keyless, no keythis project manages).
publish— oneSHA256SUMScovering all four assets together,attested too, then
gh release create --generate-notes.Linux arm64 needed no emulation. GitHub's own
ubuntu-24.04-armhostedrunner is available to this (public) repo — confirmed by actually building
and running the SEA binary on it via a throwaway probe branch
(
probe/windows-sea, workflow_dispatch + push triggered, deleted after):uname -m→aarch64,npm run build:binarysucceeded,--versionanddoctorboth ran clean.Windows is deliberately not in the matrix — verified absent, not assumed
absent. The same probe branch ran on
windows-latestand reproduced tworeal, reproducible failures, not merely "no runner yet":
npm run build:binaryfails before SEA is even reached.scripts/build-binary.mjs'sREPO_ROOTisresolve(new URL('..', import.meta.url).pathname); a Windowsfile://URL's.pathnamekeepsits leading slash in front of the drive letter, and win32
path.resolvedoubles it: the CI log shows literallyD:\D:\a\commitlore\commitlore\dist\commitlore.mjs does not exist. Nota one-off —
scripts/check-engines.mjshas the identical pattern, soit's a systemic gap in the
scripts/layer, not one missed line.classifyBinTarget(src/core/hook-target.ts) and thecommit-msgshell stub'scasepatterns recognize a compiled binaryonly by the exact basename
commitlore— nevercommitlore.exe, whichis what
build-binary.mjs's ownOUTPUTalready names the Windowsbuild. The installed hook would silently fall through Security: commitlore.bin and COMMITLORE_BIN are executed, and the env path lacks the guard the config path has #71's containment
check rather than refuse to resolve it, on the platform that most needs
that check to hold. ADR-0015 already named this "a small, additive
follow-up" — confirmed empirically here, not fixed here: fixing either
of these is a source change to this PR's own shipped
hook-target/build-binarylogic, out of scope for the release-pipeline change thatsurfaced them. Windows stays out until both are fixed and re-verified on
a real Windows runner.
install.sh— the second install path, checksum-verified before anything runscurl -fsSL https://github.com/ghraw/MongLong0214/commitlore/dev/install.sh | shDetects OS/arch, downloads
SHA256SUMSfrom the release first (fixedfilename, no version-guessing, no GitHub API call), resolves this
platform's asset out of it, and verifies the checksum before installing
anything. Honors
$PREFIXor$COMMITLORE_INSTALL_DIR; refuses tooverwrite a file it did not install (checked by output shape — "runs and
exits 0" is not enough, plenty of unrelated executables do that for an
unrecognized flag; a caught bug during testing, see below). Fails loudly
on any OS/arch that isn't published.
Verified end-to-end against a real, locally built SEA binary packaged
exactly as
release.ymlwould, served over a localpython3 -m http.serverstanding in for GitHub's release-asset URLs (no real releaseexists yet — that's the owner's action):
--versionreports0.1.0.SHA256SUMSto a wrong hash: refused before installinganything, exit 3, nothing written.
don't-overwrite-a-foreign-file check trusted
<file> --versionexiting0 alone, and silently overwrote a throwaway foreign script that printed
unrelated text and exited clean. Fixed to check the output actually
looks like this CLI's own (
[0-9]*.[0-9]*.[0-9]*); re-verified with theidentical foreign file — correctly refused, exit 4, foreign file
untouched.
uname -s/uname -mfor an unsupported OS and an unsupportedarch — both fail loudly, exit 1, before any network call.
sh -nanddash -n(strict POSIX) both accept the script; the fullhappy path re-run under
dashdirectly gives identical results.(
curl -fsSLO+grep ... | shasum -a 256 -c -/sha256sum -c -)produces "OK" against the same hand-built asset.
READMEs, all four languages, and
package.jsonCurl one-liner + manual verify steps added to README.md/.ko.md/.ja.md/
.zh-CN.md, so piping to a shell is never the only documented path in any
language this project ships docs for. While there: PR #94 had only added
"### Run as a compiled binary, without Node installed" to README.md, so
the three non-English READMEs were both missing that section and still
listing issue #39 under "not done yet." Backfilled together, since the new
release-install section reads as non-sequitur without the section it
follows in English.
package.jsongets"private": true— issue #93's own note that this is"worth a single cleanup once #39 lands rather than twice," and #39 has now
landed both halves.
bin/files/runtimedependenciesare #93's otherfindings, intentionally untouched here (separate blast radius);
private: trueis the one that's a direct, structural guarantee thatnpm publishcan't ever re-happen by accident.
What's still unverified, plainly
The entire tag-triggered flow — the real
gh release create, the realattestation publish, all four platforms' binaries actually landing on a
release — has never run end to end, because cutting a release is
explicitly not this PR's action to take. Everything above is what this
repository lets a change verify before a tag exists: real runners (via a
throwaway, now-deleted probe branch, not this workflow), the
version-consistency script against real passing/failing input, the install
script against a hand-built asset and hand-made checksums, and the
workflow file's own YAML validity (
python3 -c "import yaml; yaml.safe_load(...)"— no
act/actionlintavailable in this environment, so job-graph/expression-level linting is unexercised). The first real tag is where the
rest gets checked.
Tests
npx vitest run— 1357 passed, 1 skipped (pre-existing,COMMITLORE_PERF_LARGE-gated), 37/37 files, 0 failed.test/mcp.test.tsflaked once under concurrent load from other work on the shared machine mid-session (issue #88, exactly as expected) and passed cleanly alone and in the final full run after that load cleared.Independently re-confirmed before the release-pipeline commits (not merely re-quoted):
npx vitest run --reporter=defaulton this exact tree —Test Files 37 passed (37),Tests 1357 passed | 1 skipped (1358). Same numbers, run fresh.Also green:
npm run typecheck,npm run build(rebuiltdist/is byte-identical, confirmed twice),bash spec/verify.sh,node scripts/check-engines.mjs,node scripts/check-readme-numbers.mjs— all re-run again after the release-pipeline commits, still green.Each commit passes
node dist/commitlore.mjs validate --commit HEAD, including the two added on top for the release pipeline.Test plan
env -i PATH=/usr/bin:/binrun ofdoctor,validate,context,guard,inject,index --rebuildagainst the real binarydist/commitlore.mjsunchanged and still works (existing fresh-clone CI step untouched)dist/commitlorenever staged or pushed (git add -A -n/git ls-filesboth confirm)binaryjob (ubuntu + macos) builds the binary and runs the no-Node + Security: commitlore.bin and COMMITLORE_BIN are executed, and the env path lacks the guard the config path has #71 checksnpx vitest run— 1357/1358, 37/37 files, 0 failed (re-confirmed independently before the release-pipeline commits)release.ymlparses as valid YAML; job/matrix structure reviewed by hand (noact/actionlintavailable)scripts/check-release-version.mjsverified against real matching input and a deliberate mismatchubuntu-24.04-armconfirmed as a real, non-emulated hosted runner (throwaway probe, deleted)windows-latestconfirmed broken for this build (throwaway probe, deleted) — excluded from the matrix with the exact failure documented, not guessed atinstall.shverified end-to-end against a real local SEA build + hand-madeSHA256SUMS: happy path, upgrade, checksum mismatch, foreign-file refusal (bug found and fixed here), unsupported OS/arch,dash -nstrict-POSIX checkgh release create, real attestations, all four assets landing on an actual release) — not run. Cutting a release is the owner's action; this PR builds the machinery and leaves it unused, exactly as asked.Not done, on purpose: no tag was pushed, no release was cut, no binary or archive was committed, npm publishing was not re-added (the opposite —
private: truecloses it structurally per #93), and #71's containment / the existing CI jobs were left untouched.