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..a73f6b207a 100644 --- a/scripts/gate/workflows.test.ts +++ b/scripts/gate/workflows.test.ts @@ -194,3 +194,100 @@ 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')); +}); + +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, []); + } +}); + +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 5c04b9c632..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; @@ -151,20 +151,55 @@ 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, 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, [], inputs))], + 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 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; + 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, jobless, or nested reusable workflow fails closed as 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)) { + const unsupported = [`\`uses: ${job.uses}\` (reusable workflow)`]; + return { steps: [], inputs: {}, files: [], unsupported }; + } + const steps = called.flatMap((calledJob) => calledJob.steps ?? []); + return { steps, inputs: job.with ?? {}, files: [job.uses.slice(2)], unsupported: [] }; } export function loadLanes(