diff --git a/changelog.d/gst-971-workflow-call-lane.md b/changelog.d/gst-971-workflow-call-lane.md index 8e9828d3f..b46fd4266 100644 --- a/changelog.d/gst-971-workflow-call-lane.md +++ b/changelog.d/gst-971-workflow-call-lane.md @@ -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/` 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/` now clears the lane, a job link inside that run too, as long as the lane's own job succeeded under the calling 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). diff --git a/docs/pregate.md b/docs/pregate.md index 61befe408..b0e42084c 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -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/.yml` (GST-971), as long as a -job under that 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/.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 (` / `); 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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4d2cda4b4..7bf36bcc1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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/.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 ` / ` 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 diff --git a/lib/pregate/index.ts b/lib/pregate/index.ts index 9221b4ba9..6c25208a4 100644 --- a/lib/pregate/index.ts +++ b/lib/pregate/index.ts @@ -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 )` : ''}, ${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 )` : ''}, ${green}`); } return { receipt: `https://github.com/${repo}/actions/runs/${id}/attempts/${run.run_attempt}`, attested: false }; } diff --git a/lib/pregate/workflows.ts b/lib/pregate/workflows.ts index 59d9510f2..f6996bb60 100644 --- a/lib/pregate/workflows.ts +++ b/lib/pregate/workflows.ts @@ -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/`), 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 ` / `, 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/`), 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 ` / `, 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. */