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/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 f6adbc38..246ffcc6 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,24 +58,52 @@ jobs: - name: Resolve version and dist-tag 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 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" && "$EVENT_NAME" == "workflow_dispatch" ]]; then + DIST_TAG="$DIST_TAG_INPUT" + elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$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" && "$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 + # Compute the next development version if [[ "$PUBLISH_VERSION" == *-beta.* ]]; then BASE="${PUBLISH_VERSION%-beta.*}" @@ -92,35 +123,213 @@ 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: $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. + - 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: + EVENT_NAME: ${{ github.event_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.preflight.outputs.tag }} + PRERELEASE: ${{ needs.preflight.outputs.is_prerelease }} + run: | + 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" + 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 }} + 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] + # 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 + env: + PUBLISH_VERSION: ${{ needs.preflight.outputs.publish_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 "$PUBLISH_VERSION" --no-git-tag-version + echo "Updated package.json to $PUBLISH_VERSION" - name: Publish - run: npm publish --provenance --tag ${{ steps.version.outputs.dist_tag }} env: + DIST_TAG: ${{ needs.preflight.outputs.dist_tag }} + DRY_RUN: ${{ needs.preflight.outputs.dry_run }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + 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 "$DIST_TAG" + else + npm publish --provenance --tag "$DIST_TAG" + fi - name: Publish alias packages - run: node scripts/publish-aliases.mjs --dist-tag ${{ steps.version.outputs.dist_tag }} 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 + # 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" + # The publish-version step may have left package.json modified. + git checkout -- package.json git fetch origin main git checkout main @@ -132,3 +341,14 @@ 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')) + env: + DRY_RUN: ${{ needs.preflight.outputs.dry_run }} + REF_NAME: ${{ github.ref_name }} + run: | + 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 4ec587f0..457d062a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 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 ### Features 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()); + }, + ); +});