fix(pregate): caller runs need <calling job> / <lane job>, name comments and expressions match, callers read at pregate.json's tree, flow-mapping on: detected (GST-971 fix-check)

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:45:23 +00:00
1 parent 1bd0716ef7
commit 0513298ae8
5 files changed
+44 -26

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, 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).
- **`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 the lane's own job succeeded under the calling job (`<calling job> / <lane job>`, else `caller_job_skipped`), with callers read at pregate.json's recorded tree; 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 -3
View File
@@ -765,9 +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), 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
that says `uses: ./.github/workflows/<lane's>.yml` (GST-971), read at
pregate.json's recorded tree, as long as the lane's own job succeeded in the
run under that calling job (`<calling job> / <lane job>`); 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
+1 -1
View File
@@ -3773,7 +3773,7 @@ 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);
it refuses `caller_job_skipped` when no job under that calling job succeeded.
it refuses `caller_job_skipped` when its `<calling job> / <lane job>` job did not succeed.
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
+4 -4
View File
@@ -29,7 +29,7 @@ import { baseBranchOf, baseMismatchFix, describeSubstitution, substituteStampOnl
import { resolveBaseBranch } from '../ship-policy';
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';
import { displayNamePattern, workflowCallers } from './workflows';
export const CHECKS: readonly CheckDef[] = [regenCheck, secretsCheck, straysCheck, literalsCheck, timingCheck, guardsCheck, lanesCheck];
export const CHECK_IDS = CHECKS.map(c => c.id);
@@ -268,7 +268,7 @@ 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}`;
const { dispatch, callers } = workflowCallers(repoRoot, target.workflow);
const { dispatch, jobName, callers } = workflowCallers(repoRoot, file.tree, target.workflow, target.job);
const runPath = run?.path;
const caller = callers.find(c => runPath === `.github/workflows/${c.file}`);
if (!run || (run.path !== want && !caller)) {
@@ -287,8 +287,8 @@ export function clearDecision(file: PregateFile, lane: string, input: ClearInput
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}`);
const named = caller.jobs.map(n => new RegExp(`^${displayNamePattern(n)}(?: \\([^)]*\\))? / ${displayNamePattern(jobName)}(?: \\([^)]*\\))?$`));
if (!jobs.some(j => j.conclusion === 'success' && named.some(re => re.test(j.name)))) return invalid(`caller_job_skipped — run ${id} attempt ${run.run_attempt} of ${caller.file} has no successful job named ${caller.jobs.map(n => `"${n} / ${jobName}"`).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 };
}
+34 -17
View File
@@ -8,6 +8,7 @@
* runner-owned selection manifest `.gstack/pregate.json` declares for it, or
* it is reported as an unsupported expression and treated conservatively.
*/
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { laneOf, type Lane as Platform } from '../runtime-pins';
@@ -183,36 +184,52 @@ 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. */
/** A workflow's `on:` trigger names, from `on: x`, `on: [x, y]`, `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);
return [...on.value.replace(/\s+#.*$/, '').split(/[\s,[\]{}:]+/), ...on.children.map(c => c.key)].map(s => s.trim()).filter(Boolean);
}
/** A `name:` or `uses:` value without its trailing comment and quotes. */
function scalar(value: string): string { return value.replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, ''); }
/**
* 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 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.
* `workflow`'s workflow_dispatch trigger, the display name of its job `job`, and the workflow files with a job that calls it as
* a local reusable workflow (`uses: ./.github/workflows/<workflow>`), each with its own workflow_dispatch trigger and the display
* names of its calling jobs, all read from git `tree` (pregate.json's recorded tree), never the working tree. 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).
* Display names keep `${{ … }}` expressions as written; an unreadable tree or file has no trigger and no callers.
*/
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: [] }; }
export function workflowCallers(repoRoot: string, tree: string, workflow: string, job: string): { dispatch: boolean; jobName: string; callers: Array<{ file: string; dispatch: boolean; jobs: string[] }> } {
const git = (args: string[]) => spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
const ls = git(['ls-tree', '--name-only', `${tree}:.github/workflows`]);
const names = ls.status === 0 ? ls.stdout.split('\n').filter(n => /\.ya?ml$/.test(n)).sort() : [];
const want = `./.github/workflows/${workflow}`;
let dispatch = false;
let jobName = job;
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 show = git(['show', `${tree}:.github/workflows/${n}`]);
if (show.status !== 0) continue;
const top = outline(show.stdout);
const jobs = find(top, 'jobs')?.children ?? [];
const canDispatch = triggers(top).includes('workflow_dispatch');
if (n === workflow) 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 });
if (n === workflow) {
dispatch = canDispatch;
const own = find(jobs, job);
jobName = own ? scalar(find(own.children, 'name')?.value ?? '') || job : job;
continue;
}
const calling = jobs.filter(j => scalar(find(j.children, 'uses')?.value ?? '') === want).map(j => scalar(find(j.children, 'name')?.value ?? '') || j.key);
if (calling.length) callers.push({ file: n, dispatch: canDispatch, jobs: calling });
}
return { dispatch, callers };
return { dispatch, jobName, callers };
}
/** A regex source matching a display name GitHub renders from `name`: each `${{ … }}` expression matches any text without ` / `. */
export function displayNamePattern(name: string): string {
return name.split(/\$\{\{.*?\}\}/).map(s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('(?:(?! / ).)+?');
}
/** Every job of every workflow, in file order. */