diff --git a/changelog.d/gst-971-workflow-call-lane.md b/changelog.d/gst-971-workflow-call-lane.md index dead718b9..8e9828d3f 100644 --- a/changelog.d/gst-971-workflow-call-lane.md +++ b/changelog.d/gst-971-workflow-call-lane.md @@ -1 +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). +- **`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, as long as a job under the calling job succeeded (else `caller_job_skipped`); 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 4dc311818..61befe408 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -765,8 +765,10 @@ 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`. 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. +that says `uses: ./.github/workflows/.yml` (GST-971), as long as a +job under that calling job (` / …`) succeeded in the run; else it +refuses with `caller_job_skipped`. 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` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0e5e3a6bc..4d2cda4b4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3772,7 +3772,8 @@ run of another workflow), `run_in_progress` (still queued or running) or line; after the last three, clear again with a finished green run of the 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). +`uses: ./.github/workflows/.yml` counts as a run of the lane (GST-971); +it refuses `caller_job_skipped` when no job under that calling job succeeded. 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 d8946e7a7..9221b4ba9 100644 --- a/lib/pregate/index.ts +++ b/lib/pregate/index.ts @@ -27,7 +27,7 @@ import { NON_DOWNGRADABLE, PREGATE_CONFIG_PATH, userPolicyAbsent } from './confi import { sha256, withFingerprint } from './context'; import { baseBranchOf, baseMismatchFix, describeSubstitution, substituteStampOnlyTree } from './remote-receipt'; 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 { parseRunReceipt, parseRunRef, runChanged, runReceiptFields, uncoveredFiles, verifyRun, type ApiJob, 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'; @@ -279,10 +279,17 @@ export function clearDecision(file: PregateFile, lane: string, input: ClearInput : `${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 runOf = 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 ${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}`); + 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 ${runOf.file} if it fails), ${green}`); + if (run.conclusion !== 'success') return invalid(`run_not_success — run ${id} attempt ${run.run_attempt} concluded ${run.conclusion}`, `rerun ${runOf.file} (gh run rerun ${id}${runOf.dispatch ? `, or gh workflow run ${runOf.file} --ref ` : ''}), ${green}`); + if (caller) { + let jobs: ApiJob[]; + try { jobs = input.evidence.deps.jobs(id, run.run_attempt); } + catch (e: any) { return invalid(`api_error — reading run ${id}'s jobs 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 called = (name: string) => caller.jobs.some(n => name.startsWith(n) && /^(?: \([^)]*\))? \/ /.test(name.slice(n.length))); + if (!jobs.some(j => j.conclusion === 'success' && called(j.name))) return invalid(`caller_job_skipped — run ${id} attempt ${run.run_attempt} of ${caller.file} has no successful job under ${caller.jobs.map(n => `"${n} / …"`).join(' or ')}, so ${target.workflow} did not run in it`, `clear with a ${caller.file} run in which that job runs${caller.dispatch ? ` (gh workflow run ${caller.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 5aa26792c..59d9510f2 100644 --- a/lib/pregate/workflows.ts +++ b/lib/pregate/workflows.ts @@ -192,23 +192,25 @@ function triggers(top: Block[]): string[] { /** * 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. + * reusable workflow (`uses: ./.github/workflows/`), each with its own workflow_dispatch trigger and the display + * names of its calling jobs. A workflow_call run reports its caller's file, and its jobs as ` / `, 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 }> } { +export function workflowCallers(repoRoot: string, workflow: string): { dispatch: boolean; callers: Array<{ file: string; dispatch: boolean; jobs: string[] }> } { 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 }> = []; + const callers: Array<{ file: string; dispatch: boolean; jobs: string[] }> = []; 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 }); + const jobs = (find(top, 'jobs')?.children ?? []).filter(job => (find(job.children, 'uses')?.value ?? '').replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, '') === want) + .map(job => (find(job.children, 'name')?.value ?? job.key).replace(/^['"]|['"]$/g, '')); + if (jobs.length && n !== workflow) callers.push({ file: n, dispatch: canDispatch, jobs }); } return { dispatch, callers }; } diff --git a/test/pregate-clear-runs.test.ts b/test/pregate-clear-runs.test.ts index 9e359c053..6d986b6f8 100644 --- a/test/pregate-clear-runs.test.ts +++ b/test/pregate-clear-runs.test.ts @@ -10,7 +10,7 @@ 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 type { ApiJob, ApiRun, RunDeps, RunProfile } from '../lib/run-evidence'; import { BASE_PREGATE, cleanup, commit, makeFixtureRepo, readFile, runBin, type FixtureRepo } from './helpers/pregate-fixture'; const repos: FixtureRepo[] = []; @@ -74,13 +74,13 @@ function check(repo: FixtureRepo, runs: FakeRun[], over: Partial = {}) } /** A check whose only readable run is 300, a run of `workflow` on `head` (completed with success unless `state` says otherwise). */ -function runOfWorkflow(repo: FixtureRepo, head: string, workflow: string, state: Pick & Partial> = { status: 'completed', conclusion: 'success' }): RunCheck { +function runOfWorkflow(repo: FixtureRepo, head: string, workflow: string, state: Pick & Partial> = { status: 'completed', conclusion: 'success' }, jobs: ApiJob[] = [{ name: 'native / job', conclusion: 'success' }]): RunCheck { const c = check(repo, []); const run = (id: number): ApiRun => { if (id !== 300) throw new Error('HTTP 404'); return { id, run_attempt: 1, ...state, 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 } }; + return { ...c, deps: { ...c.deps, run, jobs: () => jobs } }; } describe.skipIf(process.platform === 'win32')('clear --run: Actions runs as remote gate evidence', () => { @@ -241,6 +241,10 @@ describe.skipIf(process.platform === 'win32')('clear --run: Actions runs as remo 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: runOfWorkflow(repo, head, 'c.yml') }, repo.work))).toBe(`cleared ${url}/attempts/1`); expect(msg(clearDecision(file, PLATFORM, { runUrl: `${url}/job/9`, evidence: runOfWorkflow(repo, head, 'c.yml') }, repo.work))).toBe(`cleared ${url}/attempts/1`); + expect(msg(clearDecision(file, PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'c.yml', undefined, [{ name: 'native (linux) / job', conclusion: 'success' }]) }, repo.work))).toBe(`cleared ${url}/attempts/1`); + for (const jobs of [[{ name: 'native', conclusion: 'skipped' }], [{ name: 'native / job', conclusion: 'skipped' }, { name: 'lint', conclusion: 'success' }], [{ name: 'native-other / job', conclusion: 'success' }]]) { + expect(msg(clearDecision(file, PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'c.yml', undefined, jobs) }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=caller_job_skipped — run 300 attempt 1 of c.yml has no successful job under "native / …", so r.yml did not run in it; ${PLATFORM} stays uncleared; fix: clear with a c.yml run in which that job runs (gh workflow run c.yml --ref ), then gstack-pregate clear --remote ${PLATFORM} --run-url `); + } expect(msg(clearDecision(file, PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'u.yml') }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=workflow_mismatch — run 300 attempt 1 ran .github/workflows/u.yml, not .github/workflows/r.yml or a workflow that calls it (c.yml); ${PLATFORM} stays uncleared; fix: clear ${PLATFORM} with a run of its own workflow or of a workflow that calls it (c.yml): gh workflow run c.yml --ref , then gstack-pregate clear --remote ${PLATFORM} --run-url `); expect(msg(clearDecision(file, PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'c.yml', { status: 'completed', conclusion: 'failure' }) }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=run_not_success — run 300 attempt 1 concluded failure; ${PLATFORM} stays uncleared; fix: rerun c.yml (gh run rerun 300, or gh workflow run c.yml --ref ), then gstack-pregate clear --remote ${PLATFORM} --run-url `); expect(msg(clearDecision(file, PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'c.yml', { status: 'in_progress', conclusion: null }) }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=run_in_progress — run 300 attempt 1 is in_progress; ${PLATFORM} stays uncleared; fix: wait for run 300 to finish green (or rerun c.yml if it fails), then gstack-pregate clear --remote ${PLATFORM} --run-url `);