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.

161 lines · 8.0 KBRaw History
1import { failure, type JobRunner, type Scope, type StepIO } from "../job.js";
2import type { Readable } from "node:stream";
3import type { StepExec } from "../steps/run.js";
4import type { ExecResult } from "../util.js";
5import { artifactDownload, artifactUpload } from "./artifacts.js";
6import { cacheAction, cacheRestoreAction, cacheSaveAction } from "./cache.js";
7import { checkout } from "./checkout.js";
8import { setupGo } from "./setup-go.js";
9import { setupNode } from "./setup-node.js";
10import { setupPython } from "./setup-python.js";
11
12export interface BuiltinContext {
13 job: JobRunner;
14 io: StepIO;
15 scope: Scope;
16 ctx: Record<string, unknown>;
17 envCtx: Record<string, string>;
18 uses: string;
19 name: string;
20 /** Raw `with:` values (already interpolated); lookups are case-insensitive. */
21 inputs: Record<string, string>;
22 input(name: string, dflt?: string): string;
23 log(msg: string): void;
24 warn(msg: string): void;
25 setOutput(name: string, value: string): void;
26 exportVariable(name: string, value: string): void;
27 addPath(dir: string): void;
28 /** Env for child tools (job env + GITHUB_ENV + PATH additions). */
29 toolEnv(extra?: Record<string, string>): NodeJS.ProcessEnv;
30 exec(cmd: string, args: string[], o?: { cwd?: string; env?: NodeJS.ProcessEnv; capture?: boolean; silent?: boolean; input?: string | Readable }): Promise<ExecResult>;
31 /** Like exec but throws on non-zero exit. */
32 run(cmd: string, args: string[], o?: { cwd?: string; env?: NodeJS.ProcessEnv; capture?: boolean; silent?: boolean; input?: string | Readable }): Promise<ExecResult>;
33 registerPost(name: string, condition: string, fn: (c: BuiltinContext) => Promise<void>): void;
34}
35
36export type Builtin = (c: BuiltinContext) => Promise<void>;
37
38interface BuiltinDef {
39 run: Builtin;
40 /** Major versions (`@vN`) the built-in mirrors; other refs (SHAs, branches, newer majors) run the real action. */
41 majors: [number, number];
42 /** The `with:` inputs it understands; anything else runs the real action. */
43 inputs: string[];
44 /** Further input values it cannot handle (the real action runs instead). */
45 supports?: (inputs: Map<string, string>) => boolean;
46}
47
48const hostArch = (v: string | undefined) => !v || v.toLowerCase() === (process.arch === "arm64" ? "arm64" : "x64");
49const CACHE_INPUTS = ["key", "path", "restore-keys", "upload-chunk-size", "enablecrossosarchive", "fail-on-cache-miss", "lookup-only", "save-always"];
50
51/**
52 * Actions the runner implements itself because that is much faster than
53 * downloading and starting them. Each mirrors the official action's inputs,
54 * outputs and storage (the same cache and artifact services), and steps
55 * aside for the real action when it sees a ref or input it does not mirror.
56 */
57const BUILTINS: Record<string, BuiltinDef> = {
58 "actions/checkout": {
59 run: checkout,
60 majors: [2, 6],
61 inputs: ["repository", "ref", "token", "ssh-key", "ssh-known-hosts", "ssh-strict", "ssh-user", "persist-credentials", "path", "clean", "filter", "sparse-checkout", "sparse-checkout-cone-mode", "fetch-depth", "fetch-tags", "show-progress", "lfs", "submodules", "set-safe-directory", "github-server-url"],
62 supports: (i) => !i.get("ssh-key"),
63 },
64 "actions/cache": { run: cacheAction, majors: [3, 6], inputs: CACHE_INPUTS },
65 "actions/cache/restore": { run: cacheRestoreAction, majors: [3, 6], inputs: CACHE_INPUTS },
66 "actions/cache/save": { run: cacheSaveAction, majors: [3, 6], inputs: CACHE_INPUTS },
67 "actions/upload-artifact": { run: artifactUpload, majors: [4, 7], inputs: ["name", "path", "if-no-files-found", "retention-days", "compression-level", "overwrite", "include-hidden-files", "archive"] },
68 "actions/download-artifact": { run: artifactDownload, majors: [4, 8], inputs: ["name", "path", "pattern", "merge-multiple", "github-token", "repository", "run-id", "artifact-ids", "skip-decompress", "digest-mismatch"] },
69 "actions/setup-node": {
70 run: setupNode,
71 majors: [3, 7],
72 inputs: ["node-version", "node-version-file", "architecture", "check-latest", "registry-url", "scope", "token", "cache", "cache-dependency-path", "always-auth", "package-manager-cache"],
73 supports: (i) => hostArch(i.get("architecture")) && ["", "npm", "pnpm", "yarn"].includes((i.get("cache") ?? "").toLowerCase()),
74 },
75 "actions/setup-python": {
76 run: setupPython,
77 majors: [4, 6],
78 inputs: ["python-version", "python-version-file", "cache", "architecture", "check-latest", "token", "cache-dependency-path", "update-environment", "allow-prereleases"],
79 supports: (i) =>
80 hostArch(i.get("architecture")) &&
81 !/^(pypy|graalpy)/i.test(i.get("python-version") ?? "") &&
82 ["", "pip", "pipenv"].includes((i.get("cache") ?? "").toLowerCase()) &&
83 (i.get("update-environment") ?? "true").toLowerCase() !== "false",
84 },
85 "actions/setup-go": { run: setupGo, majors: [4, 6], inputs: ["go-version", "go-version-file", "check-latest", "token", "cache", "cache-dependency-path", "architecture"], supports: (i) => hostArch(i.get("architecture")) },
86};
87
88const OFF = /^(0|false|no|off)$/i;
89
90/**
91 * The built-in for `name@ref` with these `with:` inputs, or null to run the
92 * real action. `DIFFINERY_BUILTINS: false` in a workflow, job or step `env:`
93 * (or DIFFINERY_DISABLE_BUILTINS=1 on the runner) always runs the real action.
94 */
95export function getBuiltin(name: string, ref: string, inputs: Record<string, string> = {}, env: Record<string, string> = {}): Builtin | null {
96 if (process.env.DIFFINERY_DISABLE_BUILTINS === "1" || OFF.test(env.DIFFINERY_BUILTINS ?? "")) return null;
97 const def = BUILTINS[name.toLowerCase()];
98 const m = /^v(\d+)(\.\d+){0,2}$/.exec(ref);
99 if (!def || !m || Number(m[1]) < def.majors[0] || Number(m[1]) > def.majors[1]) return null;
100 const given = new Map(Object.entries(inputs).map(([k, v]) => [k.toLowerCase(), v]));
101 if ([...given.keys()].some((k) => !def.inputs.includes(k))) return null;
102 if (def.supports && !def.supports(given)) return null;
103 return def.run;
104}
105
106export function makeBuiltinContext(job: JobRunner, x: StepExec, inputs: Record<string, string>, uses: string): BuiltinContext {
107 const lower = new Map(Object.entries(inputs).map(([k, v]) => [k.toLowerCase(), v]));
108 const make = (io: StepIO, envCtx: Record<string, string>, name: string): BuiltinContext => {
109 const toolEnv = (extra: Record<string, string> = {}) => job.processEnv({ ...envCtx, ...job.envAccum }, extra);
110 const c: BuiltinContext = {
111 job,
112 io,
113 scope: x.scope,
114 ctx: x.ctx,
115 envCtx,
116 uses,
117 name,
118 inputs,
119 input: (n, d = "") => {
120 const v = lower.get(n.toLowerCase());
121 return v === undefined || v === "" ? d : v;
122 },
123 log: (m) => job.logger.line(io.logIndex, m),
124 warn: (m) => {
125 job.logger.line(io.logIndex, `##[warning]${m}`);
126 job.addAnnotation(io.logIndex, { level: "warning", message: m });
127 },
128 setOutput: (k, v) => {
129 io.outputs[k] = v;
130 },
131 exportVariable: (k, v) => {
132 job.envAccum[k] = v;
133 envCtx[k] = v;
134 },
135 addPath: (d) => job.addPath(d),
136 toolEnv,
137 exec: (cmd, args, o = {}) => job.tool(cmd, args, { io, cwd: o.cwd, env: o.env ?? toolEnv(), capture: o.capture, silent: o.silent, input: o.input }),
138 run: async (cmd, args, o = {}) => {
139 const r = await c.exec(cmd, args, { ...o, capture: o.capture ?? true });
140 if (job.isStopping) throw failure("cancelled");
141 if (r.exitCode !== 0) {
142 const detail = o.silent ? ` ${r.stderr.trim().split("\n").slice(-5).join("\n")}` : "";
143 throw failure(`The process '${cmd}' failed with exit code ${r.exitCode}${detail ? ":" + detail : ""}`);
144 }
145 return r;
146 },
147 registerPost: (pname, condition, fn) => {
148 job.registerPost({
149 name: pname,
150 condition,
151 run: async (logIndex) => {
152 const pio: StepIO = { logIndex, outputs: {}, state: {} };
153 await fn(make(pio, { ...envCtx, ...job.envAccum }, pname));
154 },
155 });
156 },
157 };
158 return c;
159 };
160 return make(x.io, x.envCtx, x.name);
161}