From ed27e992af5a573923322b849c83ac1d765ce431 Mon Sep 17 00:00:00 2001 From: garrytan Date: Mon, 2 Nov 2026 10:32:21 +0000 Subject: [PATCH] fix(pregate): clear --run-url accepts a caller run for a workflow_call lane and only suggests dispatching workflows with workflow_dispatch (GST-971) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- changelog.d/gst-971-workflow-call-lane.md | 1 + docs/pregate.md | 7 +++++- docs/troubleshooting.md | 4 ++- lib/pregate/index.ts | 18 +++++++++++--- lib/pregate/workflows.ts | 30 +++++++++++++++++++++++ test/pregate-clear-runs.test.ts | 4 +++ 6 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 changelog.d/gst-971-workflow-call-lane.md diff --git a/changelog.d/gst-971-workflow-call-lane.md b/changelog.d/gst-971-workflow-call-lane.md new file mode 100644 index 000000000..dead718b9 --- /dev/null +++ b/changelog.d/gst-971-workflow-call-lane.md @@ -0,0 +1 @@ +- **`gstack-pregate clear --run-url` accepts a reusable-workflow lane's caller run.** A lane whose workflow file runs only through `workflow_call` (gbrain's `native-locks.yml`, run as a job of `test.yml`) was refused `workflow_mismatch` for every run since v1.92.62.0, because GitHub reports the caller's file as the run's workflow. A run of a workflow in the branch's own `.github/workflows/` with a job that says `uses: ./.github/workflows/` now clears the lane, a job link inside that run too, and the red and unfinished-run refusals still apply. Fix lines only suggest `gh workflow run` for a file with a `workflow_dispatch` trigger; otherwise they name the callers. An unrelated workflow still refuses. `test/pregate-clear-runs.test.ts` failed before the fix (GST-971). diff --git a/docs/pregate.md b/docs/pregate.md index e0e18d938..4dc311818 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -762,7 +762,12 @@ workflow file is the lane's own ``. A run of another workflow refuses with `REUSE: refused reason=workflow_mismatch — run attempt ran .github/workflows/.yml, not .github/workflows/.yml`; a run gh cannot read refuses with `api_error`, and a URL that is not an Actions -run URL or id on this repository with `run_ref_invalid`. The run must also +run URL or id on this repository with `run_ref_invalid`. A reusable-workflow +lane (one whose file runs only through `workflow_call`) also clears from a run +of a caller: a workflow in the branch's own `.github/workflows/` with a job +that says `uses: ./.github/workflows/.yml` (GST-971), and fix lines +only suggest `gh workflow run` for a file with a `workflow_dispatch` trigger. +The run must also have finished green (GSTA-203 PR 8): a run that is still queued or running refuses with `run_in_progress`, and one that concluded anything but `success` (`failure`, `cancelled`, `timed_out`, …) with `run_not_success`; each fix line diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 6634be10f..0e5e3a6bc 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3770,7 +3770,9 @@ repository), `api_error` (gh could not read the run), `workflow_mismatch` (a run of another workflow), `run_in_progress` (still queued or running) or `run_not_success` (it failed, was cancelled or timed out). Each prints a fix line; after the last three, clear again with a finished green run of the -lane's own workflow ([run evidence](pregate.md#run-evidence)). +lane's own workflow ([run evidence](pregate.md#run-evidence)). For a lane whose +workflow runs only through `workflow_call`, a run of a workflow whose job says +`uses: ./.github/workflows/.yml` counts as a run of the lane (GST-971). Another tree is accepted in exactly one case: it differs from `pregate.json`'s tree only in the repo's stamp rule (`.gstack/ship-policy.json` on the base diff --git a/lib/pregate/index.ts b/lib/pregate/index.ts index c91915986..d8946e7a7 100644 --- a/lib/pregate/index.ts +++ b/lib/pregate/index.ts @@ -29,6 +29,7 @@ import { baseBranchOf, baseMismatchFix, describeSubstitution, substituteStampOnl import { resolveBaseBranch } from '../ship-policy'; import { parseRunReceipt, parseRunRef, runChanged, runReceiptFields, uncoveredFiles, verifyRun, type ApiRun, type RemoteGateRuns, type RunDeps, type RunEvidence, type RunProfile } from '../run-evidence'; import type { CheckDef, CheckResult, PregateContext, Stage } from './types'; +import { workflowCallers } from './workflows'; export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, timingCheck, guardsCheck, lanesCheck]; export const CHECK_IDS = CHECKS.map(c => c.id); @@ -267,10 +268,21 @@ export function clearDecision(file: PregateFile, lane: string, input: ClearInput try { run = input.evidence.deps.run(id); } catch (e: any) { return invalid(`api_error — reading run ${id} failed: ${String(e?.message ?? e).slice(0, 300)}`, `check gh auth status and that gh can read ${repo}'s Actions runs, then rerun the same clear`); } const want = `.github/workflows/${target.workflow}`; - if (!run || run.path !== want) return invalid(`workflow_mismatch — run ${id} attempt ${run?.run_attempt ?? '?'} ran ${run?.path ?? 'an unknown workflow'}, not ${want}`, `clear ${lane} with a run of its own workflow: gh workflow run ${target.workflow} --ref , then gstack-pregate clear --remote ${lane} --run-url `); + const { dispatch, callers } = workflowCallers(repoRoot, target.workflow); + const runPath = run?.path; + const caller = callers.find(c => runPath === `.github/workflows/${c.file}`); + if (!run || (run.path !== want && !caller)) { + const names = callers.map(c => c.file).join(', '); + const dispatchable = dispatch ? [target.workflow] : callers.filter(c => c.dispatch).map(c => c.file); + const start = dispatchable.length ? dispatchable.map(f => `gh workflow run ${f} --ref `).join(' or ') + : callers.length ? `neither ${target.workflow} nor its callers have a workflow_dispatch trigger, so push a commit that starts ${callers.map(c => c.file).join(' or ')}` + : `${target.workflow} has no workflow_dispatch trigger, so push a commit that starts it`; + return invalid(`workflow_mismatch — run ${id} attempt ${run?.run_attempt ?? '?'} ran ${run?.path ?? 'an unknown workflow'}, not ${want}${callers.length ? ` or a workflow that calls it (${names})` : ''}`, `clear ${lane} with a run of its own workflow${callers.length ? ` or of a workflow that calls it (${names})` : ''}: ${start}, then gstack-pregate clear --remote ${lane} --run-url `); + } + const ran = caller ?? { file: target.workflow, dispatch }; const green = `then gstack-pregate clear --remote ${lane} --run-url `; - if (run.status !== 'completed') return invalid(`run_in_progress — run ${id} attempt ${run.run_attempt} is ${run.status}`, `wait for run ${id} to finish green (or rerun ${target.workflow} if it fails), ${green}`); - if (run.conclusion !== 'success') return invalid(`run_not_success — run ${id} attempt ${run.run_attempt} concluded ${run.conclusion}`, `rerun ${target.workflow} (gh run rerun ${id}, or gh workflow run ${target.workflow} --ref ), ${green}`); + if (run.status !== 'completed') return invalid(`run_in_progress — run ${id} attempt ${run.run_attempt} is ${run.status}`, `wait for run ${id} to finish green (or rerun ${ran.file} if it fails), ${green}`); + if (run.conclusion !== 'success') return invalid(`run_not_success — run ${id} attempt ${run.run_attempt} concluded ${run.conclusion}`, `rerun ${ran.file} (gh run rerun ${id}${ran.dispatch ? `, or gh workflow run ${ran.file} --ref ` : ''}), ${green}`); return { receipt: `https://github.com/${repo}/actions/runs/${id}/attempts/${run.run_attempt}`, attested: false }; } const receipt = input.runUrl ?? (input.bundleLane ? `bundle-lane:${input.bundleLane}` : undefined); diff --git a/lib/pregate/workflows.ts b/lib/pregate/workflows.ts index 8b43588c3..5aa26792c 100644 --- a/lib/pregate/workflows.ts +++ b/lib/pregate/workflows.ts @@ -183,6 +183,36 @@ export function parseWorkflow(file: string, text: string, platform: NodeJS.Platf }); } +/** A workflow's `on:` trigger names, from `on: x`, `on: [x, y]` or its keys and list items. */ +function triggers(top: Block[]): string[] { + const on = top.find(b => /^['"]?on['"]?$/.test(b.key)); + if (!on) return []; + return [...on.value.replace(/\s+#.*$/, '').replace(/[[\]]/g, '').split(','), ...on.children.map(c => c.key)].map(s => s.trim()).filter(Boolean); +} + +/** + * Whether `workflow` has a workflow_dispatch trigger, and the repo's workflow files with a job that calls it as a local + * reusable workflow (`uses: ./.github/workflows/`), each with its own workflow_dispatch trigger. A workflow_call + * run reports its caller's file, so a caller's run is a run of the lane (GST-971). A missing or unreadable file has no trigger. + */ +export function workflowCallers(repoRoot: string, workflow: string): { dispatch: boolean; callers: Array<{ file: string; dispatch: boolean }> } { + const dir = path.join(repoRoot, '.github', 'workflows'); + let names: string[] = []; + try { names = fs.readdirSync(dir).filter(n => /\.ya?ml$/.test(n)).sort(); } catch { return { dispatch: false, callers: [] }; } + const want = `./.github/workflows/${workflow}`; + let dispatch = false; + const callers: Array<{ file: string; dispatch: boolean }> = []; + for (const n of names) { + let top: Block[]; + try { top = outline(fs.readFileSync(path.join(dir, n), 'utf8')); } catch { continue; } + const canDispatch = triggers(top).includes('workflow_dispatch'); + if (n === workflow) dispatch = canDispatch; + const calls = (find(top, 'jobs')?.children ?? []).some(job => (find(job.children, 'uses')?.value ?? '').replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, '') === want); + if (calls && n !== workflow) callers.push({ file: n, dispatch: canDispatch }); + } + return { dispatch, callers }; +} + /** Every job of every workflow, in file order. */ export function discoverLanes(repoRoot: string, platform: NodeJS.Platform = process.platform): Lane[] { const dir = path.join(repoRoot, '.github', 'workflows'); diff --git a/test/pregate-clear-runs.test.ts b/test/pregate-clear-runs.test.ts index 19afa089c..9e359c053 100644 --- a/test/pregate-clear-runs.test.ts +++ b/test/pregate-clear-runs.test.ts @@ -194,6 +194,8 @@ describe.skipIf(process.platform === 'win32')('clear --run: Actions runs as remo const repo = fixture(); const head = repo.git('rev-parse', 'HEAD'); const base = await pregateAt(repo); + fs.mkdirSync(path.join(repo.work, '.github/workflows'), { recursive: true }); + fs.writeFileSync(path.join(repo.work, '.github/workflows/a.yml'), 'name: A\non: [pull_request, workflow_dispatch]\njobs:\n job:\n runs-on: windows-latest\n steps:\n - run: echo a\n'); 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 url = 'https://github.com/fx/fx/actions/runs/300'; @@ -210,6 +212,8 @@ describe.skipIf(process.platform === 'win32')('clear --run: Actions runs as remo const repo = fixture(); const head = repo.git('rev-parse', 'HEAD'); const base = await pregateAt(repo); + fs.mkdirSync(path.join(repo.work, '.github/workflows'), { recursive: true }); + fs.writeFileSync(path.join(repo.work, '.github/workflows/a.yml'), 'name: A\non: [pull_request, workflow_dispatch]\njobs:\n job:\n runs-on: windows-latest\n steps:\n - run: echo a\n'); 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 url = 'https://github.com/fx/fx/actions/runs/300';