From 8fac1b3522c3ec56e9d625334b0f223a5389d425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 10 Oct 2026 17:44:35 +0200 Subject: [PATCH 1/3] feat(gate): open local reusable workflows instead of refusing them A job that calls ./.github/workflows/.yml now contributes the called workflow's steps, gates, and machinery to its lane. Remote, missing, or nested reusable workflows still fail closed. --- scripts/gate/audit.test.ts | 2 +- scripts/gate/workflows.test.ts | 26 ++++++++++++++++ scripts/gate/workflows.ts | 56 +++++++++++++++++++++++++--------- 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/scripts/gate/audit.test.ts b/scripts/gate/audit.test.ts index 4d5339c29c..165ac791c7 100644 --- a/scripts/gate/audit.test.ts +++ b/scripts/gate/audit.test.ts @@ -58,7 +58,7 @@ test('raw shell text cannot declare ownership', () => { assert.ok(!audit(model).some((failure) => /not-a-real-check/.test(failure.message))); }); -test('reusable workflows fail closed because their action declarations are hidden', () => { +test('a reusable workflow the loader cannot open fails closed', () => { const model = plant(`name: Planted on: pull_request: diff --git a/scripts/gate/workflows.test.ts b/scripts/gate/workflows.test.ts index 2ac09ef9f4..ea8bc70875 100644 --- a/scripts/gate/workflows.test.ts +++ b/scripts/gate/workflows.test.ts @@ -194,3 +194,29 @@ jobs: 'a gate five levels down is still credited to the lane', ); }); + +test('a local reusable workflow is opened, so its gates belong to the calling lane', () => { + const lanes = planted({ + '.github/workflows/planted.yml': `name: Planted +on: + pull_request: +jobs: + planted: + uses: ./.github/workflows/called.yml +`, + '.github/workflows/called.yml': `name: Called +on: + workflow_call: +jobs: + called: + steps: + - uses: ./.github/actions/run-gate + with: + gate: layering +`, + }); + const caller = lanes.find((lane) => lane.workflow === 'planted.yml'); + assert.deepEqual(caller?.gates, ['layering']); + assert.deepEqual(caller?.unsupported, []); + assert.ok(caller?.uses.includes('.github/workflows/called.yml')); +}); diff --git a/scripts/gate/workflows.ts b/scripts/gate/workflows.ts index 5c04b9c632..e9de7d2d03 100644 --- a/scripts/gate/workflows.ts +++ b/scripts/gate/workflows.ts @@ -151,20 +151,48 @@ function workflowLanes( >; const qualifying = 'pull_request' in on || 'schedule' in on; const { paths, pathsIgnore } = triggerPaths(on); - return Object.entries(doc.jobs ?? {}).map(([jobId, job]) => ({ - workflow: file, - label: laneLabel(doc.name ?? file, job.name ?? jobId), - qualifying, - triggers: Object.keys(on), - gates: [...new Set(declaredGates(job.steps ?? [], root))], - uses: [...new Set([`.github/workflows/${file}`, ...localActionFiles(job.steps ?? [], root)])], - verbatim: (job.steps ?? []).flatMap((step) => - typeof step.run === 'string' ? verbatimScripts(step.run, scripts) : [], - ), - paths, - pathsIgnore, - unsupported: job.uses ? [`\`uses: ${job.uses}\` (reusable workflow)`] : [], - })); + return Object.entries(doc.jobs ?? {}).map(([jobId, job]) => { + const { steps, files, unsupported } = jobSteps(job, root); + return { + workflow: file, + label: laneLabel(doc.name ?? file, job.name ?? jobId), + qualifying, + triggers: Object.keys(on), + gates: [...new Set(declaredGates(steps, root))], + uses: [...new Set([`.github/workflows/${file}`, ...files, ...localActionFiles(steps, root)])], + verbatim: steps.flatMap((step) => + typeof step.run === 'string' ? verbatimScripts(step.run, scripts) : [], + ), + paths, + pathsIgnore, + unsupported, + }; + }); +} + +/** The jobs of a reusable workflow in this repository, or null when it lives elsewhere. */ +function readLocalWorkflowJobs(uses: string, root: string): Job[] | null { + if (!uses.startsWith('./.github/workflows/')) return null; + const file = path.join(root, uses.slice(2)); + if (!fs.existsSync(file)) return null; + return Object.values((parse(fs.readFileSync(file, 'utf8')) as WorkflowDoc).jobs ?? {}); +} + +/** + * A job's steps, opening a local reusable workflow so the gates it runs stay visible. A remote, + * missing, or nested reusable workflow fails closed as unsupported. + */ +function jobSteps( + job: Job, + root: string, +): { steps: RawStep[]; files: string[]; unsupported: string[] } { + if (!job.uses) return { steps: job.steps ?? [], files: [], unsupported: [] }; + const called = readLocalWorkflowJobs(job.uses, root); + if (!called || called.some((calledJob) => calledJob.uses)) { + return { steps: [], files: [], unsupported: [`\`uses: ${job.uses}\` (reusable workflow)`] }; + } + const steps = called.flatMap((calledJob) => calledJob.steps ?? []); + return { steps, files: [job.uses.slice(2)], unsupported: [] }; } export function loadLanes( From d7b59070d115429df2bac379bf099524065ea637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 10 Oct 2026 19:03:46 +0200 Subject: [PATCH 2/3] test(gate): pin that remote and nested reusable workflows fail closed --- scripts/gate/workflows.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/scripts/gate/workflows.test.ts b/scripts/gate/workflows.test.ts index ea8bc70875..6424c409f4 100644 --- a/scripts/gate/workflows.test.ts +++ b/scripts/gate/workflows.test.ts @@ -220,3 +220,29 @@ jobs: assert.deepEqual(caller?.unsupported, []); assert.ok(caller?.uses.includes('.github/workflows/called.yml')); }); + +test('a remote or nested reusable workflow fails closed', () => { + const lanes = planted({ + '.github/workflows/planted.yml': `name: Planted +on: + pull_request: +jobs: + remote: + uses: octo/repo/.github/workflows/called.yml@main + nested: + uses: ./.github/workflows/outer.yml +`, + '.github/workflows/outer.yml': `name: Outer +on: + workflow_call: +jobs: + inner: + uses: ./.github/workflows/inner.yml +`, + }); + for (const job of ['remote', 'nested']) { + const lane = lanes.find((candidate) => candidate.label === `Planted / ${job}`); + assert.equal(lane?.unsupported.length, 1, `${job} must stay unsupported`); + assert.deepEqual(lane?.gates, []); + } +}); From 6308cbf1be8aa73d60ed78ec764904058f160584 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 11 Oct 2026 08:42:16 +0200 Subject: [PATCH 3/3] fix(gate): resolve a reusable workflow's gate inputs and refuse a jobless one A callee's run-gate step that reads ${{ inputs.gate }} now resolves against the caller's with, and a local workflow file that declares no jobs fails closed like a missing one instead of opening as an empty lane. --- scripts/gate/workflows.test.ts | 45 ++++++++++++++++++++++++++++++++++ scripts/gate/workflows.ts | 33 +++++++++++++++---------- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/scripts/gate/workflows.test.ts b/scripts/gate/workflows.test.ts index 6424c409f4..a73f6b207a 100644 --- a/scripts/gate/workflows.test.ts +++ b/scripts/gate/workflows.test.ts @@ -246,3 +246,48 @@ jobs: assert.deepEqual(lane?.gates, []); } }); + +test("a reusable workflow's gate input resolves from the caller's with", () => { + const lanes = planted({ + '.github/workflows/planted.yml': `name: Planted +on: + pull_request: +jobs: + planted: + uses: ./.github/workflows/called.yml + with: + gate: layering +`, + '.github/workflows/called.yml': `name: Called +on: + workflow_call: + inputs: + gate: + type: string +jobs: + called: + steps: + - uses: ./.github/actions/run-gate + with: + gate: \${{ inputs.gate }} +`, + }); + assert.deepEqual(lanes.find((lane) => lane.workflow === 'planted.yml')?.gates, ['layering']); +}); + +test('a reusable workflow with no jobs fails closed', () => { + const lanes = planted({ + '.github/workflows/planted.yml': `name: Planted +on: + pull_request: +jobs: + planted: + uses: ./.github/workflows/empty.yml +`, + '.github/workflows/empty.yml': `name: Empty +on: + workflow_call: +`, + }); + assert.equal(lanes.find((lane) => lane.workflow === 'planted.yml')?.unsupported.length, 1); +}); diff --git a/scripts/gate/workflows.ts b/scripts/gate/workflows.ts index e9de7d2d03..1672a85430 100644 --- a/scripts/gate/workflows.ts +++ b/scripts/gate/workflows.ts @@ -38,7 +38,7 @@ type RawStep = { with?: Record; }; -type Job = { name?: string; steps?: RawStep[]; uses?: string }; +type Job = { name?: string; steps?: RawStep[]; uses?: string; with?: Record }; type WorkflowDoc = { name?: string; on?: Record; @@ -152,13 +152,13 @@ function workflowLanes( const qualifying = 'pull_request' in on || 'schedule' in on; const { paths, pathsIgnore } = triggerPaths(on); return Object.entries(doc.jobs ?? {}).map(([jobId, job]) => { - const { steps, files, unsupported } = jobSteps(job, root); + const { steps, inputs, files, unsupported } = jobSteps(job, root); return { workflow: file, label: laneLabel(doc.name ?? file, job.name ?? jobId), qualifying, triggers: Object.keys(on), - gates: [...new Set(declaredGates(steps, root))], + gates: [...new Set(declaredGates(steps, root, [], inputs))], uses: [...new Set([`.github/workflows/${file}`, ...files, ...localActionFiles(steps, root)])], verbatim: steps.flatMap((step) => typeof step.run === 'string' ? verbatimScripts(step.run, scripts) : [], @@ -170,29 +170,36 @@ function workflowLanes( }); } -/** The jobs of a reusable workflow in this repository, or null when it lives elsewhere. */ +/** The jobs of a reusable workflow in this repository, or null when it is elsewhere or jobless. */ function readLocalWorkflowJobs(uses: string, root: string): Job[] | null { if (!uses.startsWith('./.github/workflows/')) return null; const file = path.join(root, uses.slice(2)); if (!fs.existsSync(file)) return null; - return Object.values((parse(fs.readFileSync(file, 'utf8')) as WorkflowDoc).jobs ?? {}); + const jobs = Object.values((parse(fs.readFileSync(file, 'utf8')) as WorkflowDoc).jobs ?? {}); + return jobs.length > 0 ? jobs : null; } +/** What a job runs, with the caller's `with` inputs that the steps' `${{ inputs.* }}` resolve to. */ +type JobSteps = { + steps: RawStep[]; + inputs: Readonly>; + files: string[]; + unsupported: string[]; +}; + /** * A job's steps, opening a local reusable workflow so the gates it runs stay visible. A remote, - * missing, or nested reusable workflow fails closed as unsupported. + * missing, jobless, or nested reusable workflow fails closed as unsupported. */ -function jobSteps( - job: Job, - root: string, -): { steps: RawStep[]; files: string[]; unsupported: string[] } { - if (!job.uses) return { steps: job.steps ?? [], files: [], unsupported: [] }; +function jobSteps(job: Job, root: string): JobSteps { + if (!job.uses) return { steps: job.steps ?? [], inputs: {}, files: [], unsupported: [] }; const called = readLocalWorkflowJobs(job.uses, root); if (!called || called.some((calledJob) => calledJob.uses)) { - return { steps: [], files: [], unsupported: [`\`uses: ${job.uses}\` (reusable workflow)`] }; + const unsupported = [`\`uses: ${job.uses}\` (reusable workflow)`]; + return { steps: [], inputs: {}, files: [], unsupported }; } const steps = called.flatMap((calledJob) => calledJob.steps ?? []); - return { steps, files: [job.uses.slice(2)], unsupported: [] }; + return { steps, inputs: job.with ?? {}, files: [job.uses.slice(2)], unsupported: [] }; } export function loadLanes(