diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0f3e1320..2ddf780b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,13 @@ on: branches: [main] pull_request: branches: [main] + # A merge queue runs each candidate on a gh-readonly-queue/* ref. Every + # verification job already falls through to the ci-scope outputs when the + # event is not `push`, so they scope themselves here exactly as they do on a + # pull request. The deploy jobs additionally require + # `github.ref == 'refs/heads/main'`, which a queue ref never matches, so a + # queued candidate can never promote to production. + merge_group: concurrency: group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }} @@ -64,6 +71,15 @@ jobs: exit 0 fi + if [ "${{ github.event_name }}" = "merge_group" ]; then + node scripts/ci-scope.mjs \ + --event pull_request \ + --base "${{ github.event.merge_group.base_sha }}" \ + --head "${{ github.event.merge_group.head_sha }}" \ + --output "$GITHUB_OUTPUT" + exit 0 + fi + base_sha="${{ github.event.pull_request.base.sha }}" head_sha="${{ github.event.pull_request.head.sha }}" @@ -676,7 +692,10 @@ jobs: - scripts-tests - growth-lifecycle - lifecycle - if: ${{ always() && github.event_name == 'pull_request' }} + # `CI — required` is the only required status context. A merge queue + # waits on it for each candidate, so it must report on merge_group too — + # otherwise every queued merge blocks forever on a check that never runs. + if: ${{ always() && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }} runs-on: ubuntu-latest steps: - name: Verify scoped CI jobs diff --git a/scripts/ci-workflow.spec.mjs b/scripts/ci-workflow.spec.mjs index d48818b70..d4794125e 100644 --- a/scripts/ci-workflow.spec.mjs +++ b/scripts/ci-workflow.spec.mjs @@ -77,6 +77,53 @@ function readNamedStep(job, name) { } describe('CI workflow', () => { + it('runs in a merge queue and reports the required context there', async () => { + const workflow = await readFile('.github/workflows/ci.yml', 'utf8'); + + assert.match( + workflow, + /^ merge_group:\s*$/m, + 'ci.yml must trigger on merge_group or a merge queue blocks forever' + ); + + const required = readJobBlock(workflow, 'required-pr-checks'); + assert.match( + required, + /github\.event_name == 'merge_group'/, + 'CI — required is the only required context; it must report inside the queue' + ); + }); + + it('never lets a merge-queue candidate promote to production', async () => { + const workflow = await readFile('.github/workflows/ci.yml', 'utf8'); + + // A queue candidate builds on refs/heads/gh-readonly-queue/*, so every job + // that touches production must be pinned to refs/heads/main. Without this + // the queue would deploy unmerged candidates. + for (const job of [ + 'deploy', + 'demo-deploy', + 'ag-ui-demo-deploy', + 'production-smoke', + ]) { + const block = readJobBlock(workflow, job); + assert.match( + block, + /github\.ref == 'refs\/heads\/main'/, + `${job} must be pinned to refs/heads/main so a queue ref cannot deploy` + ); + } + }); + + it('scopes a merge-queue candidate from the merge group range', async () => { + const workflow = await readFile('.github/workflows/ci.yml', 'utf8'); + const scope = readJobBlock(workflow, 'ci-scope'); + + assert.match(scope, /github\.event\.merge_group\.base_sha/); + assert.match(scope, /github\.event\.merge_group\.head_sha/); + }); + + async function readWorkflow() { return readFile('.github/workflows/ci.yml', 'utf8'); } @@ -908,7 +955,7 @@ describe('CI workflow', () => { assert.match(requiredPrChecksJob, /name:\s*CI — required/); assert.match( requiredPrChecksJob, - /if:\s*\$\{\{\s*always\(\)\s*&&\s*github\.event_name == 'pull_request'\s*\}\}/ + /if:\s*\$\{\{\s*always\(\)\s*&&\s*\(github\.event_name == 'pull_request'\s*\|\|\s*github\.event_name == 'merge_group'\)\s*\}\}/ ); assert.deepEqual(readJobNeeds(requiredPrChecksJob), expectedNeeds);