fix(pregate): a caller run clears a workflow_call lane only when a job under the calling job succeeded (GST-971 review)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-02 12:26:56 +00:00
1 parent ed27e992af
commit 8314b95a07
6 files changed
+33 -17

No files matched your search

+1 -1
View File
@@ -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/<lane workflow>` 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/<lane workflow>` 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).
+4 -2
View File
@@ -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/<lane's>.yml` (GST-971), and fix lines
only suggest `gh workflow run` for a file with a `workflow_dispatch` trigger.
that says `uses: ./.github/workflows/<lane's>.yml` (GST-971), as long as a
job under that calling job (`<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`
+2 -1
View File
@@ -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/<lane's>.yml` counts as a run of the lane (GST-971).
`uses: ./.github/workflows/<lane's>.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
+11 -4
View File
@@ -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 <that run's 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 <the green run's 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 <branch>` : ''}), ${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 <branch>` : ''}), ${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 <branch>)` : ''}, ${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);
+8 -6
View File
@@ -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/<workflow>`), 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/<workflow>`), 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 `<calling job> / <job>`, 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 };
}
+7 -3
View File
@@ -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<RunCheck> = {})
}
/** 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<ApiRun, 'status' | 'conclusion'> & Partial<Pick<ApiRun, 'run_attempt'>> = { status: 'completed', conclusion: 'success' }): RunCheck {
function runOfWorkflow(repo: FixtureRepo, head: string, workflow: string, state: Pick<ApiRun, 'status' | 'conclusion'> & Partial<Pick<ApiRun, 'run_attempt'>> = { 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<typeof clearDecision>) => ('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 <branch>), then gstack-pregate clear --remote ${PLATFORM} --run-url <the green run's 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 <branch>, then gstack-pregate clear --remote ${PLATFORM} --run-url <that run's 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 <branch>), then gstack-pregate clear --remote ${PLATFORM} --run-url <the green run's 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 <the green run's URL>`);