From 3ab413d9dc1ea6798f7a0b5295a884321d35d58d Mon Sep 17 00:00:00 2001 From: Jayesh Betala Date: Fri, 19 Jun 2026 11:24:09 +0530 Subject: [PATCH] ci: cancel superseded actionlint and skill-docs runs actionlint.yml and skill-docs.yml trigger on both push and pull_request but declared no concurrency, so a same-repo PR branch update left stale duplicate Workflow Lint / Skill Docs Freshness runs queued for superseded commits. The heavier workflows (evals, windows-free-tests, make-pdf-gate, version-gate, pr-title-sync) already cancel superseded runs. Add a concurrency group with cancel-in-progress: true to both, keyed on github.head_ref || github.ref_name (head_ref for pull_request, ref_name as the push fallback). Add test/workflow-concurrency.test.ts, a static gate-tier tripwire that fails if any push+pull_request workflow ships without cancel-in-progress. Fixes #2046 Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/actionlint.yml | 8 +++ .github/workflows/skill-docs.yml | 8 +++ test/workflow-concurrency.test.ts | 99 +++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 test/workflow-concurrency.test.ts diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml index 1fb654aa8..6f0d3fe21 100644 --- a/.github/workflows/actionlint.yml +++ b/.github/workflows/actionlint.yml @@ -1,5 +1,13 @@ name: Workflow Lint on: [push, pull_request] + +# Cancel superseded runs for the same branch (matches evals.yml, +# windows-free-tests.yml, etc.). head_ref is set on pull_request; ref_name is +# the fallback for push so a rapid push series doesn't pile up stale lint runs. +concurrency: + group: actionlint-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + jobs: actionlint: runs-on: ubicloud-standard-8 diff --git a/.github/workflows/skill-docs.yml b/.github/workflows/skill-docs.yml index 700a8222a..0f38d5cc7 100644 --- a/.github/workflows/skill-docs.yml +++ b/.github/workflows/skill-docs.yml @@ -1,5 +1,13 @@ name: Skill Docs Freshness on: [push, pull_request] + +# Cancel superseded runs for the same branch (matches evals.yml, +# windows-free-tests.yml, etc.). head_ref is set on pull_request; ref_name is +# the fallback for push so a rapid push series doesn't pile up stale runs. +concurrency: + group: skill-docs-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + jobs: check-freshness: runs-on: ubicloud-standard-8 diff --git a/test/workflow-concurrency.test.ts b/test/workflow-concurrency.test.ts new file mode 100644 index 000000000..a19895187 --- /dev/null +++ b/test/workflow-concurrency.test.ts @@ -0,0 +1,99 @@ +/** + * Workflow concurrency tripwire (gate, free). + * + * The waste this kills: a workflow triggered on BOTH `push` and `pull_request` + * runs twice for the same commit on a same-repo PR branch — once for the push, + * once for the PR — and, without a `concurrency` group that cancels in progress, + * every new push to an active branch leaves the previous (now-obsolete) runs + * queued/running. The heavier workflows already cancel superseded runs + * (evals.yml, windows-free-tests.yml, make-pdf-gate.yml, version-gate.yml, + * pr-title-sync.yml); the two always-on lightweight ones (actionlint.yml, + * skill-docs.yml) historically did not, so a rapid push series piled up stale + * Workflow Lint / Skill Docs Freshness runs. + * + * This static check reads the workflow files directly and fails CI if a + * push+pull_request workflow ever ships again without `cancel-in-progress`. + * Mirrors the static-grep invariant tests in this dir + * (pr-title-sync-workflow-safety) and browse/test (terminal-agent-pid-identity). + */ +import { describe, test, expect } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const WORKFLOW_DIR = path.resolve(__dirname, '..', '.github', 'workflows'); + +/** + * Extract the top-level event names from a workflow's `on:` declaration. + * Handles the inline array form (`on: [push, pull_request]`) and the mapping + * form (`on:` followed by indented event keys). Returns exact event tokens, so + * `pull_request_target` is never conflated with `pull_request`. + */ +function parseTriggers(content: string): Set { + const lines = content.split('\n'); + const events = new Set(); + const onIdx = lines.findIndex((l) => /^on:/.test(l)); + if (onIdx === -1) return events; + + const onLine = lines[onIdx]; + // Inline array form: on: [push, pull_request] + const inline = onLine.match(/^on:\s*\[([^\]]*)\]/); + if (inline) { + for (const tok of inline[1].split(',')) { + const name = tok.trim(); + if (name) events.add(name); + } + return events; + } + + // Inline single form: on: push + const single = onLine.match(/^on:\s*([a-z_]+)\s*$/); + if (single) { + events.add(single[1]); + return events; + } + + // Mapping form: collect keys at the first sub-indent level under `on:`. + let keyIndent = -1; + for (let i = onIdx + 1; i < lines.length; i++) { + const line = lines[i]; + if (line.trim() === '' || /^\s*#/.test(line)) continue; // skip blanks/comments + const ind = line.match(/^( *)/)![1].length; + if (ind === 0) break; // back to a top-level key (jobs:, env:, ...) → on: block ended + if (keyIndent === -1) keyIndent = ind; + if (ind !== keyIndent) continue; // deeper config under an event (branches:, paths:, ...) + const key = line.match(/^\s*([a-z_]+):/); + if (key) events.add(key[1]); + } + return events; +} + +const workflowFiles = fs + .readdirSync(WORKFLOW_DIR) + .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')); + +describe('workflow concurrency', () => { + test('there are workflow files to check', () => { + expect(workflowFiles.length).toBeGreaterThan(0); + }); + + for (const file of workflowFiles) { + const content = fs.readFileSync(path.join(WORKFLOW_DIR, file), 'utf-8'); + const triggers = parseTriggers(content); + const isPushAndPr = triggers.has('push') && triggers.has('pull_request'); + if (!isPushAndPr) continue; + + test(`${file} (push + pull_request) cancels superseded runs`, () => { + expect(content).toMatch(/cancel-in-progress:\s*true/); + }); + } + + // Pin the two workflows the tripwire was written for, so a future trigger + // rename can't silently drop them out of the push+pull_request set above. + for (const file of ['actionlint.yml', 'skill-docs.yml']) { + test(`${file} declares a concurrency group with cancel-in-progress`, () => { + const content = fs.readFileSync(path.join(WORKFLOW_DIR, file), 'utf-8'); + expect(content).toMatch(/^concurrency:/m); + expect(content).toMatch(/cancel-in-progress:\s*true/); + }); + } +});