From 2b3a7981cd63e2292522b2fbd7600684c5329a45 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 02:56:52 +0000 Subject: [PATCH] test(pregate): the real workflows owe windows-smoke.yml/windows-smoke and only its run clears it; the ready conflict hint names each lane's own workflow (GSTA-203 PR 6) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../gsta203-pr6-clear-workflow-check.md | 2 +- docs/pregate.md | 9 +++- lib/ship-queue.ts | 2 +- test/gstack-ship-queue.test.ts | 2 +- test/pregate-clear-runs.test.ts | 48 ++++++++++++++----- 5 files changed, 45 insertions(+), 18 deletions(-) diff --git a/changelog.d/gsta203-pr6-clear-workflow-check.md b/changelog.d/gsta203-pr6-clear-workflow-check.md index 66af44cac..8465779ba 100644 --- a/changelog.d/gsta203-pr6-clear-workflow-check.md +++ b/changelog.d/gsta203-pr6-clear-workflow-check.md @@ -1 +1 @@ -- **`gstack-pregate clear --run-url` checks the run's workflow.** On a lane with no run profile, `clear` used to record any URL as the receipt, so a `windows-smoke.yml` run could clear `windows-free-tests.yml/windows-free-shard` on a branch cut before #3806. When the base config and the GitHub origin can be read, `clear` now reads the run and refuses `PREGATE_RECEIPT_INVALID` (`workflow_mismatch`) unless its workflow file is the lane's own; a run gh cannot read (`api_error`) or a URL that is not an Actions run on this repository (`run_ref_invalid`) refuses too. A run of the lane's own workflow clears as before, and `test_backend/*` lanes, `--bundle-lane` and non-GitHub origins are unchanged. `test/pregate-clear-runs.test.ts` covers the mismatch, the matching control, the unreadable run and the non-run URL; it failed before the fix. +- **`gstack-pregate clear --run-url` checks the run's workflow.** On a lane with no run profile, `clear` used to record any URL as the receipt, so a `windows-smoke.yml` run could clear `windows-free-tests.yml/windows-free-shard` on a branch cut before #3806. When the base config and the GitHub origin can be read, `clear` now reads the run and refuses `PREGATE_RECEIPT_INVALID` (`workflow_mismatch`) unless its workflow file is the lane's own; a run gh cannot read (`api_error`) or a URL that is not an Actions run on this repository (`run_ref_invalid`) refuses too, and each refusal names its own fix. A run of the lane's own workflow clears as before, including a job link copied with `?pr=` or `#step`. `test_backend/*` lanes, `--bundle-lane` and non-GitHub origins are unchanged. On main the Windows obligation is `windows-smoke.yml/windows-smoke`, and a test now checks that against the repository's own workflows. The `ready` conflict hint now says to clear each lane with a run of that lane's own workflow. `test/pregate-clear-runs.test.ts` covers the mismatch, the matching control, the unreadable run, the non-run URL and the copied job links; each failed before its fix. diff --git a/docs/pregate.md b/docs/pregate.md index eb0e1af10..e3b9393b6 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -778,8 +778,13 @@ aggregate, `os` Windows). Each accepts `pull_request` and `workflow_dispatch` runs and reads the `tested-tree` artifact, which both aggregates upload only after their strict verification passes. The main-push reuse job and `--from-run` read them. No lane has a profile, so every macOS and Windows lane -still clears by `--run-url`, checked only for its workflow file (a -`windows-smoke.yml` run cannot clear `windows-free-tests.yml/windows-free-shard`). A pull request cut before these +still clears by `--run-url`, checked only for its workflow file. On main the +Windows obligation is `windows-smoke.yml/windows-smoke` (the six-shard +`windows-free-tests.yml/windows-free-shard` job is `manual`, so it is never an +obligation), and a `windows-smoke.yml` run clears it. A branch cut before +`windows-smoke.yml` landed still owes `windows-free-tests.yml/windows-free-shard` +from its own workflows, and only a `windows-free-tests.yml` run clears that. A +pull request cut before these workflows uploaded `tested-tree` has no record a lane profile could verify, so lane profiles wait until open pull requests carry the producer (GST-555). diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index 09471df27..dea5df303 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -175,7 +175,7 @@ function conflictHint(mergeable: unknown, ci: { green: boolean; none: boolean }, if (mergeable !== 'CONFLICTING') return null; const ref = branch && /^[\w.][\w./-]*$/.test(branch) ? `--ref ${branch}` : '--ref '; if (ci.none && fork) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}) and its head is on a fork, so the free workflows cannot be dispatched here; the coordinator decides (advisory)`; - if (ci.none) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}); dispatch the free workflows on the branch (gh workflow run free-tests.yml ${ref} && gh workflow run windows-smoke.yml ${ref}), clear the pregate lanes with their run URLs (gstack-pregate clear --remote --run-url ), then rerun ready (advisory)`; + if (ci.none) return `ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${head.slice(0, 12)}); dispatch the free workflows on the branch (gh workflow run free-tests.yml ${ref} && gh workflow run windows-smoke.yml ${ref}), clear each lane pregate names with a run of that lane's own workflow (gstack-pregate clear --remote --run-url ; a run of another workflow is refused), then rerun ready (advisory)`; if (ci.green) return `ci-hint: PR conflicts with the base (no merge ref); the runs at ${head.slice(0, 12)} were not pull_request runs (dispatched on the branch, or push-triggered), and they count (advisory)`; return null; } diff --git a/test/gstack-ship-queue.test.ts b/test/gstack-ship-queue.test.ts index fab39a52a..7de60fa39 100644 --- a/test/gstack-ship-queue.test.ts +++ b/test/gstack-ship-queue.test.ts @@ -562,7 +562,7 @@ describe('ready on a PR that conflicts with the base (CHANGELOG-only conflicts, const { r, at } = readyWith('CONFLICTING', { check_runs: [] }); expect(r.status).toBe(EXIT.refused); expect(line(r.out, 'ci: ')).toBe(`ci: none at ${at} (passed=0 failed=0 pending=0 skipped=0)`); - expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${at}); dispatch the free workflows on the branch (gh workflow run free-tests.yml --ref pr-a && gh workflow run windows-smoke.yml --ref pr-a), clear the pregate lanes with their run URLs (gstack-pregate clear --remote --run-url ), then rerun ready (advisory)`); + expect(line(r.out, 'ci-hint: ')).toBe(`ci-hint: PR conflicts with the base (no merge ref, so pull_request CI did not run on ${at}); dispatch the free workflows on the branch (gh workflow run free-tests.yml --ref pr-a && gh workflow run windows-smoke.yml --ref pr-a), clear each lane pregate names with a run of that lane's own workflow (gstack-pregate clear --remote --run-url ; a run of another workflow is refused), then rerun ready (advisory)`); for (const name of ['x;$(curl evil)', '--field']) { const odd = readyWith('CONFLICTING', { check_runs: [] }, { headRefName: name }); expect(line(odd.r.out, 'ci-hint: ')).toContain('gh workflow run free-tests.yml --ref && gh workflow run windows-smoke.yml --ref '); diff --git a/test/pregate-clear-runs.test.ts b/test/pregate-clear-runs.test.ts index 4c2f2c2cb..3107a13b4 100644 --- a/test/pregate-clear-runs.test.ts +++ b/test/pregate-clear-runs.test.ts @@ -8,6 +8,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT, type PregateFile } from '../lib/headless-artifacts'; import { buildContext } from '../lib/pregate/context'; +import { discoverLanes } from '../lib/pregate/workflows'; import { clearDecision, clearObligation, runPregate, verifyPregate, type RunCheck } from '../lib/pregate/index'; import type { ApiRun, RunDeps, RunProfile } from '../lib/run-evidence'; import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, readFile, runBin, type FixtureRepo } from './helpers/pregate-fixture'; @@ -72,6 +73,16 @@ function check(repo: FixtureRepo, runs: FakeRun[], over: Partial = {}) return { repo: 'fx/fx', deps, profiles: PROFILES, gateRuns: GATE_RUNS, now: NOW, inventory: () => ['test/a.test.ts', 'test/b.test.ts'], ...over }; } +/** A check whose only readable run is 300, a successful run of `workflow` on `head`. */ +function runOfWorkflow(repo: FixtureRepo, head: string, workflow: string): RunCheck { + const c = check(repo, []); + const run = (id: number): ApiRun => { + if (id !== 300) throw new Error('HTTP 404'); + return { id, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'pull_request', head_sha: head, path: `.github/workflows/${workflow}`, repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } }; + }; + return { ...c, deps: { ...c.deps, run } }; +} + describe.skipIf(process.platform === 'win32')('clear --run: Actions runs as remote gate evidence', () => { test('one verified run per profile on the identical tree clears the gate, and verify accepts the receipt offline', async () => { const repo = fixture(); @@ -185,24 +196,35 @@ describe.skipIf(process.platform === 'win32')('clear --run: Actions runs as remo const base = await pregateAt(repo); const PLATFORM = 'a.yml/job'; const file: PregateFile = { ...base, requires_remote: [...base.requires_remote!, { lane: PLATFORM, platform: 'windows', workflow: 'a.yml', job: 'job', tests: ['test/a.test.ts'], receipt: null }] }; - const ranWorkflow = (workflow: string): RunCheck => { - const c = check(repo, []); - const run = (id: number): ApiRun => { - if (id !== 300) throw new Error('HTTP 404'); - return { id, run_attempt: 1, status: 'completed', conclusion: 'success', event: 'pull_request', head_sha: head, path: `.github/workflows/${workflow}`, repository: { full_name: 'fx/fx' }, head_repository: { full_name: 'fx/fx' } }; - }; - return { ...c, deps: { ...c.deps, run } }; - }; const url = 'https://github.com/fx/fx/actions/runs/300'; const msg = (d: ReturnType) => ('refused' in d ? `${d.refused}: ${d.message}; fix: ${d.fix}` : `cleared ${d.receipt}`); - expect(msg(clearDecision(file, PLATFORM, { runUrl: url, evidence: ranWorkflow('b.yml') }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=workflow_mismatch — run 300 attempt 1 ran .github/workflows/b.yml, not .github/workflows/a.yml; ${PLATFORM} stays uncleared; fix: clear ${PLATFORM} with a run of its own workflow: gh workflow run a.yml --ref , then gstack-pregate clear --remote ${PLATFORM} --run-url `); - expect(clearDecision(file, PLATFORM, { runUrl: url, evidence: ranWorkflow('a.yml') }, repo.work)).toEqual({ receipt: url, attested: false }); - for (const copied of [`${url}/job/9?pr=3806`, `${url}/job/9#step:3:1`]) expect(clearDecision(file, PLATFORM, { runUrl: copied, evidence: ranWorkflow('a.yml') }, repo.work)).toEqual({ receipt: copied, attested: false }); - expect(msg(clearDecision(file, PLATFORM, { runUrl: 'https://github.com/fx/fx/actions/runs/301', evidence: ranWorkflow('a.yml') }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=api_error — reading run 301 failed: HTTP 404; ${PLATFORM} stays uncleared; fix: check gh auth status and that gh can read fx/fx's Actions runs, then rerun the same clear`); - expect(msg(clearDecision(file, PLATFORM, { runUrl: 'https://ci.example.com/runs/300', evidence: ranWorkflow('a.yml') }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=run_ref_invalid — "https://ci.example.com/runs/300" is not a run id or a run URL on fx/fx; ${PLATFORM} stays uncleared; fix: pass the run's numeric id or https://github.com/fx/fx/actions/runs/`); + expect(msg(clearDecision(file, PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'b.yml') }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=workflow_mismatch — run 300 attempt 1 ran .github/workflows/b.yml, not .github/workflows/a.yml; ${PLATFORM} stays uncleared; fix: clear ${PLATFORM} with a run of its own workflow: gh workflow run a.yml --ref , then gstack-pregate clear --remote ${PLATFORM} --run-url `); + expect(clearDecision(file, PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'a.yml') }, repo.work)).toEqual({ receipt: url, attested: false }); + for (const copied of [`${url}/job/9?pr=3806`, `${url}/job/9#step:3:1`]) expect(clearDecision(file, PLATFORM, { runUrl: copied, evidence: runOfWorkflow(repo, head, 'a.yml') }, repo.work)).toEqual({ receipt: copied, attested: false }); + expect(msg(clearDecision(file, PLATFORM, { runUrl: 'https://github.com/fx/fx/actions/runs/301', evidence: runOfWorkflow(repo, head, 'a.yml') }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=api_error — reading run 301 failed: HTTP 404; ${PLATFORM} stays uncleared; fix: check gh auth status and that gh can read fx/fx's Actions runs, then rerun the same clear`); + expect(msg(clearDecision(file, PLATFORM, { runUrl: 'https://ci.example.com/runs/300', evidence: runOfWorkflow(repo, head, 'a.yml') }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=run_ref_invalid — "https://ci.example.com/runs/300" is not a run id or a run URL on fx/fx; ${PLATFORM} stays uncleared; fix: pass the run's numeric id or https://github.com/fx/fx/actions/runs/`); expect(clearDecision(file, PLATFORM, { runUrl: url }, repo.work)).toEqual({ receipt: url, attested: false }); }, 120_000); + test('on this repository\'s own workflows the Windows obligation is windows-smoke.yml/windows-smoke: a windows-smoke.yml run clears it, a run of any other workflow is refused (GSTA-203)', async () => { + const lanes = discoverLanes(path.resolve(import.meta.dir, '..'), 'linux'); + const owed = lanes.filter(l => l.tier === 'platform' && l.platform === 'windows' && l.runsTests && !l.manual); + const smoke = owed.find(l => l.workflow === 'windows-smoke.yml'); + expect(smoke?.id).toBe('windows-smoke.yml/windows-smoke'); + expect(owed.filter(l => l.workflow === 'windows-free-tests.yml').map(l => l.id)).toEqual([]); + expect(lanes.find(l => l.id === 'windows-free-tests.yml/windows-free-shard')).toMatchObject({ manual: true }); + const repo = fixture(); + const head = repo.git('rev-parse', 'HEAD'); + const base = await pregateAt(repo); + const file: PregateFile = { ...base, requires_remote: [...base.requires_remote!, { lane: smoke!.id, platform: 'windows', workflow: smoke!.workflow, job: smoke!.job, tests: smoke!.testFiles, receipt: null }] }; + const url = 'https://github.com/fx/fx/actions/runs/300'; + expect(clearDecision(file, smoke!.id, { runUrl: url, evidence: runOfWorkflow(repo, head, 'windows-smoke.yml') }, repo.work)).toEqual({ receipt: url, attested: false }); + for (const other of ['windows-free-tests.yml', 'free-tests.yml']) { + const d = clearDecision(file, smoke!.id, { runUrl: url, evidence: runOfWorkflow(repo, head, other) }, repo.work); + expect('refused' in d && `${d.refused}: ${d.message}`).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=workflow_mismatch — run 300 attempt 1 ran .github/workflows/${other}, not .github/workflows/windows-smoke.yml; windows-smoke.yml/windows-smoke stays uncleared`); + } + }, 120_000); + test('a lane the base profiles never falls back to an unverified record when the GitHub origin cannot be read', async () => { const repo = fixture({ run_profiles: PROFILES, remote_gate_runs: GATE_RUNS }); const file = await pregateAt(repo);