diff --git a/.github/workflows/codegen-models.yml b/.github/workflows/codegen-models.yml index 8568f7f10..d668f7bd5 100644 --- a/.github/workflows/codegen-models.yml +++ b/.github/workflows/codegen-models.yml @@ -6,12 +6,25 @@ on: - 'packages/utils/cli-registry.yaml' branches: [main] pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] paths: - 'packages/utils/cli-registry.yaml' +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: codegen-models-${{ github.event_name == 'push' && format('push-{0}', github.sha) || github.event_name != 'pull_request' && format('run-{0}', github.run_id) || ((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: codegen: - 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 permissions: contents: write diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 6adfe0825..17e8cf350 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -13,6 +13,11 @@ on: - 'package.json' - '.github/workflows/e2e-tests.yml' pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] branches: [main] paths: - 'packages/**' @@ -27,15 +32,19 @@ on: workflow_dispatch: concurrency: - group: ${{ 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: e2e-tests-${{ github.event_name == 'push' && format('push-{0}', github.sha) || github.event_name != 'pull_request' && format('run-{0}', github.run_id) || ((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' }} env: AGENT_RELAY_TELEMETRY_DISABLED: 1 jobs: e2e-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: E2E Integration Test runs-on: ${{ matrix.os }} strategy: diff --git a/.github/workflows/fleet-e2e.yml b/.github/workflows/fleet-e2e.yml index f2dfe93c5..af9d099b9 100644 --- a/.github/workflows/fleet-e2e.yml +++ b/.github/workflows/fleet-e2e.yml @@ -27,6 +27,11 @@ on: - 'vitest.e2e.config.ts' - '.github/workflows/fleet-e2e.yml' pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] branches: [main, 'feat/fleet-**'] paths: - 'packages/fleet/**' @@ -45,15 +50,19 @@ permissions: contents: read concurrency: - group: ${{ 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: fleet-e2e-${{ github.event_name == 'push' && format('push-{0}', github.sha) || github.event_name != 'pull_request' && format('run-{0}', github.run_id) || ((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' }} env: AGENT_RELAY_TELEMETRY_DISABLED: 1 jobs: fleet-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: Two-node fleet matrix runs-on: ubuntu-latest timeout-minutes: 20 diff --git a/.github/workflows/large-files.yml b/.github/workflows/large-files.yml index 306839141..de53f6430 100644 --- a/.github/workflows/large-files.yml +++ b/.github/workflows/large-files.yml @@ -1,8 +1,14 @@ name: Large File Check -on: [pull_request] +on: + pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] jobs: check: - 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: - uses: actions/checkout@v4 diff --git a/.github/workflows/merge-train-ready.yml b/.github/workflows/merge-train-ready.yml new file mode 100644 index 000000000..b37cb3889 --- /dev/null +++ b/.github/workflows/merge-train-ready.yml @@ -0,0 +1,66 @@ +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') + # Fork PRs run here, so never the self-hosted CI_LINUX_RUNNER pool. + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22.14.0 + cache: 'npm' + - run: npm ci + - name: Ensure rollup optional dependencies are installed + run: npm install --no-save rollup || true + - name: Merge-train workflow contract + run: node --test scripts/merge-train-ci-workflows.test.mjs + - name: Lint + run: npm run lint + - name: Check formatting + run: npm run format:check + - name: Typecheck + run: npm run typecheck diff --git a/.github/workflows/node-compat.yml b/.github/workflows/node-compat.yml index cf365c1f2..61071b5c4 100644 --- a/.github/workflows/node-compat.yml +++ b/.github/workflows/node-compat.yml @@ -4,6 +4,11 @@ on: push: branches: [main] pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] branches: [main] # Reusable workflows cannot elevate the caller's token permissions. @@ -13,18 +18,22 @@ permissions: pull-requests: read concurrency: - group: ${{ 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: node-compat-${{ github.event_name == 'push' && format('push-{0}', github.sha) || github.event_name != 'pull_request' && format('run-{0}', github.run_id) || ((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: changes: - 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') uses: ./.github/workflows/detect-changes.yml install-test: name: Install Test (Node ${{ matrix.node-version }}) needs: changes 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_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')) && (needs.changes.outputs.node_changed == 'true') runs-on: ubuntu-latest strategy: @@ -87,7 +96,7 @@ jobs: name: Fresh Install (Node ${{ matrix.node-version }}) needs: changes 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_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')) && (needs.changes.outputs.node_changed == 'true') runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/package-validation.yml b/.github/workflows/package-validation.yml index 48f241485..7627cee71 100644 --- a/.github/workflows/package-validation.yml +++ b/.github/workflows/package-validation.yml @@ -4,6 +4,11 @@ on: push: branches: [main] pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] branches: [main] # Reusable workflows cannot elevate the caller's token permissions. @@ -13,21 +18,25 @@ permissions: pull-requests: read concurrency: - group: ${{ 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: package-validation-${{ github.event_name == 'push' && format('push-{0}', github.sha) || github.event_name != 'pull_request' && format('run-{0}', github.run_id) || ((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' }} env: AGENT_RELAY_TELEMETRY_DISABLED: 1 jobs: changes: - 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') uses: ./.github/workflows/detect-changes.yml validate: name: Build & Validate needs: changes 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_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')) && (needs.changes.outputs.node_changed == 'true') runs-on: ubuntu-latest env: @@ -203,7 +212,7 @@ jobs: name: Publish Fresh Install Build needs: changes 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_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')) && (needs.changes.outputs.node_changed == 'true') runs-on: ubuntu-latest env: @@ -253,7 +262,7 @@ jobs: # provisioning an undeletable workspace; other same-repository PRs and # main pushes must reuse the CI workspace. 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_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')) && (needs.changes.outputs.node_changed == 'true' && github.actor != 'dependabot[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) runs-on: macos-latest env: diff --git a/.github/workflows/prettier-fmt-fix.yml b/.github/workflows/prettier-fmt-fix.yml index baa274e8e..f890286b2 100644 --- a/.github/workflows/prettier-fmt-fix.yml +++ b/.github/workflows/prettier-fmt-fix.yml @@ -18,6 +18,11 @@ on: - '**.yaml' - '**.yml' pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] paths: - '**.css' - '**.html' @@ -33,13 +38,21 @@ on: - '**.yaml' - '**.yml' +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: prettier-fmt-fix-${{ github.event_name == 'push' && format('push-{0}', github.sha) || github.event_name != 'pull_request' && format('run-{0}', github.run_id) || ((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: format: name: Auto-format Prettier files runs-on: ubuntu-latest # Only run on same-repo PRs where the bot can push back formatting commits. 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_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')) && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) permissions: contents: write diff --git a/.github/workflows/relay-evals.yml b/.github/workflows/relay-evals.yml index c646a2d13..54d0dabc1 100644 --- a/.github/workflows/relay-evals.yml +++ b/.github/workflows/relay-evals.yml @@ -2,6 +2,11 @@ name: Relay Evals on: pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] paths: - '.github/workflows/relay-evals.yml' - 'AGENTS.md' @@ -26,8 +31,12 @@ on: workflow_dispatch: concurrency: - group: relay-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: relay-evals-${{ github.event_name == 'push' && format('push-{0}', github.sha) || github.event_name != 'pull_request' && format('run-{0}', github.run_id) || ((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 @@ -38,7 +47,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: Offline evals runs-on: ubuntu-latest diff --git a/.github/workflows/relayflow-pr-proof-broker.yml b/.github/workflows/relayflow-pr-proof-broker.yml index 3b3000b6e..16464a090 100644 --- a/.github/workflows/relayflow-pr-proof-broker.yml +++ b/.github/workflows/relayflow-pr-proof-broker.yml @@ -6,10 +6,19 @@ on: schedule: - cron: '17 3 * * 1' pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] paths: - '.github/workflows/relayflow-pr-proof-broker.yml' pull_request_target: - types: [opened, synchronize, reopened, edited, ready_for_review] + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] paths: - 'crates/**' - 'Cargo.toml' @@ -20,7 +29,9 @@ permissions: contents: read concurrency: - group: relayflow-pr-proof-broker-${{ github.event_name }}-${{ github.event.pull_request.head.sha || github.sha }} + # A `labeled` event for any label other than `ci:run` runs nothing, so it gets + # its own throwaway group: it must never cancel the real run for the same head. + group: relayflow-pr-proof-broker-${{ github.event_name }}-${{ (github.event.action == 'labeled' && github.event.label.name != 'ci:run') && format('ignored-{0}', github.run_id) || (github.event.pull_request.head.sha || github.sha) }} cancel-in-progress: true env: @@ -31,7 +42,7 @@ jobs: build: name: ${{ github.event_name == 'pull_request' && 'Validate PR-head Linux broker' || 'Build exact Linux broker' }} 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_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')) && (github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest timeout-minutes: 30 diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index c2f97271f..3c5fddbce 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -2,6 +2,11 @@ name: CI on: pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] push: branches: [main] @@ -15,16 +20,24 @@ env: CARGO_TERM_COLOR: always AGENT_RELAY_TELEMETRY_DISABLED: 1 +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: rust-ci-${{ github.event_name == 'push' && format('push-{0}', github.sha) || github.event_name != 'pull_request' && format('run-{0}', github.run_id) || ((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: changes: - 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') uses: ./.github/workflows/detect-changes.yml rust-test: name: Rust Tests (${{ matrix.os }}) needs: changes 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_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')) && (needs.changes.outputs.rust_changed == 'true') runs-on: ${{ matrix.os == 'ubuntu-latest' && (vars.CI_LINUX_RUNNER || 'ubuntu-latest') || matrix.os }} strategy: @@ -46,7 +59,7 @@ jobs: name: Relay PTY Synchronization Tests (windows-latest) needs: changes 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_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')) && (needs.changes.outputs.rust_changed == 'true') runs-on: windows-latest timeout-minutes: 10 @@ -68,7 +81,7 @@ jobs: name: Cross-compile check (${{ matrix.target }}) needs: changes 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_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')) && (needs.changes.outputs.rust_changed == 'true') runs-on: ${{ matrix.os == 'ubuntu-latest' && (vars.CI_LINUX_RUNNER || 'ubuntu-latest') || matrix.os }} strategy: @@ -104,7 +117,7 @@ jobs: name: Clippy (${{ matrix.os }}) needs: changes 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_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')) && (needs.changes.outputs.rust_changed == 'true') runs-on: ${{ matrix.os == 'ubuntu-latest' && (vars.CI_LINUX_RUNNER || 'ubuntu-latest') || matrix.os }} strategy: @@ -125,7 +138,7 @@ jobs: name: Format needs: changes 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_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')) && (needs.changes.outputs.rust_changed == 'true') runs-on: ${{ vars.CI_LINUX_RUNNER || 'ubuntu-latest' }} steps: @@ -139,7 +152,7 @@ jobs: name: SDK TypeScript Check needs: changes 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_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')) && (needs.changes.outputs.node_changed == 'true') runs-on: ubuntu-latest steps: diff --git a/.github/workflows/rust-fmt-fix.yml b/.github/workflows/rust-fmt-fix.yml index 1df1a6ab7..49a5a7227 100644 --- a/.github/workflows/rust-fmt-fix.yml +++ b/.github/workflows/rust-fmt-fix.yml @@ -9,19 +9,32 @@ on: - 'crates/broker/Cargo.toml' - 'Cargo.lock' pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] paths: - '**.rs' - 'Cargo.toml' - 'crates/broker/Cargo.toml' - 'Cargo.lock' +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: rust-fmt-fix-${{ github.event_name == 'push' && format('push-{0}', github.sha) || github.event_name != 'pull_request' && format('run-{0}', github.run_id) || ((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: format: name: Auto-format Rust code runs-on: ubuntu-latest # Only run on same-repo PRs where the bot can push back formatting commits. 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_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')) && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) permissions: contents: write diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 903a6b907..13359b411 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -4,6 +4,11 @@ on: push: branches: [main] pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] branches: [main] schedule: # Run weekly on Sundays at midnight @@ -16,12 +21,16 @@ permissions: pull-requests: read concurrency: - group: ${{ 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: security-${{ github.event_name == 'push' && format('push-{0}', github.sha) || github.event_name != 'pull_request' && format('run-{0}', github.run_id) || ((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: changes: - 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') uses: ./.github/workflows/detect-changes.yml # NPM audit for known vulnerabilities @@ -29,7 +38,7 @@ jobs: name: NPM Audit needs: changes 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_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')) && (needs.changes.outputs.node_changed == 'true') runs-on: ubuntu-latest steps: @@ -60,7 +69,7 @@ jobs: name: Dependency Review runs-on: ubuntu-latest 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_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')) && (github.event_name == 'pull_request') # This job requires the dependency graph to be enabled in repo settings # Make it non-blocking until that's configured @@ -82,7 +91,7 @@ jobs: name: CodeQL Analysis needs: changes 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_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')) && (needs.changes.outputs.node_changed == 'true') runs-on: ubuntu-latest permissions: @@ -110,7 +119,7 @@ jobs: # Secret scanning (check for accidentally committed secrets) secrets-scan: - 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: Secret Scanning runs-on: ubuntu-latest # Make non-blocking - review findings manually @@ -143,7 +152,7 @@ jobs: name: License Compliance needs: changes 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_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')) && (needs.changes.outputs.node_changed == 'true') runs-on: ubuntu-latest steps: diff --git a/.github/workflows/stress-tests.yml b/.github/workflows/stress-tests.yml index ccaaa351d..e92d4e51b 100644 --- a/.github/workflows/stress-tests.yml +++ b/.github/workflows/stress-tests.yml @@ -10,6 +10,11 @@ on: - 'scripts/stress-test-*.mts' - '.github/workflows/stress-tests.yml' pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] branches: [main] paths: - 'packages/cli/src/**' @@ -24,12 +29,16 @@ on: - cron: '0 6 * * 1' # Every Monday at 6 AM UTC concurrency: - group: ${{ 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: stress-tests-${{ github.event_name == 'push' && format('push-{0}', github.sha) || github.event_name != 'pull_request' && format('run-{0}', github.run_id) || ((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: stress-test-orchestrator: - 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: Orchestrator Stress Tests runs-on: ${{ matrix.os }} strategy: @@ -79,7 +88,7 @@ jobs: fi stress-test-orchestrator-integration: - 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: Orchestrator Integration Stress Tests runs-on: ${{ matrix.os }} strategy: @@ -164,7 +173,7 @@ jobs: needs: [stress-test-orchestrator, stress-test-orchestrator-integration] runs-on: ubuntu-latest 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_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')) && (always()) steps: diff --git a/.github/workflows/targeted-feature-verification.yml b/.github/workflows/targeted-feature-verification.yml index 429a8d848..e0096ac12 100644 --- a/.github/workflows/targeted-feature-verification.yml +++ b/.github/workflows/targeted-feature-verification.yml @@ -6,7 +6,11 @@ name: Targeted feature verification # complete smoke profile; they never become a green skip. on: pull_request: - types: [opened, synchronize, reopened, ready_for_review] + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] paths-ignore: - '.agentworkforce/trajectories/**' - 'docs/**' @@ -30,12 +34,13 @@ permissions: contents: read concurrency: - group: targeted-feature-verification-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true + # Unrelated label events get a throwaway group so they never cancel a real run. + group: targeted-feature-verification-${{ github.event_name != 'pull_request' && format('dispatch-{0}', github.run_id) || ((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: select: - 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: Select Flows v2 shards runs-on: ubuntu-24.04 timeout-minutes: 15 @@ -94,7 +99,7 @@ jobs: retention-days: 14 targeted: - 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: Flows v2 shard ${{ matrix.shard }} needs: select runs-on: ubuntu-24.04 diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 43b8e7cb9..0108d262b 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -9,6 +9,11 @@ on: - 'scripts/verify-macos-binary.sh' - '.github/workflows/test-build.yml' pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] paths: - 'scripts/build-bun.sh' - 'scripts/sign-macos-binary.sh' @@ -19,9 +24,17 @@ on: env: AGENT_RELAY_TELEMETRY_DISABLED: 1 +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: test-build-${{ github.event_name == 'push' && format('push-{0}', github.sha) || github.event_name != 'pull_request' && format('run-{0}', github.run_id) || ((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: test-build-script: - 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: Test build-bun.sh runs-on: ${{ matrix.os == 'ubuntu-latest' && (vars.CI_LINUX_RUNNER || 'ubuntu-latest') || matrix.os }} strategy: @@ -115,7 +128,7 @@ jobs: done test-build-edge-cases: - 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: Test edge cases runs-on: ${{ vars.CI_LINUX_RUNNER || 'ubuntu-latest' }} @@ -164,7 +177,7 @@ jobs: needs: [test-build-script, test-build-edge-cases] runs-on: ubuntu-latest 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_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')) && (always()) steps: diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml index 47ab6edb4..ed160234c 100644 --- a/.github/workflows/test-install.yml +++ b/.github/workflows/test-install.yml @@ -9,6 +9,11 @@ on: - 'scripts/test-install-atomic.sh' - '.github/workflows/test-install.yml' pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] paths: - 'install.sh' - 'install.test.sh' @@ -24,9 +29,17 @@ on: env: AGENT_RELAY_TELEMETRY_DISABLED: 1 +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: test-install-${{ github.event_name == 'push' && format('push-{0}', github.sha) || github.event_name != 'pull_request' && format('run-{0}', github.run_id) || ((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: test-install: - 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: Test on ${{ matrix.os }} (${{ matrix.node }}) runs-on: ${{ matrix.os }} strategy: @@ -183,7 +196,7 @@ jobs: # Test fallback when gunzip is not available test-no-gunzip: - 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: Test without gunzip runs-on: ubuntu-latest @@ -228,7 +241,7 @@ jobs: ' test-install-alpine: - 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: Test on Alpine Linux runs-on: ubuntu-latest @@ -268,7 +281,7 @@ jobs: ' test-install-fedora: - 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: Test on Fedora runs-on: ubuntu-latest @@ -304,7 +317,7 @@ jobs: # Test script syntax and edge cases test-script-quality: - 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: Script Quality Checks runs-on: ubuntu-latest @@ -352,7 +365,7 @@ jobs: needs: [test-install, test-install-alpine, test-install-fedora, test-no-gunzip, test-script-quality] runs-on: ubuntu-latest 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_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')) && (always()) steps: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 27c1ecf2d..7a9ff50ca 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,11 @@ on: push: branches: [main] pull_request: + # Trunk -> main promotion PR only, and only on purpose: CI runs when the + # `ci:run` label is added (by the merge train, or an agent once the PR is + # ready). Opened, reopened and synchronize run nothing; any other label + # skips every job. + types: [labeled] branches: [main] # Reusable workflows cannot elevate the caller's token permissions. @@ -13,21 +18,25 @@ permissions: pull-requests: read concurrency: - group: ${{ 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: test-${{ github.event_name == 'push' && format('push-{0}', github.sha) || github.event_name != 'pull_request' && format('run-{0}', github.run_id) || ((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' }} env: AGENT_RELAY_TELEMETRY_DISABLED: 1 jobs: changes: - 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') uses: ./.github/workflows/detect-changes.yml test: needs: changes 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_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')) && (needs.changes.outputs.node_changed == 'true') runs-on: ${{ matrix.os == 'ubuntu-latest' && (vars.CI_LINUX_RUNNER || 'ubuntu-latest') || matrix.os }} strategy: @@ -77,7 +86,7 @@ jobs: name: PR-proof sandbox tests (Landlock, ubuntu-latest) needs: changes 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_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')) && (needs.changes.outputs.node_changed == 'true') runs-on: ubuntu-latest steps: @@ -114,7 +123,7 @@ jobs: name: Windows credential ACL validation needs: changes 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_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')) && (needs.changes.outputs.node_changed == 'true') runs-on: windows-latest steps: @@ -147,7 +156,7 @@ jobs: name: Coverage (upload) needs: changes 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_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')) && (needs.changes.outputs.node_changed == 'true') runs-on: ${{ vars.CI_LINUX_RUNNER || 'ubuntu-latest' }} steps: @@ -183,7 +192,7 @@ jobs: lint: needs: changes 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_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')) && (needs.changes.outputs.node_changed == 'true') runs-on: ${{ vars.CI_LINUX_RUNNER || 'ubuntu-latest' }} steps: @@ -212,7 +221,7 @@ jobs: name: Swift SDK Tests needs: changes 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_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')) && (needs.changes.outputs.sdk_swift_changed == 'true') runs-on: macos-latest defaults: diff --git a/scripts/merge-train-ci-workflows.test.mjs b/scripts/merge-train-ci-workflows.test.mjs new file mode 100644 index 000000000..ae1f6e40d --- /dev/null +++ b/scripts/merge-train-ci-workflows.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import { readdirSync, 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, `changes / Detect change scope`), 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 DIR = path.join(ROOT, '.github/workflows'); +const load = (file) => parse(readFileSync(path.join(DIR, file), 'utf8')); + +/** The complete trunk-PR + ci:run gate. Asserted exactly, never as a substring. */ +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)"; +const PROMOTION_TYPES = ['labeled']; + +/** True when `expr` has an `||` outside any parentheses. */ +function hasTopLevelOr(expr) { + let depth = 0; + for (let i = 0; i < expr.length; i += 1) { + if (expr[i] === '(') depth += 1; + else if (expr[i] === ')') depth -= 1; + else if (depth === 0 && expr.startsWith('||', i)) return true; + } + return false; +} + +/** + * A job gate is exactly the trunk gate, or `(gate) && ` where + * cannot reopen the gate with a top-level `||`. + */ +function assertGate(gate, where) { + const value = String(gate ?? '').trim(); + if (value === TRUNK_CI_GATE) return; + const prefix = `(${TRUNK_CI_GATE}) && `; + assert.ok(value.startsWith(prefix), `${where}: must be the exact trunk PR + ci:run gate, got ${value}`); + assert.ok( + !hasTopLevelOr(value.slice(prefix.length)), + `${where}: an extra condition must not add a top-level ||` + ); +} + +const workflows = readdirSync(DIR) + .filter((file) => /\.ya?ml$/.test(file)) + .map((file) => ({ file, workflow: load(file) })); +// Every workflow that runs CI on pull requests, except the guard and the +// closed-PR cleanup, is promotion CI. +const promotion = workflows.filter(({ file, workflow }) => { + const on = workflow.on ?? {}; + const prEvents = ['pull_request', 'pull_request_target'].filter((event) => event in on); + if (prEvents.length === 0 || file === 'trunk-guard.yml' || file === 'merge-train-ready.yml') return false; + const closedOnly = (event) => { + const types = on[event]?.types ?? []; + return types.length > 0 && types.every((type) => type === 'closed'); + }; + return !prEvents.every(closedOnly); +}); + +test('every promotion workflow runs the trunk PR only when the ci:run label is added, never on opened/reopened/synchronize', () => { + assert.ok(promotion.length >= 15, `expected the promotion workflows, found ${promotion.length}`); + for (const { file, workflow } of promotion) { + for (const event of ['pull_request', 'pull_request_target']) { + if (!(event in workflow.on)) continue; + assert.deepEqual(workflow.on[event]?.types, PROMOTION_TYPES, `${file} ${event}`); + } + for (const [id, job] of Object.entries(workflow.jobs)) { + // A job with `needs` and no `if` is skipped whenever its gated parent skips. + if (!job.if && job.needs) continue; + assertGate(job.if, `${file} ${id}`); + } + } +}); + +test('an unrelated label event can never cancel a real promotion CI run', () => { + for (const { file, workflow } of promotion) { + if (!workflow.concurrency) continue; + const { group } = workflow.concurrency; + assert.ok(String(group).includes(IGNORED_GROUP), `${file}: throwaway group for ignored labels`); + } +}); + +test("the sweeper's CI marker is a job every promotion CI run creates", () => { + const job = load('test.yml').jobs.changes; + assert.equal(job.uses, './.github/workflows/detect-changes.yml'); + assert.equal(job.needs, undefined); + assertGate(job.if, 'test.yml changes'); + assert.equal(load('detect-changes.yml').jobs.changes.name, 'Detect change scope'); +}); + +test('feature PRs into trunk get the ready check the sweeper requires', () => { + const ready = load('merge-train-ready.yml'); + assert.deepEqual(Object.keys(ready.on), ['pull_request']); + assert.deepEqual(ready.on.pull_request.branches, ['trunk']); + assert.deepEqual(ready.on.pull_request.types, ['labeled', 'synchronize', 'reopened']); + 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) }}" + ); + const jobs = Object.values(ready.jobs); + assert.equal(jobs.length, 1); + const [job] = jobs; + assert.equal(job.name, 'Merge-train ready check'); + assert.equal( + job.if, + "github.base_ref == 'trunk' && contains(github.event.pull_request.labels.*.name, 'mergeable')" + ); + // Fork code runs here: GitHub-hosted only, never the self-hosted pool. + assert.equal(job['runs-on'], 'ubuntu-latest'); + // 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.doesNotMatch(readFileSync(path.join(DIR, 'merge-train-ready.yml'), 'utf8'), /secrets\./); + assert.equal(job.steps[0].with?.['persist-credentials'], false); + // The sweeper kicks missing runs with `ready:check`: the job runs on ANY label event. + assert.doesNotMatch(job.if, /event\.label\.name/); +}); diff --git a/tests/fixtures/pr-proof-contract.test.ts b/tests/fixtures/pr-proof-contract.test.ts index 49061b63a..185d7d482 100644 --- a/tests/fixtures/pr-proof-contract.test.ts +++ b/tests/fixtures/pr-proof-contract.test.ts @@ -1984,17 +1984,26 @@ describe('trusted dispatcher source contract', () => { expect(triggerSection).toContain(' schedule:'); expect(triggerSection).toContain("cron: '17 3 * * 1'"); expect(triggerSection).toContain( - " pull_request:\n paths:\n - '.github/workflows/relayflow-pr-proof-broker.yml'" + " types: [labeled]\n paths:\n - '.github/workflows/relayflow-pr-proof-broker.yml'" ); expect(triggerSection).toContain(' pull_request_target:'); - expect(triggerSection).toContain('types: [opened, synchronize, reopened, edited, ready_for_review]'); + // Merge train: the trunk -> main PR runs only when `ci:run` is added. + expect(triggerSection).toContain(' pull_request_target:\n'); + expect(triggerSection.slice(triggerSection.indexOf(' pull_request_target:'))).toContain( + 'types: [labeled]' + ); + expect(triggerSection).not.toMatch(/types: \[[^\]]*synchronize/); + // The build job runs only for a `ci:run` label event on this repo's trunk -> main PR. + expect(source).toContain( + "(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')" + ); expect(triggerSection).not.toContain(' workflow_dispatch:'); expect(source).toContain('SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}'); expect(source).toContain('ref: ${{ env.SOURCE_SHA }}'); expect(source).toContain('persist-credentials: false'); expect(source).toContain('permissions:\n contents: read'); expect(source).toContain( - 'group: relayflow-pr-proof-broker-${{ github.event_name }}-${{ github.event.pull_request.head.sha || github.sha }}' + "group: relayflow-pr-proof-broker-${{ github.event_name }}-${{ (github.event.action == 'labeled' && github.event.label.name != 'ci:run') && format('ignored-{0}', github.run_id) || (github.event.pull_request.head.sha || github.sha) }}" ); expect(source).toContain('cancel-in-progress: true'); expect(source).toContain(