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..b34baa93 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: @@ -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)" || { 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"]); 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?