Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/gate/audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
97 changes: 97 additions & 0 deletions scripts/gate/workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
65 changes: 50 additions & 15 deletions scripts/gate/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ type RawStep = {
with?: Record<string, unknown>;
};

type Job = { name?: string; steps?: RawStep[]; uses?: string };
type Job = { name?: string; steps?: RawStep[]; uses?: string; with?: Record<string, unknown> };
type WorkflowDoc = {
name?: string;
on?: Record<string, never>;
Expand Down Expand Up @@ -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<Record<string, unknown>>;
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 ?? []);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
return { steps, inputs: job.with ?? {}, files: [job.uses.slice(2)], unsupported: [] };
}

export function loadLanes(
Expand Down
Loading