mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
v1.93.1.0 fix(pregate): clear --run-url accepts a reusable-workflow lane's caller run (GST-971) (#3865)
* test(pregate): clear --run-url refuses a caller run of a workflow_call lane and suggests dispatching a workflow with no workflow_dispatch (red, GST-971) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): clear --run-url accepts a caller run for a workflow_call lane and only suggests dispatching workflows with workflow_dispatch (GST-971) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * 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> * test(pregate): caller runs must succeed in <calling job> / <lane job>, names with comments or expressions match, callers read at pregate.json's tree, flow-mapping on: (red, GST-971 fix-check) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * 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> * docs(pregate): a reusable-workflow lane is any lane another workflow calls; caller runs check <calling job> / <lane job> (GST-971 docs audit) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
9d9d4c31bb
commit
be38ff07c2
9 files changed
+162
-15
No files matched your search
@@ -1,5 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## [1.93.1.0] - 2026-11-02
|
||||
|
||||
- **`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).
|
||||
|
||||
## [1.93.0.0] - 2026-11-02
|
||||
|
||||
**`gstack-clean` reclaims the throwaway files that pile up in a repository's `.gstack/` folder, and changes nothing unless you pass `--apply`** (#2627). Until now nothing removed old codex scratch files or trimmed the browse logs, so the folder only grew (the reporter measured 8.3 MB in six weeks), and the only way out was deleting the whole folder, which also deletes your cookies, Chromium profile and browser skills.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.93.0.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.93.1.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+11
-2
@@ -762,7 +762,15 @@ workflow file is the lane's own `<workflow.yml>`. A run of another workflow
|
||||
refuses with `REUSE: refused reason=workflow_mismatch — run <id> attempt <n>
|
||||
ran .github/workflows/<other>.yml, not .github/workflows/<lane's>.yml`; a
|
||||
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`. The run must also
|
||||
run URL or id on this repository with `run_ref_invalid`. A reusable-workflow
|
||||
lane (one whose file another workflow calls 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), 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
|
||||
refuses with `run_in_progress`, and one that concluded anything but `success`
|
||||
(`failure`, `cancelled`, `timed_out`, …) with `run_not_success`; each fix line
|
||||
@@ -770,7 +778,8 @@ says to rerun the workflow and clear with the green run. The check reads the
|
||||
run's newest attempt, so a `/job/<id>` or `/attempts/<n>` link is judged by
|
||||
the whole run, not that job or attempt, and the lane records
|
||||
`https://github.com/<repo>/actions/runs/<id>/attempts/<n>` for the attempt it
|
||||
judged rather than the link you passed. The job, tree and age are not
|
||||
judged rather than the link you passed. The job (apart from a caller run's
|
||||
`<calling job> / <lane job>` above), tree and age are not
|
||||
checked there; that is what a run profile adds. `test_backend/<backend>` lanes and `--bundle-lane` are
|
||||
unchanged. When the base config or the GitHub origin cannot be read, `--run`
|
||||
fails, and so do `--run-url` and `--bundle-lane` unless the base was read and
|
||||
|
||||
@@ -3855,7 +3855,10 @@ repository), `api_error` (gh could not read the run), `workflow_mismatch` (a
|
||||
run of another workflow), `run_in_progress` (still queued or running) or
|
||||
`run_not_success` (it failed, was cancelled or timed out). Each prints a fix
|
||||
line; after the last three, clear again with a finished green run of the
|
||||
lane's own workflow ([run evidence](pregate.md#run-evidence)).
|
||||
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 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
|
||||
|
||||
+23
-4
@@ -27,8 +27,9 @@ 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 { 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);
|
||||
@@ -267,10 +268,28 @@ 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}`;
|
||||
if (!run || run.path !== want) return invalid(`workflow_mismatch — run ${id} attempt ${run?.run_attempt ?? '?'} ran ${run?.path ?? 'an unknown workflow'}, not ${want}`, `clear ${lane} with a run of its own workflow: gh workflow run ${target.workflow} --ref <branch>, then gstack-pregate clear --remote ${lane} --run-url <that run's URL>`);
|
||||
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)) {
|
||||
const names = callers.map(c => c.file).join(', ');
|
||||
const dispatchable = dispatch ? [target.workflow] : callers.filter(c => c.dispatch).map(c => c.file);
|
||||
const start = dispatchable.length ? dispatchable.map(f => `gh workflow run ${f} --ref <branch>`).join(' or ')
|
||||
: callers.length ? `neither ${target.workflow} nor its callers have a workflow_dispatch trigger, so push a commit that starts ${callers.map(c => c.file).join(' or ')}`
|
||||
: `${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 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 ${target.workflow} if it fails), ${green}`);
|
||||
if (run.conclusion !== 'success') return invalid(`run_not_success — run ${id} attempt ${run.run_attempt} concluded ${run.conclusion}`, `rerun ${target.workflow} (gh run rerun ${id}, or gh workflow run ${target.workflow} --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 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 };
|
||||
}
|
||||
const receipt = input.runUrl ?? (input.bundleLane ? `bundle-lane:${input.bundleLane}` : undefined);
|
||||
|
||||
@@ -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,6 +184,54 @@ export function parseWorkflow(file: string, text: string, platform: NodeJS.Platf
|
||||
});
|
||||
}
|
||||
|
||||
/** 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+#.*$/, '').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, ''); }
|
||||
|
||||
/**
|
||||
* `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, 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) {
|
||||
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 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, 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. */
|
||||
export function discoverLanes(repoRoot: string, platform: NodeJS.Platform = process.platform): Lane[] {
|
||||
const dir = path.join(repoRoot, '.github', 'workflows');
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.93.0",
|
||||
"version": "1.93.1",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -194,8 +194,11 @@ describe.skipIf(process.platform === 'win32')('clear --run: Actions runs as remo
|
||||
const repo = fixture();
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const base = await pregateAt(repo);
|
||||
fs.mkdirSync(path.join(repo.work, '.github/workflows'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, '.github/workflows/a.yml'), 'name: A\non: [pull_request, workflow_dispatch]\njobs:\n job:\n runs-on: windows-latest\n steps:\n - run: echo a\n');
|
||||
repo.git('add', '-A', '.github');
|
||||
const PLATFORM = 'a.yml/job';
|
||||
const file: PregateFile = { ...base, requires_remote: [...base.requires_remote!, { lane: PLATFORM, platform: 'windows', workflow: 'a.yml', job: 'job', tests: ['test/a.test.ts'], receipt: null }] };
|
||||
const file: PregateFile = { ...base, tree: repo.git('write-tree'), requires_remote: [...base.requires_remote!, { lane: PLATFORM, platform: 'windows', workflow: 'a.yml', job: 'job', tests: ['test/a.test.ts'], receipt: null }] };
|
||||
const url = 'https://github.com/fx/fx/actions/runs/300';
|
||||
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, 'b.yml') }, repo.work))).toBe(`PREGATE_RECEIPT_INVALID: REUSE: refused reason=workflow_mismatch — run 300 attempt 1 ran .github/workflows/b.yml, not .github/workflows/a.yml; ${PLATFORM} stays uncleared; fix: clear ${PLATFORM} with a run of its own workflow: gh workflow run a.yml --ref <branch>, then gstack-pregate clear --remote ${PLATFORM} --run-url <that run's URL>`);
|
||||
@@ -210,8 +213,11 @@ describe.skipIf(process.platform === 'win32')('clear --run: Actions runs as remo
|
||||
const repo = fixture();
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const base = await pregateAt(repo);
|
||||
fs.mkdirSync(path.join(repo.work, '.github/workflows'), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo.work, '.github/workflows/a.yml'), 'name: A\non: [pull_request, workflow_dispatch]\njobs:\n job:\n runs-on: windows-latest\n steps:\n - run: echo a\n');
|
||||
repo.git('add', '-A', '.github');
|
||||
const PLATFORM = 'a.yml/job';
|
||||
const file: PregateFile = { ...base, requires_remote: [...base.requires_remote!, { lane: PLATFORM, platform: 'windows', workflow: 'a.yml', job: 'job', tests: ['test/a.test.ts'], receipt: null }] };
|
||||
const file: PregateFile = { ...base, tree: repo.git('write-tree'), requires_remote: [...base.requires_remote!, { lane: PLATFORM, platform: 'windows', workflow: 'a.yml', job: 'job', tests: ['test/a.test.ts'], receipt: null }] };
|
||||
const url = 'https://github.com/fx/fx/actions/runs/300';
|
||||
const msg = (d: ReturnType<typeof clearDecision>) => ('refused' in d ? `${d.refused}: ${d.message}; fix: ${d.fix}` : `cleared ${d.receipt}`);
|
||||
const rerun = `fix: rerun a.yml (gh run rerun 300, or gh workflow run a.yml --ref <branch>), then gstack-pregate clear --remote ${PLATFORM} --run-url <the green run's URL>`;
|
||||
@@ -223,6 +229,63 @@ describe.skipIf(process.platform === 'win32')('clear --run: Actions runs as remo
|
||||
expect(clearDecision(file, PLATFORM, { runUrl: `${url}/attempts/1?pr=1`, evidence: runOfWorkflow(repo, head, 'a.yml', { status: 'completed', conclusion: 'success', run_attempt: 2 }) }, repo.work)).toEqual({ receipt: `${url}/attempts/2`, attested: false });
|
||||
}, 120_000);
|
||||
|
||||
test('a reusable-workflow lane (workflow_call only) clears from a run of a caller whose job uses ./.github/workflows/<lane workflow>; an unrelated workflow still refuses, and no fix line dispatches a workflow without workflow_dispatch (GST-971)', async () => {
|
||||
const repo = fixture();
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const base = await pregateAt(repo);
|
||||
const wf = (name: string, text: string) => { fs.mkdirSync(path.join(repo.work, '.github/workflows'), { recursive: true }); fs.writeFileSync(path.join(repo.work, '.github/workflows', name), text); };
|
||||
wf('r.yml', 'name: Reusable\non:\n workflow_call:\n inputs:\n scope:\n type: string\njobs:\n job:\n runs-on: ubuntu-latest\n steps:\n - run: echo reusable\n');
|
||||
wf('c.yml', 'name: Caller\non:\n pull_request:\n workflow_dispatch:\njobs:\n native:\n uses: ./.github/workflows/r.yml\n with:\n scope: full\n');
|
||||
wf('u.yml', 'name: Unrelated\non: [push, workflow_dispatch]\njobs:\n other:\n uses: ./.github/workflows/other.yml\n');
|
||||
const PLATFORM = 'r.yml/job';
|
||||
const file: PregateFile = { ...base, requires_remote: [...base.requires_remote!, { lane: PLATFORM, platform: 'windows', workflow: 'r.yml', job: 'job', tests: ['test/a.test.ts'], receipt: null }] };
|
||||
const url = 'https://github.com/fx/fx/actions/runs/300';
|
||||
const msg = (d: ReturnType<typeof clearDecision>) => ('refused' in d ? `${d.refused}: ${d.message}; fix: ${d.fix}` : `cleared ${d.receipt}`);
|
||||
const at = (): PregateFile => { repo.git('add', '-A', '.github'); return { ...file, tree: repo.git('write-tree') }; };
|
||||
expect(msg(clearDecision(at(), PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'c.yml') }, repo.work))).toBe(`cleared ${url}/attempts/1`);
|
||||
expect(msg(clearDecision(at(), PLATFORM, { runUrl: `${url}/job/9`, evidence: runOfWorkflow(repo, head, 'c.yml') }, repo.work))).toBe(`cleared ${url}/attempts/1`);
|
||||
expect(msg(clearDecision(at(), 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' }], [{ name: 'native / lint', conclusion: 'success' }, { name: 'native / job', conclusion: 'skipped' }]]) {
|
||||
expect(msg(clearDecision(at(), 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 named "native / job", 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(at(), 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(at(), 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(at(), 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>`);
|
||||
wf('c.yml', 'name: Caller\non:\n pull_request:\njobs:\n native:\n uses: "./.github/workflows/r.yml" # reusable\n');
|
||||
expect(msg(clearDecision(at(), 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): neither r.yml nor its callers have a workflow_dispatch trigger, so push a commit that starts c.yml, then gstack-pregate clear --remote ${PLATFORM} --run-url <that run's URL>`);
|
||||
expect(msg(clearDecision(at(), 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), then gstack-pregate clear --remote ${PLATFORM} --run-url <the green run's URL>`);
|
||||
expect(msg(clearDecision(at(), PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'c.yml') }, repo.work))).toBe(`cleared ${url}/attempts/1`);
|
||||
}, 120_000);
|
||||
|
||||
test('a caller run counts only through a successful <calling job> / <lane job> job; names with comments or expressions match; callers are read at pregate.json\'s tree, not the working tree; a flow-mapping on: with workflow_dispatch counts (GST-971 fix-check)', async () => {
|
||||
const repo = fixture();
|
||||
const head = repo.git('rev-parse', 'HEAD');
|
||||
const base = await pregateAt(repo);
|
||||
const PLATFORM = 'r.yml/job';
|
||||
const lane: PregateFile = { ...base, requires_remote: [...base.requires_remote!, { lane: PLATFORM, platform: 'windows', workflow: 'r.yml', job: 'job', tests: ['test/a.test.ts'], receipt: null }] };
|
||||
const dir = path.join(repo.work, '.github/workflows');
|
||||
const write = (files: Record<string, string>) => { fs.mkdirSync(dir, { recursive: true }); for (const [n, text] of Object.entries(files)) fs.writeFileSync(path.join(dir, n), text); };
|
||||
write({
|
||||
'r.yml': 'name: Reusable\non:\n workflow_call:\njobs:\n job:\n name: Native tests # the lane\n runs-on: ubuntu-latest\n steps:\n - run: echo r\n other:\n runs-on: ubuntu-latest\n steps:\n - run: echo o\n',
|
||||
'c.yml': 'name: Caller\non: { pull_request: {}, workflow_dispatch: {} }\njobs:\n native:\n name: Native (${{ inputs.scope }}) # calls r.yml\n uses: ./.github/workflows/r.yml\n',
|
||||
});
|
||||
repo.git('add', '-A', '.github');
|
||||
const file: PregateFile = { ...lane, tree: repo.git('write-tree') };
|
||||
const url = 'https://github.com/fx/fx/actions/runs/300';
|
||||
const msg = (d: ReturnType<typeof clearDecision>) => ('refused' in d ? `${d.refused}: ${d.message}; fix: ${d.fix}` : `cleared ${d.receipt}`);
|
||||
const caller = (jobs: ApiJob[], state?: Pick<ApiRun, 'status' | 'conclusion'>) => msg(clearDecision(file, PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'c.yml', state, jobs) }, repo.work));
|
||||
expect(caller([{ name: 'Native (full) / Native tests', conclusion: 'success' }])).toBe(`cleared ${url}/attempts/1`);
|
||||
expect(caller([{ name: 'Native (smoke) / Native tests (linux)', conclusion: 'success' }])).toBe(`cleared ${url}/attempts/1`);
|
||||
const skipped = `PREGATE_RECEIPT_INVALID: REUSE: refused reason=caller_job_skipped — run 300 attempt 1 of c.yml has no successful job named "Native (\${{ inputs.scope }}) / Native tests", 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(caller([{ name: 'Native (full) / other', conclusion: 'success' }, { name: 'Native (full) / Native tests', conclusion: 'skipped' }])).toBe(skipped);
|
||||
expect(caller([{ name: 'Native (full) / lint', conclusion: 'success' }])).toBe(skipped);
|
||||
expect(caller([], { status: 'completed', conclusion: 'failure' })).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>`);
|
||||
write({ 'u.yml': 'name: Unrelated\non: [push]\njobs:\n native:\n uses: ./.github/workflows/r.yml\n' });
|
||||
fs.rmSync(path.join(dir, 'c.yml'));
|
||||
expect(msg(clearDecision(file, PLATFORM, { runUrl: url, evidence: runOfWorkflow(repo, head, 'u.yml', undefined, [{ name: 'native / Native tests', conclusion: 'success' }]) }, 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(caller([{ name: 'Native (full) / Native tests', conclusion: 'success' }])).toBe(`cleared ${url}/attempts/1`);
|
||||
}, 120_000);
|
||||
|
||||
test('on this repository\'s own workflows the Windows obligation is windows-smoke.yml/windows-smoke: a windows-smoke.yml run clears it, a run of any other workflow is refused (GSTA-203)', async () => {
|
||||
const lanes = discoverLanes(path.resolve(import.meta.dir, '..'), 'linux');
|
||||
const owed = lanes.filter(l => l.tier === 'platform' && l.platform === 'windows' && l.runsTests && !l.manual);
|
||||
|
||||
Reference in new issue
Block a user