mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 23:37:53 +01:00
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:
1 parent
ed27e992af
commit
8314b95a07
6 files changed
+33
-17
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, 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
@@ -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`
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>`);
|
||||
|
||||
Reference in new issue
Block a user