diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b37e2703..eeb0aa87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,13 +2,22 @@ name: CI on: pull_request: + # Trunk -> main promotion PR only, batched by the merge train (AgentWorkforce/cloud + # packages/web/lib/merge-train): opened, reopened, or the sweeper's `ci:run` + # label once per new trunk head. Never on synchronize; other label events + # skip every job. + types: [opened, reopened, labeled] push: branches: - main concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # PR runs collapse per pull request; push runs key off the commit sha so two + # merges landing together cannot cancel each other. A `labeled` event for any + # label other than `ci:run` runs nothing, so it gets its own throwaway group: + # it must never cancel the PR's in-progress run. + group: ci-${{ github.event_name != 'pull_request' && format('push-{0}', github.sha) || ((github.event.action == 'labeled' && github.event.label.name != 'ci:run') && format('ignored-{0}', github.run_id) || format('pr-{0}', github.event.pull_request.number)) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -19,7 +28,7 @@ env: jobs: go-test: - if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main') + if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main' && (github.event.action != 'labeled' || github.event.label.name == 'ci:run')) name: Go Test runs-on: ubuntu-latest timeout-minutes: 15 @@ -37,7 +46,7 @@ jobs: run: go test ./... go-build: - if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main') + if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main' && (github.event.action != 'labeled' || github.event.label.name == 'ci:run')) name: Go Build runs-on: ubuntu-latest needs: go-test @@ -68,7 +77,7 @@ jobs: if-no-files-found: error sdk-typecheck: - if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main') + if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main' && (github.event.action != 'labeled' || github.event.label.name == 'ci:run')) name: SDK Typecheck runs-on: ubuntu-latest timeout-minutes: 15 @@ -111,7 +120,7 @@ jobs: run: npm run test:bundle:bun --workspace=packages/sdk/typescript client-typecheck: - if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main') + if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main' && (github.event.action != 'labeled' || github.event.label.name == 'ci:run')) name: Client Typecheck runs-on: ubuntu-latest timeout-minutes: 15 @@ -144,7 +153,7 @@ jobs: run: npm run test --workspace=@relayfile/client e2e: - if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main') + if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main' && (github.event.action != 'labeled' || github.event.label.name == 'ci:run')) name: E2E runs-on: ubuntu-latest needs: go-build @@ -184,7 +193,7 @@ jobs: npx tsx scripts/e2e.ts --ci backend-conformance: - if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main') + if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main' && (github.event.action != 'labeled' || github.event.label.name == 'ci:run')) name: Backend Conformance (Go oracle) runs-on: ubuntu-latest timeout-minutes: 25 @@ -225,7 +234,7 @@ jobs: retention-days: 14 release-tooling: - if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main') + if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main' && (github.event.action != 'labeled' || github.event.label.name == 'ci:run')) name: Release Tooling runs-on: ubuntu-latest timeout-minutes: 10 @@ -249,7 +258,7 @@ jobs: run: npm run test:release workers-typecheck: - if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main') + if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main' && (github.event.action != 'labeled' || github.event.label.name == 'ci:run')) name: Workers Typecheck runs-on: ubuntu-latest timeout-minutes: 15 diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index d1438ea0..63944f4e 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -2,13 +2,26 @@ name: Contract on: pull_request: + # Trunk -> main promotion PR only, batched by the merge train (AgentWorkforce/cloud + # packages/web/lib/merge-train): opened, reopened, or the sweeper's `ci:run` + # label once per new trunk head. Never on synchronize; other label events + # skip every job. + types: [opened, reopened, labeled] push: branches: - main +concurrency: + # PR runs collapse per pull request; push runs key off the commit sha so two + # merges landing together cannot cancel each other. A `labeled` event for any + # label other than `ci:run` runs nothing, so it gets its own throwaway group: + # it must never cancel the PR's in-progress run. + group: contract-${{ github.event_name != 'pull_request' && format('push-{0}', github.sha) || ((github.event.action == 'labeled' && github.event.label.name != 'ci:run') && format('ignored-{0}', github.run_id) || format('pr-{0}', github.event.pull_request.number)) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: contract: - if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main') + if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main' && (github.event.action != 'labeled' || github.event.label.name == 'ci:run')) runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/merge-train-ready.yml b/.github/workflows/merge-train-ready.yml new file mode 100644 index 00000000..c8a9446a --- /dev/null +++ b/.github/workflows/merge-train-ready.yml @@ -0,0 +1,67 @@ +name: Merge-train ready check + +# Lightweight gate for feature PRs into `trunk` (full CI runs only on the +# trunk -> main promotion PR). It runs once the author adds the `mergeable` +# label, and again on every push while the label is on. The merge-train sweeper +# (AgentWorkforce/cloud packages/web/lib/merge-train) merges a feature PR into +# trunk only when this check is green on the PR's CURRENT head. +# +# Fork PRs run it too. `pull_request` gives a fork no secrets and a read-only +# token, and nothing here uses a secret or persists credentials. Never switch +# this to `pull_request_target`. Trust is the sweeper's job: it merges an +# external PR only after a maintainer's APPROVED review on its exact head. +# +# A labelled PR with no run for its head (labelled before this workflow +# existed, or retargeted onto trunk, which emits `edited`) is kicked by the +# sweeper re-adding the `ready:check` label: any label event runs this job while +# `mergeable` is on. `edited` is deliberately not a trigger, since body edits +# would re-run it constantly. +on: + pull_request: + branches: [trunk] + types: [labeled, synchronize, reopened] + +concurrency: + # One run per PR: a newer push or label event supersedes the in-flight run. + # An event the job will skip (`mergeable` absent) gets a throwaway group, so + # it can never cancel a real run and leave `cancelled` as the head's verdict. + group: merge-train-ready-${{ contains(github.event.pull_request.labels.*.name, 'mergeable') && format('pr-{0}', github.event.pull_request.number) || format('ignored-{0}', github.run_id) }} + cancel-in-progress: true + +permissions: {} + +jobs: + ready: + # The sweeper reads this exact check name (cloud lib/merge-train/config.ts). + name: Merge-train ready check + # Any label event re-runs it while `mergeable` is present (not just the + # `mergeable` event): a skipped run would otherwise become the latest check + # for the head and mask the real verdict. + if: >- + github.base_ref == 'trunk' && + contains(github.event.pull_request.labels.*.name, 'mergeable') + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Go build + run: go build ./... + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + - run: npm ci + - name: Build + run: npm run build + - name: Typecheck (TS workspaces + go vet) + run: npm run typecheck + - name: Merge-train workflow contract + run: node --test scripts/merge-train-ci-workflows.test.mjs diff --git a/.github/workflows/relayfile-evals.yml b/.github/workflows/relayfile-evals.yml index 72081f42..ac9e6a72 100644 --- a/.github/workflows/relayfile-evals.yml +++ b/.github/workflows/relayfile-evals.yml @@ -2,6 +2,11 @@ name: Relayfile Evals on: pull_request: + # Trunk -> main promotion PR only, batched by the merge train (AgentWorkforce/cloud + # packages/web/lib/merge-train): opened, reopened, or the sweeper's `ci:run` + # label once per new trunk head. Never on synchronize; other label events + # skip every job. + types: [opened, reopened, labeled] paths: - ".github/workflows/relayfile-evals.yml" - "AGENTS.md" @@ -38,8 +43,12 @@ on: workflow_dispatch: concurrency: - group: relayfile-evals-${{ github.ref }} - cancel-in-progress: true + # PR runs collapse per pull request; push runs key off the commit sha so two + # merges landing together cannot cancel each other. A `labeled` event for any + # label other than `ci:run` runs nothing, so it gets its own throwaway group: + # it must never cancel the PR's in-progress run. + group: relayfile-evals-${{ github.event_name != 'pull_request' && format('push-{0}', github.sha) || ((github.event.action == 'labeled' && github.event.label.name != 'ci:run') && format('ignored-{0}', github.run_id) || format('pr-{0}', github.event.pull_request.number)) }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read @@ -52,7 +61,7 @@ env: jobs: evals: - if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main') + if: (github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main' && (github.event.action != 'labeled' || github.event.label.name == 'ci:run')) name: Provider-backed evals runs-on: ubuntu-latest diff --git a/scripts/merge-train-ci-workflows.test.mjs b/scripts/merge-train-ci-workflows.test.mjs new file mode 100644 index 00000000..c06c5a57 --- /dev/null +++ b/scripts/merge-train-ci-workflows.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { parse } from "yaml"; + +/** + * The merge-train sweeper (AgentWorkforce/cloud packages/web/lib/merge-train) + * and these workflows share three names: the label that runs promotion CI + * (`ci:run`), the check every promotion CI run creates (the sweeper's "CI ran + * on this head" marker), and the feature ready check (`Merge-train ready + * check`, kicked by `ready:check`). A rename on either side silently breaks + * the gate, so they are pinned here. + */ +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const load = (file) => parse(readFileSync(path.join(ROOT, ".github/workflows", file), "utf8")); +/** The complete trunk-PR + ci:run gate. Asserted exactly: a substring check would + * still pass if a job appended an `|| ...` bypass. */ +const TRUNK_CI_GATE = + "(github.event_name != 'pull_request' && github.event_name != 'pull_request_target') || (github.head_ref == 'trunk' && github.event.pull_request.head.repo.full_name == github.repository && github.base_ref == 'main' && (github.event.action != 'labeled' || github.event.label.name == 'ci:run'))"; +const IGNORED_GROUP = + "(github.event.action == 'labeled' && github.event.label.name != 'ci:run') && format('ignored-{0}', github.run_id)"; + +/** Workflows that run on the trunk -> main promotion PR. */ +const PROMOTION_WORKFLOWS = ["ci.yml", "contract.yml", "relayfile-evals.yml"]; +/** Workflows with a concurrency group a stray label event could otherwise cancel. */ +const CONCURRENCY_WORKFLOWS = ["ci.yml", "contract.yml", "relayfile-evals.yml"]; +const MARKER = { file: "ci.yml", job: "go-test", name: "Go Test" }; + +for (const file of PROMOTION_WORKFLOWS) { + test(`${file} runs the trunk PR only on opened/reopened/ci:run, never on synchronize`, () => { + const workflow = load(file); + assert.deepEqual(workflow.on.pull_request?.types, ["opened", "reopened", "labeled"]); + // Trunk -> main PRs only (where a branches filter exists, it must name the default branch). + if (workflow.on.pull_request?.branches) assert.deepEqual(workflow.on.pull_request.branches, ["main"]); + const jobs = Object.entries(workflow.jobs); + assert.ok(jobs.length > 0); + for (const [id, job] of jobs) { + // A job with `needs` and no `if` is skipped whenever its gated parent skips. + if (!job.if && job.needs) continue; + assert.equal(job.if, TRUNK_CI_GATE, `${file} ${id}: exact trunk PR + ci:run gate`); + } + }); +} + +for (const file of CONCURRENCY_WORKFLOWS) { + test(`${file}: an unrelated label event can never cancel a real promotion CI run`, () => { + const { concurrency } = load(file); + assert.ok(String(concurrency.group).includes(IGNORED_GROUP), `${file}: throwaway group for ignored labels`); + assert.equal(concurrency["cancel-in-progress"], "${{ github.event_name == 'pull_request' }}"); + }); +} + +test("the sweeper's CI marker job has no needs, so every real promotion run creates it", () => { + const job = load(MARKER.file).jobs[MARKER.job]; + assert.ok(job, "marker job present"); + assert.equal(job.name ?? MARKER.job, MARKER.name); + assert.equal(job.needs, undefined); +}); + +test("feature PRs into trunk get the ready check the sweeper requires", () => { + const ready = load("merge-train-ready.yml"); + assert.deepEqual(ready.on.pull_request?.branches, ["trunk"]); + assert.deepEqual(ready.on.pull_request?.types, ["labeled", "synchronize", "reopened"]); + const jobs = Object.values(ready.jobs); + assert.equal(jobs.length, 1); + const [job] = jobs; + assert.equal(job.name, "Merge-train ready check"); + // A skip-bound event (no `mergeable`) must not cancel a real ready-check run. + assert.equal( + ready.concurrency.group, + "merge-train-ready-${{ contains(github.event.pull_request.labels.*.name, 'mergeable') && format('pr-{0}', github.event.pull_request.number) || format('ignored-{0}', github.run_id) }}", + ); + // Fork PRs get the check too (no secrets under `pull_request`); trust is the sweeper's gate. + assert.doesNotMatch(job.if, /head\.repo\.full_name/); + assert.deepEqual(Object.keys(ready.on), ["pull_request"]); + const raw = readFileSync(path.join(ROOT, ".github/workflows/merge-train-ready.yml"), "utf8"); + assert.doesNotMatch(raw, /secrets\./); + assert.equal(job.steps[0].with?.["persist-credentials"], false); + assert.match(job.if, /contains\(github\.event\.pull_request\.labels\.\*\.name, 'mergeable'\)/); + // The sweeper kicks missing runs with `ready:check`: the job runs on ANY label event. + assert.doesNotMatch(job.if, /event\.label\.name/); +});