ci: exempt only push from concurrency cancellation

The two self-hosted standby drills each run their complete unsharded
aggregate with one gate worker, which takes longer than the interval
between master merges, so unconditional cancel-in-progress supersedes a
drill before it reaches a verdict and the lane yields no readiness
evidence for the failover runbook to point a responder at.

Exempt push and nothing else. This has to be decided at workflow level:
cancellation applies to the whole superseded run, so a job-level
concurrency group cannot exempt its job. The negated form is
load-bearing — naming pull_request alone would also stop cancelling
workflow_dispatch, and each runner benchmark fans out to twelve larger
runners for up to fifteen minutes in this same group on master, so a
re-dispatch would queue ahead of a drill instead of replacing a stale
measurement. It does not promise every push run finishes: a newer
pending run still displaces an older one, only that the lanes
periodically reach a verdict.

A master push carries only wine-apt-cache and the two drills; every other
job is pull-request-gated, workflow_dispatch-gated, or if: false. The
spec pins that set and classifies by exact condition, since a negated
event test mentions the event it excludes.
This commit is contained in:
Chinesezjc
2026-08-12 17:55:56 +08:00
parent 15c84cdc4b
commit ba1b0e15fc
5 changed files with 88 additions and 3 deletions
+65
View File
@@ -84,6 +84,71 @@ describe('CI workflow', () => {
expect(aggregate.needs).not.toContain('serial-windows')
})
it('leaves push runs uncancelled, so the self-hosted standby drills reach a verdict', () => {
const workflow = loadWorkflow('.github/workflows/ci.yml')
if (!isRecord(workflow.jobs) || !isRecord(workflow.concurrency)) {
throw new TypeError('CI workflow must define jobs and a workflow-level concurrency block')
}
// Cancellation applies to the whole superseded RUN, so this has to be
// decided at workflow level and gated on the event: a job-level group
// cannot exempt its job from its run being cancelled. Only push is exempt —
// a drill takes longer than the interval between master merges. The negated
// form is load-bearing: `== 'pull_request'` would also stop cancelling
// workflow_dispatch, and a re-dispatched runner benchmark holds up to 12
// larger runners for 15 minutes in this same group on master.
expect(workflow.concurrency['cancel-in-progress']).toBe("${{ github.event_name != 'push' }}")
// Neither drill may re-introduce a job-level group: it would not help, and
// it would imply the run-scoped cancellation had been solved locally.
for (const name of ['serial-linux-selfhosted', 'serial-windows']) {
const job = workflow.jobs[name]
if (!isRecord(job)) throw new TypeError(`${name} must be defined`)
expect(job.concurrency).toBeUndefined()
// Both stay master-push-only; that is what makes the push carve-out safe.
expect(job.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'")
}
// What bounds the cost of never cancelling a push run: a master push may
// only carry the cache seeder and the two drills. Any job reachable on push
// would start accumulating uncancelled runs, so the set is pinned here.
//
// Classification is an exact allowlist of the conditions in use, not a
// substring match: `github.event_name != 'pull_request'` mentions
// `pull_request` yet IS push-reachable, so matching on the event name alone
// would silently misclassify it as gated.
const NOT_PUSH_REACHABLE = new Set([
"github.event_name == 'pull_request'",
"always() && github.event_name == 'pull_request'",
"github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark'",
"github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark'",
])
const pushReachable = Object.entries(workflow.jobs)
.filter(([, job]) => {
if (!isRecord(job)) return false
if (job.if === undefined) return true // unconditional: runs on every event
if (job.if === false) return false // `if: false` parses as a boolean
if (typeof job.if !== 'string') return true // unrecognized shape: surface it
return !NOT_PUSH_REACHABLE.has(job.if.trim())
})
.map(([name]) => name)
.sort()
expect(pushReachable).toEqual(['serial-linux-selfhosted', 'serial-windows', 'wine-apt-cache'])
// Why workflow_dispatch must keep cancelling: each benchmark fans out to a
// dozen larger runners at once, in this same group on master. If it stopped
// cancelling, a re-dispatch would queue ahead of a drill instead of
// replacing the stale measurement.
for (const name of ['larger-runner-benchmark', 'consolidated-runner-benchmark']) {
const job = workflow.jobs[name]
if (!isRecord(job) || !isRecord(job.strategy)) {
throw new TypeError(`${name} must define a matrix strategy`)
}
expect(job.strategy['max-parallel']).toBe(12)
expect(job['timeout-minutes']).toBe(15)
}
})
it('keeps supported LSP source under native Windows coverage', () => {
const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8')