diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 246ffcc6..06bfa9bf 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,65 @@ 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 + # 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 + + 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..cc1f9e27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,14 @@ ## 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) + +### 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/__tests__/ci/release-pipeline.test.ts b/__tests__/ci/release-pipeline.test.ts index 241db9e5..94630557 100644 --- a/__tests__/ci/release-pipeline.test.ts +++ b/__tests__/ci/release-pipeline.test.ts @@ -17,11 +17,15 @@ * (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. */ 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"; @@ -132,6 +136,95 @@ 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"); + }); + + /** + * 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, + // 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, + 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 + // 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", 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" } }