From 33f2f8772c7d6f52fb6845e2cad1842dca359e7a Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Mon, 17 Aug 2026 14:54:52 -0700 Subject: [PATCH 1/4] ci: run the gate on the self-hosted runner, and gate the hosted jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosted Actions minutes are unavailable — a release job fails with zero steps and the annotation "recent account payments have failed". That blocked the 0.5.1 publish, which then went out manually and therefore without provenance. `verify` and the invariants check now run on macmini-agentcall. Both cost nothing, so the workflows can fire on push and pull_request again rather than sitting behind workflow_dispatch, and `release.yml` moves too: OIDC trusted publishing works on a self-hosted runner because GitHub issues the token, not the runner host, so 0.5.2 gets its attestation back. `packed-cli-consumer` and `windows-compat` stay hosted and are gated to manual dispatch. Neither can run on one macOS ARM64 box — the first matrixes over ubuntu-latest and deliberately has no checkout, the second needs Windows — and without the gate they would fire on every push and fail on billing, leaving CI permanently red for reasons unrelated to the change. Drop both `if:` lines when hosted billing returns. The test that enforced blanket manual-only asserted a premise that has stopped being true: it made sense while every job was billable, and would now keep the gate switched off for free jobs. Replaced with the narrower rule that still has to hold — no automatic trigger may reach a job on a hosted runner, checked per job rather than per workflow, so a third hosted job added later is caught too — plus an assertion that `verify` and `check` really are on the runner. Refs #452. Deliberately not adding `--repo` to the attach step; GH_REPO already covers it and has simply never run. --- .github/workflows/ci.yml | 18 ++++++----- .github/workflows/invariants.yml | 12 ++++---- .github/workflows/release.yml | 6 ++-- packages/cli/test/release-workflow.test.ts | 35 +++++++++++++++++++--- 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e19c33d7..9a7a13b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,12 @@ name: CI -# Automatic runs are temporarily paused while GitHub Actions billing is -# unavailable. Keep the workflow runnable by hand so the CI definition remains -# easy to verify and restore. `scripts/ci-local.sh fast` is the local mirror of -# the `verify` job and runs from a pre-push hook in the meantime; keep the two -# in step, because a local gate that has drifted reports green for a rule this -# workflow would fail. +# `scripts/ci-local.sh fast` is the local mirror of the `verify` job and runs +# from a pre-push hook; keep the two in step, because a local gate that has +# drifted reports green for a rule this workflow would fail. on: + push: + branches: [main] + pull_request: workflow_dispatch: # A new manual run for the same ref supersedes the run in flight. @@ -16,7 +16,7 @@ concurrency: jobs: verify: - runs-on: ubuntu-latest + runs-on: [self-hosted, macOS, ARM64] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -72,6 +72,8 @@ jobs: packed-cli-consumer: needs: verify + # Hosted runner: manual-only while GitHub Actions billing is unavailable. + if: github.event_name == 'workflow_dispatch' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -114,6 +116,8 @@ jobs: # whose startup surface this job probes. windows-compat: needs: verify + # Hosted runner: manual-only while GitHub Actions billing is unavailable. + if: github.event_name == 'workflow_dispatch' runs-on: windows-2025 strategy: fail-fast: false diff --git a/.github/workflows/invariants.yml b/.github/workflows/invariants.yml index 65fbfcdc..d1006a84 100644 --- a/.github/workflows/invariants.yml +++ b/.github/workflows/invariants.yml @@ -3,12 +3,12 @@ name: Invariants # The rules in CLAUDE.md that nothing currently enforces. Deterministic checks # only -- no model, no secrets, no network. Runs read-only: findings surface as # GitHub annotations on the PR, which need no write permission. -# Automatic runs are temporarily paused while GitHub Actions billing is -# unavailable. Keep the workflow runnable by hand so the checks remain easy to -# verify and restore. `scripts/ci-local.sh fast` ports every check below and -# runs from a pre-push hook in the meantime; a check added here must be added -# there too. +# `scripts/ci-local.sh fast` ports every check below and runs from a pre-push +# hook; a check added here must be added there too. on: + push: + branches: [main] + pull_request: workflow_dispatch: permissions: @@ -20,7 +20,7 @@ concurrency: jobs: check: - runs-on: ubuntu-latest + runs-on: [self-hosted, macOS, ARM64] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bd891de..0e8b4f71 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ concurrency: jobs: build: - runs-on: ubuntu-latest + runs-on: [self-hosted, macOS, ARM64] permissions: contents: read steps: @@ -83,7 +83,7 @@ jobs: publish: needs: build - runs-on: ubuntu-latest + runs-on: [self-hosted, macOS, ARM64] environment: npm permissions: contents: read @@ -160,7 +160,7 @@ jobs: attach: needs: publish - runs-on: ubuntu-latest + runs-on: [self-hosted, macOS, ARM64] permissions: contents: write steps: diff --git a/packages/cli/test/release-workflow.test.ts b/packages/cli/test/release-workflow.test.ts index a2c7063d..5bc9b697 100644 --- a/packages/cli/test/release-workflow.test.ts +++ b/packages/cli/test/release-workflow.test.ts @@ -49,13 +49,40 @@ function actionReferences(value: unknown): string[] { } describe("npm release workflow", () => { - it("keeps billable verification workflows manual-only while automatic runs are paused", () => { - for (const source of [ciWorkflow, invariantsWorkflow]) { - expect(source).toMatch(/^on:\n workflow_dispatch:\s*$/m); - expect(source).not.toMatch(/^ (?:pull_request|push):/m); + // Was "keep these workflows manual-only". That held while every job ran on a + // GitHub-hosted runner and hosted billing was unavailable. Now `verify` and + // the invariants check run on a self-hosted runner, which costs nothing, so + // blanket manual-only would keep the gate switched off for no reason. + // + // What still has to hold is narrower: a job on a *hosted* runner must not be + // reachable from an automatic trigger, or every push fails on the billing + // block and CI reads red for a reason unrelated to the change. + it("never lets an automatic trigger reach a job on a billable hosted runner", () => { + const hostedRunner = /runs-on: (?!\[self-hosted)/; + + for (const [name, source] of [["ci", ciWorkflow], ["invariants", invariantsWorkflow]] as const) { + const automatic = /^ (?:pull_request|push):/m.test(source); + if (!automatic) continue; + + // Job blocks are two-space keys under `jobs:`; split on them so each + // job's runs-on and its `if:` guard are read together. + const jobs = source.split(/\n(?= [a-z][a-z0-9-]*:\n)/).filter((block) => /runs-on:/.test(block)); + const ungated = jobs + .filter((block) => hostedRunner.test(block)) + .filter((block) => !/if: github\.event_name == 'workflow_dispatch'/.test(block)) + .map((block) => block.trimStart().split(":")[0]); + + expect(ungated, `${name}.yml runs hosted jobs on an automatic trigger`).toEqual([]); } }); + it("runs the gate itself on the self-hosted runner, so a push is actually checked", () => { + // The point of the move: `verify` is what decides whether a change ships, + // and it has to run without hosted minutes. + expect(ciWorkflow).toMatch(/verify:\n {4}runs-on: \[self-hosted, macOS, ARM64\]/); + expect(invariantsWorkflow).toMatch(/check:\n {4}runs-on: \[self-hosted, macOS, ARM64\]/); + }); + it("publishes the CLI for both supported listener platforms", () => { const manifest = JSON.parse(readFileSync(join(root, "packages/cli/package.json"), "utf8")); expect(manifest.os).toEqual(["darwin", "linux"]); From b92173dd7c72eda18c0a900be46c70e0d26912a3 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Mon, 17 Aug 2026 14:58:49 -0700 Subject: [PATCH 2/4] ci: retrigger now that the CI and Invariants workflows are re-enabled From a5a7ea35540453048b925834a408d6cd33535c93 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Mon, 17 Aug 2026 15:17:07 -0700 Subject: [PATCH 3/4] fix(ci): read workflows portably, so the pin check runs on stock Ruby MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `YAML.safe_load_file` needs Psych 3.3, which means Ruby 3.0. The hosted ubuntu image had that; the self-hosted runner ships an older stock Ruby, so the invariants job died with `undefined method 'safe_load_file' for Psych:Module`. The failure reads like a pinning violation — the job is named "Third-party actions are immutable" and it exits 1 — when in fact the check never ran. Reading the file ourselves works on every Ruby that takes keyword arguments to `safe_load`, which is 2.6 onward. Verified behaviour-preserving: both forms parse all five workflow files to identical structures. Fixed here rather than by pinning a Ruby in the workflow, because the script should not require a specific toolchain to run by hand — and because .github/workflows/ is a hard deny for automated edits. --- scripts/verify-action-pins.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/verify-action-pins.rb b/scripts/verify-action-pins.rb index af3bd303..8cbcecc3 100644 --- a/scripts/verify-action-pins.rb +++ b/scripts/verify-action-pins.rb @@ -20,8 +20,13 @@ end end +# `safe_load_file` needs Psych 3.3 (Ruby 3.0). The self-hosted runner has an +# older stock Ruby and failed here with `undefined method 'safe_load_file'`, +# which reads like a pinning violation rather than a missing method. Reading the +# file ourselves works on every Ruby that ships `safe_load` with keyword +# arguments, which is 2.6 onward. Dir.glob(".github/workflows/*.{yml,yaml}").sort.each do |file| - visit.call(YAML.safe_load_file(file, aliases: false), file, []) + visit.call(YAML.safe_load(File.read(file), aliases: false), file, []) end unless failures.empty? From ad134d825e1e27e435bbbba1fe14a53d9b97dc61 Mon Sep 17 00:00:00 2001 From: RyuseiTaniguchi Date: Mon, 17 Aug 2026 15:39:02 -0700 Subject: [PATCH 4/4] fix(ci): unescape the identity-middleware grep so the check can match The pattern was written with literal backslashes: grep -F 'app.use(\"/v1/*\", requireIdentity)' Inside single quotes those are not escapes, so the search string contained backslashes the source never has. apps/relay/src/index.ts:37 reads `app.use("/v1/*", requireIdentity);`, so the check could not match on any machine and reported "Missing the fail-closed /v1 identity middleware" whenever it ran. It has not run since Actions billing broke, which is why nobody saw it. Re-enabling the workflow surfaced it on the first execution. scripts/ci-local.sh:146 carries the correct unescaped form, so the local gate was quietly stricter than the remote one this whole time. This is the only escaped-quote grep in the file. --- .github/workflows/invariants.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/invariants.yml b/.github/workflows/invariants.yml index d1006a84..b34baa93 100644 --- a/.github/workflows/invariants.yml +++ b/.github/workflows/invariants.yml @@ -98,7 +98,7 @@ jobs: - name: Relay authentication uses the shared middleware seam run: | - test "$(grep -F 'app.use(\"/v1/*\", requireIdentity)' apps/relay/src/index.ts)" || { + test "$(grep -F 'app.use("/v1/*", requireIdentity)' apps/relay/src/index.ts)" || { echo "::error file=apps/relay/src/index.ts::Missing the fail-closed /v1 identity middleware."; exit 1; } test "$(grep -F 'PUBLIC_V1_PATHS' apps/relay/src/middleware.ts)" || {