mirror of https://github.com/garrytan/gstack.git
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) <noreply@anthropic.com>
This commit is contained in:
parent
a861c00cfa
commit
3ab413d9dc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
const lines = content.split('\n');
|
||||
const events = new Set<string>();
|
||||
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/);
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue