Skip to content

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

62 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

pnpm-release-management

Release tooling for our pnpm monorepos — the lifecycle apps, the reusable GitHub workflows that call them, and the containerised end-to-end test that proves the whole pipeline. It replaces the four near-identical private copies of these tools that had drifted apart in systemfsoftware, omp-claude-compat and comment-checker.

A consuming repository keeps its release.jsonc, its .changeset/ intents and its manifests, and deletes its own copies of the tools.

Layout

One app per capability of the release lifecycle. Each app is a single effect/unstable/cli program (Effect 4's CLI module) whose subcommands are the steps of that capability.

apps/
  changeset-management/       changeset new | check
  version-management/         version bump | sync | sync-root
  github-release-management/  release plan | pr | tag | release
  git-hooks/                  hooks pre-commit | commit-msg
packages/*/                   the libraries every app imports by name
apps/*/dist/main.js           the tsdown bundles the workflows and the e2e image run
e2e/                          the containerised pipeline test (vitest)

Apps are Node programs built with tsdown into self-contained ESM bundles, then compiled with deno compile into one binary each. The flake exports them, and packages.<system>.release-tools joins the three release apps. A repository takes this flake as an input, pinned by its flake.lock, and puts release-tools in its dev shell to run the apps locally. release.yml does not use that pin: it builds the release-tools of its own commit and runs them in the caller's dev shell (see CI). Each job sets WORKFLOW_REPOSITORY and WORKFLOW_SHA from the job.workflow_repository and job.workflow_sha contexts and fails when either is empty:

tools=$(nix build --no-link --print-out-paths \
  "github:${WORKFLOW_REPOSITORY:?job.workflow_repository is empty}/${WORKFLOW_SHA:?job.workflow_sha is empty}#release-tools")
nix develop --command "$tools/bin/github-release-management" plan \
  --tarballs "$(nix build --no-link --print-out-paths .#workspace-tarballs)" \
  --output "$GITHUB_OUTPUT"

Each app is a composition root: main.ts declares the Flag/Argument surface and holds the one NodeRuntime.runMain edge, boundary.ts decodes the invocation into the cell's request, and render.ts turns the decision or the refusal into the lines that go out. A refusal surfaces as a ::error:: workflow annotation and exit 1.

What it does

The pipeline has one job: turn authored change intents into versioned, tagged packages and GitHub Releases without a human deciding when anything runs. Every phase is derived from durable repository state, never from a pull-request ref, so a half-finished release resumes on the next push.

flowchart LR
  A[".changeset/*.md<br/>intents"] --> B["changeset check<br/>gate"]
  B --> C["release plan<br/>phase"]
  C -->|version| D["version bump<br/>bump surfaces"]
  D --> E["release pr<br/>release PR"]
  E -->|merge| C
  C -->|release| G["release tag"]
  G --> H["release release<br/>GitHub Releases"]
  H --> I["release plan<br/>phase=none"]
Loading

version bump also writes the per-package changelog that later becomes the GitHub Release body, which is why the order matters: the release notes are authored with the version bump, not reconstructed at release time.

Nothing is published to a registry. Consumers take a package from the repository's own Nix flake at a tag or revision.

Quick start

Add a caller to the consuming repository. The trigger and the permissions stay with you; the phase decision stays in the reusable workflow.

.github/workflows/release.yml:

name: Release

on:
  push:
    branches: [main]

permissions:
  contents: write
  pull-requests: write
  actions: write

jobs:
  release:
    uses: systemfsoftware/pnpm-release-management/.github/workflows/release.yml@main
    with:
      ci-workflow: ci.yml

.github/workflows/changeset-check.yml:

name: Changeset Check

on:
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]

permissions:
  contents: read
  pull-requests: read

jobs:
  check:
    uses: systemfsoftware/pnpm-release-management/.github/workflows/changeset-check.yml@main

Then add a release.jsonc:

{
  "base": "main",
  "branch": "changeset-release/main",
  "versioning": {
    "strategy": "surfaces",
    "manifest": "package.json",
    "changelog": "CHANGELOG.md",
    "surfaces": [
      { "kind": "toml", "header": "[workspace.package]", "glob": "crates/*/Cargo.toml" },
      { "kind": "nix", "path": "nix/version.nix" }
    ]
  },
  "gate": { "strategy": "turbo", "task": "build" },
  "pr": { "title": "chore(release): version packages" }
}

Configuration

release.jsonc at the repository root. Every path is relative to the root.

Key Default Meaning
base required branch the release PR targets
branch required branch the release PR is opened from
changesetDir .changeset where pending intents live
changelogDir <changesetDir>/changelogs registry storage: where generated per-package changelogs are parked
versioning.strategy required surfaces or changesets
versioning.manifest — surfaces: the JSON manifest that owns the version
versioning.changelog — surfaces: the root changelog that receives the release summary
versioning.surfaces[] — surfaces: additional files rewritten on every bump
gate.strategy required turbo (a task graph decides what a change touches) or paths
gate.task build turbo: the task whose inputs decide the changed packages
distribution.launcherManifest — required only for repositories that ship platform packages
distribution.targets[] — { target, suffix, os, cpu, libc?, runner, bin }
legacyTags.tag / .through — release tags cut before adoption; see Legacy tags
pr.title / pr.body built-in copy release PR copy

Member changelogs follow versioning.changelog.storage in pnpm-workspace.yaml, read with pnpm config get. Under repository, bump adds a ## <version> section to each moved member's CHANGELOG.md, above its earlier sections, and the release phase reads that section back. Under registry or unset, bump parks one file per moved member under changelogDir, and the release phase reads that file.

A surface is one of:

kind Fields Example
json path package.json
toml path or glob, header ([package], [workspace.package]) Cargo.toml
cargo path, optional package Cargo.toml
nix path nix/version.nix

surfaces versioning bumps the manifest, rewrites the version in every declared surface, and appends the release summary to the root changelog. changesets versioning drives the changesets libraries per package: the assembled release plan decides each member's bump, workspace dependents move with it, and the consumed intents are removed.

A cargo surface rewrites [workspace.package] version in the named manifest, any workspace member that pins a literal [package] version, and every workspace-member entry in the sibling Cargo.lock (registry and git dependencies carry a source line and are left alone). Its optional package names the workspace package whose bumped version the Cargo workspace follows; it is required under changesets versioning, where there is no single version.

Change intents

An intent is a Markdown file in .changeset/ whose frontmatter names the packages it changes and with which bump, and whose body is the release note.

---
"@scope/alpha": minor
"@scope/beta": none
---

Alpha grows a public export.

Author one with changeset new rather than by hand:

node apps/changeset-management/dist/main.js new @scope/alpha --bump minor \
  --summary "Alpha grows a public export"

none consumes an intent without moving a version — use it for work that must be recorded but does not ship. version bump deletes every intent it consumes, so the release PR diff is the set of notes that shipped. release pr runs after version bump and commits the tree bump left: changes to tracked files open or refresh the release PR, an unchanged tree closes it, and an intent still on disk means bump has not run, so pr refuses instead of reporting nothing to release. The release commit holds the tracked changes plus the changelogs bump created; any other untracked file, such as a .release/ artifacts directory, neither opens the release PR nor rides into its commit. The release commit is authored and committed as github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>, passed to that one git commit with -c, so pr needs no git identity on the runner and changes no git config.

Capabilities

App subcommand What it does
changeset check Fails when a publishable package changed without an intent naming it; lists deleted packages, which need none
changeset new Writes an intent file
version bump Consumes intents, bumps every surface, writes per-package changelogs
version sync check or bump <version> across every declared surface
version sync-root Stamps the launcher manifest with the released version
release pr Commits the bumped tree to the release branch, opens, refreshes, or closes the release PR
release adopt Records every pre-adoption release tag's bytes in the adoption ledger
release plan Derives the release phase from repository state
release tag Captures the cycle, then pushes one tag per released package
release release Creates GitHub Releases from the generated changelogs
hooks pre-commit Formats and checks the staged set before a commit lands
hooks commit-msg Enforces the conventional-commit header and strips AI co-author trailers

Every subcommand takes --config <path> and otherwise loads release.jsonc from the directory it is run in. The flag may name either the workspace root or a file inside it: a directory is taken as the root, a file path means its directory is.

Flags worth knowing:

App subcommand Flags
changeset check <base-sha-or-ref>, --base, --skip-liveness
release plan --output <file>, --deferred <file>, --remote <name>
release adopt --registry <url>, --output <file>, --remote <name>
release tag --captured <file>, --output <file>, --exclude, --json, --dry-run, --remote <name>
release release --captured <file>, --assert, --dry-run
version sync check | bump <version>
version sync-root --manifest <path>, --version <version>, --dry-run

--output on release tag captures the cycle and skips pushing: the workflow captures once, then hands the same file to tagging and the release step so both agree on what this cycle owns.

CI

Workflow Inputs Caller must grant
release.yml ci-workflow (required), artifacts-dir contents: write, pull-requests: write, actions: write
changeset-check.yml tools-ref, base-sha, node-version, devshell contents: read, pull-requests: read

The pull_request runs of a pull request opened or updated with the workflow token wait for a maintainer's approval, so they never run on their own. release pr --output <file> appends outcome (created, updated, closed or vacant), number and branch to the file. release.yml passes $GITHUB_OUTPUT, and when the outcome is created or updated it dispatches the caller's CI workflow (ci-workflow, which must accept workflow_dispatch) on that branch. A failed dispatch fails the job. That gives the release PR the checks the branch protection requires.

release.yml runs the apps built from its own commit, not the caller's. Each job builds github:${{ job.workflow_repository }}/${{ job.workflow_sha }}#release-tools, the release-tools of the exact revision of this repository the caller's uses: resolved to, and runs those binaries by path inside the caller's dev shell (nix develop --command "$RELEASE_TOOLS/<app>" …). A caller on release.yml@main therefore always gets the flags release.yml@main passes, whatever revision its flake.lock pins for its pnpm-release-management input; that pin still decides the release-tools in its dev shell for local use, and nix flake update pnpm-release-management still moves it. The caller's devShells.<system>.default must provide pnpm, the sandbox and SANDBOX_PNPM_STORE (see Distribution through Nix). The job needs the job.workflow_* context: github.com provides it (self-hosted runners from actions/runner v2.334.0), GitHub Enterprise Server does not. A job that cannot read it fails before running any tool.

tools-ref pins the revision of this repository that a release runs from; @main tracks the tip. Without devshell, the changeset check checks this repository out into .release-tools, builds it with pnpm, and runs its dist/main.js bundles against the caller's workspace.

devshell: true makes the changeset check install Nix and run the caller's own bootstrap script inside its nix develop shell (nix develop --command pnpm run bootstrap) instead of a plain pnpm install, then run the check in that shell. The bootstrap script is where the caller installs its workspace inside its own sandbox, so no dependency code runs outside it; a caller without a bootstrap script is refused with an error naming the missing script. The workflow takes no install command of its own. The job allows unprivileged user namespaces so a bubblewrap sandbox can start. A caller needs this mode when its lockfile points at tarballs its flake builds, such as file:.sfs-deps/<name>-<version>.tgz; a plain install cannot read those. With devshell: true, node-version is ignored for the caller's install and check: they use the dev shell's node, and the check runs as nix develop --command sandbox -- changeset-management check from the release-tools in that dev shell, so tools-ref is ignored too. The default, false, installs with plain pnpm as before. This repository's own CI calls the check with devshell: true on every pull request.

Distribution through Nix

A repository ships its public workspace packages as flake outputs. Nothing goes to a registry. One call in flake.nix:

packages = forEachSystem (pkgs:
  pnpm-release-management.lib.mkPnpmWorkspacePackages {
    inherit pkgs;
    src = self;
  });

For every member of pnpm-workspace.yaml that is not private, this gives packages.<system>.<name>: the member's pnpm pack tarball, with the scope dropped from the name. It also gives packages.<system>.workspace-tarballs: every tarball plus an index.json of { name, file }.

  • Third-party tarballs enter as one fixed-output fetch per tarball, keyed by the integrity the lockfile already records, so a lockfile bump needs no hash edit. nix/lib/pnpm-lock.nix reads the lockfile's packages: map in pure Nix (a parsing derivation would import from a derivation for the target system, which breaks nix eval .#devShells.<other-system>); a plain derivation then runs pnpm offline through the importPnpmLock input's config hook and assembles packages.<system>.pnpm-store, the store directory pnpm installs from with no registry. file: tarballs and directory entries are workspace-local and are skipped before any fetch.
  • Install runs with --ignore-scripts in the Nix sandbox. The members build with their build script (buildScript overrides it), then pnpm pack writes each tarball and turns every workspace: range into the exact version.
  • The tarballs rebuild bit-for-bit. CI proves it with nix build --rebuild .#workspace-tarballs. A declaration file that prints an inferred union breaks this, because TypeScript 7 orders union members differently from run to run (microsoft/TypeScript#64589). Annotate such an export with a named type.
  • pnpm defaults to pkgs.pnpm_12. The root packageManager must pin exactly that version, or evaluation fails: one pnpm resolves everywhere.
  • packages.<system>.pnpm-store is that store directory, in the layout sandbox --pnpm-store consumes.

A consumer takes the flake as an input pinned by flake.lock. A pull request's head revision is a snapshot, and a release tag is a stable version. It builds the tarballs it needs and depends on them with file: paths, so each tarball's integrity lands in the consumer's pnpm-lock.yaml.

The consumer's sandbox installs from one store holding both its registry packages and those tarballs. A store with only the registry packages fails the install: pnpm tries to add the tarball to the read-only store. Build it with lib.mkPnpmConsumerStore:

pnpm-store = pnpm-release-management.lib.mkPnpmConsumerStore {
  inherit pkgs;
  src = self; # holds package.json, pnpm-lock.yaml, pnpm-workspace.yaml
  files.".deps" = producer.packages.${system}.workspace-tarballs;
};

files maps a directory relative to src to a derivation holding the *.tgz the lockfile names there. Third-party tarballs come from per-tarball fixed-output fetches as above; the pnpm and @pnpm/exe.* entries of pnpm 12's env document are skipped, because the sandbox never lets pnpm fetch itself.

A workspace that publishes its own packages and also consumes tarballs this way passes the same files to lib.mkPnpmWorkspacePackages. Its tarball build installs from the lockfile too, so without them the build fails on the first file: path it cannot open.

Resolving the lockfile is the one step that runs on the host: pnpm install --lockfile-only needs registry metadata, which no store carries. It reads metadata only and runs no package code. Every install, build and test after it runs in the sandbox.

Release identity

A released name@version is immutable. The tarball a consumer downloads once is the tarball every consumer downloads forever, so the integrity its lockfile records for that name@version can never change. Nothing pushes to a registry, so a rebuilt tarball at a later revision is the only way the bytes could drift, and that is exactly what the release tooling refuses.

release tag records the identity when it releases. It creates an annotated tag <name>@v<version> whose message is JSON:

{
  "integrity": "sha512-<base64 of the .tgz bytes>",
  "files": { "package/package.json": "sha512-<base64>", "package/index.js": "sha512-<base64>" }
}

--tarballs <dir> is required on both release plan and release tag; it names a directory of *.tgz (the packages.<system>.workspace-tarballs output, or pnpm -r pack --pack-destination <dir>). A tarball's name and version come from its own package/package.json, never from its filename, so a Nix output and a pnpm pack output are interchangeable.

release plan checks identity before it plans anything else. For every member whose current name@version already has a tag on the remote — except members the pending changesets plan will move to a new version — it fetches the tag object, recomputes { integrity, files } from the tarball in --tarballs, and compares. A mismatch fails the plan red, naming package@version, the recorded and current hashes, and the first differing file in sorted path order. A file present on only one side counts as differing, so a changed dependency range surfaces as package/package.json. A lightweight tag or an annotation the tool cannot parse is refused too; a release tag is never silently skipped.

Dependents always bump so the check can hold. A bare workspace:^ packs as ^<current version> of the dependency, so bumping a dependency changes the dependent's packed bytes even when its own source did not move. With updateInternalDependents: 'always' (plus updateInternalDependencies: 'patch'), a minor intent on one member moves every workspace dependent by a patch release, which keeps each dependent's own name@version identity intact instead of rewriting an already-released tarball.

Adoption

A repository that adopts this tooling already has release tags — systemfsoftware's are all lightweight — whose bytes this tooling never recorded. Adoption makes the record once, before the first managed release, so those tags are never trusted from nothing.

github-release-management adopt \
  --registry https://registry.npmjs.org \
  --output release-ledger.json

--registry <url> is required and has no default. Adoption reads every release tag <name>@v<version> on --remote (origin by default) — not only the current version of a current member. The published name comes from the manifest at the tagged commit, never from the tag string alone: adoption reads every package.json in that commit's tree and takes the one whose name equals the tag name or ends with /<tag name>. So a tag left over from before a package was scoped (hex-schema@v1.0.0, whose manifest says @systemfsoftware/hex-schema) resolves to the name the registry actually serves. Exactly one match is required; zero or several matches is a hard error naming the tag and the candidate names. When the manifest's version differs from the tag's, the entry is mismatched and records both versions. It fetches the published <name>@<version> from the registry, downloads dist.tarball, and records one entry:

{
  "entries": [
    {
      "_tag": "published",
      "tag": "@scope/name@v1.2.3",
      "commit": "<peeled commit the tag points to>",
      "package": "@scope/name",
      "version": "1.2.3",
      "integrity": "sha512-<dist.integrity>",
      "sha256": "sha256-<base64 of the downloaded .tgz>",
      "files": { "package/package.json": "sha512-<base64>", "package/index.js": "sha512-<base64>" }
    }
  ]
}

That covers an older version of a current member (the immutability law applies if anyone re-releases that name@version) and a name that is no longer a workspace member (the tag name gives name@version, split on the last @v), both resolved through the tagged manifest. The manifest's private: true at that commit means the version was never published, so it is excluded and listed in the report as private, never published. The ledger entry keeps the tag as its key and records the resolved published name, so the identity check — which looks tags up by <name>@v<version> — is unaffected.

The files map is the same digest shape the tag annotations use, so a later mismatch can name the first differing file. Fetches run four at a time and retry a transient registry failure (a 5xx or a timeout) three attempts with backoff; a 404 is not transient, but it is not an error either: an exact-version 404 means the registry never published that name@version, so the entry is recorded as unpublished with the metadata URL, the 404 status and the fetch time. That version is burned — release plan refuses a cycle member at it and release tag refuses to create its tag, both version-burned. A network failure, a download whose sha512 does not equal dist.integrity, or a tag with zero or several matching manifests is a hard error: the command exits non-zero, and an adoption with any error writes no ledger at all.

The report prints one line for each unpublished and mismatched entry, then adopted <N> published, <U> unpublished, <M> mismatched, <E> errors. A mismatched line names both versions and each version's registry state — mismatched <tag>: claims <a> <state>, manifest <b> <state> — and an error line is name@version: <reason>.

The ledger is one JSON file at the repository root, written by the command and never hand-edited: keys in a stable order, entries sorted by tag. It lands in its own commit in the adopting repository, separate from any release commit, so the adoption itself is a reviewable one-file change. Running adopt again keeps every existing entry, including one whose tag has since left the remote, and adds only tags the ledger does not hold. A ledger that cannot be read or parsed, here or at a revision the append-only check reads, stops the command instead of counting as empty.

After adoption, release plan accepts a lightweight tag only when the ledger has that tag with the same peeled commit and the same name@version. A moved tag or a mismatched entry is a red refusal naming the tag and both commits (or both values); a ledgered version whose current packed tarball differs from the ledger's integrity is refused with the first differing file, exactly like an annotated tag. A new lightweight tag that is not in the ledger stays the existing red refusal, so every tag released after adoption is annotated.

The ledger is append-only. changeset check <base> — which already receives the pull request base revision — fails red when an entry present at the base is removed or changed at the head; additions are fine. That is what proves the record was extended rather than rewritten.

Legacy tags

A repository whose releases predate this tooling and were never published to a registry has no registry bytes to adopt — only its own tags, often a bare v<version>. adopt has nothing to record there, and without help the plan reads the current version as unreleased and owes a second <name>@v<version> tag for it. legacyTags names that earlier scheme:

"legacyTags": { "tag": "v{version}", "through": "0.3.6" }

tag is the old tag template: {version} is required, {name} is optional. through is the last version released under it. release plan, release tag and release github count a publishable member as already released when its <name>@v<version> tag is absent, its version is at or below through, and the remote holds the legacy tag for it — but only after reading the tagged commit: exactly one package.json there must name the member, must not be private, and must declare the same version. Anything else is a red legacy-tag-unverified refusal naming the tag, the package and what the commit declares; the tooling never invents an identity it cannot read back. A recognised version is a declared skip, not a silent one: release plan prints plan-release: legacy release v0.3.6 (<name>@0.3.6), identity not recorded for each.

A version above through is never matched against the old template, so every release after adoption is tagged <name>@v<version> as usual. A legacy-released version gets no ledger entry and no integrity check: there are no recorded bytes to compare against, so the guarantee starts at the first managed release.

Sandbox

packages.<system>.sandbox runs dependency code with nothing it was not given:

sandbox -- pnpm install
sandbox -- pnpm build
sandbox -- pnpm test
sandbox --allow-host api.cloudflare.com --pass-env CLOUDFLARE_API_TOKEN -- pnpm deploy

pnpm never reaches a registry from the sandbox. --pnpm-store (the dev shell sets SANDBOX_PNPM_STORE to packages.<system>.pnpm-store) points pnpm at the Nix-built store. The sandbox then runs pnpm with offline, frozen-lockfile, ignore-scripts and trust-lockfile; the per-tarball fixed-output fetches already checked each integrity. pnpm never fetches another pnpm either (manage-package-manager-versions is off), so the launcher refuses a project whose packageManager names a different pnpm major.minor than the one on PATH; a patch-level difference runs with the pnpm provided. Each invocation gets a private copy of the store's index database that is discarded at exit, so the Nix store stays read-only. $HOME is a fresh tmpfs every time, so nothing a dependency plants survives. Tool caches that should persist (turbo, vite, tsbuildinfo) belong in the project's gitignored .cache/; the sandbox sets XDG_CACHE_HOME to it.

Boundary Inside the sandbox
filesystem the project directory read-write, only the invoking closure's store paths readable (/nix/store is not listable), an empty $HOME, a private /tmp; no other home directories
environment cleared, then PATH, TERM, locale, TZ, CI and colour settings, plus each --pass-env
network loopback only; each --allow-host opens HTTPS to that host through an allow-list proxy; only --publish ports reach the host
processes own PID, IPC and UTS namespaces, no capabilities, killed with its parent, no controlling terminal

It is bubblewrap (--unshare-all, --cap-drop ALL, --die-with-parent, --new-session) and runs on Linux only. Egress goes through a CONNECT proxy outside the sandbox that tunnels only to declared host[:port] (default 443; *.example.com matches subdomains). Inside, HTTPS_PROXY points at it and NODE_USE_ENV_PROXY=1 makes Node's fetch use it. There is no unsandboxed mode.

Reads of /nix/store are restricted to the invocation's closure: the launcher resolves nix-store --query --requisites over the sandboxed PATH, the command and its own helpers, then mounts each of those paths read-only over an empty /nix/store that cannot be listed. A store path outside the closure is unreadable, so a dependency cannot enumerate or reach the rest of the store.

--egress-log PATH appends one JSONL line per proxy decision, allowed or refused, at least {"host","port","decision","rule"}. The file must sit outside the sandbox's writable tree — the project, its $HOME and its /tmp — and the launcher refuses PATH inside them with exit code 2. With --egress-log set the proxy runs and the proxy environment is set even without --allow-host, so refusals are logged too.

--publish HOST_PORT:SANDBOX_PORT makes a sandbox port reachable as 127.0.0.1:HOST_PORT on the host; only published ports are reachable. On Linux the sandbox has its own network namespace, so an outside forwarder bridges the host port to an inside forwarder over a Unix socket. --listen PORT declares a port the stack may bind without publishing it. Both flags take ports 1–65535; anything malformed exits 2 with usage.

packages.<system>.sandbox-proofs is the gate. Each refusal proof first prints from inside the same sandbox, so a sandbox that fails to start fails the proof instead of passing it. The proofs: reading ~/.ssh and ~/.config fails, writing outside the project fails, a write to the real /tmp leaves nothing on the host, agent sockets and secrets do not cross the cleared environment, a store path outside the closure and the listing of /nix/store both fail while a closure tool still runs and an offline pnpm 12 install of a tiny workspace resolves from the --pnpm-store store, a consumer installs a file: workspace tarball from its mkPnpmConsumerStore store while a store without that tarball fails, a packageManager pin on another pnpm minor is refused while one on another patch installs offline (and fails once pnpm manages its version), --egress-log records exactly the allowed and refused decisions and refuses a log inside the project, an undeclared connection fails, a declared host is reachable while every other host is refused, a loopback dev server still answers, and a published port answers from the host while an unpublished one does not. CI runs them on Linux, then installs, builds and tests this repository as three separate sandbox invocations with no network at all.

bubblewrap needs unprivileged user namespaces and a mountable /proc. Ubuntu 24.04 needs sysctl kernel.apparmor_restrict_unprivileged_userns=0. A container needs /proc unmasked (--security-opt unmask=/proc/* under podman). Without them the sandbox refuses to start.

Install, build, test, package

Enter the dev shell (direnv allow, or nix develop) for node, pnpm and deno, then:

Command What it runs
pnpm install install the workspace (--frozen-lockfile in CI)
pnpm build turbo: tsdown bundles every package and app
pnpm typecheck turbo: tsc --noEmit over every package and app
pnpm lint turbo: oxlint with the strict preset over the whole tree
pnpm format:check dprint check
pnpm test turbo: the vitest suites
pnpm check:ci all of the above, the same gate CI runs

Each app builds to a self-contained ESM bundle at apps/<app>/dist/main.js (npm dependencies inlined, so nothing resolves at ship time). Packaging wraps that bundle with deno compile:

deno compile --allow-read --allow-write --allow-run --allow-env --allow-net --allow-sys \
  --output dist/<app> dist/main.js

nix build produces the same binaries; Deno is the packager here, never the runtime. Run an app from its bundle or its binary:

node apps/changeset-management/dist/main.js --help
./apps/changeset-management/dist/changeset-management --help

End-to-end test

pnpm --filter @systemfsoftware/e2e test builds one container and drives the entire pipeline in it: a two-package pnpm workspace, a bare git origin and a GitHub API mock. The phases run in order under vitest and each one's failure names itself.

The image builds the workspace with pnpm (pnpm install --frozen-lockfile, pnpm build, then the package task that runs deno compile over each bundle) and ships the four app binaries at /opt/prm/<app>. The phases drive those binaries, never app source, so the test proves what actually ships.

What the container proves is broader than the apps. git, pnpm and node are the real binaries; the workspace is a real pnpm workspace with a real lockfile; the release PR, tags and Releases go through real git and a real HTTP API surface.

The container is hermetic without weakening the apps. api.github.com is redirected to 127.0.0.1 inside the container by withExtraHosts, and a TLS front door on port 443 (plain Node, no runtime grants to widen) terminates a certificate signed by a CA the image installs into the system trust store. The apps therefore talk to their production URLs with no localhost behaviour anywhere: no app carries a test-only host, and no test-only base URL exists to forget to remove.

registry.npmjs.org is redirected the same way, but nothing publishes there: verdaccio stands behind it as a tripwire. It keeps publish: $all, so a regression that publishes succeeds at the registry instead of hiding behind an auth error, and it writes every request to /tmp/verdaccio/verdaccio.log. The last phase, the release never publishes to npm, sends one GET /-/ping and waits for it in that log, so a tripwire that logs nothing cannot pass. It then asserts zero PUT requests in the log and no package under verdaccio's storage.

Green and red runs alike leave a transcript under e2e/.artifacts/<timestamp>/:

Artifact Contents
transcript.log every command, exit code, output and timing
commands.jsonl the same records, machine readable
summary.json phase names, statuses and timings
E2E_FILTER='tagging pushes' pnpm --filter @systemfsoftware/e2e test   # run only matching phases
E2E_KEEP=1 pnpm --filter @systemfsoftware/e2e test                  # leave the container up and print its id

Contributing

Development setup, hooks and the release workflow for this repository itself: CONTRIBUTING.md.

License

Apache-2.0. See LICENSE.

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages