Skip to content

Commit 523fed7

Browse files
committed
fix(ci): give main pushes their own ImpactGate group and save PR baselines
1 parent 79e9324 commit 523fed7

3 files changed

Lines changed: 135 additions & 9 deletions

File tree

‎.github/workflows/impact.yml‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ on:
88
permissions:
99
contents: read
1010

11+
# A newer push to the same pull request supersedes the older run. Main pushes
12+
# are grouped per SHA instead: one shared main group cancelled a merge's run
13+
# before it saved that SHA's baselines, leaving PRs on it to rebuild.
1114
concurrency:
12-
group: impact-${{ github.ref }}
15+
group: impact-${{ github.event_name == 'push' && github.sha || github.ref }}
1316
cancel-in-progress: true
1417

1518
jobs:
@@ -33,7 +36,7 @@ jobs:
3336
python-version: '3.12'
3437

3538
- name: Install analysis tools
36-
run: python -m pip install -r .github/impact-gate/requirements.txt
39+
run: python -m pip install --require-hashes --no-deps -r .github/impact-gate/requirements.txt
3740

3841
- name: Check reporting behavior with the real CLI
3942
run: python -m unittest discover -s scripts/tests -p test_impact_gate.py -v
@@ -75,8 +78,11 @@ jobs:
7578
if [[ "$REFRESH" == 'true' ]]; then args+=(--refresh); fi
7679
python scripts/impact-gate.py prepare --base "$BASE_REF" --cache-dir "$BASELINE_DIR" "${args[@]}"
7780
78-
- name: Save main baselines
79-
if: github.event_name == 'push' && steps.prepare.outputs.rebuilt == 'true'
81+
# Any rebuilt pair is saved, including on pull requests: a PR's cache is
82+
# scoped to its own ref, so it cannot replace main's, and later pushes to
83+
# the PR reuse it instead of rebuilding both scopes again.
84+
- name: Save baselines
85+
if: steps.prepare.outputs.rebuilt == 'true'
8086
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
8187
with:
8288
path: ${{ env.BASELINE_DIR }}

‎docs/plans/2026-10-08-impact-gate-rollout.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Calibration date: 2026-10-08. This implements [Stack issue #1137](https://github
88
- Hyper reproduction source: `cd294f1c02e9718c7627a8d7fe7dd35ecd3ee407`, `.scratch/impact-gate/issues/05-reduce-the-lizard-typescript-span-defect.md`.
99
- Installed and exercised: `impact-gate==0.4.1`, `lizard==1.23.0`. Lizard 1.24.0 is excluded by upstream ImpactGate package metadata; it is not an upgrade candidate for this rollout.
1010
- Local measurement runtime: Python 3.13 on macOS arm64. Dependencies resolved during calibration: PyYAML 6.0.3, pygments 2.21.0, pathspec 1.1.1. Runtime timings are local evidence, not hosted-runner guarantees.
11+
- CI installs `.github/impact-gate/requirements.txt`, which pins those transitive dependencies at the same versions with hashes for every distribution, using `pip install --require-hashes --no-deps`. Its header records the regeneration command; Dependabot proposes pip updates for that directory.
1112
- Official release source: [CLI](https://github.com/officefloor/ImpactGate/blob/v0.4.1/impact_gate/cli.py), [baseline](https://github.com/officefloor/ImpactGate/blob/v0.4.1/impact_gate/baseline.py), [measurement scope](https://github.com/officefloor/ImpactGate/blob/v0.4.1/impact_gate/core/config.py). Flags and baseline serialization were also inspected in the installed wheel.
1213

1314
## Parser checks
@@ -35,7 +36,7 @@ The upstream baseline serializes `_meta` (`tool`, `n`, `head`, `base_ref`) plus
3536

3637
## Actions verification still required
3738

38-
Local calibration and tests cannot verify GitHub cache scoping, main-run cache saves, later PR restores, or the rendered job summary. After the workflow is available in Actions, record links to a cold run, a main run saving the pair, and a PR run restoring it. Verify both report sections, policy/version cache invalidation, malformed-pair rebuilding, and explicit seed-only disclosure. Do not mark these checks complete based on local cache simulation.
39+
Local calibration and tests cannot verify GitHub cache scoping, cache saves, later PR restores, or the rendered job summary. Any run that rebuilds the pair saves it, pull requests included; a PR's cache is scoped to its own ref, so it cannot replace main's, and it spares later pushes to that PR a full rebuild. Main pushes run in per-SHA concurrency groups so a quick second merge cannot cancel the first before it saves. After the workflow is available in Actions, record links to a cold run, a main run saving the pair, a PR run restoring it, and a PR run reusing its own saved pair. Verify both report sections, policy/version cache invalidation, malformed-pair rebuilding, and explicit seed-only disclosure. Do not mark these checks complete based on local cache simulation.
3940

4041
## Imported history and rename checks
4142

‎scripts/__tests__/impact-workflow.test.mjs‎

Lines changed: 123 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,45 @@
1+
import { readFileSync } from 'node:fs'
2+
import { join } from 'node:path'
13
import { describe, expect, it } from 'vitest'
4+
import { REPO_ROOT } from './lib/repo-root.mjs'
25
import { readWorkflow } from './lib/workflows.mjs'
36

47
const workflowPath = '.github/workflows/impact.yml'
58

9+
// Evaluates the small subset of the Actions expression language these guards
10+
// need (context paths, string literals, ==, !=, &&, ||, !), so a test asserts
11+
// what an expression DOES for an event rather than how it is spelled.
12+
function evaluate(expression, context) {
13+
const body = String(expression)
14+
.replace(/^\s*\$\{\{\s*/, '')
15+
.replace(/\s*\}\}\s*$/, '')
16+
const js = body
17+
.replace(/\b(github|steps)((?:\.[\w-]+)+)/g, (_, root, path) =>
18+
JSON.stringify(
19+
path
20+
.slice(1)
21+
.split('.')
22+
.reduce((value, key) => value?.[key], context[root]) ?? '',
23+
),
24+
)
25+
.replace(/==/g, '===')
26+
.replace(/!===/g, '!==')
27+
return new Function(`return (${js})`)()
28+
}
29+
30+
function interpolate(template, context) {
31+
return String(template).replace(/\$\{\{(.*?)\}\}/g, (_, expression) =>
32+
String(evaluate(expression, context)),
33+
)
34+
}
35+
36+
const pushEvent = (sha) => ({
37+
github: { event_name: 'push', ref: 'refs/heads/main', sha },
38+
})
39+
const pullRequestEvent = (sha) => ({
40+
github: { event_name: 'pull_request', ref: 'refs/pull/7/merge', sha },
41+
})
42+
643
describe('advisory change impact', () => {
744
it('applies every generated-output exclusion to both report scopes', () => {
845
const all = readWorkflow('.github/impact-gate/all.yml')
@@ -29,7 +66,7 @@ describe('advisory change impact', () => {
2966
})
3067
})
3168

32-
it('keeps baseline caches compatible and saves only main-push results', () => {
69+
it('keeps baseline caches compatible and saves every rebuilt pair', () => {
3370
const job = readWorkflow(workflowPath).jobs.impact
3471
const restore = job.steps.find((step) =>
3572
step.uses?.startsWith('actions/cache/restore@'),
@@ -53,12 +90,94 @@ describe('advisory change impact', () => {
5390
const save = job.steps.find((step) =>
5491
step.uses?.startsWith('actions/cache/save@'),
5592
)
56-
expect(save.if).toBe(
57-
"github.event_name == 'push' && steps.prepare.outputs.rebuilt == 'true'",
58-
)
93+
// F4: this used to pin `github.event_name == 'push' && …`, which is the
94+
// defect — a PR with no valid base cache rebuilt both scopes (~280 s) on
95+
// every synchronize. A PR cache is scoped to the PR ref and cannot clobber
96+
// main's, so a rebuilt pair is saved on both events, and never otherwise.
97+
for (const event of [pushEvent('a'), pullRequestEvent('b')]) {
98+
for (const rebuilt of ['true', 'false']) {
99+
const context = {
100+
...event,
101+
steps: { prepare: { outputs: { rebuilt } } },
102+
}
103+
expect(
104+
Boolean(evaluate(save.if, context)),
105+
`${event.github.event_name}, rebuilt=${rebuilt}`,
106+
).toBe(rebuilt === 'true')
107+
}
108+
}
59109
expect(save.with.path).toBe(restore.with.path)
60110
})
61111

112+
it('never lets one main push cancel another before its baselines are saved', () => {
113+
// F3: a shared `impact-refs/heads/main` group with cancel-in-progress
114+
// cancelled a merge's run before "Save main baselines", so that SHA never
115+
// got a cache and PRs on it fell back to an ancestor or a full rebuild.
116+
const { concurrency } = readWorkflow(workflowPath)
117+
const first = interpolate(concurrency.group, pushEvent('1111'))
118+
const second = interpolate(concurrency.group, pushEvent('2222'))
119+
expect(first).not.toBe(second)
120+
// A superseded pull request run is still cancelled.
121+
const prA = pullRequestEvent('3333')
122+
const prB = pullRequestEvent('4444')
123+
expect(interpolate(concurrency.group, prA)).toBe(
124+
interpolate(concurrency.group, prB),
125+
)
126+
expect(String(interpolate(concurrency['cancel-in-progress'], prA))).toBe(
127+
'true',
128+
)
129+
})
130+
131+
it('installs a fully pinned, hash-checked analysis toolchain', () => {
132+
// F5: two top-level pins left PyYAML, Pygments and pathspec floating, with
133+
// no hashes and no Dependabot coverage.
134+
const requirements = readFileSync(
135+
join(REPO_ROOT, '.github/impact-gate/requirements.txt'),
136+
'utf8',
137+
)
138+
const entries = requirements
139+
.replace(/\\\n/g, ' ')
140+
.split('\n')
141+
.map((line) => line.replace(/#.*/, '').trim())
142+
.filter(Boolean)
143+
const names = entries.map((entry) =>
144+
entry
145+
.split(/[=<>!~ ;[]/)[0]
146+
.toLowerCase()
147+
.replace(/_/g, '-'),
148+
)
149+
expect(names).toEqual(
150+
expect.arrayContaining([
151+
'impact-gate',
152+
'lizard',
153+
'pathspec',
154+
'pygments',
155+
'pyyaml',
156+
]),
157+
)
158+
for (const entry of entries) {
159+
expect(entry, entry).toMatch(/^[\w.-]+==[\w.]+(\s|;)/)
160+
expect(entry, entry).toMatch(/--hash=sha256:[0-9a-f]{64}/)
161+
}
162+
const install = readWorkflow(workflowPath).jobs.impact.steps.find((step) =>
163+
step.run?.includes('.github/impact-gate/requirements.txt'),
164+
)
165+
expect(install.run).toContain('--require-hashes')
166+
expect(install.run).toContain('--no-deps')
167+
168+
const pip = readWorkflow('.github/dependabot.yml').updates.find(
169+
(update) =>
170+
update['package-ecosystem'] === 'pip' &&
171+
update.directory === '/.github/impact-gate',
172+
)
173+
expect(pip).toBeDefined()
174+
expect(pip.cooldown).toEqual({ 'default-days': 7 })
175+
expect(pip.ignore).toContainEqual({
176+
'dependency-name': '*',
177+
'update-types': ['version-update:semver-major'],
178+
})
179+
})
180+
62181
it('runs the real-CLI checks and reports PRs without masking tool errors', () => {
63182
const job = readWorkflow(workflowPath).jobs.impact
64183
const checks = job.steps.find((step) =>

0 commit comments

Comments
 (0)