Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions __tests__/ci/release-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -132,6 +136,95 @@ describe("publish.yml", () => {
expect(scripts).toContain('DIST_TAG="next"');
});

const stableGuard = () =>
wf.jobs.preflight.steps.find((s: Record<string, any>) => 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");
});

Comment thread
coderabbitai[bot] marked this conversation as resolved.
it("bumps main's version only for a release or a dispatch from main", () => {
const bump = wf.jobs.publish.steps.find(
(s: Record<string, any>) => s.name === "Bump version for next development cycle",
Expand Down
8 changes: 4 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}