From f97772f35f247aa83ea5fce4b7720b156176f861 Mon Sep 17 00:00:00 2001 From: Simon Ostendorf Date: Thu, 15 Oct 2026 21:13:22 +0200 Subject: [PATCH 01/12] build(sea): build a single executable per platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A SEA needs two things the npm bundle cannot give it: a CommonJS entry, because node's embedder runs the main script as CJS, and the runtime dependencies inlined, because there is no node_modules beside it. Hence a second vite config writing `dist/sea/entry.cjs` rather than a format added to the existing one. `scripts/sea-build.ts` writes the blob, injects it with postject, and re-signs on darwin — injecting bytes invalidates the signature the node binary shipped with, and macOS then refuses to run it. It runs `--version` out of the finished binary before packaging, which is what catches a bad injection: both failure modes segfault rather than error. The embedded runtime is the node that ran the script, so the artifact carries the version CI resolved from `.nvmrc`. --- package.json | 3 ++ scripts/sea-build.ts | 118 +++++++++++++++++++++++++++++++++++++++++++ vite.sea.config.ts | 58 +++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 scripts/sea-build.ts create mode 100644 vite.sea.config.ts 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/scripts/sea-build.ts b/scripts/sea-build.ts new file mode 100644 index 0000000..74ed323 --- /dev/null +++ b/scripts/sea-build.ts @@ -0,0 +1,118 @@ +#!/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, 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); +} + +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. This is the check that would +// have caught both mistakes in the comment at the top. +const reported = execFileSync(binary, ['--version'], { + encoding: 'utf8' +}).trim(); +if (reported !== pkg.version) { + console.error(`The binary reports ${reported}, expected ${pkg.version}.`); + process.exit(1); +} + +const archive = `${binName}_${pkg.version}_${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/vite.sea.config.ts b/vite.sea.config.ts new file mode 100644 index 0000000..1c2729a --- /dev/null +++ b/vite.sea.config.ts @@ -0,0 +1,58 @@ +import { readFileSync } from 'node:fs'; +import { builtinModules } from 'node:module'; +import { dirname, resolve } from 'node:path'; +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. +// +// Everything else is deliberately identical to the npm build, the inlined +// version included, so `--version` reads the same out of either artifact. + +const root = dirname(new URL(import.meta.url).pathname); + +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' + } + } + } +}); From 890e8b3ce76ebb3dee2e2a48bcdb3628cb38ec9f Mon Sep 17 00:00:00 2001 From: Simon Ostendorf Date: Thu, 15 Oct 2026 21:13:34 +0200 Subject: [PATCH 02/12] feat(info): report a single executable as its own build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walking up to a package.json cannot work inside a SEA — the runtime, the bundle and the templates are all in the one file being run — so a downloaded binary reported `unknown / no package.json found above the binary`, which reads like a defect rather than the most ordinary install there is. `isSea()` answers it directly. The branch is covered by swapping `node:sea` in the test: `isSea()` is false under the runner by construction. The real artifact is checked by the build script, which runs `--version` out of the binary. --- src/commands/info.ts | 21 +++++++++++++++++++-- tests/info.test.ts | 20 +++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/commands/info.ts b/src/commands/info.ts index 603abe1..8341708 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 Homebrew install reports `unknown`, which reads + // like a defect rather than the most ordinary install there is. + 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..d66a815 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 113 MB binary 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('/opt/homebrew/bin/gitignore-sync'); + expect(build.kind).toBe('sea'); + expect(build.packageRoot).toBeNull(); + } finally { + vi.doUnmock('node:sea'); + vi.resetModules(); + } + }); }); From c15cf42c6a601fec4c3a5ccae433a2eaa979b693 Mon Sep 17 00:00:00 2001 From: Simon Ostendorf Date: Thu, 15 Oct 2026 21:13:54 +0200 Subject: [PATCH 03/12] ci(release): attach a platform binary to every release One job per platform, because there is nothing to cross-compile from: a single executable is the target platform's own node binary with a blob in it. Each job uploads its archive and a `.sha256` straight to the release release-please just cut, so no artifact handoff is needed. Stable releases only. A prerelease publishes to npm without cutting a GitHub release, so there would be nothing to attach to. --- .github/workflows/release-please.yml | 58 ++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index abcbe99..dbc9967 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -47,6 +47,64 @@ jobs: with: tag-name: ${{ needs.release-please.outputs.tag-name }} + # 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`. + # + # Only stable releases get binaries. A prerelease publishes to npm without + # cutting a GitHub release, so there would be nothing to attach them to. + publish-binaries: + name: Attach ${{ matrix.target }} + needs: release-please + if: needs.release-please.outputs.release-created == 'true' + permissions: + # `gh release upload` writes into the release release-please just cut. + contents: write + strategy: + # Every platform reports, rather than the first failure hiding the rest. + # A missing asset is visible on the release either way. + 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: + # The tag, not the branch tip: the artifact has to carry the version + # that was released, and `--version` is inlined at build time. + ref: ${{ needs.release-please.outputs.tag-name }} + + - uses: pnpm/action-setup@v6 + + - 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. + - run: pnpm build + - run: pnpm sea:build + + - name: Attach the archive and its checksum + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release upload "${{ needs.release-please.outputs.tag-name }}" + dist/release/*.tar.gz dist/release/*.tar.gz.sha256 + --clobber + publish-prerelease: name: Publish prerelease # Skip the version-bump merges release-please pushes onto main — those are From d85e0ce4996f82a7ddf4a29e63769162384594dc Mon Sep 17 00:00:00 2001 From: Simon Ostendorf Date: Thu, 15 Oct 2026 21:13:54 +0200 Subject: [PATCH 04/12] docs(readme): document the standalone binary download Covers the install path for a machine with no Node on it, including the checksum step, and says what `info` reports so a downloaded binary is not mistaken for an installed package. --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 01fa003..8f5a531 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,21 @@ 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 — `darwin_arm64`, `darwin_x64`, `linux_x64` and `linux_arm64`: + +```bash +VERSION=0.5.0 +NAME="gitignore-sync_${VERSION}_darwin_arm64.tar.gz" +curl -fsSLO "https://github.com/kirchDev/gitignore-sync/releases/download/v${VERSION}/${NAME}" +curl -fsSLO "https://github.com/kirchDev/gitignore-sync/releases/download/v${VERSION}/${NAME}.sha256" +shasum -a 256 -c "${NAME}.sha256" +tar -xzf "${NAME}" +./gitignore-sync info +``` + +> [!NOTE] +> The archive holds one file and installs nothing: drop it anywhere on your `PATH`, under either name. `gitignore-sync info` reports `single executable` so a downloaded binary is never mistaken for an installed package. + ## 🚀 Quick start ```bash From 3c18c7fe3641855ab0c44c145f0a6ee0c28be586 Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Thu, 15 Oct 2026 22:43:20 +0200 Subject: [PATCH 05/12] build(deps): lock postject --- pnpm-lock.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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: {} From cb758204b834f73fa0edb06e5f406009bcb5ee72 Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Thu, 15 Oct 2026 22:43:22 +0200 Subject: [PATCH 06/12] build: resolve the vite root with fileURLToPath A pathname keeps %20 in a path with spaces. --- vite.config.ts | 4 +++- vite.sea.config.ts | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) 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 index 1c2729a..d8e2de9 100644 --- a/vite.sea.config.ts +++ b/vite.sea.config.ts @@ -1,6 +1,7 @@ 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 @@ -14,11 +15,16 @@ import { defineConfig } from 'vite'; // - **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. // -// Everything else is deliberately identical to the npm build, the inlined -// version included, so `--version` reads the same out of either artifact. +// The target and the inlined version match the npm build, so `--version` reads +// the same out of either artifact. -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, From 03e26906871f4de1f20d6feed6714bc1435a8873 Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Thu, 15 Oct 2026 22:43:25 +0200 Subject: [PATCH 07/12] build(sea): keep the bundle one file and name archives without a version A chunk split off the entry would be missing inside the binary, so the build refuses one. list now runs out of the binary next to --version, and a stable archive name lets releases/latest/download resolve. --- scripts/sea-build.ts | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/scripts/sea-build.ts b/scripts/sea-build.ts index 74ed323..0a88fdc 100644 --- a/scripts/sea-build.ts +++ b/scripts/sea-build.ts @@ -27,7 +27,13 @@ import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { + copyFileSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync +} from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -57,6 +63,16 @@ if (arch !== 'arm64' && arch !== 'x64') { 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'); @@ -92,8 +108,9 @@ if (platform === 'darwin') { run('codesign', ['--sign', '-', binary]); } -// The binary has to answer before it is packaged. This is the check that would -// have caught both mistakes in the comment at the top. +// 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(); @@ -101,8 +118,11 @@ if (reported !== pkg.version) { console.error(`The binary reports ${reported}, expected ${pkg.version}.`); process.exit(1); } +execFileSync(binary, ['list'], { stdio: 'ignore' }); -const archive = `${binName}_${pkg.version}_${platform}_${arch}.tar.gz`; +// 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') From b09ddfb70705aa6e82490abfaddec8b240023e9f Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Thu, 15 Oct 2026 22:43:28 +0200 Subject: [PATCH 08/12] ci(sea): build the single executables on pull requests too One workflow for both callers: pull requests touching the build run it on all four platforms and install the archive the way the README does, and the release calls it with the tag to attach the archives. --- .github/workflows/release-please.yml | 57 ++------------- .github/workflows/sea.yml | 105 +++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 51 deletions(-) create mode 100644 .github/workflows/sea.yml diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index dbc9967..01fa159 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -47,63 +47,18 @@ jobs: with: tag-name: ${{ needs.release-please.outputs.tag-name }} - # 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`. - # # Only stable releases get binaries. A prerelease publishes to npm without - # cutting a GitHub release, so there would be nothing to attach them to. + # 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 ${{ matrix.target }} + name: Attach binaries needs: release-please if: needs.release-please.outputs.release-created == 'true' permissions: - # `gh release upload` writes into the release release-please just cut. contents: write - strategy: - # Every platform reports, rather than the first failure hiding the rest. - # A missing asset is visible on the release either way. - 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: - # The tag, not the branch tip: the artifact has to carry the version - # that was released, and `--version` is inlined at build time. - ref: ${{ needs.release-please.outputs.tag-name }} - - - uses: pnpm/action-setup@v6 - - - 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. - - run: pnpm build - - run: pnpm sea:build - - - name: Attach the archive and its checksum - env: - GH_TOKEN: ${{ github.token }} - run: >- - gh release upload "${{ needs.release-please.outputs.tag-name }}" - dist/release/*.tar.gz dist/release/*.tar.gz.sha256 - --clobber + uses: ./.github/workflows/sea.yml + with: + tag-name: ${{ needs.release-please.outputs.tag-name }} publish-prerelease: name: Publish prerelease diff --git a/.github/workflows/sea.yml b/.github/workflows/sea.yml new file mode 100644 index 0000000..d3402ca --- /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@v6 + + - 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 From 9846b13eb4beb25f6dc9c1c8390b69c03949fc6e Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Thu, 15 Oct 2026 22:43:30 +0200 Subject: [PATCH 09/12] test(info): describe a downloaded binary rather than a Homebrew install --- src/commands/info.ts | 4 ++-- tests/info.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/info.ts b/src/commands/info.ts index 8341708..69deff5 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -85,8 +85,8 @@ 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 Homebrew install reports `unknown`, which reads - // like a defect rather than the most ordinary install there is. + // 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', diff --git a/tests/info.test.ts b/tests/info.test.ts index d66a815..3fb33c2 100644 --- a/tests/info.test.ts +++ b/tests/info.test.ts @@ -70,15 +70,15 @@ describe('info', () => { }); // `isSea()` is false under the test runner by construction, so the branch is - // reached by swapping the module rather than by building a 113 MB binary in - // the suite. The real artifact is checked by `scripts/sea-build.ts`, which + // 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('/opt/homebrew/bin/gitignore-sync'); + const build = sea.detectBuild('/usr/local/bin/gitignore-sync'); expect(build.kind).toBe('sea'); expect(build.packageRoot).toBeNull(); } finally { From c33ae40435c3bec8be374f50ad1ac223c08cf3e6 Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Thu, 15 Oct 2026 22:43:32 +0200 Subject: [PATCH 10/12] docs(adr): ship standalone binaries as Node single executables --- ...one-binaries-as-node-single-executables.md | 73 +++++++++++++++++++ docs/99.adr/index.md | 1 + 2 files changed, 74 insertions(+) create mode 100644 docs/99.adr/0010-ship-standalone-binaries-as-node-single-executables.md 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 | From 95e740b45a15e813ab54a0015717a40671217063 Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Thu, 15 Oct 2026 22:43:33 +0200 Subject: [PATCH 11/12] docs: document installing, verifying and updating the standalone binary --- AGENTS.md | 9 ++++++--- CLAUDE.md | 9 ++++++--- README.md | 19 +++++++++++-------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 835ebbd..e727c43 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,7 +249,7 @@ 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`. diff --git a/CLAUDE.md b/CLAUDE.md index 835ebbd..e727c43 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,7 +249,7 @@ 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`. diff --git a/README.md b/README.md index cb92a5e..f6fa408 100644 --- a/README.md +++ b/README.md @@ -35,20 +35,23 @@ 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 — `darwin_arm64`, `darwin_x64`, `linux_x64` and `linux_arm64`: +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 -VERSION=0.5.0 -NAME="gitignore-sync_${VERSION}_darwin_arm64.tar.gz" -curl -fsSLO "https://github.com/kirchDev/gitignore-sync/releases/download/v${VERSION}/${NAME}" -curl -fsSLO "https://github.com/kirchDev/gitignore-sync/releases/download/v${VERSION}/${NAME}.sha256" -shasum -a 256 -c "${NAME}.sha256" +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}" -./gitignore-sync info +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: drop it anywhere on your `PATH`, under either name. `gitignore-sync info` reports `single executable` so a downloaded binary is never mistaken for an installed package. +> 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 From 99d2dd3bfed03e03a4f49233dbc0a67fe59a895a Mon Sep 17 00:00:00 2001 From: Titus Kirch Date: Thu, 15 Oct 2026 22:48:48 +0200 Subject: [PATCH 12/12] ci(sea): pin pnpm/action-setup to a commit CodeQL flags a movable tag on a third-party action. GitHub-owned actions stay on major tags. --- .github/workflows/sea.yml | 2 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sea.yml b/.github/workflows/sea.yml index d3402ca..bd519ef 100644 --- a/.github/workflows/sea.yml +++ b/.github/workflows/sea.yml @@ -66,7 +66,7 @@ jobs: # build time. Empty on a pull request, which checks out its own head. ref: ${{ inputs.tag-name }} - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@v7 with: diff --git a/AGENTS.md b/AGENTS.md index e727c43..d00a740 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -253,7 +253,7 @@ The third surface is for machines without Node: every stable release carries a * - **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 e727c43..d00a740 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -253,7 +253,7 @@ The third surface is for machines without Node: every stable release carries a * - **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