Repository navigation
fix(release): release from the package changelog under repository storage - #22
Merged
Merged
Conversation
…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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The GitHub Release step built every release body from the parked file
<changelogDir>/<pkg>@<version>.md. Under pnpm'sversioning.changelog.storage: repositorynothing is parked there: pnpm writes each section into the package's ownCHANGELOG.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
WorkspaceStoregainschangelogStorage(). The live adapter asks pnpm (pnpm config get versioning.changelog.storage); unset meansregistry, pnpm's default. A failingconfig getrefuses withManifestInvalidonpnpm-workspace.yaml, carrying pnpm's own message.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.registry, nothing changes: entries keep the parked path, and the body is the whole parked file.ReleaseChangelogMissing, naming the package, version and file. The body is never empty.Tests (
packages/github-release-engine/tests/release.integration.test.ts)ReleaseChangelogMissing, and nothing is created; the captured cycle namespackages/alpha/CHANGELOG.md.Proof
pnpm check:ciexits 0.github-release-managementbinary, run on throwaway workspaces, capturespackages/alpha/CHANGELOG.mdunder repository storage and.changeset/changelogs/alpha@1.1.0.mdwhen the setting is unset; a misspelt value exits 1.release --asserton a captured cycle passes@systemfsoftware/stryker-js@17.0.2and refuses a99.0.0entry withMissing changelog for @systemfsoftware/stryker-js@99.0.0: expected packages/stryker-js/CHANGELOG.md ….Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.