| 1 | import { failure, type JobRunner, type Scope, type StepIO } from "../job.js"; |
| 2 | import type { Readable } from "node:stream"; |
| 3 | import type { StepExec } from "../steps/run.js"; |
| 4 | import type { ExecResult } from "../util.js"; |
| 5 | import { artifactDownload, artifactUpload } from "./artifacts.js"; |
| 6 | import { cacheAction, cacheRestoreAction, cacheSaveAction } from "./cache.js"; |
| 7 | import { checkout } from "./checkout.js"; |
| 8 | import { setupGo } from "./setup-go.js"; |
| 9 | import { setupNode } from "./setup-node.js"; |
| 10 | import { setupPython } from "./setup-python.js"; |
| 11 | |
| 12 | export 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 | |
| 36 | export type Builtin = (c: BuiltinContext) => Promise<void>; |
| 37 | |
| 38 | interface 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 | |
| 48 | const hostArch = (v: string | undefined) => !v || v.toLowerCase() === (process.arch === "arm64" ? "arm64" : "x64"); |
| 49 | const 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 | */ |
| 57 | const 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 | |
| 88 | const 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 | */ |
| 95 | export 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 | |
| 106 | export 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 | } |