Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

105 lines · 5.3 KBRaw History
1import 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. */
14export type Source = { repoId: string | null; sha: string };
15
16export 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
29export 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
41export type Loader = (uses: string, from: Source) => Promise<{ workflow: Workflow; source: Source }>;
42
43export const MAX_CALL_DEPTH = 4;
44export const MAX_CALLED_WORKFLOWS = 20;
45
46export class ReusableError extends Error {}
47
48/** Short key inside the call that defines it ("deploy/build" → "build"). */
49export const localKey = (full: string, parent: string | null): string => (parent ? full.slice(parent.length + 1) : full);
50
51export 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. */
99export 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}