From 78b8196e43e248c705ee6e0b3236cc491b1293c4 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Fri, 31 Jul 2026 18:02:40 +0530 Subject: [PATCH 1/3] ci: publish the failproofaid binaries and make releases ref-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon split (#632) added every packaging input — the platform manifests, the pinned optional dependencies, and a 4-way cross-compile matrix — but never touched publish.yml, so every release built four binaries as Actions artifacts and threw them away with the runner. CI was green throughout, because nothing checks that what gets built also gets shipped. Rework publish.yml into four jobs: preflight (version/dist-tag resolution, npm credential check, daemon detection), daemon (calls build-daemon.yml, now a reusable workflow), release-assets (downloads the artifacts, assembles SHA256SUMS, attaches both to the release), and publish (npm + aliases). The assets land BEFORE the npm publish because the installed CLI downloads its daemon from that release tag — publish the package first and its binary does not exist yet. Two guards make a branch dispatch safe. The version bump checks main out and pushes to it, so it now runs only for a release or a dispatch from main; a dispatch from a feature branch would otherwise rewrite main's version line to whatever that branch carries. And `latest` is refused from a non-main dispatch, with `auto` resolving to `next` there, so a branch build cannot move a dist-tag that a later release from main would then move backwards. Everything binary-related is gated on the ref actually carrying a Rust workspace, so this is a no-op on main until #632 lands: both daemon jobs skip and the npm publish behaves exactly as before. build-daemon.yml comes along inert for the same reason — it gates its matrix on a detect job, which is also what stops its own path filter from running `cargo build` against a checkout with no crates. Also adds a dry-run input (builds, checksums and `npm publish --dry-run` while writing nothing), fixes publish.yml's bun cache key, which hashed a `bun.lockb` this repo does not track, and adds a tripwire test over both workflows, since a hand-maintained pipeline that silently drops its own build artifacts is exactly what just happened. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EbXKFnpRXBwsrvUJDdySky --- .github/workflows/build-daemon.yml | 149 +++++++++++++ .github/workflows/publish.yml | 293 ++++++++++++++++++++++---- __tests__/ci/release-pipeline.test.ts | 184 ++++++++++++++++ 3 files changed, 581 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/build-daemon.yml create mode 100644 __tests__/ci/release-pipeline.test.ts diff --git a/.github/workflows/build-daemon.yml b/.github/workflows/build-daemon.yml new file mode 100644 index 00000000..d3724b9d --- /dev/null +++ b/.github/workflows/build-daemon.yml @@ -0,0 +1,149 @@ +name: Build failproofaid + +# Builds the real per-platform failproofaid binaries. Separate from ci.yml's +# cheap rust-quality job (lint/clippy/test on one native runner) because +# this is a slow 4-way cross-compile matrix — path-filtered so ordinary PRs +# that never touch the Rust workspace don't pay for it. +# +# `workflow_call` is how a release actually gets its binaries: publish.yml +# calls this workflow and then downloads the artifacts below in the same run, +# so the assets it uploads are built from the exact commit being published. +# There is deliberately NO `release: published` trigger — publish.yml owns +# that path now, and a standalone trigger would build the whole matrix twice +# per release. +on: + pull_request: + paths: + - "crates/**" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - ".github/workflows/build-daemon.yml" + workflow_call: + workflow_dispatch: + +jobs: + # The Rust workspace does not exist on every ref this workflow can run + # against: `main` carries no `Cargo.toml` until the daemon lands, and the + # path filter above matches this file itself, so a PR that only edits the + # workflow would otherwise run `cargo build` against a checkout with no + # crates in it and fail. Detect the workspace and skip the matrix cleanly — + # a skipped job is also what lets publish.yml call this from a ref that has + # no daemon at all. + detect: + runs-on: ubuntu-latest + outputs: + has_crates: ${{ steps.check.outputs.has_crates }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + + - name: Check for the Rust workspace + id: check + run: | + if [ -f Cargo.toml ] && [ -d crates ]; then + echo "has_crates=true" >> "$GITHUB_OUTPUT" + else + echo "has_crates=false" >> "$GITHUB_OUTPUT" + echo "::notice::No Rust workspace on this ref — skipping the failproofaid build matrix." + fi + + build: + needs: detect + if: needs.detect.outputs.has_crates == 'true' + strategy: + fail-fast: false + matrix: + include: + # Linux legs use GitHub-hosted native runners for both + # architectures (including a native arm64 runner) rather than + # `cross`/Docker cross-compilation — a real linker for the target + # triple, no QEMU emulation overhead. + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + platform: linux-x64 + - target: aarch64-unknown-linux-gnu + os: ubuntu-24.04-arm + platform: linux-arm64 + # macOS cannot be cross-compiled reliably from Linux (system + # framework linking), so both legs use real Apple Silicon / Intel + # runners. `macos-15-intel` rather than `macos-13`: GitHub retired + # the macOS 13 images, and an unknown runner label doesn't degrade + # — the leg never gets a runner at all. + - target: x86_64-apple-darwin + os: macos-15-intel + platform: darwin-x64 + - target: aarch64-apple-darwin + os: macos-14 + platform: darwin-arm64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7.0.1 + with: + # Nothing here pushes, and everything here runs third-party crate + # build scripts (`cargo build` on the full dependency tree). The + # default leaves GITHUB_TOKEN sitting in .git/config for the rest + # of the job, readable by any of them — and this job publishes a + # release binary. + persist-credentials: false + + - run: rustup target add ${{ matrix.target }} + + # Split restore/save rather than `actions/cache@v6`, which does both. + # This job runs on `pull_request` AND on the release path (via + # `workflow_call` from publish.yml, where `github.event_name` is the + # caller's `release`/`workflow_dispatch`), and the cache key is shared + # between them: a PR branch could otherwise write a poisoned `target/` + # entry that a later release run restores straight into a published + # binary. PR runs read the cache; only release / manual runs write it. + - uses: actions/cache/restore@v6 + id: cargo-cache + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + key: cargo-build-${{ matrix.target }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }} + restore-keys: cargo-build-${{ matrix.target }}- + + # --locked: this job's output is the binary users install, so it must be + # built from the dependency versions committed in Cargo.lock. Without it + # Cargo is free to resolve something newer and silently rewrite the + # lockfile in the runner, making the published artifact unreproducible + # from the commit it claims to come from. + - name: cargo build --release + run: cargo build --locked --release --target ${{ matrix.target }} -p failproofaid + + - uses: actions/cache/save@v6 + if: github.event_name != 'pull_request' && steps.cargo-cache.outputs.cache-hit != 'true' + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + key: cargo-build-${{ matrix.target }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }} + + # Plain gzip, not tar: the CLI decompresses this with `node:zlib` and no + # dependency, and a single-file stream needs no archive format. It also + # sidesteps `upload-artifact` dropping the executable bit — a compressed + # blob carries no mode to lose, and both consumers (the CLI downloader + # and install.sh) chmod after decompressing anyway. + # + # Every leg runs on a runner native to its own target, so executing the + # freshly built binary here is a real smoke test that the artifact is + # not a broken build. + - name: Compress the binary for release + run: | + BIN="target/${{ matrix.target }}/release/failproofaid" + "$BIN" --version + gzip -9 -c "$BIN" > "failproofaid-${{ matrix.platform }}.gz" + ls -l "failproofaid-${{ matrix.platform }}.gz" + + - uses: actions/upload-artifact@v4 + with: + name: failproofaid-${{ matrix.platform }} + path: failproofaid-${{ matrix.platform }}.gz + if-no-files-found: error diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f6adbc38..e7700a81 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,52 +1,55 @@ name: Publish to npm +# Two entry points, one pipeline: +# * `release: published` — the normal path. Cuts `latest` (or `beta` for a +# prerelease tag) and bumps `main` to the next development version. +# * `workflow_dispatch` — a manual build from any ref, including an unmerged +# feature branch. Note that GitHub runs the workflow file FROM THE SELECTED +# REF, so a fix to this file only applies to a branch dispatch once the +# branch carries it. +# +# The jobs run in a deliberate order: build the daemon binaries, attach them to +# 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. on: release: types: [published] workflow_dispatch: + inputs: + dist_tag: + description: "npm dist-tag (auto: next from a branch, beta for a prerelease on main, else latest)" + type: choice + options: + - auto + - next + - beta + - latest + default: auto + dry_run: + description: "Build and validate everything, but publish nothing" + type: boolean + default: false jobs: - publish: + # Everything cheap and fail-fast lives here: version resolution, the npm + # credential check (so a bad token costs seconds rather than a 20-minute + # cross-compile matrix), and whether this ref even carries the daemon. + preflight: runs-on: ubuntu-latest - permissions: - contents: write - id-token: write + outputs: + publish_version: ${{ steps.version.outputs.publish_version }} + pkg_version: ${{ steps.version.outputs.pkg_version }} + dist_tag: ${{ steps.version.outputs.dist_tag }} + next_version: ${{ steps.version.outputs.next_version }} + tag: ${{ steps.version.outputs.tag }} + is_prerelease: ${{ steps.version.outputs.is_prerelease }} + has_daemon: ${{ steps.daemon.outputs.has_daemon }} + dry_run: ${{ inputs.dry_run && 'true' || 'false' }} steps: - # Token for the version-bot GitHub App — a bypass actor on the org-level - # `failproofai-rules` ruleset (PR + 1 review required on main). The default - # GITHUB_TOKEN is not a bypass actor, so the "Bump version for next - # development cycle" push below is rejected with GH013 without this. Same - # app and secrets the bump-platform-submodule workflow uses. - - name: Mint version-bot app token - id: app-token - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ secrets.VERSION_BOT_APP_ID }} - private-key: ${{ secrets.VERSION_BOT_PRIVATE_KEY }} - - uses: actions/checkout@v7.0.1 with: - fetch-depth: 0 - # Persist the app token so the version-bump `git push origin main` - # below authenticates as the bypass actor, not the default token. - token: ${{ steps.app-token.outputs.token }} - - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - - uses: actions/cache@v6 - with: - path: ~/.bun/install/cache - key: bun-${{ runner.os }}-${{ hashFiles('bun.lockb') }} - restore-keys: bun-${{ runner.os }}- - - - name: Install dependencies - uses: nick-fields/retry@v4 - with: - max_attempts: 3 - timeout_minutes: 5 - command: bun install --frozen-lockfile + persist-credentials: false - uses: actions/setup-node@v7 with: @@ -55,6 +58,8 @@ jobs: - name: Resolve version and dist-tag id: version + env: + DIST_TAG_INPUT: ${{ inputs.dist_tag || 'auto' }} run: | PKG_VERSION=$(node -p "require('./package.json').version") @@ -66,13 +71,36 @@ jobs: PUBLISH_VERSION="$PKG_VERSION" fi - # Determine dist-tag if [[ "$PUBLISH_VERSION" == *-* ]]; then + IS_PRERELEASE=true + else + IS_PRERELEASE=false + fi + + # Determine dist-tag. An explicit input always wins; `auto` keeps the + # historical rule (prerelease -> beta, stable -> latest) EXCEPT on a + # dispatch from a ref other than main, where it resolves to `next`. + # A branch build published at `beta` would move that tag to code that + # is not on main, and the next beta cut from main would then move it + # BACKWARDS — an upgrade that is really a downgrade for anyone + # tracking `failproofai@beta`. + if [[ "$DIST_TAG_INPUT" != "auto" && "${{ github.event_name }}" == "workflow_dispatch" ]]; then + DIST_TAG="$DIST_TAG_INPUT" + elif [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.ref_name }}" != "main" ]]; then + DIST_TAG="next" + elif [[ "$IS_PRERELEASE" == "true" ]]; then DIST_TAG="beta" else DIST_TAG="latest" fi + # `latest` is what a bare `npm install failproofai` resolves to, so it + # may only ever come from main or from a published release. + if [[ "$DIST_TAG" == "latest" && "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.ref_name }}" != "main" ]]; then + echo "::error::Refusing to publish dist-tag 'latest' from ref '${{ github.ref_name }}'. Dispatch from main, or cut a release." + exit 1 + fi + # Compute the next development version if [[ "$PUBLISH_VERSION" == *-beta.* ]]; then BASE="${PUBLISH_VERSION%-beta.*}" @@ -92,31 +120,198 @@ jobs: echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT" echo "next_version=$NEXT_VERSION" >> "$GITHUB_OUTPUT" echo "pkg_version=$PKG_VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$PUBLISH_VERSION" >> "$GITHUB_OUTPUT" + echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" echo "Package.json version: $PKG_VERSION" echo "Publishing: $PUBLISH_VERSION (tag: $DIST_TAG)" + echo "Release tag: v$PUBLISH_VERSION (prerelease: $IS_PRERELEASE)" echo "Next version: $NEXT_VERSION" + echo "Dry run: ${{ inputs.dry_run && 'true' || 'false' }}" + + # 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 + id: daemon + run: | + if [ -f Cargo.toml ] && [ -d crates ]; then + echo "has_daemon=true" >> "$GITHUB_OUTPUT" + else + echo "has_daemon=false" >> "$GITHUB_OUTPUT" + echo "::notice::No Rust workspace on this ref — publishing the npm package only." + fi + + # Both publish steps below authenticate with this token; checking it here + # turns a bad or expired secret into a 10-second failure instead of one + # that lands after the cross-compile matrix has already run. + - name: Verify npm credentials + if: ${{ !inputs.dry_run }} + run: | + if ! WHO=$(npm whoami 2>&1); then + echo "::error::NPM_TOKEN cannot authenticate with the registry: $WHO" + exit 1 + fi + echo "Authenticated to npm as $WHO" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + # Builds the four platform binaries from this exact commit. Skipped entirely + # on a ref with no Rust workspace. + daemon: + needs: preflight + if: needs.preflight.outputs.has_daemon == 'true' + uses: ./.github/workflows/build-daemon.yml + + # Attaches the binaries + their checksums to the GitHub Release BEFORE npm + # publishes, because that release is where the installed CLI fetches its + # daemon from. + release-assets: + needs: [preflight, daemon] + if: needs.preflight.outputs.has_daemon == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: failproofaid-* + path: release-assets + merge-multiple: true + + - name: Assemble SHA256SUMS + working-directory: release-assets + run: | + sha256sum failproofaid-*.gz > SHA256SUMS + cat SHA256SUMS + # The CLI refuses to install a binary it cannot match to a checksum, + # so a short list here is a broken release, not a partial one. + COUNT=$(grep -c . SHA256SUMS) + if [ "$COUNT" -ne 4 ]; then + echo "::error::Expected 4 platform binaries, found $COUNT" + exit 1 + fi + + - name: Attach assets to the release + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.preflight.outputs.tag }} + PRERELEASE: ${{ needs.preflight.outputs.is_prerelease }} + run: | + if [[ "${{ github.event_name }}" == "release" ]]; then + gh release upload "$GITHUB_REF_NAME" release-assets/* --clobber --repo "$GITHUB_REPOSITORY" + elif gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release upload "$TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY" + else + # A dispatch from an unmerged branch creates its own tag at the + # dispatched commit — that tag is what makes the download URLs the + # CLI builds resolvable. + FLAGS=(--target "$GITHUB_SHA" --title "$TAG" --repo "$GITHUB_REPOSITORY") + if [[ "$PRERELEASE" == "true" ]]; then FLAGS+=(--prerelease); fi + gh release create "$TAG" release-assets/* "${FLAGS[@]}" \ + --notes "Automated build of \`$TAG\` from \`$GITHUB_SHA\` (run $GITHUB_RUN_ID)." + fi + + - name: Report dry run + if: ${{ inputs.dry_run }} + run: echo "::notice::Dry run — built and checksummed 4 binaries, attached nothing to ${{ needs.preflight.outputs.tag }}." + + publish: + needs: [preflight, daemon, release-assets] + # Both daemon jobs are skipped on a ref with no Rust workspace, which must + # not block the npm publish. A FAILURE in either one must, though — and + # that is why `daemon` is checked explicitly rather than relied on through + # release-assets: a failed dependency leaves the dependent job `skipped`, + # which would otherwise read here as "nothing to do" and publish a package + # whose daemon binaries were never built. + if: >- + always() && + needs.preflight.result == 'success' && + (needs.daemon.result == 'success' || needs.daemon.result == 'skipped') && + (needs.release-assets.result == 'success' || needs.release-assets.result == 'skipped') + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + steps: + # Token for the version-bot GitHub App — a bypass actor on the org-level + # `failproofai-rules` ruleset (PR + 1 review required on main). The default + # GITHUB_TOKEN is not a bypass actor, so the "Bump version for next + # development cycle" push below is rejected with GH013 without this. Same + # app and secrets the bump-platform-submodule workflow uses. + - name: Mint version-bot app token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.VERSION_BOT_APP_ID }} + private-key: ${{ secrets.VERSION_BOT_PRIVATE_KEY }} + + - uses: actions/checkout@v7.0.1 + with: + fetch-depth: 0 + # Persist the app token so the version-bump `git push origin main` + # below authenticates as the bypass actor, not the default token. + token: ${{ steps.app-token.outputs.token }} + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - uses: actions/cache@v6 + with: + path: ~/.bun/install/cache + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} + restore-keys: bun-${{ runner.os }}- + + - name: Install dependencies + uses: nick-fields/retry@v4 + with: + max_attempts: 3 + timeout_minutes: 5 + command: bun install --frozen-lockfile + + - uses: actions/setup-node@v7 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" - name: Set publish version in package.json - if: steps.version.outputs.publish_version != steps.version.outputs.pkg_version + if: needs.preflight.outputs.publish_version != needs.preflight.outputs.pkg_version run: | - npm version "${{ steps.version.outputs.publish_version }}" --no-git-tag-version - echo "Updated package.json to ${{ steps.version.outputs.publish_version }}" + npm version "${{ needs.preflight.outputs.publish_version }}" --no-git-tag-version + echo "Updated package.json to ${{ needs.preflight.outputs.publish_version }}" - name: Publish - run: npm publish --provenance --tag ${{ steps.version.outputs.dist_tag }} + run: | + if [[ "${{ needs.preflight.outputs.dry_run }}" == "true" ]]; then + # --provenance is dropped here on purpose: attestation is a + # registry-side write that has nothing to validate in a dry run. + npm publish --dry-run --tag "${{ needs.preflight.outputs.dist_tag }}" + else + npm publish --provenance --tag "${{ needs.preflight.outputs.dist_tag }}" + fi env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish alias packages - run: node scripts/publish-aliases.mjs --dist-tag ${{ steps.version.outputs.dist_tag }} + run: | + ARGS=(--dist-tag "${{ needs.preflight.outputs.dist_tag }}") + if [[ "${{ needs.preflight.outputs.dry_run }}" == "true" ]]; then ARGS+=(--dry-run); fi + node scripts/publish-aliases.mjs "${ARGS[@]}" env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # The bump targets main unconditionally — it checks main out and pushes to + # it — so it must never run for a build that did not come from main. A + # dispatch from a feature branch would otherwise rewrite main's version + # line to whatever that branch happens to carry. - name: Bump version for next development cycle - if: steps.version.outputs.next_version != '' + if: >- + needs.preflight.outputs.next_version != '' && + needs.preflight.outputs.dry_run != 'true' && + (github.event_name == 'release' || github.ref_name == 'main') env: - NEXT_VERSION: ${{ steps.version.outputs.next_version }} + NEXT_VERSION: ${{ needs.preflight.outputs.next_version }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -132,3 +327,11 @@ jobs: # Authenticated as the version-bot App (persisted by the checkout above), # which bypasses the ruleset's pull-request requirement on main. git push origin main + + - name: Note skipped bump + if: >- + needs.preflight.outputs.next_version != '' && + (needs.preflight.outputs.dry_run == 'true' || + (github.event_name != 'release' && github.ref_name != 'main')) + run: | + echo "::notice::Left main's version untouched (ref '${{ github.ref_name }}', dry_run=${{ needs.preflight.outputs.dry_run }}). Main is bumped only by a release or a dispatch from main." diff --git a/__tests__/ci/release-pipeline.test.ts b/__tests__/ci/release-pipeline.test.ts new file mode 100644 index 00000000..241db9e5 --- /dev/null +++ b/__tests__/ci/release-pipeline.test.ts @@ -0,0 +1,184 @@ +// @vitest-environment node +/** + * Drift guard for the release pipeline. + * + * These two workflows are hand-maintained, nothing generates them, and their + * failure mode is silent: PR #632 shipped the entire daemon packaging story — + * platform manifests, pinned optional dependencies, a 4-way cross-compile — + * without ever touching publish.yml, so the binary users were told they would + * get was built on every release and then thrown away with the runner. CI was + * green throughout. This file is the tripwire for the wiring that fixes it. + * + * What it pins, and why each one is load-bearing: + * - the npm publish happens AFTER the release assets are attached (the + * installed CLI downloads its daemon from that release tag, so publishing + * the package first ships a version whose binary does not exist yet); + * - the main-version bump only runs for a release or a dispatch from main + * (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); + * - 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 { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parse } from "yaml"; + +const ROOT = process.cwd(); + +function workflow(name: string): Record { + return parse(readFileSync(resolve(ROOT, ".github/workflows", name), "utf8")); +} + +/** + * `on:` is a plain string key under YAML 1.2 (which the `yaml` package + * implements), but YAML 1.1 parsers fold it to boolean `true`. Read both so a + * parser upgrade cannot quietly turn every trigger assertion into a no-op. + */ +function triggers(wf: Record): Record { + return wf.on ?? wf[true as unknown as string] ?? {}; +} + +/** Every `run:` script in a job, concatenated — for asserting on shell logic. */ +function runScripts(job: Record): string { + return (job.steps ?? []) + .map((s: Record) => s.run ?? "") + .join("\n"); +} + +const PLATFORMS = ["linux-x64", "linux-arm64", "darwin-x64", "darwin-arm64"]; + +describe("build-daemon.yml", () => { + const wf = workflow("build-daemon.yml"); + + it("is callable by publish.yml and is not separately triggered on a release", () => { + const on = triggers(wf); + expect(on).toHaveProperty("workflow_call"); + // publish.yml calls this workflow; a standalone release trigger would + // build the whole 4-way matrix a second time for every release. + expect(on).not.toHaveProperty("release"); + }); + + it("skips the matrix on a ref with no Rust workspace", () => { + // main carries no Cargo.toml until the daemon lands, and the pull_request + // path filter matches this workflow's own file — so an edit to it would + // otherwise run `cargo build` against a checkout with no crates. + expect(wf.jobs.build.needs).toBe("detect"); + expect(wf.jobs.build.if).toContain("needs.detect.outputs.has_crates == 'true'"); + expect(runScripts(wf.jobs.detect)).toContain("Cargo.toml"); + }); + + it("builds and uploads exactly the four supported platforms", () => { + const matrix = wf.jobs.build.strategy.matrix.include; + expect(matrix.map((m: Record) => m.platform).sort()).toEqual([...PLATFORMS].sort()); + + const upload = wf.jobs.build.steps.find((s: Record) => + String(s.uses ?? "").startsWith("actions/upload-artifact"), + ); + expect(upload.with.name).toBe("failproofaid-${{ matrix.platform }}"); + expect(upload.with.path).toBe("failproofaid-${{ matrix.platform }}.gz"); + // A missing binary must fail the leg, not upload an empty artifact that + // only fails later as a short SHA256SUMS. + expect(upload.with["if-no-files-found"]).toBe("error"); + }); + + it("builds from the committed lockfile and gzips the binary", () => { + const scripts = runScripts(wf.jobs.build); + expect(scripts).toContain("cargo build --locked --release"); + expect(scripts).toContain("gzip"); + }); +}); + +describe("publish.yml", () => { + const wf = workflow("publish.yml"); + + it("offers dist-tag and dry-run inputs on a manual dispatch", () => { + const inputs = triggers(wf).workflow_dispatch.inputs; + expect(Object.keys(inputs).sort()).toEqual(["dist_tag", "dry_run"]); + expect(inputs.dist_tag.options).toContain("next"); + expect(inputs.dry_run.default).toBe(false); + }); + + it("publishes to npm only after the release assets are attached", () => { + // Reversing these two ships a package whose daemon download 404s for as + // long as the asset upload takes — or forever, if it fails. + expect(wf.jobs.publish.needs).toContain("release-assets"); + expect(wf.jobs["release-assets"].needs).toContain("daemon"); + expect(wf.jobs.daemon.uses).toBe("./.github/workflows/build-daemon.yml"); + }); + + it("still publishes on a ref that carries no daemon", () => { + // main has no Rust workspace today; a skipped daemon build must not block + // the npm publish, while a genuine failure still must. + expect(wf.jobs.daemon.if).toContain("has_daemon == 'true'"); + expect(wf.jobs.publish.if).toContain("needs.release-assets.result == 'skipped'"); + }); + + it("never publishes when the daemon build failed", () => { + // A failed dependency leaves its dependents `skipped`, so checking only + // release-assets would read a failed cross-compile as "nothing to do" and + // publish a version whose binaries do not exist. + expect(wf.jobs.publish.needs).toContain("daemon"); + expect(wf.jobs.publish.if).toContain("needs.daemon.result == 'success'"); + }); + + it("refuses to move the latest dist-tag from a branch dispatch", () => { + const scripts = runScripts(wf.jobs.preflight); + expect(scripts).toContain("Refusing to publish dist-tag 'latest'"); + // A branch dispatch defaults to `next` rather than `beta`, so it cannot + // move `beta` to code that is not on main. + expect(scripts).toContain('DIST_TAG="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", + ); + expect(bump.run).toContain("git push origin main"); + expect(bump.if).toContain("github.event_name == 'release'"); + expect(bump.if).toContain("github.ref_name == 'main'"); + expect(bump.if).toContain("dry_run != 'true'"); + }); + + it("verifies the release carries every platform binary", () => { + const scripts = runScripts(wf.jobs["release-assets"]); + expect(scripts).toContain("SHA256SUMS"); + // The CLI will not install a binary it cannot match to a checksum line, so + // a short list is a broken release rather than a partial one. + expect(scripts).toContain('"$COUNT" -ne 4'); + }); + + it("writes nothing to npm or the repo on a dry run", () => { + const publishStep = wf.jobs.publish.steps.find((s: Record) => s.name === "Publish"); + expect(publishStep.run).toContain("npm publish --dry-run"); + const assets = wf.jobs["release-assets"].steps.find( + (s: Record) => s.name === "Attach assets to the release", + ); + expect(assets.if).toContain("!inputs.dry_run"); + }); +}); + +describe("pipeline / CLI agreement", () => { + const DAEMON_SERVICE = resolve(ROOT, "src/hooks/daemon-service.ts"); + + // Only meaningful on a ref that carries the daemon. On main (before #632 + // lands) there is no CLI-side platform list to agree with. + it.skipIf(!existsSync(DAEMON_SERVICE))( + "builds a binary for every platform the CLI knows how to resolve", + () => { + const source = readFileSync(DAEMON_SERVICE, "utf8"); + const union = source.match(/type PlatformKey =([^;]+);/)?.[1] ?? ""; + const declared = [...union.matchAll(/"([a-z0-9-]+)"/g)].map((m) => m[1]); + + expect(declared.length).toBeGreaterThan(0); + const built = workflow("build-daemon.yml").jobs.build.strategy.matrix.include.map( + (m: Record) => m.platform, + ); + // A platform the CLI resolves but CI never builds is a platform whose + // users silently get no daemon. + expect([...declared].sort()).toEqual([...built].sort()); + }, + ); +}); From 8bacf06deea5d7fbd3167e88fc5ce04f3bc16fb8 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Fri, 31 Jul 2026 18:04:02 +0530 Subject: [PATCH 2/3] docs(changelog): add the release-pipeline entry Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EbXKFnpRXBwsrvUJDdySky --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec587f0..a3f1d239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.0.16-beta.0 — 2026-07-31 + +### Fixes +- 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 ### Features From 9ae8f66d10e6acf730479a0e899372ce11d60733 Mon Sep 17 00:00:00 2001 From: NiveditJain Date: Fri, 31 Jul 2026 18:17:48 +0530 Subject: [PATCH 3/3] fix: address release workflow review findings --- .github/workflows/ci.yml | 10 ++--- .github/workflows/publish.yml | 57 ++++++++++++++++++---------- .github/workflows/translate-docs.yml | 2 +- CHANGELOG.md | 1 + 4 files changed, 44 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fab7fcd7..23a5a8b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - uses: actions/cache@v6 with: path: ~/.bun/install/cache - key: bun-${{ runner.os }}-${{ hashFiles('bun.lockb') }} + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} restore-keys: bun-${{ runner.os }}- - name: Install dependencies @@ -97,7 +97,7 @@ jobs: - uses: actions/cache@v6 with: path: ~/.bun/install/cache - key: bun-${{ runner.os }}-${{ hashFiles('bun.lockb') }} + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} restore-keys: bun-${{ runner.os }}- - name: Install dependencies @@ -130,7 +130,7 @@ jobs: - uses: actions/cache@v6 with: path: ~/.bun/install/cache - key: bun-${{ runner.os }}-${{ hashFiles('bun.lockb') }} + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} restore-keys: bun-${{ runner.os }}- - name: Install dependencies @@ -159,7 +159,7 @@ jobs: - uses: actions/cache@v6 with: path: ~/.bun/install/cache - key: bun-${{ runner.os }}-${{ hashFiles('bun.lockb') }} + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} restore-keys: bun-${{ runner.os }}- - name: Install dependencies @@ -204,7 +204,7 @@ jobs: - uses: actions/cache@v6 with: path: ~/.bun/install/cache - key: bun-${{ runner.os }}-${{ hashFiles('bun.lockb') }} + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} restore-keys: bun-${{ runner.os }}- - name: Install dependencies diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e7700a81..246ffcc6 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -60,11 +60,14 @@ jobs: id: version env: DIST_TAG_INPUT: ${{ inputs.dist_tag || 'auto' }} + DRY_RUN: ${{ inputs.dry_run && 'true' || 'false' }} + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} run: | PKG_VERSION=$(node -p "require('./package.json').version") # Determine the publish version - if [[ "${{ github.event_name }}" == "release" ]]; then + if [[ "$EVENT_NAME" == "release" ]]; then TAG_VERSION="${GITHUB_REF_NAME#v}" PUBLISH_VERSION="$TAG_VERSION" else @@ -84,9 +87,9 @@ jobs: # is not on main, and the next beta cut from main would then move it # BACKWARDS — an upgrade that is really a downgrade for anyone # tracking `failproofai@beta`. - if [[ "$DIST_TAG_INPUT" != "auto" && "${{ github.event_name }}" == "workflow_dispatch" ]]; then + if [[ "$DIST_TAG_INPUT" != "auto" && "$EVENT_NAME" == "workflow_dispatch" ]]; then DIST_TAG="$DIST_TAG_INPUT" - elif [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.ref_name }}" != "main" ]]; then + elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$REF_NAME" != "main" ]]; then DIST_TAG="next" elif [[ "$IS_PRERELEASE" == "true" ]]; then DIST_TAG="beta" @@ -96,8 +99,8 @@ jobs: # `latest` is what a bare `npm install failproofai` resolves to, so it # may only ever come from main or from a published release. - if [[ "$DIST_TAG" == "latest" && "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.ref_name }}" != "main" ]]; then - echo "::error::Refusing to publish dist-tag 'latest' from ref '${{ github.ref_name }}'. Dispatch from main, or cut a release." + if [[ "$DIST_TAG" == "latest" && "$EVENT_NAME" == "workflow_dispatch" && "$REF_NAME" != "main" ]]; then + echo "::error::Refusing to publish dist-tag 'latest' from ref '$REF_NAME'. Dispatch from main, or cut a release." exit 1 fi @@ -127,7 +130,7 @@ jobs: echo "Publishing: $PUBLISH_VERSION (tag: $DIST_TAG)" echo "Release tag: v$PUBLISH_VERSION (prerelease: $IS_PRERELEASE)" echo "Next version: $NEXT_VERSION" - echo "Dry run: ${{ inputs.dry_run && 'true' || 'false' }}" + echo "Dry run: $DRY_RUN" # A ref without the Rust workspace (main, until the daemon lands) skips # the whole binary half of the pipeline and publishes exactly as before. @@ -194,11 +197,12 @@ jobs: - name: Attach assets to the release if: ${{ !inputs.dry_run }} env: + EVENT_NAME: ${{ github.event_name }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.preflight.outputs.tag }} PRERELEASE: ${{ needs.preflight.outputs.is_prerelease }} run: | - if [[ "${{ github.event_name }}" == "release" ]]; then + if [[ "$EVENT_NAME" == "release" ]]; then gh release upload "$GITHUB_REF_NAME" release-assets/* --clobber --repo "$GITHUB_REPOSITORY" elif gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then gh release upload "$TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY" @@ -214,7 +218,9 @@ jobs: - name: Report dry run if: ${{ inputs.dry_run }} - run: echo "::notice::Dry run — built and checksummed 4 binaries, attached nothing to ${{ needs.preflight.outputs.tag }}." + env: + TAG: ${{ needs.preflight.outputs.tag }} + run: echo "::notice::Dry run — built and checksummed 4 binaries, attached nothing to $TAG." publish: needs: [preflight, daemon, release-assets] @@ -277,29 +283,35 @@ jobs: - name: Set publish version in package.json if: needs.preflight.outputs.publish_version != needs.preflight.outputs.pkg_version + env: + PUBLISH_VERSION: ${{ needs.preflight.outputs.publish_version }} run: | - npm version "${{ needs.preflight.outputs.publish_version }}" --no-git-tag-version - echo "Updated package.json to ${{ needs.preflight.outputs.publish_version }}" + npm version "$PUBLISH_VERSION" --no-git-tag-version + echo "Updated package.json to $PUBLISH_VERSION" - name: Publish + env: + DIST_TAG: ${{ needs.preflight.outputs.dist_tag }} + DRY_RUN: ${{ needs.preflight.outputs.dry_run }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | - if [[ "${{ needs.preflight.outputs.dry_run }}" == "true" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then # --provenance is dropped here on purpose: attestation is a # registry-side write that has nothing to validate in a dry run. - npm publish --dry-run --tag "${{ needs.preflight.outputs.dist_tag }}" + npm publish --dry-run --tag "$DIST_TAG" else - npm publish --provenance --tag "${{ needs.preflight.outputs.dist_tag }}" + npm publish --provenance --tag "$DIST_TAG" fi - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Publish alias packages - run: | - ARGS=(--dist-tag "${{ needs.preflight.outputs.dist_tag }}") - if [[ "${{ needs.preflight.outputs.dry_run }}" == "true" ]]; then ARGS+=(--dry-run); fi - node scripts/publish-aliases.mjs "${ARGS[@]}" env: + DIST_TAG: ${{ needs.preflight.outputs.dist_tag }} + DRY_RUN: ${{ needs.preflight.outputs.dry_run }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + ARGS=(--dist-tag "$DIST_TAG") + if [[ "$DRY_RUN" == "true" ]]; then ARGS+=(--dry-run); fi + node scripts/publish-aliases.mjs "${ARGS[@]}" # The bump targets main unconditionally — it checks main out and pushes to # it — so it must never run for a build that did not come from main. A @@ -316,6 +328,8 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" + # The publish-version step may have left package.json modified. + git checkout -- package.json git fetch origin main git checkout main @@ -333,5 +347,8 @@ jobs: needs.preflight.outputs.next_version != '' && (needs.preflight.outputs.dry_run == 'true' || (github.event_name != 'release' && github.ref_name != 'main')) + env: + DRY_RUN: ${{ needs.preflight.outputs.dry_run }} + REF_NAME: ${{ github.ref_name }} run: | - echo "::notice::Left main's version untouched (ref '${{ github.ref_name }}', dry_run=${{ needs.preflight.outputs.dry_run }}). Main is bumped only by a release or a dispatch from main." + echo "::notice::Left main's version untouched (ref '$REF_NAME', dry_run=$DRY_RUN). Main is bumped only by a release or a dispatch from main." diff --git a/.github/workflows/translate-docs.yml b/.github/workflows/translate-docs.yml index ddd3dac2..17453150 100644 --- a/.github/workflows/translate-docs.yml +++ b/.github/workflows/translate-docs.yml @@ -72,7 +72,7 @@ jobs: - uses: actions/cache@v6 with: path: ~/.bun/install/cache - key: bun-${{ runner.os }}-${{ hashFiles('bun.lockb') }} + key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }} restore-keys: bun-${{ runner.os }}- - name: Install dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index a3f1d239..457d062a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.0.16-beta.0 — 2026-07-31 ### 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) ## 0.0.15-beta.1 — 2026-07-29