mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
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:
1 parent
1bd0716ef7
commit
0513298ae8
5 files changed
+44
-26
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user