Skip to content

fix(release): release from the package changelog under repository storage - #22

Merged
kiro-systemf[bot] merged 1 commit into
mainfrom
prm/repository-changelogs
Oct 7, 2026
Merged

kiro-systemf[bot] merged 1 commit into
mainfrom
prm/repository-changelogs

Conversation

@systemfsoftware-maker

@systemfsoftware-maker systemfsoftware-maker commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

The GitHub Release step built every release body from the parked file <changelogDir>/<pkg>@<version>.md. Under pnpm's versioning.changelog.storage: repository nothing is parked there: pnpm writes each section into the package's own CHANGELOG.md. stryker-js-effect moved to repository storage in systemfsoftware/stryker-js-effect#196, so its next release would have failed at that step.

What changes

  • WorkspaceStore gains changelogStorage(). The live adapter asks pnpm (pnpm config get versioning.changelog.storage); unset means registry, pnpm's default. A failing config get refuses with ManifestInvalid on pnpm-workspace.yaml, carrying pnpm's own message.
  • Under repository, the cycle (plan, tag and release) points each entry at <member dir>/CHANGELOG.md, and the release body is that file's ## <version> section, up to the next ## heading.
  • Under registry, nothing changes: entries keep the parked path, and the body is the whole parked file.
  • A changelog without the version's section refuses the release with ReleaseChangelogMissing, naming the package, version and file. The body is never empty.

Tests (packages/github-release-engine/tests/release.integration.test.ts)

  • Registry storage: the existing "Missing releases are created" scenario still takes the body from the parked file.
  • Repository storage: the body is exactly the version's section (an older section in the same file is excluded); a changelog missing the section refuses with ReleaseChangelogMissing, and nothing is created; the captured cycle names packages/alpha/CHANGELOG.md.
  • Sabotage, run locally: returning the whole file under repository storage fails both body scenarios, and returning the parked path fails all three repository scenarios.

Proof

  • pnpm check:ci exits 0.
  • The built github-release-management binary, run on throwaway workspaces, captures packages/alpha/CHANGELOG.md under repository storage and .changeset/changelogs/alpha@1.1.0.md when the setting is unset; a misspelt value exits 1.
  • In stryker-js-effect, release --assert on a captured cycle passes @systemfsoftware/stryker-js@17.0.2 and refuses a 99.0.0 entry with Missing changelog for @systemfsoftware/stryker-js@99.0.0: expected packages/stryker-js/CHANGELOG.md ….

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…rage

The GitHub Release step built every release body from the parked file
<changelogDir>/<pkg>@<version>.md. Under pnpm's
versioning.changelog.storage: repository nothing is parked there: pnpm
writes each section into the package's own CHANGELOG.md. A consumer on
repository storage, stryker-js-effect, would have failed its next release
at that step.

WorkspaceStore gains changelogStorage(), which asks pnpm (pnpm config get
versioning.changelog.storage; unset means registry). Under repository
storage the cycle points each entry at <member dir>/CHANGELOG.md, and the
release body is that file's "## <version>" section up to the next "## "
heading. A changelog without that section refuses the release with
ReleaseChangelogMissing naming the package, version and file, never an
empty body. Registry storage keeps the parked path and the whole file as
the body
@kiro-systemf
kiro-systemf Bot merged commit 5432b8b into main Oct 7, 2026
2 checks passed
systemfsoftware-maker added a commit that referenced this pull request Oct 7, 2026
…log storage

Merging main's #22 into this layer left two gaps: #22's repository-storage tag scenario built its request without the tarballs this layer requires, and main's process-adapter git fake lacked tagAnnotation. The digest read becomes a const
systemfsoftware-maker added a commit that referenced this pull request Oct 7, 2026
…ports

Main's #22 added changelogStorage to WorkspaceStore and a repository-storage tag scenario; this layer's tag needs the ledger and its GitPort reads tag commits and trees, so the merged fakes and scenario now provide them
kiro-systemf Bot pushed a commit that referenced this pull request Oct 7, 2026
* fix(version): refuse target suffixes that repeat

Each distribution target's suffix names its platform package
(<name>-<suffix>), and sync-root writes those names as optionalDependencies
keys. Two targets sharing a suffix collapsed into one key, so the manifest got
one pin while the decision reported two. CI found it as the property
counterexample ["a","0.0.0",["0","0"],false] (seed 1467794903).

release.jsonc decoding now refuses a distribution whose targets repeat a
suffix, naming it (target suffix "x" is declared by more than one target),
which ReleaseConfigStoreLive reports as ConfigMalformed. PinRootManifestCommand
refuses repeated suffixes and pin names too, and the cell builds the command
by decoding, so a collision is a typed SchemaError instead of a thrown
constructor. Both filters carry generation metadata: unique for suffix lists,
and a uniqueArray-by-suffix candidate for targets.

RepinsAll and RepinIdempotent keep their unrestricted draws and now state both
sides: distinct suffixes repin every target, colliding ones are refused. A
named scenario replays ["0","0"]. With the command checks removed, both laws
fail on the CI seed and the scenario fails

* test(repo): replay a fast-check seed from FC_SEED and FC_PATH

fast-check prints the seed and path of a failing property, but nothing read
them back, so a CI counterexample could not be replayed locally: FC_SEED was
ignored. vitest.fast-check.setup.ts sets fast-check's global seed from FC_SEED
(and the shrink path from FC_PATH), and every package with property tests
loads it. turbo hashes both variables for the test task, so a cached run never
stands in for a different seed.

FC_SEED=1467794903 FC_PATH=10:0:0:0:0 reproduces CI's ["a","0.0.0",["0","0"],false]
exactly against the unfixed command schema, while the same run without the
variables passes

* feat(nix): distribute workspace packages through Nix and sandbox dependency code

Packaging (`lib.mkPnpmWorkspacePackages`): one call in a flake gives each
public workspace member as `packages.<system>.<name>`, a deterministic
`pnpm pack` tarball built in the Nix sandbox, plus `workspace-tarballs`
(every tarball and an index.json) and `pnpm-store`. Third-party
dependencies come only from nixpkgs' `fetchPnpmDeps` (fetcherVersion 4),
keyed by the lockfile hash; no registry at build time. The library refuses a
workspace whose `packageManager` pins a different pnpm than the build uses.

- the tarballs rebuild bit-for-bit (`nix build --rebuild`). Three inferred
  exports in version-engine printed unions whose member order TypeScript 7
  varies from run to run (microsoft/TypeScript#64589); they are annotated with
  named types
- the CLI apps share the library's dependency fetch; the pinned hash in
  nix/cli-app.nix was stale and only a local store cache had hidden it
- packageManager pnpm@11.25.0, the version Nix provides

Sandbox (`packages.<system>.sandbox`): bubblewrap on Linux (--unshare-all,
--cap-drop ALL, --die-with-parent, --new-session, cleared environment), and
a deny-by-default sandbox-exec profile on macOS. The project directory is
read-write, /nix/store read-only, and $HOME a fresh tmpfs. Networking is
loopback only; `--allow-host` opens HTTPS to declared hosts through an
allow-list CONNECT proxy outside the sandbox. `--pnpm-store` (the dev shell
default) makes pnpm resolve offline from the Nix-built store with
frozen-lockfile and ignore-scripts.

`packages.<system>.sandbox-proofs` gates it. Each refusal proof prints from
inside the same sandbox first, so a sandbox that never starts fails instead
of passing. CI runs the proofs on Linux and macOS, then installs, builds and
tests this repository as three separate sandbox invocations with no network
at all, and checks that the tarballs rebuild bit-for-bit

* build(nix): evaluate the sandbox and the dev shell on every supported system

The macOS CI leg could not enter the dev shell: the sandbox script was read
back with builtins.readFile from a replaceVars output, an import from
derivation that forced a build at evaluation time. The substitution is now a
string replacement at evaluation. The dev shell takes the bubblewrap-wrapped
comment-checker only on Linux, where bubblewrap exists.

nixpkgs 26.11 dropped x86_64-darwin, so the flake no longer lists it, and the
dprint and denort release tables lose their x86_64-darwin pins

* build(nix): leave --ignore-scripts to pnpmConfigHook

pnpmConfigHook already installs with --ignore-scripts. pnpm 12 refuses the
flag twice, so the library adding it again broke every pnpm 12 workspace

* feat(nix): closure-only store, egress log, published and listen ports

The sandbox exposed the whole of /nix/store: bubblewrap bound it read-only
and the macOS profile allowed the subpath, so dependency code could list
the store and read any path in it. The launcher now resolves the
invocation's closure (nix-store --query --requisites over the sandboxed
PATH, the command, the pnpm store and its own helpers) and exposes only
those paths. On Linux they are bound over an empty /nix/store that cannot
be listed; on macOS a per-invocation profile grants file-read* and
file-map-executable on each one, plus file-read-metadata on the literal
/nix and /nix/store, which Node needs because it lstats every path
component.

- --egress-log PATH appends one JSONL line per proxy decision, allowed or
  refused, with host, port and the matched rule. A path inside the
  project, the sandbox HOME or its TMP is refused
- --publish HOST:SANDBOX exposes a sandbox port on host loopback; on Linux
  only published ports reach the host
- --listen PORT declares an internal port. macOS has no network namespace,
  so its profile allows binds only on --publish and --listen ports (port 0
  included) and names --listen when a bind was refused. A --listen port on
  macOS is reachable from host loopback, a stated platform limit
- pnpm fetches the optional binaries of every system the flake builds, so
  one dependency hash holds on all of them

The proofs cover each boundary and fail against a pass-through sandbox

* test(release): prove the release never publishes to npm

The npm retirement removed verdaccio from the harness along with the
publish phase, so nothing proved the pipeline stays off the registry.
verdaccio is back behind the redirected registry.npmjs.org as a tripwire:
it accepts publishes (publish: $all), so a regression that publishes lands
there instead of failing on auth, and it logs every request. The last
phase pings it and waits for that request in the log, so a tripwire that
logs nothing cannot pass, then asserts zero PUT requests and an empty
storage

* build(nix): give macOS sandbox runs a private XDG_RUNTIME_DIR

pnpm 12 takes its store-operation lock under XDG_RUNTIME_DIR when that points at
a directory only the user can write to, and otherwise falls back to /tmp, which
the deny-by-default darwin profile refuses, so an offline install inside the
sandbox fails on macOS. The darwin branch now creates a 0700 runtime dir inside
the run's remapped TMP and exports XDG_RUNTIME_DIR to it, so the lock lands in a
writable private path and darwin.sb keeps its write roots unchanged

The proof runs the pnpm that locks there: nixpkgs moves to aa48d34, whose
pnpm_12 builds the tiny store and lands on the proofs' PATH. proofs.sh asserts
that version, installs a tiny workspace offline from the --pnpm-store store, and
proves a real-/tmp write is refused on macOS (a private tmpfs on Linux) and
leaves nothing in the host /tmp. On macOS it repeats the install with
XDG_RUNTIME_DIR removed inside the sandbox and requires
ERR_PNPM_STORE_DIR_OPEN_OPERATION_LOCK, on every CI run. pnpm 12 is a native
binary per platform, so the tiny store's fixed-output hash is chosen by system.
The bump also re-pins denort to the deno 2.9.7 zips and moves the pinned pnpm to
11.27.0 in package.json and the e2e image. README documents the runtime dir and
the proofs; AGENTS.md records the approved rule that a launcher change is done
only when its darwin path ran a real offline pnpm install in CI

Without a sandbox (the macOS default and the dev container) nix builds with
HOME=/homeless-shelter, the real host path, and the locked comment-checker ran
cargo with that HOME, creating /homeless-shelter/.cargo so every later build
failed nix's purity check. comment-checker 3bfedd8 exports HOME under $TMPDIR
before cargo runs, and the sandbox workflow fails on both systems if any build
leaves /homeless-shelter behind

turbo.json passes TMPDIR through: strict env mode dropped it, so inside the
macOS sandbox rolldown-plugin-dts failed with EPERM on
mkdtemp('/tmp/rolldown-plugin-dts-*'). TMPDIR names where scratch goes, never
what a task outputs, so cache keys are unchanged

* build(nix): stop guessing a refused macOS bind from the unified log

The darwin launcher printed a --listen hint after a failed run when
`log show` found the kernel's `deny(1) network-bind` record. The kernel
hands that record to logd asynchronously, so a read right after the command
exits sometimes ran before it landed: the proof "an undeclared macOS bind
is refused and names --listen" failed once in sfs-xstate and passed on a
re-run. No wait can prove a record will never come, so the hint is gone.

The refusal itself is unchanged and is what the proof now asserts: the
program's own listen fails with EPERM. Linux has no hint to keep; a bind
there succeeds in the sandbox's private network namespace

* feat(release): refuse a released version whose tarball changed

A released name@version is immutable: a consumer's lockfile pins its
integrity, so the same version must never pack different bytes. `tag` now
creates annotated tags whose message records the tarball's sha512 and a
sha512 per file inside it. `plan` checks every member whose current
version already has a release tag, except the members the pending
changesets plan moves: it reads the annotation from the remote, recomputes
the digest from the packed tarball and refuses a mismatch, naming
package@version, both hashes and the first differing file in sorted order
(a file on one side only counts, so a dependency-range change surfaces as
`package/package.json`). A lightweight tag or an unreadable annotation is
refused too.

`plan` and `tag` take a required `--tarballs DIR` (Nix `workspace-tarballs`
or `pnpm pack` output). A new tarball-adapter reads gzip and tar headers
with node:zlib and node:crypto; the decision is a pure workflow with
property laws.

The e2e pipeline releases the current state, versions alpha minor+patch,
asserts beta's packed dependency moves, re-packs the earlier release to
match its recorded digest, and shows a reverted manifest refused

* feat(version): bump a Cargo workspace as a release surface

A `cargo` surface ({ kind: "cargo", path: "Cargo.toml", package? }) keeps a
Rust workspace in step with the release. A bump rewrites the
[workspace.package] version in place, every member that pins a literal
[package] version, and the workspace-member entries in the sibling
Cargo.lock, so `cargo build --locked` still passes after the bump. Registry
and git entries are never touched. `version sync check` reports a member or
lock entry that lags.

Under the pnpm strategy, `package` names the workspace member whose bumped
version the Cargo workspace follows. Omitting it, naming a non-member, or
starting from a stale lock entry refuses the bump.

The e2e pipeline gains a phase that bumps a Cargo workspace beside the pnpm
packages and checks the lock

* feat(version): version each package through the changesets libraries

The `pnpm` strategy ran `pnpm version -r`, which takes its versions from the
npm registry: with npm retired, an intent on an unpublished package left it
at its old version, later intents folded into the same changelog, and
consumed intents were never deleted, so the plan asked for a version step on
every push.

The `changesets` strategy replaces it. A new changesets-adapter drives
@changesets/read, assemble-release-plan and apply-release-plan (exact pins)
offline: the highest intent per package wins, workspace dependents are bumped
and their ranges updated, private members version too (a cargo surface can
follow a private version carrier), and consumed intents are deleted in the
release PR. prm still writes one changelog per released member. Released
state is the git tag `<name>@v<version>`, so an untagged manifest version is
what the plan releases.

Workspace dependents always bump (`updateInternalDependents: 'always'`). A
bare `workspace:^` packs as `^<current version>`, so any version change in a
dependency changes the dependent's packed bytes. Out-of-range bumping would
ship those bytes under an already released name@version.

The apps now bundle builtins as `node:` imports (tsdown nodeProtocol) and
prefer a dependency's ESM `module` entry over its `main` (jsonc-parser's
`main` is a UMD build whose `require("./impl/format")` survives bundling);
`deno compile` needs both once the changesets dependencies are bundled. The
pnpm dependency hash moves with the lockfile.

The e2e pipeline gains a phase: minor and patch intents on one package, its
dependent bumped, intents removed, tags and releases created, and the next
plan settles on none

* fix(release): check tarball identity on publishable members only

A private workspace member is never packed, so a tagged private carrier
(systemfsoftware's @systemfsoftware/gritlint@0.1.0) has no tarball and was
refused as tarball-missing. Identity candidates now come from a pure
identityCandidates helper that keeps only publishable, tagged members — the
same notion the cycle already uses. Property laws pin the selection and the
private exclusion; an integration case proves a tagged private member with
no tarball does not refuse the plan

* build(nix): reap the darwin sandbox's process group on every exit

On macOS the launcher ran the sandboxed command and its --publish
forwarders as plain children. Nothing like bwrap's --die-with-parent exists
there, so a server the command started kept running after the launcher was
killed. It held the step's stdout open, and the "--publish port answers"
proof hung until the job's 30-minute cap, which GitHub reports as cancelled.

The darwin branch now starts the forwarders and the sandboxed command in a
single process group (set -m). On every exit, including HUP, INT and TERM,
the EXIT trap kills that whole group. Without a terminal the launcher waits
on the group, so a signal runs the trap at once. On a terminal the group
takes the foreground with fg, so the command still reads input and gets
Ctrl-C. Job control also leaves the command's stdin alone; a background job
without it reads /dev/null.

The macOS proofs now bound each launcher run with timeout --foreground, and
a new proof asserts that once the --publish launcher is killed, neither the
server nor the forwarder answers

* build(nix): name the outcome of the macOS bind proof

The proof that an undeclared bind is refused never exited on macos-26: its
node server only had an error handler, so a bind the kernel did not refuse
listened until the launcher timeout and the failure said nothing. The node
program now exits with its outcome (3 EPERM, 4 listening, 5 no outcome in
10 seconds, 6 any other error), and a failure prints that code with the
output, so the run itself says which one happened

* fix(release): refuse integrity verification with nothing to check

verifyIntegrity passed an empty list of checks, and passed an annotation
whose files map was empty, so either case let a tag through with nothing
verified. Both are now refusals: IntegrityNothingToVerify, and
IntegrityFilesEmpty naming the package, version and empty side. With both
empty cases refusing, verifyIntegrity no longer chooses between decisions,
so it is a plain function in integrity.ts instead of a Workflow. plan still
skips verification when no tagged candidate needs it, which keeps a fresh
repository plannable.

The property suite pinned the old Workflow cases and is gone. Four plan
scenarios cover the behaviour: empty files refuse, zero checks refuse, a
matching tarball verifies, and a changed one refuses naming the file

* build(nix): refuse undeclared loopback listeners on macOS

On macos-26 an undeclared bind to 127.0.0.1 succeeded inside the sandbox:
the bind proof's server reported listening (exit 4) instead of EPERM, and
without an outcome code it listened until the 30-minute job cap. The
profile granted network-inbound on every localhost port, and network-bind
only on declared ones. Inbound now has the same declared-port list as
bind, so a port the command did not declare with --listen or --publish
cannot serve

* fix(release): carry the tarball integrity check through main's changelog storage

Merging main's #22 into this layer left two gaps: #22's repository-storage tag scenario built its request without the tarballs this layer requires, and main's process-adapter git fake lacked tagAnnotation. The digest read becomes a const

* fix(release): read tarball digests only when a tagged package needs checking

Effect 4 has no Effect.if, so the conditional read is a small function with an early return

* chore(version): name the packages the changesets versioning layer moves in an intent

* docs(version): keep one cargo surface paragraph, under changesets versioning

* chore(release): name the packages the tarball identity layer moves in an intent

---------

Co-authored-by: systemfsoftware-maker <drdgvhbh@gmail.com>
kiro-systemf Bot pushed a commit that referenced this pull request Oct 7, 2026
…ut (#11)

* fix(version): refuse target suffixes that repeat

Each distribution target's suffix names its platform package
(<name>-<suffix>), and sync-root writes those names as optionalDependencies
keys. Two targets sharing a suffix collapsed into one key, so the manifest got
one pin while the decision reported two. CI found it as the property
counterexample ["a","0.0.0",["0","0"],false] (seed 1467794903).

release.jsonc decoding now refuses a distribution whose targets repeat a
suffix, naming it (target suffix "x" is declared by more than one target),
which ReleaseConfigStoreLive reports as ConfigMalformed. PinRootManifestCommand
refuses repeated suffixes and pin names too, and the cell builds the command
by decoding, so a collision is a typed SchemaError instead of a thrown
constructor. Both filters carry generation metadata: unique for suffix lists,
and a uniqueArray-by-suffix candidate for targets.

RepinsAll and RepinIdempotent keep their unrestricted draws and now state both
sides: distinct suffixes repin every target, colliding ones are refused. A
named scenario replays ["0","0"]. With the command checks removed, both laws
fail on the CI seed and the scenario fails

* test(repo): replay a fast-check seed from FC_SEED and FC_PATH

fast-check prints the seed and path of a failing property, but nothing read
them back, so a CI counterexample could not be replayed locally: FC_SEED was
ignored. vitest.fast-check.setup.ts sets fast-check's global seed from FC_SEED
(and the shrink path from FC_PATH), and every package with property tests
loads it. turbo hashes both variables for the test task, so a cached run never
stands in for a different seed.

FC_SEED=1467794903 FC_PATH=10:0:0:0:0 reproduces CI's ["a","0.0.0",["0","0"],false]
exactly against the unfixed command schema, while the same run without the
variables passes

* feat(nix): distribute workspace packages through Nix and sandbox dependency code

Packaging (`lib.mkPnpmWorkspacePackages`): one call in a flake gives each
public workspace member as `packages.<system>.<name>`, a deterministic
`pnpm pack` tarball built in the Nix sandbox, plus `workspace-tarballs`
(every tarball and an index.json) and `pnpm-store`. Third-party
dependencies come only from nixpkgs' `fetchPnpmDeps` (fetcherVersion 4),
keyed by the lockfile hash; no registry at build time. The library refuses a
workspace whose `packageManager` pins a different pnpm than the build uses.

- the tarballs rebuild bit-for-bit (`nix build --rebuild`). Three inferred
  exports in version-engine printed unions whose member order TypeScript 7
  varies from run to run (microsoft/TypeScript#64589); they are annotated with
  named types
- the CLI apps share the library's dependency fetch; the pinned hash in
  nix/cli-app.nix was stale and only a local store cache had hidden it
- packageManager pnpm@11.25.0, the version Nix provides

Sandbox (`packages.<system>.sandbox`): bubblewrap on Linux (--unshare-all,
--cap-drop ALL, --die-with-parent, --new-session, cleared environment), and
a deny-by-default sandbox-exec profile on macOS. The project directory is
read-write, /nix/store read-only, and $HOME a fresh tmpfs. Networking is
loopback only; `--allow-host` opens HTTPS to declared hosts through an
allow-list CONNECT proxy outside the sandbox. `--pnpm-store` (the dev shell
default) makes pnpm resolve offline from the Nix-built store with
frozen-lockfile and ignore-scripts.

`packages.<system>.sandbox-proofs` gates it. Each refusal proof prints from
inside the same sandbox first, so a sandbox that never starts fails instead
of passing. CI runs the proofs on Linux and macOS, then installs, builds and
tests this repository as three separate sandbox invocations with no network
at all, and checks that the tarballs rebuild bit-for-bit

* build(nix): evaluate the sandbox and the dev shell on every supported system

The macOS CI leg could not enter the dev shell: the sandbox script was read
back with builtins.readFile from a replaceVars output, an import from
derivation that forced a build at evaluation time. The substitution is now a
string replacement at evaluation. The dev shell takes the bubblewrap-wrapped
comment-checker only on Linux, where bubblewrap exists.

nixpkgs 26.11 dropped x86_64-darwin, so the flake no longer lists it, and the
dprint and denort release tables lose their x86_64-darwin pins

* build(nix): leave --ignore-scripts to pnpmConfigHook

pnpmConfigHook already installs with --ignore-scripts. pnpm 12 refuses the
flag twice, so the library adding it again broke every pnpm 12 workspace

* feat(nix): closure-only store, egress log, published and listen ports

The sandbox exposed the whole of /nix/store: bubblewrap bound it read-only
and the macOS profile allowed the subpath, so dependency code could list
the store and read any path in it. The launcher now resolves the
invocation's closure (nix-store --query --requisites over the sandboxed
PATH, the command, the pnpm store and its own helpers) and exposes only
those paths. On Linux they are bound over an empty /nix/store that cannot
be listed; on macOS a per-invocation profile grants file-read* and
file-map-executable on each one, plus file-read-metadata on the literal
/nix and /nix/store, which Node needs because it lstats every path
component.

- --egress-log PATH appends one JSONL line per proxy decision, allowed or
  refused, with host, port and the matched rule. A path inside the
  project, the sandbox HOME or its TMP is refused
- --publish HOST:SANDBOX exposes a sandbox port on host loopback; on Linux
  only published ports reach the host
- --listen PORT declares an internal port. macOS has no network namespace,
  so its profile allows binds only on --publish and --listen ports (port 0
  included) and names --listen when a bind was refused. A --listen port on
  macOS is reachable from host loopback, a stated platform limit
- pnpm fetches the optional binaries of every system the flake builds, so
  one dependency hash holds on all of them

The proofs cover each boundary and fail against a pass-through sandbox

* test(release): prove the release never publishes to npm

The npm retirement removed verdaccio from the harness along with the
publish phase, so nothing proved the pipeline stays off the registry.
verdaccio is back behind the redirected registry.npmjs.org as a tripwire:
it accepts publishes (publish: $all), so a regression that publishes lands
there instead of failing on auth, and it logs every request. The last
phase pings it and waits for that request in the log, so a tripwire that
logs nothing cannot pass, then asserts zero PUT requests and an empty
storage

* build(nix): give macOS sandbox runs a private XDG_RUNTIME_DIR

pnpm 12 takes its store-operation lock under XDG_RUNTIME_DIR when that points at
a directory only the user can write to, and otherwise falls back to /tmp, which
the deny-by-default darwin profile refuses, so an offline install inside the
sandbox fails on macOS. The darwin branch now creates a 0700 runtime dir inside
the run's remapped TMP and exports XDG_RUNTIME_DIR to it, so the lock lands in a
writable private path and darwin.sb keeps its write roots unchanged

The proof runs the pnpm that locks there: nixpkgs moves to aa48d34, whose
pnpm_12 builds the tiny store and lands on the proofs' PATH. proofs.sh asserts
that version, installs a tiny workspace offline from the --pnpm-store store, and
proves a real-/tmp write is refused on macOS (a private tmpfs on Linux) and
leaves nothing in the host /tmp. On macOS it repeats the install with
XDG_RUNTIME_DIR removed inside the sandbox and requires
ERR_PNPM_STORE_DIR_OPEN_OPERATION_LOCK, on every CI run. pnpm 12 is a native
binary per platform, so the tiny store's fixed-output hash is chosen by system.
The bump also re-pins denort to the deno 2.9.7 zips and moves the pinned pnpm to
11.27.0 in package.json and the e2e image. README documents the runtime dir and
the proofs; AGENTS.md records the approved rule that a launcher change is done
only when its darwin path ran a real offline pnpm install in CI

Without a sandbox (the macOS default and the dev container) nix builds with
HOME=/homeless-shelter, the real host path, and the locked comment-checker ran
cargo with that HOME, creating /homeless-shelter/.cargo so every later build
failed nix's purity check. comment-checker 3bfedd8 exports HOME under $TMPDIR
before cargo runs, and the sandbox workflow fails on both systems if any build
leaves /homeless-shelter behind

turbo.json passes TMPDIR through: strict env mode dropped it, so inside the
macOS sandbox rolldown-plugin-dts failed with EPERM on
mkdtemp('/tmp/rolldown-plugin-dts-*'). TMPDIR names where scratch goes, never
what a task outputs, so cache keys are unchanged

* build(nix): stop guessing a refused macOS bind from the unified log

The darwin launcher printed a --listen hint after a failed run when
`log show` found the kernel's `deny(1) network-bind` record. The kernel
hands that record to logd asynchronously, so a read right after the command
exits sometimes ran before it landed: the proof "an undeclared macOS bind
is refused and names --listen" failed once in sfs-xstate and passed on a
re-run. No wait can prove a record will never come, so the hint is gone.

The refusal itself is unchanged and is what the proof now asserts: the
program's own listen fails with EPERM. Linux has no hint to keep; a bind
there succeeds in the sandbox's private network namespace

* feat(release): refuse a released version whose tarball changed

A released name@version is immutable: a consumer's lockfile pins its
integrity, so the same version must never pack different bytes. `tag` now
creates annotated tags whose message records the tarball's sha512 and a
sha512 per file inside it. `plan` checks every member whose current
version already has a release tag, except the members the pending
changesets plan moves: it reads the annotation from the remote, recomputes
the digest from the packed tarball and refuses a mismatch, naming
package@version, both hashes and the first differing file in sorted order
(a file on one side only counts, so a dependency-range change surfaces as
`package/package.json`). A lightweight tag or an unreadable annotation is
refused too.

`plan` and `tag` take a required `--tarballs DIR` (Nix `workspace-tarballs`
or `pnpm pack` output). A new tarball-adapter reads gzip and tar headers
with node:zlib and node:crypto; the decision is a pure workflow with
property laws.

The e2e pipeline releases the current state, versions alpha minor+patch,
asserts beta's packed dependency moves, re-packs the earlier release to
match its recorded digest, and shows a reverted manifest refused

* ci(release): run the release tools from the caller's pinned flake input

The reusable release and changeset-check workflows checked this repository
out at a moving `tools-ref`, ran `pnpm install` on it, and ran the bundles
with node, so every caller's CI installed and executed npm dependency code
unsandboxed. They now run `release-tools`, a new flake package that joins
the three release apps, from the caller's dev shell. The revision is the one
the caller's flake.lock pins for its pnpm-release-management input, so the
`tools-ref` and `node-version` inputs are gone.

A release PR opened with the workflow token starts no workflows, so the
version job now dispatches the caller's CI (new required `ci-workflow`
input, `actions: write`) on the release branch, as systemfsoftware's private
script did

changeset-check runs turbo, the caller's dependency code: its install and
the gate both run inside `sandbox`, offline from the Nix-built pnpm store

* feat(version): bump a Cargo workspace as a release surface

A `cargo` surface ({ kind: "cargo", path: "Cargo.toml", package? }) keeps a
Rust workspace in step with the release. A bump rewrites the
[workspace.package] version in place, every member that pins a literal
[package] version, and the workspace-member entries in the sibling
Cargo.lock, so `cargo build --locked` still passes after the bump. Registry
and git entries are never touched. `version sync check` reports a member or
lock entry that lags.

Under the pnpm strategy, `package` names the workspace member whose bumped
version the Cargo workspace follows. Omitting it, naming a non-member, or
starting from a stale lock entry refuses the bump.

The e2e pipeline gains a phase that bumps a Cargo workspace beside the pnpm
packages and checks the lock

* feat(version): version each package through the changesets libraries

The `pnpm` strategy ran `pnpm version -r`, which takes its versions from the
npm registry: with npm retired, an intent on an unpublished package left it
at its old version, later intents folded into the same changelog, and
consumed intents were never deleted, so the plan asked for a version step on
every push.

The `changesets` strategy replaces it. A new changesets-adapter drives
@changesets/read, assemble-release-plan and apply-release-plan (exact pins)
offline: the highest intent per package wins, workspace dependents are bumped
and their ranges updated, private members version too (a cargo surface can
follow a private version carrier), and consumed intents are deleted in the
release PR. prm still writes one changelog per released member. Released
state is the git tag `<name>@v<version>`, so an untagged manifest version is
what the plan releases.

Workspace dependents always bump (`updateInternalDependents: 'always'`). A
bare `workspace:^` packs as `^<current version>`, so any version change in a
dependency changes the dependent's packed bytes. Out-of-range bumping would
ship those bytes under an already released name@version.

The apps now bundle builtins as `node:` imports (tsdown nodeProtocol) and
prefer a dependency's ESM `module` entry over its `main` (jsonc-parser's
`main` is a UMD build whose `require("./impl/format")` survives bundling);
`deno compile` needs both once the changesets dependencies are bundled. The
pnpm dependency hash moves with the lockfile.

The e2e pipeline gains a phase: minor and patch intents on one package, its
dependent bumped, intents removed, tags and releases created, and the next
plan settles on none

* fix(release): check tarball identity on publishable members only

A private workspace member is never packed, so a tagged private carrier
(systemfsoftware's @systemfsoftware/gritlint@0.1.0) has no tarball and was
refused as tarball-missing. Identity candidates now come from a pure
identityCandidates helper that keeps only publishable, tagged members — the
same notion the cycle already uses. Property laws pin the selection and the
private exclusion; an integration case proves a tagged private member with
no tarball does not refuse the plan

* ci(release): pack the caller's workspace and pass --tarballs to plan and tag

* build(nix): reap the darwin sandbox's process group on every exit

On macOS the launcher ran the sandboxed command and its --publish
forwarders as plain children. Nothing like bwrap's --die-with-parent exists
there, so a server the command started kept running after the launcher was
killed. It held the step's stdout open, and the "--publish port answers"
proof hung until the job's 30-minute cap, which GitHub reports as cancelled.

The darwin branch now starts the forwarders and the sandboxed command in a
single process group (set -m). On every exit, including HUP, INT and TERM,
the EXIT trap kills that whole group. Without a terminal the launcher waits
on the group, so a signal runs the trap at once. On a terminal the group
takes the foreground with fg, so the command still reads input and gets
Ctrl-C. Job control also leaves the command's stdin alone; a background job
without it reads /dev/null.

The macOS proofs now bound each launcher run with timeout --foreground, and
a new proof asserts that once the --publish launcher is killed, neither the
server nor the forwarder answers

* build(nix): name the outcome of the macOS bind proof

The proof that an undeclared bind is refused never exited on macos-26: its
node server only had an error handler, so a bind the kernel did not refuse
listened until the launcher timeout and the failure said nothing. The node
program now exits with its outcome (3 EPERM, 4 listening, 5 no outcome in
10 seconds, 6 any other error), and a failure prints that code with the
output, so the run itself says which one happened

* fix(release): refuse integrity verification with nothing to check

verifyIntegrity passed an empty list of checks, and passed an annotation
whose files map was empty, so either case let a tag through with nothing
verified. Both are now refusals: IntegrityNothingToVerify, and
IntegrityFilesEmpty naming the package, version and empty side. With both
empty cases refusing, verifyIntegrity no longer chooses between decisions,
so it is a plain function in integrity.ts instead of a Workflow. plan still
skips verification when no tagged candidate needs it, which keeps a fresh
repository plannable.

The property suite pinned the old Workflow cases and is gone. Four plan
scenarios cover the behaviour: empty files refuse, zero checks refuse, a
matching tarball verifies, and a changed one refuses naming the file

* build(nix): refuse undeclared loopback listeners on macOS

On macos-26 an undeclared bind to 127.0.0.1 succeeded inside the sandbox:
the bind proof's server reported listening (exit 4) instead of EPERM, and
without an outcome code it listened until the 30-minute job cap. The
profile granted network-inbound on every localhost port, and network-bind
only on declared ones. Inbound now has the same declared-port list as
bind, so a port the command did not declare with --listen or --publish
cannot serve

* fix(release): carry the tarball integrity check through main's changelog storage

Merging main's #22 into this layer left two gaps: #22's repository-storage tag scenario built its request without the tarballs this layer requires, and main's process-adapter git fake lacked tagAnnotation. The digest read becomes a const

* fix(release): read tarball digests only when a tagged package needs checking

Effect 4 has no Effect.if, so the conditional read is a small function with an early return

* chore(version): name the packages the changesets versioning layer moves in an intent

* docs(version): keep one cargo surface paragraph, under changesets versioning

* chore(release): name the packages the tarball identity layer moves in an intent

* build(nix): put release-tools in this repository's own dev shell

This repository calls its own changeset check with devshell: true, so its dev shell meets the contract every caller meets and provides release-tools

* ci(release): keep the devshell changeset check, with tools from the caller's flake

The merged devshell mode (#27) stays whole: the devshell input, the caller's
own bootstrap script run in its nix develop shell, the refusal without one,
this repository's own self-caller with devshell: true and its bootstrap
script. The one change this layer makes to it is the tools source: in
devshell mode the check runs as `nix develop --command sandbox --
changeset-management check`, from the release-tools the caller's flake.lock
pins, with no tools-ref checkout or build. Callers without devshell keep the
tools-ref path unchanged, and the plain pnpm install exists only on that path.

The runs-on input (#32) stays on both workflows, and release.yml installs Nix
only on hosted runners, as #32 does. The dogfood caller now passes the
ci-workflow input release.yml requires, and ci.yml accepts workflow_dispatch
so the release can dispatch it.

The README CI section documents both modes

* docs(release): keep the devshell changeset check paragraph as merged

Restores the CI section's wording from main and changes only the sentences the flake-input tools make false: tools-ref and node-version in devshell mode, and which workflow builds .release-tools

---------

Co-authored-by: systemfsoftware-maker <drdgvhbh@gmail.com>
kiro-systemf Bot pushed a commit that referenced this pull request Oct 7, 2026
…dger (#12)

* fix(version): refuse target suffixes that repeat

Each distribution target's suffix names its platform package
(<name>-<suffix>), and sync-root writes those names as optionalDependencies
keys. Two targets sharing a suffix collapsed into one key, so the manifest got
one pin while the decision reported two. CI found it as the property
counterexample ["a","0.0.0",["0","0"],false] (seed 1467794903).

release.jsonc decoding now refuses a distribution whose targets repeat a
suffix, naming it (target suffix "x" is declared by more than one target),
which ReleaseConfigStoreLive reports as ConfigMalformed. PinRootManifestCommand
refuses repeated suffixes and pin names too, and the cell builds the command
by decoding, so a collision is a typed SchemaError instead of a thrown
constructor. Both filters carry generation metadata: unique for suffix lists,
and a uniqueArray-by-suffix candidate for targets.

RepinsAll and RepinIdempotent keep their unrestricted draws and now state both
sides: distinct suffixes repin every target, colliding ones are refused. A
named scenario replays ["0","0"]. With the command checks removed, both laws
fail on the CI seed and the scenario fails

* test(repo): replay a fast-check seed from FC_SEED and FC_PATH

fast-check prints the seed and path of a failing property, but nothing read
them back, so a CI counterexample could not be replayed locally: FC_SEED was
ignored. vitest.fast-check.setup.ts sets fast-check's global seed from FC_SEED
(and the shrink path from FC_PATH), and every package with property tests
loads it. turbo hashes both variables for the test task, so a cached run never
stands in for a different seed.

FC_SEED=1467794903 FC_PATH=10:0:0:0:0 reproduces CI's ["a","0.0.0",["0","0"],false]
exactly against the unfixed command schema, while the same run without the
variables passes

* feat(nix): distribute workspace packages through Nix and sandbox dependency code

Packaging (`lib.mkPnpmWorkspacePackages`): one call in a flake gives each
public workspace member as `packages.<system>.<name>`, a deterministic
`pnpm pack` tarball built in the Nix sandbox, plus `workspace-tarballs`
(every tarball and an index.json) and `pnpm-store`. Third-party
dependencies come only from nixpkgs' `fetchPnpmDeps` (fetcherVersion 4),
keyed by the lockfile hash; no registry at build time. The library refuses a
workspace whose `packageManager` pins a different pnpm than the build uses.

- the tarballs rebuild bit-for-bit (`nix build --rebuild`). Three inferred
  exports in version-engine printed unions whose member order TypeScript 7
  varies from run to run (microsoft/TypeScript#64589); they are annotated with
  named types
- the CLI apps share the library's dependency fetch; the pinned hash in
  nix/cli-app.nix was stale and only a local store cache had hidden it
- packageManager pnpm@11.25.0, the version Nix provides

Sandbox (`packages.<system>.sandbox`): bubblewrap on Linux (--unshare-all,
--cap-drop ALL, --die-with-parent, --new-session, cleared environment), and
a deny-by-default sandbox-exec profile on macOS. The project directory is
read-write, /nix/store read-only, and $HOME a fresh tmpfs. Networking is
loopback only; `--allow-host` opens HTTPS to declared hosts through an
allow-list CONNECT proxy outside the sandbox. `--pnpm-store` (the dev shell
default) makes pnpm resolve offline from the Nix-built store with
frozen-lockfile and ignore-scripts.

`packages.<system>.sandbox-proofs` gates it. Each refusal proof prints from
inside the same sandbox first, so a sandbox that never starts fails instead
of passing. CI runs the proofs on Linux and macOS, then installs, builds and
tests this repository as three separate sandbox invocations with no network
at all, and checks that the tarballs rebuild bit-for-bit

* build(nix): evaluate the sandbox and the dev shell on every supported system

The macOS CI leg could not enter the dev shell: the sandbox script was read
back with builtins.readFile from a replaceVars output, an import from
derivation that forced a build at evaluation time. The substitution is now a
string replacement at evaluation. The dev shell takes the bubblewrap-wrapped
comment-checker only on Linux, where bubblewrap exists.

nixpkgs 26.11 dropped x86_64-darwin, so the flake no longer lists it, and the
dprint and denort release tables lose their x86_64-darwin pins

* build(nix): leave --ignore-scripts to pnpmConfigHook

pnpmConfigHook already installs with --ignore-scripts. pnpm 12 refuses the
flag twice, so the library adding it again broke every pnpm 12 workspace

* feat(nix): closure-only store, egress log, published and listen ports

The sandbox exposed the whole of /nix/store: bubblewrap bound it read-only
and the macOS profile allowed the subpath, so dependency code could list
the store and read any path in it. The launcher now resolves the
invocation's closure (nix-store --query --requisites over the sandboxed
PATH, the command, the pnpm store and its own helpers) and exposes only
those paths. On Linux they are bound over an empty /nix/store that cannot
be listed; on macOS a per-invocation profile grants file-read* and
file-map-executable on each one, plus file-read-metadata on the literal
/nix and /nix/store, which Node needs because it lstats every path
component.

- --egress-log PATH appends one JSONL line per proxy decision, allowed or
  refused, with host, port and the matched rule. A path inside the
  project, the sandbox HOME or its TMP is refused
- --publish HOST:SANDBOX exposes a sandbox port on host loopback; on Linux
  only published ports reach the host
- --listen PORT declares an internal port. macOS has no network namespace,
  so its profile allows binds only on --publish and --listen ports (port 0
  included) and names --listen when a bind was refused. A --listen port on
  macOS is reachable from host loopback, a stated platform limit
- pnpm fetches the optional binaries of every system the flake builds, so
  one dependency hash holds on all of them

The proofs cover each boundary and fail against a pass-through sandbox

* test(release): prove the release never publishes to npm

The npm retirement removed verdaccio from the harness along with the
publish phase, so nothing proved the pipeline stays off the registry.
verdaccio is back behind the redirected registry.npmjs.org as a tripwire:
it accepts publishes (publish: $all), so a regression that publishes lands
there instead of failing on auth, and it logs every request. The last
phase pings it and waits for that request in the log, so a tripwire that
logs nothing cannot pass, then asserts zero PUT requests and an empty
storage

* build(nix): give macOS sandbox runs a private XDG_RUNTIME_DIR

pnpm 12 takes its store-operation lock under XDG_RUNTIME_DIR when that points at
a directory only the user can write to, and otherwise falls back to /tmp, which
the deny-by-default darwin profile refuses, so an offline install inside the
sandbox fails on macOS. The darwin branch now creates a 0700 runtime dir inside
the run's remapped TMP and exports XDG_RUNTIME_DIR to it, so the lock lands in a
writable private path and darwin.sb keeps its write roots unchanged

The proof runs the pnpm that locks there: nixpkgs moves to aa48d34, whose
pnpm_12 builds the tiny store and lands on the proofs' PATH. proofs.sh asserts
that version, installs a tiny workspace offline from the --pnpm-store store, and
proves a real-/tmp write is refused on macOS (a private tmpfs on Linux) and
leaves nothing in the host /tmp. On macOS it repeats the install with
XDG_RUNTIME_DIR removed inside the sandbox and requires
ERR_PNPM_STORE_DIR_OPEN_OPERATION_LOCK, on every CI run. pnpm 12 is a native
binary per platform, so the tiny store's fixed-output hash is chosen by system.
The bump also re-pins denort to the deno 2.9.7 zips and moves the pinned pnpm to
11.27.0 in package.json and the e2e image. README documents the runtime dir and
the proofs; AGENTS.md records the approved rule that a launcher change is done
only when its darwin path ran a real offline pnpm install in CI

Without a sandbox (the macOS default and the dev container) nix builds with
HOME=/homeless-shelter, the real host path, and the locked comment-checker ran
cargo with that HOME, creating /homeless-shelter/.cargo so every later build
failed nix's purity check. comment-checker 3bfedd8 exports HOME under $TMPDIR
before cargo runs, and the sandbox workflow fails on both systems if any build
leaves /homeless-shelter behind

turbo.json passes TMPDIR through: strict env mode dropped it, so inside the
macOS sandbox rolldown-plugin-dts failed with EPERM on
mkdtemp('/tmp/rolldown-plugin-dts-*'). TMPDIR names where scratch goes, never
what a task outputs, so cache keys are unchanged

* build(nix): stop guessing a refused macOS bind from the unified log

The darwin launcher printed a --listen hint after a failed run when
`log show` found the kernel's `deny(1) network-bind` record. The kernel
hands that record to logd asynchronously, so a read right after the command
exits sometimes ran before it landed: the proof "an undeclared macOS bind
is refused and names --listen" failed once in sfs-xstate and passed on a
re-run. No wait can prove a record will never come, so the hint is gone.

The refusal itself is unchanged and is what the proof now asserts: the
program's own listen fails with EPERM. Linux has no hint to keep; a bind
there succeeds in the sandbox's private network namespace

* feat(release): verify pre-adoption releases against an append-only ledger

A repository adopting this tooling already has release tags whose bytes the
tooling never recorded. `release adopt --registry <url> --output <file>` records
them once: for every publishable member whose <name>@v<version> tag exists on
the remote it fetches the registry metadata, downloads dist.tarball, and writes
one ledger entry with the tag, its peeled commit, name@version, dist.integrity,
the sha256 of the download, and a per-file sha512 map. A version whose bytes
cannot be fetched is a hard error in the report and a non-zero exit.

The ledger is one JSON file at the repository root, written only by the command,
stable key order, entries sorted by tag, and lands in its own commit. `release
plan` accepts a lightweight tag only when the ledger holds it with the same
peeled commit and name@version; a moved tag or mismatched entry is a red refusal
naming both commits or values, and a ledgered version whose current packed
tarball differs from the ledger integrity is refused naming the first differing
file. A new lightweight tag outside the ledger stays the existing red refusal.
`changeset check <base>` fails red when an entry present at the base is removed
or changed at the head, proving the ledger is append-only.

Adds the adoption-adapter package: a ledger port (fs plus git show for the base
revision) and a registry port over Effect HttpClient with tar/hash through the
tarball adapter. Pure decisions carry property laws; gherkin integration tests
drive a real git repo, a real local registry server and real tgz bytes

* test(release): stub tagCommit in the git-hooks fake GitPort

* feat(release): adopt every existing release tag into the ledger

Adoption now records every release tag on the remote, not only the current
version of a current member: an older version of a current member (the
immutability law applies if that name@version is ever re-released) and a name
that is no longer a workspace member (parsed from the tag, split on the last
@v) are both fetched from the registry and ledgered. A tag of a current private
member is never published, so it is excluded and reported as private, never
published.

Fetches run four at a time and retry a transient registry failure (5xx or
timeout) three attempts with backoff; a 404 is not transient. The report prints
the ledgered/excluded/error counts then one line per error, and any error exits
non-zero writing no ledger at all. Registry failures now carry the HTTP status
so transient classification is the layer's, not the caller's.

The integration fixture now covers an older version, a retired name and a
private member; the README states the widened scope

* fix(release): resolve the published name from the tagged manifest

The tag string is not the published name. systemfsoftware tagged packages
unscoped before it moved to scoped names, so hex-schema@v1.0.0 points at a
commit whose packages/hex-schema/package.json says
@systemfsoftware/hex-schema, which the registry serves. Adopt was asking the
registry for the tag string and logged 331 false hard errors.

Adopt now reads every package.json at the tagged commit through a new GitPort
tagTree (git ls-tree plus git show, returning the peeled commit and the
manifests) and takes the one whose version equals the tag version and whose
name equals the tag name or ends with /<tag name>. Exactly one match is
required: zero or several matches is a hard error naming the tag and the
candidate names. A manifest with private true at that commit is excluded as
private, never published. The ledger entry keeps the tag as its key and records
the resolved published name, so the identity check is unchanged.

The integration fixture now builds per-version commits (an older version of one
member and an unscoped tag whose manifest carries the scope) and asserts the
unscoped tag is ledgered under its scoped name

* fix(release): record tag/manifest mismatches and refuse unfetchable versions

* feat(release): refuse a released version whose tarball changed

A released name@version is immutable: a consumer's lockfile pins its
integrity, so the same version must never pack different bytes. `tag` now
creates annotated tags whose message records the tarball's sha512 and a
sha512 per file inside it. `plan` checks every member whose current
version already has a release tag, except the members the pending
changesets plan moves: it reads the annotation from the remote, recomputes
the digest from the packed tarball and refuses a mismatch, naming
package@version, both hashes and the first differing file in sorted order
(a file on one side only counts, so a dependency-range change surfaces as
`package/package.json`). A lightweight tag or an unreadable annotation is
refused too.

`plan` and `tag` take a required `--tarballs DIR` (Nix `workspace-tarballs`
or `pnpm pack` output). A new tarball-adapter reads gzip and tar headers
with node:zlib and node:crypto; the decision is a pure workflow with
property laws.

The e2e pipeline releases the current state, versions alpha minor+patch,
asserts beta's packed dependency moves, re-packs the earlier release to
match its recorded digest, and shows a reverted manifest refused

* ci(release): run the release tools from the caller's pinned flake input

The reusable release and changeset-check workflows checked this repository
out at a moving `tools-ref`, ran `pnpm install` on it, and ran the bundles
with node, so every caller's CI installed and executed npm dependency code
unsandboxed. They now run `release-tools`, a new flake package that joins
the three release apps, from the caller's dev shell. The revision is the one
the caller's flake.lock pins for its pnpm-release-management input, so the
`tools-ref` and `node-version` inputs are gone.

A release PR opened with the workflow token starts no workflows, so the
version job now dispatches the caller's CI (new required `ci-workflow`
input, `actions: write`) on the release branch, as systemfsoftware's private
script did

changeset-check runs turbo, the caller's dependency code: its install and
the gate both run inside `sandbox`, offline from the Nix-built pnpm store

* fix(release): ledger unpublished tags as burned versions

* feat(version): bump a Cargo workspace as a release surface

A `cargo` surface ({ kind: "cargo", path: "Cargo.toml", package? }) keeps a
Rust workspace in step with the release. A bump rewrites the
[workspace.package] version in place, every member that pins a literal
[package] version, and the workspace-member entries in the sibling
Cargo.lock, so `cargo build --locked` still passes after the bump. Registry
and git entries are never touched. `version sync check` reports a member or
lock entry that lags.

Under the pnpm strategy, `package` names the workspace member whose bumped
version the Cargo workspace follows. Omitting it, naming a non-member, or
starting from a stale lock entry refuses the bump.

The e2e pipeline gains a phase that bumps a Cargo workspace beside the pnpm
packages and checks the lock

* feat(version): version each package through the changesets libraries

The `pnpm` strategy ran `pnpm version -r`, which takes its versions from the
npm registry: with npm retired, an intent on an unpublished package left it
at its old version, later intents folded into the same changelog, and
consumed intents were never deleted, so the plan asked for a version step on
every push.

The `changesets` strategy replaces it. A new changesets-adapter drives
@changesets/read, assemble-release-plan and apply-release-plan (exact pins)
offline: the highest intent per package wins, workspace dependents are bumped
and their ranges updated, private members version too (a cargo surface can
follow a private version carrier), and consumed intents are deleted in the
release PR. prm still writes one changelog per released member. Released
state is the git tag `<name>@v<version>`, so an untagged manifest version is
what the plan releases.

Workspace dependents always bump (`updateInternalDependents: 'always'`). A
bare `workspace:^` packs as `^<current version>`, so any version change in a
dependency changes the dependent's packed bytes. Out-of-range bumping would
ship those bytes under an already released name@version.

The apps now bundle builtins as `node:` imports (tsdown nodeProtocol) and
prefer a dependency's ESM `module` entry over its `main` (jsonc-parser's
`main` is a UMD build whose `require("./impl/format")` survives bundling);
`deno compile` needs both once the changesets dependencies are bundled. The
pnpm dependency hash moves with the lockfile.

The e2e pipeline gains a phase: minor and patch intents on one package, its
dependent bumped, intents removed, tags and releases created, and the next
plan settles on none

* fix(release): check tarball identity on publishable members only

A private workspace member is never packed, so a tagged private carrier
(systemfsoftware's @systemfsoftware/gritlint@0.1.0) has no tarball and was
refused as tarball-missing. Identity candidates now come from a pure
identityCandidates helper that keeps only publishable, tagged members — the
same notion the cycle already uses. Property laws pin the selection and the
private exclusion; an integration case proves a tagged private member with
no tarball does not refuse the plan

* ci(release): pack the caller's workspace and pass --tarballs to plan and tag

* build(nix): reap the darwin sandbox's process group on every exit

On macOS the launcher ran the sandboxed command and its --publish
forwarders as plain children. Nothing like bwrap's --die-with-parent exists
there, so a server the command started kept running after the launcher was
killed. It held the step's stdout open, and the "--publish port answers"
proof hung until the job's 30-minute cap, which GitHub reports as cancelled.

The darwin branch now starts the forwarders and the sandboxed command in a
single process group (set -m). On every exit, including HUP, INT and TERM,
the EXIT trap kills that whole group. Without a terminal the launcher waits
on the group, so a signal runs the trap at once. On a terminal the group
takes the foreground with fg, so the command still reads input and gets
Ctrl-C. Job control also leaves the command's stdin alone; a background job
without it reads /dev/null.

The macOS proofs now bound each launcher run with timeout --foreground, and
a new proof asserts that once the --publish launcher is killed, neither the
server nor the forwarder answers

* fix(release): refuse unreadable ledgers and append on adoption

readAt read any `git show` failure as "no ledger at this ref", so a bad
ref or a broken repository let the append-only check pass against an empty
base. It now asks git ls-tree whether the path exists at the ref. A missing
path is still Option.none; any other failure is LedgerUnreadable, carrying
git's diagnostic.

adopt replaced the whole ledger with the entries it fetched, which dropped
any entry whose tag had since left the remote. It now reads the existing
ledger, skips tags already in it and appends only the new ones. A ledger
that cannot be read or parsed stops adoption instead of being treated as
empty, and the CLI renders both refusals

* docs(release): adopt appends to an existing ledger

Running adopt again keeps every entry and adds only tags the ledger lacks,
and an unreadable or unparseable ledger stops the command

* build(nix): name the outcome of the macOS bind proof

The proof that an undeclared bind is refused never exited on macos-26: its
node server only had an error handler, so a bind the kernel did not refuse
listened until the launcher timeout and the failure said nothing. The node
program now exits with its outcome (3 EPERM, 4 listening, 5 no outcome in
10 seconds, 6 any other error), and a failure prints that code with the
output, so the run itself says which one happened

* fix(release): refuse integrity verification with nothing to check

verifyIntegrity passed an empty list of checks, and passed an annotation
whose files map was empty, so either case let a tag through with nothing
verified. Both are now refusals: IntegrityNothingToVerify, and
IntegrityFilesEmpty naming the package, version and empty side. With both
empty cases refusing, verifyIntegrity no longer chooses between decisions,
so it is a plain function in integrity.ts instead of a Workflow. plan still
skips verification when no tagged candidate needs it, which keeps a fresh
repository plannable.

The property suite pinned the old Workflow cases and is gone. Four plan
scenarios cover the behaviour: empty files refuse, zero checks refuse, a
matching tarball verifies, and a changed one refuses naming the file

* build(nix): refuse undeclared loopback listeners on macOS

On macos-26 an undeclared bind to 127.0.0.1 succeeded inside the sandbox:
the bind proof's server reported listening (exit 4) instead of EPERM, and
without an outcome code it listened until the 30-minute job cap. The
profile granted network-inbound on every localhost port, and network-bind
only on declared ones. Inbound now has the same declared-port list as
bind, so a port the command did not declare with --listen or --publish
cannot serve

* fix(release): carry the tarball integrity check through main's changelog storage

Merging main's #22 into this layer left two gaps: #22's repository-storage tag scenario built its request without the tarballs this layer requires, and main's process-adapter git fake lacked tagAnnotation. The digest read becomes a const

* fix(release): read tarball digests only when a tagged package needs checking

Effect 4 has no Effect.if, so the conditional read is a small function with an early return

* test(release): give main's new fakes and scenario the ledger layer's ports

Main's #22 added changelogStorage to WorkspaceStore and a repository-storage tag scenario; this layer's tag needs the ledger and its GitPort reads tag commits and trees, so the merged fakes and scenario now provide them

* test(release): serve the stand-in registry in memory instead of on loopback

The adoption tests started their stand-in registry with listen(0) on
127.0.0.1. Inside the macOS sandbox every undeclared bind is refused, so
"Test through the sandbox" failed with listen EPERM on macOS (#12 2523818,
#13 0c37df8, #14 ef14cf3); Linux passed only because its sandbox has a
private network namespace.

The stand-in is a fake either way. The tests now provide
FetchHttpClient.Fetch with a function that answers the same metadata and
tarball routes from memory and records the same request paths, so
RegistryLive and the fetch client run unchanged and nothing listens. The
macOS bind refusal stays as it is

* chore(version): name the packages the changesets versioning layer moves in an intent

* docs(version): keep one cargo surface paragraph, under changesets versioning

* chore(release): name the packages the tarball identity layer moves in an intent

* build(nix): put release-tools in this repository's own dev shell

This repository calls its own changeset check with devshell: true, so its dev shell meets the contract every caller meets and provides release-tools

* ci(release): keep the devshell changeset check, with tools from the caller's flake

The merged devshell mode (#27) stays whole: the devshell input, the caller's
own bootstrap script run in its nix develop shell, the refusal without one,
this repository's own self-caller with devshell: true and its bootstrap
script. The one change this layer makes to it is the tools source: in
devshell mode the check runs as `nix develop --command sandbox --
changeset-management check`, from the release-tools the caller's flake.lock
pins, with no tools-ref checkout or build. Callers without devshell keep the
tools-ref path unchanged, and the plain pnpm install exists only on that path.

The runs-on input (#32) stays on both workflows, and release.yml installs Nix
only on hosted runners, as #32 does. The dogfood caller now passes the
ci-workflow input release.yml requires, and ci.yml accepts workflow_dispatch
so the release can dispatch it.

The README CI section documents both modes

* docs(release): keep the devshell changeset check paragraph as merged

Restores the CI section's wording from main and changes only the sentences the flake-input tools make false: tools-ref and node-version in devshell mode, and which workflow builds .release-tools

* chore(release): name the packages the adoption ledger layer moves in an intent

* test(gate): provide the ledger port to the deleted-package scenario

The gate reads the adoption ledger on this layer, so the scenario main brought in (#26) needs the fake ledger every other gate scenario has

---------

Co-authored-by: systemfsoftware-maker <drdgvhbh@gmail.com>
kiro-systemf Bot pushed a commit that referenced this pull request Oct 7, 2026
…store hash (#14)

* fix(version): refuse target suffixes that repeat

Each distribution target's suffix names its platform package
(<name>-<suffix>), and sync-root writes those names as optionalDependencies
keys. Two targets sharing a suffix collapsed into one key, so the manifest got
one pin while the decision reported two. CI found it as the property
counterexample ["a","0.0.0",["0","0"],false] (seed 1467794903).

release.jsonc decoding now refuses a distribution whose targets repeat a
suffix, naming it (target suffix "x" is declared by more than one target),
which ReleaseConfigStoreLive reports as ConfigMalformed. PinRootManifestCommand
refuses repeated suffixes and pin names too, and the cell builds the command
by decoding, so a collision is a typed SchemaError instead of a thrown
constructor. Both filters carry generation metadata: unique for suffix lists,
and a uniqueArray-by-suffix candidate for targets.

RepinsAll and RepinIdempotent keep their unrestricted draws and now state both
sides: distinct suffixes repin every target, colliding ones are refused. A
named scenario replays ["0","0"]. With the command checks removed, both laws
fail on the CI seed and the scenario fails

* test(repo): replay a fast-check seed from FC_SEED and FC_PATH

fast-check prints the seed and path of a failing property, but nothing read
them back, so a CI counterexample could not be replayed locally: FC_SEED was
ignored. vitest.fast-check.setup.ts sets fast-check's global seed from FC_SEED
(and the shrink path from FC_PATH), and every package with property tests
loads it. turbo hashes both variables for the test task, so a cached run never
stands in for a different seed.

FC_SEED=1467794903 FC_PATH=10:0:0:0:0 reproduces CI's ["a","0.0.0",["0","0"],false]
exactly against the unfixed command schema, while the same run without the
variables passes

* feat(nix): distribute workspace packages through Nix and sandbox dependency code

Packaging (`lib.mkPnpmWorkspacePackages`): one call in a flake gives each
public workspace member as `packages.<system>.<name>`, a deterministic
`pnpm pack` tarball built in the Nix sandbox, plus `workspace-tarballs`
(every tarball and an index.json) and `pnpm-store`. Third-party
dependencies come only from nixpkgs' `fetchPnpmDeps` (fetcherVersion 4),
keyed by the lockfile hash; no registry at build time. The library refuses a
workspace whose `packageManager` pins a different pnpm than the build uses.

- the tarballs rebuild bit-for-bit (`nix build --rebuild`). Three inferred
  exports in version-engine printed unions whose member order TypeScript 7
  varies from run to run (microsoft/TypeScript#64589); they are annotated with
  named types
- the CLI apps share the library's dependency fetch; the pinned hash in
  nix/cli-app.nix was stale and only a local store cache had hidden it
- packageManager pnpm@11.25.0, the version Nix provides

Sandbox (`packages.<system>.sandbox`): bubblewrap on Linux (--unshare-all,
--cap-drop ALL, --die-with-parent, --new-session, cleared environment), and
a deny-by-default sandbox-exec profile on macOS. The project directory is
read-write, /nix/store read-only, and $HOME a fresh tmpfs. Networking is
loopback only; `--allow-host` opens HTTPS to declared hosts through an
allow-list CONNECT proxy outside the sandbox. `--pnpm-store` (the dev shell
default) makes pnpm resolve offline from the Nix-built store with
frozen-lockfile and ignore-scripts.

`packages.<system>.sandbox-proofs` gates it. Each refusal proof prints from
inside the same sandbox first, so a sandbox that never starts fails instead
of passing. CI runs the proofs on Linux and macOS, then installs, builds and
tests this repository as three separate sandbox invocations with no network
at all, and checks that the tarballs rebuild bit-for-bit

* build(nix): evaluate the sandbox and the dev shell on every supported system

The macOS CI leg could not enter the dev shell: the sandbox script was read
back with builtins.readFile from a replaceVars output, an import from
derivation that forced a build at evaluation time. The substitution is now a
string replacement at evaluation. The dev shell takes the bubblewrap-wrapped
comment-checker only on Linux, where bubblewrap exists.

nixpkgs 26.11 dropped x86_64-darwin, so the flake no longer lists it, and the
dprint and denort release tables lose their x86_64-darwin pins

* build(nix): leave --ignore-scripts to pnpmConfigHook

pnpmConfigHook already installs with --ignore-scripts. pnpm 12 refuses the
flag twice, so the library adding it again broke every pnpm 12 workspace

* feat(nix): closure-only store, egress log, published and listen ports

The sandbox exposed the whole of /nix/store: bubblewrap bound it read-only
and the macOS profile allowed the subpath, so dependency code could list
the store and read any path in it. The launcher now resolves the
invocation's closure (nix-store --query --requisites over the sandboxed
PATH, the command, the pnpm store and its own helpers) and exposes only
those paths. On Linux they are bound over an empty /nix/store that cannot
be listed; on macOS a per-invocation profile grants file-read* and
file-map-executable on each one, plus file-read-metadata on the literal
/nix and /nix/store, which Node needs because it lstats every path
component.

- --egress-log PATH appends one JSONL line per proxy decision, allowed or
  refused, with host, port and the matched rule. A path inside the
  project, the sandbox HOME or its TMP is refused
- --publish HOST:SANDBOX exposes a sandbox port on host loopback; on Linux
  only published ports reach the host
- --listen PORT declares an internal port. macOS has no network namespace,
  so its profile allows binds only on --publish and --listen ports (port 0
  included) and names --listen when a bind was refused. A --listen port on
  macOS is reachable from host loopback, a stated platform limit
- pnpm fetches the optional binaries of every system the flake builds, so
  one dependency hash holds on all of them

The proofs cover each boundary and fail against a pass-through sandbox

* test(release): prove the release never publishes to npm

The npm retirement removed verdaccio from the harness along with the
publish phase, so nothing proved the pipeline stays off the registry.
verdaccio is back behind the redirected registry.npmjs.org as a tripwire:
it accepts publishes (publish: $all), so a regression that publishes lands
there instead of failing on auth, and it logs every request. The last
phase pings it and waits for that request in the log, so a tripwire that
logs nothing cannot pass, then asserts zero PUT requests and an empty
storage

* build(nix): give macOS sandbox runs a private XDG_RUNTIME_DIR

pnpm 12 takes its store-operation lock under XDG_RUNTIME_DIR when that points at
a directory only the user can write to, and otherwise falls back to /tmp, which
the deny-by-default darwin profile refuses, so an offline install inside the
sandbox fails on macOS. The darwin branch now creates a 0700 runtime dir inside
the run's remapped TMP and exports XDG_RUNTIME_DIR to it, so the lock lands in a
writable private path and darwin.sb keeps its write roots unchanged

The proof runs the pnpm that locks there: nixpkgs moves to aa48d34, whose
pnpm_12 builds the tiny store and lands on the proofs' PATH. proofs.sh asserts
that version, installs a tiny workspace offline from the --pnpm-store store, and
proves a real-/tmp write is refused on macOS (a private tmpfs on Linux) and
leaves nothing in the host /tmp. On macOS it repeats the install with
XDG_RUNTIME_DIR removed inside the sandbox and requires
ERR_PNPM_STORE_DIR_OPEN_OPERATION_LOCK, on every CI run. pnpm 12 is a native
binary per platform, so the tiny store's fixed-output hash is chosen by system.
The bump also re-pins denort to the deno 2.9.7 zips and moves the pinned pnpm to
11.27.0 in package.json and the e2e image. README documents the runtime dir and
the proofs; AGENTS.md records the approved rule that a launcher change is done
only when its darwin path ran a real offline pnpm install in CI

Without a sandbox (the macOS default and the dev container) nix builds with
HOME=/homeless-shelter, the real host path, and the locked comment-checker ran
cargo with that HOME, creating /homeless-shelter/.cargo so every later build
failed nix's purity check. comment-checker 3bfedd8 exports HOME under $TMPDIR
before cargo runs, and the sandbox workflow fails on both systems if any build
leaves /homeless-shelter behind

turbo.json passes TMPDIR through: strict env mode dropped it, so inside the
macOS sandbox rolldown-plugin-dts failed with EPERM on
mkdtemp('/tmp/rolldown-plugin-dts-*'). TMPDIR names where scratch goes, never
what a task outputs, so cache keys are unchanged

* build(nix): stop guessing a refused macOS bind from the unified log

The darwin launcher printed a --listen hint after a failed run when
`log show` found the kernel's `deny(1) network-bind` record. The kernel
hands that record to logd asynchronously, so a read right after the command
exits sometimes ran before it landed: the proof "an undeclared macOS bind
is refused and names --listen" failed once in sfs-xstate and passed on a
re-run. No wait can prove a record will never come, so the hint is gone.

The refusal itself is unchanged and is what the proof now asserts: the
program's own listen fails with EPERM. Linux has no hint to keep; a bind
there succeeds in the sandbox's private network namespace

* feat(release): verify pre-adoption releases against an append-only ledger

A repository adopting this tooling already has release tags whose bytes the
tooling never recorded. `release adopt --registry <url> --output <file>` records
them once: for every publishable member whose <name>@v<version> tag exists on
the remote it fetches the registry metadata, downloads dist.tarball, and writes
one ledger entry with the tag, its peeled commit, name@version, dist.integrity,
the sha256 of the download, and a per-file sha512 map. A version whose bytes
cannot be fetched is a hard error in the report and a non-zero exit.

The ledger is one JSON file at the repository root, written only by the command,
stable key order, entries sorted by tag, and lands in its own commit. `release
plan` accepts a lightweight tag only when the ledger holds it with the same
peeled commit and name@version; a moved tag or mismatched entry is a red refusal
naming both commits or values, and a ledgered version whose current packed
tarball differs from the ledger integrity is refused naming the first differing
file. A new lightweight tag outside the ledger stays the existing red refusal.
`changeset check <base>` fails red when an entry present at the base is removed
or changed at the head, proving the ledger is append-only.

Adds the adoption-adapter package: a ledger port (fs plus git show for the base
revision) and a registry port over Effect HttpClient with tar/hash through the
tarball adapter. Pure decisions carry property laws; gherkin integration tests
drive a real git repo, a real local registry server and real tgz bytes

* test(release): stub tagCommit in the git-hooks fake GitPort

* feat(release): adopt every existing release tag into the ledger

Adoption now records every release tag on the remote, not only the current
version of a current member: an older version of a current member (the
immutability law applies if that name@version is ever re-released) and a name
that is no longer a workspace member (parsed from the tag, split on the last
@v) are both fetched from the registry and ledgered. A tag of a current private
member is never published, so it is excluded and reported as private, never
published.

Fetches run four at a time and retry a transient registry failure (5xx or
timeout) three attempts with backoff; a 404 is not transient. The report prints
the ledgered/excluded/error counts then one line per error, and any error exits
non-zero writing no ledger at all. Registry failures now carry the HTTP status
so transient classification is the layer's, not the caller's.

The integration fixture now covers an older version, a retired name and a
private member; the README states the widened scope

* fix(release): resolve the published name from the tagged manifest

The tag string is not the published name. systemfsoftware tagged packages
unscoped before it moved to scoped names, so hex-schema@v1.0.0 points at a
commit whose packages/hex-schema/package.json says
@systemfsoftware/hex-schema, which the registry serves. Adopt was asking the
registry for the tag string and logged 331 false hard errors.

Adopt now reads every package.json at the tagged commit through a new GitPort
tagTree (git ls-tree plus git show, returning the peeled commit and the
manifests) and takes the one whose version equals the tag version and whose
name equals the tag name or ends with /<tag name>. Exactly one match is
required: zero or several matches is a hard error naming the tag and the
candidate names. A manifest with private true at that commit is excluded as
private, never published. The ledger entry keeps the tag as its key and records
the resolved published name, so the identity check is unchanged.

The integration fixture now builds per-version commits (an older version of one
member and an unscoped tag whose manifest carries the scope) and asserts the
unscoped tag is ledgered under its scoped name

* fix(release): record tag/manifest mismatches and refuse unfetchable versions

* feat(release): refuse a released version whose tarball changed

A released name@version is immutable: a consumer's lockfile pins its
integrity, so the same version must never pack different bytes. `tag` now
creates annotated tags whose message records the tarball's sha512 and a
sha512 per file inside it. `plan` checks every member whose current
version already has a release tag, except the members the pending
changesets plan moves: it reads the annotation from the remote, recomputes
the digest from the packed tarball and refuses a mismatch, naming
package@version, both hashes and the first differing file in sorted order
(a file on one side only counts, so a dependency-range change surfaces as
`package/package.json`). A lightweight tag or an unreadable annotation is
refused too.

`plan` and `tag` take a required `--tarballs DIR` (Nix `workspace-tarballs`
or `pnpm pack` output). A new tarball-adapter reads gzip and tar headers
with node:zlib and node:crypto; the decision is a pure workflow with
property laws.

The e2e pipeline releases the current state, versions alpha minor+patch,
asserts beta's packed dependency moves, re-packs the earlier release to
match its recorded digest, and shows a reverted manifest refused

* ci(release): run the release tools from the caller's pinned flake input

The reusable release and changeset-check workflows checked this repository
out at a moving `tools-ref`, ran `pnpm install` on it, and ran the bundles
with node, so every caller's CI installed and executed npm dependency code
unsandboxed. They now run `release-tools`, a new flake package that joins
the three release apps, from the caller's dev shell. The revision is the one
the caller's flake.lock pins for its pnpm-release-management input, so the
`tools-ref` and `node-version` inputs are gone.

A release PR opened with the workflow token starts no workflows, so the
version job now dispatches the caller's CI (new required `ci-workflow`
input, `actions: write`) on the release branch, as systemfsoftware's private
script did

changeset-check runs turbo, the caller's dependency code: its install and
the gate both run inside `sandbox`, offline from the Nix-built pnpm store

* fix(release): ledger unpublished tags as burned versions

* feat(version): bump a Cargo workspace as a release surface

A `cargo` surface ({ kind: "cargo", path: "Cargo.toml", package? }) keeps a
Rust workspace in step with the release. A bump rewrites the
[workspace.package] version in place, every member that pins a literal
[package] version, and the workspace-member entries in the sibling
Cargo.lock, so `cargo build --locked` still passes after the bump. Registry
and git entries are never touched. `version sync check` reports a member or
lock entry that lags.

Under the pnpm strategy, `package` names the workspace member whose bumped
version the Cargo workspace follows. Omitting it, naming a non-member, or
starting from a stale lock entry refuses the bump.

The e2e pipeline gains a phase that bumps a Cargo workspace beside the pnpm
packages and checks the lock

* feat(version): version each package through the changesets libraries

The `pnpm` strategy ran `pnpm version -r`, which takes its versions from the
npm registry: with npm retired, an intent on an unpublished package left it
at its old version, later intents folded into the same changelog, and
consumed intents were never deleted, so the plan asked for a version step on
every push.

The `changesets` strategy replaces it. A new changesets-adapter drives
@changesets/read, assemble-release-plan and apply-release-plan (exact pins)
offline: the highest intent per package wins, workspace dependents are bumped
and their ranges updated, private members version too (a cargo surface can
follow a private version carrier), and consumed intents are deleted in the
release PR. prm still writes one changelog per released member. Released
state is the git tag `<name>@v<version>`, so an untagged manifest version is
what the plan releases.

Workspace dependents always bump (`updateInternalDependents: 'always'`). A
bare `workspace:^` packs as `^<current version>`, so any version change in a
dependency changes the dependent's packed bytes. Out-of-range bumping would
ship those bytes under an already released name@version.

The apps now bundle builtins as `node:` imports (tsdown nodeProtocol) and
prefer a dependency's ESM `module` entry over its `main` (jsonc-parser's
`main` is a UMD build whose `require("./impl/format")` survives bundling);
`deno compile` needs both once the changesets dependencies are bundled. The
pnpm dependency hash moves with the lockfile.

The e2e pipeline gains a phase: minor and patch intents on one package, its
dependent bumped, intents removed, tags and releases created, and the next
plan settles on none

* fix(release): check tarball identity on publishable members only

A private workspace member is never packed, so a tagged private carrier
(systemfsoftware's @systemfsoftware/gritlint@0.1.0) has no tarball and was
refused as tarball-missing. Identity candidates now come from a pure
identityCandidates helper that keeps only publishable, tagged members — the
same notion the cycle already uses. Property laws pin the selection and the
private exclusion; an integration case proves a tagged private member with
no tarball does not refuse the plan

* ci(release): pack the caller's workspace and pass --tarballs to plan and tag

* build(nix): assemble pnpm stores from per-tarball integrity, with no store hash

* build(nix): trim lockfile fields with lib.trim so darwin evaluates

The lockfile reader trimmed with the regex '(.*[^[:space:]]|)[[:space:]]*'. glibc accepts the empty alternative, macOS libc rejects it, so every darwin evaluation of a workspace store failed with 'invalid regular expression' (systemfsoftware#606's aarch64-darwin leg). nixpkgs' lib.trim is portable

* build(nix): replay tarballs to pnpm 12 over plain HTTP

pnpm 12 verifies TLS through the platform verifier, which on macOS refuses mitm-cache's per-build certificates: UnknownIssuer (the keychain never sees the CA exported as SSL_CERT_FILE), EkuError once the CA is handed over through cafile (the leaf carries no serverAuth key usage), and its tarball client ignores strict-ssl. Replaying a tarball failed on systemfsoftware#606's aarch64-darwin leg.

pnpm 12 now uses http://registry.npmjs.org/ through mitm-cache's HTTP proxy, and every https tarball URL in the cache also answers on http as a redirect to the same fixed-output fetch, so no TLS is involved; integrity is still checked by Nix and again by pnpm. Checked locally with the https proxy pointed at a dead port: both tiny stores still build.

pnpm 11 runs on Node, which trusts the CA mitm-cache exports, and keeps the https path: its plain-HTTP requests 404 in mitm-cache

* fix(release): read the turbo pin from pnpm 12's lockfile stream

pnpm 12 writes pnpm-lock.yaml as two YAML documents, the env lockfile and then the project lockfile, each with its own lockfileVersion. change-evidence parsed the file as one document and died on the duplicate key, so changeset-management check could not run in any pnpm 12 repo (systemfsoftware). The pin now comes from the one document whose root importer pins turbo; a single-document lockfile still reads the same way

* build(nix): reap the darwin sandbox's process group on every exit

On macOS the launcher ran the sandboxed command and its --publish
forwarders as plain children. Nothing like bwrap's --die-with-parent exists
there, so a server the command started kept running after the launcher was
killed. It held the step's stdout open, and the "--publish port answers"
proof hung until the job's 30-minute cap, which GitHub reports as cancelled.

The darwin branch now starts the forwarders and the sandboxed command in a
single process group (set -m). On every exit, including HUP, INT and TERM,
the EXIT trap kills that whole group. Without a terminal the launcher waits
on the group, so a signal runs the trap at once. On a terminal the group
takes the foreground with fg, so the command still reads input and gets
Ctrl-C. Job control also leaves the command's stdin alone; a background job
without it reads /dev/null.

The macOS proofs now bound each launcher run with timeout --foreground, and
a new proof asserts that once the --publish launcher is killed, neither the
server nor the forwarder answers

* fix(release): refuse unreadable ledgers and append on adoption

readAt read any `git show` failure as "no ledger at this ref", so a bad
ref or a broken repository let the append-only check pass against an empty
base. It now asks git ls-tree whether the path exists at the ref. A missing
path is still Option.none; any other failure is LedgerUnreadable, carrying
git's diagnostic.

adopt replaced the whole ledger with the entries it fetched, which dropped
any entry whose tag had since left the remote. It now reads the existing
ledger, skips tags already in it and appends only the new ones. A ledger
that cannot be read or parsed stops adoption instead of being treated as
empty, and the CLI renders both refusals

* build(nix): refuse a lockfile package with no resolution

pnpm-lock.nix recorded a `packages:` entry only once its `resolution:`
line arrived. An entry without one dropped out of the tarball cache with no
error, and turned up later as an offline-install fetch failure on an
unrelated package. Every entry key is now recorded, and evaluation throws,
naming each package that has no resolution.

The pnpm-lock-parser flake check evaluates two inline fixtures: one entry
with no resolution, which must throw, and a well-formed lockfile, which must
produce the exact tarball cache. sandbox.yml builds the check on every
system

* docs(release): adopt appends to an existing ledger

Running adopt again keeps every entry and adds only tags the ledger lacks,
and an unreadable or unparseable ledger stops the command

* build(nix): name the outcome of the macOS bind proof

The proof that an undeclared bind is refused never exited on macos-26: its
node server only had an error handler, so a bind the kernel did not refuse
listened until the launcher timeout and the failure said nothing. The node
program now exits with its outcome (3 EPERM, 4 listening, 5 no outcome in
10 seconds, 6 any other error), and a failure prints that code with the
output, so the run itself says which one happened

* fix(release): refuse integrity verification with nothing to check

verifyIntegrity passed an empty list of checks, and passed an annotation
whose files map was empty, so either case let a tag through with nothing
verified. Both are now refusals: IntegrityNothingToVerify, and
IntegrityFilesEmpty naming the package, version and empty side. With both
empty cases refusing, verifyIntegrity no longer chooses between decisions,
so it is a plain function in integrity.ts instead of a Workflow. plan still
skips verification when no tagged candidate needs it, which keeps a fresh
repository plannable.

The property suite pinned the old Workflow cases and is gone. Four plan
scenarios cover the behaviour: empty files refuse, zero checks refuse, a
matching tarball verifies, and a changed one refuses naming the file

* build(nix): refuse undeclared loopback listeners on macOS

On macos-26 an undeclared bind to 127.0.0.1 succeeded inside the sandbox:
the bind proof's server reported listening (exit 4) instead of EPERM, and
without an outcome code it listened until the 30-minute job cap. The
profile granted network-inbound on every localhost port, and network-bind
only on declared ones. Inbound now has the same declared-port list as
bind, so a port the command did not declare with --listen or --publish
cannot serve

* fix(release): carry the tarball integrity check through main's changelog storage

Merging main's #22 into this layer left two gaps: #22's repository-storage tag scenario built its request without the tarballs this layer requires, and main's process-adapter git fake lacked tagAnnotation. The digest read becomes a const

* fix(release): read tarball digests only when a tagged package needs checking

Effect 4 has no Effect.if, so the conditional read is a small function with an early return

* test(release): give main's new fakes and scenario the ledger layer's ports

Main's #22 added changelogStorage to WorkspaceStore and a repository-storage tag scenario; this layer's tag needs the ledger and its GitPort reads tag commits and trees, so the merged fakes and scenario now provide them

* build(nix): trust mitm-cache's CA in pnpm 12 instead of replaying over plain HTTP

mitm-cache makes its per-build CA trusted through SSL_CERT_FILE only. pnpm
12 reads SSL_CERT_FILE on Linux but verifies through the macOS trust store
on darwin, so a store built on aarch64-darwin over https failed with
"invalid peer certificate: UnknownIssuer" (starter #52, job 112603379843).
This layer worked around it by pointing pnpm 12 at plain-HTTP
registry.npmjs.org, which dropped TLS rather than trusting the builder.

The replay hook now exports NODE_EXTRA_CA_CERTS=$MITM_CACHE_CA, an extra
root pnpm 12 adds to its verifier on every platform (pnpm 11 too), and
the registry stays https. The http:// redirect entries the plain-HTTP path
needed leave tarballCacheData and the parser check's expected cache

* build(nix): mint mitm-cache leaf certificates with the serverAuth usage macOS requires

With mitm-cache's CA trusted, pnpm 12 on macos-26-arm64 still refused every
replayed fetch: invalid peer certificate: Other(OtherError(EkuError)) (run
37565631293). mitm-cache 0.1.2 mints leaf certificates through hudsucker
0.22, which sets no key usage, extended key usage or authority key
identifier; Apple's TLS policy requires serverAuth. Linux's verifier does
not, so only darwin failed.

hudsucker upstream now sets all three. nix/mitm-cache.nix applies those
lines to the vendored hudsucker, and iplConfigHook is built from the input's
iplConfigHook.nix with that mitm-cache. A leaf served by the stock binary
reports no extensions; the patched one reports Digital Signature, TLS Web
Server Authentication and an authority key identifier. The cargo build gets
a writable HOME so an unsandboxed build leaves /homeless-shelter alone

* test(release): serve the stand-in registry in memory instead of on loopback

The adoption tests started their stand-in registry with listen(0) on
127.0.0.1. Inside the macOS sandbox every undeclared bind is refused, so
"Test through the sandbox" failed with listen EPERM on macOS (#12 2523818,
#13 0c37df8, #14 ef14cf3); Linux passed only because its sandbox has a
private network namespace.

The stand-in is a fake either way. The tests now provide
FetchHttpClient.Fetch with a function that answers the same metadata and
tarball routes from memory and records the same request paths, so
RegistryLive and the fetch client run unchanged and nothing listens. The
macOS bind refusal stays as it is

* build(nix): drop the macOS-only mitm-cache leaf certificate patch

ef14cf3 patched mitm-cache's vendored hudsucker 0.22 so its leaf
certificates carry the serverAuth extended key usage, which only macOS's TLS
policy requires. With macOS dropped (#29), stock mitm-cache from nixpkgs is
used again through importPnpmLock's own iplConfigHook, and the patch and the
override are removed. pnpm keeps the per-build CA as NODE_EXTRA_CA_CERTS over
https

* chore(version): name the packages the changesets versioning layer moves in an intent

* docs(version): keep one cargo surface paragraph, under changesets versioning

* chore(release): name the packages the tarball identity layer moves in an intent

* build(nix): put release-tools in this repository's own dev shell

This repository calls its own changeset check with devshell: true, so its dev shell meets the contract every caller meets and provides release-tools

* ci(release): keep the devshell changeset check, with tools from the caller's flake

The merged devshell mode (#27) stays whole: the devshell input, the caller's
own bootstrap script run in its nix develop shell, the refusal without one,
this repository's own self-caller with devshell: true and its bootstrap
script. The one change this layer makes to it is the tools source: in
devshell mode the check runs as `nix develop --command sandbox --
changeset-management check`, from the release-tools the caller's flake.lock
pins, with no tools-ref checkout or build. Callers without devshell keep the
tools-ref path unchanged, and the plain pnpm install exists only on that path.

The runs-on input (#32) stays on both workflows, and release.yml installs Nix
only on hosted runners, as #32 does. The dogfood caller now passes the
ci-workflow input release.yml requires, and ci.yml accepts workflow_dispatch
so the release can dispatch it.

The README CI section documents both modes

* docs(release): keep the devshell changeset check paragraph as merged

Restores the CI section's wording from main and changes only the sentences the flake-input tools make false: tools-ref and node-version in devshell mode, and which workflow builds .release-tools

* chore(release): name the packages the adoption ledger layer moves in an intent

* test(gate): provide the ledger port to the deleted-package scenario

The gate reads the adoption ledger on this layer, so the scenario main brought in (#26) needs the fake ledger every other gate scenario has

---------

Co-authored-by: systemfsoftware-maker <drdgvhbh@gmail.com>
kiro-systemf Bot pushed a commit that referenced this pull request Oct 7, 2026
)

* fix(version): refuse target suffixes that repeat

Each distribution target's suffix names its platform package
(<name>-<suffix>), and sync-root writes those names as optionalDependencies
keys. Two targets sharing a suffix collapsed into one key, so the manifest got
one pin while the decision reported two. CI found it as the property
counterexample ["a","0.0.0",["0","0"],false] (seed 1467794903).

release.jsonc decoding now refuses a distribution whose targets repeat a
suffix, naming it (target suffix "x" is declared by more than one target),
which ReleaseConfigStoreLive reports as ConfigMalformed. PinRootManifestCommand
refuses repeated suffixes and pin names too, and the cell builds the command
by decoding, so a collision is a typed SchemaError instead of a thrown
constructor. Both filters carry generation metadata: unique for suffix lists,
and a uniqueArray-by-suffix candidate for targets.

RepinsAll and RepinIdempotent keep their unrestricted draws and now state both
sides: distinct suffixes repin every target, colliding ones are refused. A
named scenario replays ["0","0"]. With the command checks removed, both laws
fail on the CI seed and the scenario fails

* test(repo): replay a fast-check seed from FC_SEED and FC_PATH

fast-check prints the seed and path of a failing property, but nothing read
them back, so a CI counterexample could not be replayed locally: FC_SEED was
ignored. vitest.fast-check.setup.ts sets fast-check's global seed from FC_SEED
(and the shrink path from FC_PATH), and every package with property tests
loads it. turbo hashes both variables for the test task, so a cached run never
stands in for a different seed.

FC_SEED=1467794903 FC_PATH=10:0:0:0:0 reproduces CI's ["a","0.0.0",["0","0"],false]
exactly against the unfixed command schema, while the same run without the
variables passes

* feat(nix): distribute workspace packages through Nix and sandbox dependency code

Packaging (`lib.mkPnpmWorkspacePackages`): one call in a flake gives each
public workspace member as `packages.<system>.<name>`, a deterministic
`pnpm pack` tarball built in the Nix sandbox, plus `workspace-tarballs`
(every tarball and an index.json) and `pnpm-store`. Third-party
dependencies come only from nixpkgs' `fetchPnpmDeps` (fetcherVersion 4),
keyed by the lockfile hash; no registry at build time. The library refuses a
workspace whose `packageManager` pins a different pnpm than the build uses.

- the tarballs rebuild bit-for-bit (`nix build --rebuild`). Three inferred
  exports in version-engine printed unions whose member order TypeScript 7
  varies from run to run (microsoft/TypeScript#64589); they are annotated with
  named types
- the CLI apps share the library's dependency fetch; the pinned hash in
  nix/cli-app.nix was stale and only a local store cache had hidden it
- packageManager pnpm@11.25.0, the version Nix provides

Sandbox (`packages.<system>.sandbox`): bubblewrap on Linux (--unshare-all,
--cap-drop ALL, --die-with-parent, --new-session, cleared environment), and
a deny-by-default sandbox-exec profile on macOS. The project directory is
read-write, /nix/store read-only, and $HOME a fresh tmpfs. Networking is
loopback only; `--allow-host` opens HTTPS to declared hosts through an
allow-list CONNECT proxy outside the sandbox. `--pnpm-store` (the dev shell
default) makes pnpm resolve offline from the Nix-built store with
frozen-lockfile and ignore-scripts.

`packages.<system>.sandbox-proofs` gates it. Each refusal proof prints from
inside the same sandbox first, so a sandbox that never starts fails instead
of passing. CI runs the proofs on Linux and macOS, then installs, builds and
tests this repository as three separate sandbox invocations with no network
at all, and checks that the tarballs rebuild bit-for-bit

* build(nix): evaluate the sandbox and the dev shell on every supported system

The macOS CI leg could not enter the dev shell: the sandbox script was read
back with builtins.readFile from a replaceVars output, an import from
derivation that forced a build at evaluation time. The substitution is now a
string replacement at evaluation. The dev shell takes the bubblewrap-wrapped
comment-checker only on Linux, where bubblewrap exists.

nixpkgs 26.11 dropped x86_64-darwin, so the flake no longer lists it, and the
dprint and denort release tables lose their x86_64-darwin pins

* build(nix): leave --ignore-scripts to pnpmConfigHook

pnpmConfigHook already installs with --ignore-scripts. pnpm 12 refuses the
flag twice, so the library adding it again broke every pnpm 12 workspace

* feat(nix): closure-only store, egress log, published and listen ports

The sandbox exposed the whole of /nix/store: bubblewrap bound it read-only
and the macOS profile allowed the subpath, so dependency code could list
the store and read any path in it. The launcher now resolves the
invocation's closure (nix-store --query --requisites over the sandboxed
PATH, the command, the pnpm store and its own helpers) and exposes only
those paths. On Linux they are bound over an empty /nix/store that cannot
be listed; on macOS a per-invocation profile grants file-read* and
file-map-executable on each one, plus file-read-metadata on the literal
/nix and /nix/store, which Node needs because it lstats every path
component.

- --egress-log PATH appends one JSONL line per proxy decision, allowed or
  refused, with host, port and the matched rule. A path inside the
  project, the sandbox HOME or its TMP is refused
- --publish HOST:SANDBOX exposes a sandbox port on host loopback; on Linux
  only published ports reach the host
- --listen PORT declares an internal port. macOS has no network namespace,
  so its profile allows binds only on --publish and --listen ports (port 0
  included) and names --listen when a bind was refused. A --listen port on
  macOS is reachable from host loopback, a stated platform limit
- pnpm fetches the optional binaries of every system the flake builds, so
  one dependency hash holds on all of them

The proofs cover each boundary and fail against a pass-through sandbox

* test(release): prove the release never publishes to npm

The npm retirement removed verdaccio from the harness along with the
publish phase, so nothing proved the pipeline stays off the registry.
verdaccio is back behind the redirected registry.npmjs.org as a tripwire:
it accepts publishes (publish: $all), so a regression that publishes lands
there instead of failing on auth, and it logs every request. The last
phase pings it and waits for that request in the log, so a tripwire that
logs nothing cannot pass, then asserts zero PUT requests and an empty
storage

* build(nix): give macOS sandbox runs a private XDG_RUNTIME_DIR

pnpm 12 takes its store-operation lock under XDG_RUNTIME_DIR when that points at
a directory only the user can write to, and otherwise falls back to /tmp, which
the deny-by-default darwin profile refuses, so an offline install inside the
sandbox fails on macOS. The darwin branch now creates a 0700 runtime dir inside
the run's remapped TMP and exports XDG_RUNTIME_DIR to it, so the lock lands in a
writable private path and darwin.sb keeps its write roots unchanged

The proof runs the pnpm that locks there: nixpkgs moves to aa48d34, whose
pnpm_12 builds the tiny store and lands on the proofs' PATH. proofs.sh asserts
that version, installs a tiny workspace offline from the --pnpm-store store, and
proves a real-/tmp write is refused on macOS (a private tmpfs on Linux) and
leaves nothing in the host /tmp. On macOS it repeats the install with
XDG_RUNTIME_DIR removed inside the sandbox and requires
ERR_PNPM_STORE_DIR_OPEN_OPERATION_LOCK, on every CI run. pnpm 12 is a native
binary per platform, so the tiny store's fixed-output hash is chosen by system.
The bump also re-pins denort to the deno 2.9.7 zips and moves the pinned pnpm to
11.27.0 in package.json and the e2e image. README documents the runtime dir and
the proofs; AGENTS.md records the approved rule that a launcher change is done
only when its darwin path ran a real offline pnpm install in CI

Without a sandbox (the macOS default and the dev container) nix builds with
HOME=/homeless-shelter, the real host path, and the locked comment-checker ran
cargo with that HOME, creating /homeless-shelter/.cargo so every later build
failed nix's purity check. comment-checker 3bfedd8 exports HOME under $TMPDIR
before cargo runs, and the sandbox workflow fails on both systems if any build
leaves /homeless-shelter behind

turbo.json passes TMPDIR through: strict env mode dropped it, so inside the
macOS sandbox rolldown-plugin-dts failed with EPERM on
mkdtemp('/tmp/rolldown-plugin-dts-*'). TMPDIR names where scratch goes, never
what a task outputs, so cache keys are unchanged

* build(nix): stop guessing a refused macOS bind from the unified log

The darwin launcher printed a --listen hint after a failed run when
`log show` found the kernel's `deny(1) network-bind` record. The kernel
hands that record to logd asynchronously, so a read right after the command
exits sometimes ran before it landed: the proof "an undeclared macOS bind
is refused and names --listen" failed once in sfs-xstate and passed on a
re-run. No wait can prove a record will never come, so the hint is gone.

The refusal itself is unchanged and is what the proof now asserts: the
program's own listen fails with EPERM. Linux has no hint to keep; a bind
there succeeds in the sandbox's private network namespace

* feat(release): verify pre-adoption releases against an append-only ledger

A repository adopting this tooling already has release tags whose bytes the
tooling never recorded. `release adopt --registry <url> --output <file>` records
them once: for every publishable member whose <name>@v<version> tag exists on
the remote it fetches the registry metadata, downloads dist.tarball, and writes
one ledger entry with the tag, its peeled commit, name@version, dist.integrity,
the sha256 of the download, and a per-file sha512 map. A version whose bytes
cannot be fetched is a hard error in the report and a non-zero exit.

The ledger is one JSON file at the repository root, written only by the command,
stable key order, entries sorted by tag, and lands in its own commit. `release
plan` accepts a lightweight tag only when the ledger holds it with the same
peeled commit and name@version; a moved tag or mismatched entry is a red refusal
naming both commits or values, and a ledgered version whose current packed
tarball differs from the ledger integrity is refused naming the first differing
file. A new lightweight tag outside the ledger stays the existing red refusal.
`changeset check <base>` fails red when an entry present at the base is removed
or changed at the head, proving the ledger is append-only.

Adds the adoption-adapter package: a ledger port (fs plus git show for the base
revision) and a registry port over Effect HttpClient with tar/hash through the
tarball adapter. Pure decisions carry property laws; gherkin integration tests
drive a real git repo, a real local registry server and real tgz bytes

* test(release): stub tagCommit in the git-hooks fake GitPort

* feat(release): adopt every existing release tag into the ledger

Adoption now records every release tag on the remote, not only the current
version of a current member: an older version of a current member (the
immutability law applies if that name@version is ever re-released) and a name
that is no longer a workspace member (parsed from the tag, split on the last
@v) are both fetched from the registry and ledgered. A tag of a current private
member is never published, so it is excluded and reported as private, never
published.

Fetches run four at a time and retry a transient registry failure (5xx or
timeout) three attempts with backoff; a 404 is not transient. The report prints
the ledgered/excluded/error counts then one line per error, and any error exits
non-zero writing no ledger at all. Registry failures now carry the HTTP status
so transient classification is the layer's, not the caller's.

The integration fixture now covers an older version, a retired name and a
private member; the README states the widened scope

* fix(release): resolve the published name from the tagged manifest

The tag string is not the published name. systemfsoftware tagged packages
unscoped before it moved to scoped names, so hex-schema@v1.0.0 points at a
commit whose packages/hex-schema/package.json says
@systemfsoftware/hex-schema, which the registry serves. Adopt was asking the
registry for the tag string and logged 331 false hard errors.

Adopt now reads every package.json at the tagged commit through a new GitPort
tagTree (git ls-tree plus git show, returning the peeled commit and the
manifests) and takes the one whose version equals the tag version and whose
name equals the tag name or ends with /<tag name>. Exactly one match is
required: zero or several matches is a hard error naming the tag and the
candidate names. A manifest with private true at that commit is excluded as
private, never published. The ledger entry keeps the tag as its key and records
the resolved published name, so the identity check is unchanged.

The integration fixture now builds per-version commits (an older version of one
member and an unscoped tag whose manifest carries the scope) and asserts the
unscoped tag is ledgered under its scoped name

* fix(release): record tag/manifest mismatches and refuse unfetchable versions

* feat(release): refuse a released version whose tarball changed

A released name@version is immutable: a consumer's lockfile pins its
integrity, so the same version must never pack different bytes. `tag` now
creates annotated tags whose message records the tarball's sha512 and a
sha512 per file inside it. `plan` checks every member whose current
version already has a release tag, except the members the pending
changesets plan moves: it reads the annotation from the remote, recomputes
the digest from the packed tarball and refuses a mismatch, naming
package@version, both hashes and the first differing file in sorted order
(a file on one side only counts, so a dependency-range change surfaces as
`package/package.json`). A lightweight tag or an unreadable annotation is
refused too.

`plan` and `tag` take a required `--tarballs DIR` (Nix `workspace-tarballs`
or `pnpm pack` output). A new tarball-adapter reads gzip and tar headers
with node:zlib and node:crypto; the decision is a pure workflow with
property laws.

The e2e pipeline releases the current state, versions alpha minor+patch,
asserts beta's packed dependency moves, re-packs the earlier release to
match its recorded digest, and shows a reverted manifest refused

* ci(release): run the release tools from the caller's pinned flake input

The reusable release and changeset-check workflows checked this repository
out at a moving `tools-ref`, ran `pnpm install` on it, and ran the bundles
with node, so every caller's CI installed and executed npm dependency code
unsandboxed. They now run `release-tools`, a new flake package that joins
the three release apps, from the caller's dev shell. The revision is the one
the caller's flake.lock pins for its pnpm-release-management input, so the
`tools-ref` and `node-version` inputs are gone.

A release PR opened with the workflow token starts no workflows, so the
version job now dispatches the caller's CI (new required `ci-workflow`
input, `actions: write`) on the release branch, as systemfsoftware's private
script did

changeset-check runs turbo, the caller's dependency code: its install and
the gate both run inside `sandbox`, offline from the Nix-built pnpm store

* fix(release): ledger unpublished tags as burned versions

* feat(version): bump a Cargo workspace as a release surface

A `cargo` surface ({ kind: "cargo", path: "Cargo.toml", package? }) keeps a
Rust workspace in step with the release. A bump rewrites the
[workspace.package] version in place, every member that pins a literal
[package] version, and the workspace-member entries in the sibling
Cargo.lock, so `cargo build --locked` still passes after the bump. Registry
and git entries are never touched. `version sync check` reports a member or
lock entry that lags.

Under the pnpm strategy, `package` names the workspace member whose bumped
version the Cargo workspace follows. Omitting it, naming a non-member, or
starting from a stale lock entry refuses the bump.

The e2e pipeline gains a phase that bumps a Cargo workspace beside the pnpm
packages and checks the lock

* feat(version): version each package through the changesets libraries

The `pnpm` strategy ran `pnpm version -r`, which takes its versions from the
npm registry: with npm retired, an intent on an unpublished package left it
at its old version, later intents folded into the same changelog, and
consumed intents were never deleted, so the plan asked for a version step on
every push.

The `changesets` strategy replaces it. A new changesets-adapter drives
@changesets/read, assemble-release-plan and apply-release-plan (exact pins)
offline: the highest intent per package wins, workspace dependents are bumped
and their ranges updated, private members version too (a cargo surface can
follow a private version carrier), and consumed intents are deleted in the
release PR. prm still writes one changelog per released member. Released
state is the git tag `<name>@v<version>`, so an untagged manifest version is
what the plan releases.

Workspace dependents always bump (`updateInternalDependents: 'always'`). A
bare `workspace:^` packs as `^<current version>`, so any version change in a
dependency changes the dependent's packed bytes. Out-of-range bumping would
ship those bytes under an already released name@version.

The apps now bundle builtins as `node:` imports (tsdown nodeProtocol) and
prefer a dependency's ESM `module` entry over its `main` (jsonc-parser's
`main` is a UMD build whose `require("./impl/format")` survives bundling);
`deno compile` needs both once the changesets dependencies are bundled. The
pnpm dependency hash moves with the lockfile.

The e2e pipeline gains a phase: minor and patch intents on one package, its
dependent bumped, intents removed, tags and releases created, and the next
plan settles on none

* fix(release): check tarball identity on publishable members only

A private workspace member is never packed, so a tagged private carrier
(systemfsoftware's @systemfsoftware/gritlint@0.1.0) has no tarball and was
refused as tarball-missing. Identity candidates now come from a pure
identityCandidates helper that keeps only publishable, tagged members — the
same notion the cycle already uses. Property laws pin the selection and the
private exclusion; an integration case proves a tagged private member with
no tarball does not refuse the plan

* ci(release): pack the caller's workspace and pass --tarballs to plan and tag

* build(nix): assemble pnpm stores from per-tarball integrity, with no store hash

* build(nix): trim lockfile fields with lib.trim so darwin evaluates

The lockfile reader trimmed with the regex '(.*[^[:space:]]|)[[:space:]]*'. glibc accepts the empty alternative, macOS libc rejects it, so every darwin evaluation of a workspace store failed with 'invalid regular expression' (systemfsoftware#606's aarch64-darwin leg). nixpkgs' lib.trim is portable

* build(nix): replay tarballs to pnpm 12 over plain HTTP

pnpm 12 verifies TLS through the platform verifier, which on macOS refuses mitm-cache's per-build certificates: UnknownIssuer (the keychain never sees the CA exported as SSL_CERT_FILE), EkuError once the CA is handed over through cafile (the leaf carries no serverAuth key usage), and its tarball client ignores strict-ssl. Replaying a tarball failed on systemfsoftware#606's aarch64-darwin leg.

pnpm 12 now uses http://registry.npmjs.org/ through mitm-cache's HTTP proxy, and every https tarball URL in the cache also answers on http as a redirect to the same fixed-output fetch, so no TLS is involved; integrity is still checked by Nix and again by pnpm. Checked locally with the https proxy pointed at a dead port: both tiny stores still build.

pnpm 11 runs on Node, which trusts the CA mitm-cache exports, and keeps the https path: its plain-HTTP requests 404 in mitm-cache

* fix(release): read the turbo pin from pnpm 12's lockfile stream

pnpm 12 writes pnpm-lock.yaml as two YAML documents, the env lockfile and then the project lockfile, each with its own lockfileVersion. change-evidence parsed the file as one document and died on the duplicate key, so changeset-management check could not run in any pnpm 12 repo (systemfsoftware). The pin now comes from the one document whose root importer pins turbo; a single-document lockfile still reads the same way

* feat(nix): build a consumer's store and pin its pnpm in the sandbox

A consumer of a workspace's tarballs could not install them in the sandbox.
Its store held only the registry packages, so a `file:` tarball install
tried to write into the read-only store and failed. lib.mkPnpmConsumerStore
builds one store from the consumer's lockfile: the registry packages through
the per-tarball fixed-output fetches, and the `file:` tarballs from `files`,
a directory-to-derivation map copied into the source before pnpm installs.

pnpm 12 opens its lockfile with an env document whose packageManager
dependencies are pnpm's own binaries for every platform. The store no longer
fetches them, because the sandbox and the store build turn
manage-package-manager-versions off. With it off, a pin the pnpm on PATH does
not satisfy would run the wrong pnpm, so the launcher refuses a different
major.minor, naming the pin and the version to use. It reads the pin from the
root pnpm uses, the nearest pnpm-workspace.yaml or else package.json, and
asks the pnpm on PATH for its version with management off, so the check
cannot fetch the pinned pnpm on the host.

The proofs add a consumer that installs a `file:` tarball frozen and offline
from its consumer store, while the third-party-only store fails. A pin on
another minor is refused; a pin on another patch installs offline, and the
same install fails once pnpm manages its version again, refused as
ERR_PNPM_PNPM_ENGINE_IDENTITY_UNVERIFIABLE. The README's consumer section
documents the store and the one host step: resolving the lockfile, which
reads registry metadata and runs no package code.

* build(nix): reap the darwin sandbox's process group on every exit

On macOS the launcher ran the sandboxed command and its --publish
forwarders as plain children. Nothing like bwrap's --die-with-parent exists
there, so a server the command started kept running after the launcher was
killed. It held the step's stdout open, and the "--publish port answers"
proof hung until the job's 30-minute cap, which GitHub reports as cancelled.

The darwin branch now starts the forwarders and the sandboxed command in a
single process group (set -m). On every exit, including HUP, INT and TERM,
the EXIT trap kills that whole group. Without a terminal the launcher waits
on the group, so a signal runs the trap at once. On a terminal the group
takes the foreground with fg, so the command still reads input and gets
Ctrl-C. Job control also leaves the command's stdin alone; a background job
without it reads /dev/null.

The macOS proofs now bound each launcher run with timeout --foreground, and
a new proof asserts that once the --publish launcher is killed, neither the
server nor the forwarder answers

* fix(release): refuse unreadable ledgers and append on adoption

readAt read any `git show` failure as "no ledger at this ref", so a bad
ref or a broken repository let the append-only check pass against an empty
base. It now asks git ls-tree whether the path exists at the ref. A missing
path is still Option.none; any other failure is LedgerUnreadable, carrying
git's diagnostic.

adopt replaced the whole ledger with the entries it fetched, which dropped
any entry whose tag had since left the remote. It now reads the existing
ledger, skips tags already in it and appends only the new ones. A ledger
that cannot be read or parsed stops adoption instead of being treated as
empty, and the CLI renders both refusals

* build(nix): refuse a lockfile package with no resolution

pnpm-lock.nix recorded a `packages:` entry only once its `resolution:`
line arrived. An entry without one dropped out of the tarball cache with no
error, and turned up later as an offline-install fetch failure on an
unrelated package. Every entry key is now recorded, and evaluation throws,
naming each package that has no resolution.

The pnpm-lock-parser flake check evaluates two inline fixtures: one entry
with no resolution, which must throw, and a well-formed lockfile, which must
produce the exact tarball cache. sandbox.yml builds the check on every
system

* docs(release): adopt appends to an existing ledger

Running adopt again keeps every entry and adds only tags the ledger lacks,
and an unreadable or unparseable ledger stops the command

* build(nix): name the outcome of the macOS bind proof

The proof that an undeclared bind is refused never exited on macos-26: its
node server only had an error handler, so a bind the kernel did not refuse
listened until the launcher timeout and the failure said nothing. The node
program now exits with its outcome (3 EPERM, 4 listening, 5 no outcome in
10 seconds, 6 any other error), and a failure prints that code with the
output, so the run itself says which one happened

* fix(release): refuse integrity verification with nothing to check

verifyIntegrity passed an empty list of checks, and passed an annotation
whose files map was empty, so either case let a tag through with nothing
verified. Both are now refusals: IntegrityNothingToVerify, and
IntegrityFilesEmpty naming the package, version and empty side. With both
empty cases refusing, verifyIntegrity no longer chooses between decisions,
so it is a plain function in integrity.ts instead of a Workflow. plan still
skips verification when no tagged candidate needs it, which keeps a fresh
repository plannable.

The property suite pinned the old Workflow cases and is gone. Four plan
scenarios cover the behaviour: empty files refuse, zero checks refuse, a
matching tarball verifies, and a changed one refuses naming the file

* build(nix): refuse undeclared loopback listeners on macOS

On macos-26 an undeclared bind to 127.0.0.1 succeeded inside the sandbox:
the bind proof's server reported listening (exit 4) instead of EPERM, and
without an outcome code it listened until the 30-minute job cap. The
profile granted network-inbound on every localhost port, and network-bind
only on declared ones. Inbound now has the same declared-port list as
bind, so a port the command did not declare with --listen or --publish
cannot serve

* fix(release): carry the tarball integrity check through main's changelog storage

Merging main's #22 into this layer left two gaps: #22's repository-storage tag scenario built its request without the tarballs this layer requires, and main's process-adapter git fake lacked tagAnnotation. The digest read becomes a const

* fix(release): read tarball digests only when a tagged package needs checking

Effect 4 has no Effect.if, so the conditional read is a small function with an early return

* test(release): give main's new fakes and scenario the ledger layer's ports

Main's #22 added changelogStorage to WorkspaceStore and a repository-storage tag scenario; this layer's tag needs the ledger and its GitPort reads tag commits and trees, so the merged fakes and scenario now provide them

* build(nix): trust mitm-cache's CA in pnpm 12 instead of replaying over plain HTTP

mitm-cache makes its per-build CA trusted through SSL_CERT_FILE only. pnpm
12 reads SSL_CERT_FILE on Linux but verifies through the macOS trust store
on darwin, so a store built on aarch64-darwin over https failed with
"invalid peer certificate: UnknownIssuer" (starter #52, job 112603379843).
This layer worked around it by pointing pnpm 12 at plain-HTTP
registry.npmjs.org, which dropped TLS rather than trusting the builder.

The replay hook now exports NODE_EXTRA_CA_CERTS=$MITM_CACHE_CA, an extra
root pnpm 12 adds to its verifier on every platform (pnpm 11 too), and
the registry stays https. The http:// redirect entries the plain-HTTP path
needed leave tarballCacheData and the parser check's expected cache

* build(nix): mint mitm-cache leaf certificates with the serverAuth usage macOS requires

With mitm-cache's CA trusted, pnpm 12 on macos-26-arm64 still refused every
replayed fetch: invalid peer certificate: Other(OtherError(EkuError)) (run
37565631293). mitm-cache 0.1.2 mints leaf certificates through hudsucker
0.22, which sets no key usage, extended key usage or authority key
identifier; Apple's TLS policy requires serverAuth. Linux's verifier does
not, so only darwin failed.

hudsucker upstream now sets all three. nix/mitm-cache.nix applies those
lines to the vendored hudsucker, and iplConfigHook is built from the input's
iplConfigHook.nix with that mitm-cache. A leaf served by the stock binary
reports no extensions; the patched one reports Digital Signature, TLS Web
Server Authentication and an authority key identifier. The cargo build gets
a writable HOME so an unsandboxed build leaves /homeless-shelter alone

* test(release): serve the stand-in registry in memory instead of on loopback

The adoption tests started their stand-in registry with listen(0) on
127.0.0.1. Inside the macOS sandbox every undeclared bind is refused, so
"Test through the sandbox" failed with listen EPERM on macOS (#12 2523818,
#13 0c37df8, #14 ef14cf3); Linux passed only because its sandbox has a
private network namespace.

The stand-in is a fake either way. The tests now provide
FetchHttpClient.Fetch with a function that answers the same metadata and
tarball routes from memory and records the same request paths, so
RegistryLive and the fetch client run unchanged and nothing listens. The
macOS bind refusal stays as it is

* build(nix): drop the macOS-only mitm-cache leaf certificate patch

ef14cf3 patched mitm-cache's vendored hudsucker 0.22 so its leaf
certificates carry the serverAuth extended key usage, which only macOS's TLS
policy requires. With macOS dropped (#29), stock mitm-cache from nixpkgs is
used again through importPnpmLock's own iplConfigHook, and the patch and the
override are removed. pnpm keeps the per-build CA as NODE_EXTRA_CA_CERTS over
https

* chore(version): name the packages the changesets versioning layer moves in an intent

* docs(version): keep one cargo surface paragraph, under changesets versioning

* chore(release): name the packages the tarball identity layer moves in an intent

* build(nix): put release-tools in this repository's own dev shell

This repository calls its own changeset check with devshell: true, so its dev shell meets the contract every caller meets and provides release-tools

* ci(release): keep the devshell changeset check, with tools from the caller's flake

The merged devshell mode (#27) stays whole: the devshell input, the caller's
own bootstrap script run in its nix develop shell, the refusal without one,
this repository's own self-caller with devshell: true and its bootstrap
script. The one change this layer makes to it is the tools source: in
devshell mode the check runs as `nix develop --command sandbox --
changeset-management check`, from the release-tools the caller's flake.lock
pins, with no tools-ref checkout or build. Callers without devshell keep the
tools-ref path unchanged, and the plain pnpm install exists only on that path.

The runs-on input (#32) stays on both workflows, and release.yml installs Nix
only on hosted runners, as #32 does. The dogfood caller now passes the
ci-workflow input release.yml requires, and ci.yml accepts workflow_dispatch
so the release can dispatch it.

The README CI section documents both modes

* docs(release): keep the devshell changeset check paragraph as merged

Restores the CI section's wording from main and changes only the sentences the flake-input tools make false: tools-ref and node-version in devshell mode, and which workflow builds .release-tools

* chore(release): name the packages the adoption ledger layer moves in an intent

* test(gate): provide the ledger port to the deleted-package scenario

The gate reads the adoption ledger on this layer, so the scenario main brought in (#26) needs the fake ledger every other gate scenario has

---------

Co-authored-by: systemfsoftware-maker <drdgvhbh@gmail.com>
kiro-systemf Bot pushed a commit that referenced this pull request Oct 7, 2026
…dbox (#24)

* fix(version): refuse target suffixes that repeat

Each distribution target's suffix names its platform package
(<name>-<suffix>), and sync-root writes those names as optionalDependencies
keys. Two targets sharing a suffix collapsed into one key, so the manifest got
one pin while the decision reported two. CI found it as the property
counterexample ["a","0.0.0",["0","0"],false] (seed 1467794903).

release.jsonc decoding now refuses a distribution whose targets repeat a
suffix, naming it (target suffix "x" is declared by more than one target),
which ReleaseConfigStoreLive reports as ConfigMalformed. PinRootManifestCommand
refuses repeated suffixes and pin names too, and the cell builds the command
by decoding, so a collision is a typed SchemaError instead of a thrown
constructor. Both filters carry generation metadata: unique for suffix lists,
and a uniqueArray-by-suffix candidate for targets.

RepinsAll and RepinIdempotent keep their unrestricted draws and now state both
sides: distinct suffixes repin every target, colliding ones are refused. A
named scenario replays ["0","0"]. With the command checks removed, both laws
fail on the CI seed and the scenario fails

* test(repo): replay a fast-check seed from FC_SEED and FC_PATH

fast-check prints the seed and path of a failing property, but nothing read
them back, so a CI counterexample could not be replayed locally: FC_SEED was
ignored. vitest.fast-check.setup.ts sets fast-check's global seed from FC_SEED
(and the shrink path from FC_PATH), and every package with property tests
loads it. turbo hashes both variables for the test task, so a cached run never
stands in for a different seed.

FC_SEED=1467794903 FC_PATH=10:0:0:0:0 reproduces CI's ["a","0.0.0",["0","0"],false]
exactly against the unfixed command schema, while the same run without the
variables passes

* feat(nix): distribute workspace packages through Nix and sandbox dependency code

Packaging (`lib.mkPnpmWorkspacePackages`): one call in a flake gives each
public workspace member as `packages.<system>.<name>`, a deterministic
`pnpm pack` tarball built in the Nix sandbox, plus `workspace-tarballs`
(every tarball and an index.json) and `pnpm-store`. Third-party
dependencies come only from nixpkgs' `fetchPnpmDeps` (fetcherVersion 4),
keyed by the lockfile hash; no registry at build time. The library refuses a
workspace whose `packageManager` pins a different pnpm than the build uses.

- the tarballs rebuild bit-for-bit (`nix build --rebuild`). Three inferred
  exports in version-engine printed unions whose member order TypeScript 7
  varies from run to run (microsoft/TypeScript#64589); they are annotated with
  named types
- the CLI apps share the library's dependency fetch; the pinned hash in
  nix/cli-app.nix was stale and only a local store cache had hidden it
- packageManager pnpm@11.25.0, the version Nix provides

Sandbox (`packages.<system>.sandbox`): bubblewrap on Linux (--unshare-all,
--cap-drop ALL, --die-with-parent, --new-session, cleared environment), and
a deny-by-default sandbox-exec profile on macOS. The project directory is
read-write, /nix/store read-only, and $HOME a fresh tmpfs. Networking is
loopback only; `--allow-host` opens HTTPS to declared hosts through an
allow-list CONNECT proxy outside the sandbox. `--pnpm-store` (the dev shell
default) makes pnpm resolve offline from the Nix-built store with
frozen-lockfile and ignore-scripts.

`packages.<system>.sandbox-proofs` gates it. Each refusal proof prints from
inside the same sandbox first, so a sandbox that never starts fails instead
of passing. CI runs the proofs on Linux and macOS, then installs, builds and
tests this repository as three separate sandbox invocations with no network
at all, and checks that the tarballs rebuild bit-for-bit

* build(nix): evaluate the sandbox and the dev shell on every supported system

The macOS CI leg could not enter the dev shell: the sandbox script was read
back with builtins.readFile from a replaceVars output, an import from
derivation that forced a build at evaluation time. The substitution is now a
string replacement at evaluation. The dev shell takes the bubblewrap-wrapped
comment-checker only on Linux, where bubblewrap exists.

nixpkgs 26.11 dropped x86_64-darwin, so the flake no longer lists it, and the
dprint and denort release tables lose their x86_64-darwin pins

* build(nix): leave --ignore-scripts to pnpmConfigHook

pnpmConfigHook already installs with --ignore-scripts. pnpm 12 refuses the
flag twice, so the library adding it again broke every pnpm 12 workspace

* feat(nix): closure-only store, egress log, published and listen ports

The sandbox exposed the whole of /nix/store: bubblewrap bound it read-only
and the macOS profile allowed the subpath, so dependency code could list
the store and read any path in it. The launcher now resolves the
invocation's closure (nix-store --query --requisites over the sandboxed
PATH, the command, the pnpm store and its own helpers) and exposes only
those paths. On Linux they are bound over an empty /nix/store that cannot
be listed; on macOS a per-invocation profile grants file-read* and
file-map-executable on each one, plus file-read-metadata on the literal
/nix and /nix/store, which Node needs because it lstats every path
component.

- --egress-log PATH appends one JSONL line per proxy decision, allowed or
  refused, with host, port and the matched rule. A path inside the
  project, the sandbox HOME or its TMP is refused
- --publish HOST:SANDBOX exposes a sandbox port on host loopback; on Linux
  only published ports reach the host
- --listen PORT declares an internal port. macOS has no network namespace,
  so its profile allows binds only on --publish and --listen ports (port 0
  included) and names --listen when a bind was refused. A --listen port on
  macOS is reachable from host loopback, a stated platform limit
- pnpm fetches the optional binaries of every system the flake builds, so
  one dependency hash holds on all of them

The proofs cover each boundary and fail against a pass-through sandbox

* test(release): prove the release never publishes to npm

The npm retirement removed verdaccio from the harness along with the
publish phase, so nothing proved the pipeline stays off the registry.
verdaccio is back behind the redirected registry.npmjs.org as a tripwire:
it accepts publishes (publish: $all), so a regression that publishes lands
there instead of failing on auth, and it logs every request. The last
phase pings it and waits for that request in the log, so a tripwire that
logs nothing cannot pass, then asserts zero PUT requests and an empty
storage

* build(nix): give macOS sandbox runs a private XDG_RUNTIME_DIR

pnpm 12 takes its store-operation lock under XDG_RUNTIME_DIR when that points at
a directory only the user can write to, and otherwise falls back to /tmp, which
the deny-by-default darwin profile refuses, so an offline install inside the
sandbox fails on macOS. The darwin branch now creates a 0700 runtime dir inside
the run's remapped TMP and exports XDG_RUNTIME_DIR to it, so the lock lands in a
writable private path and darwin.sb keeps its write roots unchanged

The proof runs the pnpm that locks there: nixpkgs moves to aa48d34, whose
pnpm_12 builds the tiny store and lands on the proofs' PATH. proofs.sh asserts
that version, installs a tiny workspace offline from the --pnpm-store store, and
proves a real-/tmp write is refused on macOS (a private tmpfs on Linux) and
leaves nothing in the host /tmp. On macOS it repeats the install with
XDG_RUNTIME_DIR removed inside the sandbox and requires
ERR_PNPM_STORE_DIR_OPEN_OPERATION_LOCK, on every CI run. pnpm 12 is a native
binary per platform, so the tiny store's fixed-output hash is chosen by system.
The bump also re-pins denort to the deno 2.9.7 zips and moves the pinned pnpm to
11.27.0 in package.json and the e2e image. README documents the runtime dir and
the proofs; AGENTS.md records the approved rule that a launcher change is done
only when its darwin path ran a real offline pnpm install in CI

Without a sandbox (the macOS default and the dev container) nix builds with
HOME=/homeless-shelter, the real host path, and the locked comment-checker ran
cargo with that HOME, creating /homeless-shelter/.cargo so every later build
failed nix's purity check. comment-checker 3bfedd8 exports HOME under $TMPDIR
before cargo runs, and the sandbox workflow fails on both systems if any build
leaves /homeless-shelter behind

turbo.json passes TMPDIR through: strict env mode dropped it, so inside the
macOS sandbox rolldown-plugin-dts failed with EPERM on
mkdtemp('/tmp/rolldown-plugin-dts-*'). TMPDIR names where scratch goes, never
what a task outputs, so cache keys are unchanged

* build(nix): stop guessing a refused macOS bind from the unified log

The darwin launcher printed a --listen hint after a failed run when
`log show` found the kernel's `deny(1) network-bind` record. The kernel
hands that record to logd asynchronously, so a read right after the command
exits sometimes ran before it landed: the proof "an undeclared macOS bind
is refused and names --listen" failed once in sfs-xstate and passed on a
re-run. No wait can prove a record will never come, so the hint is gone.

The refusal itself is unchanged and is what the proof now asserts: the
program's own listen fails with EPERM. Linux has no hint to keep; a bind
there succeeds in the sandbox's private network namespace

* feat(release): verify pre-adoption releases against an append-only ledger

A repository adopting this tooling already has release tags whose bytes the
tooling never recorded. `release adopt --registry <url> --output <file>` records
them once: for every publishable member whose <name>@v<version> tag exists on
the remote it fetches the registry metadata, downloads dist.tarball, and writes
one ledger entry with the tag, its peeled commit, name@version, dist.integrity,
the sha256 of the download, and a per-file sha512 map. A version whose bytes
cannot be fetched is a hard error in the report and a non-zero exit.

The ledger is one JSON file at the repository root, written only by the command,
stable key order, entries sorted by tag, and lands in its own commit. `release
plan` accepts a lightweight tag only when the ledger holds it with the same
peeled commit and name@version; a moved tag or mismatched entry is a red refusal
naming both commits or values, and a ledgered version whose current packed
tarball differs from the ledger integrity is refused naming the first differing
file. A new lightweight tag outside the ledger stays the existing red refusal.
`changeset check <base>` fails red when an entry present at the base is removed
or changed at the head, proving the ledger is append-only.

Adds the adoption-adapter package: a ledger port (fs plus git show for the base
revision) and a registry port over Effect HttpClient with tar/hash through the
tarball adapter. Pure decisions carry property laws; gherkin integration tests
drive a real git repo, a real local registry server and real tgz bytes

* test(release): stub tagCommit in the git-hooks fake GitPort

* feat(release): adopt every existing release tag into the ledger

Adoption now records every release tag on the remote, not only the current
version of a current member: an older version of a current member (the
immutability law applies if that name@version is ever re-released) and a name
that is no longer a workspace member (parsed from the tag, split on the last
@v) are both fetched from the registry and ledgered. A tag of a current private
member is never published, so it is excluded and reported as private, never
published.

Fetches run four at a time and retry a transient registry failure (5xx or
timeout) three attempts with backoff; a 404 is not transient. The report prints
the ledgered/excluded/error counts then one line per error, and any error exits
non-zero writing no ledger at all. Registry failures now carry the HTTP status
so transient classification is the layer's, not the caller's.

The integration fixture now covers an older version, a retired name and a
private member; the README states the widened scope

* fix(release): resolve the published name from the tagged manifest

The tag string is not the published name. systemfsoftware tagged packages
unscoped before it moved to scoped names, so hex-schema@v1.0.0 points at a
commit whose packages/hex-schema/package.json says
@systemfsoftware/hex-schema, which the registry serves. Adopt was asking the
registry for the tag string and logged 331 false hard errors.

Adopt now reads every package.json at the tagged commit through a new GitPort
tagTree (git ls-tree plus git show, returning the peeled commit and the
manifests) and takes the one whose version equals the tag version and whose
name equals the tag name or ends with /<tag name>. Exactly one match is
required: zero or several matches is a hard error naming the tag and the
candidate names. A manifest with private true at that commit is excluded as
private, never published. The ledger entry keeps the tag as its key and records
the resolved published name, so the identity check is unchanged.

The integration fixture now builds per-version commits (an older version of one
member and an unscoped tag whose manifest carries the scope) and asserts the
unscoped tag is ledgered under its scoped name

* fix(release): record tag/manifest mismatches and refuse unfetchable versions

* feat(release): refuse a released version whose tarball changed

A released name@version is immutable: a consumer's lockfile pins its
integrity, so the same version must never pack different bytes. `tag` now
creates annotated tags whose message records the tarball's sha512 and a
sha512 per file inside it. `plan` checks every member whose current
version already has a release tag, except the members the pending
changesets plan moves: it reads the annotation from the remote, recomputes
the digest from the packed tarball and refuses a mismatch, naming
package@version, both hashes and the first differing file in sorted order
(a file on one side only counts, so a dependency-range change surfaces as
`package/package.json`). A lightweight tag or an unreadable annotation is
refused too.

`plan` and `tag` take a required `--tarballs DIR` (Nix `workspace-tarballs`
or `pnpm pack` output). A new tarball-adapter reads gzip and tar headers
with node:zlib and node:crypto; the decision is a pure workflow with
property laws.

The e2e pipeline releases the current state, versions alpha minor+patch,
asserts beta's packed dependency moves, re-packs the earlier release to
match its recorded digest, and shows a reverted manifest refused

* ci(release): run the release tools from the caller's pinned flake input

The reusable release and changeset-check workflows checked this repository
out at a moving `tools-ref`, ran `pnpm install` on it, and ran the bundles
with node, so every caller's CI installed and executed npm dependency code
unsandboxed. They now run `release-tools`, a new flake package that joins
the three release apps, from the caller's dev shell. The revision is the one
the caller's flake.lock pins for its pnpm-release-management input, so the
`tools-ref` and `node-version` inputs are gone.

A release PR opened with the workflow token starts no workflows, so the
version job now dispatches the caller's CI (new required `ci-workflow`
input, `actions: write`) on the release branch, as systemfsoftware's private
script did

changeset-check runs turbo, the caller's dependency code: its install and
the gate both run inside `sandbox`, offline from the Nix-built pnpm store

* fix(release): ledger unpublished tags as burned versions

* feat(version): bump a Cargo workspace as a release surface

A `cargo` surface ({ kind: "cargo", path: "Cargo.toml", package? }) keeps a
Rust workspace in step with the release. A bump rewrites the
[workspace.package] version in place, every member that pins a literal
[package] version, and the workspace-member entries in the sibling
Cargo.lock, so `cargo build --locked` still passes after the bump. Registry
and git entries are never touched. `version sync check` reports a member or
lock entry that lags.

Under the pnpm strategy, `package` names the workspace member whose bumped
version the Cargo workspace follows. Omitting it, naming a non-member, or
starting from a stale lock entry refuses the bump.

The e2e pipeline gains a phase that bumps a Cargo workspace beside the pnpm
packages and checks the lock

* feat(version): version each package through the changesets libraries

The `pnpm` strategy ran `pnpm version -r`, which takes its versions from the
npm registry: with npm retired, an intent on an unpublished package left it
at its old version, later intents folded into the same changelog, and
consumed intents were never deleted, so the plan asked for a version step on
every push.

The `changesets` strategy replaces it. A new changesets-adapter drives
@changesets/read, assemble-release-plan and apply-release-plan (exact pins)
offline: the highest intent per package wins, workspace dependents are bumped
and their ranges updated, private members version too (a cargo surface can
follow a private version carrier), and consumed intents are deleted in the
release PR. prm still writes one changelog per released member. Released
state is the git tag `<name>@v<version>`, so an untagged manifest version is
what the plan releases.

Workspace dependents always bump (`updateInternalDependents: 'always'`). A
bare `workspace:^` packs as `^<current version>`, so any version change in a
dependency changes the dependent's packed bytes. Out-of-range bumping would
ship those bytes under an already released name@version.

The apps now bundle builtins as `node:` imports (tsdown nodeProtocol) and
prefer a dependency's ESM `module` entry over its `main` (jsonc-parser's
`main` is a UMD build whose `require("./impl/format")` survives bundling);
`deno compile` needs both once the changesets dependencies are bundled. The
pnpm dependency hash moves with the lockfile.

The e2e pipeline gains a phase: minor and patch intents on one package, its
dependent bumped, intents removed, tags and releases created, and the next
plan settles on none

* fix(release): check tarball identity on publishable members only

A private workspace member is never packed, so a tagged private carrier
(systemfsoftware's @systemfsoftware/gritlint@0.1.0) has no tarball and was
refused as tarball-missing. Identity candidates now come from a pure
identityCandidates helper that keeps only publishable, tagged members — the
same notion the cycle already uses. Property laws pin the selection and the
private exclusion; an integration case proves a tagged private member with
no tarball does not refuse the plan

* ci(release): pack the caller's workspace and pass --tarballs to plan and tag

* build(nix): assemble pnpm stores from per-tarball integrity, with no store hash

* build(nix): trim lockfile fields with lib.trim so darwin evaluates

The lockfile reader trimmed with the regex '(.*[^[:space:]]|)[[:space:]]*'. glibc accepts the empty alternative, macOS libc rejects it, so every darwin evaluation of a workspace store failed with 'invalid regular expression' (systemfsoftware#606's aarch64-darwin leg). nixpkgs' lib.trim is portable

* build(nix): replay tarballs to pnpm 12 over plain HTTP

pnpm 12 verifies TLS through the platform verifier, which on macOS refuses mitm-cache's per-build certificates: UnknownIssuer (the keychain never sees the CA exported as SSL_CERT_FILE), EkuError once the CA is handed over through cafile (the leaf carries no serverAuth key usage), and its tarball client ignores strict-ssl. Replaying a tarball failed on systemfsoftware#606's aarch64-darwin leg.

pnpm 12 now uses http://registry.npmjs.org/ through mitm-cache's HTTP proxy, and every https tarball URL in the cache also answers on http as a redirect to the same fixed-output fetch, so no TLS is involved; integrity is still checked by Nix and again by pnpm. Checked locally with the https proxy pointed at a dead port: both tiny stores still build.

pnpm 11 runs on Node, which trusts the CA mitm-cache exports, and keeps the https path: its plain-HTTP requests 404 in mitm-cache

* fix(release): read the turbo pin from pnpm 12's lockfile stream

pnpm 12 writes pnpm-lock.yaml as two YAML documents, the env lockfile and then the project lockfile, each with its own lockfileVersion. change-evidence parsed the file as one document and died on the duplicate key, so changeset-management check could not run in any pnpm 12 repo (systemfsoftware). The pin now comes from the one document whose root importer pins turbo; a single-document lockfile still reads the same way

* feat(nix): build a consumer's store and pin its pnpm in the sandbox

A consumer of a workspace's tarballs could not install them in the sandbox.
Its store held only the registry packages, so a `file:` tarball install
tried to write into the read-only store and failed. lib.mkPnpmConsumerStore
builds one store from the consumer's lockfile: the registry packages through
the per-tarball fixed-output fetches, and the `file:` tarballs from `files`,
a directory-to-derivation map copied into the source before pnpm installs.

pnpm 12 opens its lockfile with an env document whose packageManager
dependencies are pnpm's own binaries for every platform. The store no longer
fetches them, because the sandbox and the store build turn
manage-package-manager-versions off. With it off, a pin the pnpm on PATH does
not satisfy would run the wrong pnpm, so the launcher refuses a different
major.minor, naming the pin and the version to use. It reads the pin from the
root pnpm uses, the nearest pnpm-workspace.yaml or else package.json, and
asks the pnpm on PATH for its version with management off, so the check
cannot fetch the pinned pnpm on the host.

The proofs add a consumer that installs a `file:` tarball frozen and offline
from its consumer store, while the third-party-only store fails. A pin on
another minor is refused; a pin on another patch installs offline, and the
same install fails once pnpm manages its version again, refused as
ERR_PNPM_PNPM_ENGINE_IDENTITY_UNVERIFIABLE. The README's consumer section
documents the store and the one host step: resolving the lockfile, which
reads registry metadata and runs no package code.

* build(nix): let linked worktrees and submodules commit inside the sandbox

A linked worktree's .git file points at <main>/.git/worktrees/<name>, and
a submodule's at <super>/.git/modules/<name>; neither sits under the
project, so git inside the sandbox could not read its own repository.
The launcher now binds the worktree's git dir and the common dir's
objects, refs, logs and modules read-write, and its config, packed-refs,
info and shallow read-only, on Linux and macOS.

Proofs: a linked worktree commits and the commit lands in the shared
refs; it cannot rewrite the shared config or write the main checkout;
a submodule commits through .git/modules. With the read-write binds
removed, the three commit proofs fail.

* build(nix): reap the darwin sandbox's process group on every exit

On macOS the launcher ran the sandboxed command and its --publish
forwarders as plain children. Nothing like bwrap's --die-with-parent exists
there, so a server the command started kept running after the launcher was
killed. It held the step's stdout open, and the "--publish port answers"
proof hung until the job's 30-minute cap, which GitHub reports as cancelled.

The darwin branch now starts the forwarders and the sandboxed command in a
single process group (set -m). On every exit, including HUP, INT and TERM,
the EXIT trap kills that whole group. Without a terminal the launcher waits
on the group, so a signal runs the trap at once. On a terminal the group
takes the foreground with fg, so the command still reads input and gets
Ctrl-C. Job control also leaves the command's stdin alone; a background job
without it reads /dev/null.

The macOS proofs now bound each launcher run with timeout --foreground, and
a new proof asserts that once the --publish launcher is killed, neither the
server nor the forwarder answers

* fix(release): refuse unreadable ledgers and append on adoption

readAt read any `git show` failure as "no ledger at this ref", so a bad
ref or a broken repository let the append-only check pass against an empty
base. It now asks git ls-tree whether the path exists at the ref. A missing
path is still Option.none; any other failure is LedgerUnreadable, carrying
git's diagnostic.

adopt replaced the whole ledger with the entries it fetched, which dropped
any entry whose tag had since left the remote. It now reads the existing
ledger, skips tags already in it and appends only the new ones. A ledger
that cannot be read or parsed stops adoption instead of being treated as
empty, and the CLI renders both refusals

* build(nix): refuse a lockfile package with no resolution

pnpm-lock.nix recorded a `packages:` entry only once its `resolution:`
line arrived. An entry without one dropped out of the tarball cache with no
error, and turned up later as an offline-install fetch failure on an
unrelated package. Every entry key is now recorded, and evaluation throws,
naming each package that has no resolution.

The pnpm-lock-parser flake check evaluates two inline fixtures: one entry
with no resolution, which must throw, and a well-formed lockfile, which must
produce the exact tarball cache. sandbox.yml builds the check on every
system

* docs(release): adopt appends to an existing ledger

Running adopt again keeps every entry and adds only tags the ledger lacks,
and an unreadable or unparseable ledger stops the command

* build(nix): name the outcome of the macOS bind proof

The proof that an undeclared bind is refused never exited on macos-26: its
node server only had an error handler, so a bind the kernel did not refuse
listened until the launcher timeout and the failure said nothing. The node
program now exits with its outcome (3 EPERM, 4 listening, 5 no outcome in
10 seconds, 6 any other error), and a failure prints that code with the
output, so the run itself says which one happened

* fix(release): refuse integrity verification with nothing to check

verifyIntegrity passed an empty list of checks, and passed an annotation
whose files map was empty, so either case let a tag through with nothing
verified. Both are now refusals: IntegrityNothingToVerify, and
IntegrityFilesEmpty naming the package, version and empty side. With both
empty cases refusing, verifyIntegrity no longer chooses between decisions,
so it is a plain function in integrity.ts instead of a Workflow. plan still
skips verification when no tagged candidate needs it, which keeps a fresh
repository plannable.

The property suite pinned the old Workflow cases and is gone. Four plan
scenarios cover the behaviour: empty files refuse, zero checks refuse, a
matching tarball verifies, and a changed one refuses naming the file

* build(nix): refuse undeclared loopback listeners on macOS

On macos-26 an undeclared bind to 127.0.0.1 succeeded inside the sandbox:
the bind proof's server reported listening (exit 4) instead of EPERM, and
without an outcome code it listened until the 30-minute job cap. The
profile granted network-inbound on every localhost port, and network-bind
only on declared ones. Inbound now has the same declared-port list as
bind, so a port the command did not declare with --listen or --publish
cannot serve

* fix(release): carry the tarball integrity check through main's changelog storage

Merging main's #22 into this layer left two gaps: #22's repository-storage tag scenario built its request without the tarballs this layer requires, and main's process-adapter git fake lacked tagAnnotation. The digest read becomes a const

* fix(release): read tarball digests only when a tagged package needs checking

Effect 4 has no Effect.if, so the conditional read is a small function with an early return

* test(release): give main's new fakes and scenario the ledger layer's ports

Main's #22 added changelogStorage to WorkspaceStore and a repository-storage tag scenario; this layer's tag needs the ledger and its GitPort reads tag commits and trees, so the merged fakes and scenario now provide them

* build(nix): trust mitm-cache's CA in pnpm 12 instead of replaying over plain HTTP

mitm-cache makes its per-build CA trusted through SSL_CERT_FILE only. pnpm
12 reads SSL_CERT_FILE on Linux but verifies through the macOS trust store
on darwin, so a store built on aarch64-darwin over https failed with
"invalid peer certificate: UnknownIssuer" (starter #52, job 112603379843).
This layer worked around it by pointing pnpm 12 at plain-HTTP
registry.npmjs.org, which dropped TLS rather than trusting the builder.

The replay hook now exports NODE_EXTRA_CA_CERTS=$MITM_CACHE_CA, an extra
root pnpm 12 adds to its verifier on every platform (pnpm 11 too), and
the registry stays https. The http:// redirect entries the plain-HTTP path
needed leave tarballCacheData and the parser check's expected cache

* build(nix): mint mitm-cache leaf certificates with the serverAuth usage macOS requires

With mitm-cache's CA trusted, pnpm 12 on macos-26-arm64 still refused every
replayed fetch: invalid peer certificate: Other(OtherError(EkuError)) (run
37565631293). mitm-cache 0.1.2 mints leaf certificates through hudsucker
0.22, which sets no key usage, extended key usage or authority key
identifier; Apple's TLS policy requires serverAuth. Linux's verifier does
not, so only darwin failed.

hudsucker upstream now sets all three. nix/mitm-cache.nix applies those
lines to the vendored hudsucker, and iplConfigHook is built from the input's
iplConfigHook.nix with that mitm-cache. A leaf served by the stock binary
reports no extensions; the patched one reports Digital Signature, TLS Web
Server Authentication and an authority key identifier. The cargo build gets
a writable HOME so an unsandboxed build leaves /homeless-shelter alone

* test(release): serve the stand-in registry in memory instead of on loopback

The adoption tests started their stand-in registry with listen(0) on
127.0.0.1. Inside the macOS sandbox every undeclared bind is refused, so
"Test through the sandbox" failed with listen EPERM on macOS (#12 2523818,
#13 0c37df8, #14 ef14cf3); Linux passed only because its sandbox has a
private network namespace.

The stand-in is a fake either way. The tests now provide
FetchHttpClient.Fetch with a function that answers the same metadata and
tarball routes from memory and records the same request paths, so
RegistryLive and the fetch client run unchanged and nothing listens. The
macOS bind refusal stays as it is

* build(nix): drop the macOS-only mitm-cache leaf certificate patch

ef14cf3 patched mitm-cache's vendored hudsucker 0.22 so its leaf
certificates carry the serverAuth extended key usage, which only macOS's TLS
policy requires. With macOS dropped (#29), stock mitm-cache from nixpkgs is
used again through importPnpmLock's own iplConfigHook, and the patch and the
override are removed. pnpm keeps the per-build CA as NODE_EXTRA_CA_CERTS over
https

* chore(version): name the packages the changesets versioning layer moves in an intent

* docs(version): keep one cargo surface paragraph, under changesets versioning

* chore(release): name the packages the tarball identity layer moves in an intent

* build(nix): put release-tools in this repository's own dev shell

This repository calls its own changeset check with devshell: true, so its dev shell meets the contract every caller meets and provides release-tools

* ci(release): keep the devshell changeset check, with tools from the caller's flake

The merged devshell mode (#27) stays whole: the devshell input, the caller's
own bootstrap script run in its nix develop shell, the refusal without one,
this repository's own self-caller with devshell: true and its bootstrap
script. The one change this layer makes to it is the tools source: in
devshell mode the check runs as `nix develop --command sandbox --
changeset-management check`, from the release-tools the caller's flake.lock
pins, with no tools-ref checkout or build. Callers without devshell keep the
tools-ref path unchanged, and the plain pnpm install exists only on that path.

The runs-on input (#32) stays on both workflows, and release.yml installs Nix
only on hosted runners, as #32 does. The dogfood caller now passes the
ci-workflow input release.yml requires, and ci.yml accepts workflow_dispatch
so the release can dispatch it.

The README CI section documents both modes

* docs(release): keep the devshell changeset check paragraph as merged

Restores the CI section's wording from main and changes only the sentences the flake-input tools make false: tools-ref and node-version in devshell mode, and which workflow builds .release-tools

* chore(release): name the packages the adoption ledger layer moves in an intent

* test(gate): provide the ledger port to the deleted-package scenario

The gate reads the adoption ledger on this layer, so the scenario main brought in (#26) needs the fake ledger every other gate scenario has

---------

Co-authored-by: systemfsoftware-maker <drdgvhbh@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant