From 326e2f0dd704870b93849334a95fe2436bb70dc7 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Tue, 4 Aug 2026 17:33:07 +0530 Subject: [PATCH 1/4] [release-pipeline] Restrict stable releases to a maintainer allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prereleases are how anyone with write access ships a branch for testing, so `beta` and `next` builds stay open. A stable release is not opt-in — it is what a bare `npm install failproofai` resolves to — so publish.yml's preflight now refuses it unless the run belongs to a login in STABLE_RELEASE_ACTORS. Two conditions count as stable: dist-tag `latest`, and any non-prerelease version at any dist-tag, because publishing `1.0.0` under `next` still claims that number on npm permanently and is one `npm dist-tag add` from being the stable release. Both `github.actor` and `github.triggering_actor` must be authorized. A re-run keeps `actor` as whoever started the original run and moves `triggering_actor` to whoever pressed re-run, so checking only the first would make a maintainer's stable run a re-run button for every collaborator. The gate lives in preflight, which every other job depends on, so a refusal costs seconds rather than a 4-way cross-compile. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017xe1xFTXzkjvmkX9skBmF3 --- .github/workflows/publish.yml | 60 +++++++++++++++++++++++++++ CHANGELOG.md | 3 ++ __tests__/ci/release-pipeline.test.ts | 44 ++++++++++++++++++++ 3 files changed, 107 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 246ffcc6..d5cd1228 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,6 +12,10 @@ name: Publish to npm # the GitHub Release, and only then publish to npm. The published CLI downloads # its `failproofaid` binary from that release tag at `failproofai config` time, # so the assets have to exist before the package that looks for them does. +# +# Prerelease builds (`beta`, `next`) are open to anyone GitHub already trusts +# with write access; a STABLE release is restricted to the logins in +# `STABLE_RELEASE_ACTORS` below. See the "Authorize stable release" step. on: release: types: [published] @@ -132,6 +136,62 @@ jobs: echo "Next version: $NEXT_VERSION" echo "Dry run: $DRY_RUN" + # Who may cut a STABLE release. Prereleases are deliberately open: a beta + # or a `next` build is how anyone with write access ships a branch for + # testing, and npm's `beta`/`next` tags are opt-in. A stable release is + # not — it is what a bare `npm install failproofai` resolves to — so it + # stays with the maintainers listed here. + # + # Two conditions count as stable, and both are gated: + # * dist-tag `latest`, the tag a bare install follows; and + # * a non-prerelease VERSION at any dist-tag, because publishing `1.0.0` + # under `next` still claims that number on npm permanently and is one + # `npm dist-tag add` away from being the stable release. + # + # `actor` and `triggering_actor` differ on a re-run: `actor` stays the + # user who started the original run while `triggering_actor` is whoever + # pressed re-run. Both must be authorized, so a maintainer's stable run + # cannot be re-driven by someone else. The check is deliberately NOT + # skipped for a dry run — the rule stays "stable implies maintainer", + # with no shape of this workflow that reaches npm unchecked. + # + # Scope, honestly stated: on a `workflow_dispatch` GitHub runs the + # workflow file FROM THE SELECTED REF, so this guard binds every ref that + # carries it, but a collaborator could push a branch with the step + # deleted and dispatch that. Making it tamper-proof means moving + # `NPM_TOKEN` into a protected GitHub Environment, which is a repo + # setting rather than a file — the guard here is the fast, legible half. + - name: Authorize stable release + if: >- + steps.version.outputs.dist_tag == 'latest' || + steps.version.outputs.is_prerelease == 'false' + env: + # Space-separated GitHub logins, compared case-insensitively. + STABLE_RELEASE_ACTORS: NiveditJain + ACTOR: ${{ github.actor }} + TRIGGERING_ACTOR: ${{ github.triggering_actor }} + DIST_TAG: ${{ steps.version.outputs.dist_tag }} + PUBLISH_VERSION: ${{ steps.version.outputs.publish_version }} + run: | + ALLOWED_LC=$(printf '%s' "$STABLE_RELEASE_ACTORS" | tr '[:upper:]' '[:lower:]') + + for WHO in "$ACTOR" "$TRIGGERING_ACTOR"; do + WHO_LC=$(printf '%s' "$WHO" | tr '[:upper:]' '[:lower:]') + MATCHED=false + for ALLOWED in $ALLOWED_LC; do + if [[ "$ALLOWED" == "$WHO_LC" ]]; then + MATCHED=true + break + fi + done + if [[ "$MATCHED" != "true" ]]; then + echo "::error::Stable release refused: publishing $PUBLISH_VERSION at dist-tag '$DIST_TAG' is restricted to [$STABLE_RELEASE_ACTORS], but this run is attributed to '$WHO' (actor '$ACTOR', triggering actor '$TRIGGERING_ACTOR'). Ship a prerelease version instead, or dispatch with dist_tag 'beta' or 'next'." + exit 1 + fi + done + + echo "Stable release authorized for '$ACTOR' (triggered by '$TRIGGERING_ACTOR')." + # A ref without the Rust workspace (main, until the daemon lands) skips # the whole binary half of the pipeline and publishes exactly as before. - name: Detect the daemon workspace diff --git a/CHANGELOG.md b/CHANGELOG.md index 457d062a..4c6169fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.0.16-beta.0 — 2026-07-31 +### Features +- Restrict stable releases to a maintainer allowlist while leaving prereleases open. `publish.yml`'s preflight now refuses any publish at dist-tag `latest`, or of a non-prerelease version at any dist-tag, unless both `github.actor` and `github.triggering_actor` are on the allowlist (`NiveditJain`) — the second identity matters because a re-run keeps `actor` as the original triggerer, so checking only it would make a maintainer's stable run a re-run button for everyone with write access. A stable version published under `next` is gated too: it claims that number on npm permanently and is one `npm dist-tag add` away from being the stable release. `beta` and `next` builds are untouched, so the branch-dispatch path stays open to anyone GitHub already trusts with write access. The check runs in preflight, which every other job depends on, so a refusal costs seconds rather than a 4-way cross-compile. (#651) + ### Fixes - Harden the release workflow against shell injection from ref names and generated outputs, align every Bun cache key with the tracked `bun.lock`, and discard the temporary publish-version edit before switching to `main` for the development-version bump. (#634) - Ship the binaries the release already builds, and stop a branch dispatch from rewriting main's version. The daemon split added every packaging input — platform manifests, pinned optional dependencies, a 4-way cross-compile matrix — but never touched `publish.yml`, so each release built four binaries as Actions artifacts and discarded them with the runner; CI stayed green because nothing checks that what gets built also gets shipped. `publish.yml` is now four jobs — preflight (version/dist-tag resolution, an npm credential check that fails in seconds rather than after a 20-minute matrix, and daemon detection), a call into `build-daemon.yml` as a reusable workflow, an asset job that assembles `SHA256SUMS` and attaches it plus the four binaries to the GitHub Release, and the npm publish — in that order, because the installed CLI downloads its daemon from that release tag and publishing the package first ships a version whose binary does not exist yet. A failed cross-compile now blocks the publish explicitly: a failed dependency leaves its dependents `skipped`, which the old-style guard would have read as "nothing to do". The version bump checks main out and pushes to it, so it runs only for a release or a dispatch from main, and `latest` is refused from a non-main dispatch (`auto` resolves to `next` there) so a branch build cannot move a dist-tag that a later release from main would move backwards. Adds a `dry_run` input that builds, checksums and validates the publish while writing nothing, and fixes the bun cache key, which hashed a `bun.lockb` this repo does not track. All of it is gated on the ref carrying a Rust workspace, so on main this changes nothing until the daemon lands. (#634) diff --git a/__tests__/ci/release-pipeline.test.ts b/__tests__/ci/release-pipeline.test.ts index 241db9e5..179918ba 100644 --- a/__tests__/ci/release-pipeline.test.ts +++ b/__tests__/ci/release-pipeline.test.ts @@ -17,6 +17,9 @@ * (it checks main out and pushes to it, regardless of the dispatched ref); * - build-daemon.yml stays callable and is not also triggered standalone on * a release (that would build the matrix twice per release); + * - a stable release stays restricted to the maintainer allowlist while + * beta/next builds stay open to anyone with write access (deleting that + * step is a one-line change that nothing else would notice); * - the platform list in the build matrix matches the platforms the CLI * actually knows how to resolve — a missing leg is a platform that * silently gets no daemon. @@ -132,6 +135,47 @@ describe("publish.yml", () => { expect(scripts).toContain('DIST_TAG="next"'); }); + const stableGuard = () => + wf.jobs.preflight.steps.find((s: Record) => s.name === "Authorize stable release"); + + it("restricts a stable release to the maintainer allowlist", () => { + const guard = stableGuard(); + expect(guard).toBeDefined(); + expect(guard.env.STABLE_RELEASE_ACTORS.split(/\s+/)).toContain("NiveditJain"); + + // Both halves of "stable" are gated: the `latest` dist-tag a bare + // `npm install` follows, and any non-prerelease version, which claims that + // number on npm permanently whatever tag it was published under. + expect(guard.if).toContain("dist_tag == 'latest'"); + expect(guard.if).toContain("is_prerelease == 'false'"); + + // A re-run leaves `actor` as whoever started the original run and moves + // `triggering_actor` to whoever pressed re-run, so both are checked — + // otherwise a maintainer's stable run is a re-run button for everyone. + expect(guard.env.ACTOR).toContain("github.actor"); + expect(guard.env.TRIGGERING_ACTOR).toContain("github.triggering_actor"); + expect(guard.run).toContain("exit 1"); + }); + + it("refuses an unauthorized stable release before anything is built", () => { + // The gate lives in preflight, which every other job hangs off, so the + // refusal costs seconds instead of a 4-way cross-compile — and nothing + // downstream can publish once preflight has failed. + expect(stableGuard()).toBeDefined(); + expect(wf.jobs.daemon.needs).toContain("preflight"); + expect(wf.jobs.publish.if).toContain("needs.preflight.result == 'success'"); + }); + + it("leaves beta and next builds open to anyone with write access", () => { + // The branch-dispatch path exists so a collaborator can ship a prerelease + // for testing. An unconditional guard — or one that named those tags — + // would close it. + const guard = stableGuard(); + expect(guard.if).toBeTruthy(); + expect(guard.if).not.toContain("beta"); + expect(guard.if).not.toContain("next"); + }); + it("bumps main's version only for a release or a dispatch from main", () => { const bump = wf.jobs.publish.steps.find( (s: Record) => s.name === "Bump version for next development cycle", From 6f8731d80a7932f92a2cbeda8d8521760f291991 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Tue, 4 Aug 2026 17:36:51 +0530 Subject: [PATCH 2/4] Bump undici and brace-expansion overrides to clear new advisories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OSV published six advisories (2 High, 4 Medium) against undici 7.28.0 and brace-expansion 5.0.8 after main's last green Supply Chain run, so the gate now fails on every branch regardless of what it changed. Both are pinned in `overrides`, and both advisories have fixed releases, so bump the pins rather than time-box an entry in osv-scanner.toml — which the file itself asks for. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017xe1xFTXzkjvmkX9skBmF3 --- CHANGELOG.md | 3 +++ bun.lock | 8 ++++---- package.json | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c6169fb..cc1f9e27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ ### Fixes - Harden the release workflow against shell injection from ref names and generated outputs, align every Bun cache key with the tracked `bun.lock`, and discard the temporary publish-version edit before switching to `main` for the development-version bump. (#634) + +### Dependencies +- Bump the `undici` (7.28.0 → 7.29.0) and `brace-expansion` (5.0.8 → 5.0.9) overrides, clearing six advisories that OSV published after the last green Supply Chain run on `main`. (#651) - Ship the binaries the release already builds, and stop a branch dispatch from rewriting main's version. The daemon split added every packaging input — platform manifests, pinned optional dependencies, a 4-way cross-compile matrix — but never touched `publish.yml`, so each release built four binaries as Actions artifacts and discarded them with the runner; CI stayed green because nothing checks that what gets built also gets shipped. `publish.yml` is now four jobs — preflight (version/dist-tag resolution, an npm credential check that fails in seconds rather than after a 20-minute matrix, and daemon detection), a call into `build-daemon.yml` as a reusable workflow, an asset job that assembles `SHA256SUMS` and attaches it plus the four binaries to the GitHub Release, and the npm publish — in that order, because the installed CLI downloads its daemon from that release tag and publishing the package first ships a version whose binary does not exist yet. A failed cross-compile now blocks the publish explicitly: a failed dependency leaves its dependents `skipped`, which the old-style guard would have read as "nothing to do". The version bump checks main out and pushes to it, so it runs only for a release or a dispatch from main, and `latest` is refused from a non-main dispatch (`auto` resolves to `next` there) so a branch build cannot move a dist-tag that a later release from main would move backwards. Adds a `dry_run` input that builds, checksums and validates the publish while writing nothing, and fixes the bun cache key, which hashed a `bun.lockb` this repo does not track. All of it is gated on the ref carrying a Rust workspace, so on main this changes nothing until the daemon lands. (#634) ## 0.0.15-beta.1 — 2026-07-29 diff --git a/bun.lock b/bun.lock index 4e98a85d..bba6d413 100644 --- a/bun.lock +++ b/bun.lock @@ -39,11 +39,11 @@ }, }, "overrides": { - "brace-expansion": "5.0.8", + "brace-expansion": "5.0.9", "eslint-plugin-react-hooks": "7.0.1", "postcss": "8.5.23", "sharp": "0.35.0", - "undici": "7.28.0", + "undici": "7.29.0", "vite": "8.0.16", }, "packages": { @@ -491,7 +491,7 @@ "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], - "brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -1223,7 +1223,7 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], diff --git a/package.json b/package.json index 958e80ae..e50e24bc 100644 --- a/package.json +++ b/package.json @@ -106,8 +106,8 @@ "postcss": "8.5.23", "eslint-plugin-react-hooks": "7.0.1", "vite": "8.0.16", - "undici": "7.28.0", - "brace-expansion": "5.0.8", + "undici": "7.29.0", + "brace-expansion": "5.0.9", "sharp": "0.35.0" } } From 777dd4d5254be2784e06d43abbb113356d98672e Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Tue, 4 Aug 2026 17:46:43 +0530 Subject: [PATCH 3/4] Execute the stable-release guard in tests, and fix its remediation advice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both from CodeRabbit on #651, both real. The guard is entirely shell, but every assertion about it read YAML text — a broken comparison or a dropped TRIGGERING_ACTOR check would have passed all of them. The tests now spawn the step's actual `run:` script under `bash -e` with a controlled environment and assert exit status for three cases: a mixed-case allowlisted maintainer (0), an unauthorized ACTOR (1), and an unauthorized TRIGGERING_ACTOR (1). Verified non-vacuous by mutation: narrowing the loop to `$ACTOR` alone fails the new case and nothing else in the file. The refusal message also advised switching dist_tag to 'beta' or 'next', which does not clear the gate on its own — a non-prerelease version trips it at any dist-tag, so that advice sent a refused caller into a second failure. It now names both halves: a prerelease version at a non-latest tag, or an allowlisted maintainer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017xe1xFTXzkjvmkX9skBmF3 --- .github/workflows/publish.yml | 5 ++- __tests__/ci/release-pipeline.test.ts | 46 +++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d5cd1228..06bfa9bf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -185,7 +185,10 @@ jobs: fi done if [[ "$MATCHED" != "true" ]]; then - echo "::error::Stable release refused: publishing $PUBLISH_VERSION at dist-tag '$DIST_TAG' is restricted to [$STABLE_RELEASE_ACTORS], but this run is attributed to '$WHO' (actor '$ACTOR', triggering actor '$TRIGGERING_ACTOR'). Ship a prerelease version instead, or dispatch with dist_tag 'beta' or 'next'." + # Both halves of the remediation are required together: a + # non-prerelease version trips the gate at ANY dist-tag, so + # switching to `beta`/`next` alone does not clear it. + echo "::error::Stable release refused: publishing $PUBLISH_VERSION at dist-tag '$DIST_TAG' is restricted to [$STABLE_RELEASE_ACTORS], but this run is attributed to '$WHO' (actor '$ACTOR', triggering actor '$TRIGGERING_ACTOR'). Publish a PRERELEASE version at a non-latest dist-tag such as 'beta' or 'next', or have an allowlisted maintainer run it." exit 1 fi done diff --git a/__tests__/ci/release-pipeline.test.ts b/__tests__/ci/release-pipeline.test.ts index 179918ba..c281d80a 100644 --- a/__tests__/ci/release-pipeline.test.ts +++ b/__tests__/ci/release-pipeline.test.ts @@ -25,6 +25,7 @@ * silently gets no daemon. */ import { describe, it, expect } from "vitest"; +import { spawnSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { parse } from "yaml"; @@ -157,6 +158,51 @@ describe("publish.yml", () => { expect(guard.run).toContain("exit 1"); }); + /** + * Runs the guard step's REAL shell under a controlled environment. Every + * other assertion here reads YAML text, which a broken comparison or a + * dropped `TRIGGERING_ACTOR` check would sail straight through — the whole + * guard is shell, so the shell is what has to be exercised. `bash -e` + * mirrors the default shell Actions runs `run:` steps under. + */ + function runGuard(actor: string, triggeringActor: string) { + const guard = stableGuard(); + return spawnSync("bash", ["-e", "-c", guard.run], { + encoding: "utf8", + env: { + PATH: process.env.PATH, + STABLE_RELEASE_ACTORS: guard.env.STABLE_RELEASE_ACTORS, + ACTOR: actor, + TRIGGERING_ACTOR: triggeringActor, + DIST_TAG: "latest", + PUBLISH_VERSION: "1.0.0", + }, + }); + } + + it("passes an allowlisted maintainer in any casing and fails everyone else", () => { + // GitHub logins are not case-sensitive, so a case-sensitive comparison + // would lock the maintainer out of their own stable release. + expect(runGuard("nIvEdItJaIn", "NIVEDITJAIN").status).toBe(0); + + const wrongActor = runGuard("someone-else", "NiveditJain"); + expect(wrongActor.status).toBe(1); + expect(wrongActor.stdout).toContain("::error::Stable release refused"); + + // The re-run case: `actor` stays the maintainer who started the original + // run while `triggering_actor` becomes whoever pressed re-run. Checking + // only the first would authorize this. + expect(runGuard("NiveditJain", "someone-else").status).toBe(1); + }); + + it("tells a refused caller what would actually clear the gate", () => { + // A non-prerelease version trips the gate at ANY dist-tag, so advice to + // "use beta or next" on its own sends them into a second failure. + const refused = runGuard("someone-else", "someone-else").stdout; + expect(refused).toContain("PRERELEASE version"); + expect(refused).toContain("allowlisted maintainer"); + }); + it("refuses an unauthorized stable release before anything is built", () => { // The gate lives in preflight, which every other job hangs off, so the // refusal costs seconds instead of a 4-way cross-compile — and nothing From 535b675332a5773b0e1678716d4915c70e488f59 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Tue, 4 Aug 2026 17:50:32 +0530 Subject: [PATCH 4/4] Carry the node environment name in the guard test's spawn env `bunx tsc --noEmit` failed the quality job: Next's global augmentation makes NODE_ENV a required member of ProcessEnv, so the minimal env literal handed to spawnSync did not satisfy the type. Passing it through keeps the child's environment deliberately minimal rather than spreading the parent's into it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017xe1xFTXzkjvmkX9skBmF3 --- __tests__/ci/release-pipeline.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/__tests__/ci/release-pipeline.test.ts b/__tests__/ci/release-pipeline.test.ts index c281d80a..94630557 100644 --- a/__tests__/ci/release-pipeline.test.ts +++ b/__tests__/ci/release-pipeline.test.ts @@ -171,6 +171,9 @@ describe("publish.yml", () => { encoding: "utf8", env: { PATH: process.env.PATH, + // Next's global augmentation makes NODE_ENV a required member of + // ProcessEnv, so a minimal env literal has to carry it. + NODE_ENV: process.env.NODE_ENV, STABLE_RELEASE_ACTORS: guard.env.STABLE_RELEASE_ACTORS, ACTOR: actor, TRIGGERING_ACTOR: triggeringActor,