How commits become releases (semantic-release), the Node single-executable-application build and its macOS x64 limitation, and how npm/GitHub Packages publishing works. Verbatim from an earlier README.md.
Every push to main runs semantic-release after check passes, via release.config.ts (a typed config, matching how this org's other semantic-release repos — e.g. graphle — configure it, rather than the untyped .releaserc.json form this project started with). It analyses every commit since the last vX.Y.Z tag using the conventionalcommits preset — the same convention this project's own commitlint config already enforces — and decides whether a release is warranted at all: a feat: commit bumps minor, fix:/perf:/revert:/refactor:/docs:/build: bump patch, a BREAKING CHANGE: footer bumps major, and anything else (chore:, ci:, test:, style:) triggers no release on its own. The changelog and release notes list every one of those types except chore: (which would otherwise include the chore(release) commits), so ci:, test: and style: commits appear under their own sections whenever a release is cut for another reason. If a release is warranted, it creates and pushes the next tag itself — exactly the step that used to be a manual git tag/git push earlier in this project's life.
release.config.ts configures four plugins, in this order: @semantic-release/commit-analyzer and @semantic-release/release-notes-generator (both conventionalcommits-preset, deciding the version and generating notes text), @semantic-release/changelog (writes CHANGELOG.md), @semantic-release/npm with npmPublish: false (bumps package.json's version field and stages it, but never touches the npm registry), and @semantic-release/git (commits CHANGELOG.md + package.json with a chore(release): X.Y.Z [skip ci] message and pushes it to main). @semantic-release/github is not loaded at all. This split is deliberate: semantic-release owns only the version decision, the tag, and the changelog — actual npm publishing (this project's own OIDC trusted-publishing job, below), GitHub Release creation (with this project's own multi-platform asset list, and notes generated by the release action, since a release only exists once its assets have been built), and the Homebrew/Scoop tap updates all remain this project's own jobs. The [skip ci] marker on the changelog commit stops it from re-triggering check/semantic-release on main.
One accepted quirk worth knowing rather than being surprised by: semantic-release creates the release tag pointing at the commit that already existed (the actual code change being released) before running @semantic-release/git's commit step — so the changelog/version-bump commit lands on main after the tag, not folded into it. Every downstream job below checks out ref: main (not the commit that triggered the run) specifically to pick up this post-release state, matching how this org's other semantic-release repos handle the identical quirk.
The six platform build jobs (build-arm64 through build-windows-arm64) also run on every pull request, not only a published release. Each has its own dedicated smoke-test-* job — a separate job, not a step inside the build job, so a smoke-test failure shows as its own line in the PR checks list, distinct from "did it build" — that downloads the just-built artifact and directly executes it (--version, --help) on a runner of the same OS+architecture, since a binary built for one architecture cannot run on another. This is what would have caught, before merge rather than after a real release, an actual regression this project shipped once: a Turbo cache key that didn't distinguish runner.arch, letting one architecture's compiled binary silently serve for another (see .github/actions/setup-and-run/action.yml's own comment). release depends on all five verified smoke-test-* jobs succeeding, not just their matching build-* jobs, so a smoke-test failure blocks the release the same way a build failure already did — smoke-test-macos-x64-best-effort is deliberately excluded from that gate, matching build-x64-best-effort's own continue-on-error: true. On a PR, each build job checks out the PR's own head commit instead of ref: main, since there's no post-release state to pick up yet. Publishing itself — npm, GitHub Packages, the GitHub Release, and the Homebrew/Scoop tap updates — stays gated on needs.semantic-release.outputs.published == 'true' alone and never runs on a PR, since those all have real, one-way side effects.
Branch/tag protection had to be disabled for this to work. main's branch-protection ruleset previously required every push go through a reviewed PR, and a separate ruleset blocked tag creation/deletion outright — both only bypassable by the Admin repository role. semantic-release's own git operations run as the workflow's default GITHUB_TOKEN, which doesn't hold that role, so both rulesets were disabled (not deleted — the rule definitions are preserved and can be re-enabled with a single API call or via the repo's Rules settings page) rather than routing around them with a separate bypass credential.
The build/publish/verify pipeline below is not a separate tag-triggered workflow run — it's later jobs in this SAME run, gated on needs: [semantic-release]. This project originally tried the opposite: let semantic-release's tag push trigger a second, tag-scoped workflow run, the way ci.yml used to be structured before this section was rewritten. That never worked — GitHub Actions never lets a GITHUB_TOKEN-authenticated push start a new workflow run, to prevent recursive loops, and (confirmed empirically, since most write-ups only discuss GITHUB_TOKEN and imply any other credential is exempt) an SSH deploy key registered on the repository hit the identical restriction. This org's other semantic-release repos (graphle, spot-of-the-day) never hit this at all, because their own post-release jobs (a Pages redeploy, a Worker deploy) are idempotent and just run unconditionally on every push to main — they never need to ask "did a release actually happen." Building five platform binaries, publishing to npm, and updating two external tap repos are not safe to run unconditionally, so this project's semantic-release job runs a plain git describe --exact-match --tags HEAD right after npx semantic-release and exposes the result as job outputs (published, version, tag) — no plugin needed, since it's just a shell check, not a hook into semantic-release's own plugin lifecycle. Every downstream job gates on needs.semantic-release.outputs.published == 'true'.
scripts/build.mts bundles src/cli.ts to a single CJS file with esbuild, writes a sea-config.json next to it, then invokes the now-stable single command node --build-sea=sea-config.json — this one step handles the bundle copy, signature removal, blob injection, and re-signing that used to require chaining --experimental-sea-config with a separate postject invocation, and postject is not a dependency of this project. On macOS the resulting binary is re-signed with an ad-hoc signature (codesign --sign -) afterwards, since blob injection invalidates the original one. --build-sea requires Node ≥ v25.5.0, the version it stabilised in; the build script checks the running Node version up front and refuses with a clear error rather than failing deep inside the SEA step if it's older.
One gotcha worth knowing before reaching for this: Homebrew's macOS Node build has the single-executable-application feature compiled out. Running the build against a Homebrew-installed Node fails partway through with "Single executable application is disabled" — scripts/build.mts detects this specific error and rewrites it into an explanation naming the cause, rather than leaving a contributor to debug an opaque native error. Use a Node binary from a distribution that ships SEA support instead — the official nodejs.org build, or a version manager installing upstream builds (mise, nvm, volta, fnm) — ahead of Homebrew's on PATH.
macOS SEA support is tested and verified upstream on arm64 only — x64 is explicitly unsupported and skipped in Node core's own test suite. CI builds and publishes the arm64 binary as the verified release artefact; it also attempts an x64 build as a clearly-labelled best-effort convenience (allowed to fail without blocking the release, and published as agent-shim-macos-x64-unverified when it succeeds), never presented as a supported target. This isn't just a theoretical caveat: v0.2.7 confirmed agent-shim --version itself segfaults on real x64 macOS hardware, a crash smoke-test-macos-x64-best-effort has silently swallowed via || true until a dedicated install-and-run verify job (also best-effort, non-blocking) finally exercised the binary for real.
Root cause, confirmed rather than assumed. Reproduced locally under Rosetta with 100% fidelity to CI: lldb shows EXC_BAD_ACCESS inside __cxx_global_var_init, invoked by dyld while running C++ static initializers — before main() ever executes, with rdi (the faulting access) holding the literal value 0x2. A trivial one-line console.log(...) script built through the exact same --build-sea + ad-hoc-codesign steps crashes identically, which rules out anything in agent-shim's own bundle, build script, or code — this is --build-sea itself misbehaving on x64 macOS. This is a known, tracked, and deliberately unfixed upstream limitation: nodejs/node#62893 reproduces the identical crash and was closed as documentation-only (nodejs/node#63181), with a Node core maintainer stating SEA on x64 macOS "is not supported and skipped in the tests... until someone volunteers to implement support for it." The deeper investigation thread (nodejs/node#59553) floats an unconfirmed theory — that postject's LIEF-based Mach-O binary injection corrupts the executable such that dyld misidentifies a segment as an oversized (>4GB) thread-local-storage region — but that thread closed stale, unfixed, with the same maintainer concluding it's "unlikely that anyone would invest time in fixing it for macOS" given x64 macOS's Tier 2 deprioritisation upstream. There is no available workaround (no alternate codesign invocation, sea-config.json option, or Node flag) — the fault is inside --build-sea's own binary-injection step, before any code this project controls runs at all.
Homebrew works around this by not using the SEA binary at all on this one architecture. Since there's no fix available for the binary itself, update-tap's generated formula gives macOS x64 a genuinely different install path: on_intel do ... depends_on "node" end nested under on_macos, pointing at the npm registry tarball (https://registry.npmjs.org/agent-shim/-/agent-shim-<version>.tgz) instead of the GitHub Release SEA asset, and def install branches on OS.mac? && Hardware::CPU.intel? to run system "npm", "install", *std_npm_args (Homebrew's own documented Node-formula pattern — see the Formula Cookbook and Language-Specific Formulae docs) followed by bin.install_symlink libexec.glob("bin/*"), rather than downloading and bin.install-ing a binary. depends_on "node" inside an on_intel block is legal precisely because it's a metadata declaration Homebrew resolves at build-spec time, not runtime logic — the actual branch deciding which install steps run lives in def install using OS.mac?/Hardware::CPU.intel?, exactly as the Cookbook prescribes. This means brew install ExaDev/agent-shim/agent-shim gives macOS x64 users a genuinely working agent-shim (the same code the npm channel already publishes and verifies), unlike the raw GitHub Release binary on that architecture, which still ships the best-effort, currently-broken SEA binary described above.
install.sh takes the same workaround, since it can express the equivalent of depends_on "node" itself (a POSIX shell script, unlike a Homebrew formula, has no package manager underneath it to declare dependencies to): on macOS x64 specifically, it checks for npm on PATH before anything else, and — if present — installs into a scratch npm prefix and copies the resulting bin/agent-shim script into place, exactly matching where the binary-download path would have put it, rather than downloading the broken SEA asset at all. If npm isn't available, it exits with a clear error pointing at installing Node.js first or using Homebrew instead (which resolves this automatically via its own node dependency), rather than silently installing something broken.
Windows and Linux carry no such carve-out — Node's own SEA documentation tests both regularly across every architecture it supports on those platforms (x64 and arm64 alike), so CI builds and verifies all four of those binaries as fully supported release artefacts, same as macOS arm64.
scripts/build.mts --bundle-only (aliased as pnpm build:bundle, and run automatically by prepublishOnly) stops after the esbuild step and skips every SEA-specific one — the npm-installed package has to run under whatever Node the installer already has, not a platform-specific binary with its own embedded runtime, so it needs the plain bundle rather than the SEA output. The esbuild target for this bundle is a fixed node22, not tied to whichever Node version happens to run the build: cosmiconfig@10's own engines.node (^22.18 || >=24) is the strictest floor among this project's runtime dependencies, so that's the real minimum regardless, and package.json's own engines field states it explicitly.
The release workflow's publish-npm job builds this bundle and publishes it as the agent-shim package using npm's OIDC trusted publishing — the same pattern this org's other published packages already use: id-token: write permission, registry-url deliberately left off actions/setup-node (setting it makes setup-node write an .npmrc _authToken line that would shadow the OIDC exchange), and NODE_AUTH_TOKEN explicitly blanked rather than omitted so nothing inherited shadows it either. Trusted publishing itself has to be configured once, out of band, directly on the package's npmjs.com settings page (linking this exact GitHub repository and workflow file as an authorised publisher) — that's a one-time manual step on npmjs.com, not something any workflow file can set up on its own.
The published JSON Schemas under schema/ should self-reference (and, if ever submitted to a public schema catalog, be registered) via a version-pinned GitHub Release asset URL — releases/download/<tag>/<file> — never a live branch reference, which silently changes underneath every consumer on every push with no way to pin a version. This is deliberately a different URL form from installing the binaries's own releases/latest/download/...: the installer wants the newest release every time, but a schema an editor references long-term needs to stay stable at whatever version a given config file was written against, not shift underfoot on every future release.
The same build is also published under the project's former name, claude-use, by its own publish-npm-alias job: identical apart from name, and both packages expose the agent-shim and claude-use commands. It is a separate job so that a failure there (trusted publishing is registered per package, so claude-use needs its own registration for this repository and ci.yml) is reported on its own and does not hold back the tap, bucket and verification jobs.
The same build is also published as a second, scoped alias, @exadev/agent-shim, and under the former name as @exadev/claude-use, to GitHub Packages (npm.pkg.github.com) by its own publish-github-packages job — useful for anyone with an org-scoped registry configured who would rather never touch npmjs.com. This is a genuinely separate job rather than an extra step in publish-npm above: GitHub Packages has no OIDC trusted-publishing exchange, and pnpm/npm attempt that exchange whenever a job holds id-token: write regardless of which registry a given step actually targets, so a shared job fails the GitHub Packages leg with a 401. publish-github-packages therefore never requests id-token: write at all, authenticating instead with a plain packages: write-scoped GITHUB_TOKEN — no separate secret needed. Because GitHub Packages requires a scoped name, the job rewrites the checked-out package.json's name and publishConfig.registry in place with npm pkg set (the latter has to be set explicitly since it otherwise takes precedence over the .npmrc registry-url actions/setup-node wrote, silently sending the publish back to npmjs.org) rather than maintaining a second package.json that could drift from the real one.