ADR-0055 Architecture decision record
A release is a pushed tag, and the publish guard is the credential
ADR-0043 made the accidental-publish guard a manifest line — publishConfig.registry: http://localhost:4873 — and named the edit of that line the "we are ready" commit. Preparing the first real publish (2026-09-20) found the guard has no exit: publishConfig outranks --registry by design, so the edit that lets the kit reach npmjs is the same edit that breaks every reader of the guard at once — packages/cli/test/publish.test.ts asserts a localhost registry, scripts/dist-publish.sh refuses any other, and the @dist fixture publishes to whatever the manifest names. After the flip the local loop cannot publish at all, and flipping it back per release keeps the unit gate red for the length of the release. The property that made the guard strong is the property that makes it unreleasable. This ADR amends ADR-0043's guard and settles the rest of the release: version, images, gate, trigger.
Decision
- The guard is the credential, not the manifest.
publishConfig.registryleaves every manifest. The only holder of an npmjs credential is the release job, and it holds no token: it publishes with npm trusted publishing (OIDC from GitHub Actions), so nothing long-lived exists to leak. Apnpm -r publishon a dev box reaches npmjs and failsENEEDAUTH, which is the guard — an absence that cannot be edited past, where the manifest line was a presence that had to be.publishConfig.access: publicstays on all four, because a scoped package's first publish is 402 without it.scripts/dist-publish.shpasses--registryagain, andpublish.test.tsinverts: it asserts that no public manifest names a registry, so the old guard cannot be smuggled back in one package and silently pin a release to localhost. - Lockstep, and
maincarries the next version. One number acrosspackages/*andtemplates/default, because ADR-0019's one release train already reads a singleKIT_VERSION, ADR-0043's scaffold pins two packages at one exact number, andworkspace:*packs to an exact inter-dependency — independent versions would need ranges, and 0.x ranges break.0.0.0stops being special: it was the ephemeral registry's value only because nothing was released, and a fresh verdaccio publishes any version cleanly.just release patch|minor|major(or an explicit version) computes the number the waynpm versiondoes, writes it to every manifest and the template, runs the unit gate (init.test.tsis the tripwire), commits, and tagsv<ver>. Nothing rewrites a version at publish time: a loop that edits manifests tests edited manifests (ADR-0043), and a checkoutKIT_VERSIONmust read the number the checkout will ship. - The tag push is the release, and the train runs in order.
release.ymlruns onv*. Its first job refuses if the tag disagrees with any manifest, and then asks every other refusal of the publish (scripts/publish.sh --stage --check: the tree, the ref, the strict docs build, no output block not yet run). These take seconds, so a tag that cannot publish starts no tier. Then the full gate, as parallel jobs that each need that first one — unit, harness conformance, the default e2e profile, the docs site build and the Custodian suite in one job;@kindas four shards, each on a kind cluster its own runner creates (the operator's own e2e job already works this way); and@diston one more — so the installed path is proven on the tree that publishes, not on a maintainer's box the day before. The tiers are jobs and not steps of one job because one runner ran them in series: v0.5.0's job spent 37 minutes in@kindalone, two workers being the most a 4-vCPU runner holds, and then failed at a refusal of the publish that takes a second. Only when every tier job passes does the one job that holds the rights run:scripts/kit-push.sh ghcr.io/snapwich(multi-arch,GITHUB_TOKENwithpackages: write), and only then npm: each public package is packed with pnpm, which rewritesworkspace:*to the exact version, and staged withnpm stage publish, the client that speaks trusted publishing — in pnpm's topological order, dependencies first, skipping a version npm already holds live so a re-run converges. The order is the contract: a package whose images are not at the home fails at pull on the user's firstjr2 up(ADR-0044); images with no package are inert, and stay so while the packages sit staged. A second job,pages, follows the release job and deploys the docs site of the same tree to GitHub Pages (ADR-0072). It does not wait for the npm approval, so the site can be ahead of the live packages until the maintainer approves them. - The job stages; the maintainer approves. Each trusted publisher is configured for
npm stage publishonly, so the tag produces four staged tarballs and one 2FA approval per package (npm stage approve, dependencies first) makes them live. This is the one human act a release keeps, and it is kept on purpose: the job's identity is bound to the repository and the workflow file, so a stolen GitHub session or a bad edit to that file could otherwise publish under the project's name; staged, it can stage and nothing more. It is not a runbook step — nothing has to be remembered in order, the staged tarballs wait. - Two executors of one train.
scripts/publish.shis the irreversible half — images, then packages, dependencies first, live versions skipped — and the job runs it with--stage. A maintainer runs it asjust publishfrom the pushed tag when a 0.x release cannot wait the job's hour:npm publishwith a 2FA prompt per package in place of an approval, and the job converges behind it. The checks are the same in both (lockstep, clean tree, HEAD is the pushed tag), and so is the guard: a login alone publishes nothing under write-2FA, so a dev box holds no credential that publishes by itself. ci.ymlon push and PR is the light gate: frozen install, typecheck, format check,pnpm -r test, the default Cucumber profile — minutes, no docker. The heavy tiers and the Custodian suite run on the tag and by hand (just e2e-kind,just e2e-dist,just custodian-test) when a change touches build, deploy, or dist code.@jr2/machinespeers on@jr2/orchestratorexactly.workspace:*stays; a Machine is written against one orchestrator, and a mismatched pair fails at install with a peer error instead of at run.engines.node: ">=24"on every public package and the root. Every runtime the kit runs in is Node 24 already (both Kit Dockerfiles, the instance Dockerfile, theregisterHooksbin); the root's>=22was the straggler.
Considered options
- Keep the localhost line; the release job rewrites it before publish. Rejected by ADR-0043's own rule — the published manifest would be one no tier tested.
- Point the manifests at npmjs; the dist loop rewrites them to localhost. The same objection inverted: the tier that proves the tarballs would test a manifest the release never ships.
- A
prepublishOnlyguard (refuse unless localhost orJR2_RELEASE=1). In-tree, but it rests on pnpm exposing the effective registry to lifecycle scripts, and a one-variable bypass is a guard shaped like a suggestion. - CI stamps the version from the tag;
mainstays0.0.0. Rejected twice: edited manifests, and a checkout'sKIT_VERSIONwould read0.0.0forever. - Changesets in fixed mode. The right tool once there are contributors and changelogs; today it is a version PR in front of a five-file edit. The contract it would implement — tag equals manifests, lockstep — is the one chosen here, so adopting it later changes no ADR.
workflow_dispatchwith a version input. CI would make the bump commit, so the gate runs on a tree the maintainer never saw at that version.- Manual
just kit-pushbefore tagging. A runbook step in front of a release (ADR-0019 calls that an interface failure), and nothing checks the images exist before npm publish.
Consequences
- ADR-0043's "What publishes, and the guard" is amended: the guard is credential absence; the "we are ready" commit is no longer an edit, it is the first pushed tag. Its consequence "localhost
publishConfig… is the real shipping configuration" no longer holds; the rest of that list does. - Bootstrap, once: create the
jr2org on npmjs (a scope is a user or an org), publish the first version by hand with a token — npm attaches a trusted publisher only to a package that already exists — then configure the trusted publisher on all four packages and revoke the token. After that no token that publishes on its own exists anywhere: the job's identity stages, a maintainer's login needs its second factor. Likewise once: make the three GHCR packages public after the first push, because GHCR creates them private. publish.test.tsasserts no registry,access: public, andengines;dist-publish.shdrops its manifest read-back and passes--registry; the@distfixture takes its registry fromJR2_DIST_PORT's default rather than the manifest;kit-push.sh's cli-vs-orchestrator cross-check widens to every manifest;just releaseis new;.github/workflows/gainsci.ymlandrelease.yml.- A release costs a runner kind cluster and roughly ten minutes of docker. Accepted: it is the only time the installed path must be proven on the exact tree that ships.
- The four tarballs gain the npm-page fields (
description,repositorywithdirectory,homepage,bugs,keywords) and a README each; the root README's "only scaffolding exists" line goes.