| 1 | import type { Job, Workflow } from "@diffinery/workflow-yaml"; |
| 2 | |
| 3 | /** |
| 4 | * Reusable workflows (`jobs.<id>.uses`), GitHub-compatible. |
| 5 | * |
| 6 | * A run's workflow is flattened before it is planned: a caller job `deploy` that |
| 7 | * uses a workflow with jobs `build` and `ship` becomes the jobs `deploy/build` |
| 8 | * and `deploy/ship`. Inner `needs` are prefixed; inner jobs without `needs` |
| 9 | * wait for whatever the caller job needs; jobs that need `deploy` wait for every |
| 10 | * job inside it. Calls nest up to four levels deep, like on GitHub. |
| 11 | */ |
| 12 | |
| 13 | /** Where a workflow file came from: this repository (repoId null) or another one, at a commit. */ |
| 14 | export type Source = { repoId: string | null; sha: string }; |
| 15 | |
| 16 | export type CallSite = { |
| 17 | /** Full key of the caller job, e.g. "deploy" or "deploy/release". */ |
| 18 | key: string; |
| 19 | /** Enclosing call, or null for a top-level caller job. */ |
| 20 | parent: string | null; |
| 21 | /** The caller job: uses, with, secrets, if, name. */ |
| 22 | job: Job; |
| 23 | /** The called workflow. */ |
| 24 | workflow: Workflow; |
| 25 | /** The caller's own `needs`, as full keys in the parent scope. */ |
| 26 | needs: string[]; |
| 27 | }; |
| 28 | |
| 29 | export type Expanded = { |
| 30 | /** The run's workflow with only runnable jobs, keyed by full key, `needs` expanded for ordering. */ |
| 31 | workflow: Workflow; |
| 32 | calls: Map<string, CallSite>; |
| 33 | /** Full key → the call it belongs to (null at top level). Covers jobs and calls. */ |
| 34 | parentOf: Map<string, string | null>; |
| 35 | /** Declared `needs` per job or call, as full keys (a call stays a call). */ |
| 36 | needs: Map<string, string[]>; |
| 37 | /** The workflow each runnable job is defined in (env, defaults, permissions). */ |
| 38 | workflowOf: Map<string, Workflow>; |
| 39 | }; |
| 40 | |
| 41 | export type Loader = (uses: string, from: Source) => Promise<{ workflow: Workflow; source: Source }>; |
| 42 | |
| 43 | export const MAX_CALL_DEPTH = 4; |
| 44 | export const MAX_CALLED_WORKFLOWS = 20; |
| 45 | |
| 46 | export class ReusableError extends Error {} |
| 47 | |
| 48 | /** Short key inside the call that defines it ("deploy/build" → "build"). */ |
| 49 | export const localKey = (full: string, parent: string | null): string => (parent ? full.slice(parent.length + 1) : full); |
| 50 | |
| 51 | export async function expandReusable(root: Workflow, rootSource: Source, load: Loader): Promise<Expanded> { |
| 52 | const calls = new Map<string, CallSite>(); |
| 53 | const parentOf = new Map<string, string | null>(); |
| 54 | const needs = new Map<string, string[]>(); |
| 55 | const workflowOf = new Map<string, Workflow>(); |
| 56 | const leaves: Record<string, Job> = {}; |
| 57 | let called = 0; |
| 58 | |
| 59 | const walk = async (wf: Workflow, source: Source, prefix: string | null, depth: number, inherited: string[]): Promise<void> => { |
| 60 | for (const [key, job] of Object.entries(wf.jobs)) { |
| 61 | const full = prefix ? `${prefix}/${key}` : key; |
| 62 | const declared = job.needs.map((n) => (prefix ? `${prefix}/${n}` : n)); |
| 63 | const ordering = job.needs.length ? declared : inherited; |
| 64 | parentOf.set(full, prefix); |
| 65 | needs.set(full, declared); |
| 66 | if (!job.uses) { |
| 67 | leaves[full] = { ...job, key: full, needs: ordering }; |
| 68 | workflowOf.set(full, wf); |
| 69 | continue; |
| 70 | } |
| 71 | if (depth >= MAX_CALL_DEPTH) throw new ReusableError(`jobs.${full}: reusable workflows can be nested at most ${MAX_CALL_DEPTH} levels deep`); |
| 72 | if (++called > MAX_CALLED_WORKFLOWS) throw new ReusableError(`a run can call at most ${MAX_CALLED_WORKFLOWS} reusable workflows`); |
| 73 | if (job.strategy?.matrix !== undefined) throw new ReusableError(`jobs.${full}: a matrix on a job that calls a reusable workflow is not supported yet`); |
| 74 | let target: { workflow: Workflow; source: Source }; |
| 75 | try { |
| 76 | target = await load(job.uses, source); |
| 77 | } catch (e) { |
| 78 | throw new ReusableError(`jobs.${full}.uses: ${(e as Error).message}`); |
| 79 | } |
| 80 | if (!target.workflow.on.workflow_call) throw new ReusableError(`jobs.${full}.uses: ${job.uses} is not a reusable workflow (it has no 'on: workflow_call' trigger)`); |
| 81 | const declaredInputs = target.workflow.on.workflow_call.inputs ?? {}; |
| 82 | for (const k of Object.keys(job.with ?? {})) { |
| 83 | if (!(k in declaredInputs)) throw new ReusableError(`jobs.${full}.with.${k}: ${job.uses} has no input named '${k}'`); |
| 84 | } |
| 85 | calls.set(full, { key: full, parent: prefix, job, workflow: target.workflow, needs: declared }); |
| 86 | await walk(target.workflow, target.source, full, depth + 1, ordering); |
| 87 | } |
| 88 | }; |
| 89 | await walk(root, rootSource, null, 0, []); |
| 90 | |
| 91 | // A dependency on a call means a dependency on every runnable job inside it. |
| 92 | const under = (call: string) => Object.keys(leaves).filter((k) => k.startsWith(`${call}/`)); |
| 93 | for (const job of Object.values(leaves)) job.needs = [...new Set(job.needs.flatMap((n) => (calls.has(n) ? under(n) : [n])))]; |
| 94 | |
| 95 | return { workflow: { ...root, jobs: leaves }, calls, parentOf, needs, workflowOf }; |
| 96 | } |
| 97 | |
| 98 | /** Parse a `uses:` reference to a reusable workflow. */ |
| 99 | export function parseCallRef(uses: string): { kind: "local"; path: string } | { kind: "remote"; owner: string; repo: string; path: string; ref: string } { |
| 100 | const local = /^\.\/(.+\.ya?ml)$/.exec(uses); |
| 101 | if (local) return { kind: "local", path: local[1]! }; |
| 102 | const remote = /^([A-Za-z0-9][\w.-]*)\/([\w.-]+)\/(.+\.ya?ml)@(.+)$/.exec(uses); |
| 103 | if (remote) return { kind: "remote", owner: remote[1]!, repo: remote[2]!, path: remote[3]!, ref: remote[4]! }; |
| 104 | throw new Error(`'${uses}' must be ./.github/workflows/<file>.yml or <owner>/<repo>/.github/workflows/<file>.yml@<ref>`); |
| 105 | } |