Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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 }}"

Expand Down Expand Up @@ -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
Expand Down
49 changes: 48 additions & 1 deletion scripts/ci-workflow.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down Expand Up @@ -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);
Expand Down
Loading