|
|
@@ -84,6 +84,73 @@ describe('CI workflow', () => {
|
|
|
expect(aggregate.needs).not.toContain('serial-windows')
|
|
|
})
|
|
|
|
|
|
+ it('exempts push from cancellation, so one master merge does not cancel the running drill', () => {
|
|
|
+ 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. The
|
|
|
+ // expression is evaluated against the NEWLY TRIGGERED run, so a dispatch on
|
|
|
+ // master still cancels a mid-flight drill; the runbook records that bound.
|
|
|
+ expect(workflow.concurrency['cancel-in-progress']).toBe("${{ github.event_name != 'push' }}")
|
|
|
+
|
|
|
+ // Neither drill may carry a job-level group: it would not exempt the job
|
|
|
+ // from run-scoped cancellation.
|
|
|
+ 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 exempting push: 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')
|
|
|
|