WIP: Worker core (repo Durable Object, Git protocol, PRs, runs, auth)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
44 files changed+8687−9
Files (44)
- apps/diffinery/migrations/0001_init.sql+119−0
- apps/diffinery/src/auth/principal.ts+116−0
- apps/diffinery/src/env.ts+31−0
- apps/diffinery/src/git/protocol.ts+214−0
- apps/diffinery/src/git/secret-scan.ts+30−0
- apps/diffinery/src/lib/crypto.ts+34−0
- apps/diffinery/src/lib/directory.ts+403−0
- apps/diffinery/src/lib/jwt.ts+50−0
- apps/diffinery/src/lib/util.ts+71−0
- apps/diffinery/src/repo/RepoDO.ts+723−0
- apps/diffinery/src/repo/actions.ts+1003−0
- apps/diffinery/src/repo/collab.ts+664−0
- apps/diffinery/src/repo/objects.ts+97−0
- apps/diffinery/src/repo/schema.ts+66−0
- apps/diffinery/src/repo/types.ts+51−0
- apps/diffinery/src/runner/containers.ts+77−0
- apps/diffinery/src/runner/workflow.ts+48−0
- containers/runner/package.json+1−1
- containers/runner/src/agent.ts+65−0
- containers/runner/src/api.ts+2−2
- containers/runner/src/builtins/artifacts.ts+96−0
- containers/runner/src/builtins/cache.ts+130−0
- containers/runner/src/builtins/checkout.ts+207−0
- containers/runner/src/builtins/index.ts+114−0
- containers/runner/src/builtins/setup-go.ts+73−0
- containers/runner/src/builtins/setup-node.ts+125−0
- containers/runner/src/builtins/setup-python.ts+101−0
- containers/runner/src/builtins/tools.ts+148−0
- containers/runner/src/job.ts+678−0
- containers/runner/src/main.ts+106−0
- containers/runner/src/sandbox.ts+217−0
- containers/runner/src/steps/actions.ts+333−0
- containers/runner/src/steps/run.ts+114−0
- containers/runner/test/helpers.ts+157−0
- containers/runner/test/job.test.ts+522−0
- containers/runner/test/unit.test.ts+228−0
- packages/workflow-yaml/src/index.ts+50−0
- packages/workflow-yaml/src/matrix.ts+2−2
- packages/workflow-yaml/src/parse.ts+4−4
- packages/workflow-yaml/test/cron.test.ts+82−0
- packages/workflow-yaml/test/expr.test.ts+356−0
- packages/workflow-yaml/test/filters.test.ts+230−0
- packages/workflow-yaml/test/matrix.test.ts+149−0
- packages/workflow-yaml/test/parse.test.ts+600−0
addedapps/diffinery/migrations/0001_init.sql+119−0View file
| @@ -1,0 +1,119 @@ | ||
| 1 | +-- Global directory: accounts (users, orgs, agents), repos, access, tokens, invites. | |
| 2 | +CREATE TABLE accounts ( | |
| 3 | + id TEXT PRIMARY KEY, | |
| 4 | + login TEXT NOT NULL UNIQUE COLLATE NOCASE, | |
| 5 | + kind TEXT NOT NULL CHECK (kind IN ('user', 'org', 'agent')), | |
| 6 | + name TEXT, | |
| 7 | + email TEXT UNIQUE COLLATE NOCASE, | |
| 8 | + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'pending', 'disabled')), | |
| 9 | + site_admin INTEGER NOT NULL DEFAULT 0, | |
| 10 | + owner_id TEXT REFERENCES accounts(id), | |
| 11 | + agent_policy TEXT, | |
| 12 | + created_at TEXT NOT NULL | |
| 13 | +); | |
| 14 | +CREATE INDEX accounts_owner ON accounts(owner_id); | |
| 15 | + | |
| 16 | +CREATE TABLE org_members ( | |
| 17 | + org_id TEXT NOT NULL REFERENCES accounts(id), | |
| 18 | + user_id TEXT NOT NULL REFERENCES accounts(id), | |
| 19 | + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), | |
| 20 | + created_at TEXT NOT NULL, | |
| 21 | + PRIMARY KEY (org_id, user_id) | |
| 22 | +); | |
| 23 | +CREATE INDEX org_members_user ON org_members(user_id); | |
| 24 | + | |
| 25 | +CREATE TABLE repos ( | |
| 26 | + id TEXT PRIMARY KEY, | |
| 27 | + owner_id TEXT NOT NULL REFERENCES accounts(id), | |
| 28 | + name TEXT NOT NULL COLLATE NOCASE, | |
| 29 | + description TEXT NOT NULL DEFAULT '', | |
| 30 | + visibility TEXT NOT NULL DEFAULT 'private' CHECK (visibility IN ('private', 'internal', 'public')), | |
| 31 | + default_branch TEXT NOT NULL DEFAULT 'main', | |
| 32 | + archived INTEGER NOT NULL DEFAULT 0, | |
| 33 | + created_at TEXT NOT NULL, | |
| 34 | + updated_at TEXT NOT NULL, | |
| 35 | + pushed_at TEXT, | |
| 36 | + UNIQUE (owner_id, name) | |
| 37 | +); | |
| 38 | + | |
| 39 | +CREATE TABLE repo_access ( | |
| 40 | + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 41 | + account_id TEXT NOT NULL REFERENCES accounts(id), | |
| 42 | + role TEXT NOT NULL CHECK (role IN ('read', 'triage', 'write', 'maintain', 'admin')), | |
| 43 | + PRIMARY KEY (repo_id, account_id) | |
| 44 | +); | |
| 45 | +CREATE INDEX repo_access_account ON repo_access(account_id); | |
| 46 | + | |
| 47 | +CREATE TABLE tokens ( | |
| 48 | + id TEXT PRIMARY KEY, | |
| 49 | + account_id TEXT NOT NULL REFERENCES accounts(id), | |
| 50 | + name TEXT NOT NULL, | |
| 51 | + token_hash TEXT NOT NULL UNIQUE, | |
| 52 | + prefix TEXT NOT NULL, | |
| 53 | + scopes TEXT NOT NULL, | |
| 54 | + repo_ids TEXT, | |
| 55 | + expires_at TEXT NOT NULL, | |
| 56 | + last_used_at TEXT, | |
| 57 | + created_at TEXT NOT NULL, | |
| 58 | + revoked_at TEXT | |
| 59 | +); | |
| 60 | +CREATE INDEX tokens_account ON tokens(account_id); | |
| 61 | + | |
| 62 | +CREATE TABLE invites ( | |
| 63 | + id TEXT PRIMARY KEY, | |
| 64 | + org_id TEXT REFERENCES accounts(id), | |
| 65 | + repo_id TEXT REFERENCES repos(id), | |
| 66 | + email TEXT COLLATE NOCASE, | |
| 67 | + role TEXT NOT NULL, | |
| 68 | + token_hash TEXT NOT NULL UNIQUE, | |
| 69 | + invited_by TEXT NOT NULL REFERENCES accounts(id), | |
| 70 | + created_at TEXT NOT NULL, | |
| 71 | + expires_at TEXT NOT NULL, | |
| 72 | + accepted_at TEXT, | |
| 73 | + accepted_by TEXT | |
| 74 | +); | |
| 75 | + | |
| 76 | +CREATE TABLE notifications ( | |
| 77 | + id TEXT PRIMARY KEY, | |
| 78 | + account_id TEXT NOT NULL REFERENCES accounts(id), | |
| 79 | + repo_id TEXT, | |
| 80 | + kind TEXT NOT NULL, | |
| 81 | + title TEXT NOT NULL, | |
| 82 | + url TEXT NOT NULL, | |
| 83 | + created_at TEXT NOT NULL, | |
| 84 | + read_at TEXT | |
| 85 | +); | |
| 86 | +CREATE INDEX notifications_account ON notifications(account_id, read_at); | |
| 87 | + | |
| 88 | +CREATE TABLE audit_log ( | |
| 89 | + id TEXT PRIMARY KEY, | |
| 90 | + at TEXT NOT NULL, | |
| 91 | + actor_id TEXT, | |
| 92 | + actor_login TEXT, | |
| 93 | + on_behalf_of TEXT, | |
| 94 | + action TEXT NOT NULL, | |
| 95 | + target TEXT, | |
| 96 | + detail TEXT | |
| 97 | +); | |
| 98 | +CREATE INDEX audit_at ON audit_log(at); | |
| 99 | + | |
| 100 | +CREATE TABLE schedules ( | |
| 101 | + repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 102 | + workflow_path TEXT NOT NULL, | |
| 103 | + cron TEXT NOT NULL, | |
| 104 | + PRIMARY KEY (repo_id, workflow_path, cron) | |
| 105 | +); | |
| 106 | + | |
| 107 | +CREATE TABLE org_secrets ( | |
| 108 | + org_id TEXT NOT NULL REFERENCES accounts(id), | |
| 109 | + name TEXT NOT NULL, | |
| 110 | + ciphertext TEXT NOT NULL, | |
| 111 | + updated_at TEXT NOT NULL, | |
| 112 | + PRIMARY KEY (org_id, name) | |
| 113 | +); | |
| 114 | + | |
| 115 | +CREATE TABLE access_requests ( | |
| 116 | + account_id TEXT PRIMARY KEY REFERENCES accounts(id), | |
| 117 | + note TEXT, | |
| 118 | + created_at TEXT NOT NULL | |
| 119 | +); |
addedapps/diffinery/src/auth/principal.ts+116−0View file
| @@ -1,0 +1,116 @@ | ||
| 1 | +import type { Context } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import { Directory, type Account, type Repo, type TokenScope } from "../lib/directory.js"; | |
| 4 | +import { verifyAccessJwt } from "../lib/jwt.js"; | |
| 5 | +import { HttpError, fromB64 } from "../lib/util.js"; | |
| 6 | +import type { Caller, Role } from "../repo/types.js"; | |
| 7 | +import { ROLE_LEVEL } from "../repo/types.js"; | |
| 8 | + | |
| 9 | +export type Principal = | |
| 10 | + | { type: "account"; account: Account; via: "access" | "token" | "dev"; scopes: TokenScope[]; repoIds: string[] | null } | |
| 11 | + | { type: "repo-token"; repoId: string; token: string } | |
| 12 | + | { type: "anonymous" }; | |
| 13 | + | |
| 14 | +export interface AppVars { | |
| 15 | + principal: Principal; | |
| 16 | + dir: Directory; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export type AppContext = Context<{ Bindings: Env; Variables: AppVars }>; | |
| 20 | + | |
| 21 | +/** Extract a bearer/basic credential, as git, curl, the CLI and MCP clients send it. */ | |
| 22 | +export function credentialFrom(req: Request): string | null { | |
| 23 | + const h = req.headers.get("authorization"); | |
| 24 | + if (!h) return null; | |
| 25 | + const [scheme, value] = h.split(" ", 2); | |
| 26 | + if (!value) return null; | |
| 27 | + if (/^bearer$/i.test(scheme!) || /^token$/i.test(scheme!)) return value.trim(); | |
| 28 | + if (/^basic$/i.test(scheme!)) { | |
| 29 | + const decoded = new TextDecoder().decode(fromB64(value.trim())); | |
| 30 | + const idx = decoded.indexOf(":"); | |
| 31 | + const pass = idx >= 0 ? decoded.slice(idx + 1) : decoded; | |
| 32 | + const user = idx >= 0 ? decoded.slice(0, idx) : ""; | |
| 33 | + return pass || user || null; | |
| 34 | + } | |
| 35 | + return null; | |
| 36 | +} | |
| 37 | + | |
| 38 | +function accessToken(req: Request): string | null { | |
| 39 | + const h = req.headers.get("cf-access-jwt-assertion"); | |
| 40 | + if (h) return h; | |
| 41 | + const cookie = req.headers.get("cookie") ?? ""; | |
| 42 | + const m = /(?:^|;\s*)CF_Authorization=([^;]+)/.exec(cookie); | |
| 43 | + return m ? m[1]! : null; | |
| 44 | +} | |
| 45 | + | |
| 46 | +export async function resolvePrincipal(req: Request, env: Env, dir: Directory): Promise<Principal> { | |
| 47 | + const cred = credentialFrom(req); | |
| 48 | + if (cred) { | |
| 49 | + if (cred.startsWith("dfy_")) { | |
| 50 | + const v = await dir.verifyToken(cred); | |
| 51 | + if (!v) throw new HttpError(401, "invalid, expired or revoked token"); | |
| 52 | + return { type: "account", account: v.account, via: "token", scopes: v.scopes, repoIds: v.repoIds }; | |
| 53 | + } | |
| 54 | + if (cred.startsWith("djt_") || cred.startsWith("dst_")) { | |
| 55 | + const repoId = cred.split("_")[1]; | |
| 56 | + if (!repoId) throw new HttpError(401, "malformed token"); | |
| 57 | + return { type: "repo-token", repoId, token: cred }; | |
| 58 | + } | |
| 59 | + throw new HttpError(401, "unrecognised token; create one under Settings → Tokens"); | |
| 60 | + } | |
| 61 | + const jwt = accessToken(req); | |
| 62 | + if (jwt && env.ACCESS_TEAM_DOMAIN && env.ACCESS_AUD) { | |
| 63 | + const claims = await verifyAccessJwt(jwt, env.ACCESS_TEAM_DOMAIN, env.ACCESS_AUD).catch((e) => { | |
| 64 | + throw new HttpError(401, `Access token rejected: ${(e as Error).message}`); | |
| 65 | + }); | |
| 66 | + const email = typeof claims.email === "string" ? claims.email : null; | |
| 67 | + if (!email) throw new HttpError(401, "Access token has no email; service tokens must use a Diffinery token"); | |
| 68 | + const account = await dir.userForEmail(email, { name: typeof claims.name === "string" ? claims.name : undefined }); | |
| 69 | + return { type: "account", account, via: "access", scopes: ["admin"], repoIds: null }; | |
| 70 | + } | |
| 71 | + if (env.DEV_AUTH && !env.ACCESS_AUD) { | |
| 72 | + const account = await dir.userForEmail(env.DEV_AUTH); | |
| 73 | + return { type: "account", account, via: "dev", scopes: ["admin"], repoIds: null }; | |
| 74 | + } | |
| 75 | + return { type: "anonymous" }; | |
| 76 | +} | |
| 77 | + | |
| 78 | +export function requireAccount(c: AppContext): Account { | |
| 79 | + const p = c.get("principal"); | |
| 80 | + if (p.type !== "account") throw new HttpError(401, "sign in required"); | |
| 81 | + if (p.account.status === "pending") throw new HttpError(403, "your account is waiting for an invite"); | |
| 82 | + if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled"); | |
| 83 | + return p.account; | |
| 84 | +} | |
| 85 | + | |
| 86 | +const SCOPE_CAP: Record<TokenScope, Role> = { read: "read", write: "maintain", admin: "admin" }; | |
| 87 | + | |
| 88 | +/** | |
| 89 | + * Resolve the caller's effective role on a repository, combining account | |
| 90 | + * permissions with token scopes and repo restrictions. Returns null for no access. | |
| 91 | + */ | |
| 92 | +export async function callerFor(c: AppContext, repo: Repo): Promise<Caller | null> { | |
| 93 | + const p = c.get("principal"); | |
| 94 | + const dir = c.get("dir"); | |
| 95 | + if (p.type === "repo-token") { | |
| 96 | + if (p.repoId !== repo.id) return null; | |
| 97 | + const stub = c.env.REPO.get(c.env.REPO.idFromName(repo.id)); | |
| 98 | + const auth = await stub.runnerAuth(p.token); | |
| 99 | + if (!auth) return null; | |
| 100 | + if (auth.kind === "job") return { id: `job:${auth.jobId}`, login: "diffinery-actions[bot]", kind: "system", role: "read" }; | |
| 101 | + const cap: Role = ROLE_LEVEL[auth.caller.role] > ROLE_LEVEL.write ? "write" : auth.caller.role; | |
| 102 | + return { ...auth.caller, role: cap }; | |
| 103 | + } | |
| 104 | + if (p.type !== "account") { | |
| 105 | + return repo.visibility === "public" ? { id: "anonymous", login: "anonymous", kind: "user", role: "read" } : null; | |
| 106 | + } | |
| 107 | + if (p.repoIds && !p.repoIds.includes(repo.id)) return null; | |
| 108 | + let role = await dir.roleFor(p.account, repo); | |
| 109 | + if (!role) return null; | |
| 110 | + const cap = p.scopes.reduce<Role>((best, s) => (ROLE_LEVEL[SCOPE_CAP[s]] > ROLE_LEVEL[best] ? SCOPE_CAP[s] : best), "read"); | |
| 111 | + if (ROLE_LEVEL[role] > ROLE_LEVEL[cap]) role = cap; | |
| 112 | + const a = p.account; | |
| 113 | + let onBehalfOf: string | undefined; | |
| 114 | + if (a.kind === "agent" && a.owner_id) onBehalfOf = (await dir.account(a.owner_id))?.login; | |
| 115 | + return { id: a.id, login: a.login, kind: a.kind === "agent" ? "agent" : "user", onBehalfOf, role }; | |
| 116 | +} |
addedapps/diffinery/src/env.ts+31−0View file
| @@ -1,0 +1,31 @@ | ||
| 1 | +import type { DiffineryEvent } from "@diffinery/shared"; | |
| 2 | +import type { RepoDO } from "./repo/RepoDO.js"; | |
| 3 | +import type { RunnerContainer, SandboxContainer } from "./runner/containers.js"; | |
| 4 | + | |
| 5 | +export interface Env { | |
| 6 | + DB: D1Database; | |
| 7 | + BUCKET: R2Bucket; | |
| 8 | + EVENTS: Queue<DiffineryEvent>; | |
| 9 | + REPO: DurableObjectNamespace<RepoDO>; | |
| 10 | + RUNNER: DurableObjectNamespace<RunnerContainer>; | |
| 11 | + SANDBOX: DurableObjectNamespace<SandboxContainer>; | |
| 12 | + RUN_WORKFLOW: Workflow<{ repoId: string; runId: string }>; | |
| 13 | + | |
| 14 | + /** Canonical origin, e.g. https://diffinery.com */ | |
| 15 | + PUBLIC_URL: string; | |
| 16 | + /** Comma-separated emails or logins that become site admins on first sign-in. */ | |
| 17 | + BOOTSTRAP_ADMINS: string; | |
| 18 | + /** e.g. codician.cloudflareaccess.com — empty disables Access verification. */ | |
| 19 | + ACCESS_TEAM_DOMAIN: string; | |
| 20 | + /** Access application audience tag. */ | |
| 21 | + ACCESS_AUD: string; | |
| 22 | + /** Local development only: act as this email when Access is not configured. */ | |
| 23 | + DEV_AUTH?: string; | |
| 24 | + | |
| 25 | + /** Secret: base64 32 bytes; root key for envelope encryption of secrets. */ | |
| 26 | + MASTER_KEY?: string; | |
| 27 | + /** Secret: ES256 private JWK (JSON) used to sign OIDC tokens for jobs. */ | |
| 28 | + OIDC_PRIVATE_KEY?: string; | |
| 29 | + /** Secret: optional GitHub token used by the importer. */ | |
| 30 | + GITHUB_IMPORT_TOKEN?: string; | |
| 31 | +} |
addedapps/diffinery/src/git/protocol.ts+214−0View file
| @@ -1,0 +1,214 @@ | ||
| 1 | +import { ByteWriter, FLUSH, DELIM, ZERO_OID, fromUtf8, objectsForFetch, parseTag, pkt, pktText, readPkts, sideband, writePack, utf8, type ObjectReader } from "@diffinery/git-core"; | |
| 2 | +import type { RawObject } from "../repo/objects.js"; | |
| 3 | + | |
| 4 | +export const AGENT = "diffinery/0.1"; | |
| 5 | + | |
| 6 | +export interface RefEntry { | |
| 7 | + name: string; | |
| 8 | + oid: string; | |
| 9 | +} | |
| 10 | + | |
| 11 | +export interface UploadContext { | |
| 12 | + store: ObjectReader & { readRaw(oid: string): Promise<RawObject | null>; has(oid: string): boolean }; | |
| 13 | + refs: RefEntry[]; | |
| 14 | + /** e.g. refs/heads/main — HEAD points here even when the branch does not exist yet. */ | |
| 15 | + headTarget: string; | |
| 16 | +} | |
| 17 | + | |
| 18 | +// ------------------------------------------------------------------ advertisements | |
| 19 | + | |
| 20 | +export function advertiseUploadPackV2(): Uint8Array { | |
| 21 | + const w = new ByteWriter(); | |
| 22 | + w.write(pkt("version 2\n")); | |
| 23 | + w.write(pkt(`agent=${AGENT}\n`)); | |
| 24 | + w.write(pkt("ls-refs=unborn\n")); | |
| 25 | + w.write(pkt("fetch=wait-for-done\n")); | |
| 26 | + w.write(pkt("server-option\n")); | |
| 27 | + w.write(pkt("object-format=sha1\n")); | |
| 28 | + w.write(FLUSH); | |
| 29 | + return w.bytes(); | |
| 30 | +} | |
| 31 | + | |
| 32 | +export function advertiseReceivePack(refs: RefEntry[]): Uint8Array { | |
| 33 | + const w = new ByteWriter(); | |
| 34 | + w.write(pkt("# service=git-receive-pack\n")); | |
| 35 | + w.write(FLUSH); | |
| 36 | + const caps = `report-status report-status-v2 delete-refs side-band-64k quiet atomic ofs-delta push-options object-format=sha1 agent=${AGENT}`; | |
| 37 | + if (refs.length === 0) { | |
| 38 | + w.write(pkt(`${ZERO_OID} capabilities^{}\0${caps}\n`)); | |
| 39 | + } else { | |
| 40 | + refs.forEach((r, i) => w.write(pkt(i === 0 ? `${r.oid} ${r.name}\0${caps}\n` : `${r.oid} ${r.name}\n`))); | |
| 41 | + } | |
| 42 | + w.write(FLUSH); | |
| 43 | + return w.bytes(); | |
| 44 | +} | |
| 45 | + | |
| 46 | +// ------------------------------------------------------------------ protocol v2: upload-pack | |
| 47 | + | |
| 48 | +interface V2Request { | |
| 49 | + command: string; | |
| 50 | + capabilities: string[]; | |
| 51 | + args: string[]; | |
| 52 | +} | |
| 53 | + | |
| 54 | +function parseV2(body: Uint8Array): V2Request { | |
| 55 | + const { pkts } = readPkts(body); | |
| 56 | + const req: V2Request = { command: "", capabilities: [], args: [] }; | |
| 57 | + let section: "caps" | "args" = "caps"; | |
| 58 | + for (const p of pkts) { | |
| 59 | + if (p.type === "delim") { | |
| 60 | + section = "args"; | |
| 61 | + continue; | |
| 62 | + } | |
| 63 | + if (p.type === "flush") break; | |
| 64 | + const line = pktText(p); | |
| 65 | + if (section === "caps") { | |
| 66 | + if (line.startsWith("command=")) req.command = line.slice(8); | |
| 67 | + else req.capabilities.push(line); | |
| 68 | + } else req.args.push(line); | |
| 69 | + } | |
| 70 | + return req; | |
| 71 | +} | |
| 72 | + | |
| 73 | +export async function handleUploadPackV2(body: Uint8Array, ctx: UploadContext): Promise<Uint8Array> { | |
| 74 | + const req = parseV2(body); | |
| 75 | + if (req.command === "ls-refs") return lsRefs(req, ctx); | |
| 76 | + if (req.command === "fetch") return fetchCommand(req, ctx); | |
| 77 | + throw new Error(`unsupported command: ${req.command || "(none)"}`); | |
| 78 | +} | |
| 79 | + | |
| 80 | +async function lsRefs(req: V2Request, ctx: UploadContext): Promise<Uint8Array> { | |
| 81 | + const prefixes = req.args.filter((a) => a.startsWith("ref-prefix ")).map((a) => a.slice(11)); | |
| 82 | + const symrefs = req.args.includes("symrefs"); | |
| 83 | + const peel = req.args.includes("peel"); | |
| 84 | + const unborn = req.args.includes("unborn"); | |
| 85 | + const match = (name: string) => prefixes.length === 0 || prefixes.some((p) => name.startsWith(p)); | |
| 86 | + const w = new ByteWriter(); | |
| 87 | + const head = ctx.refs.find((r) => r.name === ctx.headTarget); | |
| 88 | + if (match("HEAD")) { | |
| 89 | + if (head) w.write(pkt(`${head.oid} HEAD${symrefs ? ` symref-target:${ctx.headTarget}` : ""}\n`)); | |
| 90 | + else if (unborn) w.write(pkt(`unborn HEAD${symrefs ? ` symref-target:${ctx.headTarget}` : ""}\n`)); | |
| 91 | + } | |
| 92 | + for (const r of ctx.refs) { | |
| 93 | + if (!match(r.name)) continue; | |
| 94 | + let line = `${r.oid} ${r.name}`; | |
| 95 | + if (peel && r.name.startsWith("refs/tags/")) { | |
| 96 | + const o = await ctx.store.read(r.oid); | |
| 97 | + if (o?.type === "tag") line += ` peeled:${parseTag(o.content).object}`; | |
| 98 | + } | |
| 99 | + w.write(pkt(`${line}\n`)); | |
| 100 | + } | |
| 101 | + w.write(FLUSH); | |
| 102 | + return w.bytes(); | |
| 103 | +} | |
| 104 | + | |
| 105 | +async function fetchCommand(req: V2Request, ctx: UploadContext): Promise<Uint8Array> { | |
| 106 | + const wants: string[] = []; | |
| 107 | + const haves: string[] = []; | |
| 108 | + let done = false; | |
| 109 | + let noProgress = false; | |
| 110 | + for (const a of req.args) { | |
| 111 | + if (a.startsWith("want ")) wants.push(a.slice(5)); | |
| 112 | + else if (a.startsWith("want-ref ")) { | |
| 113 | + const ref = ctx.refs.find((r) => r.name === a.slice(9)); | |
| 114 | + if (ref) wants.push(ref.oid); | |
| 115 | + } else if (a.startsWith("have ")) haves.push(a.slice(5)); | |
| 116 | + else if (a === "done") done = true; | |
| 117 | + else if (a === "no-progress") noProgress = true; | |
| 118 | + else if (a.startsWith("deepen") || a.startsWith("shallow ") || a.startsWith("filter ")) { | |
| 119 | + throw new Error("shallow and partial clones are not supported yet; clone without --depth/--filter"); | |
| 120 | + } | |
| 121 | + } | |
| 122 | + for (const w of wants) if (!ctx.store.has(w)) throw new Error(`not our ref ${w}`); | |
| 123 | + const common = haves.filter((h) => ctx.store.has(h)); | |
| 124 | + | |
| 125 | + const out = new ByteWriter(); | |
| 126 | + if (!done) { | |
| 127 | + out.write(pkt("acknowledgments\n")); | |
| 128 | + if (common.length === 0) out.write(pkt("NAK\n")); | |
| 129 | + for (const c of common) out.write(pkt(`ACK ${c}\n`)); | |
| 130 | + out.write(pkt("ready\n")); | |
| 131 | + out.write(DELIM); | |
| 132 | + } | |
| 133 | + out.write(pkt("packfile\n")); | |
| 134 | + const oids = await objectsForFetch(ctx.store, wants, common); | |
| 135 | + if (!noProgress) sideband(2, utf8(`Enumerating objects: ${oids.length}, done.\n`), out); | |
| 136 | + const entries = []; | |
| 137 | + for (const oid of oids) { | |
| 138 | + const raw = await ctx.store.readRaw(oid); | |
| 139 | + if (!raw) throw new Error(`missing object ${oid}`); | |
| 140 | + entries.push({ type: raw.type, size: raw.size, deflated: raw.deflated }); | |
| 141 | + } | |
| 142 | + sideband(1, await writePack(entries), out); | |
| 143 | + if (!noProgress) sideband(2, utf8(`Total ${oids.length} (delta 0), reused ${oids.length} (delta 0)\n`), out); | |
| 144 | + out.write(FLUSH); | |
| 145 | + return out.bytes(); | |
| 146 | +} | |
| 147 | + | |
| 148 | +// ------------------------------------------------------------------ receive-pack | |
| 149 | + | |
| 150 | +export interface RefCommand { | |
| 151 | + oldOid: string; | |
| 152 | + newOid: string; | |
| 153 | + ref: string; | |
| 154 | +} | |
| 155 | + | |
| 156 | +export interface ReceiveRequest { | |
| 157 | + commands: RefCommand[]; | |
| 158 | + capabilities: Set<string>; | |
| 159 | + pushOptions: string[]; | |
| 160 | + pack: Uint8Array | null; | |
| 161 | +} | |
| 162 | + | |
| 163 | +export function parseReceivePack(body: Uint8Array): ReceiveRequest { | |
| 164 | + const { pkts, offset } = readPkts(body, 0, true); | |
| 165 | + const commands: RefCommand[] = []; | |
| 166 | + let capabilities = new Set<string>(); | |
| 167 | + for (const p of pkts) { | |
| 168 | + if (p.type !== "data") continue; | |
| 169 | + let line = fromUtf8(p.data).replace(/\n$/, ""); | |
| 170 | + const nul = line.indexOf("\0"); | |
| 171 | + if (nul >= 0) { | |
| 172 | + capabilities = new Set(line.slice(nul + 1).trim().split(" ").filter(Boolean)); | |
| 173 | + line = line.slice(0, nul); | |
| 174 | + } | |
| 175 | + if (line.startsWith("shallow ")) continue; | |
| 176 | + const [oldOid, newOid, ref] = line.split(" "); | |
| 177 | + if (!oldOid || !newOid || !ref) throw new Error(`bad command: ${line}`); | |
| 178 | + commands.push({ oldOid, newOid, ref }); | |
| 179 | + } | |
| 180 | + let rest = offset; | |
| 181 | + const pushOptions: string[] = []; | |
| 182 | + if (capabilities.has("push-options")) { | |
| 183 | + const opts = readPkts(body, rest, true); | |
| 184 | + for (const p of opts.pkts) if (p.type === "data") pushOptions.push(pktText(p)); | |
| 185 | + rest = opts.offset; | |
| 186 | + } | |
| 187 | + const pack = rest < body.length ? body.subarray(rest) : null; | |
| 188 | + return { commands, capabilities, pushOptions, pack }; | |
| 189 | +} | |
| 190 | + | |
| 191 | +export interface RefResult { | |
| 192 | + ref: string; | |
| 193 | + ok: boolean; | |
| 194 | + reason?: string; | |
| 195 | +} | |
| 196 | + | |
| 197 | +export function reportStatus(req: ReceiveRequest, unpackError: string | null, results: RefResult[], messages: string[] = []): Uint8Array { | |
| 198 | + const report = new ByteWriter(); | |
| 199 | + report.write(pkt(unpackError ? `unpack ${unpackError}\n` : "unpack ok\n")); | |
| 200 | + for (const r of results) report.write(pkt(r.ok ? `ok ${r.ref}\n` : `ng ${r.ref} ${r.reason ?? "rejected"}\n`)); | |
| 201 | + report.write(FLUSH); | |
| 202 | + if (!req.capabilities.has("side-band-64k")) return report.bytes(); | |
| 203 | + const out = new ByteWriter(); | |
| 204 | + for (const m of messages) sideband(2, utf8(`${m}\n`), out); | |
| 205 | + sideband(1, report.bytes(), out); | |
| 206 | + out.write(FLUSH); | |
| 207 | + return out.bytes(); | |
| 208 | +} | |
| 209 | + | |
| 210 | +export function errorPacket(message: string): Uint8Array { | |
| 211 | + const w = new ByteWriter(); | |
| 212 | + w.write(pkt(`ERR ${message}\n`)); | |
| 213 | + return w.bytes(); | |
| 214 | +} |
addedapps/diffinery/src/git/secret-scan.ts+30−0View file
| @@ -1,0 +1,30 @@ | ||
| 1 | +import { fromUtf8 } from "@diffinery/git-core"; | |
| 2 | + | |
| 3 | +/** High-confidence credential patterns. Push protection blocks pushes that add these. */ | |
| 4 | +const PATTERNS: Array<{ kind: string; re: RegExp }> = [ | |
| 5 | + { kind: "AWS access key", re: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/ }, | |
| 6 | + { kind: "GitHub token", re: /\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{82}\b/ }, | |
| 7 | + { kind: "Diffinery token", re: /\bdfy_[A-Za-z0-9_-]{32,}\b/ }, | |
| 8 | + { kind: "Slack token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ }, | |
| 9 | + { kind: "Stripe live key", re: /\b[rs]k_live_[A-Za-z0-9]{20,}\b/ }, | |
| 10 | + { kind: "Google API key", re: /\bAIza[0-9A-Za-z_-]{35}\b/ }, | |
| 11 | + { kind: "Anthropic API key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ }, | |
| 12 | + { kind: "OpenAI API key", re: /\bsk-(proj-)?[A-Za-z0-9_-]{40,}\b/ }, | |
| 13 | + { kind: "Private key", re: /-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY( BLOCK)?-----/ }, | |
| 14 | +]; | |
| 15 | + | |
| 16 | +export interface SecretHit { | |
| 17 | + oid: string; | |
| 18 | + kind: string; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export function scanForSecrets(blobs: Array<{ oid: string; content: Uint8Array }>): SecretHit[] { | |
| 22 | + const hits: SecretHit[] = []; | |
| 23 | + for (const b of blobs) { | |
| 24 | + if (b.content.includes(0)) continue; // binary | |
| 25 | + const text = fromUtf8(b.content); | |
| 26 | + if (/diffinery:allow-secret/.test(text)) continue; // explicit, auditable opt-out for test fixtures | |
| 27 | + for (const p of PATTERNS) if (p.re.test(text)) hits.push({ oid: b.oid, kind: p.kind }); | |
| 28 | + } | |
| 29 | + return hits; | |
| 30 | +} |
addedapps/diffinery/src/lib/crypto.ts+34−0View file
| @@ -1,0 +1,34 @@ | ||
| 1 | +import { b64, fromB64 } from "./util.js"; | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Envelope encryption for secrets. Each scope (repo or org) gets its own AES-GCM | |
| 5 | + * key derived from MASTER_KEY with HKDF, so a leaked ciphertext from one repo | |
| 6 | + * cannot be replayed into another. | |
| 7 | + */ | |
| 8 | +async function scopeKey(masterKeyB64: string, scope: string): Promise<CryptoKey> { | |
| 9 | + const raw = await crypto.subtle.importKey("raw", fromB64(masterKeyB64) as BufferSource, "HKDF", false, ["deriveKey"]); | |
| 10 | + return crypto.subtle.deriveKey( | |
| 11 | + { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("diffinery-secrets-v1"), info: new TextEncoder().encode(scope) }, | |
| 12 | + raw, | |
| 13 | + { name: "AES-GCM", length: 256 }, | |
| 14 | + false, | |
| 15 | + ["encrypt", "decrypt"], | |
| 16 | + ); | |
| 17 | +} | |
| 18 | + | |
| 19 | +export async function encryptSecret(masterKey: string | undefined, scope: string, plaintext: string): Promise<string> { | |
| 20 | + if (!masterKey) throw new Error("MASTER_KEY is not configured"); | |
| 21 | + const iv = crypto.getRandomValues(new Uint8Array(12)); | |
| 22 | + const key = await scopeKey(masterKey, scope); | |
| 23 | + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: new TextEncoder().encode(scope) }, key, new TextEncoder().encode(plaintext))); | |
| 24 | + return `v1.${b64(iv)}.${b64(ct)}`; | |
| 25 | +} | |
| 26 | + | |
| 27 | +export async function decryptSecret(masterKey: string | undefined, scope: string, envelope: string): Promise<string> { | |
| 28 | + if (!masterKey) throw new Error("MASTER_KEY is not configured"); | |
| 29 | + const [v, ivB, ctB] = envelope.split("."); | |
| 30 | + if (v !== "v1" || !ivB || !ctB) throw new Error("bad secret envelope"); | |
| 31 | + const key = await scopeKey(masterKey, scope); | |
| 32 | + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromB64(ivB) as BufferSource, additionalData: new TextEncoder().encode(scope) }, key, fromB64(ctB) as BufferSource); | |
| 33 | + return new TextDecoder().decode(pt); | |
| 34 | +} |
addedapps/diffinery/src/lib/directory.ts+403−0View file
| @@ -1,0 +1,403 @@ | ||
| 1 | +import type { Env } from "../env.js"; | |
| 2 | +import type { RepoMeta, Role } from "../repo/types.js"; | |
| 3 | +import { ROLE_LEVEL } from "../repo/types.js"; | |
| 4 | +import { HttpError, isValidName, newId, now, randomToken, sha256 } from "./util.js"; | |
| 5 | + | |
| 6 | +export interface Account { | |
| 7 | + id: string; | |
| 8 | + login: string; | |
| 9 | + kind: "user" | "org" | "agent"; | |
| 10 | + name: string | null; | |
| 11 | + email: string | null; | |
| 12 | + status: "active" | "pending" | "disabled"; | |
| 13 | + site_admin: number; | |
| 14 | + owner_id: string | null; | |
| 15 | + agent_policy: string | null; | |
| 16 | + created_at: string; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export interface Repo { | |
| 20 | + id: string; | |
| 21 | + owner_id: string; | |
| 22 | + owner_login: string; | |
| 23 | + owner_kind: "user" | "org" | "agent"; | |
| 24 | + name: string; | |
| 25 | + description: string; | |
| 26 | + visibility: "private" | "internal" | "public"; | |
| 27 | + default_branch: string; | |
| 28 | + archived: number; | |
| 29 | + created_at: string; | |
| 30 | + updated_at: string; | |
| 31 | + pushed_at: string | null; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export type TokenScope = "read" | "write" | "admin"; | |
| 35 | + | |
| 36 | +/** Data access for the global directory in D1. */ | |
| 37 | +export class Directory { | |
| 38 | + constructor(private env: Env) {} | |
| 39 | + | |
| 40 | + private get db() { | |
| 41 | + return this.env.DB; | |
| 42 | + } | |
| 43 | + | |
| 44 | + // ---------------------------------------------------------------- accounts | |
| 45 | + | |
| 46 | + async account(id: string): Promise<Account | null> { | |
| 47 | + return this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(id).first<Account>(); | |
| 48 | + } | |
| 49 | + | |
| 50 | + async accountByLogin(login: string): Promise<Account | null> { | |
| 51 | + return this.db.prepare("SELECT * FROM accounts WHERE login = ?").bind(login).first<Account>(); | |
| 52 | + } | |
| 53 | + | |
| 54 | + async accountByEmail(email: string): Promise<Account | null> { | |
| 55 | + return this.db.prepare("SELECT * FROM accounts WHERE email = ? AND kind = 'user'").bind(email).first<Account>(); | |
| 56 | + } | |
| 57 | + | |
| 58 | + private isBootstrapAdmin(email: string, login?: string): boolean { | |
| 59 | + const list = (this.env.BOOTSTRAP_ADMINS ?? "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean); | |
| 60 | + return list.includes(email.toLowerCase()) || (!!login && list.includes(login.toLowerCase())); | |
| 61 | + } | |
| 62 | + | |
| 63 | + private async uniqueLogin(base: string): Promise<string> { | |
| 64 | + let candidate = base.replace(/[^A-Za-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 39) || "user"; | |
| 65 | + for (let i = 0; i < 50; i++) { | |
| 66 | + const tryLogin = i === 0 ? candidate : `${candidate}-${i + 1}`; | |
| 67 | + if (!(await this.accountByLogin(tryLogin))) return tryLogin; | |
| 68 | + } | |
| 69 | + candidate = `${candidate}-${randomToken(3)}`; | |
| 70 | + return candidate; | |
| 71 | + } | |
| 72 | + | |
| 73 | + /** Find or create the user behind a verified email (Access sign-in). */ | |
| 74 | + async userForEmail(email: string, hints: { name?: string; login?: string } = {}): Promise<Account> { | |
| 75 | + const existing = await this.accountByEmail(email); | |
| 76 | + if (existing) { | |
| 77 | + if (existing.status === "pending" && this.isBootstrapAdmin(email, existing.login)) { | |
| 78 | + await this.db.prepare("UPDATE accounts SET status = 'active', site_admin = 1 WHERE id = ?").bind(existing.id).run(); | |
| 79 | + return { ...existing, status: "active", site_admin: 1 }; | |
| 80 | + } | |
| 81 | + return existing; | |
| 82 | + } | |
| 83 | + const admin = this.isBootstrapAdmin(email, hints.login); | |
| 84 | + const login = await this.uniqueLogin(hints.login ?? email.split("@")[0]!); | |
| 85 | + const id = newId(); | |
| 86 | + const hasInvite = await this.db.prepare("SELECT 1 FROM invites WHERE email = ? AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).first(); | |
| 87 | + await this.db | |
| 88 | + .prepare("INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES (?, ?, 'user', ?, ?, ?, ?, ?)") | |
| 89 | + .bind(id, login, hints.name ?? null, email, admin || hasInvite ? "active" : "pending", admin ? 1 : 0, now()) | |
| 90 | + .run(); | |
| 91 | + if (hasInvite) await this.acceptInvitesForEmail(id, email); | |
| 92 | + return (await this.account(id))!; | |
| 93 | + } | |
| 94 | + | |
| 95 | + async updateAccount(id: string, patch: { login?: string; name?: string }): Promise<void> { | |
| 96 | + if (patch.login && !isValidName(patch.login)) throw new HttpError(422, "invalid login"); | |
| 97 | + if (patch.login) { | |
| 98 | + const clash = await this.accountByLogin(patch.login); | |
| 99 | + if (clash && clash.id !== id) throw new HttpError(422, "login already taken"); | |
| 100 | + } | |
| 101 | + await this.db.prepare("UPDATE accounts SET login = COALESCE(?, login), name = COALESCE(?, name) WHERE id = ?").bind(patch.login ?? null, patch.name ?? null, id).run(); | |
| 102 | + if (patch.login) { | |
| 103 | + // Repos keep their ids; their Durable Objects cache the owner login, so refresh them. | |
| 104 | + const repos = await this.db.prepare("SELECT id FROM repos WHERE owner_id = ?").bind(id).all<{ id: string }>(); | |
| 105 | + for (const r of repos.results) await this.env.REPO.get(this.env.REPO.idFromName(r.id)).updateMeta({ owner: patch.login }); | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + async createOrg(login: string, name: string, creator: Account): Promise<Account> { | |
| 110 | + if (!isValidName(login)) throw new HttpError(422, "invalid organization name"); | |
| 111 | + if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken"); | |
| 112 | + const id = newId(); | |
| 113 | + const t = now(); | |
| 114 | + await this.db.batch([ | |
| 115 | + this.db.prepare("INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES (?, ?, 'org', ?, 'active', ?)").bind(id, login, name || login, t), | |
| 116 | + this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, 'owner', ?)").bind(id, creator.id, t), | |
| 117 | + ]); | |
| 118 | + return (await this.account(id))!; | |
| 119 | + } | |
| 120 | + | |
| 121 | + async orgRole(orgId: string, userId: string): Promise<"owner" | "admin" | "member" | null> { | |
| 122 | + const r = await this.db.prepare("SELECT role FROM org_members WHERE org_id = ? AND user_id = ?").bind(orgId, userId).first<{ role: "owner" | "admin" | "member" }>(); | |
| 123 | + return r?.role ?? null; | |
| 124 | + } | |
| 125 | + | |
| 126 | + async orgsFor(userId: string): Promise<Array<Account & { role: string }>> { | |
| 127 | + const r = await this.db.prepare("SELECT a.*, m.role FROM org_members m JOIN accounts a ON a.id = m.org_id WHERE m.user_id = ? ORDER BY a.login").bind(userId).all<Account & { role: string }>(); | |
| 128 | + return r.results; | |
| 129 | + } | |
| 130 | + | |
| 131 | + async orgMembers(orgId: string): Promise<Array<Account & { role: string }>> { | |
| 132 | + const r = await this.db.prepare("SELECT a.*, m.role FROM org_members m JOIN accounts a ON a.id = m.user_id WHERE m.org_id = ? ORDER BY a.login").bind(orgId).all<Account & { role: string }>(); | |
| 133 | + return r.results; | |
| 134 | + } | |
| 135 | + | |
| 136 | + async setOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null): Promise<void> { | |
| 137 | + if (role === null) { | |
| 138 | + const owners = await this.db.prepare("SELECT count(*) AS n FROM org_members WHERE org_id = ? AND role = 'owner' AND user_id != ?").bind(orgId, userId).first<{ n: number }>(); | |
| 139 | + if ((await this.orgRole(orgId, userId)) === "owner" && !owners?.n) throw new HttpError(422, "an organization needs at least one owner"); | |
| 140 | + await this.db.prepare("DELETE FROM org_members WHERE org_id = ? AND user_id = ?").bind(orgId, userId).run(); | |
| 141 | + // Removing someone revokes their tokens' reach immediately: roles are computed per request. | |
| 142 | + return; | |
| 143 | + } | |
| 144 | + await this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(org_id, user_id) DO UPDATE SET role = excluded.role").bind(orgId, userId, role, now()).run(); | |
| 145 | + } | |
| 146 | + | |
| 147 | + async users(): Promise<Account[]> { | |
| 148 | + return (await this.db.prepare("SELECT * FROM accounts WHERE kind = 'user' ORDER BY login").all<Account>()).results; | |
| 149 | + } | |
| 150 | + | |
| 151 | + async setStatus(id: string, status: Account["status"]): Promise<void> { | |
| 152 | + await this.db.prepare("UPDATE accounts SET status = ? WHERE id = ?").bind(status, id).run(); | |
| 153 | + if (status === "disabled") await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE account_id = ? AND revoked_at IS NULL").bind(now(), id).run(); | |
| 154 | + } | |
| 155 | + | |
| 156 | + // ---------------------------------------------------------------- agents | |
| 157 | + | |
| 158 | + async createAgent(owner: Account, input: { login: string; name?: string; orgId?: string; policy?: Record<string, unknown> }): Promise<Account> { | |
| 159 | + if (!isValidName(input.login)) throw new HttpError(422, "invalid agent name"); | |
| 160 | + const login = input.login.endsWith("[bot]") ? input.login : `${input.login}`; | |
| 161 | + if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken"); | |
| 162 | + const id = newId(); | |
| 163 | + const ownerId = input.orgId ?? owner.id; | |
| 164 | + if (input.orgId) { | |
| 165 | + const role = await this.orgRole(input.orgId, owner.id); | |
| 166 | + if (role !== "owner" && role !== "admin") throw new HttpError(403, "only organization admins can create organization agents"); | |
| 167 | + } | |
| 168 | + const policy = { maxRole: "write", canMergeProtected: false, budgetUsdMonthly: null, ...input.policy }; | |
| 169 | + await this.db | |
| 170 | + .prepare("INSERT INTO accounts (id, login, kind, name, status, owner_id, agent_policy, created_at) VALUES (?, ?, 'agent', ?, 'active', ?, ?, ?)") | |
| 171 | + .bind(id, login, input.name ?? login, ownerId, JSON.stringify(policy), now()) | |
| 172 | + .run(); | |
| 173 | + return (await this.account(id))!; | |
| 174 | + } | |
| 175 | + | |
| 176 | + async agentsOwnedBy(ownerIds: string[]): Promise<Account[]> { | |
| 177 | + if (!ownerIds.length) return []; | |
| 178 | + const q = `SELECT * FROM accounts WHERE kind = 'agent' AND owner_id IN (${ownerIds.map(() => "?").join(",")}) ORDER BY login`; | |
| 179 | + return (await this.db.prepare(q).bind(...ownerIds).all<Account>()).results; | |
| 180 | + } | |
| 181 | + | |
| 182 | + // ---------------------------------------------------------------- tokens | |
| 183 | + | |
| 184 | + async createToken(account: Account, input: { name: string; scopes?: TokenScope[]; repoIds?: string[] | null; expiresInDays?: number }): Promise<{ token: string; id: string; expiresAt: string }> { | |
| 185 | + const days = Math.min(Math.max(input.expiresInDays ?? 90, 1), 366); | |
| 186 | + const token = `dfy_${randomToken(30)}`; | |
| 187 | + const id = newId(); | |
| 188 | + const expiresAt = new Date(Date.now() + days * 86400_000).toISOString(); | |
| 189 | + await this.db | |
| 190 | + .prepare("INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, repo_ids, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 191 | + .bind(id, account.id, input.name || "token", await sha256(token), token.slice(0, 10), JSON.stringify(input.scopes?.length ? input.scopes : ["write"]), input.repoIds ? JSON.stringify(input.repoIds) : null, expiresAt, now()) | |
| 192 | + .run(); | |
| 193 | + return { token, id, expiresAt }; | |
| 194 | + } | |
| 195 | + | |
| 196 | + async tokens(accountId: string): Promise<Array<{ id: string; name: string; prefix: string; scopes: string[]; repoIds: string[] | null; expiresAt: string; lastUsedAt: string | null; createdAt: string }>> { | |
| 197 | + const r = await this.db.prepare("SELECT * FROM tokens WHERE account_id = ? AND revoked_at IS NULL ORDER BY created_at DESC").bind(accountId).all<Record<string, string | null>>(); | |
| 198 | + return r.results.map((t) => ({ id: t.id!, name: t.name!, prefix: t.prefix!, scopes: JSON.parse(t.scopes!), repoIds: t.repo_ids ? JSON.parse(t.repo_ids) : null, expiresAt: t.expires_at!, lastUsedAt: t.last_used_at, createdAt: t.created_at! })); | |
| 199 | + } | |
| 200 | + | |
| 201 | + async revokeToken(accountId: string, id: string): Promise<void> { | |
| 202 | + await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE id = ? AND account_id = ?").bind(now(), id, accountId).run(); | |
| 203 | + } | |
| 204 | + | |
| 205 | + async verifyToken(token: string): Promise<{ account: Account; scopes: TokenScope[]; repoIds: string[] | null } | null> { | |
| 206 | + const row = await this.db.prepare("SELECT * FROM tokens WHERE token_hash = ?").bind(await sha256(token)).first<Record<string, string | null>>(); | |
| 207 | + if (!row || row.revoked_at || row.expires_at! < now()) return null; | |
| 208 | + const account = await this.account(row.account_id!); | |
| 209 | + if (!account || account.status !== "active") return null; | |
| 210 | + if (!row.last_used_at || Date.now() - Date.parse(row.last_used_at) > 3600_000) { | |
| 211 | + await this.db.prepare("UPDATE tokens SET last_used_at = ? WHERE id = ?").bind(now(), row.id!).run(); | |
| 212 | + } | |
| 213 | + return { account, scopes: JSON.parse(row.scopes!), repoIds: row.repo_ids ? JSON.parse(row.repo_ids) : null }; | |
| 214 | + } | |
| 215 | + | |
| 216 | + // ---------------------------------------------------------------- invites | |
| 217 | + | |
| 218 | + async createInvite(inviter: Account, input: { email: string; orgId?: string; repoId?: string; role: string }): Promise<{ id: string; url: string; token: string }> { | |
| 219 | + const token = randomToken(24); | |
| 220 | + const id = newId(); | |
| 221 | + await this.db | |
| 222 | + .prepare("INSERT INTO invites (id, org_id, repo_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 223 | + .bind(id, input.orgId ?? null, input.repoId ?? null, input.email, input.role, await sha256(token), inviter.id, now(), new Date(Date.now() + 14 * 86400_000).toISOString()) | |
| 224 | + .run(); | |
| 225 | + // If the person already exists, activate them now so the invite also works from the dashboard. | |
| 226 | + const existing = await this.accountByEmail(input.email); | |
| 227 | + if (existing?.status === "pending") await this.setStatus(existing.id, "active"); | |
| 228 | + return { id, url: `${this.env.PUBLIC_URL}/invite/${token}`, token }; | |
| 229 | + } | |
| 230 | + | |
| 231 | + async invites(filter: { orgId?: string }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null }>> { | |
| 232 | + const r = await this.db.prepare("SELECT * FROM invites WHERE (? IS NULL OR org_id = ?) ORDER BY created_at DESC LIMIT 200").bind(filter.orgId ?? null, filter.orgId ?? null).all<Record<string, string | null>>(); | |
| 233 | + return r.results.map((i) => ({ id: i.id!, email: i.email!, role: i.role!, createdAt: i.created_at!, expiresAt: i.expires_at!, acceptedAt: i.accepted_at })); | |
| 234 | + } | |
| 235 | + | |
| 236 | + async acceptInvite(token: string, user: Account): Promise<{ orgId: string | null; repoId: string | null }> { | |
| 237 | + const inv = await this.db.prepare("SELECT * FROM invites WHERE token_hash = ?").bind(await sha256(token)).first<Record<string, string | null>>(); | |
| 238 | + if (!inv || inv.accepted_at || inv.expires_at! < now()) throw new HttpError(404, "this invite is invalid or has expired"); | |
| 239 | + if (inv.email && user.email && inv.email.toLowerCase() !== user.email.toLowerCase()) throw new HttpError(403, `this invite was sent to ${inv.email}; sign in with that address`); | |
| 240 | + await this.applyInvite(inv, user); | |
| 241 | + return { orgId: inv.org_id, repoId: inv.repo_id }; | |
| 242 | + } | |
| 243 | + | |
| 244 | + private async acceptInvitesForEmail(userId: string, email: string): Promise<void> { | |
| 245 | + const user = (await this.account(userId))!; | |
| 246 | + const r = await this.db.prepare("SELECT * FROM invites WHERE email = ? AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).all<Record<string, string | null>>(); | |
| 247 | + for (const inv of r.results) await this.applyInvite(inv, user); | |
| 248 | + } | |
| 249 | + | |
| 250 | + private async applyInvite(inv: Record<string, string | null>, user: Account): Promise<void> { | |
| 251 | + const stmts = [this.db.prepare("UPDATE invites SET accepted_at = ?, accepted_by = ? WHERE id = ?").bind(now(), user.id, inv.id!), this.db.prepare("UPDATE accounts SET status = 'active' WHERE id = ? AND status = 'pending'").bind(user.id)]; | |
| 252 | + if (inv.org_id) stmts.push(this.db.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(org_id, user_id) DO NOTHING").bind(inv.org_id, user.id, inv.role === "admin" || inv.role === "owner" ? inv.role : "member", now())); | |
| 253 | + if (inv.repo_id) stmts.push(this.db.prepare("INSERT OR REPLACE INTO repo_access (repo_id, account_id, role) VALUES (?, ?, ?)").bind(inv.repo_id, user.id, inv.role)); | |
| 254 | + await this.db.batch(stmts); | |
| 255 | + } | |
| 256 | + | |
| 257 | + async requestAccess(accountId: string, note: string): Promise<void> { | |
| 258 | + await this.db.prepare("INSERT OR REPLACE INTO access_requests (account_id, note, created_at) VALUES (?, ?, ?)").bind(accountId, note.slice(0, 500), now()).run(); | |
| 259 | + } | |
| 260 | + | |
| 261 | + async accessRequests(): Promise<Array<{ account: Account; note: string; createdAt: string }>> { | |
| 262 | + const r = await this.db.prepare("SELECT a.*, r.note AS req_note, r.created_at AS req_at FROM access_requests r JOIN accounts a ON a.id = r.account_id WHERE a.status = 'pending' ORDER BY r.created_at").all<Account & { req_note: string; req_at: string }>(); | |
| 263 | + return r.results.map((x) => ({ account: x, note: x.req_note, createdAt: x.req_at })); | |
| 264 | + } | |
| 265 | + | |
| 266 | + // ---------------------------------------------------------------- repositories | |
| 267 | + | |
| 268 | + async repo(owner: string, name: string): Promise<Repo | null> { | |
| 269 | + return this.db | |
| 270 | + .prepare("SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE a.login = ? AND r.name = ?") | |
| 271 | + .bind(owner, name.replace(/\.git$/, "")) | |
| 272 | + .first<Repo>(); | |
| 273 | + } | |
| 274 | + | |
| 275 | + async repoById(id: string): Promise<Repo | null> { | |
| 276 | + return this.db.prepare("SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id = ?").bind(id).first<Repo>(); | |
| 277 | + } | |
| 278 | + | |
| 279 | + /** Repositories an account can see. */ | |
| 280 | + async reposVisibleTo(account: Account, ownerId?: string): Promise<Repo[]> { | |
| 281 | + const params: unknown[] = []; | |
| 282 | + let q = "SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id"; | |
| 283 | + const subjectId = account.kind === "agent" ? account.owner_id! : account.id; | |
| 284 | + if (!account.site_admin) { | |
| 285 | + q += ` WHERE (r.visibility IN ('internal', 'public') OR r.owner_id = ? OR r.owner_id IN (SELECT org_id FROM org_members WHERE user_id = ?) OR r.id IN (SELECT repo_id FROM repo_access WHERE account_id IN (?, ?)))`; | |
| 286 | + params.push(subjectId, subjectId, account.id, subjectId); | |
| 287 | + } else q += " WHERE 1 = 1"; | |
| 288 | + if (ownerId) { | |
| 289 | + q += " AND r.owner_id = ?"; | |
| 290 | + params.push(ownerId); | |
| 291 | + } | |
| 292 | + q += " ORDER BY COALESCE(r.pushed_at, r.updated_at) DESC LIMIT 500"; | |
| 293 | + return (await this.db.prepare(q).bind(...params).all<Repo>()).results; | |
| 294 | + } | |
| 295 | + | |
| 296 | + async createRepo(owner: Account, creator: Account, input: { name: string; description?: string; visibility?: Repo["visibility"]; defaultBranch?: string }): Promise<Repo> { | |
| 297 | + if (!isValidName(input.name)) throw new HttpError(422, "invalid repository name: use letters, numbers, '.', '-' and '_'"); | |
| 298 | + if (owner.kind === "org") { | |
| 299 | + const role = await this.orgRole(owner.id, creator.kind === "agent" ? creator.owner_id! : creator.id); | |
| 300 | + if (!role && !creator.site_admin) throw new HttpError(403, "you are not a member of this organization"); | |
| 301 | + } else if (owner.id !== creator.id && !creator.site_admin) throw new HttpError(403, "you can only create repositories for yourself or your organizations"); | |
| 302 | + if (await this.repo(owner.login, input.name)) throw new HttpError(422, "a repository with this name already exists"); | |
| 303 | + const id = newId(); | |
| 304 | + const t = now(); | |
| 305 | + const branch = input.defaultBranch || "main"; | |
| 306 | + await this.db | |
| 307 | + .prepare("INSERT INTO repos (id, owner_id, name, description, visibility, default_branch, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") | |
| 308 | + .bind(id, owner.id, input.name, input.description ?? "", input.visibility ?? "private", branch, t, t) | |
| 309 | + .run(); | |
| 310 | + const meta: RepoMeta = { repoId: id, ownerId: owner.id, owner: owner.login, name: input.name, defaultBranch: branch, ownerKind: owner.kind === "org" ? "org" : "user" }; | |
| 311 | + await this.env.REPO.get(this.env.REPO.idFromName(id)).init(meta); | |
| 312 | + return (await this.repoById(id))!; | |
| 313 | + } | |
| 314 | + | |
| 315 | + async updateRepo(repo: Repo, patch: { name?: string; description?: string; visibility?: Repo["visibility"]; defaultBranch?: string; archived?: boolean }): Promise<Repo> { | |
| 316 | + if (patch.name && !isValidName(patch.name)) throw new HttpError(422, "invalid repository name"); | |
| 317 | + await this.db | |
| 318 | + .prepare("UPDATE repos SET name = COALESCE(?, name), description = COALESCE(?, description), visibility = COALESCE(?, visibility), default_branch = COALESCE(?, default_branch), archived = COALESCE(?, archived), updated_at = ? WHERE id = ?") | |
| 319 | + .bind(patch.name ?? null, patch.description ?? null, patch.visibility ?? null, patch.defaultBranch ?? null, patch.archived === undefined ? null : patch.archived ? 1 : 0, now(), repo.id) | |
| 320 | + .run(); | |
| 321 | + if (patch.name || patch.defaultBranch) await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).updateMeta({ name: patch.name ?? repo.name, defaultBranch: patch.defaultBranch ?? repo.default_branch }); | |
| 322 | + return (await this.repoById(repo.id))!; | |
| 323 | + } | |
| 324 | + | |
| 325 | + async deleteRepo(repo: Repo): Promise<void> { | |
| 326 | + await this.db.batch([this.db.prepare("DELETE FROM repo_access WHERE repo_id = ?").bind(repo.id), this.db.prepare("DELETE FROM schedules WHERE repo_id = ?").bind(repo.id), this.db.prepare("DELETE FROM repos WHERE id = ?").bind(repo.id)]); | |
| 327 | + // R2 objects under repos/<id>/ are removed by the cleanup job; the DO's storage is wiped here. | |
| 328 | + await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).destroy(); | |
| 329 | + } | |
| 330 | + | |
| 331 | + async touchPushed(repoId: string): Promise<void> { | |
| 332 | + await this.db.prepare("UPDATE repos SET pushed_at = ?, updated_at = ? WHERE id = ?").bind(now(), now(), repoId).run(); | |
| 333 | + } | |
| 334 | + | |
| 335 | + async collaborators(repoId: string): Promise<Array<{ account: Account; role: Role }>> { | |
| 336 | + const r = await this.db.prepare("SELECT a.*, x.role AS access_role FROM repo_access x JOIN accounts a ON a.id = x.account_id WHERE x.repo_id = ? ORDER BY a.login").bind(repoId).all<Account & { access_role: Role }>(); | |
| 337 | + return r.results.map((x) => ({ account: x, role: x.access_role })); | |
| 338 | + } | |
| 339 | + | |
| 340 | + async setCollaborator(repoId: string, accountId: string, role: Role | null): Promise<void> { | |
| 341 | + if (role) await this.db.prepare("INSERT OR REPLACE INTO repo_access (repo_id, account_id, role) VALUES (?, ?, ?)").bind(repoId, accountId, role).run(); | |
| 342 | + else await this.db.prepare("DELETE FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repoId, accountId).run(); | |
| 343 | + } | |
| 344 | + | |
| 345 | + /** Effective role of an account on a repository (null = no access). */ | |
| 346 | + async roleFor(account: Account, repo: Repo): Promise<Role | null> { | |
| 347 | + if (account.status !== "active") return null; | |
| 348 | + if (account.kind === "agent") { | |
| 349 | + if (!account.owner_id) return null; | |
| 350 | + const owner = await this.account(account.owner_id); | |
| 351 | + if (!owner || owner.status !== "active") return null; | |
| 352 | + const ownerRole = owner.kind === "org" ? (repo.owner_id === owner.id ? "admin" : null) : await this.roleFor(owner, repo); | |
| 353 | + if (!ownerRole) return null; | |
| 354 | + const explicit = await this.db.prepare("SELECT role FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, account.id).first<{ role: Role }>(); | |
| 355 | + const policy = account.agent_policy ? (JSON.parse(account.agent_policy) as { maxRole?: Role }) : {}; | |
| 356 | + const cap: Role = explicit?.role ?? policy.maxRole ?? "write"; | |
| 357 | + return ROLE_LEVEL[ownerRole] < ROLE_LEVEL[cap] ? ownerRole : cap; | |
| 358 | + } | |
| 359 | + if (account.site_admin) return "admin"; | |
| 360 | + let best: Role | null = null; | |
| 361 | + const bump = (r: Role | null) => { | |
| 362 | + if (r && (!best || ROLE_LEVEL[r] > ROLE_LEVEL[best])) best = r; | |
| 363 | + }; | |
| 364 | + if (repo.owner_id === account.id) bump("admin"); | |
| 365 | + if (repo.owner_kind === "org") { | |
| 366 | + const orgRole = await this.orgRole(repo.owner_id, account.id); | |
| 367 | + if (orgRole === "owner" || orgRole === "admin") bump("admin"); | |
| 368 | + else if (orgRole === "member") bump("write"); | |
| 369 | + } | |
| 370 | + const explicit = await this.db.prepare("SELECT role FROM repo_access WHERE repo_id = ? AND account_id = ?").bind(repo.id, account.id).first<{ role: Role }>(); | |
| 371 | + bump(explicit?.role ?? null); | |
| 372 | + if (repo.visibility !== "private") bump("read"); | |
| 373 | + return best; | |
| 374 | + } | |
| 375 | + | |
| 376 | + // ---------------------------------------------------------------- notifications and audit | |
| 377 | + | |
| 378 | + async notify(accountIds: string[], n: { repoId?: string; kind: string; title: string; url: string }): Promise<void> { | |
| 379 | + const unique = [...new Set(accountIds)]; | |
| 380 | + if (!unique.length) return; | |
| 381 | + await this.db.batch(unique.map((id) => this.db.prepare("INSERT INTO notifications (id, account_id, repo_id, kind, title, url, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)").bind(newId(), id, n.repoId ?? null, n.kind, n.title, n.url, now()))); | |
| 382 | + } | |
| 383 | + | |
| 384 | + async notifications(accountId: string, unreadOnly = false): Promise<Array<{ id: string; kind: string; title: string; url: string; createdAt: string; readAt: string | null }>> { | |
| 385 | + const r = await this.db.prepare(`SELECT * FROM notifications WHERE account_id = ? ${unreadOnly ? "AND read_at IS NULL" : ""} ORDER BY created_at DESC LIMIT 100`).bind(accountId).all<Record<string, string | null>>(); | |
| 386 | + return r.results.map((n) => ({ id: n.id!, kind: n.kind!, title: n.title!, url: n.url!, createdAt: n.created_at!, readAt: n.read_at })); | |
| 387 | + } | |
| 388 | + | |
| 389 | + async markRead(accountId: string, id?: string): Promise<void> { | |
| 390 | + await this.db.prepare(`UPDATE notifications SET read_at = ? WHERE account_id = ? ${id ? "AND id = ?" : ""} AND read_at IS NULL`).bind(...(id ? [now(), accountId, id] : [now(), accountId])).run(); | |
| 391 | + } | |
| 392 | + | |
| 393 | + async audit(entry: { actorId?: string; actorLogin?: string; onBehalfOf?: string; action: string; target?: string; detail?: unknown; at?: string }): Promise<void> { | |
| 394 | + await this.db | |
| 395 | + .prepare("INSERT INTO audit_log (id, at, actor_id, actor_login, on_behalf_of, action, target, detail) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") | |
| 396 | + .bind(newId(), entry.at ?? now(), entry.actorId ?? null, entry.actorLogin ?? null, entry.onBehalfOf ?? null, entry.action, entry.target ?? null, entry.detail ? JSON.stringify(entry.detail) : null) | |
| 397 | + .run(); | |
| 398 | + } | |
| 399 | + | |
| 400 | + async auditLog(limit = 200): Promise<Array<Record<string, string | null>>> { | |
| 401 | + return (await this.db.prepare("SELECT * FROM audit_log ORDER BY at DESC LIMIT ?").bind(limit).all<Record<string, string | null>>()).results; | |
| 402 | + } | |
| 403 | +} |
addedapps/diffinery/src/lib/jwt.ts+50−0View file
| @@ -1,0 +1,50 @@ | ||
| 1 | +import { b64url, fromB64url } from "./util.js"; | |
| 2 | + | |
| 3 | +interface Jwk extends JsonWebKey { | |
| 4 | + kid?: string; | |
| 5 | +} | |
| 6 | + | |
| 7 | +const certCache = new Map<string, { keys: Jwk[]; at: number }>(); | |
| 8 | + | |
| 9 | +/** Verify a Cloudflare Access JWT (RS256) and return its claims. */ | |
| 10 | +export async function verifyAccessJwt(token: string, teamDomain: string, aud: string): Promise<Record<string, unknown>> { | |
| 11 | + const [h, p, s] = token.split("."); | |
| 12 | + if (!h || !p || !s) throw new Error("malformed JWT"); | |
| 13 | + const header = JSON.parse(new TextDecoder().decode(fromB64url(h))) as { kid?: string; alg?: string }; | |
| 14 | + if (header.alg !== "RS256") throw new Error("unexpected alg"); | |
| 15 | + const certsUrl = `https://${teamDomain}/cdn-cgi/access/certs`; | |
| 16 | + let cached = certCache.get(certsUrl); | |
| 17 | + if (!cached || Date.now() - cached.at > 10 * 60_000 || !cached.keys.some((k) => k.kid === header.kid)) { | |
| 18 | + const res = await fetch(certsUrl); | |
| 19 | + if (!res.ok) throw new Error(`failed to fetch Access certs: ${res.status}`); | |
| 20 | + cached = { keys: ((await res.json()) as { keys: Jwk[] }).keys, at: Date.now() }; | |
| 21 | + certCache.set(certsUrl, cached); | |
| 22 | + } | |
| 23 | + const jwk = cached.keys.find((k) => k.kid === header.kid); | |
| 24 | + if (!jwk) throw new Error("unknown signing key"); | |
| 25 | + const key = await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]); | |
| 26 | + const ok = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, fromB64url(s) as BufferSource, new TextEncoder().encode(`${h}.${p}`)); | |
| 27 | + if (!ok) throw new Error("bad signature"); | |
| 28 | + const claims = JSON.parse(new TextDecoder().decode(fromB64url(p))) as Record<string, unknown>; | |
| 29 | + const now = Math.floor(Date.now() / 1000); | |
| 30 | + if (typeof claims.exp === "number" && claims.exp < now) throw new Error("token expired"); | |
| 31 | + const auds = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; | |
| 32 | + if (!auds.includes(aud)) throw new Error("audience mismatch"); | |
| 33 | + if (claims.iss !== `https://${teamDomain}`) throw new Error("issuer mismatch"); | |
| 34 | + return claims; | |
| 35 | +} | |
| 36 | + | |
| 37 | +/** Sign an ES256 JWT with a private JWK (used for job OIDC tokens). */ | |
| 38 | +export async function signEs256(privateJwk: Jwk, claims: Record<string, unknown>): Promise<string> { | |
| 39 | + const key = await crypto.subtle.importKey("jwk", privateJwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]); | |
| 40 | + const enc = (o: unknown) => b64url(new TextEncoder().encode(JSON.stringify(o))); | |
| 41 | + const head = enc({ alg: "ES256", typ: "JWT", kid: privateJwk.kid ?? "diffinery-1" }); | |
| 42 | + const body = enc(claims); | |
| 43 | + const sig = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, new TextEncoder().encode(`${head}.${body}`))); | |
| 44 | + return `${head}.${body}.${b64url(sig)}`; | |
| 45 | +} | |
| 46 | + | |
| 47 | +export function publicJwk(privateJwk: Jwk): Jwk { | |
| 48 | + const { d: _d, key_ops: _ops, ...pub } = privateJwk; | |
| 49 | + return { ...pub, use: "sig", alg: "ES256", kid: privateJwk.kid ?? "diffinery-1" }; | |
| 50 | +} |
addedapps/diffinery/src/lib/util.ts+71−0View file
| @@ -1,0 +1,71 @@ | ||
| 1 | +export const now = (): string => new Date().toISOString(); | |
| 2 | + | |
| 3 | +export function newId(): string { | |
| 4 | + return crypto.randomUUID().replace(/-/g, ""); | |
| 5 | +} | |
| 6 | + | |
| 7 | +export function randomToken(bytes = 24): string { | |
| 8 | + const b = crypto.getRandomValues(new Uint8Array(bytes)); | |
| 9 | + return b64url(b); | |
| 10 | +} | |
| 11 | + | |
| 12 | +export function b64url(b: Uint8Array): string { | |
| 13 | + let s = ""; | |
| 14 | + for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]!); | |
| 15 | + return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); | |
| 16 | +} | |
| 17 | + | |
| 18 | +export function fromB64url(s: string): Uint8Array { | |
| 19 | + const pad = s.length % 4 ? "=".repeat(4 - (s.length % 4)) : ""; | |
| 20 | + const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad); | |
| 21 | + const out = new Uint8Array(bin.length); | |
| 22 | + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); | |
| 23 | + return out; | |
| 24 | +} | |
| 25 | + | |
| 26 | +export function b64(b: Uint8Array): string { | |
| 27 | + let s = ""; | |
| 28 | + for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]!); | |
| 29 | + return btoa(s); | |
| 30 | +} | |
| 31 | + | |
| 32 | +export function fromB64(s: string): Uint8Array { | |
| 33 | + const bin = atob(s); | |
| 34 | + const out = new Uint8Array(bin.length); | |
| 35 | + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); | |
| 36 | + return out; | |
| 37 | +} | |
| 38 | + | |
| 39 | +export async function sha256(s: string): Promise<string> { | |
| 40 | + const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); | |
| 41 | + return [...new Uint8Array(d)].map((x) => x.toString(16).padStart(2, "0")).join(""); | |
| 42 | +} | |
| 43 | + | |
| 44 | +export async function hmacSha256Hex(secret: string, body: string | Uint8Array): Promise<string> { | |
| 45 | + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); | |
| 46 | + const data = typeof body === "string" ? new TextEncoder().encode(body) : body; | |
| 47 | + const sig = await crypto.subtle.sign("HMAC", key, data as BufferSource); | |
| 48 | + return [...new Uint8Array(sig)].map((x) => x.toString(16).padStart(2, "0")).join(""); | |
| 49 | +} | |
| 50 | + | |
| 51 | +/** Constant-time string comparison. */ | |
| 52 | +export function safeEqual(a: string, b: string): boolean { | |
| 53 | + if (a.length !== b.length) return false; | |
| 54 | + let r = 0; | |
| 55 | + for (let i = 0; i < a.length; i++) r |= a.charCodeAt(i) ^ b.charCodeAt(i); | |
| 56 | + return r === 0; | |
| 57 | +} | |
| 58 | + | |
| 59 | +export class HttpError extends Error { | |
| 60 | + constructor(public status: number, message: string, public extra?: Record<string, unknown>) { | |
| 61 | + super(message); | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +export const isValidName = (s: string): boolean => /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/.test(s) && !s.endsWith(".git") && s !== "." && s !== ".."; | |
| 66 | + | |
| 67 | +export function isValidRefName(ref: string): boolean { | |
| 68 | + if (!ref.startsWith("refs/")) return false; | |
| 69 | + if (/(^|\/)\.|\.\.|[\x00-\x20~^:?*[\\\x7f]|@\{|\/\/|\/$|\.lock(\/|$)|\.$/.test(ref)) return false; | |
| 70 | + return true; | |
| 71 | +} |
addedapps/diffinery/src/repo/RepoDO.ts+723−0View file
| @@ -1,0 +1,723 @@ | ||
| 1 | +import { DurableObject } from "cloudflare:workers"; | |
| 2 | +import { | |
| 3 | + EMPTY_TREE_OID, | |
| 4 | + ZERO_OID, | |
| 5 | + applyChanges, | |
| 6 | + diffTrees, | |
| 7 | + fileDiff, | |
| 8 | + formatUnified, | |
| 9 | + fromUtf8, | |
| 10 | + isAncestor, | |
| 11 | + isBinary, | |
| 12 | + isOid, | |
| 13 | + isTreeMode, | |
| 14 | + mergeBase, | |
| 15 | + parsePack, | |
| 16 | + peelToCommit, | |
| 17 | + readCommit, | |
| 18 | + readTree, | |
| 19 | + resolvePath, | |
| 20 | + serializeCommit, | |
| 21 | + walkCommits, | |
| 22 | + type Commit, | |
| 23 | + type FileChange, | |
| 24 | + type Signature, | |
| 25 | +} from "@diffinery/git-core"; | |
| 26 | +import type { DiffineryEvent } from "@diffinery/shared"; | |
| 27 | +import type { Env } from "../env.js"; | |
| 28 | +import { | |
| 29 | + advertiseReceivePack, | |
| 30 | + advertiseUploadPackV2, | |
| 31 | + errorPacket, | |
| 32 | + handleUploadPackV2, | |
| 33 | + parseReceivePack, | |
| 34 | + reportStatus, | |
| 35 | + type RefEntry, | |
| 36 | + type RefResult, | |
| 37 | +} from "../git/protocol.js"; | |
| 38 | +import { scanForSecrets } from "../git/secret-scan.js"; | |
| 39 | +import { HttpError, isValidRefName, now } from "../lib/util.js"; | |
| 40 | +import { Actions } from "./actions.js"; | |
| 41 | +import { Collab } from "./collab.js"; | |
| 42 | +import { RepoObjectStore } from "./objects.js"; | |
| 43 | +import { REPO_SCHEMA } from "./schema.js"; | |
| 44 | +import { atLeast, globToRegExp, type Caller, type ProtectionRules, type RepoMeta } from "./types.js"; | |
| 45 | + | |
| 46 | +const MAX_PUSH_BYTES = 100 * 1024 * 1024; | |
| 47 | + | |
| 48 | +export interface TreeItem { | |
| 49 | + name: string; | |
| 50 | + path: string; | |
| 51 | + type: "tree" | "blob" | "commit"; | |
| 52 | + mode: string; | |
| 53 | + oid: string; | |
| 54 | + size?: number; | |
| 55 | +} | |
| 56 | + | |
| 57 | +export interface CommitInfo { | |
| 58 | + sha: string; | |
| 59 | + tree: string; | |
| 60 | + parents: string[]; | |
| 61 | + author: Signature; | |
| 62 | + committer: Signature; | |
| 63 | + message: string; | |
| 64 | + signed: boolean; | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** | |
| 68 | + * One Durable Object per repository. It is the single writer for the repo's | |
| 69 | + * refs, Git objects, issues, pull requests, reviews, checks and workflow runs, | |
| 70 | + * which gives strongly consistent ref updates and merges without locks. | |
| 71 | + */ | |
| 72 | +export class RepoDO extends DurableObject<Env> { | |
| 73 | + readonly sql: SqlStorage; | |
| 74 | + store!: RepoObjectStore; | |
| 75 | + readonly collab: Collab; | |
| 76 | + readonly actions: Actions; | |
| 77 | + private metaCache: RepoMeta | null = null; | |
| 78 | + | |
| 79 | + constructor(ctx: DurableObjectState, env: Env) { | |
| 80 | + super(ctx, env); | |
| 81 | + this.sql = ctx.storage.sql; | |
| 82 | + this.sql.exec(REPO_SCHEMA); | |
| 83 | + const meta = this.readMeta(); | |
| 84 | + if (meta) this.store = new RepoObjectStore(this.sql, env.BUCKET, meta.repoId); | |
| 85 | + this.collab = new Collab(this); | |
| 86 | + this.actions = new Actions(this); | |
| 87 | + } | |
| 88 | + | |
| 89 | + // ---------------------------------------------------------------- meta | |
| 90 | + | |
| 91 | + private readMeta(): RepoMeta | null { | |
| 92 | + const row = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = 'meta'").toArray()[0]; | |
| 93 | + this.metaCache = row ? (JSON.parse(row.value) as RepoMeta) : null; | |
| 94 | + return this.metaCache; | |
| 95 | + } | |
| 96 | + | |
| 97 | + get meta(): RepoMeta { | |
| 98 | + const m = this.metaCache ?? this.readMeta(); | |
| 99 | + if (!m) throw new HttpError(404, "repository not initialised"); | |
| 100 | + return m; | |
| 101 | + } | |
| 102 | + | |
| 103 | + async init(meta: RepoMeta): Promise<void> { | |
| 104 | + this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES ('meta', ?)", JSON.stringify(meta)); | |
| 105 | + this.metaCache = meta; | |
| 106 | + this.store = new RepoObjectStore(this.sql, this.env.BUCKET, meta.repoId); | |
| 107 | + } | |
| 108 | + | |
| 109 | + async updateMeta(patch: Partial<RepoMeta>): Promise<RepoMeta> { | |
| 110 | + const next = { ...this.meta, ...patch }; | |
| 111 | + await this.init(next); | |
| 112 | + return next; | |
| 113 | + } | |
| 114 | + | |
| 115 | + /** Wipe all storage (repository deletion). */ | |
| 116 | + async destroy(): Promise<void> { | |
| 117 | + await this.ctx.storage.deleteAll(); | |
| 118 | + this.metaCache = null; | |
| 119 | + } | |
| 120 | + | |
| 121 | + async stats(): Promise<{ objects: number; bytes: number; refs: number; openIssues: number; openPulls: number }> { | |
| 122 | + const q = (s: string) => this.sql.exec<{ n: number }>(s).one().n; | |
| 123 | + return { | |
| 124 | + objects: this.store.count(), | |
| 125 | + bytes: this.store.sizeBytes(), | |
| 126 | + refs: q("SELECT count(*) AS n FROM refs"), | |
| 127 | + openIssues: q("SELECT count(*) AS n FROM issues WHERE kind = 'issue' AND state = 'open'"), | |
| 128 | + openPulls: q("SELECT count(*) AS n FROM issues WHERE kind = 'pull' AND state = 'open'"), | |
| 129 | + }; | |
| 130 | + } | |
| 131 | + | |
| 132 | + emit(event: DiffineryEvent): void { | |
| 133 | + this.ctx.waitUntil(this.env.EVENTS.send(event).catch((e) => console.error("event send failed", e))); | |
| 134 | + this.broadcast("repo", { kind: "event", event }); | |
| 135 | + } | |
| 136 | + | |
| 137 | + // ---------------------------------------------------------------- refs | |
| 138 | + | |
| 139 | + refs(prefix = "refs/"): RefEntry[] { | |
| 140 | + return this.sql.exec<RefEntry>("SELECT name, oid FROM refs WHERE name >= ? AND name < ? ORDER BY name", prefix, `${prefix}`).toArray(); | |
| 141 | + } | |
| 142 | + | |
| 143 | + getRef(name: string): string | null { | |
| 144 | + return this.sql.exec<{ oid: string }>("SELECT oid FROM refs WHERE name = ?", name).toArray()[0]?.oid ?? null; | |
| 145 | + } | |
| 146 | + | |
| 147 | + /** Compare-and-swap a ref. `expected` = ZERO_OID means "must not exist". */ | |
| 148 | + casRef(name: string, expected: string | null, next: string | null): boolean { | |
| 149 | + const current = this.getRef(name); | |
| 150 | + if (expected !== null && (current ?? ZERO_OID) !== expected) return false; | |
| 151 | + if (next === null || next === ZERO_OID) this.sql.exec("DELETE FROM refs WHERE name = ?", name); | |
| 152 | + else this.sql.exec("INSERT OR REPLACE INTO refs (name, oid) VALUES (?, ?)", name, next); | |
| 153 | + return true; | |
| 154 | + } | |
| 155 | + | |
| 156 | + get headRef(): string { | |
| 157 | + return `refs/heads/${this.meta.defaultBranch}`; | |
| 158 | + } | |
| 159 | + | |
| 160 | + /** Resolve a branch, tag, full ref, HEAD or (abbreviated) SHA to a commit SHA. */ | |
| 161 | + async resolve(rev: string | undefined | null): Promise<string | null> { | |
| 162 | + const r = rev && rev.length ? rev : "HEAD"; | |
| 163 | + let oid: string | null = null; | |
| 164 | + if (r === "HEAD") oid = this.getRef(this.headRef); | |
| 165 | + else if (isOid(r)) oid = this.store.has(r) ? r : null; | |
| 166 | + else if (r.startsWith("refs/")) oid = this.getRef(r); | |
| 167 | + else oid = this.getRef(`refs/heads/${r}`) ?? this.getRef(`refs/tags/${r}`); | |
| 168 | + if (!oid && /^[0-9a-f]{7,39}$/.test(r)) { | |
| 169 | + const rows = this.sql.exec<{ oid: string }>("SELECT oid FROM objects WHERE oid >= ? AND oid < ? LIMIT 2", r, `${r}g`).toArray(); | |
| 170 | + if (rows.length === 1) oid = rows[0]!.oid; | |
| 171 | + } | |
| 172 | + if (!oid) return null; | |
| 173 | + return peelToCommit(this.store, oid); | |
| 174 | + } | |
| 175 | + | |
| 176 | + async branches(): Promise<Array<{ name: string; sha: string; protected: boolean; isDefault: boolean }>> { | |
| 177 | + return this.refs("refs/heads/").map((r) => { | |
| 178 | + const name = r.name.slice(11); | |
| 179 | + return { name, sha: r.oid, protected: !!this.protectionFor(name), isDefault: name === this.meta.defaultBranch }; | |
| 180 | + }); | |
| 181 | + } | |
| 182 | + | |
| 183 | + async tags(): Promise<Array<{ name: string; sha: string; target: string }>> { | |
| 184 | + const out = []; | |
| 185 | + for (const r of this.refs("refs/tags/")) out.push({ name: r.name.slice(10), sha: r.oid, target: await peelToCommit(this.store, r.oid).catch(() => r.oid) }); | |
| 186 | + return out; | |
| 187 | + } | |
| 188 | + | |
| 189 | + // ---------------------------------------------------------------- git smart HTTP | |
| 190 | + | |
| 191 | + async gitInfoRefs(service: "git-upload-pack" | "git-receive-pack"): Promise<Uint8Array> { | |
| 192 | + if (service === "git-upload-pack") return advertiseUploadPackV2(); | |
| 193 | + return advertiseReceivePack(this.refs()); | |
| 194 | + } | |
| 195 | + | |
| 196 | + async gitUploadPack(body: Uint8Array): Promise<Uint8Array> { | |
| 197 | + try { | |
| 198 | + return await handleUploadPackV2(body, { store: this.store, refs: this.refs(), headTarget: this.headRef }); | |
| 199 | + } catch (e) { | |
| 200 | + return errorPacket((e as Error).message); | |
| 201 | + } | |
| 202 | + } | |
| 203 | + | |
| 204 | + async gitReceivePack(body: Uint8Array, caller: Caller): Promise<Uint8Array> { | |
| 205 | + if (body.length > MAX_PUSH_BYTES) { | |
| 206 | + const req = { commands: [], capabilities: new Set<string>(), pushOptions: [], pack: null }; | |
| 207 | + return reportStatus(req, "push exceeds 100 MB; split it into smaller pushes", []); | |
| 208 | + } | |
| 209 | + const req = parseReceivePack(body); | |
| 210 | + const messages: string[] = []; | |
| 211 | + let unpackError: string | null = null; | |
| 212 | + const newBlobs: Array<{ oid: string; content: Uint8Array }> = []; | |
| 213 | + if (req.pack && req.pack.length > 0) { | |
| 214 | + try { | |
| 215 | + const objects = await parsePack(req.pack, (oid) => this.store.read(oid)); | |
| 216 | + for (const o of objects) { | |
| 217 | + await this.store.writeKnown(o.oid, o.type, o.content); | |
| 218 | + if (o.type === "blob" && o.content.length < 1024 * 1024) newBlobs.push({ oid: o.oid, content: o.content }); | |
| 219 | + } | |
| 220 | + } catch (e) { | |
| 221 | + unpackError = (e as Error).message; | |
| 222 | + } | |
| 223 | + } | |
| 224 | + const results: RefResult[] = []; | |
| 225 | + if (unpackError) { | |
| 226 | + for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "unpacker error" }); | |
| 227 | + return reportStatus(req, unpackError, results); | |
| 228 | + } | |
| 229 | + | |
| 230 | + const leaks = scanForSecrets(newBlobs); | |
| 231 | + if (leaks.length) { | |
| 232 | + messages.push("Push blocked: possible secrets detected."); | |
| 233 | + for (const l of leaks.slice(0, 10)) messages.push(` ${l.kind} in blob ${l.oid.slice(0, 10)}`); | |
| 234 | + messages.push("Remove the secret from your commits, or rotate it and contact an admin."); | |
| 235 | + for (const c of req.commands) results.push({ ref: c.ref, ok: false, reason: "secret detected (push protection)" }); | |
| 236 | + this.emit({ type: "audit", action: "push.blocked_secret", actor: caller, target: `${this.meta.owner}/${this.meta.name}`, detail: { kinds: leaks.map((l) => l.kind) }, at: now() }); | |
| 237 | + return reportStatus(req, null, results, messages); | |
| 238 | + } | |
| 239 | + | |
| 240 | + // Validate every command before applying any, so `atomic` is honoured. | |
| 241 | + const checks: Array<RefResult & { cmd: (typeof req.commands)[number] }> = []; | |
| 242 | + for (const cmd of req.commands) checks.push({ cmd, ...(await this.checkRefUpdate(cmd.ref, cmd.oldOid, cmd.newOid, caller)) }); | |
| 243 | + const atomic = req.capabilities.has("atomic"); | |
| 244 | + const anyFailed = checks.some((c) => !c.ok); | |
| 245 | + for (const c of checks) { | |
| 246 | + if (!c.ok) { | |
| 247 | + results.push({ ref: c.ref, ok: false, reason: c.reason }); | |
| 248 | + continue; | |
| 249 | + } | |
| 250 | + if (atomic && anyFailed) { | |
| 251 | + results.push({ ref: c.ref, ok: false, reason: "atomic transaction failed" }); | |
| 252 | + continue; | |
| 253 | + } | |
| 254 | + const applied = this.casRef(c.cmd.ref, c.cmd.oldOid, c.cmd.newOid); | |
| 255 | + results.push(applied ? { ref: c.ref, ok: true } : { ref: c.ref, ok: false, reason: "fetch first (stale info)" }); | |
| 256 | + if (applied) await this.afterRefUpdate(c.cmd.ref, c.cmd.oldOid, c.cmd.newOid, caller, messages); | |
| 257 | + } | |
| 258 | + return reportStatus(req, null, results, messages); | |
| 259 | + } | |
| 260 | + | |
| 261 | + protectionFor(branch: string): ProtectionRules | null { | |
| 262 | + for (const row of this.sql.exec<{ pattern: string; rules: string }>("SELECT pattern, rules FROM protections").toArray()) { | |
| 263 | + if (globToRegExp(row.pattern).test(branch)) return JSON.parse(row.rules) as ProtectionRules; | |
| 264 | + } | |
| 265 | + return null; | |
| 266 | + } | |
| 267 | + | |
| 268 | + /** Server-side rules for a ref update: permissions, protection, fast-forward, agent guardrails. */ | |
| 269 | + async checkRefUpdate(ref: string, oldOid: string, newOid: string, caller: Caller): Promise<RefResult> { | |
| 270 | + if (!isValidRefName(ref)) return { ref, ok: false, reason: "invalid ref name" }; | |
| 271 | + if (!atLeast(caller.role, "write")) return { ref, ok: false, reason: "permission denied" }; | |
| 272 | + const deleting = newOid === ZERO_OID; | |
| 273 | + if (!deleting && !this.store.has(newOid)) return { ref, ok: false, reason: "missing objects" }; | |
| 274 | + if (!ref.startsWith("refs/heads/")) { | |
| 275 | + if (ref.startsWith("refs/tags/") && oldOid !== ZERO_OID && !atLeast(caller.role, "maintain")) return { ref, ok: false, reason: "only maintainers may move tags" }; | |
| 276 | + return { ref, ok: true }; | |
| 277 | + } | |
| 278 | + const branch = ref.slice(11); | |
| 279 | + const rules = this.protectionFor(branch); | |
| 280 | + const isDefault = branch === this.meta.defaultBranch; | |
| 281 | + if (caller.kind === "agent" && (isDefault || rules) && !rules?.agentsMayPush) { | |
| 282 | + return { ref, ok: false, reason: "agents may not push to protected or default branches; open a pull request" }; | |
| 283 | + } | |
| 284 | + if (deleting && isDefault) return { ref, ok: false, reason: "cannot delete the default branch" }; | |
| 285 | + if (!rules) return { ref, ok: true }; | |
| 286 | + const bypass = caller.role === "admin" && !rules.enforceAdmins; | |
| 287 | + if (bypass) return { ref, ok: true }; | |
| 288 | + if (deleting) return rules.allowDeletion ? { ref, ok: true } : { ref, ok: false, reason: "protected branch: deletion not allowed" }; | |
| 289 | + if (rules.requirePullRequest) return { ref, ok: false, reason: "protected branch: changes must be made through a pull request" }; | |
| 290 | + if (oldOid !== ZERO_OID && !rules.allowForcePush && !(await isAncestor(this.store, oldOid, newOid))) { | |
| 291 | + return { ref, ok: false, reason: "protected branch: force push not allowed" }; | |
| 292 | + } | |
| 293 | + if (rules.requireLinearHistory) { | |
| 294 | + const fresh = await walkCommits(this.store, [newOid], { stop: new Set(oldOid === ZERO_OID ? [] : [oldOid]), limit: 1000 }); | |
| 295 | + if (fresh.some((c) => c.commit.parents.length > 1)) return { ref, ok: false, reason: "protected branch: merge commits not allowed (linear history)" }; | |
| 296 | + } | |
| 297 | + return { ref, ok: true }; | |
| 298 | + } | |
| 299 | + | |
| 300 | + /** Side effects after a ref moved: events, PR sync, workflow triggers. */ | |
| 301 | + async afterRefUpdate(ref: string, oldOid: string, newOid: string, caller: Caller, messages: string[] = []): Promise<void> { | |
| 302 | + const at = now(); | |
| 303 | + const forced = oldOid !== ZERO_OID && newOid !== ZERO_OID && !(await isAncestor(this.store, oldOid, newOid)); | |
| 304 | + const ev: DiffineryEvent = { | |
| 305 | + type: "push", | |
| 306 | + repoId: this.meta.repoId, | |
| 307 | + owner: this.meta.owner, | |
| 308 | + repo: this.meta.name, | |
| 309 | + ref, | |
| 310 | + before: oldOid, | |
| 311 | + after: newOid, | |
| 312 | + actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, | |
| 313 | + forced, | |
| 314 | + created: oldOid === ZERO_OID, | |
| 315 | + deleted: newOid === ZERO_OID, | |
| 316 | + at, | |
| 317 | + }; | |
| 318 | + this.emit(ev); | |
| 319 | + if (ref.startsWith("refs/heads/") && newOid !== ZERO_OID) { | |
| 320 | + const branch = ref.slice(11); | |
| 321 | + const synced = await this.collab.syncPullsForBranch(branch, newOid, caller); | |
| 322 | + for (const n of synced) messages.push(`Updated pull request #${n}: ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}/pull/${n}`); | |
| 323 | + if (oldOid === ZERO_OID && branch !== this.meta.defaultBranch && synced.length === 0) { | |
| 324 | + messages.push(`Create a pull request for '${branch}':`); | |
| 325 | + messages.push(` ${this.env.PUBLIC_URL}/${this.meta.owner}/${this.meta.name}/compare/${encodeURIComponent(branch)}`); | |
| 326 | + } | |
| 327 | + } | |
| 328 | + if (newOid !== ZERO_OID) await this.actions.onPush(ev, caller).catch((e) => messages.push(`Workflow trigger error: ${(e as Error).message}`)); | |
| 329 | + } | |
| 330 | + | |
| 331 | + // ---------------------------------------------------------------- browsing | |
| 332 | + | |
| 333 | + async commitInfo(sha: string): Promise<CommitInfo> { | |
| 334 | + const c = await readCommit(this.store, sha); | |
| 335 | + return toInfo(sha, c); | |
| 336 | + } | |
| 337 | + | |
| 338 | + async tree(rev: string, path = ""): Promise<{ sha: string; items: TreeItem[] } | null> { | |
| 339 | + const sha = await this.resolve(rev); | |
| 340 | + if (!sha) return null; | |
| 341 | + const c = await readCommit(this.store, sha); | |
| 342 | + const entry = await resolvePath(this.store, c.tree, path); | |
| 343 | + if (!entry || !isTreeMode(entry.mode)) return null; | |
| 344 | + const items: TreeItem[] = []; | |
| 345 | + for (const e of await readTree(this.store, entry.oid)) { | |
| 346 | + const type = isTreeMode(e.mode) ? "tree" : e.mode === "160000" ? "commit" : "blob"; | |
| 347 | + const raw = type === "blob" ? this.sql.exec<{ size: number }>("SELECT size FROM objects WHERE oid = ?", e.oid).toArray()[0] : undefined; | |
| 348 | + items.push({ name: e.name, path: path ? `${path}/${e.name}` : e.name, type, mode: e.mode, oid: e.oid, size: raw?.size }); | |
| 349 | + } | |
| 350 | + items.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "tree" ? -1 : 1)); | |
| 351 | + return { sha, items }; | |
| 352 | + } | |
| 353 | + | |
| 354 | + async blob(rev: string, path: string): Promise<{ sha: string; oid: string; mode: string; size: number; binary: boolean; content: Uint8Array } | null> { | |
| 355 | + const sha = await this.resolve(rev); | |
| 356 | + if (!sha) return null; | |
| 357 | + const c = await readCommit(this.store, sha); | |
| 358 | + const entry = await resolvePath(this.store, c.tree, path); | |
| 359 | + if (!entry || isTreeMode(entry.mode)) return null; | |
| 360 | + const o = await this.store.read(entry.oid); | |
| 361 | + if (!o) return null; | |
| 362 | + return { sha, oid: entry.oid, mode: entry.mode, size: o.content.length, binary: isBinary(o.content), content: o.content }; | |
| 363 | + } | |
| 364 | + | |
| 365 | + async log(rev: string, opts: { path?: string; limit?: number; skip?: number } = {}): Promise<CommitInfo[]> { | |
| 366 | + const sha = await this.resolve(rev); | |
| 367 | + if (!sha) return []; | |
| 368 | + const limit = Math.min(opts.limit ?? 30, 100); | |
| 369 | + const skip = opts.skip ?? 0; | |
| 370 | + if (!opts.path) { | |
| 371 | + const commits = await walkCommits(this.store, [sha], { limit: limit + skip }); | |
| 372 | + return commits.slice(skip).map((c) => toInfo(c.oid, c.commit)); | |
| 373 | + } | |
| 374 | + // Path history: commits whose tree entry at `path` differs from their first parent's. | |
| 375 | + const out: CommitInfo[] = []; | |
| 376 | + let skipped = 0; | |
| 377 | + for (const c of await walkCommits(this.store, [sha], { limit: 2000 })) { | |
| 378 | + const mine = await resolvePath(this.store, c.commit.tree, opts.path); | |
| 379 | + const parent = c.commit.parents[0] ? await readCommit(this.store, c.commit.parents[0]).catch(() => null) : null; | |
| 380 | + const theirs = parent ? await resolvePath(this.store, parent.tree, opts.path) : null; | |
| 381 | + if ((mine?.oid ?? null) === (theirs?.oid ?? null)) continue; | |
| 382 | + if (skipped++ < skip) continue; | |
| 383 | + out.push(toInfo(c.oid, c.commit)); | |
| 384 | + if (out.length >= limit) break; | |
| 385 | + } | |
| 386 | + return out; | |
| 387 | + } | |
| 388 | + | |
| 389 | + /** Diff between two commits (or a commit and its first parent). */ | |
| 390 | + async diff(baseSha: string | null, headSha: string, opts: { patch?: boolean; maxFiles?: number } = {}) { | |
| 391 | + const head = await readCommit(this.store, headSha); | |
| 392 | + const baseTree = baseSha ? (await readCommit(this.store, baseSha)).tree : null; | |
| 393 | + const changes = await diffTrees(this.store, baseTree, head.tree); | |
| 394 | + const max = opts.maxFiles ?? 300; | |
| 395 | + const files = []; | |
| 396 | + for (const ch of changes.slice(0, max)) { | |
| 397 | + const d = await fileDiff(this.store, ch); | |
| 398 | + files.push({ ...d, patch: opts.patch === false ? undefined : formatUnified(d) }); | |
| 399 | + } | |
| 400 | + return { | |
| 401 | + files, | |
| 402 | + totalFiles: changes.length, | |
| 403 | + additions: files.reduce((s, f) => s + f.additions, 0), | |
| 404 | + deletions: files.reduce((s, f) => s + f.deletions, 0), | |
| 405 | + truncated: changes.length > max, | |
| 406 | + }; | |
| 407 | + } | |
| 408 | + | |
| 409 | + async commitWithDiff(rev: string) { | |
| 410 | + const sha = await this.resolve(rev); | |
| 411 | + if (!sha) throw new HttpError(404, "commit not found"); | |
| 412 | + const info = await this.commitInfo(sha); | |
| 413 | + return { ...info, ...(await this.diff(info.parents[0] ?? null, sha)) }; | |
| 414 | + } | |
| 415 | + | |
| 416 | + async compare(base: string, head: string) { | |
| 417 | + const b = await this.resolve(base); | |
| 418 | + const h = await this.resolve(head); | |
| 419 | + if (!b || !h) throw new HttpError(404, "unknown base or head"); | |
| 420 | + const mb = await mergeBase(this.store, b, h); | |
| 421 | + const commits = (await walkCommits(this.store, [h], { stop: new Set(mb ? (await walkCommits(this.store, [mb], { limit: 100000 })).map((c) => c.oid) : []), limit: 250 })).map((c) => toInfo(c.oid, c.commit)); | |
| 422 | + const ahead = commits.length; | |
| 423 | + const behind = mb ? (await walkCommits(this.store, [b], { stop: new Set([mb]), limit: 1000 })).filter((c) => c.oid !== mb).length : 0; | |
| 424 | + return { baseSha: b, headSha: h, mergeBase: mb, aheadBy: ahead, behindBy: behind, commits: commits.reverse(), ...(await this.diff(mb, h)) }; | |
| 425 | + } | |
| 426 | + | |
| 427 | + /** | |
| 428 | + * Create a commit from file changes without a Git client (used by the web | |
| 429 | + * editor, the REST contents API and AI agents through MCP). | |
| 430 | + */ | |
| 431 | + async commitFiles(input: { | |
| 432 | + branch: string; | |
| 433 | + message: string; | |
| 434 | + changes: FileChange[]; | |
| 435 | + expectedHeadSha?: string; | |
| 436 | + createBranchFrom?: string; | |
| 437 | + author?: { name: string; email: string }; | |
| 438 | + }, caller: Caller): Promise<{ sha: string; branch: string }> { | |
| 439 | + const ref = `refs/heads/${input.branch}`; | |
| 440 | + let parent = this.getRef(ref); | |
| 441 | + if (!parent) { | |
| 442 | + if (input.createBranchFrom) parent = await this.resolve(input.createBranchFrom); | |
| 443 | + else if (this.refs("refs/heads/").length > 0) throw new HttpError(404, `branch ${input.branch} not found`); | |
| 444 | + } | |
| 445 | + if (input.expectedHeadSha && parent !== input.expectedHeadSha) throw new HttpError(409, "branch moved; refresh and retry", { currentSha: parent }); | |
| 446 | + const baseTree = parent ? (await readCommit(this.store, parent)).tree : null; | |
| 447 | + const tree = await applyChanges(this.store, baseTree, input.changes); | |
| 448 | + const ts = Math.floor(Date.now() / 1000); | |
| 449 | + const committer = { name: caller.login, email: `${caller.login}@users.noreply.diffinery.com`, timestamp: ts, timezone: "+0000" }; | |
| 450 | + const author = input.author ? { ...input.author, timestamp: ts, timezone: "+0000" } : committer; | |
| 451 | + const extra: [string, string][] = caller.kind === "agent" ? [["diffinery-agent", `${caller.login}${caller.onBehalfOf ? ` on-behalf-of ${caller.onBehalfOf}` : ""}`]] : []; | |
| 452 | + const sha = await this.store.write( | |
| 453 | + "commit", | |
| 454 | + serializeCommit({ tree: tree === EMPTY_TREE_OID ? tree : tree, parents: parent ? [parent] : [], author, committer, message: input.message.endsWith("\n") ? input.message : `${input.message}\n`, extraHeaders: extra }), | |
| 455 | + ); | |
| 456 | + const old = parent && this.getRef(ref) ? parent : ZERO_OID; | |
| 457 | + const check = await this.checkRefUpdate(ref, old, sha, caller); | |
| 458 | + if (!check.ok) throw new HttpError(403, check.reason ?? "rejected"); | |
| 459 | + if (!this.casRef(ref, old, sha)) throw new HttpError(409, "branch moved; refresh and retry"); | |
| 460 | + await this.afterRefUpdate(ref, old, sha, caller); | |
| 461 | + return { sha, branch: input.branch }; | |
| 462 | + } | |
| 463 | + | |
| 464 | + async createRef(ref: string, sha: string, caller: Caller): Promise<void> { | |
| 465 | + if (!this.store.has(sha)) throw new HttpError(422, "object does not exist"); | |
| 466 | + const check = await this.checkRefUpdate(ref, ZERO_OID, sha, caller); | |
| 467 | + if (!check.ok) throw new HttpError(403, check.reason ?? "rejected"); | |
| 468 | + if (!this.casRef(ref, ZERO_OID, sha)) throw new HttpError(422, "reference already exists"); | |
| 469 | + await this.afterRefUpdate(ref, ZERO_OID, sha, caller); | |
| 470 | + } | |
| 471 | + | |
| 472 | + async updateRef(ref: string, sha: string, force: boolean, caller: Caller): Promise<void> { | |
| 473 | + const old = this.getRef(ref); | |
| 474 | + if (!old) throw new HttpError(404, "reference not found"); | |
| 475 | + if (!force && !(await isAncestor(this.store, old, sha))) throw new HttpError(422, "update is not a fast-forward"); | |
| 476 | + const check = await this.checkRefUpdate(ref, old, sha, caller); | |
| 477 | + if (!check.ok) throw new HttpError(403, check.reason ?? "rejected"); | |
| 478 | + if (!this.casRef(ref, old, sha)) throw new HttpError(409, "reference moved"); | |
| 479 | + await this.afterRefUpdate(ref, old, sha, caller); | |
| 480 | + } | |
| 481 | + | |
| 482 | + async deleteRef(ref: string, caller: Caller): Promise<void> { | |
| 483 | + const old = this.getRef(ref); | |
| 484 | + if (!old) throw new HttpError(404, "reference not found"); | |
| 485 | + const check = await this.checkRefUpdate(ref, old, ZERO_OID, caller); | |
| 486 | + if (!check.ok) throw new HttpError(403, check.reason ?? "rejected"); | |
| 487 | + this.casRef(ref, old, null); | |
| 488 | + await this.afterRefUpdate(ref, old, ZERO_OID, caller); | |
| 489 | + } | |
| 490 | + | |
| 491 | + // ---------------------------------------------------------------- protections | |
| 492 | + | |
| 493 | + listProtections(): Array<{ pattern: string; rules: ProtectionRules }> { | |
| 494 | + return this.sql.exec<{ pattern: string; rules: string }>("SELECT pattern, rules FROM protections ORDER BY pattern").toArray().map((r) => ({ pattern: r.pattern, rules: JSON.parse(r.rules) })); | |
| 495 | + } | |
| 496 | + | |
| 497 | + setProtection(pattern: string, rules: ProtectionRules | null): void { | |
| 498 | + if (rules) this.sql.exec("INSERT OR REPLACE INTO protections (pattern, rules) VALUES (?, ?)", pattern, JSON.stringify(rules)); | |
| 499 | + else this.sql.exec("DELETE FROM protections WHERE pattern = ?", pattern); | |
| 500 | + } | |
| 501 | + | |
| 502 | + // ---------------------------------------------------------------- RPC passthroughs (collab + actions) | |
| 503 | + // Workers RPC only exposes methods on the class itself, so the sub-modules are wrapped here. | |
| 504 | + | |
| 505 | + issues(...a: Parameters<Collab["list"]>) { | |
| 506 | + return this.collab.list(...a); | |
| 507 | + } | |
| 508 | + issue(...a: Parameters<Collab["get"]>) { | |
| 509 | + return this.collab.get(...a); | |
| 510 | + } | |
| 511 | + createIssue(...a: Parameters<Collab["createIssue"]>) { | |
| 512 | + return this.collab.createIssue(...a); | |
| 513 | + } | |
| 514 | + updateIssue(...a: Parameters<Collab["update"]>) { | |
| 515 | + return this.collab.update(...a); | |
| 516 | + } | |
| 517 | + addComment(...a: Parameters<Collab["addComment"]>) { | |
| 518 | + return this.collab.addComment(...a); | |
| 519 | + } | |
| 520 | + editComment(...a: Parameters<Collab["editComment"]>) { | |
| 521 | + return this.collab.editComment(...a); | |
| 522 | + } | |
| 523 | + createPull(...a: Parameters<Collab["createPull"]>) { | |
| 524 | + return this.collab.createPull(...a); | |
| 525 | + } | |
| 526 | + pullFiles(...a: Parameters<Collab["pullFiles"]>) { | |
| 527 | + return this.collab.pullFiles(...a); | |
| 528 | + } | |
| 529 | + pullCommits(...a: Parameters<Collab["pullCommits"]>) { | |
| 530 | + return this.collab.pullCommits(...a); | |
| 531 | + } | |
| 532 | + submitReview(...a: Parameters<Collab["submitReview"]>) { | |
| 533 | + return this.collab.submitReview(...a); | |
| 534 | + } | |
| 535 | + resolveThread(...a: Parameters<Collab["resolveThread"]>) { | |
| 536 | + return this.collab.resolveThread(...a); | |
| 537 | + } | |
| 538 | + mergeability(...a: Parameters<Collab["mergeability"]>) { | |
| 539 | + return this.collab.mergeability(...a); | |
| 540 | + } | |
| 541 | + mergePull(...a: Parameters<Collab["merge"]>) { | |
| 542 | + return this.collab.merge(...a); | |
| 543 | + } | |
| 544 | + requestReviewers(...a: Parameters<Collab["requestReviewers"]>) { | |
| 545 | + return this.collab.requestReviewers(...a); | |
| 546 | + } | |
| 547 | + labels(...a: Parameters<Collab["labels"]>) { | |
| 548 | + return this.collab.labels(...a); | |
| 549 | + } | |
| 550 | + setLabel(...a: Parameters<Collab["setLabel"]>) { | |
| 551 | + return this.collab.setLabel(...a); | |
| 552 | + } | |
| 553 | + checks(...a: Parameters<Collab["checks"]>) { | |
| 554 | + return this.collab.checks(...a); | |
| 555 | + } | |
| 556 | + upsertCheck(...a: Parameters<Collab["upsertCheck"]>) { | |
| 557 | + return this.collab.upsertCheck(...a); | |
| 558 | + } | |
| 559 | + webhooks(...a: Parameters<Collab["webhooks"]>) { | |
| 560 | + return this.collab.webhooks(...a); | |
| 561 | + } | |
| 562 | + createWebhook(...a: Parameters<Collab["createWebhook"]>) { | |
| 563 | + return this.collab.createWebhook(...a); | |
| 564 | + } | |
| 565 | + deleteWebhook(...a: Parameters<Collab["deleteWebhook"]>) { | |
| 566 | + return this.collab.deleteWebhook(...a); | |
| 567 | + } | |
| 568 | + recordDelivery(...a: Parameters<Collab["recordDelivery"]>) { | |
| 569 | + return this.collab.recordDelivery(...a); | |
| 570 | + } | |
| 571 | + | |
| 572 | + runs(...a: Parameters<Actions["listRuns"]>) { | |
| 573 | + return this.actions.listRuns(...a); | |
| 574 | + } | |
| 575 | + run(...a: Parameters<Actions["getRun"]>) { | |
| 576 | + return this.actions.getRun(...a); | |
| 577 | + } | |
| 578 | + runJobs(...a: Parameters<Actions["jobs"]>) { | |
| 579 | + return this.actions.jobs(...a); | |
| 580 | + } | |
| 581 | + job(...a: Parameters<Actions["getJob"]>) { | |
| 582 | + return this.actions.getJob(...a); | |
| 583 | + } | |
| 584 | + jobLogs(...a: Parameters<Actions["logs"]>) { | |
| 585 | + return this.actions.logs(...a); | |
| 586 | + } | |
| 587 | + cancelRun(...a: Parameters<Actions["cancel"]>) { | |
| 588 | + return this.actions.cancel(...a); | |
| 589 | + } | |
| 590 | + rerun(...a: Parameters<Actions["rerun"]>) { | |
| 591 | + return this.actions.rerun(...a); | |
| 592 | + } | |
| 593 | + dispatchWorkflow(...a: Parameters<Actions["dispatch"]>) { | |
| 594 | + return this.actions.dispatch(...a); | |
| 595 | + } | |
| 596 | + scheduleTick(...a: Parameters<Actions["schedule"]>) { | |
| 597 | + return this.actions.schedule(...a); | |
| 598 | + } | |
| 599 | + onPullRequestEvent(...a: Parameters<Actions["onPullRequest"]>) { | |
| 600 | + return this.actions.onPullRequest(...a); | |
| 601 | + } | |
| 602 | + workflows(...a: Parameters<Actions["listWorkflows"]>) { | |
| 603 | + return this.actions.listWorkflows(...a); | |
| 604 | + } | |
| 605 | + runPlan(...a: Parameters<Actions["plan"]>) { | |
| 606 | + return this.actions.plan(...a); | |
| 607 | + } | |
| 608 | + startJobKey(...a: Parameters<Actions["startJobKey"]>) { | |
| 609 | + return this.actions.startJobKey(...a); | |
| 610 | + } | |
| 611 | + jobTimedOut(...a: Parameters<Actions["jobTimedOut"]>) { | |
| 612 | + return this.actions.jobTimedOut(...a); | |
| 613 | + } | |
| 614 | + finishRun(...a: Parameters<Actions["finishRun"]>) { | |
| 615 | + return this.actions.finishRun(...a); | |
| 616 | + } | |
| 617 | + runnerSpec(...a: Parameters<Actions["runnerSpec"]>) { | |
| 618 | + return this.actions.runnerSpec(...a); | |
| 619 | + } | |
| 620 | + runnerLogs(...a: Parameters<Actions["runnerLogs"]>) { | |
| 621 | + return this.actions.runnerLogs(...a); | |
| 622 | + } | |
| 623 | + runnerStep(...a: Parameters<Actions["runnerStep"]>) { | |
| 624 | + return this.actions.runnerStep(...a); | |
| 625 | + } | |
| 626 | + runnerComplete(...a: Parameters<Actions["runnerComplete"]>) { | |
| 627 | + return this.actions.runnerComplete(...a); | |
| 628 | + } | |
| 629 | + runnerAuth(...a: Parameters<Actions["runnerAuth"]>) { | |
| 630 | + return this.actions.runnerAuth(...a); | |
| 631 | + } | |
| 632 | + containerStopped(...a: Parameters<Actions["containerStopped"]>) { | |
| 633 | + return this.actions.containerStopped(...a); | |
| 634 | + } | |
| 635 | + setSecret(...a: Parameters<Actions["setSecret"]>) { | |
| 636 | + return this.actions.setSecret(...a); | |
| 637 | + } | |
| 638 | + deleteSecret(...a: Parameters<Actions["deleteSecret"]>) { | |
| 639 | + return this.actions.deleteSecret(...a); | |
| 640 | + } | |
| 641 | + secretNames(...a: Parameters<Actions["secretNames"]>) { | |
| 642 | + return this.actions.secretNames(...a); | |
| 643 | + } | |
| 644 | + setVariable(...a: Parameters<Actions["setVariable"]>) { | |
| 645 | + return this.actions.setVariable(...a); | |
| 646 | + } | |
| 647 | + variables(...a: Parameters<Actions["variables"]>) { | |
| 648 | + return this.actions.variables(...a); | |
| 649 | + } | |
| 650 | + cacheLookup(...a: Parameters<Actions["cacheLookup"]>) { | |
| 651 | + return this.actions.cacheLookup(...a); | |
| 652 | + } | |
| 653 | + cacheSaved(...a: Parameters<Actions["cacheSaved"]>) { | |
| 654 | + return this.actions.cacheSaved(...a); | |
| 655 | + } | |
| 656 | + artifactSaved(...a: Parameters<Actions["artifactSaved"]>) { | |
| 657 | + return this.actions.artifactSaved(...a); | |
| 658 | + } | |
| 659 | + artifacts(...a: Parameters<Actions["artifacts"]>) { | |
| 660 | + return this.actions.artifacts(...a); | |
| 661 | + } | |
| 662 | + createSandbox(...a: Parameters<Actions["createSandbox"]>) { | |
| 663 | + return this.actions.createSandbox(...a); | |
| 664 | + } | |
| 665 | + sandbox(...a: Parameters<Actions["getSandbox"]>) { | |
| 666 | + return this.actions.getSandbox(...a); | |
| 667 | + } | |
| 668 | + closeSandbox(...a: Parameters<Actions["closeSandbox"]>) { | |
| 669 | + return this.actions.closeSandbox(...a); | |
| 670 | + } | |
| 671 | + | |
| 672 | + plannedRunError(runId: string, message: string) { | |
| 673 | + return this.actions.plannedError(runId, message); | |
| 674 | + } | |
| 675 | + cacheKeyFor(key: string) { | |
| 676 | + return this.actions.cacheR2Key(key); | |
| 677 | + } | |
| 678 | + artifactKeyFor(jobId: string, name: string) { | |
| 679 | + return this.actions.artifactR2Key(this.actions.runIdForJob(jobId), name); | |
| 680 | + } | |
| 681 | + artifactKeyForRun(runId: string, name: string) { | |
| 682 | + return this.actions.artifactR2Key(runId, name); | |
| 683 | + } | |
| 684 | + | |
| 685 | + // ---------------------------------------------------------------- live updates (WebSockets with hibernation) | |
| 686 | + | |
| 687 | + override async fetch(request: Request): Promise<Response> { | |
| 688 | + if (request.headers.get("Upgrade") !== "websocket") return new Response("expected websocket", { status: 426 }); | |
| 689 | + const topics = (new URL(request.url).searchParams.get("topics") ?? "repo").split(",").filter((t) => /^[a-z]+(:[A-Za-z0-9_-]+)?$/.test(t)).slice(0, 10); | |
| 690 | + const pair = new WebSocketPair(); | |
| 691 | + this.ctx.acceptWebSocket(pair[1], topics.length ? topics : ["repo"]); | |
| 692 | + return new Response(null, { status: 101, webSocket: pair[0] }); | |
| 693 | + } | |
| 694 | + | |
| 695 | + override async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> { | |
| 696 | + if (message === "ping") ws.send("pong"); | |
| 697 | + } | |
| 698 | + | |
| 699 | + override async webSocketClose(ws: WebSocket, code: number): Promise<void> { | |
| 700 | + try { | |
| 701 | + ws.close(code, "bye"); | |
| 702 | + } catch { | |
| 703 | + /* already closed */ | |
| 704 | + } | |
| 705 | + } | |
| 706 | + | |
| 707 | + broadcast(topic: string, data: unknown): void { | |
| 708 | + const msg = JSON.stringify({ topic, data }); | |
| 709 | + for (const ws of this.ctx.getWebSockets(topic)) { | |
| 710 | + try { | |
| 711 | + ws.send(msg); | |
| 712 | + } catch { | |
| 713 | + /* client went away */ | |
| 714 | + } | |
| 715 | + } | |
| 716 | + } | |
| 717 | +} | |
| 718 | + | |
| 719 | +export function toInfo(sha: string, c: Commit): CommitInfo { | |
| 720 | + return { sha, tree: c.tree, parents: c.parents, author: c.author, committer: c.committer, message: c.message, signed: !!c.gpgsig }; | |
| 721 | +} | |
| 722 | + | |
| 723 | +export { fromUtf8 }; |
addedapps/diffinery/src/repo/actions.ts+1003−0View file
| @@ -1,0 +1,1003 @@ | ||
| 1 | +import { ZERO_OID, diffTrees, fromUtf8, isTreeMode, readCommit, readTree, resolvePath } from "@diffinery/git-core"; | |
| 2 | +import type { Actor, Conclusion, JobCompletion, JobSpec, LogLine, NeedsResult, StepReport } from "@diffinery/shared"; | |
| 3 | +import { | |
| 4 | + evaluateIf, | |
| 5 | + evaluateValue, | |
| 6 | + expandMatrix, | |
| 7 | + interpolate, | |
| 8 | + isWarning, | |
| 9 | + jobOrder, | |
| 10 | + matchesTrigger, | |
| 11 | + parseWorkflow, | |
| 12 | + type ExprContext, | |
| 13 | + type Job, | |
| 14 | + type StatusFns, | |
| 15 | + type TriggerEvent, | |
| 16 | + type Workflow, | |
| 17 | +} from "@diffinery/workflow-yaml"; | |
| 18 | +import { decryptSecret, encryptSecret } from "../lib/crypto.js"; | |
| 19 | +import { HttpError, newId, now, randomToken, sha256 } from "../lib/util.js"; | |
| 20 | +import type { RepoDO } from "./RepoDO.js"; | |
| 21 | +import { atLeast, type Caller } from "./types.js"; | |
| 22 | + | |
| 23 | +const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"]; | |
| 24 | +const SUPPORTED_LABEL = /^(ubuntu-.*|linux|diffinery(-.*)?|self-hosted|x64|amd64)$/i; | |
| 25 | + | |
| 26 | +export interface RunRow { | |
| 27 | + id: string; | |
| 28 | + run_number: number; | |
| 29 | + workflow_path: string; | |
| 30 | + workflow_name: string; | |
| 31 | + event: string; | |
| 32 | + ref: string; | |
| 33 | + sha: string; | |
| 34 | + actor: string; | |
| 35 | + status: "queued" | "in_progress" | "completed" | "waiting"; | |
| 36 | + conclusion: Conclusion | "timed_out" | null; | |
| 37 | + created_at: string; | |
| 38 | + updated_at: string; | |
| 39 | + pr_number: number | null; | |
| 40 | + attempt: number; | |
| 41 | + event_payload: string; | |
| 42 | + inputs: string; | |
| 43 | + concurrency_group: string | null; | |
| 44 | + display_title: string; | |
| 45 | +} | |
| 46 | + | |
| 47 | +export interface JobRow { | |
| 48 | + id: string; | |
| 49 | + run_id: string; | |
| 50 | + job_key: string; | |
| 51 | + name: string; | |
| 52 | + status: "queued" | "waiting" | "in_progress" | "completed"; | |
| 53 | + conclusion: Conclusion | "timed_out" | null; | |
| 54 | + matrix: string; | |
| 55 | + strategy: string; | |
| 56 | + started_at: string | null; | |
| 57 | + completed_at: string | null; | |
| 58 | + outputs: string; | |
| 59 | + steps: string; | |
| 60 | + failure: string | null; | |
| 61 | + token_hash: string | null; | |
| 62 | + summary: string | null; | |
| 63 | + container: string | null; | |
| 64 | + timeout_minutes: number; | |
| 65 | +} | |
| 66 | + | |
| 67 | +export interface RunInfo { | |
| 68 | + id: string; | |
| 69 | + number: number; | |
| 70 | + attempt: number; | |
| 71 | + workflowPath: string; | |
| 72 | + workflowName: string; | |
| 73 | + title: string; | |
| 74 | + event: string; | |
| 75 | + ref: string; | |
| 76 | + sha: string; | |
| 77 | + actor: Actor; | |
| 78 | + status: RunRow["status"]; | |
| 79 | + conclusion: RunRow["conclusion"]; | |
| 80 | + createdAt: string; | |
| 81 | + updatedAt: string; | |
| 82 | + prNumber: number | null; | |
| 83 | +} | |
| 84 | + | |
| 85 | +export interface JobInfo { | |
| 86 | + id: string; | |
| 87 | + runId: string; | |
| 88 | + key: string; | |
| 89 | + name: string; | |
| 90 | + status: JobRow["status"]; | |
| 91 | + conclusion: JobRow["conclusion"]; | |
| 92 | + matrix: Record<string, unknown>; | |
| 93 | + startedAt: string | null; | |
| 94 | + completedAt: string | null; | |
| 95 | + outputs: Record<string, string>; | |
| 96 | + steps: Array<StepReport & { index: number }>; | |
| 97 | + failure: JobCompletion["failure"] | null; | |
| 98 | + summary: string | null; | |
| 99 | +} | |
| 100 | + | |
| 101 | +const toRun = (r: RunRow): RunInfo => ({ | |
| 102 | + id: r.id, | |
| 103 | + number: r.run_number, | |
| 104 | + attempt: r.attempt, | |
| 105 | + workflowPath: r.workflow_path, | |
| 106 | + workflowName: r.workflow_name, | |
| 107 | + title: r.display_title, | |
| 108 | + event: r.event, | |
| 109 | + ref: r.ref, | |
| 110 | + sha: r.sha, | |
| 111 | + actor: JSON.parse(r.actor), | |
| 112 | + status: r.status, | |
| 113 | + conclusion: r.conclusion, | |
| 114 | + createdAt: r.created_at, | |
| 115 | + updatedAt: r.updated_at, | |
| 116 | + prNumber: r.pr_number, | |
| 117 | +}); | |
| 118 | + | |
| 119 | +const toJob = (j: JobRow): JobInfo => ({ | |
| 120 | + id: j.id, | |
| 121 | + runId: j.run_id, | |
| 122 | + key: j.job_key, | |
| 123 | + name: j.name, | |
| 124 | + status: j.status, | |
| 125 | + conclusion: j.conclusion, | |
| 126 | + matrix: JSON.parse(j.matrix), | |
| 127 | + startedAt: j.started_at, | |
| 128 | + completedAt: j.completed_at, | |
| 129 | + outputs: JSON.parse(j.outputs), | |
| 130 | + steps: JSON.parse(j.steps), | |
| 131 | + failure: j.failure ? JSON.parse(j.failure) : null, | |
| 132 | + summary: j.summary, | |
| 133 | +}); | |
| 134 | + | |
| 135 | +const statusFor = (needs: NeedsResult[], cancelled: boolean): StatusFns => ({ | |
| 136 | + success: () => !cancelled && needs.every((n) => n.result === "success" || n.result === "skipped"), | |
| 137 | + failure: () => needs.some((n) => n.result === "failure"), | |
| 138 | + cancelled: () => cancelled, | |
| 139 | + always: () => true, | |
| 140 | +}); | |
| 141 | + | |
| 142 | +/** | |
| 143 | + * GitHub Actions compatible run orchestration state. The Cloudflare Workflow | |
| 144 | + * (`RunWorkflow`) drives a run through these methods; the runner agent inside | |
| 145 | + * each job's container reports back through the runner* methods. | |
| 146 | + */ | |
| 147 | +export class Actions { | |
| 148 | + constructor(private repo: RepoDO) {} | |
| 149 | + | |
| 150 | + private get sql() { | |
| 151 | + return this.repo.sql; | |
| 152 | + } | |
| 153 | + private get env() { | |
| 154 | + return this.repo.env; | |
| 155 | + } | |
| 156 | + | |
| 157 | + // ---------------------------------------------------------------- workflow discovery | |
| 158 | + | |
| 159 | + async loadWorkflows(sha: string): Promise<Array<{ path: string; workflow?: Workflow; errors: string[] }>> { | |
| 160 | + const commit = await readCommit(this.repo.store, sha); | |
| 161 | + const out: Array<{ path: string; workflow?: Workflow; errors: string[] }> = []; | |
| 162 | + for (const dir of WORKFLOW_DIRS) { | |
| 163 | + const entry = await resolvePath(this.repo.store, commit.tree, dir); | |
| 164 | + if (!entry || !isTreeMode(entry.mode)) continue; | |
| 165 | + for (const f of await readTree(this.repo.store, entry.oid)) { | |
| 166 | + if (!/\.ya?ml$/.test(f.name) || isTreeMode(f.mode)) continue; | |
| 167 | + const path = `${dir}/${f.name}`; | |
| 168 | + const o = await this.repo.store.read(f.oid); | |
| 169 | + if (!o) continue; | |
| 170 | + const { workflow, errors } = parseWorkflow(fromUtf8(o.content), path); | |
| 171 | + out.push({ path, workflow, errors: errors.filter((e) => !isWarning(e)).map((e) => `${e.path}${e.line ? `:${e.line}` : ""}: ${e.message}`) }); | |
| 172 | + } | |
| 173 | + } | |
| 174 | + return out; | |
| 175 | + } | |
| 176 | + | |
| 177 | + async listWorkflows(): Promise<Array<{ path: string; name: string; errors: string[]; triggers: string[] }>> { | |
| 178 | + const sha = await this.repo.resolve("HEAD"); | |
| 179 | + if (!sha) return []; | |
| 180 | + return (await this.loadWorkflows(sha)).map((w) => ({ path: w.path, name: w.workflow?.name ?? w.path.split("/").pop()!, errors: w.errors, triggers: w.workflow ? Object.keys(w.workflow.on) : [] })); | |
| 181 | + } | |
| 182 | + | |
| 183 | + private repositoryPayload() { | |
| 184 | + const m = this.repo.meta; | |
| 185 | + return { id: m.repoId, name: m.name, full_name: `${m.owner}/${m.name}`, owner: { login: m.owner }, default_branch: m.defaultBranch, private: true, html_url: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}`, clone_url: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git` }; | |
| 186 | + } | |
| 187 | + | |
| 188 | + // ---------------------------------------------------------------- triggers | |
| 189 | + | |
| 190 | + async onPush(ev: { ref: string; before: string; after: string; forced: boolean; created: boolean; deleted: boolean }, caller: Caller): Promise<void> { | |
| 191 | + if (ev.deleted) return; | |
| 192 | + const workflows = await this.loadWorkflows(ev.after); | |
| 193 | + let changedFiles: string[] | undefined; | |
| 194 | + if (ev.before !== ZERO_OID && this.repo.store.has(ev.before)) { | |
| 195 | + const [a, b] = await Promise.all([readCommit(this.repo.store, ev.before), readCommit(this.repo.store, ev.after)]); | |
| 196 | + changedFiles = (await diffTrees(this.repo.store, a.tree, b.tree)).map((c) => c.path); | |
| 197 | + } | |
| 198 | + const head = await readCommit(this.repo.store, ev.after); | |
| 199 | + const payload = { | |
| 200 | + ref: ev.ref, | |
| 201 | + before: ev.before, | |
| 202 | + after: ev.after, | |
| 203 | + created: ev.created, | |
| 204 | + deleted: ev.deleted, | |
| 205 | + forced: ev.forced, | |
| 206 | + repository: this.repositoryPayload(), | |
| 207 | + pusher: { name: caller.login }, | |
| 208 | + sender: { login: caller.login, type: caller.kind === "agent" ? "Bot" : "User" }, | |
| 209 | + head_commit: { id: ev.after, message: head.message, timestamp: new Date(head.committer.timestamp * 1000).toISOString(), author: { name: head.author.name, email: head.author.email } }, | |
| 210 | + commits: [{ id: ev.after, message: head.message }], | |
| 211 | + }; | |
| 212 | + if (ev.ref === `refs/heads/${this.repo.meta.defaultBranch}`) await this.syncSchedules(workflows); | |
| 213 | + for (const w of workflows) { | |
| 214 | + if (!w.workflow) { | |
| 215 | + if (w.errors.length) await this.createFailedRun(w.path, "push", ev.ref, ev.after, caller, w.errors); | |
| 216 | + continue; | |
| 217 | + } | |
| 218 | + if (!matchesTrigger(w.workflow, { name: "push", ref: ev.ref, changedFiles })) continue; | |
| 219 | + await this.createRun(w.workflow, { event: "push", ref: ev.ref, sha: ev.after, payload, caller, title: head.message.split("\n")[0] ?? "" }); | |
| 220 | + } | |
| 221 | + } | |
| 222 | + | |
| 223 | + async onPullRequest(action: string, number: number, caller: Caller): Promise<void> { | |
| 224 | + const { issue } = this.repo.collab.get(number); | |
| 225 | + const pull = issue.pull!; | |
| 226 | + const files = await this.repo.collab.pullFiles(number); | |
| 227 | + const workflows = await this.loadWorkflows(pull.headSha); | |
| 228 | + const baseSha = this.repo.getRef(`refs/heads/${pull.base}`); | |
| 229 | + const payload = { | |
| 230 | + action, | |
| 231 | + number, | |
| 232 | + pull_request: { | |
| 233 | + number, | |
| 234 | + title: issue.title, | |
| 235 | + body: issue.body, | |
| 236 | + draft: pull.draft, | |
| 237 | + user: { login: issue.author.login }, | |
| 238 | + head: { ref: pull.head, sha: pull.headSha, repo: this.repositoryPayload() }, | |
| 239 | + base: { ref: pull.base, sha: baseSha, repo: this.repositoryPayload() }, | |
| 240 | + html_url: `${this.env.PUBLIC_URL}/${this.repo.meta.owner}/${this.repo.meta.name}/pull/${number}`, | |
| 241 | + }, | |
| 242 | + repository: this.repositoryPayload(), | |
| 243 | + sender: { login: caller.login }, | |
| 244 | + }; | |
| 245 | + for (const w of workflows) { | |
| 246 | + if (!w.workflow) continue; | |
| 247 | + for (const name of ["pull_request", "pull_request_target"] as const) { | |
| 248 | + const ev: TriggerEvent = { name, action, baseRef: pull.base, changedFiles: files.files.map((f) => f.path) }; | |
| 249 | + if (!matchesTrigger(w.workflow, ev)) continue; | |
| 250 | + await this.createRun(w.workflow, { | |
| 251 | + event: name, | |
| 252 | + ref: `refs/pull/${number}/head`, | |
| 253 | + sha: pull.headSha, | |
| 254 | + payload, | |
| 255 | + caller, | |
| 256 | + prNumber: number, | |
| 257 | + headRef: pull.head, | |
| 258 | + baseRef: pull.base, | |
| 259 | + title: issue.title, | |
| 260 | + }); | |
| 261 | + } | |
| 262 | + } | |
| 263 | + } | |
| 264 | + | |
| 265 | + async dispatch(workflowFile: string, ref: string, inputs: Record<string, unknown>, caller: Caller): Promise<RunInfo> { | |
| 266 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to run workflows"); | |
| 267 | + const sha = await this.repo.resolve(ref); | |
| 268 | + if (!sha) throw new HttpError(422, `unknown ref ${ref}`); | |
| 269 | + const all = await this.loadWorkflows(sha); | |
| 270 | + const w = all.find((x) => x.path === workflowFile || x.path.endsWith(`/${workflowFile}`)); | |
| 271 | + if (!w?.workflow) throw new HttpError(404, `workflow ${workflowFile} not found at ${ref}${w?.errors.length ? `: ${w.errors.join("; ")}` : ""}`); | |
| 272 | + if (!w.workflow.on.workflow_dispatch) throw new HttpError(422, "workflow does not have a workflow_dispatch trigger"); | |
| 273 | + const defs = w.workflow.on.workflow_dispatch.inputs ?? {}; | |
| 274 | + const resolved: Record<string, unknown> = {}; | |
| 275 | + for (const [k, d] of Object.entries(defs)) { | |
| 276 | + const v = inputs[k] ?? d.default; | |
| 277 | + if (d.required && (v === undefined || v === "")) throw new HttpError(422, `input '${k}' is required`); | |
| 278 | + if (d.type === "choice" && d.options && v !== undefined && !d.options.includes(String(v))) throw new HttpError(422, `input '${k}' must be one of ${d.options.join(", ")}`); | |
| 279 | + resolved[k] = d.type === "boolean" ? v === true || v === "true" : d.type === "number" ? Number(v) : v; | |
| 280 | + } | |
| 281 | + const fullRef = ref.startsWith("refs/") ? ref : this.repo.getRef(`refs/heads/${ref}`) ? `refs/heads/${ref}` : `refs/tags/${ref}`; | |
| 282 | + const payload = { inputs: resolved, ref: fullRef, repository: this.repositoryPayload(), sender: { login: caller.login }, workflow: w.path }; | |
| 283 | + return (await this.createRun(w.workflow, { event: "workflow_dispatch", ref: fullRef, sha, payload, caller, inputs: resolved, title: w.workflow.name ?? w.path }))!; | |
| 284 | + } | |
| 285 | + | |
| 286 | + async schedule(workflowPath: string, cron: string): Promise<void> { | |
| 287 | + const sha = await this.repo.resolve("HEAD"); | |
| 288 | + if (!sha) return; | |
| 289 | + const w = (await this.loadWorkflows(sha)).find((x) => x.path === workflowPath); | |
| 290 | + if (!w?.workflow || !matchesTrigger(w.workflow, { name: "schedule", cron })) return; | |
| 291 | + const system: Caller = { id: "system", login: "diffinery-scheduler", kind: "system", role: "write" }; | |
| 292 | + await this.createRun(w.workflow, { event: "schedule", ref: this.repo.headRef, sha, payload: { schedule: cron, repository: this.repositoryPayload() }, caller: system, title: w.workflow.name ?? w.path }); | |
| 293 | + } | |
| 294 | + | |
| 295 | + private async syncSchedules(workflows: Array<{ path: string; workflow?: Workflow }>): Promise<void> { | |
| 296 | + const repoId = this.repo.meta.repoId; | |
| 297 | + const stmts = [this.env.DB.prepare("DELETE FROM schedules WHERE repo_id = ?").bind(repoId)]; | |
| 298 | + for (const w of workflows) for (const s of w.workflow?.on.schedule ?? []) stmts.push(this.env.DB.prepare("INSERT OR IGNORE INTO schedules (repo_id, workflow_path, cron) VALUES (?, ?, ?)").bind(repoId, w.path, s.cron)); | |
| 299 | + await this.env.DB.batch(stmts); | |
| 300 | + } | |
| 301 | + | |
| 302 | + // ---------------------------------------------------------------- runs | |
| 303 | + | |
| 304 | + private nextRunNumber(): number { | |
| 305 | + this.sql.exec("INSERT INTO counters (name, value) VALUES ('run', 1) ON CONFLICT(name) DO UPDATE SET value = value + 1"); | |
| 306 | + return this.sql.exec<{ value: number }>("SELECT value FROM counters WHERE name = 'run'").one().value; | |
| 307 | + } | |
| 308 | + | |
| 309 | + private githubContext(run: RunRow, jobKey: string, token: string): Record<string, unknown> { | |
| 310 | + const m = this.repo.meta; | |
| 311 | + const payload = JSON.parse(run.event_payload) as Record<string, unknown>; | |
| 312 | + const actor = JSON.parse(run.actor) as Actor; | |
| 313 | + const refName = run.ref.replace(/^refs\/(heads|tags)\//, "").replace(/^refs\/pull\/(\d+)\/head$/, "$1/merge"); | |
| 314 | + const pr = payload.pull_request as { head?: { ref?: string }; base?: { ref?: string } } | undefined; | |
| 315 | + return { | |
| 316 | + event_name: run.event, | |
| 317 | + event: payload, | |
| 318 | + sha: run.sha, | |
| 319 | + ref: run.ref, | |
| 320 | + ref_name: refName, | |
| 321 | + ref_type: run.ref.startsWith("refs/tags/") ? "tag" : "branch", | |
| 322 | + ref_protected: false, | |
| 323 | + head_ref: pr?.head?.ref ?? "", | |
| 324 | + base_ref: pr?.base?.ref ?? "", | |
| 325 | + repository: `${m.owner}/${m.name}`, | |
| 326 | + repository_id: m.repoId, | |
| 327 | + repository_owner: m.owner, | |
| 328 | + repositoryUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git`, | |
| 329 | + actor: actor.login, | |
| 330 | + triggering_actor: actor.login, | |
| 331 | + run_id: run.id, | |
| 332 | + run_number: String(run.run_number), | |
| 333 | + run_attempt: String(run.attempt), | |
| 334 | + workflow: run.workflow_name, | |
| 335 | + workflow_ref: `${m.owner}/${m.name}/${run.workflow_path}@${run.ref}`, | |
| 336 | + job: jobKey, | |
| 337 | + server_url: this.env.PUBLIC_URL, | |
| 338 | + api_url: `${this.env.PUBLIC_URL}/api/v3`, | |
| 339 | + graphql_url: `${this.env.PUBLIC_URL}/api/graphql`, | |
| 340 | + token, | |
| 341 | + retention_days: "90", | |
| 342 | + secret_source: "Actions", | |
| 343 | + }; | |
| 344 | + } | |
| 345 | + | |
| 346 | + private async createFailedRun(path: string, event: string, ref: string, sha: string, caller: Caller, errors: string[]): Promise<void> { | |
| 347 | + const id = newId(); | |
| 348 | + const t = now(); | |
| 349 | + this.sql.exec( | |
| 350 | + "INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, conclusion, created_at, updated_at, event_payload, display_title) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'completed', 'failure', ?, ?, ?, ?)", | |
| 351 | + id, | |
| 352 | + this.nextRunNumber(), | |
| 353 | + path, | |
| 354 | + path, | |
| 355 | + event, | |
| 356 | + ref, | |
| 357 | + sha, | |
| 358 | + JSON.stringify({ id: caller.id, login: caller.login, kind: caller.kind }), | |
| 359 | + t, | |
| 360 | + t, | |
| 361 | + "{}", | |
| 362 | + `Invalid workflow file: ${errors[0] ?? path}`, | |
| 363 | + ); | |
| 364 | + const jobId = newId(); | |
| 365 | + this.sql.exec( | |
| 366 | + "INSERT INTO jobs (id, run_id, job_key, name, status, conclusion, started_at, completed_at, failure) VALUES (?, ?, 'invalid', 'Invalid workflow file', 'completed', 'failure', ?, ?, ?)", | |
| 367 | + jobId, | |
| 368 | + id, | |
| 369 | + t, | |
| 370 | + t, | |
| 371 | + JSON.stringify({ message: errors.join("\n") }), | |
| 372 | + ); | |
| 373 | + } | |
| 374 | + | |
| 375 | + async createRun( | |
| 376 | + wf: Workflow, | |
| 377 | + o: { event: string; ref: string; sha: string; payload: Record<string, unknown>; caller: Caller | { id: string; login: string; kind: Actor["kind"] }; prNumber?: number; inputs?: Record<string, unknown>; headRef?: string; baseRef?: string; title: string }, | |
| 378 | + ): Promise<RunInfo | null> { | |
| 379 | + const id = newId(); | |
| 380 | + const t = now(); | |
| 381 | + const run: RunRow = { | |
| 382 | + id, | |
| 383 | + run_number: this.nextRunNumber(), | |
| 384 | + workflow_path: wf.path, | |
| 385 | + workflow_name: wf.name ?? wf.path, | |
| 386 | + event: o.event, | |
| 387 | + ref: o.ref, | |
| 388 | + sha: o.sha, | |
| 389 | + actor: JSON.stringify({ id: o.caller.id, login: o.caller.login, kind: o.caller.kind }), | |
| 390 | + status: "queued", | |
| 391 | + conclusion: null, | |
| 392 | + created_at: t, | |
| 393 | + updated_at: t, | |
| 394 | + pr_number: o.prNumber ?? null, | |
| 395 | + attempt: 1, | |
| 396 | + event_payload: JSON.stringify(o.payload), | |
| 397 | + inputs: JSON.stringify(o.inputs ?? {}), | |
| 398 | + concurrency_group: null, | |
| 399 | + display_title: o.title, | |
| 400 | + }; | |
| 401 | + const ctx: ExprContext = { github: this.githubContext(run, "", ""), inputs: o.inputs ?? {}, vars: this.variables() }; | |
| 402 | + try { | |
| 403 | + if (wf.runName) run.display_title = interpolate(wf.runName, ctx); | |
| 404 | + if (wf.concurrency) run.concurrency_group = interpolate(wf.concurrency.group, ctx); | |
| 405 | + } catch (e) { | |
| 406 | + console.warn("run-name/concurrency evaluation failed", e); | |
| 407 | + } | |
| 408 | + this.sql.exec( | |
| 409 | + "INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, pr_number, attempt, event_payload, inputs, concurrency_group, display_title) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 410 | + run.id, | |
| 411 | + run.run_number, | |
| 412 | + run.workflow_path, | |
| 413 | + run.workflow_name, | |
| 414 | + run.event, | |
| 415 | + run.ref, | |
| 416 | + run.sha, | |
| 417 | + run.actor, | |
| 418 | + run.status, | |
| 419 | + run.created_at, | |
| 420 | + run.updated_at, | |
| 421 | + run.pr_number, | |
| 422 | + run.attempt, | |
| 423 | + run.event_payload, | |
| 424 | + run.inputs, | |
| 425 | + run.concurrency_group, | |
| 426 | + run.display_title, | |
| 427 | + ); | |
| 428 | + if (run.concurrency_group && wf.concurrency) { | |
| 429 | + const cancelInProgress = evaluateValue(wf.concurrency.cancelInProgress, ctx); | |
| 430 | + const others = this.sql.exec<{ id: string }>("SELECT id FROM runs WHERE concurrency_group = ? AND status != 'completed' AND id != ?", run.concurrency_group, id).toArray(); | |
| 431 | + for (const other of others) if (cancelInProgress === true || cancelInProgress === "true") await this.cancelInternal(other.id, "cancelled by a newer run in the same concurrency group"); | |
| 432 | + } | |
| 433 | + await this.env.RUN_WORKFLOW.create({ id, params: { repoId: this.repo.meta.repoId, runId: id } }); | |
| 434 | + const m = this.repo.meta; | |
| 435 | + this.repo.emit({ type: "workflow_run", action: "requested", repoId: m.repoId, owner: m.owner, repo: m.name, runId: id, actor: JSON.parse(run.actor), at: t }); | |
| 436 | + this.repo.broadcast("runs", { action: "created", runId: id }); | |
| 437 | + return toRun(run); | |
| 438 | + } | |
| 439 | + | |
| 440 | + private runRow(id: string): RunRow { | |
| 441 | + const r = this.sql.exec<RunRow>("SELECT * FROM runs WHERE id = ?", id).toArray()[0]; | |
| 442 | + if (!r) throw new HttpError(404, "run not found"); | |
| 443 | + return r; | |
| 444 | + } | |
| 445 | + | |
| 446 | + private jobRow(id: string): JobRow { | |
| 447 | + const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", id).toArray()[0]; | |
| 448 | + if (!j) throw new HttpError(404, "job not found"); | |
| 449 | + return j; | |
| 450 | + } | |
| 451 | + | |
| 452 | + listRuns(opts: { workflow?: string; branch?: string; status?: string; event?: string; sha?: string; prNumber?: number; limit?: number; offset?: number } = {}): { runs: RunInfo[]; total: number } { | |
| 453 | + const where: string[] = []; | |
| 454 | + const args: unknown[] = []; | |
| 455 | + if (opts.workflow) { | |
| 456 | + where.push("(workflow_path = ? OR workflow_path LIKE ?)"); | |
| 457 | + args.push(opts.workflow, `%/${opts.workflow}`); | |
| 458 | + } | |
| 459 | + if (opts.branch) { | |
| 460 | + where.push("ref = ?"); | |
| 461 | + args.push(`refs/heads/${opts.branch}`); | |
| 462 | + } | |
| 463 | + if (opts.status) { | |
| 464 | + where.push(opts.status === "completed" || opts.status === "queued" || opts.status === "in_progress" ? "status = ?" : "conclusion = ?"); | |
| 465 | + args.push(opts.status); | |
| 466 | + } | |
| 467 | + if (opts.event) { | |
| 468 | + where.push("event = ?"); | |
| 469 | + args.push(opts.event); | |
| 470 | + } | |
| 471 | + if (opts.sha) { | |
| 472 | + where.push("sha = ?"); | |
| 473 | + args.push(opts.sha); | |
| 474 | + } | |
| 475 | + if (opts.prNumber) { | |
| 476 | + where.push("pr_number = ?"); | |
| 477 | + args.push(opts.prNumber); | |
| 478 | + } | |
| 479 | + const w = where.length ? `WHERE ${where.join(" AND ")}` : ""; | |
| 480 | + const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM runs ${w}`, ...args).one().n; | |
| 481 | + const rows = this.sql.exec<RunRow>(`SELECT * FROM runs ${w} ORDER BY created_at DESC LIMIT ? OFFSET ?`, ...args, Math.min(opts.limit ?? 25, 100), opts.offset ?? 0).toArray(); | |
| 482 | + return { runs: rows.map(toRun), total }; | |
| 483 | + } | |
| 484 | + | |
| 485 | + getRun(id: string): RunInfo & { jobs: JobInfo[] } { | |
| 486 | + return { ...toRun(this.runRow(id)), jobs: this.jobs(id) }; | |
| 487 | + } | |
| 488 | + | |
| 489 | + jobs(runId: string): JobInfo[] { | |
| 490 | + return this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? ORDER BY rowid", runId).toArray().map(toJob); | |
| 491 | + } | |
| 492 | + | |
| 493 | + getJob(id: string): JobInfo { | |
| 494 | + return toJob(this.jobRow(id)); | |
| 495 | + } | |
| 496 | + | |
| 497 | + async logs(jobId: string, afterSeq = -1): Promise<{ lines: Array<LogLine & { seq: number }>; complete: boolean }> { | |
| 498 | + const j = this.jobRow(jobId); | |
| 499 | + const rows = this.sql.exec<LogLine & { seq: number }>("SELECT seq, t, step, text FROM logs WHERE job_id = ? AND seq > ? ORDER BY seq LIMIT 5000", jobId, afterSeq).toArray(); | |
| 500 | + if (rows.length === 0 && j.status === "completed") { | |
| 501 | + const archived = await this.env.BUCKET.get(this.logKey(jobId)); | |
| 502 | + if (archived && afterSeq < 0) return { lines: (await archived.json()) as Array<LogLine & { seq: number }>, complete: true }; | |
| 503 | + } | |
| 504 | + return { lines: rows, complete: j.status === "completed" }; | |
| 505 | + } | |
| 506 | + | |
| 507 | + private logKey(jobId: string) { | |
| 508 | + return `repos/${this.repo.meta.repoId}/logs/${jobId}.json`; | |
| 509 | + } | |
| 510 | + | |
| 511 | + // ---------------------------------------------------------------- orchestration (called by RunWorkflow) | |
| 512 | + | |
| 513 | + async plan(runId: string): Promise<{ levels: string[][]; error?: string; concurrencyBlocked: boolean }> { | |
| 514 | + const run = this.runRow(runId); | |
| 515 | + if (run.status === "completed") return { levels: [], error: "run already completed", concurrencyBlocked: false }; | |
| 516 | + const wf = (await this.loadWorkflows(run.sha)).find((w) => w.path === run.workflow_path); | |
| 517 | + if (!wf?.workflow) return { levels: [], error: `workflow file not found or invalid: ${wf?.errors.join("; ") ?? run.workflow_path}`, concurrencyBlocked: false }; | |
| 518 | + const { order, errors } = jobOrder(wf.workflow); | |
| 519 | + if (errors.length) return { levels: [], error: errors.map((e) => e.message).join("; "), concurrencyBlocked: false }; | |
| 520 | + const blocked = | |
| 521 | + !!run.concurrency_group && | |
| 522 | + this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM runs WHERE concurrency_group = ? AND status = 'in_progress' AND id != ?", run.concurrency_group, runId).one().n > 0; | |
| 523 | + if (!blocked) this.sql.exec("UPDATE runs SET status = 'in_progress', updated_at = ? WHERE id = ? AND status = 'queued'", now(), runId); | |
| 524 | + return { levels: order, concurrencyBlocked: blocked }; | |
| 525 | + } | |
| 526 | + | |
| 527 | + private needsResults(runId: string, keys: string[]): Record<string, NeedsResult> { | |
| 528 | + const out: Record<string, NeedsResult> = {}; | |
| 529 | + for (const key of keys) { | |
| 530 | + const rows = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND job_key = ?", runId, key).toArray(); | |
| 531 | + let result: Conclusion = "success"; | |
| 532 | + const outputs: Record<string, string> = {}; | |
| 533 | + if (rows.length === 0) result = "skipped"; | |
| 534 | + for (const r of rows) { | |
| 535 | + Object.assign(outputs, JSON.parse(r.outputs)); | |
| 536 | + const c = r.conclusion === "timed_out" ? "failure" : (r.conclusion ?? "failure"); | |
| 537 | + if (c === "failure") result = "failure"; | |
| 538 | + else if (c === "cancelled" && result !== "failure") result = "cancelled"; | |
| 539 | + else if (c === "skipped" && result === "success" && rows.length === 1) result = "skipped"; | |
| 540 | + } | |
| 541 | + out[key] = { result, outputs }; | |
| 542 | + } | |
| 543 | + return out; | |
| 544 | + } | |
| 545 | + | |
| 546 | + /** | |
| 547 | + * Start every matrix instance of a job. Returns the ids the workflow must wait | |
| 548 | + * for (instances beyond max-parallel are queued and started as others finish). | |
| 549 | + */ | |
| 550 | + async startJobKey(runId: string, jobKey: string): Promise<{ jobIds: string[] }> { | |
| 551 | + const run = this.runRow(runId); | |
| 552 | + const wf = (await this.loadWorkflows(run.sha)).find((w) => w.path === run.workflow_path)!.workflow!; | |
| 553 | + const job = wf.jobs[jobKey]!; | |
| 554 | + const cancelled = run.conclusion === "cancelled"; | |
| 555 | + const needs = this.needsResults(runId, job.needs); | |
| 556 | + const status = statusFor(Object.values(needs), cancelled); | |
| 557 | + const baseCtx: ExprContext = { github: this.githubContext(run, jobKey, ""), needs, inputs: JSON.parse(run.inputs), vars: this.variables(), env: wf.env }; | |
| 558 | + const fail = (name: string, message: string, conclusion: Conclusion = "failure") => { | |
| 559 | + const id = newId(); | |
| 560 | + const t = now(); | |
| 561 | + this.sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status, conclusion, started_at, completed_at, failure) VALUES (?, ?, ?, ?, 'completed', ?, ?, ?, ?)", id, runId, jobKey, name, conclusion, t, t, conclusion === "failure" ? JSON.stringify({ message }) : null); | |
| 562 | + this.repo.broadcast(`run:${runId}`, { action: "job", jobId: id }); | |
| 563 | + return { jobIds: [] as string[] }; | |
| 564 | + }; | |
| 565 | + let shouldRun: boolean; | |
| 566 | + try { | |
| 567 | + shouldRun = evaluateIf(job.if, baseCtx, status); | |
| 568 | + } catch (e) { | |
| 569 | + return fail(job.name ?? jobKey, `Error evaluating 'if': ${(e as Error).message}`); | |
| 570 | + } | |
| 571 | + if (!shouldRun) return fail(job.name ?? jobKey, "", "skipped"); | |
| 572 | + if (job.uses) return fail(job.name ?? jobKey, `Reusable workflows (jobs.${jobKey}.uses) are not supported yet on Diffinery.`); | |
| 573 | + const labels = job.runsOn.map((l) => String(evaluateValue(l, baseCtx))); | |
| 574 | + const unsupported = labels.filter((l) => !SUPPORTED_LABEL.test(l)); | |
| 575 | + if (unsupported.length) return fail(job.name ?? jobKey, `No Diffinery runner matches runs-on: ${unsupported.join(", ")}. Linux (ubuntu-*) runners are available today.`); | |
| 576 | + if (job.container) return fail(job.name ?? jobKey, "Job containers (jobs.<id>.container) are not supported yet; steps run directly on the Ubuntu runner image."); | |
| 577 | + if (job.services && Object.keys(job.services).length) return fail(job.name ?? jobKey, "Service containers (jobs.<id>.services) are not supported yet on Diffinery runners."); | |
| 578 | + | |
| 579 | + let combos: Array<Record<string, unknown>>; | |
| 580 | + try { | |
| 581 | + combos = expandMatrix(job.strategy?.matrix, baseCtx); | |
| 582 | + } catch (e) { | |
| 583 | + return fail(job.name ?? jobKey, `Error expanding matrix: ${(e as Error).message}`); | |
| 584 | + } | |
| 585 | + if (combos.length > 256) return fail(job.name ?? jobKey, "A matrix may produce at most 256 jobs."); | |
| 586 | + const failFast = evaluateValue(job.strategy?.failFast ?? true, baseCtx) !== false; | |
| 587 | + const maxParallel = Number(evaluateValue(job.strategy?.maxParallel ?? combos.length, baseCtx)) || combos.length; | |
| 588 | + const ids: string[] = []; | |
| 589 | + combos.forEach((matrix, i) => { | |
| 590 | + const ctx = { ...baseCtx, matrix, strategy: { "fail-fast": failFast, "job-index": i, "job-total": combos.length, "max-parallel": maxParallel } }; | |
| 591 | + let name = job.name ? interpolate(job.name, ctx) : jobKey; | |
| 592 | + if (!job.name && Object.keys(matrix).length) name = `${jobKey} (${Object.values(matrix).map((v) => (typeof v === "object" ? JSON.stringify(v) : String(v))).join(", ")})`; | |
| 593 | + const timeout = Number(evaluateValue(job.timeoutMinutes, ctx)) || 360; | |
| 594 | + const id = newId(); | |
| 595 | + this.sql.exec( | |
| 596 | + "INSERT INTO jobs (id, run_id, job_key, name, status, matrix, strategy, timeout_minutes) VALUES (?, ?, ?, ?, 'queued', ?, ?, ?)", | |
| 597 | + id, | |
| 598 | + runId, | |
| 599 | + jobKey, | |
| 600 | + name, | |
| 601 | + JSON.stringify(matrix), | |
| 602 | + JSON.stringify(ctx.strategy), | |
| 603 | + Math.min(timeout, 360), | |
| 604 | + ); | |
| 605 | + ids.push(id); | |
| 606 | + }); | |
| 607 | + for (const id of ids.slice(0, maxParallel)) await this.launch(id); | |
| 608 | + this.repo.broadcast(`run:${runId}`, { action: "jobs" }); | |
| 609 | + return { jobIds: ids }; | |
| 610 | + } | |
| 611 | + | |
| 612 | + private checkName(run: RunRow, job: JobRow): string { | |
| 613 | + return `${run.workflow_name} / ${job.name}`; | |
| 614 | + } | |
| 615 | + | |
| 616 | + private async launch(jobId: string): Promise<void> { | |
| 617 | + const job = this.jobRow(jobId); | |
| 618 | + const run = this.runRow(job.run_id); | |
| 619 | + const m = this.repo.meta; | |
| 620 | + const token = `djt_${m.repoId}_${jobId}_${randomToken(24)}`; | |
| 621 | + const container = `job-${jobId}`; | |
| 622 | + this.sql.exec("UPDATE jobs SET status = 'waiting', token_hash = ?, container = ? WHERE id = ?", await sha256(token), container, jobId); | |
| 623 | + this.repo.upsertCheck({ sha: run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` }); | |
| 624 | + try { | |
| 625 | + const stub = this.env.RUNNER.get(this.env.RUNNER.idFromName(container)); | |
| 626 | + await stub.startJob({ repoId: m.repoId, jobId, token, apiUrl: this.env.PUBLIC_URL }); | |
| 627 | + } catch (e) { | |
| 628 | + await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `Could not start a runner: ${(e as Error).message}` } }); | |
| 629 | + } | |
| 630 | + } | |
| 631 | + | |
| 632 | + async jobTimedOut(jobId: string): Promise<void> { | |
| 633 | + const j = this.jobRow(jobId); | |
| 634 | + if (j.status === "completed") return; | |
| 635 | + await this.stopContainer(j); | |
| 636 | + await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The job exceeded its timeout of ${j.timeout_minutes} minutes.` } }, "timed_out"); | |
| 637 | + } | |
| 638 | + | |
| 639 | + async containerStopped(jobId: string, exitCode: number | null): Promise<void> { | |
| 640 | + const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", jobId).toArray()[0]; | |
| 641 | + if (!j || j.status === "completed") return; | |
| 642 | + await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The runner stopped unexpectedly (exit code ${exitCode ?? "unknown"}).` } }); | |
| 643 | + } | |
| 644 | + | |
| 645 | + private async stopContainer(j: JobRow): Promise<void> { | |
| 646 | + if (!j.container) return; | |
| 647 | + try { | |
| 648 | + await this.env.RUNNER.get(this.env.RUNNER.idFromName(j.container)).stopJob(); | |
| 649 | + } catch (e) { | |
| 650 | + console.warn("stop container failed", e); | |
| 651 | + } | |
| 652 | + } | |
| 653 | + | |
| 654 | + async finishRun(runId: string): Promise<RunInfo> { | |
| 655 | + const run = this.runRow(runId); | |
| 656 | + const jobs = this.jobs(runId); | |
| 657 | + let conclusion: RunRow["conclusion"] = run.conclusion === "cancelled" ? "cancelled" : "success"; | |
| 658 | + if (conclusion !== "cancelled") { | |
| 659 | + if (jobs.some((j) => j.conclusion === "failure" || j.conclusion === "timed_out")) conclusion = "failure"; | |
| 660 | + else if (jobs.some((j) => j.conclusion === "cancelled")) conclusion = "cancelled"; | |
| 661 | + } | |
| 662 | + const t = now(); | |
| 663 | + this.sql.exec("UPDATE runs SET status = 'completed', conclusion = ?, updated_at = ? WHERE id = ?", conclusion, t, runId); | |
| 664 | + const m = this.repo.meta; | |
| 665 | + this.repo.emit({ type: "workflow_run", action: "completed", repoId: m.repoId, owner: m.owner, repo: m.name, runId, conclusion: conclusion ?? undefined, actor: JSON.parse(run.actor), at: t }); | |
| 666 | + this.repo.broadcast(`run:${runId}`, { action: "completed", conclusion }); | |
| 667 | + this.repo.broadcast("runs", { action: "completed", runId }); | |
| 668 | + return toRun(this.runRow(runId)); | |
| 669 | + } | |
| 670 | + | |
| 671 | + async plannedError(runId: string, message: string): Promise<void> { | |
| 672 | + const t = now(); | |
| 673 | + this.sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status, conclusion, started_at, completed_at, failure) VALUES (?, ?, 'setup', 'Set up workflow', 'completed', 'failure', ?, ?, ?)", newId(), runId, t, t, JSON.stringify({ message })); | |
| 674 | + } | |
| 675 | + | |
| 676 | + // ---------------------------------------------------------------- cancellation and re-runs | |
| 677 | + | |
| 678 | + private async cancelInternal(runId: string, reason: string): Promise<void> { | |
| 679 | + const run = this.runRow(runId); | |
| 680 | + if (run.status === "completed") return; | |
| 681 | + this.sql.exec("UPDATE runs SET conclusion = 'cancelled', updated_at = ? WHERE id = ?", now(), runId); | |
| 682 | + for (const j of this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND status != 'completed'", runId).toArray()) { | |
| 683 | + await this.stopContainer(j); | |
| 684 | + await this.completeJob(j.id, { conclusion: "cancelled", outputs: {}, failure: { message: reason } }); | |
| 685 | + } | |
| 686 | + } | |
| 687 | + | |
| 688 | + async cancel(runId: string, caller: Caller): Promise<void> { | |
| 689 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "permission denied"); | |
| 690 | + await this.cancelInternal(runId, `Cancelled by ${caller.login}`); | |
| 691 | + } | |
| 692 | + | |
| 693 | + async rerun(runId: string, caller: Caller): Promise<RunInfo> { | |
| 694 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "permission denied"); | |
| 695 | + const old = this.runRow(runId); | |
| 696 | + const wf = (await this.loadWorkflows(old.sha)).find((w) => w.path === old.workflow_path)?.workflow; | |
| 697 | + if (!wf) throw new HttpError(422, "workflow file no longer valid at this commit"); | |
| 698 | + const run = await this.createRun(wf, { | |
| 699 | + event: old.event, | |
| 700 | + ref: old.ref, | |
| 701 | + sha: old.sha, | |
| 702 | + payload: JSON.parse(old.event_payload), | |
| 703 | + caller, | |
| 704 | + prNumber: old.pr_number ?? undefined, | |
| 705 | + inputs: JSON.parse(old.inputs), | |
| 706 | + title: old.display_title, | |
| 707 | + }); | |
| 708 | + this.sql.exec("UPDATE runs SET attempt = ?, run_number = ? WHERE id = ?", old.attempt + 1, old.run_number, run!.id); | |
| 709 | + return toRun(this.runRow(run!.id)); | |
| 710 | + } | |
| 711 | + | |
| 712 | + // ---------------------------------------------------------------- runner protocol | |
| 713 | + | |
| 714 | + /** Validate a job or sandbox token. Returns what it grants. */ | |
| 715 | + async runnerAuth(token: string): Promise<{ kind: "job"; jobId: string; runId: string } | { kind: "sandbox"; sandboxId: string; caller: Caller } | null> { | |
| 716 | + const parts = token.split("_"); | |
| 717 | + if (parts.length < 4) return null; | |
| 718 | + const hash = await sha256(token); | |
| 719 | + if (parts[0] === "djt") { | |
| 720 | + const j = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE id = ?", parts[2]!).toArray()[0]; | |
| 721 | + if (!j || j.token_hash !== hash || j.status === "completed") return null; | |
| 722 | + return { kind: "job", jobId: j.id, runId: j.run_id }; | |
| 723 | + } | |
| 724 | + if (parts[0] === "dst") { | |
| 725 | + const s = this.sql.exec<{ id: string; secret: string; created_by: string; status: string }>("SELECT * FROM sandboxes WHERE id = ?", parts[2]!).toArray()[0]; | |
| 726 | + if (!s || s.secret !== hash || s.status !== "running") return null; | |
| 727 | + return { kind: "sandbox", sandboxId: s.id, caller: JSON.parse(s.created_by) as Caller }; | |
| 728 | + } | |
| 729 | + return null; | |
| 730 | + } | |
| 731 | + | |
| 732 | + private async authJob(jobId: string, token: string): Promise<JobRow> { | |
| 733 | + const auth = await this.runnerAuth(token); | |
| 734 | + if (!auth || auth.kind !== "job" || auth.jobId !== jobId) throw new HttpError(401, "invalid job token"); | |
| 735 | + return this.jobRow(jobId); | |
| 736 | + } | |
| 737 | + | |
| 738 | + async runnerSpec(jobId: string, token: string): Promise<JobSpec> { | |
| 739 | + const job = await this.authJob(jobId, token); | |
| 740 | + const run = this.runRow(job.run_id); | |
| 741 | + const wf = (await this.loadWorkflows(run.sha)).find((w) => w.path === run.workflow_path)!.workflow!; | |
| 742 | + const def: Job = wf.jobs[job.job_key]!; | |
| 743 | + const m = this.repo.meta; | |
| 744 | + const t = now(); | |
| 745 | + this.sql.exec("UPDATE jobs SET status = 'in_progress', started_at = ? WHERE id = ?", t, jobId); | |
| 746 | + this.repo.upsertCheck({ sha: run.sha, name: this.checkName(run, job), status: "in_progress", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` }); | |
| 747 | + this.repo.broadcast(`run:${run.id}`, { action: "job", jobId }); | |
| 748 | + const secrets: Record<string, string> = { ...(await this.orgSecrets()), ...(await this.repoSecrets()), GITHUB_TOKEN: token, DIFFINERY_TOKEN: token }; | |
| 749 | + const idToken = wf.permissions === "write-all" || (typeof wf.permissions === "object" && wf.permissions["id-token"] === "write") || (typeof def.permissions === "object" && def.permissions["id-token"] === "write") || def.permissions === "write-all"; | |
| 750 | + return { | |
| 751 | + runId: run.id, | |
| 752 | + jobId, | |
| 753 | + jobKey: job.job_key, | |
| 754 | + name: job.name, | |
| 755 | + repo: { owner: m.owner, name: m.name, cloneUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git`, defaultBranch: m.defaultBranch }, | |
| 756 | + sha: run.sha, | |
| 757 | + ref: run.ref, | |
| 758 | + workflowPath: run.workflow_path, | |
| 759 | + github: this.githubContext(run, job.job_key, token), | |
| 760 | + env: { ...wf.env, ...def.env }, | |
| 761 | + defaults: { run: { ...wf.defaults.run, ...def.defaults.run } }, | |
| 762 | + matrix: JSON.parse(job.matrix), | |
| 763 | + strategy: JSON.parse(job.strategy), | |
| 764 | + needs: this.needsResults(run.id, def.needs), | |
| 765 | + inputs: JSON.parse(run.inputs), | |
| 766 | + vars: this.variables(), | |
| 767 | + secrets, | |
| 768 | + outputs: def.outputs, | |
| 769 | + steps: def.steps.map((s) => ({ ...s, "timeout-minutes": s["timeout-minutes"] === undefined ? undefined : Number(s["timeout-minutes"]) })), | |
| 770 | + timeoutMinutes: job.timeout_minutes, | |
| 771 | + continueOnError: def.continueOnError === true || def.continueOnError === "true", | |
| 772 | + idToken: !!idToken, | |
| 773 | + apiUrl: this.env.PUBLIC_URL, | |
| 774 | + token, | |
| 775 | + }; | |
| 776 | + } | |
| 777 | + | |
| 778 | + async runnerLogs(jobId: string, token: string, lines: LogLine[]): Promise<void> { | |
| 779 | + await this.authJob(jobId, token); | |
| 780 | + let seq = this.sql.exec<{ n: number | null }>("SELECT max(seq) AS n FROM logs WHERE job_id = ?", jobId).one().n ?? -1; | |
| 781 | + const out: Array<LogLine & { seq: number }> = []; | |
| 782 | + for (const l of lines.slice(0, 5000)) { | |
| 783 | + seq++; | |
| 784 | + const text = l.text.length > 16384 ? `${l.text.slice(0, 16384)}…` : l.text; | |
| 785 | + this.sql.exec("INSERT INTO logs (job_id, seq, t, step, text) VALUES (?, ?, ?, ?, ?)", jobId, seq, l.t, l.step, text); | |
| 786 | + out.push({ ...l, text, seq }); | |
| 787 | + } | |
| 788 | + this.repo.broadcast(`job:${jobId}`, { action: "logs", lines: out }); | |
| 789 | + } | |
| 790 | + | |
| 791 | + async runnerStep(jobId: string, token: string, index: number, report: StepReport): Promise<void> { | |
| 792 | + const job = await this.authJob(jobId, token); | |
| 793 | + const steps = JSON.parse(job.steps) as Array<StepReport & { index: number }>; | |
| 794 | + const existing = steps.findIndex((s) => s.index === index); | |
| 795 | + const next = { ...(existing >= 0 ? steps[existing] : {}), ...report, index }; | |
| 796 | + if (existing >= 0) steps[existing] = next; | |
| 797 | + else steps.push(next); | |
| 798 | + steps.sort((a, b) => a.index - b.index); | |
| 799 | + this.sql.exec("UPDATE jobs SET steps = ? WHERE id = ?", JSON.stringify(steps), jobId); | |
| 800 | + this.repo.broadcast(`job:${jobId}`, { action: "step", step: next }); | |
| 801 | + this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId }); | |
| 802 | + } | |
| 803 | + | |
| 804 | + async runnerComplete(jobId: string, token: string, completion: JobCompletion): Promise<void> { | |
| 805 | + await this.authJob(jobId, token); | |
| 806 | + await this.completeJob(jobId, completion); | |
| 807 | + } | |
| 808 | + | |
| 809 | + private async completeJob(jobId: string, completion: JobCompletion, override?: "timed_out"): Promise<void> { | |
| 810 | + const job = this.jobRow(jobId); | |
| 811 | + if (job.status === "completed") return; | |
| 812 | + const run = this.runRow(job.run_id); | |
| 813 | + const t = now(); | |
| 814 | + const conclusion = override ?? completion.conclusion; | |
| 815 | + this.sql.exec( | |
| 816 | + "UPDATE jobs SET status = 'completed', conclusion = ?, completed_at = ?, outputs = ?, failure = ?, summary = ?, token_hash = NULL, started_at = COALESCE(started_at, ?) WHERE id = ?", | |
| 817 | + conclusion, | |
| 818 | + t, | |
| 819 | + JSON.stringify(completion.outputs ?? {}), | |
| 820 | + completion.failure ? JSON.stringify(completion.failure) : null, | |
| 821 | + completion.summary ?? null, | |
| 822 | + t, | |
| 823 | + jobId, | |
| 824 | + ); | |
| 825 | + const m = this.repo.meta; | |
| 826 | + this.repo.upsertCheck({ | |
| 827 | + sha: run.sha, | |
| 828 | + name: this.checkName(run, job), | |
| 829 | + status: "completed", | |
| 830 | + conclusion: conclusion === "timed_out" ? "timed_out" : conclusion, | |
| 831 | + runId: run.id, | |
| 832 | + jobId, | |
| 833 | + detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}`, | |
| 834 | + output: completion.failure ? { title: "Failed", summary: completion.failure.message } : { title: conclusion }, | |
| 835 | + }); | |
| 836 | + // Archive logs to R2 and free the Durable Object's storage. | |
| 837 | + const lines = this.sql.exec<LogLine & { seq: number }>("SELECT seq, t, step, text FROM logs WHERE job_id = ? ORDER BY seq", jobId).toArray(); | |
| 838 | + if (lines.length) { | |
| 839 | + await this.env.BUCKET.put(this.logKey(jobId), JSON.stringify(lines), { httpMetadata: { contentType: "application/json" } }); | |
| 840 | + this.sql.exec("DELETE FROM logs WHERE job_id = ?", jobId); | |
| 841 | + } | |
| 842 | + this.repo.broadcast(`job:${jobId}`, { action: "completed", conclusion }); | |
| 843 | + this.repo.broadcast(`run:${run.id}`, { action: "job", jobId }); | |
| 844 | + | |
| 845 | + // fail-fast: cancel unfinished siblings of a failed matrix job. | |
| 846 | + const siblings = this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND job_key = ? AND id != ? AND status != 'completed'", run.id, job.job_key, jobId).toArray(); | |
| 847 | + const strategy = JSON.parse(job.strategy || "{}") as { "fail-fast"?: boolean }; | |
| 848 | + if (conclusion === "failure" && strategy["fail-fast"] !== false && siblings.length) { | |
| 849 | + for (const s of siblings) { | |
| 850 | + await this.stopContainer(s); | |
| 851 | + await this.completeJob(s.id, { conclusion: "cancelled", outputs: {}, failure: { message: `Cancelled because ${job.name} failed (fail-fast).` } }); | |
| 852 | + } | |
| 853 | + } else { | |
| 854 | + const queued = siblings.find((s) => s.status === "queued"); | |
| 855 | + if (queued) await this.launch(queued.id); | |
| 856 | + } | |
| 857 | + try { | |
| 858 | + const instance = await this.env.RUN_WORKFLOW.get(run.id); | |
| 859 | + await instance.sendEvent({ type: `job-${jobId}`, payload: { conclusion } }); | |
| 860 | + } catch (e) { | |
| 861 | + console.error("failed to notify workflow", e); | |
| 862 | + } | |
| 863 | + } | |
| 864 | + | |
| 865 | + // ---------------------------------------------------------------- secrets and variables | |
| 866 | + | |
| 867 | + private async repoSecrets(): Promise<Record<string, string>> { | |
| 868 | + const out: Record<string, string> = {}; | |
| 869 | + for (const r of this.sql.exec<{ name: string; ciphertext: string }>("SELECT name, ciphertext FROM secrets").toArray()) { | |
| 870 | + out[r.name] = await decryptSecret(this.env.MASTER_KEY, `repo:${this.repo.meta.repoId}`, r.ciphertext); | |
| 871 | + } | |
| 872 | + return out; | |
| 873 | + } | |
| 874 | + | |
| 875 | + private async orgSecrets(): Promise<Record<string, string>> { | |
| 876 | + const m = this.repo.meta; | |
| 877 | + if (m.ownerKind !== "org") return {}; | |
| 878 | + const rows = await this.env.DB.prepare("SELECT name, ciphertext FROM org_secrets WHERE org_id = ?").bind(m.ownerId).all<{ name: string; ciphertext: string }>(); | |
| 879 | + const out: Record<string, string> = {}; | |
| 880 | + for (const r of rows.results) out[r.name] = await decryptSecret(this.env.MASTER_KEY, `org:${m.ownerId}`, r.ciphertext); | |
| 881 | + return out; | |
| 882 | + } | |
| 883 | + | |
| 884 | + async setSecret(name: string, value: string): Promise<void> { | |
| 885 | + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || /^GITHUB_/i.test(name)) throw new HttpError(422, "invalid secret name"); | |
| 886 | + this.sql.exec("INSERT OR REPLACE INTO secrets (name, ciphertext, updated_at) VALUES (?, ?, ?)", name.toUpperCase(), await encryptSecret(this.env.MASTER_KEY, `repo:${this.repo.meta.repoId}`, value), now()); | |
| 887 | + } | |
| 888 | + | |
| 889 | + deleteSecret(name: string): void { | |
| 890 | + this.sql.exec("DELETE FROM secrets WHERE name = ?", name.toUpperCase()); | |
| 891 | + } | |
| 892 | + | |
| 893 | + secretNames(): Array<{ name: string; updatedAt: string }> { | |
| 894 | + return this.sql.exec<{ name: string; updated_at: string }>("SELECT name, updated_at FROM secrets ORDER BY name").toArray().map((r) => ({ name: r.name, updatedAt: r.updated_at })); | |
| 895 | + } | |
| 896 | + | |
| 897 | + setVariable(name: string, value: string | null): void { | |
| 898 | + if (value === null) this.sql.exec("DELETE FROM variables WHERE name = ?", name); | |
| 899 | + else this.sql.exec("INSERT OR REPLACE INTO variables (name, value, updated_at) VALUES (?, ?, ?)", name.toUpperCase(), value, now()); | |
| 900 | + } | |
| 901 | + | |
| 902 | + variables(): Record<string, string> { | |
| 903 | + return Object.fromEntries(this.sql.exec<{ name: string; value: string }>("SELECT name, value FROM variables").toArray().map((r) => [r.name, r.value])); | |
| 904 | + } | |
| 905 | + | |
| 906 | + // ---------------------------------------------------------------- cache and artifacts | |
| 907 | + | |
| 908 | + async cacheLookup(jobId: string, token: string, key: string, restoreKeys: string[]): Promise<{ key: string; r2Key: string } | null> { | |
| 909 | + await this.authJob(jobId, token); | |
| 910 | + const exact = this.sql.exec<{ key: string }>("SELECT key FROM caches WHERE key = ?", key).toArray()[0]; | |
| 911 | + let hit = exact?.key; | |
| 912 | + if (!hit) { | |
| 913 | + for (const prefix of restoreKeys) { | |
| 914 | + const r = this.sql.exec<{ key: string }>("SELECT key FROM caches WHERE key >= ? AND key < ? ORDER BY created_at DESC LIMIT 1", prefix, `${prefix}`).toArray()[0]; | |
| 915 | + if (r) { | |
| 916 | + hit = r.key; | |
| 917 | + break; | |
| 918 | + } | |
| 919 | + } | |
| 920 | + } | |
| 921 | + if (!hit) return null; | |
| 922 | + this.sql.exec("UPDATE caches SET last_used = ? WHERE key = ?", now(), hit); | |
| 923 | + return { key: hit, r2Key: this.cacheR2Key(hit) }; | |
| 924 | + } | |
| 925 | + | |
| 926 | + cacheR2Key(key: string): string { | |
| 927 | + return `repos/${this.repo.meta.repoId}/cache/${encodeURIComponent(key)}`; | |
| 928 | + } | |
| 929 | + | |
| 930 | + async cacheSaved(jobId: string, token: string, key: string, size: number): Promise<string[]> { | |
| 931 | + await this.authJob(jobId, token); | |
| 932 | + this.sql.exec("INSERT OR REPLACE INTO caches (key, size, created_at, last_used) VALUES (?, ?, ?, ?)", key, size, now(), now()); | |
| 933 | + // Evict least recently used entries above 10 GB per repository. | |
| 934 | + const evicted: string[] = []; | |
| 935 | + let total = this.sql.exec<{ n: number | null }>("SELECT sum(size) AS n FROM caches").one().n ?? 0; | |
| 936 | + while (total > 10 * 1024 ** 3) { | |
| 937 | + const old = this.sql.exec<{ key: string; size: number }>("SELECT key, size FROM caches ORDER BY last_used LIMIT 1").one(); | |
| 938 | + this.sql.exec("DELETE FROM caches WHERE key = ?", old.key); | |
| 939 | + evicted.push(this.cacheR2Key(old.key)); | |
| 940 | + total -= old.size; | |
| 941 | + } | |
| 942 | + return evicted; | |
| 943 | + } | |
| 944 | + | |
| 945 | + async artifactSaved(jobId: string, token: string, name: string, size: number): Promise<{ r2Key: string }> { | |
| 946 | + const job = await this.authJob(jobId, token); | |
| 947 | + this.sql.exec("INSERT OR REPLACE INTO artifacts (id, run_id, name, size, created_at) VALUES (?, ?, ?, ?, ?)", newId(), job.run_id, name, size, now()); | |
| 948 | + return { r2Key: this.artifactR2Key(job.run_id, name) }; | |
| 949 | + } | |
| 950 | + | |
| 951 | + artifactR2Key(runId: string, name: string): string { | |
| 952 | + return `repos/${this.repo.meta.repoId}/artifacts/${runId}/${encodeURIComponent(name)}`; | |
| 953 | + } | |
| 954 | + | |
| 955 | + artifacts(runId: string): Array<{ name: string; size: number; createdAt: string; r2Key: string }> { | |
| 956 | + return this.sql.exec<{ name: string; size: number; created_at: string }>("SELECT name, size, created_at FROM artifacts WHERE run_id = ? ORDER BY name", runId).toArray().map((a) => ({ name: a.name, size: a.size, createdAt: a.created_at, r2Key: this.artifactR2Key(runId, a.name) })); | |
| 957 | + } | |
| 958 | + | |
| 959 | + /** Runs of the same commit; lets a job download artifacts uploaded by earlier jobs. */ | |
| 960 | + runIdForJob(jobId: string): string { | |
| 961 | + return this.jobRow(jobId).run_id; | |
| 962 | + } | |
| 963 | + | |
| 964 | + // ---------------------------------------------------------------- sandboxes for agents | |
| 965 | + | |
| 966 | + async createSandbox(caller: Caller, ref?: string): Promise<{ id: string; ref: string }> { | |
| 967 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to create a sandbox"); | |
| 968 | + const sha = await this.repo.resolve(ref ?? "HEAD"); | |
| 969 | + if (!sha) throw new HttpError(422, "unknown ref"); | |
| 970 | + const id = newId(); | |
| 971 | + const m = this.repo.meta; | |
| 972 | + const token = `dst_${m.repoId}_${id}_${randomToken(24)}`; | |
| 973 | + const secret = randomToken(32); | |
| 974 | + this.sql.exec("INSERT INTO sandboxes (id, secret, created_by, ref, created_at, status) VALUES (?, ?, ?, ?, ?, 'running')", id, await sha256(token), JSON.stringify(caller), ref ?? m.defaultBranch, now()); | |
| 975 | + const stub = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)); | |
| 976 | + await stub.boot({ | |
| 977 | + DIFFINERY_CLONE_URL: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}.git`, | |
| 978 | + DIFFINERY_REF: ref ?? m.defaultBranch, | |
| 979 | + DIFFINERY_JOB_TOKEN: token, | |
| 980 | + DIFFINERY_SANDBOX_SECRET: secret, | |
| 981 | + DIFFINERY_API_URL: this.env.PUBLIC_URL, | |
| 982 | + GIT_AUTHOR_NAME: caller.login, | |
| 983 | + GIT_AUTHOR_EMAIL: `${caller.login}@users.noreply.diffinery.com`, | |
| 984 | + GIT_COMMITTER_NAME: caller.login, | |
| 985 | + GIT_COMMITTER_EMAIL: `${caller.login}@users.noreply.diffinery.com`, | |
| 986 | + }, secret); | |
| 987 | + return { id, ref: ref ?? m.defaultBranch }; | |
| 988 | + } | |
| 989 | + | |
| 990 | + getSandbox(id: string, caller: Caller): { id: string; ref: string; status: string; createdAt: string } { | |
| 991 | + const s = this.sql.exec<{ id: string; ref: string; status: string; created_at: string; created_by: string }>("SELECT * FROM sandboxes WHERE id = ?", id).toArray()[0]; | |
| 992 | + if (!s) throw new HttpError(404, "sandbox not found"); | |
| 993 | + const owner = JSON.parse(s.created_by) as Caller; | |
| 994 | + if (owner.id !== caller.id && !atLeast(caller.role, "admin")) throw new HttpError(403, "not your sandbox"); | |
| 995 | + return { id: s.id, ref: s.ref, status: s.status, createdAt: s.created_at }; | |
| 996 | + } | |
| 997 | + | |
| 998 | + async closeSandbox(id: string, caller: Caller): Promise<void> { | |
| 999 | + this.getSandbox(id, caller); | |
| 1000 | + this.sql.exec("UPDATE sandboxes SET status = 'closed' WHERE id = ?", id); | |
| 1001 | + await this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)).shutdown(); | |
| 1002 | + } | |
| 1003 | +} |
addedapps/diffinery/src/repo/collab.ts+664−0View file
| @@ -1,0 +1,664 @@ | ||
| 1 | +import { ZERO_OID, isAncestor, mergeBase, mergeTrees, readCommit, serializeCommit, walkCommits } from "@diffinery/git-core"; | |
| 2 | +import { HttpError, newId, now, randomToken } from "../lib/util.js"; | |
| 3 | +import type { RepoDO } from "./RepoDO.js"; | |
| 4 | +import { toInfo } from "./RepoDO.js"; | |
| 5 | +import { atLeast, type Caller } from "./types.js"; | |
| 6 | + | |
| 7 | +export interface IssueRow { | |
| 8 | + number: number; | |
| 9 | + kind: "issue" | "pull"; | |
| 10 | + title: string; | |
| 11 | + body: string; | |
| 12 | + state: "open" | "closed"; | |
| 13 | + state_reason: string | null; | |
| 14 | + author_id: string; | |
| 15 | + author_login: string; | |
| 16 | + author_kind: string; | |
| 17 | + assignees: string; | |
| 18 | + labels: string; | |
| 19 | + created_at: string; | |
| 20 | + updated_at: string; | |
| 21 | + closed_at: string | null; | |
| 22 | + locked: number; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export interface PullRow { | |
| 26 | + number: number; | |
| 27 | + head_ref: string; | |
| 28 | + head_sha: string; | |
| 29 | + base_ref: string; | |
| 30 | + draft: number; | |
| 31 | + merged: number; | |
| 32 | + merged_at: string | null; | |
| 33 | + merged_by: string | null; | |
| 34 | + merge_commit: string | null; | |
| 35 | + requested_reviewers: string; | |
| 36 | + intent: string | null; | |
| 37 | +} | |
| 38 | + | |
| 39 | +export interface Issue { | |
| 40 | + number: number; | |
| 41 | + kind: "issue" | "pull"; | |
| 42 | + title: string; | |
| 43 | + body: string; | |
| 44 | + state: "open" | "closed"; | |
| 45 | + stateReason: string | null; | |
| 46 | + author: { id: string; login: string; kind: string }; | |
| 47 | + assignees: string[]; | |
| 48 | + labels: string[]; | |
| 49 | + createdAt: string; | |
| 50 | + updatedAt: string; | |
| 51 | + closedAt: string | null; | |
| 52 | + comments: number; | |
| 53 | + pull?: { | |
| 54 | + head: string; | |
| 55 | + headSha: string; | |
| 56 | + base: string; | |
| 57 | + draft: boolean; | |
| 58 | + merged: boolean; | |
| 59 | + mergedAt: string | null; | |
| 60 | + mergedBy: string | null; | |
| 61 | + mergeCommit: string | null; | |
| 62 | + requestedReviewers: string[]; | |
| 63 | + intent: string | null; | |
| 64 | + }; | |
| 65 | +} | |
| 66 | + | |
| 67 | +export interface Comment { | |
| 68 | + id: string; | |
| 69 | + author: { id: string; login: string; kind: string }; | |
| 70 | + body: string; | |
| 71 | + createdAt: string; | |
| 72 | + updatedAt: string; | |
| 73 | + reviewId: string | null; | |
| 74 | + path: string | null; | |
| 75 | + line: number | null; | |
| 76 | + side: string | null; | |
| 77 | + commitId: string | null; | |
| 78 | + inReplyTo: string | null; | |
| 79 | + resolved: boolean; | |
| 80 | +} | |
| 81 | + | |
| 82 | +export interface Review { | |
| 83 | + id: string; | |
| 84 | + author: { id: string; login: string; kind: string }; | |
| 85 | + state: "APPROVED" | "CHANGES_REQUESTED" | "COMMENTED" | "DISMISSED"; | |
| 86 | + body: string; | |
| 87 | + commitId: string; | |
| 88 | + submittedAt: string; | |
| 89 | +} | |
| 90 | + | |
| 91 | +export interface Check { | |
| 92 | + id: string; | |
| 93 | + sha: string; | |
| 94 | + name: string; | |
| 95 | + status: "queued" | "in_progress" | "completed"; | |
| 96 | + conclusion: string | null; | |
| 97 | + runId: string | null; | |
| 98 | + jobId: string | null; | |
| 99 | + detailsUrl: string | null; | |
| 100 | + output: { title?: string; summary?: string } | null; | |
| 101 | + app: string; | |
| 102 | + startedAt: string | null; | |
| 103 | + completedAt: string | null; | |
| 104 | +} | |
| 105 | + | |
| 106 | +export interface Mergeability { | |
| 107 | + mergeable: boolean; | |
| 108 | + state: "clean" | "blocked" | "conflicting" | "behind" | "draft" | "merged" | "closed" | "unknown"; | |
| 109 | + conflicts: string[]; | |
| 110 | + approvals: number; | |
| 111 | + requiredApprovals: number; | |
| 112 | + changesRequested: string[]; | |
| 113 | + checks: { name: string; state: string }[]; | |
| 114 | + blockers: string[]; | |
| 115 | + mergeBase: string | null; | |
| 116 | +} | |
| 117 | + | |
| 118 | +const actorOf = (c: Caller) => ({ id: c.id, login: c.login, kind: c.kind, onBehalfOf: c.onBehalfOf }); | |
| 119 | + | |
| 120 | +export class Collab { | |
| 121 | + constructor(private repo: RepoDO) {} | |
| 122 | + | |
| 123 | + private get sql() { | |
| 124 | + return this.repo.sql; | |
| 125 | + } | |
| 126 | + | |
| 127 | + private nextNumber(): number { | |
| 128 | + this.sql.exec("INSERT INTO counters (name, value) VALUES ('issue', 1) ON CONFLICT(name) DO UPDATE SET value = value + 1"); | |
| 129 | + return this.sql.exec<{ value: number }>("SELECT value FROM counters WHERE name = 'issue'").one().value; | |
| 130 | + } | |
| 131 | + | |
| 132 | + private row(number: number): IssueRow { | |
| 133 | + const r = this.sql.exec<IssueRow>("SELECT * FROM issues WHERE number = ?", number).toArray()[0]; | |
| 134 | + if (!r) throw new HttpError(404, `#${number} not found`); | |
| 135 | + return r; | |
| 136 | + } | |
| 137 | + | |
| 138 | + private pullRow(number: number): PullRow { | |
| 139 | + const r = this.sql.exec<PullRow>("SELECT * FROM pulls WHERE number = ?", number).toArray()[0]; | |
| 140 | + if (!r) throw new HttpError(404, `pull request #${number} not found`); | |
| 141 | + return r; | |
| 142 | + } | |
| 143 | + | |
| 144 | + private toIssue(r: IssueRow): Issue { | |
| 145 | + const comments = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM comments WHERE number = ? AND review_id IS NULL", r.number).one().n; | |
| 146 | + const issue: Issue = { | |
| 147 | + number: r.number, | |
| 148 | + kind: r.kind, | |
| 149 | + title: r.title, | |
| 150 | + body: r.body, | |
| 151 | + state: r.state, | |
| 152 | + stateReason: r.state_reason, | |
| 153 | + author: { id: r.author_id, login: r.author_login, kind: r.author_kind }, | |
| 154 | + assignees: JSON.parse(r.assignees), | |
| 155 | + labels: JSON.parse(r.labels), | |
| 156 | + createdAt: r.created_at, | |
| 157 | + updatedAt: r.updated_at, | |
| 158 | + closedAt: r.closed_at, | |
| 159 | + comments, | |
| 160 | + }; | |
| 161 | + if (r.kind === "pull") { | |
| 162 | + const p = this.pullRow(r.number); | |
| 163 | + issue.pull = { | |
| 164 | + head: p.head_ref, | |
| 165 | + headSha: p.head_sha, | |
| 166 | + base: p.base_ref, | |
| 167 | + draft: !!p.draft, | |
| 168 | + merged: !!p.merged, | |
| 169 | + mergedAt: p.merged_at, | |
| 170 | + mergedBy: p.merged_by, | |
| 171 | + mergeCommit: p.merge_commit, | |
| 172 | + requestedReviewers: JSON.parse(p.requested_reviewers), | |
| 173 | + intent: p.intent, | |
| 174 | + }; | |
| 175 | + } | |
| 176 | + return issue; | |
| 177 | + } | |
| 178 | + | |
| 179 | + // ---------------------------------------------------------------- issues | |
| 180 | + | |
| 181 | + list(opts: { kind?: "issue" | "pull"; state?: "open" | "closed" | "all"; label?: string; author?: string; limit?: number; offset?: number; q?: string } = {}): { items: Issue[]; total: number } { | |
| 182 | + const where: string[] = []; | |
| 183 | + const args: unknown[] = []; | |
| 184 | + if (opts.kind) { | |
| 185 | + where.push("kind = ?"); | |
| 186 | + args.push(opts.kind); | |
| 187 | + } | |
| 188 | + if (opts.state && opts.state !== "all") { | |
| 189 | + where.push("state = ?"); | |
| 190 | + args.push(opts.state); | |
| 191 | + } | |
| 192 | + if (opts.author) { | |
| 193 | + where.push("author_login = ? COLLATE NOCASE"); | |
| 194 | + args.push(opts.author); | |
| 195 | + } | |
| 196 | + if (opts.label) { | |
| 197 | + where.push("EXISTS (SELECT 1 FROM json_each(labels) WHERE value = ?)"); | |
| 198 | + args.push(opts.label); | |
| 199 | + } | |
| 200 | + if (opts.q) { | |
| 201 | + where.push("(title LIKE ? OR body LIKE ?)"); | |
| 202 | + args.push(`%${opts.q}%`, `%${opts.q}%`); | |
| 203 | + } | |
| 204 | + const w = where.length ? `WHERE ${where.join(" AND ")}` : ""; | |
| 205 | + const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM issues ${w}`, ...args).one().n; | |
| 206 | + const rows = this.sql.exec<IssueRow>(`SELECT * FROM issues ${w} ORDER BY number DESC LIMIT ? OFFSET ?`, ...args, Math.min(opts.limit ?? 30, 100), opts.offset ?? 0).toArray(); | |
| 207 | + return { items: rows.map((r) => this.toIssue(r)), total }; | |
| 208 | + } | |
| 209 | + | |
| 210 | + get(number: number): { issue: Issue; comments: Comment[]; reviews: Review[] } { | |
| 211 | + const issue = this.toIssue(this.row(number)); | |
| 212 | + const comments = this.sql | |
| 213 | + .exec<Record<string, unknown>>("SELECT * FROM comments WHERE number = ? ORDER BY created_at", number) | |
| 214 | + .toArray() | |
| 215 | + .map( | |
| 216 | + (c): Comment => ({ | |
| 217 | + id: c.id as string, | |
| 218 | + author: { id: c.author_id as string, login: c.author_login as string, kind: c.author_kind as string }, | |
| 219 | + body: c.body as string, | |
| 220 | + createdAt: c.created_at as string, | |
| 221 | + updatedAt: c.updated_at as string, | |
| 222 | + reviewId: (c.review_id as string) ?? null, | |
| 223 | + path: (c.path as string) ?? null, | |
| 224 | + line: (c.line as number) ?? null, | |
| 225 | + side: (c.side as string) ?? null, | |
| 226 | + commitId: (c.commit_id as string) ?? null, | |
| 227 | + inReplyTo: (c.in_reply_to as string) ?? null, | |
| 228 | + resolved: !!c.resolved, | |
| 229 | + }), | |
| 230 | + ); | |
| 231 | + const reviews = this.reviews(number); | |
| 232 | + return { issue, comments, reviews }; | |
| 233 | + } | |
| 234 | + | |
| 235 | + private reviews(number: number): Review[] { | |
| 236 | + return this.sql | |
| 237 | + .exec<Record<string, string>>("SELECT * FROM reviews WHERE number = ? ORDER BY submitted_at", number) | |
| 238 | + .toArray() | |
| 239 | + .map((r) => ({ | |
| 240 | + id: r.id!, | |
| 241 | + author: { id: r.author_id!, login: r.author_login!, kind: r.author_kind! }, | |
| 242 | + state: r.state as Review["state"], | |
| 243 | + body: r.body!, | |
| 244 | + commitId: r.commit_id!, | |
| 245 | + submittedAt: r.submitted_at!, | |
| 246 | + })); | |
| 247 | + } | |
| 248 | + | |
| 249 | + createIssue(input: { title: string; body?: string; labels?: string[]; assignees?: string[] }, caller: Caller): Issue { | |
| 250 | + if (!input.title?.trim()) throw new HttpError(422, "title is required"); | |
| 251 | + const n = this.nextNumber(); | |
| 252 | + const t = now(); | |
| 253 | + this.sql.exec( | |
| 254 | + "INSERT INTO issues (number, kind, title, body, author_id, author_login, author_kind, labels, assignees, created_at, updated_at) VALUES (?, 'issue', ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 255 | + n, | |
| 256 | + input.title.trim(), | |
| 257 | + input.body ?? "", | |
| 258 | + caller.id, | |
| 259 | + caller.login, | |
| 260 | + caller.kind, | |
| 261 | + JSON.stringify(input.labels ?? []), | |
| 262 | + JSON.stringify(input.assignees ?? []), | |
| 263 | + t, | |
| 264 | + t, | |
| 265 | + ); | |
| 266 | + const m = this.repo.meta; | |
| 267 | + this.repo.emit({ type: "issues", action: "opened", repoId: m.repoId, owner: m.owner, repo: m.name, number: n, actor: actorOf(caller), at: t }); | |
| 268 | + this.repo.broadcast("issues", { number: n, action: "opened" }); | |
| 269 | + return this.toIssue(this.row(n)); | |
| 270 | + } | |
| 271 | + | |
| 272 | + update(number: number, patch: { title?: string; body?: string; state?: "open" | "closed"; stateReason?: string; labels?: string[]; assignees?: string[] }, caller: Caller): Issue { | |
| 273 | + const r = this.row(number); | |
| 274 | + const isAuthor = r.author_id === caller.id; | |
| 275 | + if (!isAuthor && !atLeast(caller.role, "triage")) throw new HttpError(403, "permission denied"); | |
| 276 | + if (r.kind === "pull" && patch.state === "open" && this.pullRow(number).merged) throw new HttpError(422, "a merged pull request cannot be reopened"); | |
| 277 | + const t = now(); | |
| 278 | + const state = patch.state ?? r.state; | |
| 279 | + this.sql.exec( | |
| 280 | + "UPDATE issues SET title = ?, body = ?, state = ?, state_reason = ?, labels = ?, assignees = ?, updated_at = ?, closed_at = ? WHERE number = ?", | |
| 281 | + patch.title ?? r.title, | |
| 282 | + patch.body ?? r.body, | |
| 283 | + state, | |
| 284 | + patch.state === "closed" ? (patch.stateReason ?? "completed") : patch.state === "open" ? null : r.state_reason, | |
| 285 | + JSON.stringify(patch.labels ?? JSON.parse(r.labels)), | |
| 286 | + JSON.stringify(patch.assignees ?? JSON.parse(r.assignees)), | |
| 287 | + t, | |
| 288 | + state === "closed" ? (r.closed_at ?? t) : null, | |
| 289 | + number, | |
| 290 | + ); | |
| 291 | + const m = this.repo.meta; | |
| 292 | + if (patch.state && patch.state !== r.state) { | |
| 293 | + const action = patch.state === "closed" ? "closed" : "reopened"; | |
| 294 | + if (r.kind === "pull") { | |
| 295 | + const p = this.pullRow(number); | |
| 296 | + this.repo.emit({ type: "pull_request", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t }); | |
| 297 | + } else this.repo.emit({ type: "issues", action, repoId: m.repoId, owner: m.owner, repo: m.name, number, actor: actorOf(caller), at: t }); | |
| 298 | + } | |
| 299 | + this.repo.broadcast(`pr:${number}`, { action: "updated" }); | |
| 300 | + return this.toIssue(this.row(number)); | |
| 301 | + } | |
| 302 | + | |
| 303 | + addComment(number: number, body: string, caller: Caller): Comment { | |
| 304 | + const r = this.row(number); | |
| 305 | + if (r.locked && !atLeast(caller.role, "triage")) throw new HttpError(403, "conversation is locked"); | |
| 306 | + if (!body?.trim()) throw new HttpError(422, "body is required"); | |
| 307 | + const id = newId(); | |
| 308 | + const t = now(); | |
| 309 | + this.sql.exec("INSERT INTO comments (id, number, author_id, author_login, author_kind, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", id, number, caller.id, caller.login, caller.kind, body, t, t); | |
| 310 | + this.sql.exec("UPDATE issues SET updated_at = ? WHERE number = ?", t, number); | |
| 311 | + const m = this.repo.meta; | |
| 312 | + this.repo.emit({ type: "issue_comment", action: "created", repoId: m.repoId, owner: m.owner, repo: m.name, number, commentId: id, actor: actorOf(caller), at: t }); | |
| 313 | + this.repo.broadcast(`pr:${number}`, { action: "comment", id }); | |
| 314 | + return this.get(number).comments.find((c) => c.id === id)!; | |
| 315 | + } | |
| 316 | + | |
| 317 | + editComment(id: string, body: string, caller: Caller): void { | |
| 318 | + const c = this.sql.exec<{ author_id: string }>("SELECT author_id FROM comments WHERE id = ?", id).toArray()[0]; | |
| 319 | + if (!c) throw new HttpError(404, "comment not found"); | |
| 320 | + if (c.author_id !== caller.id && !atLeast(caller.role, "maintain")) throw new HttpError(403, "permission denied"); | |
| 321 | + this.sql.exec("UPDATE comments SET body = ?, updated_at = ? WHERE id = ?", body, now(), id); | |
| 322 | + } | |
| 323 | + | |
| 324 | + labels(): Array<{ name: string; color: string; description: string }> { | |
| 325 | + return this.sql.exec<{ name: string; color: string; description: string }>("SELECT * FROM labels ORDER BY name").toArray(); | |
| 326 | + } | |
| 327 | + | |
| 328 | + setLabel(name: string, color: string | null, description = ""): void { | |
| 329 | + if (color === null) this.sql.exec("DELETE FROM labels WHERE name = ?", name); | |
| 330 | + else this.sql.exec("INSERT OR REPLACE INTO labels (name, color, description) VALUES (?, ?, ?)", name, color.replace(/^#/, ""), description); | |
| 331 | + } | |
| 332 | + | |
| 333 | + // ---------------------------------------------------------------- pull requests | |
| 334 | + | |
| 335 | + async createPull(input: { title: string; body?: string; head: string; base?: string; draft?: boolean; intent?: string; reviewers?: string[] }, caller: Caller): Promise<Issue> { | |
| 336 | + const base = input.base ?? this.repo.meta.defaultBranch; | |
| 337 | + if (input.head === base) throw new HttpError(422, "head and base must be different branches"); | |
| 338 | + const headSha = this.repo.getRef(`refs/heads/${input.head}`); | |
| 339 | + const baseSha = this.repo.getRef(`refs/heads/${base}`); | |
| 340 | + if (!headSha) throw new HttpError(422, `head branch '${input.head}' does not exist`); | |
| 341 | + if (!baseSha) throw new HttpError(422, `base branch '${base}' does not exist`); | |
| 342 | + if (await isAncestor(this.repo.store, headSha, baseSha)) throw new HttpError(422, `no commits between ${base} and ${input.head}`); | |
| 343 | + const dup = this.sql.exec<{ number: number }>("SELECT p.number FROM pulls p JOIN issues i ON i.number = p.number WHERE p.head_ref = ? AND p.base_ref = ? AND i.state = 'open'", input.head, base).toArray()[0]; | |
| 344 | + if (dup) throw new HttpError(422, `a pull request already exists for ${input.head}: #${dup.number}`, { number: dup.number }); | |
| 345 | + if (!input.title?.trim()) throw new HttpError(422, "title is required"); | |
| 346 | + const n = this.nextNumber(); | |
| 347 | + const t = now(); | |
| 348 | + this.sql.exec( | |
| 349 | + "INSERT INTO issues (number, kind, title, body, author_id, author_login, author_kind, created_at, updated_at) VALUES (?, 'pull', ?, ?, ?, ?, ?, ?, ?)", | |
| 350 | + n, | |
| 351 | + input.title.trim(), | |
| 352 | + input.body ?? "", | |
| 353 | + caller.id, | |
| 354 | + caller.login, | |
| 355 | + caller.kind, | |
| 356 | + t, | |
| 357 | + t, | |
| 358 | + ); | |
| 359 | + this.sql.exec( | |
| 360 | + "INSERT INTO pulls (number, head_ref, head_sha, base_ref, draft, requested_reviewers, intent) VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 361 | + n, | |
| 362 | + input.head, | |
| 363 | + headSha, | |
| 364 | + base, | |
| 365 | + input.draft ? 1 : 0, | |
| 366 | + JSON.stringify(input.reviewers ?? []), | |
| 367 | + input.intent ?? null, | |
| 368 | + ); | |
| 369 | + const m = this.repo.meta; | |
| 370 | + this.repo.emit({ type: "pull_request", action: "opened", repoId: m.repoId, owner: m.owner, repo: m.name, number: n, headRef: input.head, headSha, baseRef: base, actor: actorOf(caller), at: t }); | |
| 371 | + await this.repo.actions.onPullRequest("opened", n, caller).catch((e) => console.error("pr workflow trigger", e)); | |
| 372 | + return this.toIssue(this.row(n)); | |
| 373 | + } | |
| 374 | + | |
| 375 | + requestReviewers(number: number, reviewers: string[], caller: Caller): void { | |
| 376 | + if (!atLeast(caller.role, "triage") && this.row(number).author_id !== caller.id) throw new HttpError(403, "permission denied"); | |
| 377 | + const p = this.pullRow(number); | |
| 378 | + const set = new Set<string>([...JSON.parse(p.requested_reviewers), ...reviewers]); | |
| 379 | + this.sql.exec("UPDATE pulls SET requested_reviewers = ? WHERE number = ?", JSON.stringify([...set]), number); | |
| 380 | + } | |
| 381 | + | |
| 382 | + /** Called after a branch moves: refresh open pull requests whose head is that branch. */ | |
| 383 | + async syncPullsForBranch(branch: string, sha: string, caller: Caller): Promise<number[]> { | |
| 384 | + const rows = this.sql.exec<PullRow>("SELECT p.* FROM pulls p JOIN issues i ON i.number = p.number WHERE p.head_ref = ? AND i.state = 'open'", branch).toArray(); | |
| 385 | + const m = this.repo.meta; | |
| 386 | + const out: number[] = []; | |
| 387 | + for (const p of rows) { | |
| 388 | + if (p.head_sha === sha) continue; | |
| 389 | + this.sql.exec("UPDATE pulls SET head_sha = ? WHERE number = ?", sha, p.number); | |
| 390 | + this.sql.exec("UPDATE issues SET updated_at = ? WHERE number = ?", now(), p.number); | |
| 391 | + const prefs = this.repo.protectionFor(p.base_ref); | |
| 392 | + if (prefs?.dismissStaleReviews) this.sql.exec("UPDATE reviews SET state = 'DISMISSED' WHERE number = ? AND state = 'APPROVED' AND commit_id != ?", p.number, sha); | |
| 393 | + this.repo.emit({ type: "pull_request", action: "synchronize", repoId: m.repoId, owner: m.owner, repo: m.name, number: p.number, headRef: branch, headSha: sha, baseRef: p.base_ref, actor: actorOf(caller), at: now() }); | |
| 394 | + await this.repo.actions.onPullRequest("synchronize", p.number, caller).catch((e) => console.error("pr workflow trigger", e)); | |
| 395 | + this.repo.broadcast(`pr:${p.number}`, { action: "synchronize", sha }); | |
| 396 | + out.push(p.number); | |
| 397 | + } | |
| 398 | + return out; | |
| 399 | + } | |
| 400 | + | |
| 401 | + async pullFiles(number: number) { | |
| 402 | + const p = this.pullRow(number); | |
| 403 | + const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`) ?? p.head_sha; | |
| 404 | + const head = p.merged && p.merge_commit ? p.head_sha : p.head_sha; | |
| 405 | + const mb = await mergeBase(this.repo.store, baseSha, head); | |
| 406 | + return { mergeBase: mb, headSha: head, ...(await this.repo.diff(mb, head)) }; | |
| 407 | + } | |
| 408 | + | |
| 409 | + async pullCommits(number: number) { | |
| 410 | + const p = this.pullRow(number); | |
| 411 | + const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`); | |
| 412 | + const mb = baseSha ? await mergeBase(this.repo.store, baseSha, p.head_sha) : null; | |
| 413 | + const stop = new Set<string>(); | |
| 414 | + if (mb) for (const c of await walkCommits(this.repo.store, [mb], { limit: 100000 })) stop.add(c.oid); | |
| 415 | + return (await walkCommits(this.repo.store, [p.head_sha], { stop, limit: 250 })).map((c) => toInfo(c.oid, c.commit)).reverse(); | |
| 416 | + } | |
| 417 | + | |
| 418 | + submitReview( | |
| 419 | + number: number, | |
| 420 | + input: { event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; body?: string; comments?: Array<{ path: string; line: number; side?: "LEFT" | "RIGHT"; body: string; inReplyTo?: string }> }, | |
| 421 | + caller: Caller, | |
| 422 | + ): Review { | |
| 423 | + const r = this.row(number); | |
| 424 | + const p = this.pullRow(number); | |
| 425 | + if (r.state !== "open") throw new HttpError(422, "pull request is closed"); | |
| 426 | + if (input.event !== "COMMENT" && r.author_id === caller.id) throw new HttpError(422, "you cannot approve or request changes on your own pull request"); | |
| 427 | + if (input.event !== "COMMENT" && !atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to approve or request changes"); | |
| 428 | + if (input.event === "COMMENT" && !input.body?.trim() && !input.comments?.length) throw new HttpError(422, "a comment review needs a body or line comments"); | |
| 429 | + const id = newId(); | |
| 430 | + const t = now(); | |
| 431 | + const state = input.event === "APPROVE" ? "APPROVED" : input.event === "REQUEST_CHANGES" ? "CHANGES_REQUESTED" : "COMMENTED"; | |
| 432 | + this.sql.exec( | |
| 433 | + "INSERT INTO reviews (id, number, author_id, author_login, author_kind, state, body, commit_id, submitted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 434 | + id, | |
| 435 | + number, | |
| 436 | + caller.id, | |
| 437 | + caller.login, | |
| 438 | + caller.kind, | |
| 439 | + state, | |
| 440 | + input.body ?? "", | |
| 441 | + p.head_sha, | |
| 442 | + t, | |
| 443 | + ); | |
| 444 | + for (const c of input.comments ?? []) { | |
| 445 | + this.sql.exec( | |
| 446 | + "INSERT INTO comments (id, number, author_id, author_login, author_kind, body, created_at, updated_at, review_id, path, line, side, commit_id, in_reply_to) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 447 | + newId(), | |
| 448 | + number, | |
| 449 | + caller.id, | |
| 450 | + caller.login, | |
| 451 | + caller.kind, | |
| 452 | + c.body, | |
| 453 | + t, | |
| 454 | + t, | |
| 455 | + id, | |
| 456 | + c.path, | |
| 457 | + c.line, | |
| 458 | + c.side ?? "RIGHT", | |
| 459 | + p.head_sha, | |
| 460 | + c.inReplyTo ?? null, | |
| 461 | + ); | |
| 462 | + } | |
| 463 | + const reviewers = (JSON.parse(p.requested_reviewers) as string[]).filter((x) => x.toLowerCase() !== caller.login.toLowerCase()); | |
| 464 | + this.sql.exec("UPDATE pulls SET requested_reviewers = ? WHERE number = ?", JSON.stringify(reviewers), number); | |
| 465 | + this.sql.exec("UPDATE issues SET updated_at = ? WHERE number = ?", t, number); | |
| 466 | + const m = this.repo.meta; | |
| 467 | + this.repo.emit({ type: "pull_request_review", action: "submitted", repoId: m.repoId, owner: m.owner, repo: m.name, number, state, actor: actorOf(caller), at: t }); | |
| 468 | + this.repo.broadcast(`pr:${number}`, { action: "review", state }); | |
| 469 | + return this.reviews(number).find((x) => x.id === id)!; | |
| 470 | + } | |
| 471 | + | |
| 472 | + resolveThread(commentId: string, resolved: boolean, caller: Caller): void { | |
| 473 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "permission denied"); | |
| 474 | + this.sql.exec("UPDATE comments SET resolved = ? WHERE id = ? OR in_reply_to = ?", resolved ? 1 : 0, commentId, commentId); | |
| 475 | + } | |
| 476 | + | |
| 477 | + // ---------------------------------------------------------------- checks | |
| 478 | + | |
| 479 | + checks(sha: string): Check[] { | |
| 480 | + return this.sql | |
| 481 | + .exec<Record<string, string | null>>("SELECT * FROM checks WHERE sha = ? ORDER BY name", sha) | |
| 482 | + .toArray() | |
| 483 | + .map((c) => ({ | |
| 484 | + id: c.id!, | |
| 485 | + sha: c.sha!, | |
| 486 | + name: c.name!, | |
| 487 | + status: c.status as Check["status"], | |
| 488 | + conclusion: c.conclusion, | |
| 489 | + runId: c.run_id, | |
| 490 | + jobId: c.job_id, | |
| 491 | + detailsUrl: c.details_url, | |
| 492 | + output: c.output ? JSON.parse(c.output) : null, | |
| 493 | + app: c.app!, | |
| 494 | + startedAt: c.started_at, | |
| 495 | + completedAt: c.completed_at, | |
| 496 | + })); | |
| 497 | + } | |
| 498 | + | |
| 499 | + /** Create or update a check run, keyed by (sha, name, app). */ | |
| 500 | + upsertCheck(input: { sha: string; name: string; status: Check["status"]; conclusion?: string | null; runId?: string; jobId?: string; detailsUrl?: string; output?: Check["output"]; app?: string }): Check { | |
| 501 | + const app = input.app ?? "diffinery-actions"; | |
| 502 | + const existing = this.sql.exec<{ id: string; started_at: string | null }>("SELECT id, started_at FROM checks WHERE sha = ? AND name = ? AND app = ?", input.sha, input.name, app).toArray()[0]; | |
| 503 | + const t = now(); | |
| 504 | + const id = existing?.id ?? newId(); | |
| 505 | + this.sql.exec( | |
| 506 | + "INSERT OR REPLACE INTO checks (id, sha, name, status, conclusion, run_id, job_id, details_url, output, app, started_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 507 | + id, | |
| 508 | + input.sha, | |
| 509 | + input.name, | |
| 510 | + input.status, | |
| 511 | + input.conclusion ?? null, | |
| 512 | + input.runId ?? null, | |
| 513 | + input.jobId ?? null, | |
| 514 | + input.detailsUrl ?? null, | |
| 515 | + input.output ? JSON.stringify(input.output) : null, | |
| 516 | + app, | |
| 517 | + existing?.started_at ?? (input.status !== "queued" ? t : null), | |
| 518 | + input.status === "completed" ? t : null, | |
| 519 | + ); | |
| 520 | + for (const p of this.sql.exec<{ number: number }>("SELECT number FROM pulls WHERE head_sha = ?", input.sha).toArray()) this.repo.broadcast(`pr:${p.number}`, { action: "checks" }); | |
| 521 | + return this.checks(input.sha).find((c) => c.id === id)!; | |
| 522 | + } | |
| 523 | + | |
| 524 | + // ---------------------------------------------------------------- merging | |
| 525 | + | |
| 526 | + async mergeability(number: number, caller?: Caller): Promise<Mergeability> { | |
| 527 | + const r = this.row(number); | |
| 528 | + const p = this.pullRow(number); | |
| 529 | + const result: Mergeability = { mergeable: false, state: "unknown", conflicts: [], approvals: 0, requiredApprovals: 0, changesRequested: [], checks: [], blockers: [], mergeBase: null }; | |
| 530 | + if (p.merged) return { ...result, state: "merged", blockers: ["already merged"] }; | |
| 531 | + if (r.state === "closed") return { ...result, state: "closed", blockers: ["pull request is closed"] }; | |
| 532 | + const baseSha = this.repo.getRef(`refs/heads/${p.base_ref}`); | |
| 533 | + if (!baseSha) return { ...result, blockers: [`base branch ${p.base_ref} no longer exists`] }; | |
| 534 | + const mb = await mergeBase(this.repo.store, baseSha, p.head_sha); | |
| 535 | + result.mergeBase = mb; | |
| 536 | + const [bt, ht, mt] = await Promise.all([readCommit(this.repo.store, baseSha), readCommit(this.repo.store, p.head_sha), mb ? readCommit(this.repo.store, mb) : null]); | |
| 537 | + const merged = await mergeTrees(this.repo.store, mt?.tree ?? null, bt.tree, ht.tree); | |
| 538 | + result.conflicts = merged.conflicts; | |
| 539 | + const rules = this.repo.protectionFor(p.base_ref) ?? {}; | |
| 540 | + result.requiredApprovals = rules.requiredApprovals ?? 0; | |
| 541 | + const latest = new Map<string, Review>(); | |
| 542 | + for (const rv of this.reviews(number)) if (rv.state !== "COMMENTED") latest.set(rv.author.id, rv); | |
| 543 | + for (const rv of latest.values()) { | |
| 544 | + if (rv.state === "APPROVED" && (!rules.dismissStaleReviews || rv.commitId === p.head_sha)) result.approvals++; | |
| 545 | + if (rv.state === "CHANGES_REQUESTED") result.changesRequested.push(rv.author.login); | |
| 546 | + } | |
| 547 | + const checks = this.checks(p.head_sha); | |
| 548 | + result.checks = checks.map((c) => ({ name: c.name, state: c.status === "completed" ? (c.conclusion ?? "neutral") : c.status })); | |
| 549 | + if (r.author_kind === "agent" && result.approvals === 0 && (rules.requiredApprovals ?? 0) === 0) { | |
| 550 | + // Agent-authored changes always need one human approval unless a rule says otherwise. | |
| 551 | + result.requiredApprovals = 1; | |
| 552 | + } | |
| 553 | + if (p.draft) result.blockers.push("pull request is a draft"); | |
| 554 | + if (result.conflicts.length) result.blockers.push(`merge conflicts in ${result.conflicts.length} file(s)`); | |
| 555 | + if (result.approvals < result.requiredApprovals) result.blockers.push(`needs ${result.requiredApprovals - result.approvals} more approving review(s)`); | |
| 556 | + if (result.changesRequested.length) result.blockers.push(`changes requested by ${result.changesRequested.join(", ")}`); | |
| 557 | + for (const name of rules.requiredChecks ?? []) { | |
| 558 | + const c = checks.find((x) => x.name === name); | |
| 559 | + if (!c) result.blockers.push(`required check '${name}' has not run`); | |
| 560 | + else if (c.status !== "completed") result.blockers.push(`required check '${name}' is ${c.status}`); | |
| 561 | + else if (c.conclusion !== "success" && c.conclusion !== "skipped" && c.conclusion !== "neutral") result.blockers.push(`required check '${name}' ${c.conclusion}`); | |
| 562 | + } | |
| 563 | + if (rules.requireLinearHistory && mb !== baseSha) result.blockers.push("branch is behind its base; update it (linear history required)"); | |
| 564 | + if (caller) { | |
| 565 | + if (!atLeast(caller.role, "write")) result.blockers.push("write access is required to merge"); | |
| 566 | + const isDefault = p.base_ref === this.repo.meta.defaultBranch; | |
| 567 | + if (caller.kind === "agent" && (isDefault || Object.keys(rules).length) && !rules.agentsMayMerge) result.blockers.push("agents may not merge into this branch; a human must merge"); | |
| 568 | + } | |
| 569 | + result.mergeable = result.blockers.length === 0; | |
| 570 | + result.state = p.draft ? "draft" : result.conflicts.length ? "conflicting" : result.mergeable ? "clean" : "blocked"; | |
| 571 | + return result; | |
| 572 | + } | |
| 573 | + | |
| 574 | + async merge(number: number, input: { method?: "merge" | "squash" | "rebase"; title?: string; message?: string; expectedHeadSha?: string; deleteBranch?: boolean }, caller: Caller) { | |
| 575 | + const p = this.pullRow(number); | |
| 576 | + const r = this.row(number); | |
| 577 | + if (input.expectedHeadSha && input.expectedHeadSha !== p.head_sha) throw new HttpError(409, "head branch was modified; review and try again"); | |
| 578 | + const m = await this.mergeability(number, caller); | |
| 579 | + const rules = this.repo.protectionFor(p.base_ref) ?? {}; | |
| 580 | + const adminBypass = caller.role === "admin" && !rules.enforceAdmins && caller.kind === "user" && m.conflicts.length === 0 && !p.draft; | |
| 581 | + if (!m.mergeable && !adminBypass) throw new HttpError(405, `pull request is not mergeable: ${m.blockers.join("; ")}`, { blockers: m.blockers }); | |
| 582 | + const baseRef = `refs/heads/${p.base_ref}`; | |
| 583 | + const baseSha = this.repo.getRef(baseRef)!; | |
| 584 | + const store = this.repo.store; | |
| 585 | + const method = input.method ?? (rules.requireLinearHistory ? "squash" : "merge"); | |
| 586 | + let newSha: string; | |
| 587 | + const ts = Math.floor(Date.now() / 1000); | |
| 588 | + const sig = { name: caller.login, email: `${caller.login}@users.noreply.diffinery.com`, timestamp: ts, timezone: "+0000" }; | |
| 589 | + if (method === "rebase") { | |
| 590 | + if (!(await isAncestor(store, baseSha, p.head_sha))) throw new HttpError(405, "rebase merge requires the branch to be up to date with its base (fast-forward only for now)"); | |
| 591 | + newSha = p.head_sha; | |
| 592 | + } else { | |
| 593 | + const [bt, ht, mt] = await Promise.all([readCommit(store, baseSha), readCommit(store, p.head_sha), m.mergeBase ? readCommit(store, m.mergeBase) : null]); | |
| 594 | + const merged = await mergeTrees(store, mt?.tree ?? null, bt.tree, ht.tree); | |
| 595 | + if (merged.conflicts.length || !merged.tree) throw new HttpError(405, "merge conflicts"); | |
| 596 | + const title = input.title ?? (method === "squash" ? `${r.title} (#${number})` : `Merge pull request #${number} from ${p.head_ref}`); | |
| 597 | + const body = input.message ?? (method === "squash" ? (await this.pullCommits(number)).map((c) => `* ${c.message.split("\n")[0]}`).join("\n") : r.title); | |
| 598 | + const author = method === "squash" ? { ...sig, name: r.author_login, email: `${r.author_login}@users.noreply.diffinery.com` } : sig; | |
| 599 | + newSha = await store.write( | |
| 600 | + "commit", | |
| 601 | + serializeCommit({ tree: merged.tree, parents: method === "squash" ? [baseSha] : [baseSha, p.head_sha], author, committer: sig, message: `${title}\n\n${body}\n` }), | |
| 602 | + ); | |
| 603 | + } | |
| 604 | + if (!this.repo.casRef(baseRef, baseSha, newSha)) throw new HttpError(409, "base branch moved during merge; try again"); | |
| 605 | + const t = now(); | |
| 606 | + this.sql.exec("UPDATE pulls SET merged = 1, merged_at = ?, merged_by = ?, merge_commit = ? WHERE number = ?", t, caller.login, newSha, number); | |
| 607 | + this.sql.exec("UPDATE issues SET state = 'closed', state_reason = 'merged', closed_at = ?, updated_at = ? WHERE number = ?", t, t, number); | |
| 608 | + const meta = this.repo.meta; | |
| 609 | + this.repo.emit({ type: "pull_request", action: "merged", repoId: meta.repoId, owner: meta.owner, repo: meta.name, number, headRef: p.head_ref, headSha: p.head_sha, baseRef: p.base_ref, actor: actorOf(caller), at: t }); | |
| 610 | + // A merge is a push to the base branch from the server's point of view. | |
| 611 | + const serverCaller: Caller = { ...caller, role: "admin" }; | |
| 612 | + await this.repo.afterRefUpdate(baseRef, baseSha, newSha, serverCaller); | |
| 613 | + // Close keywords: "fixes #12", "closes #3", "resolves #7". | |
| 614 | + for (const match of `${r.title}\n${r.body}`.matchAll(/\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)/gi)) { | |
| 615 | + const n = Number(match[1]); | |
| 616 | + try { | |
| 617 | + const target = this.row(n); | |
| 618 | + if (target.kind === "issue" && target.state === "open") this.update(n, { state: "closed", stateReason: "completed" }, serverCaller); | |
| 619 | + } catch { | |
| 620 | + /* unknown issue number */ | |
| 621 | + } | |
| 622 | + } | |
| 623 | + if (input.deleteBranch && p.head_ref !== meta.defaultBranch) { | |
| 624 | + const headRef = `refs/heads/${p.head_ref}`; | |
| 625 | + if (this.repo.getRef(headRef) === p.head_sha) this.repo.casRef(headRef, p.head_sha, ZERO_OID); | |
| 626 | + } | |
| 627 | + this.repo.broadcast(`pr:${number}`, { action: "merged", sha: newSha }); | |
| 628 | + return { merged: true, sha: newSha, method }; | |
| 629 | + } | |
| 630 | + | |
| 631 | + // ---------------------------------------------------------------- webhooks | |
| 632 | + | |
| 633 | + webhooks(): Array<{ id: string; url: string; events: string[]; active: boolean; createdAt: string; lastStatus: number | null; lastDeliveryAt: string | null; secret: string }> { | |
| 634 | + return this.sql | |
| 635 | + .exec<Record<string, string | number | null>>("SELECT * FROM webhooks ORDER BY created_at") | |
| 636 | + .toArray() | |
| 637 | + .map((w) => ({ | |
| 638 | + id: w.id as string, | |
| 639 | + url: w.url as string, | |
| 640 | + events: JSON.parse(w.events as string), | |
| 641 | + active: !!w.active, | |
| 642 | + createdAt: w.created_at as string, | |
| 643 | + lastStatus: (w.last_status as number) ?? null, | |
| 644 | + lastDeliveryAt: (w.last_delivery_at as string) ?? null, | |
| 645 | + secret: w.secret as string, | |
| 646 | + })); | |
| 647 | + } | |
| 648 | + | |
| 649 | + createWebhook(url: string, events: string[], secret?: string): { id: string; secret: string } { | |
| 650 | + if (!/^https:\/\//.test(url)) throw new HttpError(422, "webhook URL must use https"); | |
| 651 | + const id = newId(); | |
| 652 | + const s = secret ?? randomToken(24); | |
| 653 | + this.sql.exec("INSERT INTO webhooks (id, url, secret, events, created_at) VALUES (?, ?, ?, ?, ?)", id, url, s, JSON.stringify(events.length ? events : ["*"]), now()); | |
| 654 | + return { id, secret: s }; | |
| 655 | + } | |
| 656 | + | |
| 657 | + deleteWebhook(id: string): void { | |
| 658 | + this.sql.exec("DELETE FROM webhooks WHERE id = ?", id); | |
| 659 | + } | |
| 660 | + | |
| 661 | + recordDelivery(id: string, status: number): void { | |
| 662 | + this.sql.exec("UPDATE webhooks SET last_status = ?, last_delivery_at = ? WHERE id = ?", status, now(), id); | |
| 663 | + } | |
| 664 | +} |
addedapps/diffinery/src/repo/objects.ts+97−0View file
| @@ -1,0 +1,97 @@ | ||
| 1 | +import { CODE_TYPES, TYPE_CODES, deflate, hashObject, inflate, type GitObject, type ObjectStore, type ObjectType } from "@diffinery/git-core"; | |
| 2 | + | |
| 3 | +/** Objects larger than this (compressed) go to R2 instead of SQLite (rows are capped at 2 MB). */ | |
| 4 | +const INLINE_LIMIT = 512 * 1024; | |
| 5 | +const CACHE_BYTES = 24 * 1024 * 1024; | |
| 6 | + | |
| 7 | +export interface RawObject { | |
| 8 | + type: ObjectType; | |
| 9 | + size: number; | |
| 10 | + deflated: Uint8Array; | |
| 11 | +} | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * Git object storage for one repository: SQLite inside the Durable Object for | |
| 15 | + * small objects, R2 for large ones, with an in-memory LRU of inflated objects. | |
| 16 | + */ | |
| 17 | +export class RepoObjectStore implements ObjectStore { | |
| 18 | + private cache = new Map<string, GitObject>(); | |
| 19 | + private cacheBytes = 0; | |
| 20 | + | |
| 21 | + constructor(private sql: SqlStorage, private bucket: R2Bucket, private repoId: string) {} | |
| 22 | + | |
| 23 | + private r2Key(oid: string) { | |
| 24 | + return `repos/${this.repoId}/objects/${oid.slice(0, 2)}/${oid.slice(2)}`; | |
| 25 | + } | |
| 26 | + | |
| 27 | + private remember(oid: string, obj: GitObject) { | |
| 28 | + if (obj.content.length > 4 * 1024 * 1024) return; | |
| 29 | + this.cache.set(oid, obj); | |
| 30 | + this.cacheBytes += obj.content.length; | |
| 31 | + while (this.cacheBytes > CACHE_BYTES) { | |
| 32 | + const [k, v] = this.cache.entries().next().value as [string, GitObject]; | |
| 33 | + this.cache.delete(k); | |
| 34 | + this.cacheBytes -= v.content.length; | |
| 35 | + } | |
| 36 | + } | |
| 37 | + | |
| 38 | + has(oid: string): boolean { | |
| 39 | + return this.sql.exec("SELECT 1 FROM objects WHERE oid = ?", oid).toArray().length > 0; | |
| 40 | + } | |
| 41 | + | |
| 42 | + async readRaw(oid: string): Promise<RawObject | null> { | |
| 43 | + const row = this.sql.exec<{ type: number; size: number; data: ArrayBuffer | null; r2: number }>("SELECT type, size, data, r2 FROM objects WHERE oid = ?", oid).toArray()[0]; | |
| 44 | + if (!row) return null; | |
| 45 | + let deflated: Uint8Array; | |
| 46 | + if (row.r2) { | |
| 47 | + const o = await this.bucket.get(this.r2Key(oid)); | |
| 48 | + if (!o) throw new Error(`object ${oid} missing from R2`); | |
| 49 | + deflated = new Uint8Array(await o.arrayBuffer()); | |
| 50 | + } else { | |
| 51 | + deflated = new Uint8Array(row.data!); | |
| 52 | + } | |
| 53 | + return { type: CODE_TYPES[row.type]!, size: row.size, deflated }; | |
| 54 | + } | |
| 55 | + | |
| 56 | + async read(oid: string): Promise<GitObject | null> { | |
| 57 | + const hit = this.cache.get(oid); | |
| 58 | + if (hit) { | |
| 59 | + this.cache.delete(oid); | |
| 60 | + this.cache.set(oid, hit); | |
| 61 | + return hit; | |
| 62 | + } | |
| 63 | + const raw = await this.readRaw(oid); | |
| 64 | + if (!raw) return null; | |
| 65 | + const obj = { type: raw.type, content: inflate(raw.deflated) }; | |
| 66 | + this.remember(oid, obj); | |
| 67 | + return obj; | |
| 68 | + } | |
| 69 | + | |
| 70 | + async write(type: ObjectType, content: Uint8Array): Promise<string> { | |
| 71 | + const oid = await hashObject(type, content); | |
| 72 | + await this.writeKnown(oid, type, content); | |
| 73 | + return oid; | |
| 74 | + } | |
| 75 | + | |
| 76 | + /** Store an object whose oid is already verified. */ | |
| 77 | + async writeKnown(oid: string, type: ObjectType, content: Uint8Array): Promise<void> { | |
| 78 | + if (this.has(oid)) return; | |
| 79 | + const deflated = deflate(content); | |
| 80 | + if (deflated.length > INLINE_LIMIT) { | |
| 81 | + await this.bucket.put(this.r2Key(oid), deflated); | |
| 82 | + this.sql.exec("INSERT OR IGNORE INTO objects (oid, type, size, data, r2) VALUES (?, ?, ?, NULL, 1)", oid, TYPE_CODES[type], content.length); | |
| 83 | + } else { | |
| 84 | + this.sql.exec("INSERT OR IGNORE INTO objects (oid, type, size, data, r2) VALUES (?, ?, ?, ?, 0)", oid, TYPE_CODES[type], content.length, deflated); | |
| 85 | + } | |
| 86 | + this.remember(oid, { type, content }); | |
| 87 | + } | |
| 88 | + | |
| 89 | + count(): number { | |
| 90 | + return this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM objects").one().n; | |
| 91 | + } | |
| 92 | + | |
| 93 | + /** Approximate stored bytes (compressed). */ | |
| 94 | + sizeBytes(): number { | |
| 95 | + return this.sql.exec<{ n: number | null }>("SELECT sum(length(data)) AS n FROM objects").one().n ?? 0; | |
| 96 | + } | |
| 97 | +} |
addedapps/diffinery/src/repo/schema.ts+66−0View file
| @@ -1,0 +1,66 @@ | ||
| 1 | +/** SQLite schema of a repository Durable Object. Applied idempotently on construction. */ | |
| 2 | +export const REPO_SCHEMA = ` | |
| 3 | +CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); | |
| 4 | +CREATE TABLE IF NOT EXISTS refs (name TEXT PRIMARY KEY, oid TEXT NOT NULL); | |
| 5 | +CREATE TABLE IF NOT EXISTS objects ( | |
| 6 | + oid TEXT PRIMARY KEY, type INTEGER NOT NULL, size INTEGER NOT NULL, data BLOB, r2 INTEGER NOT NULL DEFAULT 0 | |
| 7 | +); | |
| 8 | +CREATE TABLE IF NOT EXISTS counters (name TEXT PRIMARY KEY, value INTEGER NOT NULL); | |
| 9 | + | |
| 10 | +CREATE TABLE IF NOT EXISTS issues ( | |
| 11 | + number INTEGER PRIMARY KEY, kind TEXT NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', | |
| 12 | + state TEXT NOT NULL DEFAULT 'open', state_reason TEXT, author_id TEXT NOT NULL, author_login TEXT NOT NULL, | |
| 13 | + author_kind TEXT NOT NULL DEFAULT 'user', assignees TEXT NOT NULL DEFAULT '[]', labels TEXT NOT NULL DEFAULT '[]', | |
| 14 | + created_at TEXT NOT NULL, updated_at TEXT NOT NULL, closed_at TEXT, locked INTEGER NOT NULL DEFAULT 0 | |
| 15 | +); | |
| 16 | +CREATE TABLE IF NOT EXISTS pulls ( | |
| 17 | + number INTEGER PRIMARY KEY, head_ref TEXT NOT NULL, head_sha TEXT NOT NULL, base_ref TEXT NOT NULL, | |
| 18 | + draft INTEGER NOT NULL DEFAULT 0, merged INTEGER NOT NULL DEFAULT 0, merged_at TEXT, merged_by TEXT, | |
| 19 | + merge_commit TEXT, requested_reviewers TEXT NOT NULL DEFAULT '[]', intent TEXT | |
| 20 | +); | |
| 21 | +CREATE INDEX IF NOT EXISTS pulls_head ON pulls(head_ref); | |
| 22 | +CREATE TABLE IF NOT EXISTS comments ( | |
| 23 | + id TEXT PRIMARY KEY, number INTEGER NOT NULL, author_id TEXT NOT NULL, author_login TEXT NOT NULL, | |
| 24 | + author_kind TEXT NOT NULL DEFAULT 'user', body TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, | |
| 25 | + review_id TEXT, path TEXT, line INTEGER, side TEXT, commit_id TEXT, in_reply_to TEXT, resolved INTEGER NOT NULL DEFAULT 0 | |
| 26 | +); | |
| 27 | +CREATE INDEX IF NOT EXISTS comments_number ON comments(number); | |
| 28 | +CREATE TABLE IF NOT EXISTS reviews ( | |
| 29 | + id TEXT PRIMARY KEY, number INTEGER NOT NULL, author_id TEXT NOT NULL, author_login TEXT NOT NULL, | |
| 30 | + author_kind TEXT NOT NULL DEFAULT 'user', state TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', commit_id TEXT NOT NULL, | |
| 31 | + submitted_at TEXT NOT NULL | |
| 32 | +); | |
| 33 | +CREATE INDEX IF NOT EXISTS reviews_number ON reviews(number); | |
| 34 | +CREATE TABLE IF NOT EXISTS labels (name TEXT PRIMARY KEY, color TEXT NOT NULL, description TEXT NOT NULL DEFAULT ''); | |
| 35 | +CREATE TABLE IF NOT EXISTS protections (pattern TEXT PRIMARY KEY, rules TEXT NOT NULL); | |
| 36 | +CREATE TABLE IF NOT EXISTS secrets (name TEXT PRIMARY KEY, ciphertext TEXT NOT NULL, updated_at TEXT NOT NULL); | |
| 37 | +CREATE TABLE IF NOT EXISTS variables (name TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL); | |
| 38 | +CREATE TABLE IF NOT EXISTS webhooks ( | |
| 39 | + id TEXT PRIMARY KEY, url TEXT NOT NULL, secret TEXT NOT NULL, events TEXT NOT NULL, active INTEGER NOT NULL DEFAULT 1, | |
| 40 | + created_at TEXT NOT NULL, last_status INTEGER, last_delivery_at TEXT | |
| 41 | +); | |
| 42 | +CREATE TABLE IF NOT EXISTS checks ( | |
| 43 | + id TEXT PRIMARY KEY, sha TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL, conclusion TEXT, | |
| 44 | + run_id TEXT, job_id TEXT, details_url TEXT, output TEXT, app TEXT NOT NULL DEFAULT 'diffinery-actions', | |
| 45 | + started_at TEXT, completed_at TEXT | |
| 46 | +); | |
| 47 | +CREATE INDEX IF NOT EXISTS checks_sha ON checks(sha); | |
| 48 | +CREATE TABLE IF NOT EXISTS runs ( | |
| 49 | + id TEXT PRIMARY KEY, run_number INTEGER NOT NULL, workflow_path TEXT NOT NULL, workflow_name TEXT NOT NULL, | |
| 50 | + event TEXT NOT NULL, ref TEXT NOT NULL, sha TEXT NOT NULL, actor TEXT NOT NULL, status TEXT NOT NULL, | |
| 51 | + conclusion TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, pr_number INTEGER, attempt INTEGER NOT NULL DEFAULT 1, | |
| 52 | + event_payload TEXT NOT NULL, inputs TEXT NOT NULL DEFAULT '{}', concurrency_group TEXT, display_title TEXT NOT NULL DEFAULT '' | |
| 53 | +); | |
| 54 | +CREATE INDEX IF NOT EXISTS runs_created ON runs(created_at); | |
| 55 | +CREATE TABLE IF NOT EXISTS jobs ( | |
| 56 | + id TEXT PRIMARY KEY, run_id TEXT NOT NULL, job_key TEXT NOT NULL, name TEXT NOT NULL, status TEXT NOT NULL, | |
| 57 | + conclusion TEXT, matrix TEXT NOT NULL DEFAULT '{}', strategy TEXT NOT NULL DEFAULT '{}', started_at TEXT, completed_at TEXT, | |
| 58 | + outputs TEXT NOT NULL DEFAULT '{}', steps TEXT NOT NULL DEFAULT '[]', failure TEXT, token_hash TEXT, summary TEXT, | |
| 59 | + container TEXT, timeout_minutes INTEGER NOT NULL DEFAULT 360 | |
| 60 | +); | |
| 61 | +CREATE INDEX IF NOT EXISTS jobs_run ON jobs(run_id); | |
| 62 | +CREATE TABLE IF NOT EXISTS logs (job_id TEXT NOT NULL, seq INTEGER NOT NULL, t TEXT NOT NULL, step INTEGER NOT NULL, text TEXT NOT NULL, PRIMARY KEY (job_id, seq)); | |
| 63 | +CREATE TABLE IF NOT EXISTS caches (key TEXT PRIMARY KEY, size INTEGER NOT NULL, created_at TEXT NOT NULL, last_used TEXT NOT NULL); | |
| 64 | +CREATE TABLE IF NOT EXISTS artifacts (id TEXT PRIMARY KEY, run_id TEXT NOT NULL, name TEXT NOT NULL, size INTEGER NOT NULL, created_at TEXT NOT NULL, UNIQUE(run_id, name)); | |
| 65 | +CREATE TABLE IF NOT EXISTS sandboxes (id TEXT PRIMARY KEY, secret TEXT NOT NULL, created_by TEXT NOT NULL, ref TEXT NOT NULL, created_at TEXT NOT NULL, status TEXT NOT NULL); | |
| 66 | +`; |
addedapps/diffinery/src/repo/types.ts+51−0View file
| @@ -1,0 +1,51 @@ | ||
| 1 | +import type { Actor } from "@diffinery/shared"; | |
| 2 | + | |
| 3 | +export type Role = "read" | "triage" | "write" | "maintain" | "admin"; | |
| 4 | +export const ROLE_LEVEL: Record<Role, number> = { read: 1, triage: 2, write: 3, maintain: 4, admin: 5 }; | |
| 5 | +export const atLeast = (role: Role | null | undefined, needed: Role): boolean => !!role && ROLE_LEVEL[role] >= ROLE_LEVEL[needed]; | |
| 6 | + | |
| 7 | +/** The authenticated caller, with their effective role on this repository. */ | |
| 8 | +export interface Caller extends Actor { | |
| 9 | + role: Role; | |
| 10 | +} | |
| 11 | + | |
| 12 | +export interface RepoMeta { | |
| 13 | + repoId: string; | |
| 14 | + ownerId: string; | |
| 15 | + owner: string; | |
| 16 | + name: string; | |
| 17 | + defaultBranch: string; | |
| 18 | + /** Owner kind decides whether org secrets apply. */ | |
| 19 | + ownerKind: "user" | "org"; | |
| 20 | +} | |
| 21 | + | |
| 22 | +export interface ProtectionRules { | |
| 23 | + requirePullRequest?: boolean; | |
| 24 | + requiredApprovals?: number; | |
| 25 | + dismissStaleReviews?: boolean; | |
| 26 | + requiredChecks?: string[]; | |
| 27 | + allowForcePush?: boolean; | |
| 28 | + allowDeletion?: boolean; | |
| 29 | + requireLinearHistory?: boolean; | |
| 30 | + /** When false (default), admins may bypass the rules. */ | |
| 31 | + enforceAdmins?: boolean; | |
| 32 | + /** Agents may push directly to matching branches. Default false. */ | |
| 33 | + agentsMayPush?: boolean; | |
| 34 | + /** Agents may merge pull requests into matching branches. Default false. */ | |
| 35 | + agentsMayMerge?: boolean; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export function globToRegExp(pattern: string): RegExp { | |
| 39 | + let re = ""; | |
| 40 | + for (let i = 0; i < pattern.length; i++) { | |
| 41 | + const c = pattern[i]!; | |
| 42 | + if (c === "*") { | |
| 43 | + if (pattern[i + 1] === "*") { | |
| 44 | + re += ".*"; | |
| 45 | + i++; | |
| 46 | + } else re += "[^/]*"; | |
| 47 | + } else if (c === "?") re += "[^/]"; | |
| 48 | + else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&"); | |
| 49 | + } | |
| 50 | + return new RegExp(`^${re}$`); | |
| 51 | +} |
addedapps/diffinery/src/runner/containers.ts+77−0View file
| @@ -1,0 +1,77 @@ | ||
| 1 | +import { Container } from "@cloudflare/containers"; | |
| 2 | +import type { StopParams } from "@cloudflare/containers"; | |
| 3 | +import type { Env } from "../env.js"; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * One container per workflow job. The runner agent inside fetches its job spec | |
| 7 | + * from the Diffinery API, runs the steps and reports back, then exits. | |
| 8 | + */ | |
| 9 | +export class RunnerContainer extends Container<Env> { | |
| 10 | + // Jobs make no inbound requests, so activity-based sleep must not stop them. | |
| 11 | + override sleepAfter = "6h"; | |
| 12 | + override enableInternet = true; | |
| 13 | + | |
| 14 | + async startJob(input: { repoId: string; jobId: string; token: string; apiUrl: string }): Promise<void> { | |
| 15 | + await this.ctx.storage.put("job", { repoId: input.repoId, jobId: input.jobId }); | |
| 16 | + await this.start({ | |
| 17 | + envVars: { | |
| 18 | + DIFFINERY_API_URL: input.apiUrl, | |
| 19 | + DIFFINERY_JOB_ID: input.jobId, | |
| 20 | + DIFFINERY_JOB_TOKEN: input.token, | |
| 21 | + }, | |
| 22 | + }); | |
| 23 | + } | |
| 24 | + | |
| 25 | + async stopJob(): Promise<void> { | |
| 26 | + if (this.ctx.container?.running) await this.stop("SIGTERM"); | |
| 27 | + } | |
| 28 | + | |
| 29 | + override async onStop(params: StopParams): Promise<void> { | |
| 30 | + const job = await this.ctx.storage.get<{ repoId: string; jobId: string }>("job"); | |
| 31 | + if (!job) return; | |
| 32 | + const repo = this.env.REPO.get(this.env.REPO.idFromName(job.repoId)); | |
| 33 | + await repo.containerStopped(job.jobId, params.exitCode ?? null); | |
| 34 | + await this.ctx.storage.delete("job"); | |
| 35 | + } | |
| 36 | + | |
| 37 | + override onError(error: unknown): void { | |
| 38 | + console.error("runner container error", error); | |
| 39 | + } | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** | |
| 43 | + * Long-lived task sandbox for AI agents: the repo checked out with toolchains, | |
| 44 | + * and an HTTP API (exec, read/write files) on port 8080. | |
| 45 | + */ | |
| 46 | +export class SandboxContainer extends Container<Env> { | |
| 47 | + override defaultPort = 8080; | |
| 48 | + override sleepAfter = "30m"; | |
| 49 | + override enableInternet = true; | |
| 50 | + | |
| 51 | + async boot(envVars: Record<string, string>, secret: string): Promise<void> { | |
| 52 | + await this.ctx.storage.put("secret", secret); | |
| 53 | + await this.startAndWaitForPorts({ ports: 8080, startOptions: { envVars, entrypoint: ["node", "/opt/diffinery/agent.mjs", "--sandbox"] } }); | |
| 54 | + } | |
| 55 | + | |
| 56 | + /** Forward an API call to the sandbox's HTTP server with its secret attached. */ | |
| 57 | + async call(path: string, body: unknown): Promise<{ status: number; body: unknown }> { | |
| 58 | + const secret = await this.ctx.storage.get<string>("secret"); | |
| 59 | + const res = await this.containerFetch(`http://sandbox${path}`, { | |
| 60 | + method: "POST", | |
| 61 | + headers: { "content-type": "application/json", authorization: `Bearer ${secret}` }, | |
| 62 | + body: JSON.stringify(body ?? {}), | |
| 63 | + }); | |
| 64 | + const text = await res.text(); | |
| 65 | + let parsed: unknown = text; | |
| 66 | + try { | |
| 67 | + parsed = JSON.parse(text); | |
| 68 | + } catch { | |
| 69 | + /* plain text */ | |
| 70 | + } | |
| 71 | + return { status: res.status, body: parsed }; | |
| 72 | + } | |
| 73 | + | |
| 74 | + async shutdown(): Promise<void> { | |
| 75 | + if (this.ctx.container?.running) await this.stop(); | |
| 76 | + } | |
| 77 | +} |
addedapps/diffinery/src/runner/workflow.ts+48−0View file
| @@ -1,0 +1,48 @@ | ||
| 1 | +import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | + | |
| 4 | +export interface RunParams { | |
| 5 | + repoId: string; | |
| 6 | + runId: string; | |
| 7 | +} | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Durable orchestration of one workflow run: resolves the job graph level by | |
| 11 | + * level, starts each job's containers, and waits for the runner to report | |
| 12 | + * completion. Retries, sleeps and waits survive restarts of the platform. | |
| 13 | + */ | |
| 14 | +export class RunWorkflow extends WorkflowEntrypoint<Env, RunParams> { | |
| 15 | + override async run(event: WorkflowEvent<RunParams>, step: WorkflowStep): Promise<unknown> { | |
| 16 | + const { repoId, runId } = event.payload; | |
| 17 | + const repo = () => this.env.REPO.get(this.env.REPO.idFromName(repoId)); | |
| 18 | + | |
| 19 | + let plan = await step.do("plan", () => repo().runPlan(runId)); | |
| 20 | + // Concurrency groups without cancel-in-progress: wait for the running run to finish. | |
| 21 | + for (let i = 0; plan.concurrencyBlocked && i < 360; i++) { | |
| 22 | + await step.sleep(`concurrency-wait-${i}`, "1 minute"); | |
| 23 | + plan = await step.do(`plan-${i}`, () => repo().runPlan(runId)); | |
| 24 | + } | |
| 25 | + if (plan.error) { | |
| 26 | + await step.do("record-error", () => repo().plannedRunError(runId, plan.error!)); | |
| 27 | + return step.do("finish", () => repo().finishRun(runId)); | |
| 28 | + } | |
| 29 | + | |
| 30 | + for (const [levelIndex, level] of plan.levels.entries()) { | |
| 31 | + const started = await Promise.all(level.map((key) => step.do(`start ${key}`, { retries: { limit: 3, delay: "10 seconds", backoff: "exponential" } }, () => repo().startJobKey(runId, key)))); | |
| 32 | + const jobIds = started.flatMap((s) => s.jobIds); | |
| 33 | + await Promise.all( | |
| 34 | + jobIds.map(async (jobId) => { | |
| 35 | + const job = await step.do(`job ${jobId} info`, () => repo().job(jobId)); | |
| 36 | + if (job.status === "completed") return; | |
| 37 | + try { | |
| 38 | + await step.waitForEvent(`wait ${jobId}`, { type: `job-${jobId}`, timeout: "6 hours" }); | |
| 39 | + } catch { | |
| 40 | + await step.do(`timeout ${jobId}`, () => repo().jobTimedOut(jobId)); | |
| 41 | + } | |
| 42 | + }), | |
| 43 | + ); | |
| 44 | + void levelIndex; | |
| 45 | + } | |
| 46 | + return step.do("finish", () => repo().finishRun(runId)); | |
| 47 | + } | |
| 48 | +} |
modifiedcontainers/runner/package.json+1−1View file
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | 6 | "scripts": { |
| 7 | - "build": "esbuild src/main.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/agent.mjs", | |
| 7 | + "build": "esbuild src/main.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/agent.mjs \"--banner:js=import { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);\"", | |
| 8 | 8 | "typecheck": "tsc --noEmit", |
| 9 | 9 | "test": "vitest run" |
| 10 | 10 | }, |
addedcontainers/runner/src/agent.ts+65−0View file
| @@ -1,0 +1,65 @@ | ||
| 1 | +import type { JobCompletion, JobSpec } from "@diffinery/shared"; | |
| 2 | +import { LogBatcher, RunnerApi } from "./api.js"; | |
| 3 | +import { JobRunner, errMsg } from "./job.js"; | |
| 4 | + | |
| 5 | +export interface AgentOptions { | |
| 6 | + /** `${apiUrl}/runner/jobs/${jobId}`, or null for offline mode (logs to stdout, no reports). */ | |
| 7 | + apiBase: string | null; | |
| 8 | + workRoot: string; | |
| 9 | + toolCache?: string; | |
| 10 | + echoLogs?: boolean; | |
| 11 | + /** Install a SIGTERM/SIGINT handler that cancels the job. */ | |
| 12 | + handleSignals?: boolean; | |
| 13 | + /** Exposes the runner (e.g. so tests can cancel). */ | |
| 14 | + onRunner?: (r: JobRunner) => void; | |
| 15 | + logIntervalMs?: number; | |
| 16 | + baseEnv?: NodeJS.ProcessEnv; | |
| 17 | +} | |
| 18 | + | |
| 19 | +export function apiBaseFor(apiUrl: string | undefined | null, jobId: string): string | null { | |
| 20 | + if (!apiUrl) return null; | |
| 21 | + return `${apiUrl.replace(/\/$/, "")}/runner/jobs/${encodeURIComponent(jobId)}`; | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** Runs one job end-to-end: execute steps, ship logs, POST completion. */ | |
| 25 | +export async function runAgentJob(spec: JobSpec, o: AgentOptions): Promise<JobCompletion> { | |
| 26 | + const api = new RunnerApi(o.apiBase, spec.token); | |
| 27 | + const batcher = api.online | |
| 28 | + ? new LogBatcher((lines) => api.postLogs(lines), { | |
| 29 | + intervalMs: o.logIntervalMs ?? 500, | |
| 30 | + onError: (e) => process.stderr.write(`[diffinery-runner] log upload failed: ${errMsg(e)}\n`), | |
| 31 | + }) | |
| 32 | + : null; | |
| 33 | + const runner = new JobRunner(spec, api, null, { workRoot: o.workRoot, toolCache: o.toolCache, echoLogs: o.echoLogs ?? !api.online, baseEnv: o.baseEnv }, batcher); | |
| 34 | + o.onRunner?.(runner); | |
| 35 | + const onSignal = (sig: string) => { | |
| 36 | + runner.logger.line(-1, `##[error]Received ${sig}; cancelling the job.`); | |
| 37 | + runner.cancel("cancelled"); | |
| 38 | + }; | |
| 39 | + const sigterm = () => onSignal("SIGTERM"); | |
| 40 | + const sigint = () => onSignal("SIGINT"); | |
| 41 | + if (o.handleSignals) { | |
| 42 | + process.on("SIGTERM", sigterm); | |
| 43 | + process.on("SIGINT", sigint); | |
| 44 | + } | |
| 45 | + let completion: JobCompletion; | |
| 46 | + try { | |
| 47 | + completion = await runner.run(); | |
| 48 | + } catch (e) { | |
| 49 | + completion = { conclusion: "failure", outputs: {}, failure: { message: `Runner error: ${errMsg(e)}` } }; | |
| 50 | + } | |
| 51 | + if (batcher) { | |
| 52 | + const ok = await batcher.close(120_000); | |
| 53 | + if (!ok) process.stderr.write(`[diffinery-runner] gave up shipping ${batcher.pending} log lines\n`); | |
| 54 | + } | |
| 55 | + try { | |
| 56 | + await api.complete(completion); | |
| 57 | + } catch (e) { | |
| 58 | + process.stderr.write(`[diffinery-runner] failed to report completion: ${errMsg(e)}\n`); | |
| 59 | + } | |
| 60 | + if (o.handleSignals) { | |
| 61 | + process.off("SIGTERM", sigterm); | |
| 62 | + process.off("SIGINT", sigint); | |
| 63 | + } | |
| 64 | + return completion; | |
| 65 | +} |
modifiedcontainers/runner/src/api.ts+2−2View file
| @@ -19,7 +19,7 @@ | ||
| 19 | 19 | method?: string; |
| 20 | 20 | /** JSON body, or a factory producing a raw body (re-invoked on retry). */ |
| 21 | 21 | json?: unknown; |
| 22 | - body?: () => Promise<{ body: BodyInit; length?: number; contentType?: string }>; | |
| 22 | + body?: () => Promise<{ body: NonNullable<RequestInit["body"]>; length?: number; contentType?: string }>; | |
| 23 | 23 | retries?: number; |
| 24 | 24 | /** Return the response for these statuses instead of throwing. */ |
| 25 | 25 | allow?: number[]; |
| @@ -134,7 +134,7 @@ | ||
| 134 | 134 | method: "PUT", |
| 135 | 135 | body: async () => { |
| 136 | 136 | const s = await stat(file); |
| 137 | - return { body: Readable.toWeb(createReadStream(file)) as unknown as BodyInit, length: s.size, contentType }; | |
| 137 | + return { body: Readable.toWeb(createReadStream(file)) as unknown as NonNullable<RequestInit["body"]>, length: s.size, contentType }; | |
| 138 | 138 | }, |
| 139 | 139 | allow: [409], |
| 140 | 140 | }); |
addedcontainers/runner/src/builtins/artifacts.ts+96−0View file
| @@ -1,0 +1,96 @@ | ||
| 1 | +import { randomUUID } from "node:crypto"; | |
| 2 | +import { mkdir, rm, stat, writeFile } from "node:fs/promises"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { errMsg, failure, StepFailure } from "../job.js"; | |
| 5 | +import { globPaths, inputList, truthyInput } from "../util.js"; | |
| 6 | +import { downloadTo } from "./cache.js"; | |
| 7 | +import type { Builtin } from "./index.js"; | |
| 8 | + | |
| 9 | +function lca(dirs: string[]): string { | |
| 10 | + if (dirs.length === 0) return "/"; | |
| 11 | + let parts = dirs[0]!.split("/"); | |
| 12 | + for (const d of dirs.slice(1)) { | |
| 13 | + const p = d.split("/"); | |
| 14 | + let i = 0; | |
| 15 | + while (i < parts.length && i < p.length && parts[i] === p[i]) i++; | |
| 16 | + parts = parts.slice(0, i); | |
| 17 | + } | |
| 18 | + return parts.join("/") || "/"; | |
| 19 | +} | |
| 20 | + | |
| 21 | +export const artifactUpload: Builtin = async (c) => { | |
| 22 | + const name = c.input("name", "artifact"); | |
| 23 | + if (/[":<>|*?\\/\r\n]/.test(name)) throw failure(`The artifact name is not valid: ${name}. Contains invalid characters.`); | |
| 24 | + const patterns = inputList(c.input("path")); | |
| 25 | + if (!patterns.length) throw failure("Input required and not supplied: path"); | |
| 26 | + const ifNone = c.input("if-no-files-found", "warn").toLowerCase(); | |
| 27 | + const includeHidden = truthyInput(c.input("include-hidden-files"), false); | |
| 28 | + let files = globPaths(c.job.workspace, patterns, { filesOnly: true }); | |
| 29 | + if (!includeHidden) files = files.filter((f) => !path.relative(c.job.workspace, f).split("/").some((s) => s.startsWith(".") && s !== "..")); | |
| 30 | + if (files.length === 0) { | |
| 31 | + const msg = `No files were found with the provided path: ${patterns.join(", ")}. No artifacts will be uploaded.`; | |
| 32 | + if (ifNone === "error") throw failure(msg); | |
| 33 | + if (ifNone === "warn") c.warn(msg); | |
| 34 | + else c.log(msg); | |
| 35 | + return; | |
| 36 | + } | |
| 37 | + if (!c.job.api.online) { | |
| 38 | + c.warn("Artifact service is not available in offline mode; skipping upload."); | |
| 39 | + return; | |
| 40 | + } | |
| 41 | + const root = lca(files.map((f) => path.dirname(f))); | |
| 42 | + const rel = files.map((f) => path.relative(root, f)); | |
| 43 | + c.log(`With the provided path, there will be ${files.length} file(s) uploaded (root: ${root})`); | |
| 44 | + const list = path.join(c.job.tempDir, `artifact-${randomUUID()}.list`); | |
| 45 | + const archive = path.join(c.job.tempDir, `artifact-${randomUUID()}.tgz`); | |
| 46 | + try { | |
| 47 | + await writeFile(list, rel.join("\0") + "\0"); | |
| 48 | + await c.run("tar", ["-czf", archive, "-C", root, "--null", "-T", list], { silent: true }); | |
| 49 | + const size = (await stat(archive)).size; | |
| 50 | + const q = new URLSearchParams(); | |
| 51 | + const retention = c.input("retention-days"); | |
| 52 | + if (retention) q.set("retention-days", retention); | |
| 53 | + if (truthyInput(c.input("overwrite"), false)) q.set("overwrite", "true"); | |
| 54 | + const qs = q.toString(); | |
| 55 | + const res = await c.job.api.putFile(`/artifacts/${encodeURIComponent(name)}${qs ? `?${qs}` : ""}`, archive); | |
| 56 | + if (res.status === 409) throw failure(`An artifact with the name '${name}' already exists. Set 'overwrite: true' to replace it.`); | |
| 57 | + let body: { id?: string | number; url?: string } = {}; | |
| 58 | + try { | |
| 59 | + body = (await res.json()) as typeof body; | |
| 60 | + } catch { | |
| 61 | + /* no body */ | |
| 62 | + } | |
| 63 | + c.log(`Artifact '${name}' uploaded (${size} bytes compressed).`); | |
| 64 | + if (body.id !== undefined) c.setOutput("artifact-id", String(body.id)); | |
| 65 | + if (body.url) c.setOutput("artifact-url", body.url); | |
| 66 | + } catch (e) { | |
| 67 | + if (e instanceof StepFailure) throw e; | |
| 68 | + throw failure(`Failed to upload artifact '${name}': ${errMsg(e)}`); | |
| 69 | + } finally { | |
| 70 | + await rm(list, { force: true }); | |
| 71 | + await rm(archive, { force: true }); | |
| 72 | + } | |
| 73 | +}; | |
| 74 | + | |
| 75 | +export const artifactDownload: Builtin = async (c) => { | |
| 76 | + const name = c.input("name"); | |
| 77 | + if (!name) { | |
| 78 | + throw failure("download-artifact without `name` (download all / `pattern`) is not supported on Diffinery runners yet; specify `name`."); | |
| 79 | + } | |
| 80 | + if (!c.job.api.online) throw failure("Artifact service is not available in offline mode."); | |
| 81 | + const dest = path.resolve(c.job.workspace, c.input("path", ".")); | |
| 82 | + await mkdir(dest, { recursive: true }); | |
| 83 | + const archive = path.join(c.job.tempDir, `artifact-${randomUUID()}.tgz`); | |
| 84 | + try { | |
| 85 | + try { | |
| 86 | + await downloadTo(c, c.job.api.url(`/artifacts/${encodeURIComponent(name)}`), archive); | |
| 87 | + } catch (e) { | |
| 88 | + throw failure(`Unable to download artifact '${name}': ${errMsg(e)}`); | |
| 89 | + } | |
| 90 | + await c.run("tar", ["-xzf", archive, "-C", dest], { silent: true }); | |
| 91 | + c.log(`Artifact '${name}' downloaded to ${dest}`); | |
| 92 | + c.setOutput("download-path", dest); | |
| 93 | + } finally { | |
| 94 | + await rm(archive, { force: true }); | |
| 95 | + } | |
| 96 | +}; |
addedcontainers/runner/src/builtins/cache.ts+130−0View file
| @@ -1,0 +1,130 @@ | ||
| 1 | +import { randomUUID } from "node:crypto"; | |
| 2 | +import { createWriteStream } from "node:fs"; | |
| 3 | +import { rm, stat } from "node:fs/promises"; | |
| 4 | +import path from "node:path"; | |
| 5 | +import { Readable } from "node:stream"; | |
| 6 | +import { pipeline } from "node:stream/promises"; | |
| 7 | +import type { ReadableStream as WebReadableStream } from "node:stream/web"; | |
| 8 | +import { errMsg, failure } from "../job.js"; | |
| 9 | +import { globPaths, inputList, truthyInput } from "../util.js"; | |
| 10 | +import type { Builtin, BuiltinContext } from "./index.js"; | |
| 11 | + | |
| 12 | +export async function downloadTo(c: BuiltinContext, url: string, file: string): Promise<void> { | |
| 13 | + const res = await c.job.api.download(url); | |
| 14 | + if (!res.body) throw new Error("empty response body"); | |
| 15 | + await pipeline(Readable.fromWeb(res.body as unknown as WebReadableStream), createWriteStream(file)); | |
| 16 | +} | |
| 17 | + | |
| 18 | +export interface RestoreResult { | |
| 19 | + matchedKey?: string; | |
| 20 | + exact: boolean; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** Restores a cache entry; never throws for transport errors (warns instead). */ | |
| 24 | +export async function restoreCache(c: BuiltinContext, paths: string[], key: string, restoreKeys: string[], lookupOnly = false): Promise<RestoreResult> { | |
| 25 | + if (!key) throw failure("Input required and not supplied: key"); | |
| 26 | + if (paths.length === 0) throw failure("Input required and not supplied: path"); | |
| 27 | + if (!c.job.api.online) { | |
| 28 | + c.warn("Cache service is not available in offline mode; skipping restore."); | |
| 29 | + return { exact: false }; | |
| 30 | + } | |
| 31 | + let hit: { key: string; url: string } | null; | |
| 32 | + try { | |
| 33 | + hit = await c.job.api.cacheLookup(key, restoreKeys); | |
| 34 | + } catch (e) { | |
| 35 | + c.warn(`Failed to look up cache: ${errMsg(e)}`); | |
| 36 | + return { exact: false }; | |
| 37 | + } | |
| 38 | + if (!hit) { | |
| 39 | + c.log(`Cache not found for input keys: ${[key, ...restoreKeys].join(", ")}`); | |
| 40 | + return { exact: false }; | |
| 41 | + } | |
| 42 | + const exact = hit.key === key; | |
| 43 | + if (lookupOnly) { | |
| 44 | + c.log(`Cache found and can be restored from key: ${hit.key}`); | |
| 45 | + return { matchedKey: hit.key, exact }; | |
| 46 | + } | |
| 47 | + const file = path.join(c.job.tempDir, `cache-${randomUUID()}.tgz`); | |
| 48 | + try { | |
| 49 | + await downloadTo(c, hit.url, file); | |
| 50 | + const size = (await stat(file)).size; | |
| 51 | + c.log(`Cache Size: ~${Math.round(size / 1024 / 1024)} MB (${size} B)`); | |
| 52 | + await c.run("tar", ["-xzPf", file], { silent: true }); | |
| 53 | + c.log(`Cache restored from key: ${hit.key}`); | |
| 54 | + return { matchedKey: hit.key, exact }; | |
| 55 | + } catch (e) { | |
| 56 | + c.warn(`Failed to restore cache: ${errMsg(e)}`); | |
| 57 | + return { exact: false }; | |
| 58 | + } finally { | |
| 59 | + await rm(file, { force: true }); | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +/** Archives `paths` (tar.gz, absolute paths) and uploads it under `key`. */ | |
| 64 | +export async function saveCache(c: BuiltinContext, paths: string[], key: string): Promise<boolean> { | |
| 65 | + if (!c.job.api.online) { | |
| 66 | + c.warn("Cache service is not available in offline mode; skipping save."); | |
| 67 | + return false; | |
| 68 | + } | |
| 69 | + const resolved = globPaths(c.job.workspace, paths); | |
| 70 | + if (resolved.length === 0) { | |
| 71 | + c.warn("Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved."); | |
| 72 | + return false; | |
| 73 | + } | |
| 74 | + const file = path.join(c.job.tempDir, `cache-${randomUUID()}.tgz`); | |
| 75 | + try { | |
| 76 | + await c.run("tar", ["-czPf", file, "--", ...resolved], { silent: true }); | |
| 77 | + const size = (await stat(file)).size; | |
| 78 | + c.log(`Cache Size: ~${Math.round(size / 1024 / 1024)} MB (${size} B)`); | |
| 79 | + const res = await c.job.api.putFile(`/cache/${encodeURIComponent(key)}`, file); | |
| 80 | + if (res.status === 409) { | |
| 81 | + c.log(`Cache entry with key ${key} already exists; not saving.`); | |
| 82 | + return false; | |
| 83 | + } | |
| 84 | + c.log(`Cache saved with key: ${key}`); | |
| 85 | + return true; | |
| 86 | + } catch (e) { | |
| 87 | + c.warn(`Failed to save cache: ${errMsg(e)}`); | |
| 88 | + return false; | |
| 89 | + } finally { | |
| 90 | + await rm(file, { force: true }); | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +export const cacheAction: Builtin = async (c) => { | |
| 95 | + const key = c.input("key"); | |
| 96 | + const paths = inputList(c.input("path")); | |
| 97 | + const restoreKeys = inputList(c.input("restore-keys")); | |
| 98 | + const r = await restoreCache(c, paths, key, restoreKeys, truthyInput(c.input("lookup-only"), false)); | |
| 99 | + c.setOutput("cache-hit", r.exact ? "true" : "false"); | |
| 100 | + c.setOutput("cache-primary-key", key); | |
| 101 | + c.setOutput("cache-matched-key", r.matchedKey ?? ""); | |
| 102 | + if (!r.matchedKey && truthyInput(c.input("fail-on-cache-miss"), false)) { | |
| 103 | + throw failure(`Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: ${key}`); | |
| 104 | + } | |
| 105 | + if (r.exact) return; | |
| 106 | + const saveAlways = truthyInput(c.input("save-always"), false); | |
| 107 | + c.registerPost(`Post ${c.name}`, saveAlways ? "always()" : "success()", async (pc) => { | |
| 108 | + await saveCache(pc, paths, key); | |
| 109 | + }); | |
| 110 | +}; | |
| 111 | + | |
| 112 | +export const cacheRestoreAction: Builtin = async (c) => { | |
| 113 | + const key = c.input("key"); | |
| 114 | + const paths = inputList(c.input("path")); | |
| 115 | + const r = await restoreCache(c, paths, key, inputList(c.input("restore-keys")), truthyInput(c.input("lookup-only"), false)); | |
| 116 | + c.setOutput("cache-hit", r.exact ? "true" : "false"); | |
| 117 | + c.setOutput("cache-primary-key", key); | |
| 118 | + c.setOutput("cache-matched-key", r.matchedKey ?? ""); | |
| 119 | + if (!r.matchedKey && truthyInput(c.input("fail-on-cache-miss"), false)) { | |
| 120 | + throw failure(`Failed to restore cache entry. Exiting as fail-on-cache-miss is set. Input key: ${key}`); | |
| 121 | + } | |
| 122 | +}; | |
| 123 | + | |
| 124 | +export const cacheSaveAction: Builtin = async (c) => { | |
| 125 | + const key = c.input("key"); | |
| 126 | + if (!key) throw failure("Input required and not supplied: key"); | |
| 127 | + const paths = inputList(c.input("path")); | |
| 128 | + if (!paths.length) throw failure("Input required and not supplied: path"); | |
| 129 | + await saveCache(c, paths, key); | |
| 130 | +}; |
addedcontainers/runner/src/builtins/checkout.ts+207−0View file
| @@ -1,0 +1,207 @@ | ||
| 1 | +import { existsSync } from "node:fs"; | |
| 2 | +import { mkdir, readdir, rm } from "node:fs/promises"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { failure } from "../job.js"; | |
| 5 | +import { truthyInput } from "../util.js"; | |
| 6 | +import type { Builtin, BuiltinContext } from "./index.js"; | |
| 7 | + | |
| 8 | +export function basicAuthHeader(token: string): string { | |
| 9 | + return `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`; | |
| 10 | +} | |
| 11 | + | |
| 12 | +/** Derives the clone URL of `owner/repo` from the job's own clone URL. */ | |
| 13 | +export function cloneUrlFor(spec: { repo: { owner: string; name: string; cloneUrl: string } }, repository: string): string { | |
| 14 | + const own = `${spec.repo.owner}/${spec.repo.name}`; | |
| 15 | + if (repository.toLowerCase() === own.toLowerCase()) return spec.repo.cloneUrl; | |
| 16 | + const u = spec.repo.cloneUrl; | |
| 17 | + const idx = u.toLowerCase().lastIndexOf(own.toLowerCase()); | |
| 18 | + if (idx >= 0) return u.slice(0, idx) + repository + u.slice(idx + own.length); | |
| 19 | + try { | |
| 20 | + const parsed = new URL(u); | |
| 21 | + return `${parsed.origin}/${repository}.git`; | |
| 22 | + } catch { | |
| 23 | + return u; | |
| 24 | + } | |
| 25 | +} | |
| 26 | + | |
| 27 | +function isHttp(url: string): boolean { | |
| 28 | + return /^https?:\/\//i.test(url); | |
| 29 | +} | |
| 30 | + | |
| 31 | +export const checkout: Builtin = async (c) => { | |
| 32 | + const spec = c.job.spec; | |
| 33 | + const ownRepo = `${spec.repo.owner}/${spec.repo.name}`; | |
| 34 | + const repository = c.input("repository", ownRepo); | |
| 35 | + const isOwn = repository.toLowerCase() === ownRepo.toLowerCase(); | |
| 36 | + const token = c.input("token", spec.token); | |
| 37 | + const refInput = c.input("ref"); | |
| 38 | + const depthRaw = Number(c.input("fetch-depth", "1")); | |
| 39 | + const fetchDepth = Number.isFinite(depthRaw) && depthRaw >= 0 ? Math.floor(depthRaw) : 1; | |
| 40 | + const dest = path.resolve(c.job.workspace, c.input("path", ".")); | |
| 41 | + const clean = truthyInput(c.input("clean"), true); | |
| 42 | + const submodules = c.input("submodules", "false").toLowerCase(); | |
| 43 | + const persist = truthyInput(c.input("persist-credentials"), true); | |
| 44 | + const fetchTags = truthyInput(c.input("fetch-tags"), false); | |
| 45 | + if (truthyInput(c.input("lfs"), false)) c.warn("Git LFS is not supported by the Diffinery checkout built-in; LFS pointers will not be resolved."); | |
| 46 | + if (c.input("sparse-checkout")) c.warn("sparse-checkout is not supported by the Diffinery checkout built-in; doing a full checkout."); | |
| 47 | + | |
| 48 | + const url = cloneUrlFor(spec, repository); | |
| 49 | + const header = basicAuthHeader(token); | |
| 50 | + c.job.masker.add(token); | |
| 51 | + c.job.masker.add(header.slice("AUTHORIZATION: basic ".length)); | |
| 52 | + const authArgs = isHttp(url) && token ? ["-c", `http.extraheader=${header}`] : []; | |
| 53 | + const env = c.toolEnv({ GIT_TERMINAL_PROMPT: "0", GIT_LFS_SKIP_SMUDGE: "1" }); | |
| 54 | + const git = (args: string[], o: { silent?: boolean; allowFail?: boolean } = {}) => | |
| 55 | + o.allowFail ? c.exec("git", args, { cwd: dest, env, capture: true, silent: o.silent }) : c.run("git", args, { cwd: dest, env, silent: o.silent }); | |
| 56 | + | |
| 57 | + c.log(`Syncing repository: ${repository}`); | |
| 58 | + await mkdir(dest, { recursive: true }); | |
| 59 | + const hasGit = existsSync(path.join(dest, ".git")); | |
| 60 | + if (hasGit && clean) { | |
| 61 | + await git(["clean", "-ffdx"], { allowFail: true }); | |
| 62 | + await git(["reset", "--hard", "HEAD"], { allowFail: true, silent: true }); | |
| 63 | + } else if (!hasGit) { | |
| 64 | + const entries = await readdir(dest); | |
| 65 | + if (entries.length && clean) { | |
| 66 | + c.log(`Deleting the contents of '${dest}'`); | |
| 67 | + await Promise.all(entries.map((e) => rm(path.join(dest, e), { recursive: true, force: true }))); | |
| 68 | + } | |
| 69 | + } | |
| 70 | + if (!hasGit) await git(["init", "-q", dest]); | |
| 71 | + const setUrl = await git(["remote", "set-url", "origin", url], { allowFail: true, silent: true }); | |
| 72 | + if (setUrl.exitCode !== 0) await git(["remote", "add", "origin", url]); | |
| 73 | + await git(["config", "--local", "gc.auto", "0"]); | |
| 74 | + | |
| 75 | + // Decide what to fetch | |
| 76 | + let sha: string | undefined; | |
| 77 | + let ref: string | undefined; | |
| 78 | + if (refInput) { | |
| 79 | + if (/^[0-9a-f]{40}$/i.test(refInput)) sha = refInput; | |
| 80 | + else ref = refInput; | |
| 81 | + } else if (isOwn) { | |
| 82 | + sha = spec.sha; | |
| 83 | + ref = spec.ref; | |
| 84 | + } else { | |
| 85 | + ref = "HEAD"; | |
| 86 | + } | |
| 87 | + | |
| 88 | + const depthArgs = fetchDepth > 0 ? [`--depth=${fetchDepth}`] : []; | |
| 89 | + const tagArgs = fetchDepth === 0 || fetchTags ? [] : ["--no-tags"]; | |
| 90 | + const fetch = (refspecs: string[], allowFail = false) => | |
| 91 | + git([...authArgs, "fetch", ...tagArgs, "--prune", "--no-recurse-submodules", ...depthArgs, "origin", ...refspecs], { allowFail }); | |
| 92 | + | |
| 93 | + let checkoutTarget: string | undefined; | |
| 94 | + let branch: string | undefined; | |
| 95 | + if (ref?.startsWith("refs/heads/")) branch = ref.slice("refs/heads/".length); | |
| 96 | + | |
| 97 | + if (fetchDepth === 0) { | |
| 98 | + c.log("Fetching the repository (full history)"); | |
| 99 | + await fetch(["+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*"]); | |
| 100 | + if (ref && !ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/") && ref !== "HEAD") { | |
| 101 | + if (ref.startsWith("refs/")) await fetch([`+${ref}:${ref.replace(/^refs\//, "refs/remotes/")}`], true); | |
| 102 | + } | |
| 103 | + checkoutTarget = sha ?? (await resolveRef(git, ref!, (b) => (branch = b))); | |
| 104 | + } else { | |
| 105 | + c.log(`Fetching the repository (depth ${fetchDepth})`); | |
| 106 | + let fetched = false; | |
| 107 | + if (sha) { | |
| 108 | + const r = await fetch([`+${sha}:refs/remotes/origin/${branch ?? "diffinery-checkout"}`], true); | |
| 109 | + fetched = r.exitCode === 0; | |
| 110 | + if (!fetched) c.log("Fetching by commit SHA failed; falling back to fetching the ref."); | |
| 111 | + } | |
| 112 | + if (!fetched && ref) { | |
| 113 | + const specs = refspecsFor(ref); | |
| 114 | + let ok = false; | |
| 115 | + for (const rs of specs) { | |
| 116 | + const r = await fetch([rs.refspec], true); | |
| 117 | + if (r.exitCode === 0) { | |
| 118 | + ok = true; | |
| 119 | + if (rs.branch) branch = rs.branch; | |
| 120 | + break; | |
| 121 | + } | |
| 122 | + } | |
| 123 | + if (!ok) throw failure(`Failed to fetch ref '${ref}' from ${repository}. Check that the ref exists and the token has read access.`); | |
| 124 | + if (!sha) { | |
| 125 | + const target = specs.find((s) => s.branch === branch)?.local ?? "FETCH_HEAD"; | |
| 126 | + checkoutTarget = target; | |
| 127 | + } | |
| 128 | + } else if (!fetched) { | |
| 129 | + throw failure(`Failed to fetch commit ${sha} from ${repository}.`); | |
| 130 | + } | |
| 131 | + checkoutTarget ??= sha ?? "FETCH_HEAD"; | |
| 132 | + } | |
| 133 | + | |
| 134 | + if (branch) await git(["checkout", "--progress", "--force", "-B", branch, checkoutTarget!]); | |
| 135 | + else await git(["checkout", "--progress", "--force", checkoutTarget!]); | |
| 136 | + const head = (await git(["rev-parse", "HEAD"], { silent: true })).stdout.trim(); | |
| 137 | + if (sha && /^[0-9a-f]{40}$/i.test(sha) && head.toLowerCase() !== sha.toLowerCase()) { | |
| 138 | + throw failure(`Checked out ${head} but expected ${sha}.`); | |
| 139 | + } | |
| 140 | + c.log(`HEAD is now at ${head}`); | |
| 141 | + c.setOutput("commit", head); | |
| 142 | + c.setOutput("ref", ref && ref !== "HEAD" ? ref : branch ? `refs/heads/${branch}` : ""); | |
| 143 | + | |
| 144 | + if (submodules === "true" || submodules === "recursive") { | |
| 145 | + c.log("Fetching submodules"); | |
| 146 | + await git(["submodule", "sync", ...(submodules === "recursive" ? ["--recursive"] : [])], { allowFail: true }); | |
| 147 | + const r = await git( | |
| 148 | + [...authArgs, "submodule", "update", "--init", "--force", ...(fetchDepth > 0 ? [`--depth=${fetchDepth}`] : []), ...(submodules === "recursive" ? ["--recursive"] : [])], | |
| 149 | + { allowFail: true }, | |
| 150 | + ); | |
| 151 | + if (r.exitCode !== 0) c.warn("Failed to update submodules (best effort)."); | |
| 152 | + } | |
| 153 | + | |
| 154 | + if (persist && authArgs.length) { | |
| 155 | + const key = `http.${new URL(url).origin}/.extraheader`; | |
| 156 | + await git(["config", "--local", key, header], { silent: true }); | |
| 157 | + c.registerPost(`Post ${c.name}`, "always()", async (pc: BuiltinContext) => { | |
| 158 | + if (!existsSync(path.join(dest, ".git"))) return; | |
| 159 | + pc.log("Removing persisted credentials"); | |
| 160 | + await pc.exec("git", ["config", "--local", "--unset-all", key], { cwd: dest, capture: true, silent: true }); | |
| 161 | + }); | |
| 162 | + } | |
| 163 | +}; | |
| 164 | + | |
| 165 | +function refspecsFor(ref: string): Array<{ refspec: string; local: string; branch?: string }> { | |
| 166 | + if (ref === "HEAD") return [{ refspec: "HEAD", local: "FETCH_HEAD" }]; | |
| 167 | + if (ref.startsWith("refs/heads/")) { | |
| 168 | + const b = ref.slice(11); | |
| 169 | + return [{ refspec: `+${ref}:refs/remotes/origin/${b}`, local: `refs/remotes/origin/${b}`, branch: b }]; | |
| 170 | + } | |
| 171 | + if (ref.startsWith("refs/tags/")) return [{ refspec: `+${ref}:${ref}`, local: ref }]; | |
| 172 | + if (ref.startsWith("refs/")) { | |
| 173 | + const local = ref.replace(/^refs\//, "refs/remotes/"); | |
| 174 | + return [{ refspec: `+${ref}:${local}`, local }]; | |
| 175 | + } | |
| 176 | + return [ | |
| 177 | + { refspec: `+refs/heads/${ref}:refs/remotes/origin/${ref}`, local: `refs/remotes/origin/${ref}`, branch: ref }, | |
| 178 | + { refspec: `+refs/tags/${ref}:refs/tags/${ref}`, local: `refs/tags/${ref}` }, | |
| 179 | + ]; | |
| 180 | +} | |
| 181 | + | |
| 182 | +async function resolveRef( | |
| 183 | + git: (args: string[], o?: { silent?: boolean; allowFail?: boolean }) => Promise<{ exitCode: number; stdout: string }>, | |
| 184 | + ref: string, | |
| 185 | + setBranch: (b: string) => void, | |
| 186 | +): Promise<string> { | |
| 187 | + if (ref === "HEAD") { | |
| 188 | + const r = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFail: true, silent: true }); | |
| 189 | + if (r.exitCode === 0) return r.stdout.trim(); | |
| 190 | + } | |
| 191 | + const short = ref.replace(/^refs\/heads\//, ""); | |
| 192 | + const candidates = ref.startsWith("refs/tags/") | |
| 193 | + ? [ref] | |
| 194 | + : ref.startsWith("refs/heads/") | |
| 195 | + ? [`refs/remotes/origin/${short}`] | |
| 196 | + : ref.startsWith("refs/") | |
| 197 | + ? [ref.replace(/^refs\//, "refs/remotes/")] | |
| 198 | + : [`refs/remotes/origin/${ref}`, `refs/tags/${ref}`]; | |
| 199 | + for (const cand of candidates) { | |
| 200 | + const r = await git(["rev-parse", "--verify", "--quiet", cand], { allowFail: true, silent: true }); | |
| 201 | + if (r.exitCode === 0) { | |
| 202 | + if (cand.startsWith("refs/remotes/origin/")) setBranch(cand.slice("refs/remotes/origin/".length)); | |
| 203 | + return cand; | |
| 204 | + } | |
| 205 | + } | |
| 206 | + throw failure(`A branch or tag with the name '${ref}' could not be found`); | |
| 207 | +} |
addedcontainers/runner/src/builtins/index.ts+114−0View file
| @@ -1,0 +1,114 @@ | ||
| 1 | +import { failure, type JobRunner, type Scope, type StepIO } from "../job.js"; | |
| 2 | +import type { StepExec } from "../steps/run.js"; | |
| 3 | +import type { ExecResult } from "../util.js"; | |
| 4 | +import { artifactDownload, artifactUpload } from "./artifacts.js"; | |
| 5 | +import { cacheAction, cacheRestoreAction, cacheSaveAction } from "./cache.js"; | |
| 6 | +import { checkout } from "./checkout.js"; | |
| 7 | +import { setupGo } from "./setup-go.js"; | |
| 8 | +import { setupNode } from "./setup-node.js"; | |
| 9 | +import { setupPython } from "./setup-python.js"; | |
| 10 | + | |
| 11 | +export interface BuiltinContext { | |
| 12 | + job: JobRunner; | |
| 13 | + io: StepIO; | |
| 14 | + scope: Scope; | |
| 15 | + ctx: Record<string, unknown>; | |
| 16 | + envCtx: Record<string, string>; | |
| 17 | + uses: string; | |
| 18 | + name: string; | |
| 19 | + /** Raw `with:` values (already interpolated); lookups are case-insensitive. */ | |
| 20 | + inputs: Record<string, string>; | |
| 21 | + input(name: string, dflt?: string): string; | |
| 22 | + log(msg: string): void; | |
| 23 | + warn(msg: string): void; | |
| 24 | + setOutput(name: string, value: string): void; | |
| 25 | + exportVariable(name: string, value: string): void; | |
| 26 | + addPath(dir: string): void; | |
| 27 | + /** Env for child tools (job env + GITHUB_ENV + PATH additions). */ | |
| 28 | + toolEnv(extra?: Record<string, string>): NodeJS.ProcessEnv; | |
| 29 | + exec(cmd: string, args: string[], o?: { cwd?: string; env?: NodeJS.ProcessEnv; capture?: boolean; silent?: boolean; input?: string }): Promise<ExecResult>; | |
| 30 | + /** Like exec but throws on non-zero exit. */ | |
| 31 | + run(cmd: string, args: string[], o?: { cwd?: string; env?: NodeJS.ProcessEnv; capture?: boolean; silent?: boolean; input?: string }): Promise<ExecResult>; | |
| 32 | + registerPost(name: string, condition: string, fn: (c: BuiltinContext) => Promise<void>): void; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export type Builtin = (c: BuiltinContext) => Promise<void>; | |
| 36 | + | |
| 37 | +const githubScript: Builtin = async () => { | |
| 38 | + throw failure("actions/github-script is not supported on Diffinery runners yet. Use a `run:` step with `node` or `curl` against $GITHUB_API_URL instead."); | |
| 39 | +}; | |
| 40 | + | |
| 41 | +const BUILTINS: Record<string, Builtin> = { | |
| 42 | + "actions/checkout": checkout, | |
| 43 | + "actions/cache": cacheAction, | |
| 44 | + "actions/cache/restore": cacheRestoreAction, | |
| 45 | + "actions/cache/save": cacheSaveAction, | |
| 46 | + "actions/upload-artifact": artifactUpload, | |
| 47 | + "actions/download-artifact": artifactDownload, | |
| 48 | + "actions/setup-node": setupNode, | |
| 49 | + "actions/setup-python": setupPython, | |
| 50 | + "actions/setup-go": setupGo, | |
| 51 | + "actions/github-script": githubScript, | |
| 52 | +}; | |
| 53 | + | |
| 54 | +export function getBuiltin(name: string, _ref: string): Builtin | null { | |
| 55 | + if (process.env.DIFFINERY_DISABLE_BUILTINS === "1") return null; | |
| 56 | + return BUILTINS[name.toLowerCase()] ?? null; | |
| 57 | +} | |
| 58 | + | |
| 59 | +export function makeBuiltinContext(job: JobRunner, x: StepExec, inputs: Record<string, string>, uses: string): BuiltinContext { | |
| 60 | + const lower = new Map(Object.entries(inputs).map(([k, v]) => [k.toLowerCase(), v])); | |
| 61 | + const make = (io: StepIO, envCtx: Record<string, string>, name: string): BuiltinContext => { | |
| 62 | + const toolEnv = (extra: Record<string, string> = {}) => job.processEnv({ ...envCtx, ...job.envAccum }, extra); | |
| 63 | + const c: BuiltinContext = { | |
| 64 | + job, | |
| 65 | + io, | |
| 66 | + scope: x.scope, | |
| 67 | + ctx: x.ctx, | |
| 68 | + envCtx, | |
| 69 | + uses, | |
| 70 | + name, | |
| 71 | + inputs, | |
| 72 | + input: (n, d = "") => { | |
| 73 | + const v = lower.get(n.toLowerCase()); | |
| 74 | + return v === undefined || v === "" ? d : v; | |
| 75 | + }, | |
| 76 | + log: (m) => job.logger.line(io.logIndex, m), | |
| 77 | + warn: (m) => { | |
| 78 | + job.logger.line(io.logIndex, `##[warning]${m}`); | |
| 79 | + job.addAnnotation(io.logIndex, { level: "warning", message: m }); | |
| 80 | + }, | |
| 81 | + setOutput: (k, v) => { | |
| 82 | + io.outputs[k] = v; | |
| 83 | + }, | |
| 84 | + exportVariable: (k, v) => { | |
| 85 | + job.envAccum[k] = v; | |
| 86 | + envCtx[k] = v; | |
| 87 | + }, | |
| 88 | + addPath: (d) => job.addPath(d), | |
| 89 | + toolEnv, | |
| 90 | + 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 }), | |
| 91 | + run: async (cmd, args, o = {}) => { | |
| 92 | + const r = await c.exec(cmd, args, { ...o, capture: o.capture ?? true }); | |
| 93 | + if (job.isStopping) throw failure("cancelled"); | |
| 94 | + if (r.exitCode !== 0) { | |
| 95 | + const detail = o.silent ? ` ${r.stderr.trim().split("\n").slice(-5).join("\n")}` : ""; | |
| 96 | + throw failure(`The process '${cmd}' failed with exit code ${r.exitCode}${detail ? ":" + detail : ""}`); | |
| 97 | + } | |
| 98 | + return r; | |
| 99 | + }, | |
| 100 | + registerPost: (pname, condition, fn) => { | |
| 101 | + job.registerPost({ | |
| 102 | + name: pname, | |
| 103 | + condition, | |
| 104 | + run: async (logIndex) => { | |
| 105 | + const pio: StepIO = { logIndex, outputs: {}, state: {} }; | |
| 106 | + await fn(make(pio, { ...envCtx, ...job.envAccum }, pname)); | |
| 107 | + }, | |
| 108 | + }); | |
| 109 | + }, | |
| 110 | + }; | |
| 111 | + return c; | |
| 112 | + }; | |
| 113 | + return make(x.io, x.envCtx, x.name); | |
| 114 | +} |
addedcontainers/runner/src/builtins/setup-go.ts+73−0View file
| @@ -1,0 +1,73 @@ | ||
| 1 | +import { existsSync } from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { errMsg, failure } from "../job.js"; | |
| 4 | +import { maxSatisfying, satisfies, truthyInput } from "../util.js"; | |
| 5 | +import type { Builtin } from "./index.js"; | |
| 6 | +import { fetchJson, installTarball, isCached, packageCache, probe, readVersionFile, toolArch, toolDir } from "./tools.js"; | |
| 7 | + | |
| 8 | +interface GoRelease { | |
| 9 | + version: string; // "go1.22.3" | |
| 10 | + stable: boolean; | |
| 11 | + files: Array<{ filename: string; os: string; arch: string; kind: string }>; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export const setupGo: Builtin = async (c) => { | |
| 15 | + let spec = c.input("go-version"); | |
| 16 | + const file = c.input("go-version-file"); | |
| 17 | + if (!spec && file) spec = await readVersionFile(c, file, "go"); | |
| 18 | + spec = spec.trim().replace(/^go/, ""); | |
| 19 | + const installedRaw = await probe(c, "go", ["version"]); | |
| 20 | + const installed = /go(\d+\.\d+(?:\.\d+)?)/.exec(installedRaw ?? "")?.[1] ?? null; | |
| 21 | + let version = installed; | |
| 22 | + | |
| 23 | + if (!spec && !installed) throw failure("No go-version specified and Go is not installed on this runner."); | |
| 24 | + const useInstalled = installed && (!spec || (!/^(stable|oldstable)$/.test(spec) && satisfies(installed, spec))) && !truthyInput(c.input("check-latest"), false); | |
| 25 | + if (useInstalled) { | |
| 26 | + c.log(`Using pre-installed Go ${installed}`); | |
| 27 | + } else { | |
| 28 | + let releases: GoRelease[]; | |
| 29 | + try { | |
| 30 | + releases = await fetchJson<GoRelease[]>(process.env.DIFFINERY_GO_INDEX ?? "https://go.dev/dl/?mode=json&include=all"); | |
| 31 | + } catch (e) { | |
| 32 | + throw failure(`Unable to fetch the Go versions index: ${errMsg(e)}`); | |
| 33 | + } | |
| 34 | + const arch = toolArch("go"); | |
| 35 | + const stable = releases.filter((r) => r.stable && r.files.some((f) => f.os === "linux" && f.arch === arch && f.kind === "archive")); | |
| 36 | + let v: string | null; | |
| 37 | + if (spec === "stable") v = maxSatisfying(stable.map((r) => r.version.replace(/^go/, "")), "*"); | |
| 38 | + else if (spec === "oldstable") { | |
| 39 | + const sorted = stable.map((r) => r.version.replace(/^go/, "")); | |
| 40 | + const latest = maxSatisfying(sorted, "*"); | |
| 41 | + const latestMinor = latest ? latest.split(".").slice(0, 2).join(".") : ""; | |
| 42 | + v = maxSatisfying(sorted.filter((x) => !x.startsWith(latestMinor + ".") && x !== latestMinor), "*"); | |
| 43 | + } else v = maxSatisfying(stable.map((r) => r.version.replace(/^go/, "")), spec); | |
| 44 | + if (!v) throw failure(`Unable to find Go version '${spec}' for platform linux and architecture ${arch}.`); | |
| 45 | + const rel = stable.find((r) => r.version === `go${v}`)!; | |
| 46 | + const f = rel.files.find((x) => x.os === "linux" && x.arch === arch && x.kind === "archive")!; | |
| 47 | + const dir = toolDir(c, "go", v, arch); | |
| 48 | + if (!isCached(dir)) await installTarball(c, `https://go.dev/dl/${f.filename}`, dir); | |
| 49 | + else c.log(`Found in cache @ ${dir}`); | |
| 50 | + c.addPath(path.join(dir, "bin")); | |
| 51 | + version = v; | |
| 52 | + } | |
| 53 | + const gopath = await probe(c, "go", ["env", "GOPATH"], c.toolEnv()); | |
| 54 | + if (gopath) c.addPath(path.join(gopath, "bin")); | |
| 55 | + if (version) c.setOutput("go-version", version); | |
| 56 | + | |
| 57 | + if (truthyInput(c.input("cache"), true)) { | |
| 58 | + const deps = c.input("cache-dependency-path"); | |
| 59 | + if (!deps && !existsSync(path.join(c.job.workspace, "go.sum"))) { | |
| 60 | + c.log("go.sum not found; skipping dependency cache."); | |
| 61 | + return; | |
| 62 | + } | |
| 63 | + const env = c.toolEnv(); | |
| 64 | + const modcache = await probe(c, "go", ["env", "GOMODCACHE"], env); | |
| 65 | + const buildcache = await probe(c, "go", ["env", "GOCACHE"], env); | |
| 66 | + await packageCache(c, { | |
| 67 | + prefix: `setup-go-Linux-${toolArch("go")}-go-${version}`, | |
| 68 | + paths: [modcache ?? "", buildcache ?? ""], | |
| 69 | + lockPatterns: ["**/go.sum"], | |
| 70 | + what: "go", | |
| 71 | + }); | |
| 72 | + } | |
| 73 | +}; |
addedcontainers/runner/src/builtins/setup-node.ts+125−0View file
| @@ -1,0 +1,125 @@ | ||
| 1 | +import path from "node:path"; | |
| 2 | +import { errMsg, failure } from "../job.js"; | |
| 3 | +import { maxSatisfying, parseVer, satisfies, truthyInput } from "../util.js"; | |
| 4 | +import type { Builtin } from "./index.js"; | |
| 5 | +import { fetchJson, installTarball, isCached, packageCache, probe, readVersionFile, toolArch, toolDir } from "./tools.js"; | |
| 6 | + | |
| 7 | +interface NodeDistEntry { | |
| 8 | + version: string; // "v22.11.0" | |
| 9 | + lts: string | false; | |
| 10 | + files: string[]; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export function nodeMirror(): string { | |
| 14 | + return (process.env.DIFFINERY_NODE_MIRROR ?? "https://nodejs.org/dist").replace(/\/$/, ""); | |
| 15 | +} | |
| 16 | + | |
| 17 | +/** Resolves a setup-node version spec against nodejs.org's index. */ | |
| 18 | +export function resolveNodeVersion(index: NodeDistEntry[], spec: string): string | null { | |
| 19 | + const s = spec.trim().toLowerCase(); | |
| 20 | + const arch = `linux-${toolArch("node")}`; | |
| 21 | + const avail = index.filter((e) => e.files.includes(arch) || e.files.length === 0); | |
| 22 | + if (s.startsWith("lts/")) { | |
| 23 | + const code = s.slice(4); | |
| 24 | + const lts = avail.filter((e) => e.lts && (code === "*" || String(e.lts).toLowerCase() === code)); | |
| 25 | + if (code.startsWith("-")) { | |
| 26 | + // lts/-1: previous LTS line | |
| 27 | + const n = Number(code.slice(1)); | |
| 28 | + const majors = [...new Set(avail.filter((e) => e.lts).map((e) => parseVer(e.version)![0]))].sort((a, b) => b - a); | |
| 29 | + const major = majors[n]; | |
| 30 | + return major === undefined ? null : maxSatisfying(avail.filter((e) => e.lts).map((e) => e.version), String(major))?.replace(/^v/, "") ?? null; | |
| 31 | + } | |
| 32 | + return maxSatisfying(lts.map((e) => e.version), "*")?.replace(/^v/, "") ?? null; | |
| 33 | + } | |
| 34 | + if (s === "latest" || s === "current" || s === "node") return maxSatisfying(avail.map((e) => e.version), "*")?.replace(/^v/, "") ?? null; | |
| 35 | + return maxSatisfying(avail.map((e) => e.version), s.replace(/^v/, ""))?.replace(/^v/, "") ?? null; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export const setupNode: Builtin = async (c) => { | |
| 39 | + let spec = c.input("node-version"); | |
| 40 | + const file = c.input("node-version-file"); | |
| 41 | + if (!spec && file) spec = await readVersionFile(c, file, "node"); | |
| 42 | + const installed = (await probe(c, "node", ["--version"]))?.replace(/^v/, "") ?? null; | |
| 43 | + let version = installed; | |
| 44 | + | |
| 45 | + if (spec) { | |
| 46 | + const s = spec.trim().replace(/^v(?=\d)/, ""); | |
| 47 | + let ok = false; | |
| 48 | + if (installed && !truthyInput(c.input("check-latest"), false)) { | |
| 49 | + if (/^(lts\/|latest|current|node$)/i.test(s)) { | |
| 50 | + try { | |
| 51 | + const target = resolveNodeVersion(await fetchJson<NodeDistEntry[]>(`${nodeMirror()}/index.json`), s); | |
| 52 | + ok = !!target && parseVer(target)![0] === parseVer(installed)![0]; | |
| 53 | + } catch (e) { | |
| 54 | + c.warn(`Could not resolve '${s}' (${errMsg(e)}); using installed node ${installed}.`); | |
| 55 | + ok = true; | |
| 56 | + } | |
| 57 | + } else { | |
| 58 | + const want = parseVer(s); | |
| 59 | + ok = satisfies(installed, s) || (!!want && /^\d/.test(s) && want[0] === parseVer(installed)![0]); | |
| 60 | + } | |
| 61 | + } | |
| 62 | + if (ok) { | |
| 63 | + c.log(`Found in cache @ ${installed} (pre-installed node satisfies '${s}')`); | |
| 64 | + } else { | |
| 65 | + let index: NodeDistEntry[]; | |
| 66 | + try { | |
| 67 | + index = await fetchJson<NodeDistEntry[]>(`${nodeMirror()}/index.json`); | |
| 68 | + } catch (e) { | |
| 69 | + throw failure(`Unable to fetch the Node.js version index: ${errMsg(e)}`); | |
| 70 | + } | |
| 71 | + const v = resolveNodeVersion(index, s); | |
| 72 | + if (!v) throw failure(`Unable to find Node version '${s}' for platform linux and architecture ${toolArch("node")}.`); | |
| 73 | + const dir = toolDir(c, "node", v, toolArch("node")); | |
| 74 | + if (!isCached(dir)) { | |
| 75 | + await installTarball(c, `${nodeMirror()}/v${v}/node-v${v}-linux-${toolArch("node")}.tar.gz`, dir); | |
| 76 | + } else c.log(`Found in cache @ ${dir}`); | |
| 77 | + c.addPath(path.join(dir, "bin")); | |
| 78 | + version = v; | |
| 79 | + } | |
| 80 | + } else { | |
| 81 | + c.log(`No node-version specified; using the pre-installed node ${installed ?? "(none)"}.`); | |
| 82 | + } | |
| 83 | + if (version) c.setOutput("node-version", `v${version}`); | |
| 84 | + | |
| 85 | + const registry = c.input("registry-url"); | |
| 86 | + if (registry) { | |
| 87 | + // Minimal setup-node `registry-url` support: write an .npmrc that reads NODE_AUTH_TOKEN. | |
| 88 | + const { writeFile, mkdir } = await import("node:fs/promises"); | |
| 89 | + const npmrc = path.join(c.job.tempDir, ".npmrc"); | |
| 90 | + await mkdir(c.job.tempDir, { recursive: true }); | |
| 91 | + const url = registry.endsWith("/") ? registry : `${registry}/`; | |
| 92 | + const scope = c.input("scope"); | |
| 93 | + const authLine = `${url.replace(/^\w+:/, "")}:_authToken=\${NODE_AUTH_TOKEN}`; | |
| 94 | + const regLine = scope ? `${scope.startsWith("@") ? scope : "@" + scope}:registry=${url}` : `registry=${url}`; | |
| 95 | + await writeFile(npmrc, `${authLine}\n${regLine}\n`); | |
| 96 | + c.exportVariable("NPM_CONFIG_USERCONFIG", npmrc); | |
| 97 | + c.exportVariable("NODE_AUTH_TOKEN", "XXXXX-XXXXX-XXXXX-XXXXX"); | |
| 98 | + } | |
| 99 | + | |
| 100 | + const pm = c.input("cache").toLowerCase(); | |
| 101 | + if (pm) { | |
| 102 | + const env = c.toolEnv(); | |
| 103 | + let dir: string | null = null; | |
| 104 | + let locks: string[]; | |
| 105 | + if (pm === "npm") { | |
| 106 | + dir = await probe(c, "npm", ["config", "get", "cache"], env); | |
| 107 | + locks = ["**/package-lock.json", "**/npm-shrinkwrap.json"]; | |
| 108 | + } else if (pm === "pnpm") { | |
| 109 | + dir = await probe(c, "pnpm", ["store", "path", "--silent"], env); | |
| 110 | + locks = ["**/pnpm-lock.yaml"]; | |
| 111 | + } else if (pm === "yarn") { | |
| 112 | + const ver = await probe(c, "yarn", ["--version"], env); | |
| 113 | + dir = ver && parseVer(ver)![0] >= 2 ? await probe(c, "yarn", ["config", "get", "cacheFolder"], env) : await probe(c, "yarn", ["cache", "dir"], env); | |
| 114 | + locks = ["**/yarn.lock"]; | |
| 115 | + } else { | |
| 116 | + throw failure(`Caching for '${pm}' is not supported`); | |
| 117 | + } | |
| 118 | + await packageCache(c, { | |
| 119 | + prefix: `node-cache-Linux-${toolArch("node")}-${pm}`, | |
| 120 | + paths: dir ? [dir] : [], | |
| 121 | + lockPatterns: locks, | |
| 122 | + what: pm, | |
| 123 | + }); | |
| 124 | + } | |
| 125 | +}; |
addedcontainers/runner/src/builtins/setup-python.ts+101−0View file
| @@ -1,0 +1,101 @@ | ||
| 1 | +import { existsSync, realpathSync } from "node:fs"; | |
| 2 | +import { mkdir, rm, symlink } from "node:fs/promises"; | |
| 3 | +import { randomUUID } from "node:crypto"; | |
| 4 | +import path from "node:path"; | |
| 5 | +import { errMsg, failure } from "../job.js"; | |
| 6 | +import { maxSatisfying, satisfies, which } from "../util.js"; | |
| 7 | +import type { Builtin } from "./index.js"; | |
| 8 | +import { fetchJson, fetchToFile, packageCache, probe, readVersionFile, toolArch } from "./tools.js"; | |
| 9 | + | |
| 10 | +interface PyManifestEntry { | |
| 11 | + version: string; | |
| 12 | + stable: boolean; | |
| 13 | + files: Array<{ filename: string; arch: string; platform: string; platform_version?: string; download_url: string }>; | |
| 14 | +} | |
| 15 | + | |
| 16 | +const MANIFEST = "https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json"; | |
| 17 | + | |
| 18 | +export const setupPython: Builtin = async (c) => { | |
| 19 | + let spec = c.input("python-version"); | |
| 20 | + const file = c.input("python-version-file"); | |
| 21 | + if (!spec && file) spec = await readVersionFile(c, file, "python"); | |
| 22 | + if (!spec && existsSync(path.join(c.job.workspace, ".python-version"))) spec = await readVersionFile(c, ".python-version", "python"); | |
| 23 | + spec = spec.trim(); | |
| 24 | + if (/^pypy|graalpy/i.test(spec)) throw failure(`'${spec}' is not supported by the Diffinery setup-python built-in.`); | |
| 25 | + const env = c.toolEnv(); | |
| 26 | + const installedRaw = await probe(c, "python3", ["--version"], env); | |
| 27 | + const installed = installedRaw?.replace(/^Python\s+/, "") ?? null; | |
| 28 | + | |
| 29 | + let binDir: string; | |
| 30 | + let version: string; | |
| 31 | + const want = spec.replace(/-dev$/, "").replace(/\.x$/, ""); | |
| 32 | + if (installed && (!spec || satisfies(installed, want))) { | |
| 33 | + c.log(`Using pre-installed Python ${installed}`); | |
| 34 | + const py3 = which("python3", env.PATH)!; | |
| 35 | + binDir = path.dirname(realpathSync(py3)); | |
| 36 | + version = installed; | |
| 37 | + // make sure `python`/`pip` resolve to python3 | |
| 38 | + if (!which("python", env.PATH) || !which("pip", env.PATH)) { | |
| 39 | + const shim = path.join(c.job.tempDir, "_python_shim"); | |
| 40 | + await mkdir(shim, { recursive: true }); | |
| 41 | + for (const [n, t] of [["python", "python3"], ["pip", "pip3"]] as const) { | |
| 42 | + const target = which(t, env.PATH); | |
| 43 | + const link = path.join(shim, n); | |
| 44 | + if (target && !existsSync(link)) await symlink(target, link).catch(() => {}); | |
| 45 | + } | |
| 46 | + c.addPath(shim); | |
| 47 | + } | |
| 48 | + } else { | |
| 49 | + let manifest: PyManifestEntry[]; | |
| 50 | + try { | |
| 51 | + manifest = await fetchJson<PyManifestEntry[]>(process.env.DIFFINERY_PYTHON_MANIFEST ?? MANIFEST); | |
| 52 | + } catch (e) { | |
| 53 | + throw failure(`Unable to fetch the Python versions manifest: ${errMsg(e)}`); | |
| 54 | + } | |
| 55 | + const arch = toolArch("python"); | |
| 56 | + const candidates = manifest.filter((m) => m.stable && m.files.some((f) => f.platform === "linux" && f.arch === arch)); | |
| 57 | + const v = maxSatisfying(candidates.map((m) => m.version), want); | |
| 58 | + if (!v) throw failure(`The version '${spec}' with architecture '${arch}' was not found for this operating system.`); | |
| 59 | + const entry = candidates.find((m) => m.version === v)!; | |
| 60 | + const files = entry.files.filter((f) => f.platform === "linux" && f.arch === arch); | |
| 61 | + const f = files.find((x) => x.platform_version === "24.04") ?? files.find((x) => x.platform_version === "22.04") ?? files[0]!; | |
| 62 | + const installDir = path.join(c.job.toolCache, "Python", v, arch); | |
| 63 | + if (!existsSync(`${installDir}.complete`)) { | |
| 64 | + const work = path.join(c.job.tempDir, `python-${randomUUID()}`); | |
| 65 | + await mkdir(work, { recursive: true }); | |
| 66 | + try { | |
| 67 | + c.log(`Downloading ${f.download_url}`); | |
| 68 | + const tarball = path.join(work, "python.tar.gz"); | |
| 69 | + await fetchToFile(f.download_url, tarball); | |
| 70 | + await c.run("tar", ["-xzf", tarball, "-C", work], { silent: true }); | |
| 71 | + c.log("Running python installer (setup.sh)"); | |
| 72 | + await c.run("bash", ["./setup.sh"], { cwd: work, env: c.toolEnv({ RUNNER_TOOL_CACHE: c.job.toolCache, AGENT_TOOLSDIRECTORY: c.job.toolCache }) }); | |
| 73 | + } finally { | |
| 74 | + await rm(work, { recursive: true, force: true }); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + binDir = path.join(installDir, "bin"); | |
| 78 | + version = v; | |
| 79 | + c.addPath(installDir); | |
| 80 | + c.addPath(binDir); | |
| 81 | + c.exportVariable("pythonLocation", installDir); | |
| 82 | + c.exportVariable("Python_ROOT_DIR", installDir); | |
| 83 | + c.exportVariable("Python3_ROOT_DIR", installDir); | |
| 84 | + c.exportVariable("PKG_CONFIG_PATH", path.join(installDir, "lib", "pkgconfig")); | |
| 85 | + c.exportVariable("LD_LIBRARY_PATH", [path.join(installDir, "lib"), process.env.LD_LIBRARY_PATH].filter(Boolean).join(":")); | |
| 86 | + } | |
| 87 | + c.setOutput("python-version", version); | |
| 88 | + c.setOutput("python-path", path.join(binDir, existsSync(path.join(binDir, "python")) ? "python" : "python3")); | |
| 89 | + | |
| 90 | + const cache = c.input("cache").toLowerCase(); | |
| 91 | + if (cache) { | |
| 92 | + if (cache !== "pip") throw failure(`Caching for '${cache}' is not supported by the Diffinery setup-python built-in (only pip).`); | |
| 93 | + const dir = await probe(c, "python3", ["-m", "pip", "cache", "dir"], c.toolEnv()); | |
| 94 | + await packageCache(c, { | |
| 95 | + prefix: `setup-python-Linux-${toolArch("python")}-python-${version}-pip`, | |
| 96 | + paths: dir ? [dir] : [], | |
| 97 | + lockPatterns: ["**/requirements*.txt", "**/pyproject.toml"], | |
| 98 | + what: "pip", | |
| 99 | + }); | |
| 100 | + } | |
| 101 | +}; |
addedcontainers/runner/src/builtins/tools.ts+148−0View file
| @@ -1,0 +1,148 @@ | ||
| 1 | +import { randomUUID } from "node:crypto"; | |
| 2 | +import { createWriteStream, existsSync } from "node:fs"; | |
| 3 | +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; | |
| 4 | +import path from "node:path"; | |
| 5 | +import { Readable } from "node:stream"; | |
| 6 | +import { pipeline } from "node:stream/promises"; | |
| 7 | +import type { ReadableStream as WebReadableStream } from "node:stream/web"; | |
| 8 | +import { failure } from "../job.js"; | |
| 9 | +import { hashFiles, inputList } from "../util.js"; | |
| 10 | +import { restoreCache, saveCache } from "./cache.js"; | |
| 11 | +import type { BuiltinContext } from "./index.js"; | |
| 12 | + | |
| 13 | +/** Downloads a public URL to a file with a few retries. */ | |
| 14 | +export async function fetchToFile(url: string, file: string): Promise<void> { | |
| 15 | + let last: unknown; | |
| 16 | + for (let i = 0; i < 4; i++) { | |
| 17 | + if (i) await new Promise((r) => setTimeout(r, 1000 * 2 ** i)); | |
| 18 | + try { | |
| 19 | + const res = await fetch(url, { headers: { "user-agent": "diffinery-runner" }, redirect: "follow" }); | |
| 20 | + if (res.status === 404) throw failure(`Not found: ${url}`); | |
| 21 | + if (!res.ok || !res.body) throw new Error(`HTTP ${res.status} for ${url}`); | |
| 22 | + await pipeline(Readable.fromWeb(res.body as unknown as WebReadableStream), createWriteStream(file)); | |
| 23 | + return; | |
| 24 | + } catch (e) { | |
| 25 | + if (e instanceof Error && e.message.startsWith("Not found:")) throw e; | |
| 26 | + last = e; | |
| 27 | + } | |
| 28 | + } | |
| 29 | + throw last instanceof Error ? last : new Error(String(last)); | |
| 30 | +} | |
| 31 | + | |
| 32 | +export async function fetchJson<T>(url: string): Promise<T> { | |
| 33 | + let last: unknown; | |
| 34 | + for (let i = 0; i < 3; i++) { | |
| 35 | + if (i) await new Promise((r) => setTimeout(r, 1000 * 2 ** i)); | |
| 36 | + try { | |
| 37 | + const res = await fetch(url, { headers: { "user-agent": "diffinery-runner", accept: "application/json" } }); | |
| 38 | + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); | |
| 39 | + return (await res.json()) as T; | |
| 40 | + } catch (e) { | |
| 41 | + last = e; | |
| 42 | + } | |
| 43 | + } | |
| 44 | + throw last instanceof Error ? last : new Error(String(last)); | |
| 45 | +} | |
| 46 | + | |
| 47 | +export function toolArch(kind: "node" | "go" | "python"): string { | |
| 48 | + const a = process.arch; | |
| 49 | + if (kind === "go") return a === "arm64" ? "arm64" : a === "arm" ? "armv6l" : "amd64"; | |
| 50 | + return a === "arm64" ? "arm64" : a === "arm" ? "armv7l" : "x64"; | |
| 51 | +} | |
| 52 | + | |
| 53 | +export function toolDir(c: BuiltinContext, tool: string, version: string, arch: string): string { | |
| 54 | + return path.join(c.job.toolCache, tool, version, arch); | |
| 55 | +} | |
| 56 | + | |
| 57 | +export function isCached(dir: string): boolean { | |
| 58 | + return existsSync(`${dir}.complete`) && existsSync(dir); | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** Downloads a tarball and extracts it into `dir` (stripping the top-level folder). */ | |
| 62 | +export async function installTarball(c: BuiltinContext, url: string, dir: string, strip = 1): Promise<void> { | |
| 63 | + c.log(`Downloading ${url}`); | |
| 64 | + const file = path.join(c.job.tempDir, `tool-${randomUUID()}${url.endsWith(".xz") ? ".tar.xz" : ".tar.gz"}`); | |
| 65 | + try { | |
| 66 | + await fetchToFile(url, file); | |
| 67 | + await rm(dir, { recursive: true, force: true }); | |
| 68 | + await mkdir(dir, { recursive: true }); | |
| 69 | + c.log(`Extracting to ${dir}`); | |
| 70 | + await c.run("tar", [url.endsWith(".xz") ? "-xJf" : "-xzf", file, "-C", dir, `--strip-components=${strip}`], { silent: true }); | |
| 71 | + await writeFile(`${dir}.complete`, ""); | |
| 72 | + } finally { | |
| 73 | + await rm(file, { force: true }); | |
| 74 | + } | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** Runs `cmd args` and returns trimmed stdout, or null if it fails. */ | |
| 78 | +export async function probe(c: BuiltinContext, cmd: string, args: string[], env?: NodeJS.ProcessEnv): Promise<string | null> { | |
| 79 | + const r = await c.exec(cmd, args, { capture: true, silent: true, env }); | |
| 80 | + if (r.exitCode !== 0) return null; | |
| 81 | + return (r.stdout || r.stderr).trim(); | |
| 82 | +} | |
| 83 | + | |
| 84 | +export async function readVersionFile(c: BuiltinContext, file: string, kind: "node" | "python" | "go"): Promise<string> { | |
| 85 | + const p = path.resolve(c.job.workspace, file); | |
| 86 | + if (!existsSync(p)) throw failure(`The specified ${kind} version file at: ${p} does not exist`); | |
| 87 | + const content = await readFile(p, "utf8"); | |
| 88 | + const base = path.basename(p); | |
| 89 | + if (base === "package.json") { | |
| 90 | + try { | |
| 91 | + const j = JSON.parse(content) as { volta?: { node?: string }; engines?: { node?: string }; devEngines?: { runtime?: { name?: string; version?: string } } }; | |
| 92 | + const v = j.volta?.node ?? j.devEngines?.runtime?.version ?? j.engines?.node; | |
| 93 | + if (v) return v; | |
| 94 | + } catch { | |
| 95 | + /* fallthrough */ | |
| 96 | + } | |
| 97 | + throw failure(`No node version found in ${p}`); | |
| 98 | + } | |
| 99 | + if (base === "go.mod" || base === "go.work") { | |
| 100 | + const tc = /^toolchain\s+go(\S+)/m.exec(content); | |
| 101 | + const g = /^go\s+(\d+(?:\.\d+){0,2})/m.exec(content); | |
| 102 | + const v = tc?.[1] ?? g?.[1]; | |
| 103 | + if (!v) throw failure(`No go version found in ${p}`); | |
| 104 | + return v; | |
| 105 | + } | |
| 106 | + if (base === ".tool-versions") { | |
| 107 | + const names = kind === "node" ? ["nodejs", "node"] : kind === "python" ? ["python"] : ["golang", "go"]; | |
| 108 | + for (const line of content.split("\n")) { | |
| 109 | + const [n, v] = line.trim().split(/\s+/); | |
| 110 | + if (n && v && names.includes(n)) return v; | |
| 111 | + } | |
| 112 | + throw failure(`No ${kind} version found in ${p}`); | |
| 113 | + } | |
| 114 | + if (base === "pyproject.toml") { | |
| 115 | + const m = /requires-python\s*=\s*["']([^"']+)["']/.exec(content); | |
| 116 | + if (m) return m[1]!.replace(/,/g, " "); | |
| 117 | + throw failure(`No python version found in ${p}`); | |
| 118 | + } | |
| 119 | + const first = content.split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#")); | |
| 120 | + if (!first) throw failure(`The ${kind} version file at ${p} is empty`); | |
| 121 | + return first.replace(/^v(?=\d)/, ""); | |
| 122 | +} | |
| 123 | + | |
| 124 | +/** | |
| 125 | + * Restores a package-manager cache keyed by lockfile hash and registers a | |
| 126 | + * post-step save, mirroring setup-node/setup-python/setup-go `cache:`. | |
| 127 | + */ | |
| 128 | +export async function packageCache(c: BuiltinContext, o: { prefix: string; paths: string[]; lockPatterns: string[]; what: string }): Promise<void> { | |
| 129 | + const patterns = inputList(c.input("cache-dependency-path")).length ? inputList(c.input("cache-dependency-path")) : o.lockPatterns; | |
| 130 | + const hash = hashFiles(c.job.workspace, patterns); | |
| 131 | + if (!hash) { | |
| 132 | + throw failure(`Dependencies lock file is not found in ${c.job.workspace}. Supported file patterns: ${patterns.join(", ")}`); | |
| 133 | + } | |
| 134 | + const key = `${o.prefix}-${hash}`; | |
| 135 | + const paths = o.paths.filter(Boolean); | |
| 136 | + if (!paths.length) { | |
| 137 | + c.warn(`Could not determine the ${o.what} cache directory; skipping cache.`); | |
| 138 | + return; | |
| 139 | + } | |
| 140 | + const r = await restoreCache(c, paths, key, []); | |
| 141 | + c.setOutput("cache-hit", r.exact ? "true" : "false"); | |
| 142 | + if (r.exact) return; | |
| 143 | + c.registerPost(`Post ${c.name}`, "success()", async (pc) => { | |
| 144 | + for (const p of paths) await mkdir(p, { recursive: true }).catch(() => {}); | |
| 145 | + await saveCache(pc, paths, key); | |
| 146 | + }); | |
| 147 | +} | |
| 148 | + |
addedcontainers/runner/src/job.ts+678−0View file
| @@ -1,0 +1,678 @@ | ||
| 1 | +import type { ChildProcess } from "node:child_process"; | |
| 2 | +import { randomUUID } from "node:crypto"; | |
| 3 | +import { existsSync } from "node:fs"; | |
| 4 | +import { mkdir, readFile, writeFile } from "node:fs/promises"; | |
| 5 | +import path from "node:path"; | |
| 6 | +import type { Conclusion, JobCompletion, JobSpec, StepReport, StepSpec } from "@diffinery/shared"; | |
| 7 | +import { evaluate, evaluateIf, interpolate, type StatusFns } from "@diffinery/workflow-yaml"; | |
| 8 | +import type { RunnerApi } from "./api.js"; | |
| 9 | +import { parseKeyValueFile, parsePathFile } from "./commands.js"; | |
| 10 | +import { JobLogger, OutputProcessor, type Annotation, type OutputSink } from "./log.js"; | |
| 11 | +import { Masker } from "./mask.js"; | |
| 12 | +import { runAction } from "./steps/actions.js"; | |
| 13 | +import { runScript } from "./steps/run.js"; | |
| 14 | +import { exec, hashFiles, killTree, nowIso, runnerArch, type ExecResult } from "./util.js"; | |
| 15 | + | |
| 16 | +export type Outcome = "success" | "failure" | "cancelled" | "skipped"; | |
| 17 | + | |
| 18 | +export interface StepContextEntry { | |
| 19 | + outputs: Record<string, string>; | |
| 20 | + outcome: Outcome; | |
| 21 | + conclusion: Outcome; | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** Execution scope: the job itself, or the inside of a composite action. */ | |
| 25 | +export interface Scope { | |
| 26 | + steps: Record<string, StepContextEntry>; | |
| 27 | + inputs: Record<string, unknown>; | |
| 28 | + /** A step in this scope failed (without continue-on-error). */ | |
| 29 | + failed: boolean; | |
| 30 | + /** Env inherited from the calling step (composite actions). */ | |
| 31 | + inheritedEnv: Record<string, string>; | |
| 32 | + composite: boolean; | |
| 33 | + action?: { path: string; repository: string; ref: string }; | |
| 34 | + depth: number; | |
| 35 | +} | |
| 36 | + | |
| 37 | +export interface StepIO extends OutputSink { | |
| 38 | + /** Short reason recorded when the step fails (e.g. exit code). */ | |
| 39 | + reason?: string; | |
| 40 | +} | |
| 41 | + | |
| 42 | +export interface StepResult { | |
| 43 | + outcome: Outcome; | |
| 44 | + conclusion: Outcome; | |
| 45 | + outputs: Record<string, string>; | |
| 46 | +} | |
| 47 | + | |
| 48 | +export interface PostStep { | |
| 49 | + name: string; | |
| 50 | + /** Expression evaluated with job status (e.g. `always()`, `success()`). */ | |
| 51 | + condition: string; | |
| 52 | + run: (logIndex: number) => Promise<void>; | |
| 53 | +} | |
| 54 | + | |
| 55 | +export interface RunnerOptions { | |
| 56 | + workRoot: string; | |
| 57 | + /** Tool cache dir; defaults to `${workRoot}/_tool` (or RUNNER_TOOL_CACHE). */ | |
| 58 | + toolCache?: string; | |
| 59 | + echoLogs?: boolean; | |
| 60 | + /** Base env for child processes (defaults to process.env). */ | |
| 61 | + baseEnv?: NodeJS.ProcessEnv; | |
| 62 | +} | |
| 63 | + | |
| 64 | +export class StepFailure extends Error {} | |
| 65 | + | |
| 66 | +const SENSITIVE_ENV = ["DIFFINERY_JOB_TOKEN", "DIFFINERY_SANDBOX_SECRET"]; | |
| 67 | + | |
| 68 | +export class JobRunner { | |
| 69 | + readonly masker = new Masker(); | |
| 70 | + readonly logger: JobLogger; | |
| 71 | + readonly workspace: string; | |
| 72 | + readonly tempDir: string; | |
| 73 | + readonly toolCache: string; | |
| 74 | + readonly actionsDir: string; | |
| 75 | + readonly fileCmdDir: string; | |
| 76 | + readonly eventPath: string; | |
| 77 | + readonly debug: boolean; | |
| 78 | + | |
| 79 | + /** Accumulated GITHUB_ENV values. */ | |
| 80 | + envAccum: Record<string, string> = {}; | |
| 81 | + /** Accumulated GITHUB_PATH entries, most recent first. */ | |
| 82 | + pathAccum: string[] = []; | |
| 83 | + jobEnv: Record<string, string> = {}; | |
| 84 | + readonly summaries: string[] = []; | |
| 85 | + readonly posts: PostStep[] = []; | |
| 86 | + readonly children = new Set<ChildProcess>(); | |
| 87 | + readonly stepErrors = new Map<number, Annotation[]>(); | |
| 88 | + readonly stepReasons = new Map<number, string>(); | |
| 89 | + | |
| 90 | + private stopping: null | "cancelled" | "timeout" = null; | |
| 91 | + private firstFailure: { step?: number; message: string } | null = null; | |
| 92 | + private githubCtx: Record<string, unknown> = {}; | |
| 93 | + private defaultEnv: Record<string, string> = {}; | |
| 94 | + private baseEnv: NodeJS.ProcessEnv; | |
| 95 | + private secrets: Record<string, string>; | |
| 96 | + | |
| 97 | + constructor( | |
| 98 | + readonly spec: JobSpec, | |
| 99 | + readonly api: RunnerApi, | |
| 100 | + logger: JobLogger | null, | |
| 101 | + readonly opts: RunnerOptions, | |
| 102 | + batcher: import("./api.js").LogBatcher | null = null, | |
| 103 | + ) { | |
| 104 | + this.logger = logger ?? new JobLogger(this.masker, batcher, !!opts.echoLogs); | |
| 105 | + this.workspace = path.join(opts.workRoot, spec.repo.name); | |
| 106 | + this.tempDir = path.join(opts.workRoot, "_temp"); | |
| 107 | + this.toolCache = opts.toolCache ?? process.env.RUNNER_TOOL_CACHE ?? path.join(opts.workRoot, "_tool"); | |
| 108 | + this.actionsDir = path.join(opts.workRoot, "_actions"); | |
| 109 | + this.fileCmdDir = path.join(this.tempDir, "_runner_file_commands"); | |
| 110 | + this.eventPath = path.join(this.tempDir, "_github_workflow", "event.json"); | |
| 111 | + this.secrets = { ...spec.secrets, GITHUB_TOKEN: spec.token }; | |
| 112 | + this.debug = this.secrets.ACTIONS_STEP_DEBUG === "true" || spec.vars?.ACTIONS_STEP_DEBUG === "true"; | |
| 113 | + const base = { ...(opts.baseEnv ?? process.env) }; | |
| 114 | + for (const k of SENSITIVE_ENV) delete base[k]; | |
| 115 | + this.baseEnv = base; | |
| 116 | + for (const v of Object.values(this.secrets)) this.masker.add(v); | |
| 117 | + } | |
| 118 | + | |
| 119 | + // ------------------------------------------------------------ public | |
| 120 | + | |
| 121 | + get isStopping(): boolean { | |
| 122 | + return this.stopping !== null; | |
| 123 | + } | |
| 124 | + | |
| 125 | + /** SIGTERM / external cancellation. */ | |
| 126 | + cancel(reason: "cancelled" | "timeout" = "cancelled"): void { | |
| 127 | + if (this.stopping) return; | |
| 128 | + this.stopping = reason; | |
| 129 | + for (const c of this.children) killTree(c); | |
| 130 | + } | |
| 131 | + | |
| 132 | + async run(): Promise<JobCompletion> { | |
| 133 | + const spec = this.spec; | |
| 134 | + let jobTimer: NodeJS.Timeout | undefined; | |
| 135 | + if (spec.timeoutMinutes > 0) { | |
| 136 | + jobTimer = setTimeout(() => { | |
| 137 | + this.logger.line(-1, `##[error]The job running on runner has exceeded the maximum execution time of ${spec.timeoutMinutes} minutes.`); | |
| 138 | + this.cancel("timeout"); | |
| 139 | + }, spec.timeoutMinutes * 60_000); | |
| 140 | + jobTimer.unref(); | |
| 141 | + } | |
| 142 | + const top: Scope = { steps: {}, inputs: spec.inputs ?? {}, failed: false, inheritedEnv: {}, composite: false, depth: 0 }; | |
| 143 | + try { | |
| 144 | + await this.setup(); | |
| 145 | + for (let i = 0; i < spec.steps.length; i++) { | |
| 146 | + const step = spec.steps[i]!; | |
| 147 | + const fallbackName = defaultStepName(step); | |
| 148 | + if (this.stopping) { | |
| 149 | + await this.report(i, { status: "completed", conclusion: "cancelled", name: safeName(step, fallbackName), completedAt: nowIso() }); | |
| 150 | + continue; | |
| 151 | + } | |
| 152 | + const startedAt = nowIso(); | |
| 153 | + let started = false; | |
| 154 | + const res = await this.executeStep(step, top, i, async (name) => { | |
| 155 | + started = true; | |
| 156 | + await this.report(i, { status: "in_progress", name, startedAt }); | |
| 157 | + }); | |
| 158 | + await this.report(i, { | |
| 159 | + status: "completed", | |
| 160 | + conclusion: res.conclusion, | |
| 161 | + name: res.name ?? fallbackName, | |
| 162 | + ...(started ? { startedAt } : {}), | |
| 163 | + completedAt: nowIso(), | |
| 164 | + }); | |
| 165 | + } | |
| 166 | + if (!this.stopping) await this.runPosts(top); | |
| 167 | + } catch (e) { | |
| 168 | + const msg = e instanceof Error ? e.message : String(e); | |
| 169 | + this.logger.line(-1, `##[error]${msg}`); | |
| 170 | + top.failed = true; | |
| 171 | + this.firstFailure ??= { message: msg }; | |
| 172 | + } finally { | |
| 173 | + if (jobTimer) clearTimeout(jobTimer); | |
| 174 | + } | |
| 175 | + | |
| 176 | + let conclusion: Conclusion = top.failed ? "failure" : "success"; | |
| 177 | + if (this.stopping === "cancelled") conclusion = "cancelled"; | |
| 178 | + if (this.stopping === "timeout") { | |
| 179 | + conclusion = "failure"; | |
| 180 | + this.firstFailure = { ...(this.firstFailure ?? {}), message: `The job exceeded the maximum execution time of ${spec.timeoutMinutes} minutes.` }; | |
| 181 | + } | |
| 182 | + const outputs = this.jobOutputs(top); | |
| 183 | + const completion: JobCompletion = { conclusion, outputs }; | |
| 184 | + const summary = this.summaries.filter((s) => s.trim() !== "").join("\n"); | |
| 185 | + if (summary) completion.summary = this.masker.mask(summary); | |
| 186 | + if (conclusion === "failure" && this.firstFailure) completion.failure = { ...this.firstFailure, message: this.masker.mask(this.firstFailure.message) }; | |
| 187 | + if (conclusion === "cancelled") completion.failure = { message: "The job was cancelled." }; | |
| 188 | + this.logger.line(-1, `Job completed with conclusion: ${conclusion}`); | |
| 189 | + return completion; | |
| 190 | + } | |
| 191 | + | |
| 192 | + // ------------------------------------------------------------ setup | |
| 193 | + | |
| 194 | + private async setup(): Promise<void> { | |
| 195 | + const spec = this.spec; | |
| 196 | + await mkdir(this.workspace, { recursive: true }); | |
| 197 | + await mkdir(this.fileCmdDir, { recursive: true }); | |
| 198 | + await mkdir(this.toolCache, { recursive: true }); | |
| 199 | + await mkdir(this.actionsDir, { recursive: true }); | |
| 200 | + await mkdir(path.dirname(this.eventPath), { recursive: true }); | |
| 201 | + const g = spec.github ?? {}; | |
| 202 | + const refName = String(g.ref_name ?? spec.ref.replace(/^refs\/(heads|tags)\//, "")); | |
| 203 | + const refType = String(g.ref_type ?? (spec.ref.startsWith("refs/tags/") ? "tag" : "branch")); | |
| 204 | + const server = spec.apiUrl.replace(/\/$/, ""); | |
| 205 | + const repoFull = `${spec.repo.owner}/${spec.repo.name}`; | |
| 206 | + this.githubCtx = { | |
| 207 | + repository: repoFull, | |
| 208 | + repository_owner: spec.repo.owner, | |
| 209 | + sha: spec.sha, | |
| 210 | + ref: spec.ref, | |
| 211 | + ref_name: refName, | |
| 212 | + ref_type: refType, | |
| 213 | + run_id: spec.runId, | |
| 214 | + run_number: g.run_number ?? "1", | |
| 215 | + run_attempt: g.run_attempt ?? "1", | |
| 216 | + job: spec.jobKey, | |
| 217 | + workflow: spec.workflowPath, | |
| 218 | + event_name: "push", | |
| 219 | + event: {}, | |
| 220 | + actor: "diffinery", | |
| 221 | + server_url: server, | |
| 222 | + api_url: `${server}/api/v3`, | |
| 223 | + graphql_url: `${server}/api/graphql`, | |
| 224 | + ...g, | |
| 225 | + workspace: this.workspace, | |
| 226 | + token: spec.token, | |
| 227 | + action: "", | |
| 228 | + action_path: "", | |
| 229 | + }; | |
| 230 | + await writeFile(this.eventPath, JSON.stringify(this.githubCtx.event ?? {}, null, 2)); | |
| 231 | + const str = (v: unknown) => (v === undefined || v === null ? "" : String(v)); | |
| 232 | + const d: Record<string, string> = { | |
| 233 | + CI: "true", | |
| 234 | + GITHUB_ACTIONS: "true", | |
| 235 | + DIFFINERY: "true", | |
| 236 | + GITHUB_REPOSITORY: repoFull, | |
| 237 | + GITHUB_REPOSITORY_OWNER: spec.repo.owner, | |
| 238 | + GITHUB_SHA: spec.sha, | |
| 239 | + GITHUB_REF: spec.ref, | |
| 240 | + GITHUB_REF_NAME: refName, | |
| 241 | + GITHUB_REF_TYPE: refType, | |
| 242 | + GITHUB_HEAD_REF: str(g.head_ref), | |
| 243 | + GITHUB_BASE_REF: str(g.base_ref), | |
| 244 | + GITHUB_RUN_ID: spec.runId, | |
| 245 | + GITHUB_RUN_NUMBER: str(this.githubCtx.run_number), | |
| 246 | + GITHUB_RUN_ATTEMPT: str(this.githubCtx.run_attempt), | |
| 247 | + GITHUB_JOB: spec.jobKey, | |
| 248 | + GITHUB_ACTOR: str(this.githubCtx.actor), | |
| 249 | + GITHUB_TRIGGERING_ACTOR: str(g.triggering_actor ?? this.githubCtx.actor), | |
| 250 | + GITHUB_EVENT_NAME: str(this.githubCtx.event_name), | |
| 251 | + GITHUB_EVENT_PATH: this.eventPath, | |
| 252 | + GITHUB_SERVER_URL: server, | |
| 253 | + GITHUB_API_URL: `${server}/api/v3`, | |
| 254 | + GITHUB_GRAPHQL_URL: `${server}/api/graphql`, | |
| 255 | + GITHUB_WORKFLOW: str(g.workflow ?? spec.workflowPath), | |
| 256 | + GITHUB_WORKFLOW_REF: str(g.workflow_ref ?? `${repoFull}/${spec.workflowPath}@${spec.ref}`), | |
| 257 | + GITHUB_WORKSPACE: this.workspace, | |
| 258 | + GITHUB_RETENTION_DAYS: "90", | |
| 259 | + RUNNER_OS: "Linux", | |
| 260 | + RUNNER_ARCH: runnerArch(), | |
| 261 | + RUNNER_NAME: "diffinery", | |
| 262 | + RUNNER_ENVIRONMENT: "diffinery-hosted", | |
| 263 | + RUNNER_TEMP: this.tempDir, | |
| 264 | + RUNNER_TOOL_CACHE: this.toolCache, | |
| 265 | + RUNNER_WORKSPACE: path.dirname(this.workspace), | |
| 266 | + AGENT_TOOLSDIRECTORY: this.toolCache, | |
| 267 | + }; | |
| 268 | + if (this.debug) d.RUNNER_DEBUG = "1"; | |
| 269 | + if (spec.idToken && this.api.online) { | |
| 270 | + // @actions/core appends `&audience=...`, so the URL must already carry a query string. | |
| 271 | + d.ACTIONS_ID_TOKEN_REQUEST_URL = `${this.api.url("/oidc")}?api-version=2.0`; | |
| 272 | + d.ACTIONS_ID_TOKEN_REQUEST_TOKEN = spec.token; | |
| 273 | + } | |
| 274 | + this.defaultEnv = d; | |
| 275 | + this.logger.line(-1, `Diffinery runner: job ${spec.jobKey} (${spec.name}) for ${repoFull}@${spec.sha.slice(0, 12)}`); | |
| 276 | + this.logger.line(-1, `Workspace: ${this.workspace}`); | |
| 277 | + // job-level env | |
| 278 | + const ctx = this.contexts({ steps: {}, inputs: spec.inputs ?? {}, failed: false, inheritedEnv: {}, composite: false, depth: 0 }, {}); | |
| 279 | + for (const [k, v] of Object.entries(spec.env ?? {})) { | |
| 280 | + this.jobEnv[k] = this.interp(String(v ?? ""), ctx); | |
| 281 | + } | |
| 282 | + } | |
| 283 | + | |
| 284 | + // ------------------------------------------------------------ contexts | |
| 285 | + | |
| 286 | + statusFns(scope: Scope): StatusFns { | |
| 287 | + return { | |
| 288 | + success: () => !scope.failed && this.stopping === null, | |
| 289 | + failure: () => scope.failed, | |
| 290 | + cancelled: () => this.stopping !== null, | |
| 291 | + always: () => true, | |
| 292 | + }; | |
| 293 | + } | |
| 294 | + | |
| 295 | + extra(): { hashFiles: (...p: string[]) => string } { | |
| 296 | + return { hashFiles: (...patterns: string[]) => hashFiles(this.workspace, patterns) }; | |
| 297 | + } | |
| 298 | + | |
| 299 | + /** Env context visible to a step before its own `env:` is applied. */ | |
| 300 | + baseEnvContext(scope: Scope): Record<string, string> { | |
| 301 | + return { ...this.jobEnv, ...this.envAccum, ...scope.inheritedEnv }; | |
| 302 | + } | |
| 303 | + | |
| 304 | + contexts(scope: Scope, env: Record<string, string>): Record<string, unknown> { | |
| 305 | + const spec = this.spec; | |
| 306 | + const github = { ...this.githubCtx }; | |
| 307 | + if (scope.action) { | |
| 308 | + github.action_path = scope.action.path; | |
| 309 | + github.action_repository = scope.action.repository; | |
| 310 | + github.action_ref = scope.action.ref; | |
| 311 | + } | |
| 312 | + return { | |
| 313 | + github, | |
| 314 | + env, | |
| 315 | + vars: spec.vars ?? {}, | |
| 316 | + secrets: this.secrets, | |
| 317 | + matrix: spec.matrix ?? {}, | |
| 318 | + needs: spec.needs ?? {}, | |
| 319 | + steps: scope.steps, | |
| 320 | + runner: { | |
| 321 | + os: "Linux", | |
| 322 | + arch: runnerArch(), | |
| 323 | + name: "diffinery", | |
| 324 | + temp: this.tempDir, | |
| 325 | + tool_cache: this.toolCache, | |
| 326 | + workspace: path.dirname(this.workspace), | |
| 327 | + debug: this.debug ? "1" : "", | |
| 328 | + environment: "diffinery-hosted", | |
| 329 | + }, | |
| 330 | + inputs: scope.inputs, | |
| 331 | + job: { status: this.stopping ? "cancelled" : scope.failed ? "failure" : "success", container: {}, services: {} }, | |
| 332 | + strategy: spec.strategy ?? {}, | |
| 333 | + }; | |
| 334 | + } | |
| 335 | + | |
| 336 | + interp(template: string, ctx: Record<string, unknown>, scope?: Scope): string { | |
| 337 | + if (!template.includes("${{")) return template; | |
| 338 | + const status = scope ? this.statusFns(scope) : undefined; | |
| 339 | + return interpolate(template, ctx, status, this.extra()); | |
| 340 | + } | |
| 341 | + | |
| 342 | + evalIf(cond: string | undefined, ctx: Record<string, unknown>, scope: Scope): boolean { | |
| 343 | + return evaluateIf(cond, ctx, this.statusFns(scope), this.extra()); | |
| 344 | + } | |
| 345 | + | |
| 346 | + evalExpr(expr: string, ctx: Record<string, unknown>, scope: Scope): unknown { | |
| 347 | + return evaluate(expr, ctx, this.statusFns(scope), this.extra()); | |
| 348 | + } | |
| 349 | + | |
| 350 | + /** Builds the environment for a child process. */ | |
| 351 | + processEnv(envCtx: Record<string, string>, extra: Record<string, string> = {}): NodeJS.ProcessEnv { | |
| 352 | + const env: NodeJS.ProcessEnv = { ...this.baseEnv, ...envCtx, ...this.defaultEnv, ...extra }; | |
| 353 | + const basePath = envCtx.PATH ?? this.baseEnv.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; | |
| 354 | + env.PATH = [...this.pathAccum, basePath].filter(Boolean).join(path.delimiter); | |
| 355 | + return env; | |
| 356 | + } | |
| 357 | + | |
| 358 | + // ------------------------------------------------------------ steps | |
| 359 | + | |
| 360 | + /** | |
| 361 | + * Evaluates `if`, env, name; runs the step; updates the scope's steps | |
| 362 | + * context and failure state. | |
| 363 | + */ | |
| 364 | + async executeStep(step: StepSpec, scope: Scope, logIndex: number, onStart?: (name: string) => Promise<void>): Promise<StepResult & { name: string }> { | |
| 365 | + const baseEnv = this.baseEnvContext(scope); | |
| 366 | + let ctx = this.contexts(scope, baseEnv); | |
| 367 | + let name = defaultStepName(step); | |
| 368 | + try { | |
| 369 | + name = step.name !== undefined ? this.interp(String(step.name), ctx, scope) : name; | |
| 370 | + } catch { | |
| 371 | + /* keep default */ | |
| 372 | + } | |
| 373 | + const id = step.id; | |
| 374 | + const record = (outcome: Outcome, conclusion: Outcome, outputs: Record<string, string>) => { | |
| 375 | + if (id) scope.steps[id] = { outputs, outcome, conclusion }; | |
| 376 | + }; | |
| 377 | + | |
| 378 | + let run: boolean; | |
| 379 | + try { | |
| 380 | + run = evaluateIf(step.if, ctx, this.statusFns(scope), this.extra()); | |
| 381 | + } catch (e) { | |
| 382 | + this.logger.line(logIndex, `##[error]Error evaluating 'if' condition: ${errMsg(e)}`); | |
| 383 | + this.noteFailure(logIndex, `Error evaluating 'if' condition of step '${name}': ${errMsg(e)}`); | |
| 384 | + scope.failed = true; | |
| 385 | + record("failure", "failure", {}); | |
| 386 | + return { outcome: "failure", conclusion: "failure", outputs: {}, name }; | |
| 387 | + } | |
| 388 | + if (!run) { | |
| 389 | + if (scope.depth > 0) this.logger.line(logIndex, `Skipping step '${name}' (condition not met)`); | |
| 390 | + record("skipped", "skipped", {}); | |
| 391 | + return { outcome: "skipped", conclusion: "skipped", outputs: {}, name }; | |
| 392 | + } | |
| 393 | + await onStart?.(name); | |
| 394 | + | |
| 395 | + const io: StepIO = { logIndex, outputs: {}, state: {} }; | |
| 396 | + let outcome: Outcome = "success"; | |
| 397 | + let continueOnError = false; | |
| 398 | + try { | |
| 399 | + continueOnError = this.evalBoolish(step["continue-on-error"], ctx, scope); | |
| 400 | + const stepEnv: Record<string, string> = {}; | |
| 401 | + const envCtx = { ...baseEnv }; | |
| 402 | + ctx = this.contexts(scope, envCtx); | |
| 403 | + for (const [k, v] of Object.entries(step.env ?? {})) { | |
| 404 | + stepEnv[k] = this.interp(String(v ?? ""), ctx, scope); | |
| 405 | + envCtx[k] = stepEnv[k]!; | |
| 406 | + } | |
| 407 | + const timeoutMin = this.evalNumber(step["timeout-minutes"], ctx, scope); | |
| 408 | + const timeoutMs = timeoutMin > 0 ? timeoutMin * 60_000 : 0; | |
| 409 | + if (step.run !== undefined) { | |
| 410 | + await runScript(this, { step, scope, ctx, envCtx, io, timeoutMs, name }); | |
| 411 | + } else if (step.uses) { | |
| 412 | + await runAction(this, { step, scope, ctx, envCtx, io, timeoutMs, name }); | |
| 413 | + } else { | |
| 414 | + throw new StepFailure("Step has neither `run` nor `uses`."); | |
| 415 | + } | |
| 416 | + if (this.stopping) outcome = "cancelled"; | |
| 417 | + } catch (e) { | |
| 418 | + if (this.stopping) { | |
| 419 | + outcome = "cancelled"; | |
| 420 | + } else { | |
| 421 | + outcome = "failure"; | |
| 422 | + const msg = errMsg(e); | |
| 423 | + if (!(e instanceof StepFailure && (e as StepFailure & { logged?: boolean }).logged)) { | |
| 424 | + this.logger.line(logIndex, `##[error]${msg}`); | |
| 425 | + this.addAnnotation(logIndex, { level: "error", message: msg }); | |
| 426 | + } | |
| 427 | + io.reason ??= msg; | |
| 428 | + } | |
| 429 | + } | |
| 430 | + | |
| 431 | + let conclusion: Outcome = outcome; | |
| 432 | + if (outcome === "failure") { | |
| 433 | + if (continueOnError) { | |
| 434 | + conclusion = "success"; | |
| 435 | + this.logger.line(logIndex, "##[warning]Step failed but continue-on-error is set; continuing."); | |
| 436 | + } else { | |
| 437 | + scope.failed = true; | |
| 438 | + this.noteFailure(logIndex, io.reason); | |
| 439 | + } | |
| 440 | + } | |
| 441 | + record(outcome, conclusion, io.outputs); | |
| 442 | + return { outcome, conclusion, outputs: io.outputs, name }; | |
| 443 | + } | |
| 444 | + | |
| 445 | + private evalBoolish(v: unknown, ctx: Record<string, unknown>, scope: Scope): boolean { | |
| 446 | + if (v === undefined || v === null || v === false) return false; | |
| 447 | + if (v === true) return true; | |
| 448 | + let s = String(v).trim(); | |
| 449 | + if (s.includes("${{")) s = this.interp(s, ctx, scope); | |
| 450 | + return s === "true"; | |
| 451 | + } | |
| 452 | + | |
| 453 | + private evalNumber(v: unknown, ctx: Record<string, unknown>, scope: Scope): number { | |
| 454 | + if (v === undefined || v === null || v === "") return 0; | |
| 455 | + if (typeof v === "number") return v; | |
| 456 | + let s = String(v); | |
| 457 | + if (s.includes("${{")) s = this.interp(s, ctx, scope); | |
| 458 | + const n = Number(s); | |
| 459 | + return Number.isFinite(n) ? n : 0; | |
| 460 | + } | |
| 461 | + | |
| 462 | + addAnnotation(logIndex: number, a: Annotation): void { | |
| 463 | + let list = this.stepErrors.get(logIndex); | |
| 464 | + if (!list) this.stepErrors.set(logIndex, (list = [])); | |
| 465 | + list.push(a); | |
| 466 | + } | |
| 467 | + | |
| 468 | + private noteFailure(logIndex: number, reason?: string): void { | |
| 469 | + if (this.firstFailure) return; | |
| 470 | + const errs = (this.stepErrors.get(logIndex) ?? []).filter((a) => a.level === "error"); | |
| 471 | + // prefer annotations emitted by the step itself over our generic "exit code" message | |
| 472 | + const userErr = [...errs].reverse().find((a) => a.message !== reason); | |
| 473 | + let message: string; | |
| 474 | + if (userErr) { | |
| 475 | + const loc = userErr.file ? `${userErr.file}${userErr.line ? `:${userErr.line}` : ""}: ` : ""; | |
| 476 | + message = `${loc}${userErr.title ? `${userErr.title}: ` : ""}${userErr.message}`; | |
| 477 | + } else { | |
| 478 | + const tail = this.logger.tail(logIndex, 20).filter((l) => !l.startsWith("##[error]")); | |
| 479 | + message = [reason ?? "Step failed", ...(tail.length ? ["", ...tail] : [])].join("\n"); | |
| 480 | + } | |
| 481 | + this.firstFailure = { step: logIndex >= 0 ? logIndex : undefined, message }; | |
| 482 | + if (this.firstFailure.step === undefined) delete this.firstFailure.step; | |
| 483 | + } | |
| 484 | + | |
| 485 | + /** | |
| 486 | + * Runs a process for a step with fresh file-command files, interpreting | |
| 487 | + * workflow commands, then applies GITHUB_ENV/PATH/OUTPUT/STATE/SUMMARY. | |
| 488 | + */ | |
| 489 | + async runStepProcess( | |
| 490 | + argv: string[], | |
| 491 | + o: { cwd: string; envCtx: Record<string, string>; io: StepIO; timeoutMs?: number; extraEnv?: Record<string, string>; timeoutLabel?: string }, | |
| 492 | + ): Promise<ExecResult> { | |
| 493 | + const id = randomUUID(); | |
| 494 | + const files = { | |
| 495 | + GITHUB_ENV: path.join(this.fileCmdDir, `set_env_${id}`), | |
| 496 | + GITHUB_PATH: path.join(this.fileCmdDir, `add_path_${id}`), | |
| 497 | + GITHUB_OUTPUT: path.join(this.fileCmdDir, `set_output_${id}`), | |
| 498 | + GITHUB_STATE: path.join(this.fileCmdDir, `save_state_${id}`), | |
| 499 | + GITHUB_STEP_SUMMARY: path.join(this.fileCmdDir, `step_summary_${id}`), | |
| 500 | + }; | |
| 501 | + await mkdir(this.fileCmdDir, { recursive: true }); | |
| 502 | + await Promise.all(Object.values(files).map((f) => writeFile(f, ""))); | |
| 503 | + const proc = this.processor(o.io); | |
| 504 | + if (!existsSync(o.cwd)) { | |
| 505 | + throw new StepFailure(`An error occurred trying to start process '${argv[0]}' with working directory '${o.cwd}'. No such file or directory`); | |
| 506 | + } | |
| 507 | + const res = await exec(argv[0]!, argv.slice(1), { | |
| 508 | + cwd: o.cwd, | |
| 509 | + env: this.processEnv(o.envCtx, { ...(o.extraEnv ?? {}), ...files }), | |
| 510 | + timeoutMs: o.timeoutMs, | |
| 511 | + onLine: (l) => proc.handle(l), | |
| 512 | + onSpawn: (c) => { | |
| 513 | + this.children.add(c); | |
| 514 | + c.once("close", () => this.children.delete(c)); | |
| 515 | + }, | |
| 516 | + }); | |
| 517 | + await this.applyFileCommands(files, o.io); | |
| 518 | + if (res.timedOut) { | |
| 519 | + const mins = (o.timeoutMs ?? 0) / 60000; | |
| 520 | + const msg = `The ${o.timeoutLabel ?? "step"} has timed out after ${Number(mins.toFixed(2))} minutes.`; | |
| 521 | + this.logger.line(o.io.logIndex, `##[error]${msg}`); | |
| 522 | + this.addAnnotation(o.io.logIndex, { level: "error", message: msg }); | |
| 523 | + o.io.reason = msg; | |
| 524 | + } | |
| 525 | + return res; | |
| 526 | + } | |
| 527 | + | |
| 528 | + processor(io: StepIO): OutputProcessor { | |
| 529 | + return new OutputProcessor(this.logger, this.masker, io, { | |
| 530 | + debug: this.debug, | |
| 531 | + onAnnotation: (a) => this.addAnnotation(io.logIndex, a), | |
| 532 | + }); | |
| 533 | + } | |
| 534 | + | |
| 535 | + private async applyFileCommands(files: Record<string, string>, io: StepIO): Promise<void> { | |
| 536 | + const read = async (f: string) => { | |
| 537 | + try { | |
| 538 | + return await readFile(f, "utf8"); | |
| 539 | + } catch { | |
| 540 | + return ""; | |
| 541 | + } | |
| 542 | + }; | |
| 543 | + const [envC, pathC, outC, stateC, sumC] = await Promise.all([ | |
| 544 | + read(files.GITHUB_ENV!), | |
| 545 | + read(files.GITHUB_PATH!), | |
| 546 | + read(files.GITHUB_OUTPUT!), | |
| 547 | + read(files.GITHUB_STATE!), | |
| 548 | + read(files.GITHUB_STEP_SUMMARY!), | |
| 549 | + ]); | |
| 550 | + const safe = (label: string, fn: () => void) => { | |
| 551 | + try { | |
| 552 | + fn(); | |
| 553 | + } catch (e) { | |
| 554 | + this.logger.line(io.logIndex, `##[error]Unable to process file command '${label}' successfully: ${errMsg(e)}`); | |
| 555 | + this.addAnnotation(io.logIndex, { level: "error", message: `Unable to process file command '${label}': ${errMsg(e)}` }); | |
| 556 | + } | |
| 557 | + }; | |
| 558 | + safe("env", () => { | |
| 559 | + for (const [k, v] of parseKeyValueFile(envC)) { | |
| 560 | + if (k === "NODE_OPTIONS" && /--require|-r\b/.test(v)) continue; | |
| 561 | + this.envAccum[k] = v; | |
| 562 | + } | |
| 563 | + }); | |
| 564 | + safe("path", () => { | |
| 565 | + for (const p of parsePathFile(pathC)) this.addPath(p); | |
| 566 | + }); | |
| 567 | + safe("output", () => { | |
| 568 | + for (const [k, v] of parseKeyValueFile(outC)) io.outputs[k] = v; | |
| 569 | + }); | |
| 570 | + safe("state", () => { | |
| 571 | + for (const [k, v] of parseKeyValueFile(stateC)) io.state[k] = v; | |
| 572 | + }); | |
| 573 | + if (sumC.trim()) this.summaries.push(sumC); | |
| 574 | + } | |
| 575 | + | |
| 576 | + addPath(p: string): void { | |
| 577 | + this.pathAccum = [p, ...this.pathAccum.filter((x) => x !== p)]; | |
| 578 | + } | |
| 579 | + | |
| 580 | + /** Runs an internal tool (git, tar, ...) streaming its output into the step log. */ | |
| 581 | + async tool(cmd: string, args: string[], o: { io: StepIO; cwd?: string; env?: NodeJS.ProcessEnv; capture?: boolean; silent?: boolean; input?: string }): Promise<ExecResult> { | |
| 582 | + return exec(cmd, args, { | |
| 583 | + cwd: o.cwd ?? this.workspace, | |
| 584 | + env: o.env ?? this.processEnv(this.baseEnvContext({ steps: {}, inputs: {}, failed: false, inheritedEnv: {}, composite: false, depth: 0 })), | |
| 585 | + capture: o.capture, | |
| 586 | + input: o.input, | |
| 587 | + onLine: o.silent ? undefined : (l) => this.logger.line(o.io.logIndex, l), | |
| 588 | + onSpawn: (c) => { | |
| 589 | + this.children.add(c); | |
| 590 | + c.once("close", () => this.children.delete(c)); | |
| 591 | + }, | |
| 592 | + }); | |
| 593 | + } | |
| 594 | + | |
| 595 | + registerPost(p: PostStep): void { | |
| 596 | + this.posts.push(p); | |
| 597 | + } | |
| 598 | + | |
| 599 | + private async runPosts(top: Scope): Promise<void> { | |
| 600 | + const posts = [...this.posts].reverse(); | |
| 601 | + let index = this.spec.steps.length; | |
| 602 | + for (const p of posts) { | |
| 603 | + const logIndex = index++; | |
| 604 | + if (this.stopping) break; | |
| 605 | + let ok: boolean; | |
| 606 | + try { | |
| 607 | + ok = evaluateIf(p.condition, this.contexts(top, this.baseEnvContext(top)), this.statusFns(top), this.extra()); | |
| 608 | + } catch { | |
| 609 | + ok = false; | |
| 610 | + } | |
| 611 | + if (!ok) continue; | |
| 612 | + const startedAt = nowIso(); | |
| 613 | + await this.report(logIndex, { status: "in_progress", name: p.name, startedAt }); | |
| 614 | + let conclusion: Conclusion = "success"; | |
| 615 | + try { | |
| 616 | + await p.run(logIndex); | |
| 617 | + } catch (e) { | |
| 618 | + conclusion = "failure"; | |
| 619 | + const msg = errMsg(e); | |
| 620 | + if (!(e instanceof StepFailure && (e as StepFailure & { logged?: boolean }).logged)) { | |
| 621 | + this.logger.line(logIndex, `##[error]${msg}`); | |
| 622 | + this.addAnnotation(logIndex, { level: "error", message: msg }); | |
| 623 | + } | |
| 624 | + top.failed = true; | |
| 625 | + this.noteFailure(logIndex, msg); | |
| 626 | + } | |
| 627 | + if (this.stopping) conclusion = "cancelled"; | |
| 628 | + await this.report(logIndex, { status: "completed", conclusion, name: p.name, startedAt, completedAt: nowIso() }); | |
| 629 | + } | |
| 630 | + } | |
| 631 | + | |
| 632 | + private jobOutputs(top: Scope): Record<string, string> { | |
| 633 | + const out: Record<string, string> = {}; | |
| 634 | + const ctx = this.contexts(top, this.baseEnvContext(top)); | |
| 635 | + for (const [k, expr] of Object.entries(this.spec.outputs ?? {})) { | |
| 636 | + try { | |
| 637 | + const v = this.interp(String(expr), ctx, top); | |
| 638 | + if (this.masker.containsSecret(v)) { | |
| 639 | + this.logger.line(-1, `##[warning]Skip output '${k}' since it may contain secret.`); | |
| 640 | + continue; | |
| 641 | + } | |
| 642 | + out[k] = v; | |
| 643 | + } catch (e) { | |
| 644 | + this.logger.line(-1, `##[warning]Failed to evaluate job output '${k}': ${errMsg(e)}`); | |
| 645 | + } | |
| 646 | + } | |
| 647 | + return out; | |
| 648 | + } | |
| 649 | + | |
| 650 | + private async report(index: number, r: StepReport): Promise<void> { | |
| 651 | + try { | |
| 652 | + await this.api.postStep(index, { ...r, name: this.masker.mask(r.name) }); | |
| 653 | + } catch (e) { | |
| 654 | + this.logger.line(-1, `##[warning]Failed to report step ${index}: ${errMsg(e)}`); | |
| 655 | + } | |
| 656 | + } | |
| 657 | +} | |
| 658 | + | |
| 659 | +export function errMsg(e: unknown): string { | |
| 660 | + return e instanceof Error ? e.message : String(e); | |
| 661 | +} | |
| 662 | + | |
| 663 | +export function failure(message: string, logged = false): StepFailure { | |
| 664 | + const f = new StepFailure(message) as StepFailure & { logged?: boolean }; | |
| 665 | + f.logged = logged; | |
| 666 | + return f; | |
| 667 | +} | |
| 668 | + | |
| 669 | +export function defaultStepName(step: StepSpec): string { | |
| 670 | + if (step.name) return String(step.name); | |
| 671 | + if (step.uses) return `Run ${step.uses}`; | |
| 672 | + if (step.run) return `Run ${String(step.run).trim().split("\n")[0]}`; | |
| 673 | + return "Step"; | |
| 674 | +} | |
| 675 | + | |
| 676 | +function safeName(step: StepSpec, fallback: string): string { | |
| 677 | + return step.name && !String(step.name).includes("${{") ? String(step.name) : fallback; | |
| 678 | +} |
addedcontainers/runner/src/main.ts+106−0View file
| @@ -1,0 +1,106 @@ | ||
| 1 | +/** | |
| 2 | + * Diffinery runner agent entry point. | |
| 3 | + * | |
| 4 | + * agent.mjs fetch the job from DIFFINERY_API_URL and run it | |
| 5 | + * agent.mjs --spec job.json run a JobSpec from a file (reports to spec.apiUrl, | |
| 6 | + * or DIFFINERY_API_URL; `--offline` logs to stdout only) | |
| 7 | + * agent.mjs --sandbox AI-agent sandbox HTTP server on :8080 | |
| 8 | + */ | |
| 9 | +import { readFile } from "node:fs/promises"; | |
| 10 | +import type { JobSpec } from "@diffinery/shared"; | |
| 11 | +import { apiBaseFor, runAgentJob } from "./agent.js"; | |
| 12 | +import { RunnerApi } from "./api.js"; | |
| 13 | +import { startSandbox } from "./sandbox.js"; | |
| 14 | + | |
| 15 | +interface Args { | |
| 16 | + spec?: string; | |
| 17 | + sandbox: boolean; | |
| 18 | + offline: boolean; | |
| 19 | + workRoot?: string; | |
| 20 | +} | |
| 21 | + | |
| 22 | +function parseArgs(argv: string[]): Args { | |
| 23 | + const a: Args = { sandbox: false, offline: false }; | |
| 24 | + for (let i = 0; i < argv.length; i++) { | |
| 25 | + const v = argv[i]!; | |
| 26 | + if (v === "--spec") a.spec = argv[++i]; | |
| 27 | + else if (v.startsWith("--spec=")) a.spec = v.slice(7); | |
| 28 | + else if (v === "--sandbox") a.sandbox = true; | |
| 29 | + else if (v === "--offline") a.offline = true; | |
| 30 | + else if (v === "--work-root") a.workRoot = argv[++i]; | |
| 31 | + else if (v === "--help" || v === "-h") { | |
| 32 | + process.stdout.write("usage: agent.mjs [--spec job.json [--offline]] [--sandbox] [--work-root dir]\n"); | |
| 33 | + process.exit(0); | |
| 34 | + } | |
| 35 | + } | |
| 36 | + return a; | |
| 37 | +} | |
| 38 | + | |
| 39 | +function fail(msg: string): never { | |
| 40 | + process.stderr.write(`[diffinery-runner] ${msg}\n`); | |
| 41 | + process.exit(2); | |
| 42 | +} | |
| 43 | + | |
| 44 | +async function main(): Promise<void> { | |
| 45 | + const args = parseArgs(process.argv.slice(2)); | |
| 46 | + const env = process.env; | |
| 47 | + const workRoot = args.workRoot ?? env.DIFFINERY_WORK_ROOT ?? "/workspace"; | |
| 48 | + | |
| 49 | + if (args.sandbox) { | |
| 50 | + const secret = env.DIFFINERY_SANDBOX_SECRET; | |
| 51 | + if (!secret) fail("DIFFINERY_SANDBOX_SECRET is required in --sandbox mode"); | |
| 52 | + const h = await startSandbox({ | |
| 53 | + port: Number(env.DIFFINERY_SANDBOX_PORT ?? env.PORT ?? 8080), | |
| 54 | + secret, | |
| 55 | + workRoot, | |
| 56 | + cloneUrl: env.DIFFINERY_CLONE_URL, | |
| 57 | + ref: env.DIFFINERY_REF, | |
| 58 | + token: env.DIFFINERY_JOB_TOKEN, | |
| 59 | + }); | |
| 60 | + process.stdout.write(`[sandbox] listening on :${h.port}\n`); | |
| 61 | + const stop = () => void h.close().then(() => process.exit(0)); | |
| 62 | + process.on("SIGTERM", stop); | |
| 63 | + process.on("SIGINT", stop); | |
| 64 | + return; | |
| 65 | + } | |
| 66 | + | |
| 67 | + let spec: JobSpec; | |
| 68 | + let apiBase: string | null; | |
| 69 | + if (args.spec) { | |
| 70 | + spec = JSON.parse(await readFile(args.spec, "utf8")) as JobSpec; | |
| 71 | + if (env.DIFFINERY_JOB_TOKEN && !spec.token) spec.token = env.DIFFINERY_JOB_TOKEN; | |
| 72 | + apiBase = args.offline ? null : apiBaseFor(env.DIFFINERY_API_URL ?? spec.apiUrl, spec.jobId); | |
| 73 | + } else { | |
| 74 | + const api = env.DIFFINERY_API_URL; | |
| 75 | + const jobId = env.DIFFINERY_JOB_ID; | |
| 76 | + const token = env.DIFFINERY_JOB_TOKEN; | |
| 77 | + if (!api || !jobId || !token) fail("DIFFINERY_API_URL, DIFFINERY_JOB_ID and DIFFINERY_JOB_TOKEN are required (or pass --spec / --sandbox)"); | |
| 78 | + apiBase = apiBaseFor(api, jobId); | |
| 79 | + const client = new RunnerApi(apiBase, token); | |
| 80 | + try { | |
| 81 | + spec = await client.getSpec(); | |
| 82 | + } catch (e) { | |
| 83 | + const msg = e instanceof Error ? e.message : String(e); | |
| 84 | + try { | |
| 85 | + await client.complete({ conclusion: "failure", outputs: {}, failure: { message: `Runner could not fetch the job spec: ${msg}` } }); | |
| 86 | + } catch { | |
| 87 | + /* nothing more we can do */ | |
| 88 | + } | |
| 89 | + fail(`could not fetch job spec: ${msg}`); | |
| 90 | + } | |
| 91 | + spec.token ||= token; | |
| 92 | + spec.apiUrl ||= api; | |
| 93 | + } | |
| 94 | + | |
| 95 | + const completion = await runAgentJob(spec, { | |
| 96 | + apiBase, | |
| 97 | + workRoot, | |
| 98 | + toolCache: env.RUNNER_TOOL_CACHE, | |
| 99 | + echoLogs: env.DIFFINERY_ECHO_LOGS === "1" || apiBase === null, | |
| 100 | + handleSignals: true, | |
| 101 | + }); | |
| 102 | + process.stdout.write(`[diffinery-runner] job finished: ${completion.conclusion}\n`); | |
| 103 | + process.exit(completion.conclusion === "success" ? 0 : 1); | |
| 104 | +} | |
| 105 | + | |
| 106 | +main().catch((e: unknown) => fail(e instanceof Error ? (e.stack ?? e.message) : String(e))); |
addedcontainers/runner/src/sandbox.ts+217−0View file
| @@ -1,0 +1,217 @@ | ||
| 1 | +import { timingSafeEqual } from "node:crypto"; | |
| 2 | +import { existsSync } from "node:fs"; | |
| 3 | +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; | |
| 4 | +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; | |
| 5 | +import type { AddressInfo } from "node:net"; | |
| 6 | +import path from "node:path"; | |
| 7 | +import { basicAuthHeader } from "./builtins/checkout.js"; | |
| 8 | +import { Masker } from "./mask.js"; | |
| 9 | +import { exec } from "./util.js"; | |
| 10 | + | |
| 11 | +export interface SandboxOptions { | |
| 12 | + port: number; | |
| 13 | + host?: string; | |
| 14 | + secret: string; | |
| 15 | + workRoot: string; | |
| 16 | + cloneUrl?: string; | |
| 17 | + ref?: string; | |
| 18 | + token?: string; | |
| 19 | + /** Extra values to mask in /exec output. */ | |
| 20 | + maskValues?: string[]; | |
| 21 | + maxOutputBytes?: number; | |
| 22 | + baseEnv?: NodeJS.ProcessEnv; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export interface SandboxHandle { | |
| 26 | + server: Server; | |
| 27 | + port: number; | |
| 28 | + repoDir: string; | |
| 29 | + ready: Promise<void>; | |
| 30 | + close(): Promise<void>; | |
| 31 | +} | |
| 32 | + | |
| 33 | +const MAX_BODY = 64 * 1024 * 1024; | |
| 34 | +const SENSITIVE = ["DIFFINERY_JOB_TOKEN", "DIFFINERY_SANDBOX_SECRET"]; | |
| 35 | + | |
| 36 | +class HttpErr extends Error { | |
| 37 | + constructor( | |
| 38 | + public status: number, | |
| 39 | + message: string, | |
| 40 | + ) { | |
| 41 | + super(message); | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +async function readBody(req: IncomingMessage): Promise<unknown> { | |
| 46 | + const chunks: Buffer[] = []; | |
| 47 | + let size = 0; | |
| 48 | + for await (const c of req) { | |
| 49 | + size += (c as Buffer).length; | |
| 50 | + if (size > MAX_BODY) throw new HttpErr(413, "request body too large"); | |
| 51 | + chunks.push(c as Buffer); | |
| 52 | + } | |
| 53 | + const raw = Buffer.concat(chunks).toString("utf8"); | |
| 54 | + if (!raw) return {}; | |
| 55 | + try { | |
| 56 | + return JSON.parse(raw); | |
| 57 | + } catch { | |
| 58 | + throw new HttpErr(400, "invalid JSON body"); | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 62 | +function send(res: ServerResponse, status: number, body: unknown): void { | |
| 63 | + const data = JSON.stringify(body); | |
| 64 | + res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(data) }); | |
| 65 | + res.end(data); | |
| 66 | +} | |
| 67 | + | |
| 68 | +function authorized(req: IncomingMessage, secret: string): boolean { | |
| 69 | + const h = req.headers.authorization ?? ""; | |
| 70 | + const m = /^Bearer\s+(.+)$/i.exec(h); | |
| 71 | + if (!m || !secret) return false; | |
| 72 | + const a = Buffer.from(m[1]!.trim()); | |
| 73 | + const b = Buffer.from(secret); | |
| 74 | + return a.length === b.length && timingSafeEqual(a, b); | |
| 75 | +} | |
| 76 | + | |
| 77 | +function truncate(s: string, max: number): { text: string; truncated: boolean } { | |
| 78 | + const buf = Buffer.from(s, "utf8"); | |
| 79 | + if (buf.length <= max) return { text: s, truncated: false }; | |
| 80 | + return { text: buf.subarray(0, max).toString("utf8"), truncated: true }; | |
| 81 | +} | |
| 82 | + | |
| 83 | +async function cloneRepo(o: SandboxOptions, repoDir: string, masker: Masker, log: (l: string) => void): Promise<void> { | |
| 84 | + if (!o.cloneUrl) { | |
| 85 | + await mkdir(repoDir, { recursive: true }); | |
| 86 | + return; | |
| 87 | + } | |
| 88 | + if (existsSync(path.join(repoDir, ".git"))) return; | |
| 89 | + await mkdir(path.dirname(repoDir), { recursive: true }); | |
| 90 | + const auth = o.token && /^https?:/i.test(o.cloneUrl) ? ["-c", `http.extraheader=${basicAuthHeader(o.token)}`] : []; | |
| 91 | + const env: NodeJS.ProcessEnv = { ...(o.baseEnv ?? process.env), GIT_TERMINAL_PROMPT: "0" }; | |
| 92 | + for (const k of SENSITIVE) delete env[k]; | |
| 93 | + const onLine = (l: string) => log(masker.mask(l)); | |
| 94 | + const r = await exec("git", [...auth, "clone", "--no-tags", o.cloneUrl, repoDir], { env, onLine, capture: true }); | |
| 95 | + if (r.exitCode !== 0) throw new Error(`git clone failed (exit ${r.exitCode}): ${masker.mask(r.stderr.trim().split("\n").slice(-3).join(" "))}`); | |
| 96 | + if (auth.length) { | |
| 97 | + await exec("git", ["config", "--local", `http.${new URL(o.cloneUrl).origin}/.extraheader`, basicAuthHeader(o.token!)], { cwd: repoDir, env }); | |
| 98 | + } | |
| 99 | + if (o.ref) { | |
| 100 | + const ref = o.ref; | |
| 101 | + const short = ref.replace(/^refs\/heads\//, ""); | |
| 102 | + let c = await exec("git", ["checkout", "-q", short], { cwd: repoDir, env, capture: true }); | |
| 103 | + if (c.exitCode !== 0) { | |
| 104 | + const f = await exec("git", [...auth, "fetch", "-q", "origin", ref], { cwd: repoDir, env, capture: true }); | |
| 105 | + if (f.exitCode === 0) c = await exec("git", ["checkout", "-q", "FETCH_HEAD"], { cwd: repoDir, env, capture: true }); | |
| 106 | + if (c.exitCode !== 0) throw new Error(`could not check out ${ref}: ${masker.mask(c.stderr.trim())}`); | |
| 107 | + } | |
| 108 | + } | |
| 109 | +} | |
| 110 | + | |
| 111 | +/** Starts the AI-agent task sandbox HTTP server. */ | |
| 112 | +export async function startSandbox(o: SandboxOptions): Promise<SandboxHandle> { | |
| 113 | + const masker = new Masker(); | |
| 114 | + masker.add(o.secret); | |
| 115 | + if (o.token) masker.add(o.token); | |
| 116 | + if (o.token) masker.add(basicAuthHeader(o.token).slice("AUTHORIZATION: basic ".length)); | |
| 117 | + for (const v of o.maskValues ?? []) masker.add(v); | |
| 118 | + const repoDir = path.join(o.workRoot, "repo"); | |
| 119 | + const maxOut = o.maxOutputBytes ?? 1024 * 1024; | |
| 120 | + let cloneError: string | null = null; | |
| 121 | + let isReady = false; | |
| 122 | + const ready = cloneRepo(o, repoDir, masker, (l) => process.stdout.write(`[sandbox] ${l}\n`)) | |
| 123 | + .then(() => { | |
| 124 | + isReady = true; | |
| 125 | + }) | |
| 126 | + .catch((e: unknown) => { | |
| 127 | + cloneError = e instanceof Error ? e.message : String(e); | |
| 128 | + process.stderr.write(`[sandbox] ${cloneError}\n`); | |
| 129 | + }); | |
| 130 | + | |
| 131 | + const resolvePath = (p: unknown): string => { | |
| 132 | + if (typeof p !== "string" || !p) throw new HttpErr(400, "`path` is required"); | |
| 133 | + return path.isAbsolute(p) ? p : path.join(repoDir, p); | |
| 134 | + }; | |
| 135 | + | |
| 136 | + const server = createServer((req, res) => { | |
| 137 | + void (async () => { | |
| 138 | + const url = new URL(req.url ?? "/", "http://sandbox"); | |
| 139 | + try { | |
| 140 | + if (req.method === "GET" && url.pathname === "/health") { | |
| 141 | + send(res, 200, { ok: true, ready: isReady, ...(cloneError ? { error: cloneError } : {}) }); | |
| 142 | + return; | |
| 143 | + } | |
| 144 | + if (!authorized(req, o.secret)) throw new HttpErr(401, "unauthorized"); | |
| 145 | + if (req.method !== "POST") throw new HttpErr(405, "method not allowed"); | |
| 146 | + const body = (await readBody(req)) as Record<string, unknown>; | |
| 147 | + await ready; | |
| 148 | + switch (url.pathname) { | |
| 149 | + case "/exec": { | |
| 150 | + const cmd = body.cmd; | |
| 151 | + if (typeof cmd !== "string" || !cmd) throw new HttpErr(400, "`cmd` is required"); | |
| 152 | + const cwd = body.cwd ? resolvePath(body.cwd) : repoDir; | |
| 153 | + if (!existsSync(cwd)) throw new HttpErr(400, `cwd does not exist: ${cwd}`); | |
| 154 | + const env: NodeJS.ProcessEnv = { ...(o.baseEnv ?? process.env), CI: "true", DIFFINERY: "true", DIFFINERY_SANDBOX: "true" }; | |
| 155 | + for (const k of SENSITIVE) delete env[k]; | |
| 156 | + if (body.env && typeof body.env === "object") for (const [k, v] of Object.entries(body.env)) env[k] = String(v); | |
| 157 | + const timeoutMs = typeof body.timeoutMs === "number" && body.timeoutMs > 0 ? body.timeoutMs : 10 * 60_000; | |
| 158 | + const started = Date.now(); | |
| 159 | + const r = await exec("bash", ["-c", cmd], { cwd, env, timeoutMs, capture: true, maxCapture: maxOut + 1 }); | |
| 160 | + const so = truncate(masker.mask(r.stdout), maxOut); | |
| 161 | + const se = truncate(masker.mask(r.stderr), maxOut); | |
| 162 | + send(res, 200, { | |
| 163 | + exitCode: r.timedOut ? 124 : r.exitCode, | |
| 164 | + stdout: so.text, | |
| 165 | + stderr: se.text, | |
| 166 | + timedOut: r.timedOut, | |
| 167 | + truncated: so.truncated || se.truncated || r.stdoutTruncated || r.stderrTruncated, | |
| 168 | + durationMs: Date.now() - started, | |
| 169 | + }); | |
| 170 | + return; | |
| 171 | + } | |
| 172 | + case "/files/read": { | |
| 173 | + const p = resolvePath(body.path); | |
| 174 | + let st; | |
| 175 | + try { | |
| 176 | + st = await stat(p); | |
| 177 | + } catch { | |
| 178 | + throw new HttpErr(404, `not found: ${body.path}`); | |
| 179 | + } | |
| 180 | + if (st.isDirectory()) throw new HttpErr(400, "path is a directory"); | |
| 181 | + if (st.size > MAX_BODY) throw new HttpErr(413, "file too large"); | |
| 182 | + const data = await readFile(p); | |
| 183 | + send(res, 200, { path: p, size: st.size, content: data.toString("base64") }); | |
| 184 | + return; | |
| 185 | + } | |
| 186 | + case "/files/write": { | |
| 187 | + const p = resolvePath(body.path); | |
| 188 | + if (typeof body.content !== "string") throw new HttpErr(400, "`content` (base64) is required"); | |
| 189 | + const data = Buffer.from(body.content, "base64"); | |
| 190 | + await mkdir(path.dirname(p), { recursive: true }); | |
| 191 | + await writeFile(p, data, typeof body.mode === "number" ? { mode: body.mode } : undefined); | |
| 192 | + send(res, 200, { ok: true, path: p, size: data.length }); | |
| 193 | + return; | |
| 194 | + } | |
| 195 | + default: | |
| 196 | + throw new HttpErr(404, "not found"); | |
| 197 | + } | |
| 198 | + } catch (e) { | |
| 199 | + if (e instanceof HttpErr) send(res, e.status, { error: e.message }); | |
| 200 | + else send(res, 500, { error: masker.mask(e instanceof Error ? e.message : String(e)) }); | |
| 201 | + } | |
| 202 | + })(); | |
| 203 | + }); | |
| 204 | + | |
| 205 | + await new Promise<void>((resolve, reject) => { | |
| 206 | + server.once("error", reject); | |
| 207 | + server.listen(o.port, o.host ?? "0.0.0.0", () => resolve()); | |
| 208 | + }); | |
| 209 | + const port = (server.address() as AddressInfo).port; | |
| 210 | + return { | |
| 211 | + server, | |
| 212 | + port, | |
| 213 | + repoDir, | |
| 214 | + ready, | |
| 215 | + close: () => new Promise<void>((r) => server.close(() => r())), | |
| 216 | + }; | |
| 217 | +} |
addedcontainers/runner/src/steps/actions.ts+333−0View file
| @@ -1,0 +1,333 @@ | ||
| 1 | +import { existsSync } from "node:fs"; | |
| 2 | +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import type { StepSpec } from "@diffinery/shared"; | |
| 5 | +import { getBuiltin, makeBuiltinContext } from "../builtins/index.js"; | |
| 6 | +import { errMsg, failure, StepFailure, type JobRunner, type Scope, type StepIO } from "../job.js"; | |
| 7 | +import { exec } from "../util.js"; | |
| 8 | +import { parseYaml } from "../yaml.js"; | |
| 9 | +import type { StepExec } from "./run.js"; | |
| 10 | + | |
| 11 | +export type UsesRef = | |
| 12 | + | { kind: "docker"; image: string } | |
| 13 | + | { kind: "local"; path: string } | |
| 14 | + | { kind: "remote"; owner: string; repo: string; subpath: string; ref: string; name: string }; | |
| 15 | + | |
| 16 | +export function parseUses(uses: string): UsesRef { | |
| 17 | + const u = uses.trim(); | |
| 18 | + if (u.startsWith("docker://")) return { kind: "docker", image: u.slice("docker://".length) }; | |
| 19 | + if (u.startsWith("./") || u.startsWith("../") || u === ".") return { kind: "local", path: u }; | |
| 20 | + const at = u.lastIndexOf("@"); | |
| 21 | + if (at <= 0) throw failure(`Expected format {owner}/{repo}[/path]@ref. Actual '${u}'`); | |
| 22 | + const ref = u.slice(at + 1); | |
| 23 | + const parts = u.slice(0, at).split("/"); | |
| 24 | + if (parts.length < 2 || !parts[0] || !parts[1] || !ref) throw failure(`Expected format {owner}/{repo}[/path]@ref. Actual '${u}'`); | |
| 25 | + return { kind: "remote", owner: parts[0], repo: parts[1], subpath: parts.slice(2).join("/"), ref, name: parts.join("/") }; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export interface ActionInput { | |
| 29 | + description?: string; | |
| 30 | + required?: boolean; | |
| 31 | + default?: unknown; | |
| 32 | + deprecationMessage?: string; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export interface ActionMeta { | |
| 36 | + name?: string; | |
| 37 | + inputs?: Record<string, ActionInput | null>; | |
| 38 | + outputs?: Record<string, { description?: string; value?: string } | null>; | |
| 39 | + runs: { | |
| 40 | + using: string; | |
| 41 | + main?: string; | |
| 42 | + pre?: string; | |
| 43 | + "pre-if"?: string; | |
| 44 | + post?: string; | |
| 45 | + "post-if"?: string; | |
| 46 | + steps?: StepSpec[]; | |
| 47 | + image?: string; | |
| 48 | + env?: Record<string, string>; | |
| 49 | + }; | |
| 50 | +} | |
| 51 | + | |
| 52 | +export async function loadActionMeta(dir: string): Promise<ActionMeta> { | |
| 53 | + for (const f of ["action.yml", "action.yaml"]) { | |
| 54 | + const p = path.join(dir, f); | |
| 55 | + if (existsSync(p)) { | |
| 56 | + const meta = parseYaml(await readFile(p, "utf8")) as ActionMeta | null; | |
| 57 | + if (!meta || typeof meta !== "object" || !meta.runs || typeof meta.runs !== "object") { | |
| 58 | + throw failure(`Invalid action metadata in ${p}: missing 'runs'`); | |
| 59 | + } | |
| 60 | + return meta; | |
| 61 | + } | |
| 62 | + } | |
| 63 | + if (existsSync(path.join(dir, "Dockerfile"))) return { runs: { using: "docker", image: "Dockerfile" } }; | |
| 64 | + throw failure(`Can't find 'action.yml' or 'action.yaml' under '${dir}'. Did you forget to run actions/checkout before running your local action?`); | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** Downloads `owner/repo@ref` into the action cache and returns its root dir. */ | |
| 68 | +export async function downloadAction(job: JobRunner, io: StepIO, owner: string, repo: string, ref: string): Promise<string> { | |
| 69 | + const dest = path.join(job.actionsDir, owner, repo, ref); | |
| 70 | + if (existsSync(path.join(dest, ".diffinery-complete"))) return dest; | |
| 71 | + const tpl = process.env.DIFFINERY_ACTIONS_DOWNLOAD_URL ?? "https://codeload.github.com/{owner}/{repo}/tar.gz/{ref}"; | |
| 72 | + const url = tpl.replace("{owner}", owner).replace("{repo}", repo).replace("{ref}", encodeURIComponent(ref)); | |
| 73 | + job.logger.line(io.logIndex, `Download action repository '${owner}/${repo}@${ref}'`); | |
| 74 | + const headers: Record<string, string> = { "user-agent": "diffinery-runner" }; | |
| 75 | + const ghToken = process.env.DIFFINERY_GITHUB_TOKEN; | |
| 76 | + if (ghToken && url.startsWith("https://codeload.github.com/")) headers.authorization = `token ${ghToken}`; | |
| 77 | + let lastErr: unknown; | |
| 78 | + for (let attempt = 0; attempt < 4; attempt++) { | |
| 79 | + if (attempt) await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); | |
| 80 | + try { | |
| 81 | + const res = await fetch(url, { headers, redirect: "follow" }); | |
| 82 | + if (res.status === 404) throw failure(`Unable to resolve action '${owner}/${repo}@${ref}', repository or version not found`); | |
| 83 | + if (!res.ok) throw new Error(`HTTP ${res.status} downloading ${url}`); | |
| 84 | + const buf = Buffer.from(await res.arrayBuffer()); | |
| 85 | + const tmp = `${dest}.tmp-${process.pid}-${Date.now()}`; | |
| 86 | + await mkdir(tmp, { recursive: true }); | |
| 87 | + const tarFile = `${tmp}.tar.gz`; | |
| 88 | + await writeFile(tarFile, buf); | |
| 89 | + const r = await exec("tar", ["-xzf", tarFile, "-C", tmp, "--strip-components=1"], { capture: true }); | |
| 90 | + await rm(tarFile, { force: true }); | |
| 91 | + if (r.exitCode !== 0) throw new Error(`Failed to extract action archive: ${r.stderr.trim()}`); | |
| 92 | + await rm(dest, { recursive: true, force: true }); | |
| 93 | + await mkdir(path.dirname(dest), { recursive: true }); | |
| 94 | + await rename(tmp, dest); | |
| 95 | + await writeFile(path.join(dest, ".diffinery-complete"), ""); | |
| 96 | + return dest; | |
| 97 | + } catch (e) { | |
| 98 | + if (e instanceof StepFailure) throw e; | |
| 99 | + lastErr = e; | |
| 100 | + } | |
| 101 | + } | |
| 102 | + throw failure(`Failed to download action '${owner}/${repo}@${ref}': ${errMsg(lastErr)}`); | |
| 103 | +} | |
| 104 | + | |
| 105 | +export function inputEnvName(name: string): string { | |
| 106 | + return `INPUT_${name.replace(/ /g, "_").toUpperCase()}`; | |
| 107 | +} | |
| 108 | + | |
| 109 | +/** Evaluates `with:` plus defaults from action.yml. */ | |
| 110 | +export function computeInputs(job: JobRunner, x: StepExec, meta: ActionMeta | null): Record<string, string> { | |
| 111 | + const inputs: Record<string, string> = {}; | |
| 112 | + const given = new Map<string, string>(); | |
| 113 | + for (const [k, v] of Object.entries(x.step.with ?? {})) { | |
| 114 | + given.set(k.toLowerCase(), job.interp(v === null || v === undefined ? "" : String(v), x.ctx, x.scope)); | |
| 115 | + inputs[k] = given.get(k.toLowerCase())!; | |
| 116 | + } | |
| 117 | + for (const [k, def] of Object.entries(meta?.inputs ?? {})) { | |
| 118 | + if (given.has(k.toLowerCase())) { | |
| 119 | + // normalise the key to the declared spelling | |
| 120 | + const actual = Object.keys(inputs).find((x) => x.toLowerCase() === k.toLowerCase())!; | |
| 121 | + if (actual !== k) { | |
| 122 | + inputs[k] = inputs[actual]!; | |
| 123 | + delete inputs[actual]; | |
| 124 | + } | |
| 125 | + if (def?.deprecationMessage) job.logger.line(x.io.logIndex, `##[warning]Input '${k}' has been deprecated with message: ${def.deprecationMessage}`); | |
| 126 | + continue; | |
| 127 | + } | |
| 128 | + if (def && def.default !== undefined && def.default !== null) { | |
| 129 | + inputs[k] = job.interp(String(def.default), x.ctx, x.scope); | |
| 130 | + } else if (def?.required) { | |
| 131 | + job.logger.line(x.io.logIndex, `##[warning]Input required and not supplied: ${k}`); | |
| 132 | + } | |
| 133 | + } | |
| 134 | + return inputs; | |
| 135 | +} | |
| 136 | + | |
| 137 | +export async function runAction(job: JobRunner, x: StepExec): Promise<void> { | |
| 138 | + const uses = job.interp(String(x.step.uses), x.ctx, x.scope); | |
| 139 | + const ref = parseUses(uses); | |
| 140 | + if (ref.kind === "docker") throw failure("Docker actions are not supported on Diffinery runners yet"); | |
| 141 | + | |
| 142 | + if (ref.kind === "remote") { | |
| 143 | + const builtin = getBuiltin(ref.name, ref.ref); | |
| 144 | + if (builtin) { | |
| 145 | + const inputs = computeInputs(job, x, null); | |
| 146 | + logActionHeader(job, x.io, uses, inputs); | |
| 147 | + const c = makeBuiltinContext(job, x, inputs, uses); | |
| 148 | + await builtin(c); | |
| 149 | + return; | |
| 150 | + } | |
| 151 | + } | |
| 152 | + | |
| 153 | + let dir: string; | |
| 154 | + let repository: string; | |
| 155 | + let actionRef: string; | |
| 156 | + if (ref.kind === "local") { | |
| 157 | + dir = path.resolve(job.workspace, ref.path); | |
| 158 | + repository = ""; | |
| 159 | + actionRef = ""; | |
| 160 | + } else { | |
| 161 | + const root = await downloadAction(job, x.io, ref.owner, ref.repo, ref.ref); | |
| 162 | + dir = ref.subpath ? path.join(root, ref.subpath) : root; | |
| 163 | + repository = `${ref.owner}/${ref.repo}`; | |
| 164 | + actionRef = ref.ref; | |
| 165 | + } | |
| 166 | + const meta = await loadActionMeta(dir); | |
| 167 | + const using = String(meta.runs.using ?? "").toLowerCase(); | |
| 168 | + const inputs = computeInputs(job, x, meta); | |
| 169 | + logActionHeader(job, x.io, uses, inputs); | |
| 170 | + const actionInfo = { path: dir, repository, ref: actionRef }; | |
| 171 | + | |
| 172 | + if (using === "docker" || meta.runs.image) throw failure("Docker actions are not supported on Diffinery runners yet"); | |
| 173 | + if (using === "composite") { | |
| 174 | + await runComposite(job, x, meta, inputs, actionInfo); | |
| 175 | + return; | |
| 176 | + } | |
| 177 | + if (/^node\d+$/.test(using)) { | |
| 178 | + await runNodeAction(job, x, meta, inputs, actionInfo); | |
| 179 | + return; | |
| 180 | + } | |
| 181 | + throw failure(`Unsupported action runtime 'runs.using: ${meta.runs.using}' in ${uses}`); | |
| 182 | +} | |
| 183 | + | |
| 184 | +function logActionHeader(job: JobRunner, io: StepIO, uses: string, inputs: Record<string, string>): void { | |
| 185 | + job.logger.line(io.logIndex, `##[group]Run ${uses}`); | |
| 186 | + const keys = Object.keys(inputs); | |
| 187 | + if (keys.length) { | |
| 188 | + job.logger.line(io.logIndex, "with:"); | |
| 189 | + for (const k of keys) job.logger.line(io.logIndex, ` ${k}: ${inputs[k]!.split("\n").join("\n ")}`); | |
| 190 | + } | |
| 191 | + job.logger.line(io.logIndex, "##[endgroup]"); | |
| 192 | +} | |
| 193 | + | |
| 194 | +async function runNodeAction( | |
| 195 | + job: JobRunner, | |
| 196 | + x: StepExec, | |
| 197 | + meta: ActionMeta, | |
| 198 | + inputs: Record<string, string>, | |
| 199 | + action: { path: string; repository: string; ref: string }, | |
| 200 | +): Promise<void> { | |
| 201 | + const runs = meta.runs; | |
| 202 | + if (!runs.main) throw failure(`Action '${x.step.uses}' has no 'runs.main'`); | |
| 203 | + const inputEnv: Record<string, string> = {}; | |
| 204 | + for (const [k, v] of Object.entries(inputs)) inputEnv[inputEnvName(k)] = v; | |
| 205 | + const baseExtra: Record<string, string> = { | |
| 206 | + ...inputEnv, | |
| 207 | + GITHUB_ACTION: x.step.id ?? "__" + (action.repository || "local").replace(/[^A-Za-z0-9]/g, "_"), | |
| 208 | + GITHUB_ACTION_PATH: action.path, | |
| 209 | + GITHUB_ACTION_REPOSITORY: action.repository, | |
| 210 | + GITHUB_ACTION_REF: action.ref, | |
| 211 | + }; | |
| 212 | + const state: Record<string, string> = {}; | |
| 213 | + const runScriptFile = async (file: string, io: StepIO, envCtx: Record<string, string>, extra: Record<string, string>, label: string) => { | |
| 214 | + const res = await job.runStepProcess([process.execPath, path.join(action.path, file)], { | |
| 215 | + cwd: job.workspace, | |
| 216 | + envCtx, | |
| 217 | + io, | |
| 218 | + timeoutMs: x.timeoutMs, | |
| 219 | + extraEnv: extra, | |
| 220 | + }); | |
| 221 | + if (job.isStopping) return; | |
| 222 | + if (res.timedOut) throw failure(io.reason ?? "The step timed out.", true); | |
| 223 | + if (res.exitCode !== 0) { | |
| 224 | + const msg = `${label}: Process completed with exit code ${res.exitCode}.`; | |
| 225 | + job.logger.line(io.logIndex, `##[error]${msg}`); | |
| 226 | + job.addAnnotation(io.logIndex, { level: "error", message: msg }); | |
| 227 | + io.reason = msg; | |
| 228 | + throw failure(msg, true); | |
| 229 | + } | |
| 230 | + }; | |
| 231 | + | |
| 232 | + const ctxScope = x.scope; | |
| 233 | + if (runs.pre) { | |
| 234 | + const cond = runs["pre-if"] ?? "always()"; | |
| 235 | + let ok = true; | |
| 236 | + try { | |
| 237 | + ok = job.evalIf(cond, x.ctx, ctxScope); | |
| 238 | + } catch (e) { | |
| 239 | + throw failure(`Error evaluating pre-if: ${errMsg(e)}`); | |
| 240 | + } | |
| 241 | + if (ok) { | |
| 242 | + const preIo: StepIO = { logIndex: x.io.logIndex, outputs: {}, state: {} }; | |
| 243 | + job.logger.line(x.io.logIndex, `Running pre: ${runs.pre}`); | |
| 244 | + await runScriptFile(runs.pre, preIo, x.envCtx, baseExtra, "pre"); | |
| 245 | + Object.assign(state, preIo.state); | |
| 246 | + } | |
| 247 | + } | |
| 248 | + if (runs.post) { | |
| 249 | + const postFile = runs.post; | |
| 250 | + const envAtMain = { ...x.envCtx }; | |
| 251 | + job.registerPost({ | |
| 252 | + name: `Post ${x.name}`, | |
| 253 | + condition: runs["post-if"] ?? "always()", | |
| 254 | + run: async (logIndex) => { | |
| 255 | + const stateEnv: Record<string, string> = {}; | |
| 256 | + for (const [k, v] of Object.entries(state)) stateEnv[`STATE_${k}`] = v; | |
| 257 | + const postIo: StepIO = { logIndex, outputs: {}, state: {} }; | |
| 258 | + await runScriptFile(postFile, postIo, { ...envAtMain, ...job.envAccum }, { ...baseExtra, ...stateEnv }, "post"); | |
| 259 | + }, | |
| 260 | + }); | |
| 261 | + } | |
| 262 | + const mainIo = x.io; | |
| 263 | + try { | |
| 264 | + await runScriptFile(runs.main, mainIo, x.envCtx, { ...baseExtra, ...stateEnvOf(state) }, "main"); | |
| 265 | + } finally { | |
| 266 | + Object.assign(state, mainIo.state); | |
| 267 | + } | |
| 268 | +} | |
| 269 | + | |
| 270 | +function stateEnvOf(state: Record<string, string>): Record<string, string> { | |
| 271 | + const out: Record<string, string> = {}; | |
| 272 | + for (const [k, v] of Object.entries(state)) out[`STATE_${k}`] = v; | |
| 273 | + return out; | |
| 274 | +} | |
| 275 | + | |
| 276 | +async function runComposite( | |
| 277 | + job: JobRunner, | |
| 278 | + x: StepExec, | |
| 279 | + meta: ActionMeta, | |
| 280 | + inputs: Record<string, string>, | |
| 281 | + action: { path: string; repository: string; ref: string }, | |
| 282 | +): Promise<void> { | |
| 283 | + if (x.scope.depth > 9) throw failure("Composite action nesting is too deep (max 10)."); | |
| 284 | + const steps = Array.isArray(meta.runs.steps) ? meta.runs.steps : []; | |
| 285 | + const scope: Scope = { | |
| 286 | + steps: {}, | |
| 287 | + inputs, | |
| 288 | + failed: false, | |
| 289 | + inheritedEnv: { ...x.envCtx }, | |
| 290 | + composite: true, | |
| 291 | + action, | |
| 292 | + depth: x.scope.depth + 1, | |
| 293 | + }; | |
| 294 | + for (const s of steps) { | |
| 295 | + if (job.isStopping) break; | |
| 296 | + const sub = normaliseStep(s); | |
| 297 | + await job.executeStep(sub, scope, x.io.logIndex); | |
| 298 | + } | |
| 299 | + // outputs | |
| 300 | + const ctx = job.contexts(scope, job.baseEnvContext(scope)); | |
| 301 | + for (const [k, o] of Object.entries(meta.outputs ?? {})) { | |
| 302 | + if (!o || o.value === undefined || o.value === null) continue; | |
| 303 | + try { | |
| 304 | + x.io.outputs[k] = job.interp(String(o.value), ctx, scope); | |
| 305 | + } catch (e) { | |
| 306 | + job.logger.line(x.io.logIndex, `##[warning]Failed to evaluate output '${k}': ${errMsg(e)}`); | |
| 307 | + } | |
| 308 | + } | |
| 309 | + if (job.isStopping) return; | |
| 310 | + if (scope.failed) throw failure(`Composite action '${x.step.uses}' failed.`, true); | |
| 311 | +} | |
| 312 | + | |
| 313 | +function normaliseStep(s: unknown): StepSpec { | |
| 314 | + const o = (s ?? {}) as Record<string, unknown>; | |
| 315 | + const step: StepSpec = {}; | |
| 316 | + if (o.id !== undefined) step.id = String(o.id); | |
| 317 | + if (o.name !== undefined) step.name = String(o.name); | |
| 318 | + if (o.if !== undefined && o.if !== null) step.if = typeof o.if === "boolean" ? String(o.if) : String(o.if); | |
| 319 | + if (o.run !== undefined) step.run = String(o.run); | |
| 320 | + if (o.uses !== undefined) step.uses = String(o.uses); | |
| 321 | + if (o.shell !== undefined) step.shell = String(o.shell); | |
| 322 | + if (o["working-directory"] !== undefined) step["working-directory"] = String(o["working-directory"]); | |
| 323 | + if (o["continue-on-error"] !== undefined) step["continue-on-error"] = o["continue-on-error"] as boolean | string; | |
| 324 | + if (o["timeout-minutes"] !== undefined) step["timeout-minutes"] = Number(o["timeout-minutes"]); | |
| 325 | + const strMap = (m: unknown) => { | |
| 326 | + const r: Record<string, string> = {}; | |
| 327 | + if (m && typeof m === "object") for (const [k, v] of Object.entries(m)) r[k] = v === null || v === undefined ? "" : String(v); | |
| 328 | + return r; | |
| 329 | + }; | |
| 330 | + if (o.with) step.with = strMap(o.with); | |
| 331 | + if (o.env) step.env = strMap(o.env); | |
| 332 | + return step; | |
| 333 | +} |
addedcontainers/runner/src/steps/run.ts+114−0View file
| @@ -1,0 +1,114 @@ | ||
| 1 | +import { randomUUID } from "node:crypto"; | |
| 2 | +import { writeFile } from "node:fs/promises"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import type { StepSpec } from "@diffinery/shared"; | |
| 5 | +import { failure, type JobRunner, type Scope, type StepIO } from "../job.js"; | |
| 6 | +import { shellWords, which } from "../util.js"; | |
| 7 | + | |
| 8 | +export interface StepExec { | |
| 9 | + step: StepSpec; | |
| 10 | + scope: Scope; | |
| 11 | + ctx: Record<string, unknown>; | |
| 12 | + envCtx: Record<string, string>; | |
| 13 | + io: StepIO; | |
| 14 | + timeoutMs: number; | |
| 15 | + name: string; | |
| 16 | +} | |
| 17 | + | |
| 18 | +interface ShellPlan { | |
| 19 | + argv: string[]; | |
| 20 | + ext: string; | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** Resolves a `shell:` value to an argv (with `{0}` replaced by the script path). */ | |
| 24 | +export function resolveShell(shell: string | undefined, scriptPath: string, pathEnv: string | undefined): ShellPlan { | |
| 25 | + const s = (shell ?? "").trim(); | |
| 26 | + const sub = (tpl: string, ext: string): ShellPlan => { | |
| 27 | + const words = shellWords(tpl).map((w) => w.split("{0}").join(scriptPath)); | |
| 28 | + return { argv: words, ext }; | |
| 29 | + }; | |
| 30 | + if (s === "" ) { | |
| 31 | + if (which("bash", pathEnv)) return sub("bash --noprofile --norc -eo pipefail {0}", ".sh"); | |
| 32 | + return sub("sh -e {0}", ".sh"); | |
| 33 | + } | |
| 34 | + switch (s) { | |
| 35 | + case "bash": | |
| 36 | + return sub("bash --noprofile --norc -eo pipefail {0}", ".sh"); | |
| 37 | + case "sh": | |
| 38 | + return sub("sh -e {0}", ".sh"); | |
| 39 | + case "python": { | |
| 40 | + const py = which("python", pathEnv) ? "python" : "python3"; | |
| 41 | + return sub(`${py} {0}`, ".py"); | |
| 42 | + } | |
| 43 | + case "pwsh": | |
| 44 | + return sub(`pwsh -command ". '{0}'"`, ".ps1"); | |
| 45 | + case "powershell": | |
| 46 | + return sub(`${which("powershell", pathEnv) ? "powershell" : "pwsh"} -command ". '{0}'"`, ".ps1"); | |
| 47 | + case "cmd": | |
| 48 | + throw failure("The `cmd` shell is not supported on Linux runners."); | |
| 49 | + } | |
| 50 | + if (!s.includes("{0}")) { | |
| 51 | + throw failure(`Invalid shell option '${s}'. Shell must be a valid built-in (bash, sh, python, pwsh) or a format string containing '{0}'`); | |
| 52 | + } | |
| 53 | + const first = shellWords(s)[0] ?? ""; | |
| 54 | + const ext = /python/.test(first) ? ".py" : /pwsh|powershell/.test(first) ? ".ps1" : /node/.test(first) ? ".js" : ".sh"; | |
| 55 | + return sub(s, ext); | |
| 56 | +} | |
| 57 | + | |
| 58 | +export function resolveWorkingDirectory(job: JobRunner, wd: string | undefined): string { | |
| 59 | + if (!wd) return job.workspace; | |
| 60 | + return path.isAbsolute(wd) ? wd : path.join(job.workspace, wd); | |
| 61 | +} | |
| 62 | + | |
| 63 | +export async function runScript(job: JobRunner, x: StepExec): Promise<void> { | |
| 64 | + const { step, scope, ctx, envCtx, io } = x; | |
| 65 | + const script = job.interp(String(step.run ?? ""), ctx, scope); | |
| 66 | + const defaults = scope.composite ? undefined : job.spec.defaults?.run; | |
| 67 | + const shellRaw = step.shell ?? defaults?.shell; | |
| 68 | + const shell = shellRaw ? job.interp(String(shellRaw), ctx, scope) : undefined; | |
| 69 | + const wdRaw = step["working-directory"] ?? defaults?.["working-directory"]; | |
| 70 | + const wd = resolveWorkingDirectory(job, wdRaw ? job.interp(String(wdRaw), ctx, scope) : undefined); | |
| 71 | + | |
| 72 | + // Log a GitHub-like header | |
| 73 | + const lines = script.replace(/\n$/, "").split("\n"); | |
| 74 | + job.logger.line(io.logIndex, `##[group]Run ${lines[0] ?? ""}`); | |
| 75 | + for (const l of lines.slice(1)) job.logger.line(io.logIndex, l); | |
| 76 | + const shown = { ...(step.env ?? {}) }; | |
| 77 | + const envKeys = Object.keys(shown); | |
| 78 | + if (shell) job.logger.line(io.logIndex, `shell: ${shell}`); | |
| 79 | + if (envKeys.length) { | |
| 80 | + job.logger.line(io.logIndex, "env:"); | |
| 81 | + for (const k of envKeys) job.logger.line(io.logIndex, ` ${k}: ${envCtx[k] ?? ""}`); | |
| 82 | + } | |
| 83 | + job.logger.line(io.logIndex, "##[endgroup]"); | |
| 84 | + | |
| 85 | + const tmpBase = path.join(job.tempDir, randomUUID()); | |
| 86 | + const pathEnv = job.processEnv(envCtx).PATH; | |
| 87 | + const probe = resolveShell(shell, tmpBase, pathEnv); | |
| 88 | + const scriptPath = tmpBase + probe.ext; | |
| 89 | + const plan = resolveShell(shell, scriptPath, pathEnv); | |
| 90 | + let content = script; | |
| 91 | + if (probe.ext === ".ps1") { | |
| 92 | + content = `$ErrorActionPreference = 'stop'\n${script}\nif ((Test-Path -LiteralPath variable:\\LASTEXITCODE)) { exit $LASTEXITCODE }\n`; | |
| 93 | + } | |
| 94 | + await writeFile(scriptPath, content, { mode: 0o755 }); | |
| 95 | + if (!which(plan.argv[0]!, pathEnv)) { | |
| 96 | + throw failure(`${plan.argv[0]}: command not found. The shell '${shell ?? "bash"}' is not available on this runner.`); | |
| 97 | + } | |
| 98 | + const extraEnv: Record<string, string> = { GITHUB_ACTION: step.id ?? "__run" }; | |
| 99 | + if (scope.action) { | |
| 100 | + extraEnv.GITHUB_ACTION_PATH = scope.action.path; | |
| 101 | + extraEnv.GITHUB_ACTION_REPOSITORY = scope.action.repository; | |
| 102 | + extraEnv.GITHUB_ACTION_REF = scope.action.ref; | |
| 103 | + } | |
| 104 | + const res = await job.runStepProcess(plan.argv, { cwd: wd, envCtx, io, timeoutMs: x.timeoutMs, extraEnv }); | |
| 105 | + if (job.isStopping) return; | |
| 106 | + if (res.timedOut) throw failure(io.reason ?? "The step timed out.", true); | |
| 107 | + if (res.exitCode !== 0) { | |
| 108 | + const msg = `Process completed with exit code ${res.exitCode}.`; | |
| 109 | + job.logger.line(io.logIndex, `##[error]${msg}`); | |
| 110 | + job.addAnnotation(io.logIndex, { level: "error", message: msg }); | |
| 111 | + io.reason = msg; | |
| 112 | + throw failure(msg, true); | |
| 113 | + } | |
| 114 | +} |
addedcontainers/runner/test/helpers.ts+157−0View file
| @@ -1,0 +1,157 @@ | ||
| 1 | +import { mkdtempSync } from "node:fs"; | |
| 2 | +import { createServer, type Server } from "node:http"; | |
| 3 | +import type { AddressInfo } from "node:net"; | |
| 4 | +import { tmpdir } from "node:os"; | |
| 5 | +import path from "node:path"; | |
| 6 | +import type { JobCompletion, JobSpec, LogLine, StepReport, StepSpec } from "@diffinery/shared"; | |
| 7 | + | |
| 8 | +export interface FakeApi { | |
| 9 | + url: string; | |
| 10 | + token: string; | |
| 11 | + logs: LogLine[]; | |
| 12 | + steps: Array<{ index: number; report: StepReport }>; | |
| 13 | + completion: JobCompletion | null; | |
| 14 | + cache: Map<string, Buffer>; | |
| 15 | + artifacts: Map<string, Buffer>; | |
| 16 | + requests: Array<{ method: string; path: string; auth: string | undefined }>; | |
| 17 | + /** Fail the next N log POSTs with 503 (to test retries). */ | |
| 18 | + failLogs: number; | |
| 19 | + specs: Map<string, JobSpec>; | |
| 20 | + close(): Promise<void>; | |
| 21 | + logText(step?: number): string; | |
| 22 | + lastReport(index: number): StepReport | undefined; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export async function startFakeApi(token = "job-token-123"): Promise<FakeApi> { | |
| 26 | + const api: FakeApi = { | |
| 27 | + url: "", | |
| 28 | + token, | |
| 29 | + logs: [], | |
| 30 | + steps: [], | |
| 31 | + completion: null, | |
| 32 | + cache: new Map(), | |
| 33 | + artifacts: new Map(), | |
| 34 | + requests: [], | |
| 35 | + failLogs: 0, | |
| 36 | + specs: new Map(), | |
| 37 | + close: async () => {}, | |
| 38 | + logText(step?: number) { | |
| 39 | + return this.logs | |
| 40 | + .filter((l) => step === undefined || l.step === step) | |
| 41 | + .map((l) => l.text) | |
| 42 | + .join("\n"); | |
| 43 | + }, | |
| 44 | + lastReport(index: number) { | |
| 45 | + return [...this.steps].reverse().find((s) => s.index === index)?.report; | |
| 46 | + }, | |
| 47 | + }; | |
| 48 | + const server: Server = createServer(async (req, res) => { | |
| 49 | + const chunks: Buffer[] = []; | |
| 50 | + for await (const c of req) chunks.push(c as Buffer); | |
| 51 | + const body = Buffer.concat(chunks); | |
| 52 | + const u = new URL(req.url ?? "/", "http://x"); | |
| 53 | + api.requests.push({ method: req.method!, path: u.pathname + u.search, auth: req.headers.authorization }); | |
| 54 | + const json = (status: number, v: unknown) => { | |
| 55 | + res.writeHead(status, { "content-type": "application/json" }); | |
| 56 | + res.end(JSON.stringify(v)); | |
| 57 | + }; | |
| 58 | + if (req.headers.authorization !== `Bearer ${token}`) return json(401, { error: "unauthorized" }); | |
| 59 | + const m = /^\/runner\/jobs\/([^/]+)(\/.*)?$/.exec(u.pathname); | |
| 60 | + if (!m) return json(404, {}); | |
| 61 | + const sub = m[2] ?? ""; | |
| 62 | + if (sub === "" && req.method === "GET") { | |
| 63 | + const s = api.specs.get(decodeURIComponent(m[1]!)); | |
| 64 | + return s ? json(200, s) : json(404, {}); | |
| 65 | + } | |
| 66 | + if (sub === "/logs" && req.method === "POST") { | |
| 67 | + if (api.failLogs > 0) { | |
| 68 | + api.failLogs--; | |
| 69 | + return json(503, { error: "try later" }); | |
| 70 | + } | |
| 71 | + api.logs.push(...(JSON.parse(body.toString()) as { lines: LogLine[] }).lines); | |
| 72 | + return json(200, {}); | |
| 73 | + } | |
| 74 | + const sm = /^\/steps\/(\d+)$/.exec(sub); | |
| 75 | + if (sm && req.method === "POST") { | |
| 76 | + api.steps.push({ index: Number(sm[1]), report: JSON.parse(body.toString()) as StepReport }); | |
| 77 | + return json(200, {}); | |
| 78 | + } | |
| 79 | + if (sub === "/complete" && req.method === "POST") { | |
| 80 | + api.completion = JSON.parse(body.toString()) as JobCompletion; | |
| 81 | + return json(200, {}); | |
| 82 | + } | |
| 83 | + if (sub === "/cache" && req.method === "GET") { | |
| 84 | + const key = u.searchParams.get("key")!; | |
| 85 | + if (api.cache.has(key)) return json(200, { key, url: `${m[0].replace(/\/cache$/, "")}/cache/${encodeURIComponent(key)}` }); | |
| 86 | + for (const prefix of (u.searchParams.get("restore") ?? "").split(",").filter(Boolean)) { | |
| 87 | + const hit = [...api.cache.keys()].find((k) => k.startsWith(prefix)); | |
| 88 | + if (hit) return json(200, { key: hit, url: `/runner/jobs/${m[1]}/cache/${encodeURIComponent(hit)}` }); | |
| 89 | + } | |
| 90 | + return json(404, {}); | |
| 91 | + } | |
| 92 | + const cm = /^\/cache\/(.+)$/.exec(sub); | |
| 93 | + if (cm) { | |
| 94 | + const key = decodeURIComponent(cm[1]!); | |
| 95 | + if (req.method === "PUT") { | |
| 96 | + if (api.cache.has(key)) return json(409, {}); | |
| 97 | + api.cache.set(key, body); | |
| 98 | + return json(201, {}); | |
| 99 | + } | |
| 100 | + const b = api.cache.get(key); | |
| 101 | + if (!b) return json(404, {}); | |
| 102 | + res.writeHead(200, { "content-type": "application/gzip" }); | |
| 103 | + return res.end(b); | |
| 104 | + } | |
| 105 | + const am = /^\/artifacts\/(.+)$/.exec(sub); | |
| 106 | + if (am) { | |
| 107 | + const name = decodeURIComponent(am[1]!); | |
| 108 | + if (req.method === "PUT") { | |
| 109 | + api.artifacts.set(name, body); | |
| 110 | + return json(201, { id: name.length, url: `https://example.test/artifacts/${name}` }); | |
| 111 | + } | |
| 112 | + const b = api.artifacts.get(name); | |
| 113 | + if (!b) return json(404, {}); | |
| 114 | + res.writeHead(200, { "content-type": "application/gzip" }); | |
| 115 | + return res.end(b); | |
| 116 | + } | |
| 117 | + if (sub === "/oidc") return json(200, { value: `oidc-for-${u.searchParams.get("audience")}` }); | |
| 118 | + return json(404, {}); | |
| 119 | + }); | |
| 120 | + await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r())); | |
| 121 | + api.url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; | |
| 122 | + api.close = () => new Promise<void>((r) => server.close(() => r())); | |
| 123 | + return api; | |
| 124 | +} | |
| 125 | + | |
| 126 | +export function tmp(prefix = "runner-test-"): string { | |
| 127 | + return mkdtempSync(path.join(tmpdir(), prefix)); | |
| 128 | +} | |
| 129 | + | |
| 130 | +export function makeSpec(api: FakeApi, steps: StepSpec[], over: Partial<JobSpec> = {}): JobSpec { | |
| 131 | + return { | |
| 132 | + runId: "run-1", | |
| 133 | + jobId: "job-1", | |
| 134 | + jobKey: "build", | |
| 135 | + name: "build", | |
| 136 | + repo: { owner: "acme", name: "widgets", cloneUrl: "http://invalid.test/acme/widgets.git", defaultBranch: "main" }, | |
| 137 | + sha: "0123456789abcdef0123456789abcdef01234567", | |
| 138 | + ref: "refs/heads/main", | |
| 139 | + workflowPath: ".github/workflows/ci.yml", | |
| 140 | + github: { event_name: "push", actor: "octo", event: { ref: "refs/heads/main", pusher: { name: "octo" } } }, | |
| 141 | + env: {}, | |
| 142 | + matrix: {}, | |
| 143 | + strategy: { "fail-fast": true, "job-index": 0, "job-total": 1 }, | |
| 144 | + needs: {}, | |
| 145 | + inputs: {}, | |
| 146 | + vars: {}, | |
| 147 | + secrets: {}, | |
| 148 | + outputs: {}, | |
| 149 | + steps, | |
| 150 | + timeoutMinutes: 10, | |
| 151 | + continueOnError: false, | |
| 152 | + idToken: false, | |
| 153 | + apiUrl: api.url, | |
| 154 | + token: api.token, | |
| 155 | + ...over, | |
| 156 | + }; | |
| 157 | +} |
addedcontainers/runner/test/job.test.ts+522−0View file
| @@ -1,0 +1,522 @@ | ||
| 1 | +import { execFileSync } from "node:child_process"; | |
| 2 | +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 3 | +import path from "node:path"; | |
| 4 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; | |
| 5 | +import type { JobSpec } from "@diffinery/shared"; | |
| 6 | +import { apiBaseFor, runAgentJob } from "../src/agent.js"; | |
| 7 | +import type { JobRunner } from "../src/job.js"; | |
| 8 | +import { makeSpec, startFakeApi, tmp, type FakeApi } from "./helpers.js"; | |
| 9 | + | |
| 10 | +let api: FakeApi; | |
| 11 | +let workRoot: string; | |
| 12 | + | |
| 13 | +beforeEach(async () => { | |
| 14 | + api = await startFakeApi(); | |
| 15 | + workRoot = tmp(); | |
| 16 | +}); | |
| 17 | +afterEach(async () => { | |
| 18 | + await api.close(); | |
| 19 | + rmSync(workRoot, { recursive: true, force: true }); | |
| 20 | +}); | |
| 21 | + | |
| 22 | +function run(spec: JobSpec, extra: { onRunner?: (r: JobRunner) => void } = {}) { | |
| 23 | + return runAgentJob(spec, { | |
| 24 | + apiBase: apiBaseFor(spec.apiUrl, spec.jobId), | |
| 25 | + workRoot, | |
| 26 | + toolCache: path.join(workRoot, "_tool"), | |
| 27 | + logIntervalMs: 50, | |
| 28 | + echoLogs: false, | |
| 29 | + ...extra, | |
| 30 | + }); | |
| 31 | +} | |
| 32 | + | |
| 33 | +const workspace = () => path.join(workRoot, "widgets"); | |
| 34 | + | |
| 35 | +describe("job runner end-to-end", () => { | |
| 36 | + it("propagates env, outputs, path, summaries and job outputs", async () => { | |
| 37 | + const spec = makeSpec( | |
| 38 | + api, | |
| 39 | + [ | |
| 40 | + { | |
| 41 | + id: "one", | |
| 42 | + name: "Step ${{ github.job }} one", | |
| 43 | + run: [ | |
| 44 | + 'echo "hello from step one"', | |
| 45 | + 'echo "FOO=bar" >> "$GITHUB_ENV"', | |
| 46 | + '{ echo "MULTI<<EOF"; echo "line1"; echo "line2"; echo "EOF"; } >> "$GITHUB_ENV"', | |
| 47 | + 'echo "greeting=hi there" >> "$GITHUB_OUTPUT"', | |
| 48 | + 'mkdir -p "$RUNNER_TEMP/mybin" "$GITHUB_WORKSPACE/sub"', | |
| 49 | + `printf '#!/bin/sh\\necho mytool-ran\\n' > "$RUNNER_TEMP/mybin/mytool"`, | |
| 50 | + 'chmod +x "$RUNNER_TEMP/mybin/mytool"', | |
| 51 | + 'echo "$RUNNER_TEMP/mybin" >> "$GITHUB_PATH"', | |
| 52 | + 'echo "::set-output name=legacy::old-style"', | |
| 53 | + 'echo "# Summary one" >> "$GITHUB_STEP_SUMMARY"', | |
| 54 | + ].join("\n"), | |
| 55 | + }, | |
| 56 | + { | |
| 57 | + id: "two", | |
| 58 | + env: { STEP_VAR: "${{ steps.one.outputs.greeting }}", FOO: "${{ env.FOO }}-override" }, | |
| 59 | + run: [ | |
| 60 | + 'echo "foo=$FOO"', | |
| 61 | + 'echo "multi=$(echo "$MULTI" | tr "\\n" ",")"', | |
| 62 | + "mytool", | |
| 63 | + 'echo "step_var=$STEP_VAR job_var=$JOB_VAR matrix=${{ matrix.os }}"', | |
| 64 | + 'echo "ci=$CI actions=$GITHUB_ACTIONS diffinery=$DIFFINERY repo=$GITHUB_REPOSITORY ref_name=$GITHUB_REF_NAME type=$GITHUB_REF_TYPE"', | |
| 65 | + 'echo "server=$GITHUB_SERVER_URL api=$GITHUB_API_URL os=$RUNNER_OS job=$GITHUB_JOB actor=$GITHUB_ACTOR"', | |
| 66 | + 'echo "event=$(cat "$GITHUB_EVENT_PATH" | tr -d " \\n")"', | |
| 67 | + 'echo "## Summary two" >> "$GITHUB_STEP_SUMMARY"', | |
| 68 | + ].join("\n"), | |
| 69 | + }, | |
| 70 | + { name: "conditional", if: "steps.one.outputs.greeting == 'hi there'", run: 'echo "conditional ran with $FOO"' }, | |
| 71 | + { name: "never", if: "steps.one.outputs.greeting == 'nope'", run: 'echo "SHOULD NOT RUN"' }, | |
| 72 | + { name: "wd", "working-directory": "sub", run: 'echo "pwd=$(pwd)"' }, | |
| 73 | + { name: "py", shell: "python", run: 'import os\nprint("py-foo=" + os.environ["FOO"])' }, | |
| 74 | + { name: "custom", shell: "sh -e {0}", run: 'echo "custom shell ok"' }, | |
| 75 | + ], | |
| 76 | + { | |
| 77 | + env: { JOB_VAR: "${{ github.repository }}-x" }, | |
| 78 | + matrix: { os: "linux" }, | |
| 79 | + outputs: { greeting: "${{ steps.one.outputs.greeting }}", legacy: "${{ steps.one.outputs.legacy }}", combo: "${{ steps.two.outcome }}/${{ env.FOO }}" }, | |
| 80 | + }, | |
| 81 | + ); | |
| 82 | + const c = await run(spec); | |
| 83 | + const log = api.logText(); | |
| 84 | + expect(c.conclusion, log).toBe("success"); | |
| 85 | + expect(api.completion).toEqual(c); | |
| 86 | + expect(c.outputs).toEqual({ greeting: "hi there", legacy: "old-style", combo: "success/bar" }); | |
| 87 | + expect(c.summary).toContain("# Summary one"); | |
| 88 | + expect(c.summary).toContain("## Summary two"); | |
| 89 | + | |
| 90 | + const s1 = api.logText(1); | |
| 91 | + expect(s1).toContain("foo=bar-override"); | |
| 92 | + expect(s1).toContain("multi=line1,line2,"); | |
| 93 | + expect(s1).toContain("mytool-ran"); | |
| 94 | + expect(s1).toContain("step_var=hi there job_var=acme/widgets-x matrix=linux"); | |
| 95 | + expect(s1).toContain("ci=true actions=true diffinery=true repo=acme/widgets ref_name=main type=branch"); | |
| 96 | + expect(s1).toContain(`server=${api.url} api=${api.url}/api/v3 os=Linux job=build actor=octo`); | |
| 97 | + expect(s1).toContain('event={"ref":"refs/heads/main","pusher":{"name":"octo"}}'); | |
| 98 | + expect(api.logText(2)).toContain("conditional ran with bar"); | |
| 99 | + expect(log).not.toContain("SHOULD NOT RUN"); | |
| 100 | + expect(api.logText(4)).toContain(`pwd=${path.join(workspace(), "sub")}`); | |
| 101 | + expect(api.logText(5)).toContain("py-foo=bar"); | |
| 102 | + expect(api.logText(6)).toContain("custom shell ok"); | |
| 103 | + | |
| 104 | + expect(api.lastReport(0)).toMatchObject({ status: "completed", conclusion: "success", name: "Step build one" }); | |
| 105 | + expect(api.lastReport(3)).toMatchObject({ status: "completed", conclusion: "skipped" }); | |
| 106 | + expect(api.steps.find((s) => s.index === 0 && s.report.status === "in_progress")).toBeTruthy(); | |
| 107 | + expect(api.steps.find((s) => s.index === 3 && s.report.status === "in_progress")).toBeFalsy(); | |
| 108 | + // all requests authenticated with the job token | |
| 109 | + expect(api.requests.every((r) => r.auth === `Bearer ${api.token}`)).toBe(true); | |
| 110 | + }); | |
| 111 | + | |
| 112 | + it("handles failures, continue-on-error, failure() and always()", async () => { | |
| 113 | + const spec = makeSpec(api, [ | |
| 114 | + { id: "ok", run: "echo ok" }, | |
| 115 | + { id: "soft", "continue-on-error": true, run: "echo soft-failing; exit 3" }, | |
| 116 | + { name: "after-soft", run: 'echo "after soft ${{ steps.soft.outcome }} ${{ steps.soft.conclusion }} ${{ job.status }}"' }, | |
| 117 | + { id: "bad", name: "Compile", run: 'echo "compiling..."\necho "::error file=src/a.ts,line=4::type mismatch"\nexit 1' }, | |
| 118 | + { name: "skipped-by-failure", run: "echo SHOULD-NOT-RUN" }, | |
| 119 | + { name: "on-failure", if: "failure()", run: 'echo "failure handler: ${{ steps.bad.outcome }} ${{ job.status }}"' }, | |
| 120 | + { name: "always", if: "${{ always() }}", run: "echo always-ran" }, | |
| 121 | + { name: "on-success", if: "success()", run: "echo SHOULD-NOT-RUN-2" }, | |
| 122 | + { name: "cancelled-only", if: "cancelled()", run: "echo SHOULD-NOT-RUN-3" }, | |
| 123 | + ]); | |
| 124 | + const c = await run(spec); | |
| 125 | + expect(c.conclusion).toBe("failure"); | |
| 126 | + expect(c.failure).toEqual({ step: 3, message: "src/a.ts:4: type mismatch" }); | |
| 127 | + expect(api.lastReport(1)?.conclusion).toBe("success"); | |
| 128 | + expect(api.logText(2)).toContain("after soft failure success success"); | |
| 129 | + expect(api.lastReport(3)?.conclusion).toBe("failure"); | |
| 130 | + expect(api.lastReport(4)?.conclusion).toBe("skipped"); | |
| 131 | + expect(api.logText(5)).toContain("failure handler: failure failure"); | |
| 132 | + expect(api.lastReport(5)?.conclusion).toBe("success"); | |
| 133 | + expect(api.logText(6)).toContain("always-ran"); | |
| 134 | + expect(api.lastReport(7)?.conclusion).toBe("skipped"); | |
| 135 | + expect(api.lastReport(8)?.conclusion).toBe("skipped"); | |
| 136 | + expect(api.logText()).not.toMatch(/SHOULD-NOT-RUN/); | |
| 137 | + expect(api.logText(3)).toContain("##[error]src/a.ts:4: type mismatch"); | |
| 138 | + expect(api.logText(3)).toContain("##[error]Process completed with exit code 1."); | |
| 139 | + }); | |
| 140 | + | |
| 141 | + it("summarises failures without annotations from the step's last log lines", async () => { | |
| 142 | + const spec = makeSpec(api, [{ run: 'for i in $(seq 1 30); do echo "line $i"; done\necho "fatal: it broke" >&2\nexit 7' }]); | |
| 143 | + const c = await run(spec); | |
| 144 | + expect(c.conclusion).toBe("failure"); | |
| 145 | + expect(c.failure?.step).toBe(0); | |
| 146 | + expect(c.failure?.message).toMatch(/^Process completed with exit code 7\./); | |
| 147 | + expect(c.failure?.message).toContain("fatal: it broke"); | |
| 148 | + expect(c.failure?.message).toContain("line 30"); | |
| 149 | + expect(c.failure?.message).not.toContain("line 5\n"); | |
| 150 | + }); | |
| 151 | + | |
| 152 | + it("masks secrets (incl. multi-line and ::add-mask::) in logs and outputs", async () => { | |
| 153 | + const spec = makeSpec( | |
| 154 | + api, | |
| 155 | + [ | |
| 156 | + { | |
| 157 | + id: "s", | |
| 158 | + env: { TOKEN: "${{ secrets.GITHUB_TOKEN }}", PEM: "${{ secrets.PEM }}" }, | |
| 159 | + run: [ | |
| 160 | + 'echo "key=${{ secrets.API_KEY }}"', | |
| 161 | + 'echo "token=$TOKEN"', | |
| 162 | + 'echo "$PEM"', | |
| 163 | + 'V=dynamic; echo "::add-mask::${V}-secret-42"', | |
| 164 | + 'echo "now ${V}-secret-42 is hidden"', | |
| 165 | + 'echo "leak=${{ secrets.API_KEY }}" >> "$GITHUB_OUTPUT"', | |
| 166 | + 'echo "fine=visible" >> "$GITHUB_OUTPUT"', | |
| 167 | + ].join("\n"), | |
| 168 | + }, | |
| 169 | + ], | |
| 170 | + { | |
| 171 | + secrets: { API_KEY: "s3cr3t-value", PEM: "-----BEGIN-----\nabcdefghijklmnop\n-----END-----" }, | |
| 172 | + outputs: { leak: "${{ steps.s.outputs.leak }}", fine: "${{ steps.s.outputs.fine }}" }, | |
| 173 | + }, | |
| 174 | + ); | |
| 175 | + const c = await run(spec); | |
| 176 | + expect(c.conclusion).toBe("success"); | |
| 177 | + const log = api.logText(); | |
| 178 | + expect(log).not.toContain("s3cr3t-value"); | |
| 179 | + expect(log).not.toContain(api.token); | |
| 180 | + expect(log).not.toContain("abcdefghijklmnop"); | |
| 181 | + expect(log).not.toContain("dynamic-secret-42"); | |
| 182 | + expect(log).toContain("key=***"); | |
| 183 | + expect(log).toContain("token=***"); | |
| 184 | + expect(log).toContain("now *** is hidden"); | |
| 185 | + expect(c.outputs).toEqual({ fine: "visible" }); | |
| 186 | + }); | |
| 187 | + | |
| 188 | + it("interprets workflow commands", async () => { | |
| 189 | + const spec = makeSpec( | |
| 190 | + api, | |
| 191 | + [ | |
| 192 | + { | |
| 193 | + run: [ | |
| 194 | + 'echo "::group::My Group"', | |
| 195 | + "echo inside", | |
| 196 | + 'echo "::endgroup::"', | |
| 197 | + 'echo "::warning title=Heads up::be careful"', | |
| 198 | + 'echo "::notice::multi%0Aline"', | |
| 199 | + 'echo "::debug::hidden debug"', | |
| 200 | + 'T=pause123; echo "::stop-commands::$T"', | |
| 201 | + 'echo "::warning::not a command"', | |
| 202 | + 'echo "::$T::"', | |
| 203 | + 'echo "::warning::real again"', | |
| 204 | + 'echo "::save-state name=x::y"', | |
| 205 | + ].join("\n"), | |
| 206 | + }, | |
| 207 | + ], | |
| 208 | + {}, | |
| 209 | + ); | |
| 210 | + const c = await run(spec); | |
| 211 | + expect(c.conclusion).toBe("success"); | |
| 212 | + const lines = api.logs.filter((l) => l.step === 0).map((l) => l.text); | |
| 213 | + const after = lines.slice(lines.indexOf("##[endgroup]") + 1); | |
| 214 | + expect(after).toEqual([ | |
| 215 | + "##[group]My Group", | |
| 216 | + "inside", | |
| 217 | + "##[endgroup]", | |
| 218 | + "##[warning]Heads up: be careful", | |
| 219 | + "##[notice]multi", | |
| 220 | + "line", | |
| 221 | + "::warning::not a command", | |
| 222 | + "##[warning]real again", | |
| 223 | + ]); | |
| 224 | + }); | |
| 225 | + | |
| 226 | + it("shows ::debug:: when ACTIONS_STEP_DEBUG is set", async () => { | |
| 227 | + const spec = makeSpec(api, [{ run: 'echo "::debug::now visible"; echo "dbg=$RUNNER_DEBUG"' }], { secrets: { ACTIONS_STEP_DEBUG: "true" } }); | |
| 228 | + await run(spec); | |
| 229 | + expect(api.logText(0)).toContain("##[debug]now visible"); | |
| 230 | + expect(api.logText(0)).toContain("dbg=1"); | |
| 231 | + }); | |
| 232 | + | |
| 233 | + it("runs composite and node local actions with pre/post and state", async () => { | |
| 234 | + const ws = workspace(); | |
| 235 | + mkdirSync(path.join(ws, ".github/actions/greet"), { recursive: true }); | |
| 236 | + mkdirSync(path.join(ws, ".github/actions/nodey"), { recursive: true }); | |
| 237 | + writeFileSync( | |
| 238 | + path.join(ws, ".github/actions/greet/action.yml"), | |
| 239 | + `name: Greet | |
| 240 | +description: composite greet | |
| 241 | +inputs: | |
| 242 | + who: | |
| 243 | + description: who | |
| 244 | + required: true | |
| 245 | + punct: | |
| 246 | + default: "!" | |
| 247 | +outputs: | |
| 248 | + message: | |
| 249 | + description: msg | |
| 250 | + value: \${{ steps.say.outputs.msg }} | |
| 251 | +runs: | |
| 252 | + using: composite | |
| 253 | + steps: | |
| 254 | + - id: say | |
| 255 | + shell: bash | |
| 256 | + run: | | |
| 257 | + echo "Hello \${{ inputs.who }}\${{ inputs.punct }}" | |
| 258 | + echo "msg=Hello \${{ inputs.who }}" >> "$GITHUB_OUTPUT" | |
| 259 | + echo "action path is $GITHUB_ACTION_PATH" | |
| 260 | + echo "inherited=$OUTER" | |
| 261 | + - name: nested if | |
| 262 | + if: \${{ inputs.who == 'nobody' }} | |
| 263 | + shell: bash | |
| 264 | + run: echo "SHOULD-NOT-RUN" | |
| 265 | + - shell: bash | |
| 266 | + run: echo "COMPOSITE_ENV=set" >> $GITHUB_ENV | |
| 267 | + - uses: ./.github/actions/nodey | |
| 268 | + with: | |
| 269 | + my-input: from-composite | |
| 270 | +`, | |
| 271 | + ); | |
| 272 | + writeFileSync( | |
| 273 | + path.join(ws, ".github/actions/nodey/action.yml"), | |
| 274 | + `name: nodey | |
| 275 | +inputs: | |
| 276 | + my-input: | |
| 277 | + default: default-val | |
| 278 | + repo: | |
| 279 | + default: \${{ github.repository }} | |
| 280 | +outputs: | |
| 281 | + result: | |
| 282 | + description: r | |
| 283 | +runs: | |
| 284 | + using: node20 | |
| 285 | + pre: pre.js | |
| 286 | + main: index.js | |
| 287 | + post: post.js | |
| 288 | + post-if: success() | |
| 289 | +`, | |
| 290 | + ); | |
| 291 | + writeFileSync( | |
| 292 | + path.join(ws, ".github/actions/nodey/pre.js"), | |
| 293 | + `require("fs").appendFileSync(process.env.GITHUB_STATE, "fromPre=pre-state\\n"); console.log("pre ran");`, | |
| 294 | + ); | |
| 295 | + writeFileSync( | |
| 296 | + path.join(ws, ".github/actions/nodey/index.js"), | |
| 297 | + `const fs = require("fs"); | |
| 298 | +console.log("input=" + process.env["INPUT_MY-INPUT"] + " repo=" + process.env.INPUT_REPO + " prestate=" + process.env.STATE_fromPre); | |
| 299 | +fs.appendFileSync(process.env.GITHUB_OUTPUT, "result=from-node-" + process.env["INPUT_MY-INPUT"] + "\\n"); | |
| 300 | +fs.appendFileSync(process.env.GITHUB_STATE, "saved=state-" + process.env["INPUT_MY-INPUT"] + "\\n"); | |
| 301 | +`, | |
| 302 | + ); | |
| 303 | + writeFileSync(path.join(ws, ".github/actions/nodey/post.js"), `console.log("post ran with state " + process.env.STATE_saved + "/" + process.env.STATE_fromPre);`); | |
| 304 | + | |
| 305 | + const spec = makeSpec(api, [ | |
| 306 | + { uses: "./.github/actions/greet", id: "greet", with: { who: "World" }, env: { OUTER: "outer-env" } }, | |
| 307 | + { run: 'echo "composite said ${{ steps.greet.outputs.message }} env=$COMPOSITE_ENV"' }, | |
| 308 | + { uses: "./.github/actions/nodey", id: "nodey", with: { "my-input": "hello" } }, | |
| 309 | + { run: 'echo "node said ${{ steps.nodey.outputs.result }}"' }, | |
| 310 | + ]); | |
| 311 | + const c = await run(spec); | |
| 312 | + const log = api.logText(); | |
| 313 | + expect(c.conclusion, log).toBe("success"); | |
| 314 | + const s0 = api.logText(0); | |
| 315 | + expect(s0).toContain("Hello World!"); | |
| 316 | + expect(s0).toContain(`action path is ${path.join(ws, ".github/actions/greet")}`); | |
| 317 | + expect(s0).toContain("inherited=outer-env"); | |
| 318 | + expect(s0).toContain("input=from-composite repo=acme/widgets prestate=pre-state"); | |
| 319 | + expect(log).not.toContain("SHOULD-NOT-RUN"); | |
| 320 | + expect(api.logText(1)).toContain("composite said Hello World env=set"); | |
| 321 | + expect(api.logText(2)).toContain("pre ran"); | |
| 322 | + expect(api.logText(2)).toContain("input=hello repo=acme/widgets prestate=pre-state"); | |
| 323 | + expect(api.logText(3)).toContain("node said from-node-hello"); | |
| 324 | + // post steps run in reverse order after the main steps | |
| 325 | + expect(api.logText(4)).toContain("post ran with state state-hello/pre-state"); | |
| 326 | + expect(api.logText(5)).toContain("post ran with state state-from-composite/pre-state"); | |
| 327 | + expect(api.lastReport(4)).toMatchObject({ status: "completed", conclusion: "success", name: "Post Run ./.github/actions/nodey" }); | |
| 328 | + }); | |
| 329 | + | |
| 330 | + it("fails composite steps and reports the inner error", async () => { | |
| 331 | + const ws = workspace(); | |
| 332 | + mkdirSync(path.join(ws, "act"), { recursive: true }); | |
| 333 | + writeFileSync( | |
| 334 | + path.join(ws, "act/action.yaml"), | |
| 335 | + `runs: | |
| 336 | + using: composite | |
| 337 | + steps: | |
| 338 | + - run: echo "::error::inner broke"; exit 2 | |
| 339 | + shell: bash | |
| 340 | + - run: echo "inner-cleanup" | |
| 341 | + if: always() | |
| 342 | + shell: bash | |
| 343 | +`, | |
| 344 | + ); | |
| 345 | + const c = await run(makeSpec(api, [{ uses: "./act" }, { run: "echo after", if: "always()" }])); | |
| 346 | + expect(c.conclusion).toBe("failure"); | |
| 347 | + expect(c.failure).toEqual({ step: 0, message: "inner broke" }); | |
| 348 | + expect(api.logText(0)).toContain("inner-cleanup"); | |
| 349 | + expect(api.logText(1)).toContain("after"); | |
| 350 | + }); | |
| 351 | + | |
| 352 | + it("rejects docker actions and github-script with clear errors", async () => { | |
| 353 | + const c = await run(makeSpec(api, [{ uses: "docker://alpine:3" }])); | |
| 354 | + expect(c.failure?.message).toContain("Docker actions are not supported on Diffinery runners yet"); | |
| 355 | + const c2 = await run(makeSpec(api, [{ uses: "actions/github-script@v7", with: { script: "1" } }])); | |
| 356 | + expect(c2.failure?.message).toContain("actions/github-script is not supported"); | |
| 357 | + }); | |
| 358 | + | |
| 359 | + it("enforces step timeout-minutes", async () => { | |
| 360 | + const start = Date.now(); | |
| 361 | + const c = await run(makeSpec(api, [{ "timeout-minutes": 0.02, run: "sleep 30" }, { if: "always()", run: "echo after-timeout" }])); | |
| 362 | + expect(Date.now() - start).toBeLessThan(15_000); | |
| 363 | + expect(c.conclusion).toBe("failure"); | |
| 364 | + expect(c.failure?.message).toContain("timed out"); | |
| 365 | + expect(api.logText(1)).toContain("after-timeout"); | |
| 366 | + }); | |
| 367 | + | |
| 368 | + it("cancels on request: kills the process group and reports cancelled", async () => { | |
| 369 | + let runner: JobRunner | undefined; | |
| 370 | + const spec = makeSpec(api, [{ run: "echo started; (sleep 60 & wait)" }, { run: "echo never" }, { if: "always()", run: "echo never2" }]); | |
| 371 | + const p = run(spec, { onRunner: (r) => (runner = r) }); | |
| 372 | + const t0 = Date.now(); | |
| 373 | + while (!api.logText(0).includes("started") && Date.now() - t0 < 10_000) await new Promise((r) => setTimeout(r, 50)); | |
| 374 | + runner!.cancel(); | |
| 375 | + const c = await p; | |
| 376 | + expect(c.conclusion).toBe("cancelled"); | |
| 377 | + expect(api.lastReport(0)?.conclusion).toBe("cancelled"); | |
| 378 | + expect(api.lastReport(1)?.conclusion).toBe("cancelled"); | |
| 379 | + expect(api.lastReport(2)?.conclusion).toBe("cancelled"); | |
| 380 | + expect(api.completion?.conclusion).toBe("cancelled"); | |
| 381 | + expect(Date.now() - t0).toBeLessThan(10_000); | |
| 382 | + }); | |
| 383 | + | |
| 384 | + it("never loses log lines when the log endpoint flakes", async () => { | |
| 385 | + api.failLogs = 2; | |
| 386 | + const c = await run(makeSpec(api, [{ run: "for i in $(seq 1 500); do echo \"n$i\"; done" }])); | |
| 387 | + expect(c.conclusion).toBe("success"); | |
| 388 | + const nums = api.logs.filter((l) => l.step === 0 && /^n\d+$/.test(l.text)).map((l) => l.text); | |
| 389 | + expect(nums).toEqual(Array.from({ length: 500 }, (_, i) => `n${i + 1}`)); | |
| 390 | + }); | |
| 391 | + | |
| 392 | + it("caches and restores via the runner cache endpoint, and round-trips artifacts", async () => { | |
| 393 | + const steps = [ | |
| 394 | + { run: 'mkdir -p deps dist/sub && echo "${{ github.run_id }}" > deps/marker && echo a > dist/a.txt && echo b > dist/sub/b.txt' }, | |
| 395 | + { id: "cache", uses: "actions/cache@v4", with: { path: "deps", key: "deps-${{ hashFiles('**/lock.txt') }}-v1", "restore-keys": "deps-" } }, | |
| 396 | + { run: 'echo "hit=${{ steps.cache.outputs.cache-hit }}"; cat deps/marker' }, | |
| 397 | + { uses: "actions/upload-artifact@v4", with: { name: "dist", path: "dist/" } }, | |
| 398 | + { id: "dl", uses: "actions/download-artifact@v4", with: { name: "dist", path: "out" } }, | |
| 399 | + { run: 'cat out/a.txt out/sub/b.txt; echo "dl=${{ steps.dl.outputs.download-path }}"' }, | |
| 400 | + ]; | |
| 401 | + mkdirSync(workspace(), { recursive: true }); | |
| 402 | + writeFileSync(path.join(workspace(), "lock.txt"), "v1"); | |
| 403 | + const c1 = await run(makeSpec(api, steps, { runId: "first" })); | |
| 404 | + expect(c1.conclusion, api.logText()).toBe("success"); | |
| 405 | + expect(api.logText(2)).toContain("hit=false"); | |
| 406 | + expect([...api.cache.keys()]).toHaveLength(1); | |
| 407 | + const key = [...api.cache.keys()][0]!; | |
| 408 | + expect(key).toMatch(/^deps-[0-9a-f]{64}-v1$/); | |
| 409 | + expect(api.artifacts.has("dist")).toBe(true); | |
| 410 | + expect(api.logText(5)).toMatch(/a\nb\ndl=.*\/out/); | |
| 411 | + // post step saved the cache | |
| 412 | + expect(api.steps.some((s) => s.report.name === "Post Run actions/cache@v4" && s.report.conclusion === "success")).toBe(true); | |
| 413 | + | |
| 414 | + // second run: wipe the cached dir, it must come back from the cache | |
| 415 | + rmSync(path.join(workspace(), "deps"), { recursive: true, force: true }); | |
| 416 | + api.logs = []; | |
| 417 | + api.steps = []; | |
| 418 | + const steps2 = [steps[1]!, steps[2]!]; | |
| 419 | + const c2 = await run(makeSpec(api, steps2, { runId: "second" })); | |
| 420 | + expect(c2.conclusion, api.logText()).toBe("success"); | |
| 421 | + expect(api.logText(1)).toContain("hit=true"); | |
| 422 | + expect(api.logText(1)).toContain("first"); | |
| 423 | + expect(api.steps.some((s) => s.report.name?.startsWith("Post"))).toBe(false); | |
| 424 | + }); | |
| 425 | + | |
| 426 | + it("checks out a repository with actions/checkout", async () => { | |
| 427 | + const origin = tmp("origin-"); | |
| 428 | + const git = (...args: string[]) => execFileSync("git", args, { cwd: origin, env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } }).toString().trim(); | |
| 429 | + git("init", "-q", "-b", "main"); | |
| 430 | + writeFileSync(path.join(origin, "README.md"), "first\n"); | |
| 431 | + git("add", "."); | |
| 432 | + git("commit", "-q", "-m", "first"); | |
| 433 | + writeFileSync(path.join(origin, "README.md"), "second\n"); | |
| 434 | + git("commit", "-q", "-am", "second"); | |
| 435 | + const sha = git("rev-parse", "HEAD"); | |
| 436 | + git("tag", "v1"); | |
| 437 | + const spec = makeSpec( | |
| 438 | + api, | |
| 439 | + [ | |
| 440 | + { id: "co", uses: "actions/checkout@v4" }, | |
| 441 | + { run: 'cat README.md; git rev-parse HEAD; git rev-parse --abbrev-ref HEAD; echo "commit=${{ steps.co.outputs.commit }}"; git rev-list --count HEAD' }, | |
| 442 | + { uses: "actions/checkout@v4", with: { path: "full", "fetch-depth": "0", ref: "v1" } }, | |
| 443 | + { run: "git -C full rev-list --count HEAD; cat full/README.md" }, | |
| 444 | + ], | |
| 445 | + { sha, repo: { owner: "acme", name: "widgets", cloneUrl: `file://${origin}`, defaultBranch: "main" } }, | |
| 446 | + ); | |
| 447 | + const c = await run(spec); | |
| 448 | + expect(c.conclusion, api.logText()).toBe("success"); | |
| 449 | + const out = api.logText(1); | |
| 450 | + expect(out).toContain("second"); | |
| 451 | + expect(out).toContain(sha); | |
| 452 | + expect(out).toMatch(/\nmain\n/); | |
| 453 | + expect(out).toContain(`commit=${sha}`); | |
| 454 | + expect(out).toMatch(/\n1$/); | |
| 455 | + expect(api.logText(3)).toMatch(/2\nsecond/); | |
| 456 | + expect(existsSync(path.join(workspace(), ".git"))).toBe(true); | |
| 457 | + rmSync(origin, { recursive: true, force: true }); | |
| 458 | + }); | |
| 459 | + | |
| 460 | + it("sets OIDC request variables when id-token is granted", async () => { | |
| 461 | + const c = await run( | |
| 462 | + makeSpec(api, [{ run: 'curl -s -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts" ; echo' }], { idToken: true }), | |
| 463 | + ); | |
| 464 | + expect(c.conclusion).toBe("success"); | |
| 465 | + expect(api.logText(0)).toContain('{"value":"oidc-for-sts"}'); | |
| 466 | + }); | |
| 467 | + | |
| 468 | + it("evaluates job-level env and spec-level defaults.run", async () => { | |
| 469 | + mkdirSync(path.join(workspace(), "pkg"), { recursive: true }); | |
| 470 | + const c = await run( | |
| 471 | + makeSpec(api, [{ run: 'echo "wd=$(basename "$(pwd)") shell=$0 e=$E"' }], { | |
| 472 | + defaults: { run: { shell: "sh -e {0}", "working-directory": "pkg" } }, | |
| 473 | + env: { E: "${{ vars.V }}-${{ inputs.i }}-${{ needs.prev.outputs.o }}" }, | |
| 474 | + vars: { V: "var" }, | |
| 475 | + inputs: { i: "inp" }, | |
| 476 | + needs: { prev: { result: "success", outputs: { o: "out" } } }, | |
| 477 | + }), | |
| 478 | + ); | |
| 479 | + expect(c.conclusion).toBe("success"); | |
| 480 | + expect(api.logText(0)).toMatch(/wd=pkg shell=\S+\.sh e=var-inp-out/); | |
| 481 | + }); | |
| 482 | +}); | |
| 483 | + | |
| 484 | +describe("agent binary (esbuild bundle)", () => { | |
| 485 | + it("runs a --spec job and an env-driven job", async () => { | |
| 486 | + const { build } = await import("esbuild"); | |
| 487 | + const out = path.join(workRoot, "agent.mjs"); | |
| 488 | + await build({ | |
| 489 | + entryPoints: [path.join(__dirname, "../src/main.ts")], | |
| 490 | + bundle: true, | |
| 491 | + platform: "node", | |
| 492 | + target: "node22", | |
| 493 | + format: "esm", | |
| 494 | + outfile: out, | |
| 495 | + logLevel: "silent", | |
| 496 | + // keep in sync with the `build` script in package.json | |
| 497 | + banner: { js: "import { createRequire as __diffineryCreateRequire } from 'node:module'; const require = __diffineryCreateRequire(import.meta.url);" }, | |
| 498 | + }); | |
| 499 | + const spec = makeSpec(api, [{ run: 'echo "from bundle $GITHUB_JOB"' }], { outputs: { x: "y" } }); | |
| 500 | + const specFile = path.join(workRoot, "spec.json"); | |
| 501 | + writeFileSync(specFile, JSON.stringify(spec)); | |
| 502 | + const env = { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, "w1"), DIFFINERY_API_URL: "" }; | |
| 503 | + delete env.DIFFINERY_API_URL; | |
| 504 | + const { spawnSync } = await import("node:child_process"); | |
| 505 | + const r = spawnSync(process.execPath, [out, "--spec", specFile], { env, encoding: "utf8", timeout: 30_000 }); | |
| 506 | + expect(r.status, r.stderr + r.stdout).toBe(0); | |
| 507 | + expect(api.completion).toMatchObject({ conclusion: "success", outputs: { x: "y" } }); | |
| 508 | + expect(api.logText(0)).toContain("from bundle build"); | |
| 509 | + | |
| 510 | + // env mode: fetch the spec from the API | |
| 511 | + api.completion = null; | |
| 512 | + api.specs.set("job-2", { ...spec, jobId: "job-2", steps: [{ run: "exit 4" }] }); | |
| 513 | + const r2 = spawnSync(process.execPath, [out], { | |
| 514 | + env: { ...process.env, DIFFINERY_WORK_ROOT: path.join(workRoot, "w2"), DIFFINERY_API_URL: api.url, DIFFINERY_JOB_ID: "job-2", DIFFINERY_JOB_TOKEN: api.token }, | |
| 515 | + encoding: "utf8", | |
| 516 | + timeout: 30_000, | |
| 517 | + }); | |
| 518 | + expect(r2.status).toBe(1); | |
| 519 | + expect(api.completion).toMatchObject({ conclusion: "failure", failure: { step: 0 } }); | |
| 520 | + expect(readFileSync(path.join(workRoot, "w2/_temp/_github_workflow/event.json"), "utf8")).toContain("pusher"); | |
| 521 | + }, 60_000); | |
| 522 | +}); |
addedcontainers/runner/test/unit.test.ts+228−0View file
| @@ -1,0 +1,228 @@ | ||
| 1 | +import { mkdirSync, writeFileSync } from "node:fs"; | |
| 2 | +import path from "node:path"; | |
| 3 | +import { describe, expect, it } from "vitest"; | |
| 4 | +import { LogBatcher } from "../src/api.js"; | |
| 5 | +import { parseCommand, parseKeyValueFile, parsePathFile } from "../src/commands.js"; | |
| 6 | +import { Masker } from "../src/mask.js"; | |
| 7 | +import { parseUses, inputEnvName } from "../src/steps/actions.js"; | |
| 8 | +import { resolveShell } from "../src/steps/run.js"; | |
| 9 | +import { globPaths, hashFiles, maxSatisfying, satisfies, shellWords } from "../src/util.js"; | |
| 10 | +import { parseYaml } from "../src/yaml.js"; | |
| 11 | +import { resolveNodeVersion } from "../src/builtins/setup-node.js"; | |
| 12 | +import { cloneUrlFor, basicAuthHeader } from "../src/builtins/checkout.js"; | |
| 13 | +import { tmp } from "./helpers.js"; | |
| 14 | + | |
| 15 | +describe("yaml", () => { | |
| 16 | + it("parses a typical action.yml", () => { | |
| 17 | + const doc = parseYaml(` | |
| 18 | +# comment | |
| 19 | +name: 'My Action' | |
| 20 | +description: "Does \\"things\\"" | |
| 21 | +author: someone # trailing comment | |
| 22 | +inputs: | |
| 23 | + token: | |
| 24 | + description: > | |
| 25 | + The token to use | |
| 26 | + for things. | |
| 27 | + required: true | |
| 28 | + default: \${{ github.token }} | |
| 29 | + list: | |
| 30 | + default: | | |
| 31 | + a | |
| 32 | + b | |
| 33 | + empty: | |
| 34 | +outputs: | |
| 35 | + result: | |
| 36 | + value: \${{ steps.x.outputs.y }} | |
| 37 | +branding: { icon: 'box', color: blue } | |
| 38 | +tags: [a, "b c", 3] | |
| 39 | +runs: | |
| 40 | + using: composite | |
| 41 | + steps: | |
| 42 | + - id: x | |
| 43 | + run: | | |
| 44 | + echo "hi: there" | |
| 45 | + echo done | |
| 46 | + shell: bash | |
| 47 | + - uses: actions/checkout@v4 | |
| 48 | + with: | |
| 49 | + fetch-depth: 0 | |
| 50 | + - | |
| 51 | + name: nested | |
| 52 | + run: echo nested | |
| 53 | + multi: this is | |
| 54 | + a folded plain | |
| 55 | +`) as Record<string, any>; | |
| 56 | + expect(doc.name).toBe("My Action"); | |
| 57 | + expect(doc.description).toBe('Does "things"'); | |
| 58 | + expect(doc.author).toBe("someone"); | |
| 59 | + expect(doc.inputs.token.description).toBe("The token to use for things.\n"); | |
| 60 | + expect(doc.inputs.token.required).toBe(true); | |
| 61 | + expect(doc.inputs.token.default).toBe("${{ github.token }}"); | |
| 62 | + expect(doc.inputs.list.default).toBe("a\nb\n"); | |
| 63 | + expect(doc.inputs.empty).toBeNull(); | |
| 64 | + expect(doc.outputs.result.value).toBe("${{ steps.x.outputs.y }}"); | |
| 65 | + expect(doc.branding).toEqual({ icon: "box", color: "blue" }); | |
| 66 | + expect(doc.tags).toEqual(["a", "b c", 3]); | |
| 67 | + expect(doc.runs.using).toBe("composite"); | |
| 68 | + expect(doc.runs.steps).toHaveLength(3); | |
| 69 | + expect(doc.runs.steps[0]).toEqual({ id: "x", run: 'echo "hi: there"\necho done\n', shell: "bash" }); | |
| 70 | + expect(doc.runs.steps[1]).toEqual({ uses: "actions/checkout@v4", with: { "fetch-depth": 0 } }); | |
| 71 | + expect(doc.runs.steps[2]).toEqual({ name: "nested", run: "echo nested" }); | |
| 72 | + expect(doc.runs.multi).toBe("this is a folded plain"); | |
| 73 | + }); | |
| 74 | + | |
| 75 | + it("handles chomping, sequences at mapping indent and quotes", () => { | |
| 76 | + const doc = parseYaml(`a: |- | |
| 77 | + keep | |
| 78 | + this | |
| 79 | +b: >- | |
| 80 | + folded | |
| 81 | + text | |
| 82 | + | |
| 83 | + para | |
| 84 | +c: | |
| 85 | +- x | |
| 86 | +- 'it''s' | |
| 87 | +- "tab\\tnl\\n" | |
| 88 | +d: "multi | |
| 89 | + line" | |
| 90 | +`) as Record<string, any>; | |
| 91 | + expect(doc.a).toBe("keep\nthis"); | |
| 92 | + expect(doc.b).toBe("folded text\npara"); | |
| 93 | + expect(doc.c).toEqual(["x", "it's", "tab\tnl\n"]); | |
| 94 | + expect(doc.d).toBe("multi line"); | |
| 95 | + }); | |
| 96 | +}); | |
| 97 | + | |
| 98 | +describe("workflow commands", () => { | |
| 99 | + it("parses commands with properties and escapes", () => { | |
| 100 | + expect(parseCommand("::set-output name=foo::bar")).toEqual({ command: "set-output", properties: { name: "foo" }, message: "bar" }); | |
| 101 | + expect(parseCommand("::error file=a.ts,line=3,title=Bad%3A thing::multi%0Aline%25")).toEqual({ | |
| 102 | + command: "error", | |
| 103 | + properties: { file: "a.ts", line: "3", title: "Bad: thing" }, | |
| 104 | + message: "multi\nline%", | |
| 105 | + }); | |
| 106 | + expect(parseCommand("::endgroup::")).toEqual({ command: "endgroup", properties: {}, message: "" }); | |
| 107 | + expect(parseCommand("not a command")).toBeNull(); | |
| 108 | + expect(parseCommand(":: nope")).toBeNull(); | |
| 109 | + }); | |
| 110 | + | |
| 111 | + it("parses env files incl. heredoc syntax", () => { | |
| 112 | + const c = "A=1\nB=x=y\nMULTI<<EOF\nl1\nl2\nEOF\nEMPTY=\nC<<ghadelimiter_123\n\nghadelimiter_123\n"; | |
| 113 | + expect(parseKeyValueFile(c)).toEqual([ | |
| 114 | + ["A", "1"], | |
| 115 | + ["B", "x=y"], | |
| 116 | + ["MULTI", "l1\nl2"], | |
| 117 | + ["EMPTY", ""], | |
| 118 | + ["C", ""], | |
| 119 | + ]); | |
| 120 | + expect(() => parseKeyValueFile("X<<EOF\nnever closed")).toThrow(/delimiter/); | |
| 121 | + expect(() => parseKeyValueFile("garbage")).toThrow(); | |
| 122 | + expect(parsePathFile("/a\n\n /b \n")).toEqual(["/a", "/b"]); | |
| 123 | + }); | |
| 124 | +}); | |
| 125 | + | |
| 126 | +describe("masker", () => { | |
| 127 | + it("masks values, longest first, and each line of multi-line secrets", () => { | |
| 128 | + const m = new Masker(); | |
| 129 | + m.add("abc"); | |
| 130 | + m.add("abcdef"); | |
| 131 | + m.add("-----BEGIN KEY-----\nAAAABBBB\n-----END KEY-----"); | |
| 132 | + m.add(""); | |
| 133 | + m.add(" "); | |
| 134 | + expect(m.mask("x abcdef y abc z")).toBe("x *** y *** z"); | |
| 135 | + expect(m.mask("AAAABBBB")).toBe("***"); | |
| 136 | + expect(m.mask("nothing here")).toBe("nothing here"); | |
| 137 | + expect(m.containsSecret("..abc..")).toBe(true); | |
| 138 | + }); | |
| 139 | +}); | |
| 140 | + | |
| 141 | +describe("versions", () => { | |
| 142 | + it("matches common version specs", () => { | |
| 143 | + expect(satisfies("22.11.0", "22")).toBe(true); | |
| 144 | + expect(satisfies("22.11.0", "22.x")).toBe(true); | |
| 145 | + expect(satisfies("22.11.0", "20")).toBe(false); | |
| 146 | + expect(satisfies("22.11.0", ">=20")).toBe(true); | |
| 147 | + expect(satisfies("22.11.0", ">=18 <21")).toBe(false); | |
| 148 | + expect(satisfies("20.5.0", "18 || 20")).toBe(true); | |
| 149 | + expect(satisfies("20.5.1", "^20.5")).toBe(true); | |
| 150 | + expect(satisfies("20.6.0", "~20.5")).toBe(false); | |
| 151 | + expect(satisfies("3.12.4", "3.12")).toBe(true); | |
| 152 | + expect(satisfies("3.13.0", "3.12")).toBe(false); | |
| 153 | + expect(satisfies("1.22.3", "1.22.3")).toBe(true); | |
| 154 | + expect(maxSatisfying(["v20.1.0", "v22.3.0", "v22.10.1", "v23.0.0"], "22")).toBe("v22.10.1"); | |
| 155 | + }); | |
| 156 | + | |
| 157 | + it("resolves node lts aliases", () => { | |
| 158 | + const idx = [ | |
| 159 | + { version: "v23.1.0", lts: false as const, files: ["linux-x64", "linux-arm64"] }, | |
| 160 | + { version: "v22.11.0", lts: "Jod", files: ["linux-x64", "linux-arm64"] }, | |
| 161 | + { version: "v20.18.0", lts: "Iron", files: ["linux-x64", "linux-arm64"] }, | |
| 162 | + ]; | |
| 163 | + expect(resolveNodeVersion(idx, "lts/*")).toBe("22.11.0"); | |
| 164 | + expect(resolveNodeVersion(idx, "lts/iron")).toBe("20.18.0"); | |
| 165 | + expect(resolveNodeVersion(idx, "lts/-1")).toBe("20.18.0"); | |
| 166 | + expect(resolveNodeVersion(idx, "latest")).toBe("23.1.0"); | |
| 167 | + expect(resolveNodeVersion(idx, "20")).toBe("20.18.0"); | |
| 168 | + }); | |
| 169 | +}); | |
| 170 | + | |
| 171 | +describe("glob + hashFiles", () => { | |
| 172 | + it("expands globs with negation and hashes deterministically", () => { | |
| 173 | + const root = tmp(); | |
| 174 | + mkdirSync(path.join(root, "a/b"), { recursive: true }); | |
| 175 | + writeFileSync(path.join(root, "a/x.txt"), "x"); | |
| 176 | + writeFileSync(path.join(root, "a/b/y.txt"), "y"); | |
| 177 | + writeFileSync(path.join(root, "a/b/z.log"), "z"); | |
| 178 | + expect(globPaths(root, ["**/*.txt"], { filesOnly: true }).map((p) => path.relative(root, p))).toEqual(["a/b/y.txt", "a/x.txt"]); | |
| 179 | + expect(globPaths(root, ["a/**", "!a/b/z.log"], { filesOnly: true }).map((p) => path.relative(root, p))).toEqual(["a/b/y.txt", "a/x.txt"]); | |
| 180 | + expect(globPaths(root, ["a"]).map((p) => path.relative(root, p))).toEqual(["a"]); | |
| 181 | + const h1 = hashFiles(root, ["**/*.txt"]); | |
| 182 | + expect(h1).toMatch(/^[0-9a-f]{64}$/); | |
| 183 | + expect(hashFiles(root, ["**/*.txt"])).toBe(h1); | |
| 184 | + expect(hashFiles(root, ["**/*.nope"])).toBe(""); | |
| 185 | + }); | |
| 186 | +}); | |
| 187 | + | |
| 188 | +describe("misc", () => { | |
| 189 | + it("splits shell words and resolves shells", () => { | |
| 190 | + expect(shellWords(`pwsh -command ". '/tmp/x.ps1'"`)).toEqual(["pwsh", "-command", ". '/tmp/x.ps1'"]); | |
| 191 | + expect(resolveShell(undefined, "/t/s.sh", process.env.PATH).argv).toEqual(["bash", "--noprofile", "--norc", "-eo", "pipefail", "/t/s.sh"]); | |
| 192 | + expect(resolveShell("sh", "/t/s.sh", process.env.PATH).argv).toEqual(["sh", "-e", "/t/s.sh"]); | |
| 193 | + expect(resolveShell("perl {0}", "/t/s.sh", process.env.PATH).argv).toEqual(["perl", "/t/s.sh"]); | |
| 194 | + expect(resolveShell("python", "/t/s.py", process.env.PATH).ext).toBe(".py"); | |
| 195 | + expect(() => resolveShell("zsh", "/t/s", process.env.PATH)).toThrow(/Invalid shell/); | |
| 196 | + }); | |
| 197 | + | |
| 198 | + it("parses uses references", () => { | |
| 199 | + expect(parseUses("actions/checkout@v4")).toMatchObject({ kind: "remote", owner: "actions", repo: "checkout", subpath: "", ref: "v4", name: "actions/checkout" }); | |
| 200 | + expect(parseUses("actions/cache/restore@v4")).toMatchObject({ kind: "remote", subpath: "restore", name: "actions/cache/restore" }); | |
| 201 | + expect(parseUses("./.github/actions/x")).toEqual({ kind: "local", path: "./.github/actions/x" }); | |
| 202 | + expect(parseUses("docker://alpine:3")).toEqual({ kind: "docker", image: "alpine:3" }); | |
| 203 | + expect(() => parseUses("nope")).toThrow(); | |
| 204 | + expect(inputEnvName("my input-name")).toBe("INPUT_MY_INPUT-NAME"); | |
| 205 | + }); | |
| 206 | + | |
| 207 | + it("derives clone urls and auth headers", () => { | |
| 208 | + const spec = { repo: { owner: "acme", name: "widgets", cloneUrl: "https://diffinery.com/acme/widgets.git" } }; | |
| 209 | + expect(cloneUrlFor(spec, "acme/widgets")).toBe("https://diffinery.com/acme/widgets.git"); | |
| 210 | + expect(cloneUrlFor(spec, "other/lib")).toBe("https://diffinery.com/other/lib.git"); | |
| 211 | + expect(basicAuthHeader("tok")).toBe(`AUTHORIZATION: basic ${Buffer.from("x-access-token:tok").toString("base64")}`); | |
| 212 | + }); | |
| 213 | + | |
| 214 | + it("log batcher retries and never drops lines", async () => { | |
| 215 | + const received: string[] = []; | |
| 216 | + let fails = 2; | |
| 217 | + const b = new LogBatcher( | |
| 218 | + async (lines) => { | |
| 219 | + if (fails-- > 0) throw new Error("boom"); | |
| 220 | + received.push(...lines.map((l) => l.text)); | |
| 221 | + }, | |
| 222 | + { intervalMs: 20, maxBatch: 3 }, | |
| 223 | + ); | |
| 224 | + for (let i = 0; i < 10; i++) b.push({ t: "", step: 0, text: `l${i}` }); | |
| 225 | + expect(await b.close(10_000)).toBe(true); | |
| 226 | + expect(received).toEqual(Array.from({ length: 10 }, (_, i) => `l${i}`)); | |
| 227 | + }); | |
| 228 | +}); |
addedpackages/workflow-yaml/src/index.ts+50−0View file
| @@ -1,0 +1,50 @@ | ||
| 1 | +export type { | |
| 2 | + ActivityTrigger, | |
| 3 | + Concurrency, | |
| 4 | + ContainerSpec, | |
| 5 | + Defaults, | |
| 6 | + DispatchInput, | |
| 7 | + EnvironmentSpec, | |
| 8 | + Job, | |
| 9 | + Permissions, | |
| 10 | + PullRequestTrigger, | |
| 11 | + PushTrigger, | |
| 12 | + RefFilters, | |
| 13 | + ScheduleTrigger, | |
| 14 | + StepSpec, | |
| 15 | + Strategy, | |
| 16 | + Workflow, | |
| 17 | + WorkflowCallTrigger, | |
| 18 | + WorkflowDispatchTrigger, | |
| 19 | + WorkflowError, | |
| 20 | + WorkflowOn, | |
| 21 | + WorkflowRunTrigger, | |
| 22 | +} from "./model.js"; | |
| 23 | +export { parseWorkflow, isWarning, formatPath, WARNING_PREFIX, KNOWN_EVENTS } from "./parse.js"; | |
| 24 | +export { | |
| 25 | + matchesTrigger, | |
| 26 | + globMatch, | |
| 27 | + globToRegExp, | |
| 28 | + matchesPatternList, | |
| 29 | + DEFAULT_PULL_REQUEST_TYPES, | |
| 30 | + type TriggerEvent, | |
| 31 | +} from "./filters.js"; | |
| 32 | +export { expandMatrix, deepEqual, MatrixError, type MatrixCombination } from "./matrix.js"; | |
| 33 | +export { jobOrder } from "./graph.js"; | |
| 34 | +export { parseCron, cronMatches, nextCronMatch, validateCron, CronError, type ParsedCron } from "./cron.js"; | |
| 35 | +export { | |
| 36 | + evaluate, | |
| 37 | + interpolate, | |
| 38 | + evaluateIf, | |
| 39 | + evaluateValue, | |
| 40 | + checkExpressionSyntax, | |
| 41 | + format, | |
| 42 | + ExpressionEvaluationError, | |
| 43 | + type ExprContext, | |
| 44 | + type StatusFns, | |
| 45 | + type EvalExtra, | |
| 46 | +} from "./expr/evaluate.js"; | |
| 47 | +export { containsExpression, splitTemplate, soleExpression, TemplateError, type TemplatePart } from "./expr/template.js"; | |
| 48 | +export { parseExpression, usesStatusFunction, type Node as ExpressionNode } from "./expr/parser.js"; | |
| 49 | +export { ExpressionSyntaxError } from "./expr/lexer.js"; | |
| 50 | +export { isTruthy, looseEquals, looseCompare, toNumber, toStr as coerceToString } from "./expr/coerce.js"; |
modifiedpackages/workflow-yaml/src/matrix.ts+2−2View file
| @@ -107,10 +107,10 @@ | ||
| 107 | 107 | for (const entry of include) { |
| 108 | 108 | let added = false; |
| 109 | 109 | for (const combo of combos) { |
| 110 | - // Original vector values are never overwritten, so checking `combo` is enough. | |
| 111 | 110 | const fits = Object.keys(entry).every((k) => !originalKeys.has(k) || deepEqual(combo[k], entry[k])); |
| 112 | 111 | if (fits) { |
| 113 | - Object.assign(combo, entry); | |
| 112 | + // Original vector values are never overwritten; values added by earlier includes may be. | |
| 113 | + for (const [k, v] of Object.entries(entry)) if (!originalKeys.has(k)) combo[k] = v; | |
| 114 | 114 | added = true; |
| 115 | 115 | } |
| 116 | 116 | } |
modifiedpackages/workflow-yaml/src/parse.ts+4−4View file
| @@ -637,7 +637,7 @@ | ||
| 637 | 637 | /** Checks every string for unclosed `${{` and expression syntax errors. */ |
| 638 | 638 | function checkExpressions(c: Context, value: unknown, path: Seg[]): void { |
| 639 | 639 | if (typeof value === "string") { |
| 640 | - const isIf = path[path.length - 1] === "if" && (path.length === 3 || (path.length === 5 && path[2] === "steps")); | |
| 640 | + const isIf = path[0] === "jobs" && path[path.length - 1] === "if" && (path.length === 3 || (path.length === 5 && path[2] === "steps")); | |
| 641 | 641 | if (isIf && !value.includes("${{")) { |
| 642 | 642 | if (value.trim() !== "") { |
| 643 | 643 | const err = checkExpressionSyntax(value); |
| @@ -676,7 +676,7 @@ | ||
| 676 | 676 | if (doc.errors.length > 0) { |
| 677 | 677 | return { |
| 678 | 678 | errors: doc.errors.map((e) => { |
| 679 | - const pos = e.linePos?.[0]; | |
| 679 | + const pos = e.linePos?.[0] ?? lc.linePos(e.pos[0]); | |
| 680 | 680 | return { path: "", ...(pos ? { line: pos.line, col: pos.col } : {}), message: `YAML syntax error: ${e.message.split("\n")[0]}` }; |
| 681 | 681 | }), |
| 682 | 682 | }; |
| @@ -707,7 +707,7 @@ | ||
| 707 | 707 | const runName = c.str(raw["run-name"], ["run-name"]); |
| 708 | 708 | if (runName !== undefined) wf.runName = runName; |
| 709 | 709 | |
| 710 | - if (raw.on === undefined || raw.on === null) c.error([], "workflow is missing the 'on' trigger section"); | |
| 710 | + if (raw.on === undefined || raw.on === null) c.error(["on"], "workflow is missing the 'on' trigger section"); | |
| 711 | 711 | else wf.on = normalizeOn(c, raw.on); |
| 712 | 712 | |
| 713 | 713 | const permissions = normalizePermissions(c, raw.permissions, ["permissions"]); |
| @@ -716,7 +716,7 @@ | ||
| 716 | 716 | if (concurrency) wf.concurrency = concurrency; |
| 717 | 717 | |
| 718 | 718 | if (raw.jobs === undefined || raw.jobs === null) { |
| 719 | - c.error([], "workflow is missing the 'jobs' section"); | |
| 719 | + c.error(["jobs"], "workflow is missing the 'jobs' section"); | |
| 720 | 720 | } else if (!isObj(raw.jobs)) { |
| 721 | 721 | c.error(["jobs"], "'jobs' must be a mapping of job id to job"); |
| 722 | 722 | } else { |
addedpackages/workflow-yaml/test/cron.test.ts+82−0View file
| @@ -1,0 +1,82 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { CronError, cronMatches, nextCronMatch, parseCron, validateCron } from "../src/index.js"; | |
| 3 | + | |
| 4 | +const d = (iso: string) => new Date(iso); | |
| 5 | + | |
| 6 | +describe("parseCron", () => { | |
| 7 | + it("parses wildcards, lists, ranges and steps", () => { | |
| 8 | + const c = parseCron("*/15 0-6/2 1,15 * *"); | |
| 9 | + expect([...c.minutes]).toEqual([0, 15, 30, 45]); | |
| 10 | + expect([...c.hours]).toEqual([0, 2, 4, 6]); | |
| 11 | + expect([...c.daysOfMonth]).toEqual([1, 15]); | |
| 12 | + expect(c.months.size).toBe(12); | |
| 13 | + expect(c.daysOfWeek.size).toBe(7); | |
| 14 | + }); | |
| 15 | + | |
| 16 | + it("supports month and day names (case-insensitive) and 7 as Sunday", () => { | |
| 17 | + const c = parseCron("0 0 * jan-mar,DEC MON-FRI"); | |
| 18 | + expect([...c.months]).toEqual([1, 2, 3, 12]); | |
| 19 | + expect([...c.daysOfWeek]).toEqual([1, 2, 3, 4, 5]); | |
| 20 | + expect([...parseCron("0 0 * * 7").daysOfWeek]).toEqual([0]); | |
| 21 | + expect([...parseCron("0 0 * * 5-7").daysOfWeek].sort()).toEqual([0, 5, 6]); | |
| 22 | + }); | |
| 23 | + | |
| 24 | + it("supports a start/step without a range", () => { | |
| 25 | + expect([...parseCron("5/20 * * * *").minutes]).toEqual([5, 25, 45]); | |
| 26 | + }); | |
| 27 | + | |
| 28 | + const invalid = ["", "* * * *", "* * * * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "* * * 13 *", "* * * * 8", | |
| 29 | + "*/0 * * * *", "5-1 * * * *", "a * * * *", "1,,2 * * * *", "* * * foo *"]; | |
| 30 | + for (const cron of invalid) { | |
| 31 | + it(`rejects ${JSON.stringify(cron)}`, () => { | |
| 32 | + expect(() => parseCron(cron)).toThrow(CronError); | |
| 33 | + expect(validateCron(cron)).toBeTypeOf("string"); | |
| 34 | + }); | |
| 35 | + } | |
| 36 | +}); | |
| 37 | + | |
| 38 | +describe("cronMatches", () => { | |
| 39 | + it("matches in UTC", () => { | |
| 40 | + expect(cronMatches("30 5 * * *", d("2026-10-12T05:30:00Z"))).toBe(true); | |
| 41 | + expect(cronMatches("30 5 * * *", d("2026-10-12T05:31:00Z"))).toBe(false); | |
| 42 | + expect(cronMatches("30 5 * * *", d("2026-10-12T05:30:00+02:00"))).toBe(false); | |
| 43 | + }); | |
| 44 | + | |
| 45 | + it("matches weekdays", () => { | |
| 46 | + // 2026-10-12 is a Monday. | |
| 47 | + expect(cronMatches("0 9 * * MON", d("2026-10-12T09:00:00Z"))).toBe(true); | |
| 48 | + expect(cronMatches("0 9 * * 2", d("2026-10-12T09:00:00Z"))).toBe(false); | |
| 49 | + expect(cronMatches("30 5 * * 1,3", d("2026-10-14T05:30:00Z"))).toBe(true); | |
| 50 | + }); | |
| 51 | + | |
| 52 | + it("day-of-month OR day-of-week when both are restricted", () => { | |
| 53 | + // 1st of month or Friday | |
| 54 | + expect(cronMatches("0 0 1 * 5", d("2026-10-01T00:00:00Z"))).toBe(true); // Thursday, 1st | |
| 55 | + expect(cronMatches("0 0 1 * 5", d("2026-10-16T00:00:00Z"))).toBe(true); // Friday | |
| 56 | + expect(cronMatches("0 0 1 * 5", d("2026-10-15T00:00:00Z"))).toBe(false); | |
| 57 | + // With dow wildcard, only dom applies. | |
| 58 | + expect(cronMatches("0 0 1 * *", d("2026-10-16T00:00:00Z"))).toBe(false); | |
| 59 | + expect(cronMatches("0 0 * * 5", d("2026-10-01T00:00:00Z"))).toBe(false); | |
| 60 | + }); | |
| 61 | + | |
| 62 | + it("matches months", () => { | |
| 63 | + expect(cronMatches("0 0 1 JAN *", d("2027-01-01T00:00:00Z"))).toBe(true); | |
| 64 | + expect(cronMatches("0 0 1 JAN *", d("2026-11-01T00:00:00Z"))).toBe(false); | |
| 65 | + }); | |
| 66 | + | |
| 67 | + it("ignores seconds", () => { | |
| 68 | + expect(cronMatches("*/5 * * * *", d("2026-10-12T10:05:42Z"))).toBe(true); | |
| 69 | + }); | |
| 70 | +}); | |
| 71 | + | |
| 72 | +describe("nextCronMatch", () => { | |
| 73 | + it("finds the next minute strictly after the given date", () => { | |
| 74 | + expect(nextCronMatch("*/15 * * * *", d("2026-10-12T10:00:00Z"))?.toISOString()).toBe("2026-10-12T10:15:00.000Z"); | |
| 75 | + expect(nextCronMatch("0 9 * * MON", d("2026-10-12T09:00:00Z"))?.toISOString()).toBe("2026-10-19T09:00:00.000Z"); | |
| 76 | + expect(nextCronMatch("0 0 29 2 *", d("2026-10-12T00:00:00Z"))?.toISOString()).toBe("2028-02-29T00:00:00.000Z"); | |
| 77 | + }); | |
| 78 | + | |
| 79 | + it("returns null when it never matches", () => { | |
| 80 | + expect(nextCronMatch("0 0 31 2 *", d("2026-10-12T00:00:00Z"))).toBe(null); | |
| 81 | + }); | |
| 82 | +}); |
addedpackages/workflow-yaml/test/expr.test.ts+356−0View file
| @@ -1,0 +1,356 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { | |
| 3 | + containsExpression, | |
| 4 | + evaluate, | |
| 5 | + evaluateIf, | |
| 6 | + evaluateValue, | |
| 7 | + ExpressionSyntaxError, | |
| 8 | + interpolate, | |
| 9 | + splitTemplate, | |
| 10 | + type ExprContext, | |
| 11 | + type StatusFns, | |
| 12 | +} from "../src/index.js"; | |
| 13 | + | |
| 14 | +const ctx: ExprContext = { | |
| 15 | + github: { | |
| 16 | + event_name: "push", | |
| 17 | + ref: "refs/heads/main", | |
| 18 | + repository: "acme/widgets", | |
| 19 | + event: { | |
| 20 | + commits: [ | |
| 21 | + { id: "a1", message: "fix: bug", author: { name: "Ann" } }, | |
| 22 | + { id: "b2", message: "feat: thing", author: { name: "Bob" } }, | |
| 23 | + { id: "c3", message: "chore", author: null }, | |
| 24 | + ], | |
| 25 | + pull_request: { labels: [{ name: "bug" }, { name: "ci" }], number: 42 }, | |
| 26 | + }, | |
| 27 | + }, | |
| 28 | + env: { NODE_ENV: "production", Mixed_Case: "yes" }, | |
| 29 | + matrix: { node: 20, os: "ubuntu-latest" }, | |
| 30 | + steps: { "build-step": { outputs: { artifact: "dist.tgz" }, outcome: "success" } }, | |
| 31 | + needs: { build: { result: "success", outputs: { version: "1.2.3" } } }, | |
| 32 | + inputs: { debug: true, count: 3, name: "" }, | |
| 33 | + vars: { REGION: "eu" }, | |
| 34 | +}; | |
| 35 | + | |
| 36 | +const ev = (e: string) => evaluate(e, ctx); | |
| 37 | + | |
| 38 | +const status = (s: "success" | "failure" | "cancelled"): StatusFns => ({ | |
| 39 | + success: () => s === "success", | |
| 40 | + failure: () => s === "failure", | |
| 41 | + cancelled: () => s === "cancelled", | |
| 42 | +}); | |
| 43 | + | |
| 44 | +describe("literals", () => { | |
| 45 | + it("parses null, booleans, numbers, strings", () => { | |
| 46 | + expect(ev("null")).toBe(null); | |
| 47 | + expect(ev("true")).toBe(true); | |
| 48 | + expect(ev("false")).toBe(false); | |
| 49 | + expect(ev("711")).toBe(711); | |
| 50 | + expect(ev("-9.2")).toBe(-9.2); | |
| 51 | + expect(ev("0xff")).toBe(255); | |
| 52 | + expect(ev("-2.99e-2")).toBe(-0.0299); | |
| 53 | + expect(ev("1e3")).toBe(1000); | |
| 54 | + expect(ev("'Mona the Octocat'")).toBe("Mona the Octocat"); | |
| 55 | + expect(ev("'It''s open source!'")).toBe("It's open source!"); | |
| 56 | + expect(ev("''")).toBe(""); | |
| 57 | + }); | |
| 58 | + | |
| 59 | + it("supports NaN and Infinity keywords", () => { | |
| 60 | + expect(ev("NaN")).toBeNaN(); | |
| 61 | + expect(ev("Infinity")).toBe(Infinity); | |
| 62 | + }); | |
| 63 | +}); | |
| 64 | + | |
| 65 | +describe("property access", () => { | |
| 66 | + it("dereferences with . and []", () => { | |
| 67 | + expect(ev("github.event_name")).toBe("push"); | |
| 68 | + expect(ev("github['event_name']")).toBe("push"); | |
| 69 | + expect(ev("steps.build-step.outputs.artifact")).toBe("dist.tgz"); | |
| 70 | + expect(ev("steps['build-step'].outputs['artifact']")).toBe("dist.tgz"); | |
| 71 | + expect(ev("github.event.commits[1].id")).toBe("b2"); | |
| 72 | + expect(ev("github.event.commits['1'].id")).toBe("b2"); | |
| 73 | + }); | |
| 74 | + | |
| 75 | + it("is case-insensitive for property and context names", () => { | |
| 76 | + expect(ev("GITHUB.Event_Name")).toBe("push"); | |
| 77 | + expect(ev("env.mixed_case")).toBe("yes"); | |
| 78 | + expect(ev("Needs.BUILD.outputs.VERSION")).toBe("1.2.3"); | |
| 79 | + }); | |
| 80 | + | |
| 81 | + it("returns null for unknown properties and contexts", () => { | |
| 82 | + expect(ev("github.nope")).toBe(null); | |
| 83 | + expect(ev("github.nope.deeper.still")).toBe(null); | |
| 84 | + expect(ev("unknowncontext.x")).toBe(null); | |
| 85 | + expect(ev("github.event.commits[99]")).toBe(null); | |
| 86 | + expect(ev("github.event.commits[-1]")).toBe(null); | |
| 87 | + expect(ev("github.ref.length")).toBe(null); | |
| 88 | + }); | |
| 89 | + | |
| 90 | + it("supports object filters", () => { | |
| 91 | + expect(ev("github.event.commits.*.message")).toEqual(["fix: bug", "feat: thing", "chore"]); | |
| 92 | + expect(ev("github.event.commits.*.author.name")).toEqual(["Ann", "Bob"]); | |
| 93 | + expect(ev("github.event.pull_request.labels.*.name")).toEqual(["bug", "ci"]); | |
| 94 | + expect(ev("github.event.commits[*].id")).toEqual(["a1", "b2", "c3"]); | |
| 95 | + expect(ev("contains(github.event.pull_request.labels.*.name, 'CI')")).toBe(true); | |
| 96 | + expect(ev("needs.*.result")).toEqual(["success"]); | |
| 97 | + expect(evaluate("fruits.*.*", { fruits: { a: [1, 2], b: { x: 3 } } })).toEqual([1, 2, 3]); | |
| 98 | + expect(ev("github.missing.*")).toEqual([]); | |
| 99 | + }); | |
| 100 | +}); | |
| 101 | + | |
| 102 | +describe("operators", () => { | |
| 103 | + it("logical not and grouping", () => { | |
| 104 | + expect(ev("!true")).toBe(false); | |
| 105 | + expect(ev("!''")).toBe(true); | |
| 106 | + expect(ev("!!github")).toBe(true); | |
| 107 | + expect(ev("!(1 == 2)")).toBe(true); | |
| 108 | + expect(ev("(true || false) && false")).toBe(false); | |
| 109 | + }); | |
| 110 | + | |
| 111 | + it("&& and || return operand values", () => { | |
| 112 | + expect(ev("'a' && 'b'")).toBe("b"); | |
| 113 | + expect(ev("'' && 'b'")).toBe(""); | |
| 114 | + expect(ev("null || 'fallback'")).toBe("fallback"); | |
| 115 | + expect(ev("inputs.name || 'default-name'")).toBe("default-name"); | |
| 116 | + expect(ev("0 || null")).toBe(null); | |
| 117 | + expect(ev("github.event_name == 'push' && 'yes' || 'no'")).toBe("yes"); | |
| 118 | + expect(ev("github.event_name == 'pr' && 'yes' || 'no'")).toBe("no"); | |
| 119 | + }); | |
| 120 | + | |
| 121 | + it("precedence: comparison binds tighter than equality, && tighter than ||", () => { | |
| 122 | + expect(ev("1 < 2 == true")).toBe(true); | |
| 123 | + expect(ev("true || false && false")).toBe(true); | |
| 124 | + }); | |
| 125 | + | |
| 126 | + it("compares strings case-insensitively", () => { | |
| 127 | + expect(ev("'ABC' == 'abc'")).toBe(true); | |
| 128 | + expect(ev("'abc' != 'ABD'")).toBe(true); | |
| 129 | + expect(ev("'a' < 'B'")).toBe(true); | |
| 130 | + expect(ev("'b' >= 'B'")).toBe(true); | |
| 131 | + }); | |
| 132 | + | |
| 133 | + it("coerces mismatched types to numbers", () => { | |
| 134 | + expect(ev("null == 0")).toBe(true); | |
| 135 | + expect(ev("true == 1")).toBe(true); | |
| 136 | + expect(ev("false == 0")).toBe(true); | |
| 137 | + expect(ev("'' == 0")).toBe(true); | |
| 138 | + expect(ev("'0x10' == 16")).toBe(true); | |
| 139 | + expect(ev("' 42 ' == 42")).toBe(true); | |
| 140 | + expect(ev("'1e2' == 100")).toBe(true); | |
| 141 | + expect(ev("'abc' == 0")).toBe(false); | |
| 142 | + expect(ev("'true' == true")).toBe(false); // 'true' -> NaN | |
| 143 | + expect(ev("null == false")).toBe(true); | |
| 144 | + expect(ev("inputs.count == '3'")).toBe(true); | |
| 145 | + expect(ev("inputs.debug == 'true'")).toBe(false); | |
| 146 | + expect(ev("inputs.debug == true")).toBe(true); | |
| 147 | + expect(ev("2 > '10'")).toBe(false); | |
| 148 | + expect(ev("'2' > '10'")).toBe(true); // string compare | |
| 149 | + }); | |
| 150 | + | |
| 151 | + it("follows NaN rules", () => { | |
| 152 | + expect(ev("NaN == NaN")).toBe(false); | |
| 153 | + expect(ev("NaN != NaN")).toBe(true); | |
| 154 | + expect(ev("'abc' < 5")).toBe(false); | |
| 155 | + expect(ev("'abc' >= 5")).toBe(false); | |
| 156 | + expect(ev("!NaN")).toBe(true); | |
| 157 | + }); | |
| 158 | + | |
| 159 | + it("compares objects and arrays by reference", () => { | |
| 160 | + expect(ev("github.event == github.event")).toBe(true); | |
| 161 | + expect(ev("fromJSON('{}') == fromJSON('{}')")).toBe(false); | |
| 162 | + expect(ev("github.event == 1")).toBe(false); | |
| 163 | + expect(ev("github.event < github.event")).toBe(false); | |
| 164 | + }); | |
| 165 | + | |
| 166 | + it("null compares equal to null", () => { | |
| 167 | + expect(ev("null == null")).toBe(true); | |
| 168 | + expect(ev("github.nope == null")).toBe(true); | |
| 169 | + expect(ev("github.nope == ''")).toBe(true); // null -> 0, '' -> 0 | |
| 170 | + }); | |
| 171 | +}); | |
| 172 | + | |
| 173 | +describe("functions", () => { | |
| 174 | + it("contains", () => { | |
| 175 | + expect(ev("contains('Hello world', 'llo')")).toBe(true); | |
| 176 | + expect(ev("contains('Hello world', 'LLO')")).toBe(true); | |
| 177 | + expect(ev("contains('Hello world', 'xyz')")).toBe(false); | |
| 178 | + expect(ev("contains(fromJSON('[\"push\", \"pull_request\"]'), github.event_name)")).toBe(true); | |
| 179 | + expect(ev("contains(fromJSON('[1, 2]'), '2')")).toBe(true); | |
| 180 | + expect(ev("contains(fromJSON('[1, 2]'), 3)")).toBe(false); | |
| 181 | + expect(ev("contains(github.ref, 'main')")).toBe(true); | |
| 182 | + expect(ev("CONTAINS('abc', 'b')")).toBe(true); | |
| 183 | + }); | |
| 184 | + | |
| 185 | + it("startsWith / endsWith", () => { | |
| 186 | + expect(ev("startsWith('Hello world', 'he')")).toBe(true); | |
| 187 | + expect(ev("startsWith(github.ref, 'refs/tags/')")).toBe(false); | |
| 188 | + expect(ev("endsWith('Hello world', 'LD')")).toBe(true); | |
| 189 | + expect(ev("endswith(github.ref, '/main')")).toBe(true); | |
| 190 | + expect(ev("startsWith(123, 12)")).toBe(true); | |
| 191 | + }); | |
| 192 | + | |
| 193 | + it("format", () => { | |
| 194 | + expect(ev("format('Hello {0} {1} {2}', 'Mona', 'the', 'Octocat')")).toBe("Hello Mona the Octocat"); | |
| 195 | + expect(ev("format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')")).toBe("{Hello Mona the Octocat!}"); | |
| 196 | + expect(ev("format('{0}{0}', 'x')")).toBe("xx"); | |
| 197 | + expect(ev("format('{0}-{1}', 1.5, true)")).toBe("1.5-true"); | |
| 198 | + expect(ev("format('{0}', null)")).toBe(""); | |
| 199 | + expect(() => ev("format('{1}', 'a')")).toThrow(/no matching argument/); | |
| 200 | + expect(() => ev("format('{x}', 'a')")).toThrow(/invalid format/); | |
| 201 | + expect(() => ev("format('a } b')")).toThrow(/invalid format/); | |
| 202 | + }); | |
| 203 | + | |
| 204 | + it("join", () => { | |
| 205 | + expect(ev("join(github.event.pull_request.labels.*.name, ', ')")).toBe("bug, ci"); | |
| 206 | + expect(ev("join(fromJSON('[1, true, null]'))")).toBe("1,true,"); | |
| 207 | + expect(ev("join('abc', '-')")).toBe("abc"); | |
| 208 | + }); | |
| 209 | + | |
| 210 | + it("toJSON pretty prints with 2 spaces", () => { | |
| 211 | + expect(ev("toJSON(matrix)")).toBe('{\n "node": 20,\n "os": "ubuntu-latest"\n}'); | |
| 212 | + expect(ev("toJSON('x')")).toBe('"x"'); | |
| 213 | + expect(ev("toJSON(null)")).toBe("null"); | |
| 214 | + expect(ev("toJSON(github.nope)")).toBe("null"); | |
| 215 | + }); | |
| 216 | + | |
| 217 | + it("fromJSON", () => { | |
| 218 | + expect(ev("fromJSON('{\"a\": [1, 2]}').a[1]")).toBe(2); | |
| 219 | + expect(ev("fromJSON('true')")).toBe(true); | |
| 220 | + expect(ev("fromJSON('42') == 42")).toBe(true); | |
| 221 | + expect(() => ev("fromJSON('{nope')")).toThrow(/fromJSON/); | |
| 222 | + }); | |
| 223 | + | |
| 224 | + it("hashFiles delegates to extra.hashFiles", () => { | |
| 225 | + expect(ev("hashFiles('**/package-lock.json')")).toBe(""); | |
| 226 | + const seen: string[][] = []; | |
| 227 | + const out = evaluate("hashFiles('**/package-lock.json', '!node_modules/**')", ctx, undefined, { | |
| 228 | + hashFiles: (...p) => { | |
| 229 | + seen.push(p); | |
| 230 | + return "abc123"; | |
| 231 | + }, | |
| 232 | + }); | |
| 233 | + expect(out).toBe("abc123"); | |
| 234 | + expect(seen).toEqual([["**/package-lock.json", "!node_modules/**"]]); | |
| 235 | + }); | |
| 236 | + | |
| 237 | + it("status functions", () => { | |
| 238 | + expect(evaluate("success()", {}, status("failure"))).toBe(false); | |
| 239 | + expect(evaluate("failure()", {}, status("failure"))).toBe(true); | |
| 240 | + expect(evaluate("cancelled()", {}, status("cancelled"))).toBe(true); | |
| 241 | + expect(evaluate("always()", {}, status("cancelled"))).toBe(true); | |
| 242 | + expect(evaluate("always()", {}, { ...status("success"), always: () => false })).toBe(false); | |
| 243 | + expect(evaluate("success()", {})).toBe(true); | |
| 244 | + }); | |
| 245 | +}); | |
| 246 | + | |
| 247 | +describe("syntax errors", () => { | |
| 248 | + const bad = [ | |
| 249 | + "", | |
| 250 | + "github.", | |
| 251 | + "1 ==", | |
| 252 | + "(true", | |
| 253 | + "'unterminated", | |
| 254 | + "a b", | |
| 255 | + "nosuchfn(1)", | |
| 256 | + "contains('a')", | |
| 257 | + "success(1)", | |
| 258 | + "\"double\"", | |
| 259 | + "a = b", | |
| 260 | + "a & b", | |
| 261 | + "github[", | |
| 262 | + ]; | |
| 263 | + for (const expr of bad) { | |
| 264 | + it(`throws for ${JSON.stringify(expr)}`, () => { | |
| 265 | + expect(() => evaluate(expr, ctx)).toThrow(ExpressionSyntaxError); | |
| 266 | + }); | |
| 267 | + } | |
| 268 | + it("has a clear message", () => { | |
| 269 | + expect(() => evaluate("foo(1)", {})).toThrow(/Unrecognized function 'foo'/); | |
| 270 | + expect(() => evaluate("1 ==", {})).toThrow(/end of expression/); | |
| 271 | + }); | |
| 272 | +}); | |
| 273 | + | |
| 274 | +describe("interpolate", () => { | |
| 275 | + it("replaces every expression", () => { | |
| 276 | + expect(interpolate("node-${{ matrix.node }}-${{ matrix.os }}", ctx)).toBe("node-20-ubuntu-latest"); | |
| 277 | + expect(interpolate("no expressions", ctx)).toBe("no expressions"); | |
| 278 | + expect(interpolate("${{matrix.node}}", ctx)).toBe("20"); | |
| 279 | + }); | |
| 280 | + | |
| 281 | + it("coerces values like GitHub", () => { | |
| 282 | + expect(interpolate("[${{ github.nope }}]", ctx)).toBe("[]"); | |
| 283 | + expect(interpolate("${{ true }} ${{ false }}", ctx)).toBe("true false"); | |
| 284 | + expect(interpolate("${{ 1.50 }} ${{ 0x10 }} ${{ 1e3 }} ${{ -0 }}", ctx)).toBe("1.5 16 1000 0"); | |
| 285 | + expect(interpolate("${{ 0.1 }}", ctx)).toBe("0.1"); | |
| 286 | + expect(interpolate("${{ needs.build.outputs }}", ctx)).toBe('{\n "version": "1.2.3"\n}'); | |
| 287 | + expect(interpolate("${{ github.event.commits.*.id }}", ctx)).toBe('[\n "a1",\n "b2",\n "c3"\n]'); | |
| 288 | + }); | |
| 289 | + | |
| 290 | + it("does not end an expression at }} inside a string literal", () => { | |
| 291 | + expect(interpolate("${{ format('{{x}}={0}', 1) }}!", ctx)).toBe("{x}=1!"); | |
| 292 | + expect(interpolate("${{ 'a}}b' }}", ctx)).toBe("a}}b"); | |
| 293 | + }); | |
| 294 | + | |
| 295 | + it("throws on unclosed expressions", () => { | |
| 296 | + expect(() => interpolate("echo ${{ github.ref", ctx)).toThrow(/Unclosed expression/); | |
| 297 | + }); | |
| 298 | + | |
| 299 | + it("evaluateValue keeps types for sole expressions", () => { | |
| 300 | + expect(evaluateValue("${{ inputs.count }}", ctx)).toBe(3); | |
| 301 | + expect(evaluateValue("${{ fromJSON('[1,2]') }}", ctx)).toEqual([1, 2]); | |
| 302 | + expect(evaluateValue(" v${{ inputs.count }}", ctx)).toBe(" v3"); | |
| 303 | + expect(evaluateValue(5, ctx)).toBe(5); | |
| 304 | + }); | |
| 305 | + | |
| 306 | + it("containsExpression / splitTemplate", () => { | |
| 307 | + expect(containsExpression("a ${{ b }}")).toBe(true); | |
| 308 | + expect(containsExpression("a ${ b }")).toBe(false); | |
| 309 | + expect(splitTemplate("a${{ x }}b")).toEqual([ | |
| 310 | + { kind: "text", text: "a" }, | |
| 311 | + { kind: "expr", expr: "x", start: 1, end: 9 }, | |
| 312 | + { kind: "text", text: "b" }, | |
| 313 | + ]); | |
| 314 | + }); | |
| 315 | +}); | |
| 316 | + | |
| 317 | +describe("evaluateIf", () => { | |
| 318 | + it("undefined means success()", () => { | |
| 319 | + expect(evaluateIf(undefined, ctx, status("success"))).toBe(true); | |
| 320 | + expect(evaluateIf(undefined, ctx, status("failure"))).toBe(false); | |
| 321 | + expect(evaluateIf("", ctx, status("failure"))).toBe(false); | |
| 322 | + }); | |
| 323 | + | |
| 324 | + it("adds implicit success() when no status function is used", () => { | |
| 325 | + expect(evaluateIf("github.event_name == 'push'", ctx, status("success"))).toBe(true); | |
| 326 | + expect(evaluateIf("github.event_name == 'push'", ctx, status("failure"))).toBe(false); | |
| 327 | + expect(evaluateIf("${{ github.event_name == 'push' }}", ctx, status("success"))).toBe(true); | |
| 328 | + expect(evaluateIf("true", ctx, status("cancelled"))).toBe(false); | |
| 329 | + expect(evaluateIf(true, ctx, status("failure"))).toBe(false); | |
| 330 | + expect(evaluateIf(false, ctx, status("success"))).toBe(false); | |
| 331 | + }); | |
| 332 | + | |
| 333 | + it("does not add success() when a status function is present", () => { | |
| 334 | + expect(evaluateIf("always()", ctx, status("failure"))).toBe(true); | |
| 335 | + expect(evaluateIf("failure()", ctx, status("failure"))).toBe(true); | |
| 336 | + expect(evaluateIf("${{ failure() && github.event_name == 'push' }}", ctx, status("failure"))).toBe(true); | |
| 337 | + expect(evaluateIf("!cancelled()", ctx, status("failure"))).toBe(true); | |
| 338 | + expect(evaluateIf("cancelled()", ctx, status("success"))).toBe(false); | |
| 339 | + expect(evaluateIf("success() || failure()", ctx, status("failure"))).toBe(true); | |
| 340 | + }); | |
| 341 | + | |
| 342 | + it("uses truthiness of the result", () => { | |
| 343 | + expect(evaluateIf("inputs.name", ctx, status("success"))).toBe(false); | |
| 344 | + expect(evaluateIf("inputs.count", ctx, status("success"))).toBe(true); | |
| 345 | + expect(evaluateIf("steps.build-step.outputs.artifact", ctx, status("success"))).toBe(true); | |
| 346 | + expect(evaluateIf("github.nope", ctx, status("success"))).toBe(false); | |
| 347 | + }); | |
| 348 | + | |
| 349 | + it("treats mixed text and expressions as a (truthy) string", () => { | |
| 350 | + expect(evaluateIf("${{ false }} == true", ctx, status("success"))).toBe(true); | |
| 351 | + }); | |
| 352 | + | |
| 353 | + it("throws on syntax errors", () => { | |
| 354 | + expect(() => evaluateIf("github.event_name ==", ctx, status("success"))).toThrow(ExpressionSyntaxError); | |
| 355 | + }); | |
| 356 | +}); |
addedpackages/workflow-yaml/test/filters.test.ts+230−0View file
| @@ -1,0 +1,230 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { globMatch, matchesPatternList, matchesTrigger, parseWorkflow, type Workflow } from "../src/index.js"; | |
| 3 | + | |
| 4 | +function wf(on: string): Workflow { | |
| 5 | + const src = `on:${on}\njobs:\n j:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n`; | |
| 6 | + const { workflow, errors } = parseWorkflow(src, ".github/workflows/t.yml"); | |
| 7 | + if (!workflow) throw new Error(JSON.stringify(errors)); | |
| 8 | + return workflow; | |
| 9 | +} | |
| 10 | + | |
| 11 | +describe("globMatch (GitHub filter pattern cheat sheet)", () => { | |
| 12 | + const cases: Array<[string, string, boolean]> = [ | |
| 13 | + ["feature/*", "feature/my-branch", true], | |
| 14 | + ["feature/*", "feature/your-branch", true], | |
| 15 | + ["feature/*", "feature/a/b", false], | |
| 16 | + ["feature/**", "feature/beta-a/my-branch", true], | |
| 17 | + ["feature/**", "feature/mona/the/octocat", true], | |
| 18 | + ["main", "main", true], | |
| 19 | + ["main", "main2", false], | |
| 20 | + ["releases/mona-the-octocat", "releases/mona-the-octocat", true], | |
| 21 | + ["*", "main", true], | |
| 22 | + ["*", "releases/v1", false], | |
| 23 | + ["**", "all/the/branches", true], | |
| 24 | + ["*feature", "mona-feature", true], | |
| 25 | + ["*feature", "feature", true], | |
| 26 | + ["*feature", "ver-10-feature", true], | |
| 27 | + ["v2*", "v2", true], | |
| 28 | + ["v2*", "v2.0", true], | |
| 29 | + ["v2*", "v2.9", true], | |
| 30 | + ["v2*", "v3", false], | |
| 31 | + ["v[12].[0-9]+.[0-9]+", "v1.10.1", true], | |
| 32 | + ["v[12].[0-9]+.[0-9]+", "v2.0.0", true], | |
| 33 | + ["v[12].[0-9]+.[0-9]+", "v3.0.0", false], | |
| 34 | + ["v[12].[0-9]+.[0-9]+", "v1..0", false], | |
| 35 | + ["v1.?", "v1.", true], | |
| 36 | + ["v1.?", "v1", true], | |
| 37 | + ["v1.?", "v1x", false], | |
| 38 | + ["*.jsx?", "page.js", true], | |
| 39 | + ["*.jsx?", "page.jsx", true], | |
| 40 | + ["*.jsx?", "dir/page.js", false], | |
| 41 | + ["**.js", "index.js", true], | |
| 42 | + ["**.js", "js/index.js", true], | |
| 43 | + ["**.js", "src/js/app.js", true], | |
| 44 | + ["docs/*", "docs/README.md", true], | |
| 45 | + ["docs/*", "docs/file.txt", true], | |
| 46 | + ["docs/*", "docs/a/file.txt", false], | |
| 47 | + ["docs/**", "docs/README.md", true], | |
| 48 | + ["docs/**", "docs/mona/octocat.txt", true], | |
| 49 | + ["docs/**/*.md", "docs/README.md", true], | |
| 50 | + ["docs/**/*.md", "docs/mona/hello-world.md", true], | |
| 51 | + ["docs/**/*.md", "docs/a/markdown/file.md", true], | |
| 52 | + ["**/docs/**", "docs/hello.md", true], | |
| 53 | + ["**/docs/**", "dir/docs/my-file.txt", true], | |
| 54 | + ["**/docs/**", "space/docs/plan/space.doc", true], | |
| 55 | + ["**/README.md", "README.md", true], | |
| 56 | + ["**/README.md", "js/README.md", true], | |
| 57 | + ["**/*src/**", "a/src/app.js", true], | |
| 58 | + ["**/*src/**", "my-src/code/js/app.js", true], | |
| 59 | + ["**/*-post.md", "my-post.md", true], | |
| 60 | + ["**/*-post.md", "path/their-post.md", true], | |
| 61 | + ["**/migrate-*.sql", "migrate-10909.sql", true], | |
| 62 | + ["**/migrate-*.sql", "db/migrate-v1.0.sql", true], | |
| 63 | + ["**/migrate-*.sql", "db/sept/migrate-v1.sql", true], | |
| 64 | + ["*.md", "README.md", true], | |
| 65 | + ["*.md", "docs/README.md", false], | |
| 66 | + ["releases/\\*", "releases/*", true], | |
| 67 | + ["releases/\\*", "releases/x", false], | |
| 68 | + ["a+b", "aaab", true], | |
| 69 | + ["a+b", "b", false], | |
| 70 | + ["file(1).txt", "file(1).txt", true], | |
| 71 | + ["v1.0", "v1x0", false], | |
| 72 | + ]; | |
| 73 | + for (const [pattern, value, expected] of cases) { | |
| 74 | + it(`${pattern} ${expected ? "matches" : "does not match"} ${value}`, () => { | |
| 75 | + expect(globMatch(pattern, value)).toBe(expected); | |
| 76 | + }); | |
| 77 | + } | |
| 78 | + | |
| 79 | + it("evaluates negations in order", () => { | |
| 80 | + const patterns = ["releases/**", "!releases/**-alpha"]; | |
| 81 | + expect(matchesPatternList(patterns, "releases/10")).toBe(true); | |
| 82 | + expect(matchesPatternList(patterns, "releases/10-alpha")).toBe(false); | |
| 83 | + expect(matchesPatternList(patterns, "main")).toBe(false); | |
| 84 | + // A later positive pattern re-includes. | |
| 85 | + const reinclude = ["sub-package/**", "!sub-package/docs/**", "sub-package/docs/important.md"]; | |
| 86 | + expect(matchesPatternList(reinclude, "sub-package/docs/important.md")).toBe(true); | |
| 87 | + expect(matchesPatternList(reinclude, "sub-package/docs/other.md")).toBe(false); | |
| 88 | + // A negation before the positive pattern has no effect. | |
| 89 | + expect(matchesPatternList(["!releases/**-alpha", "releases/**"], "releases/10-alpha")).toBe(true); | |
| 90 | + }); | |
| 91 | +}); | |
| 92 | + | |
| 93 | +describe("matchesTrigger: push", () => { | |
| 94 | + it("bare push runs for every branch and tag", () => { | |
| 95 | + const w = wf(" push"); | |
| 96 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/anything" })).toBe(true); | |
| 97 | + expect(matchesTrigger(w, { name: "push", ref: "refs/tags/v1" })).toBe(true); | |
| 98 | + expect(matchesTrigger(w, { name: "pull_request", action: "opened", baseRef: "main" })).toBe(false); | |
| 99 | + }); | |
| 100 | + | |
| 101 | + it("branch filters", () => { | |
| 102 | + const w = wf(`\n push:\n branches: [main, 'releases/**', '!releases/**-alpha']`); | |
| 103 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/main" })).toBe(true); | |
| 104 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/releases/v1" })).toBe(true); | |
| 105 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/releases/v1-alpha" })).toBe(false); | |
| 106 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/dev" })).toBe(false); | |
| 107 | + // Only branches defined: tag pushes do not trigger. | |
| 108 | + expect(matchesTrigger(w, { name: "push", ref: "refs/tags/v1" })).toBe(false); | |
| 109 | + }); | |
| 110 | + | |
| 111 | + it("branches-ignore", () => { | |
| 112 | + const w = wf(`\n push:\n branches-ignore: ['mona/octocat', 'releases/**-alpha']`); | |
| 113 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/main" })).toBe(true); | |
| 114 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/mona/octocat" })).toBe(false); | |
| 115 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/releases/beta/3-alpha" })).toBe(false); | |
| 116 | + expect(matchesTrigger(w, { name: "push", ref: "refs/tags/v1" })).toBe(false); | |
| 117 | + }); | |
| 118 | + | |
| 119 | + it("tag filters only: branch pushes do not trigger", () => { | |
| 120 | + const w = wf(`\n push:\n tags: ['v*.*.*']`); | |
| 121 | + expect(matchesTrigger(w, { name: "push", ref: "refs/tags/v1.2.3" })).toBe(true); | |
| 122 | + expect(matchesTrigger(w, { name: "push", ref: "refs/tags/latest" })).toBe(false); | |
| 123 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/main" })).toBe(false); | |
| 124 | + }); | |
| 125 | + | |
| 126 | + it("tags-ignore", () => { | |
| 127 | + const w = wf(`\n push:\n tags-ignore: ['v1.*']`); | |
| 128 | + expect(matchesTrigger(w, { name: "push", ref: "refs/tags/v2.0" })).toBe(true); | |
| 129 | + expect(matchesTrigger(w, { name: "push", ref: "refs/tags/v1.9" })).toBe(false); | |
| 130 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/main" })).toBe(false); | |
| 131 | + }); | |
| 132 | + | |
| 133 | + it("branches and tags together", () => { | |
| 134 | + const w = wf(`\n push:\n branches: [main]\n tags: ['v*']`); | |
| 135 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/main" })).toBe(true); | |
| 136 | + expect(matchesTrigger(w, { name: "push", ref: "refs/tags/v1" })).toBe(true); | |
| 137 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/dev" })).toBe(false); | |
| 138 | + }); | |
| 139 | + | |
| 140 | + it("paths filters", () => { | |
| 141 | + const w = wf(`\n push:\n paths: ['**.js', '!docs/**']`); | |
| 142 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/main", changedFiles: ["src/a.js"] })).toBe(true); | |
| 143 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/main", changedFiles: ["docs/a.js"] })).toBe(false); | |
| 144 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/main", changedFiles: ["docs/a.js", "lib/b.js"] })).toBe(true); | |
| 145 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/main", changedFiles: ["README.md"] })).toBe(false); | |
| 146 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/main", changedFiles: [] })).toBe(false); | |
| 147 | + // Unknown changed files: run. | |
| 148 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/main" })).toBe(true); | |
| 149 | + // Paths are not evaluated for tags. | |
| 150 | + expect(matchesTrigger(w, { name: "push", ref: "refs/tags/v1", changedFiles: ["README.md"] })).toBe(true); | |
| 151 | + }); | |
| 152 | + | |
| 153 | + it("paths-ignore runs unless every file is ignored", () => { | |
| 154 | + const w = wf(`\n push:\n paths-ignore: ['docs/**', '*.md']`); | |
| 155 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/main", changedFiles: ["docs/a.txt", "README.md"] })).toBe(false); | |
| 156 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/main", changedFiles: ["docs/a.txt", "src/x.ts"] })).toBe(true); | |
| 157 | + }); | |
| 158 | + | |
| 159 | + it("branches and paths must both match", () => { | |
| 160 | + const w = wf(`\n push:\n branches: [main]\n paths: ['src/**']`); | |
| 161 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/main", changedFiles: ["src/a"] })).toBe(true); | |
| 162 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/dev", changedFiles: ["src/a"] })).toBe(false); | |
| 163 | + expect(matchesTrigger(w, { name: "push", ref: "refs/heads/main", changedFiles: ["test/a"] })).toBe(false); | |
| 164 | + }); | |
| 165 | +}); | |
| 166 | + | |
| 167 | +describe("matchesTrigger: pull_request", () => { | |
| 168 | + it("default types are opened, synchronize, reopened", () => { | |
| 169 | + const w = wf(" [pull_request]"); | |
| 170 | + for (const a of ["opened", "synchronize", "reopened"]) { | |
| 171 | + expect(matchesTrigger(w, { name: "pull_request", action: a, baseRef: "main" })).toBe(true); | |
| 172 | + } | |
| 173 | + expect(matchesTrigger(w, { name: "pull_request", action: "closed", baseRef: "main" })).toBe(false); | |
| 174 | + expect(matchesTrigger(w, { name: "pull_request", action: "labeled", baseRef: "main" })).toBe(false); | |
| 175 | + }); | |
| 176 | + | |
| 177 | + it("explicit types and branch filters on the base branch", () => { | |
| 178 | + const w = wf(`\n pull_request:\n types: [closed, labeled]\n branches: ['releases/**']`); | |
| 179 | + expect(matchesTrigger(w, { name: "pull_request", action: "closed", baseRef: "releases/1.x" })).toBe(true); | |
| 180 | + expect(matchesTrigger(w, { name: "pull_request", action: "closed", baseRef: "refs/heads/releases/1.x" })).toBe(true); | |
| 181 | + expect(matchesTrigger(w, { name: "pull_request", action: "opened", baseRef: "releases/1.x" })).toBe(false); | |
| 182 | + expect(matchesTrigger(w, { name: "pull_request", action: "closed", baseRef: "main" })).toBe(false); | |
| 183 | + }); | |
| 184 | + | |
| 185 | + it("types as a single string", () => { | |
| 186 | + const w = wf(`\n pull_request:\n types: closed`); | |
| 187 | + expect(matchesTrigger(w, { name: "pull_request", action: "closed", baseRef: "main" })).toBe(true); | |
| 188 | + expect(matchesTrigger(w, { name: "pull_request", action: "opened", baseRef: "main" })).toBe(false); | |
| 189 | + }); | |
| 190 | + | |
| 191 | + it("pull_request_target is separate from pull_request", () => { | |
| 192 | + const w = wf(`\n pull_request_target:\n branches-ignore: [gh-pages]\n paths: ['src/**']`); | |
| 193 | + expect(matchesTrigger(w, { name: "pull_request", action: "opened", baseRef: "main" })).toBe(false); | |
| 194 | + expect(matchesTrigger(w, { name: "pull_request_target", action: "opened", baseRef: "main", changedFiles: ["src/x"] })).toBe(true); | |
| 195 | + expect(matchesTrigger(w, { name: "pull_request_target", action: "opened", baseRef: "gh-pages" })).toBe(false); | |
| 196 | + expect(matchesTrigger(w, { name: "pull_request_target", action: "opened", baseRef: "main", changedFiles: ["x"] })).toBe(false); | |
| 197 | + }); | |
| 198 | +}); | |
| 199 | + | |
| 200 | +describe("matchesTrigger: other events", () => { | |
| 201 | + it("schedule matches by cron string", () => { | |
| 202 | + const w = wf(`\n schedule:\n - cron: '30 5 * * 1,3'\n - cron: '0 0 * * *'`); | |
| 203 | + expect(matchesTrigger(w, { name: "schedule", cron: "30 5 * * 1,3" })).toBe(true); | |
| 204 | + expect(matchesTrigger(w, { name: "schedule", cron: "0 0 * * *" })).toBe(true); | |
| 205 | + expect(matchesTrigger(w, { name: "schedule", cron: "0 1 * * *" })).toBe(false); | |
| 206 | + }); | |
| 207 | + | |
| 208 | + it("workflow_dispatch", () => { | |
| 209 | + expect(matchesTrigger(wf(" workflow_dispatch"), { name: "workflow_dispatch" })).toBe(true); | |
| 210 | + expect(matchesTrigger(wf(" push"), { name: "workflow_dispatch" })).toBe(false); | |
| 211 | + }); | |
| 212 | + | |
| 213 | + it("activity types for generic events", () => { | |
| 214 | + const w = wf(`\n issues:\n types: [opened, labeled]\n issue_comment:\n release:\n types: [published]`); | |
| 215 | + expect(matchesTrigger(w, { name: "issues", action: "opened" })).toBe(true); | |
| 216 | + expect(matchesTrigger(w, { name: "issues", action: "closed" })).toBe(false); | |
| 217 | + expect(matchesTrigger(w, { name: "issue_comment", action: "created" })).toBe(true); | |
| 218 | + expect(matchesTrigger(w, { name: "release", action: "published" })).toBe(true); | |
| 219 | + expect(matchesTrigger(w, { name: "release", action: "created" })).toBe(false); | |
| 220 | + expect(matchesTrigger(w, { name: "watch", action: "started" })).toBe(false); | |
| 221 | + }); | |
| 222 | + | |
| 223 | + it("workflow_run filters by workflow, type and branch", () => { | |
| 224 | + const w = wf(`\n workflow_run:\n workflows: [CI]\n types: [completed]\n branches: [main]`); | |
| 225 | + expect(matchesTrigger(w, { name: "workflow_run", action: "completed", workflow: "CI", branch: "main" })).toBe(true); | |
| 226 | + expect(matchesTrigger(w, { name: "workflow_run", action: "requested", workflow: "CI", branch: "main" })).toBe(false); | |
| 227 | + expect(matchesTrigger(w, { name: "workflow_run", action: "completed", workflow: "Deploy", branch: "main" })).toBe(false); | |
| 228 | + expect(matchesTrigger(w, { name: "workflow_run", action: "completed", workflow: "CI", branch: "dev" })).toBe(false); | |
| 229 | + }); | |
| 230 | +}); |
addedpackages/workflow-yaml/test/matrix.test.ts+149−0View file
| @@ -1,0 +1,149 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { expandMatrix, MatrixError } from "../src/index.js"; | |
| 3 | + | |
| 4 | +describe("expandMatrix", () => { | |
| 5 | + it("no matrix yields one empty combination", () => { | |
| 6 | + expect(expandMatrix(undefined)).toEqual([{}]); | |
| 7 | + expect(expandMatrix(null)).toEqual([{}]); | |
| 8 | + }); | |
| 9 | + | |
| 10 | + it("single dimension", () => { | |
| 11 | + expect(expandMatrix({ version: [10, 12, 14] })).toEqual([{ version: 10 }, { version: 12 }, { version: 14 }]); | |
| 12 | + }); | |
| 13 | + | |
| 14 | + it("cartesian product with first key outermost", () => { | |
| 15 | + expect(expandMatrix({ os: ["ubuntu-latest", "windows-latest"], version: [10, 12, 14] })).toEqual([ | |
| 16 | + { os: "ubuntu-latest", version: 10 }, | |
| 17 | + { os: "ubuntu-latest", version: 12 }, | |
| 18 | + { os: "ubuntu-latest", version: 14 }, | |
| 19 | + { os: "windows-latest", version: 10 }, | |
| 20 | + { os: "windows-latest", version: 12 }, | |
| 21 | + { os: "windows-latest", version: 14 }, | |
| 22 | + ]); | |
| 23 | + }); | |
| 24 | + | |
| 25 | + it("include example from the GitHub docs", () => { | |
| 26 | + const out = expandMatrix({ | |
| 27 | + fruit: ["apple", "pear"], | |
| 28 | + animal: ["cat", "dog"], | |
| 29 | + include: [ | |
| 30 | + { color: "green" }, | |
| 31 | + { color: "pink", animal: "cat" }, | |
| 32 | + { fruit: "apple", shape: "circle" }, | |
| 33 | + { fruit: "banana" }, | |
| 34 | + { fruit: "banana", animal: "cat" }, | |
| 35 | + ], | |
| 36 | + }); | |
| 37 | + expect(out).toEqual([ | |
| 38 | + { fruit: "apple", animal: "cat", color: "pink", shape: "circle" }, | |
| 39 | + { fruit: "apple", animal: "dog", color: "green", shape: "circle" }, | |
| 40 | + { fruit: "pear", animal: "cat", color: "pink" }, | |
| 41 | + { fruit: "pear", animal: "dog", color: "green" }, | |
| 42 | + { fruit: "banana" }, | |
| 43 | + { fruit: "banana", animal: "cat" }, | |
| 44 | + ]); | |
| 45 | + }); | |
| 46 | + | |
| 47 | + it("expanding configurations example from the docs", () => { | |
| 48 | + const out = expandMatrix({ | |
| 49 | + os: ["windows-latest", "ubuntu-latest"], | |
| 50 | + node: [14, 16], | |
| 51 | + include: [{ os: "windows-latest", node: 16, npm: 6 }], | |
| 52 | + }); | |
| 53 | + expect(out).toEqual([ | |
| 54 | + { os: "windows-latest", node: 14 }, | |
| 55 | + { os: "windows-latest", node: 16, npm: 6 }, | |
| 56 | + { os: "ubuntu-latest", node: 14 }, | |
| 57 | + { os: "ubuntu-latest", node: 16 }, | |
| 58 | + ]); | |
| 59 | + }); | |
| 60 | + | |
| 61 | + it("include-only matrix", () => { | |
| 62 | + expect( | |
| 63 | + expandMatrix({ | |
| 64 | + include: [ | |
| 65 | + { site: "production", datacenter: "site-a" }, | |
| 66 | + { site: "staging", datacenter: "site-b" }, | |
| 67 | + ], | |
| 68 | + }), | |
| 69 | + ).toEqual([ | |
| 70 | + { site: "production", datacenter: "site-a" }, | |
| 71 | + { site: "staging", datacenter: "site-b" }, | |
| 72 | + ]); | |
| 73 | + }); | |
| 74 | + | |
| 75 | + it("exclude with partial match (docs example)", () => { | |
| 76 | + const out = expandMatrix({ | |
| 77 | + os: ["macos-latest", "windows-latest"], | |
| 78 | + version: [12, 14, 16], | |
| 79 | + environment: ["staging", "production"], | |
| 80 | + exclude: [ | |
| 81 | + { os: "macos-latest", version: 12, environment: "production" }, | |
| 82 | + { os: "windows-latest", version: 16 }, | |
| 83 | + ], | |
| 84 | + }); | |
| 85 | + expect(out).toHaveLength(12 - 1 - 2); | |
| 86 | + expect(out).not.toContainEqual({ os: "macos-latest", version: 12, environment: "production" }); | |
| 87 | + expect(out.filter((c) => c.os === "windows-latest" && c.version === 16)).toEqual([]); | |
| 88 | + expect(out).toContainEqual({ os: "macos-latest", version: 12, environment: "staging" }); | |
| 89 | + }); | |
| 90 | + | |
| 91 | + it("exclude is applied before include, and include can re-add", () => { | |
| 92 | + const out = expandMatrix({ | |
| 93 | + os: ["linux", "mac"], | |
| 94 | + exclude: [{ os: "mac" }], | |
| 95 | + include: [{ os: "mac", experimental: true }], | |
| 96 | + }); | |
| 97 | + expect(out).toEqual([{ os: "linux" }, { os: "mac", experimental: true }]); | |
| 98 | + }); | |
| 99 | + | |
| 100 | + it("exclude everything leaves no combinations", () => { | |
| 101 | + expect(expandMatrix({ os: ["a"], exclude: [{ os: "a" }] })).toEqual([]); | |
| 102 | + }); | |
| 103 | + | |
| 104 | + it("object-valued vectors and partial object exclude", () => { | |
| 105 | + const out = expandMatrix({ | |
| 106 | + cfg: [ | |
| 107 | + { name: "a", flags: "-O2" }, | |
| 108 | + { name: "b", flags: "-O0" }, | |
| 109 | + ], | |
| 110 | + arch: ["x64", "arm64"], | |
| 111 | + exclude: [{ cfg: { name: "b" }, arch: "arm64" }], | |
| 112 | + }); | |
| 113 | + expect(out).toEqual([ | |
| 114 | + { cfg: { name: "a", flags: "-O2" }, arch: "x64" }, | |
| 115 | + { cfg: { name: "a", flags: "-O2" }, arch: "arm64" }, | |
| 116 | + { cfg: { name: "b", flags: "-O0" }, arch: "x64" }, | |
| 117 | + ]); | |
| 118 | + }); | |
| 119 | + | |
| 120 | + it("include matching values loosely ('18' vs 18)", () => { | |
| 121 | + expect(expandMatrix({ node: [18, 20], include: [{ node: "18", lts: true }] })).toEqual([ | |
| 122 | + { node: 18, lts: true }, | |
| 123 | + { node: 20 }, | |
| 124 | + ]); | |
| 125 | + }); | |
| 126 | + | |
| 127 | + it("evaluates a fromJSON matrix expression with ctx", () => { | |
| 128 | + const ctx = { needs: { setup: { outputs: { matrix: '{"pkg":["a","b"],"include":[{"pkg":"a","extra":1}]}' } } } }; | |
| 129 | + expect(expandMatrix("${{ fromJSON(needs.setup.outputs.matrix) }}", ctx)).toEqual([ | |
| 130 | + { pkg: "a", extra: 1 }, | |
| 131 | + { pkg: "b" }, | |
| 132 | + ]); | |
| 133 | + }); | |
| 134 | + | |
| 135 | + it("evaluates expression vectors and include lists", () => { | |
| 136 | + const ctx = { inputs: { versions: "[1,2]", extra: '[{"v":3}]' } }; | |
| 137 | + expect(expandMatrix({ v: "${{ fromJSON(inputs.versions) }}", include: "${{ fromJSON(inputs.extra) }}" }, ctx)).toEqual([ | |
| 138 | + { v: 1 }, | |
| 139 | + { v: 2 }, | |
| 140 | + { v: 3 }, | |
| 141 | + ]); | |
| 142 | + }); | |
| 143 | + | |
| 144 | + it("errors on invalid shapes", () => { | |
| 145 | + expect(() => expandMatrix({ os: "ubuntu" })).toThrow(MatrixError); | |
| 146 | + expect(() => expandMatrix({ include: ["x"] })).toThrow(/include\[0\]/); | |
| 147 | + expect(() => expandMatrix([1, 2])).toThrow(/mapping/); | |
| 148 | + }); | |
| 149 | +}); |
addedpackages/workflow-yaml/test/parse.test.ts+600−0View file
| @@ -1,0 +1,600 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { expandMatrix, isWarning, jobOrder, matchesTrigger, parseWorkflow, type Workflow, type WorkflowError } from "../src/index.js"; | |
| 3 | + | |
| 4 | +function ok(src: string, path = ".github/workflows/test.yml"): Workflow { | |
| 5 | + const { workflow, errors } = parseWorkflow(src, path); | |
| 6 | + const fatal = errors.filter((e) => !isWarning(e)); | |
| 7 | + if (fatal.length > 0 || !workflow) throw new Error(`unexpected errors: ${JSON.stringify(errors, null, 2)}`); | |
| 8 | + return workflow; | |
| 9 | +} | |
| 10 | + | |
| 11 | +function errs(src: string): WorkflowError[] { | |
| 12 | + return parseWorkflow(src, "w.yml").errors; | |
| 13 | +} | |
| 14 | + | |
| 15 | +function fatal(src: string): WorkflowError[] { | |
| 16 | + const res = parseWorkflow(src, "w.yml"); | |
| 17 | + const out = res.errors.filter((e) => !isWarning(e)); | |
| 18 | + expect(out.length).toBeGreaterThan(0); | |
| 19 | + expect(res.workflow).toBeUndefined(); | |
| 20 | + return out; | |
| 21 | +} | |
| 22 | + | |
| 23 | +const minimalJob = `jobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n`; | |
| 24 | + | |
| 25 | +describe("real-world workflows", () => { | |
| 26 | + it("Node CI with matrix and cache", () => { | |
| 27 | + const wf = ok(` | |
| 28 | +name: Node CI | |
| 29 | +on: | |
| 30 | + push: | |
| 31 | + branches: [ main ] | |
| 32 | + pull_request: | |
| 33 | + branches: [ main ] | |
| 34 | +env: | |
| 35 | + CI: true | |
| 36 | + FORCE_COLOR: 1 | |
| 37 | +concurrency: | |
| 38 | + group: ci-\${{ github.ref }} | |
| 39 | + cancel-in-progress: true | |
| 40 | +permissions: | |
| 41 | + contents: read | |
| 42 | +jobs: | |
| 43 | + test: | |
| 44 | + name: Test (node \${{ matrix.node-version }}, \${{ matrix.os }}) | |
| 45 | + runs-on: \${{ matrix.os }} | |
| 46 | + timeout-minutes: 20 | |
| 47 | + strategy: | |
| 48 | + fail-fast: false | |
| 49 | + max-parallel: 4 | |
| 50 | + matrix: | |
| 51 | + os: [ubuntu-latest, windows-latest] | |
| 52 | + node-version: [18.x, 20.x, 22.x] | |
| 53 | + exclude: | |
| 54 | + - os: windows-latest | |
| 55 | + node-version: 18.x | |
| 56 | + include: | |
| 57 | + - os: ubuntu-latest | |
| 58 | + node-version: 22.x | |
| 59 | + coverage: true | |
| 60 | + steps: | |
| 61 | + - uses: actions/checkout@v4 | |
| 62 | + with: | |
| 63 | + fetch-depth: 0 | |
| 64 | + - name: Use Node.js \${{ matrix.node-version }} | |
| 65 | + uses: actions/setup-node@v4 | |
| 66 | + with: | |
| 67 | + node-version: \${{ matrix.node-version }} | |
| 68 | + cache: npm | |
| 69 | + - name: Cache node modules | |
| 70 | + uses: actions/cache@v4 | |
| 71 | + with: | |
| 72 | + path: ~/.npm | |
| 73 | + key: \${{ runner.os }}-node-\${{ hashFiles('**/package-lock.json') }} | |
| 74 | + restore-keys: | | |
| 75 | + \${{ runner.os }}-node- | |
| 76 | + - run: npm ci | |
| 77 | + - run: npm test -- --coverage | |
| 78 | + if: matrix.coverage | |
| 79 | + env: | |
| 80 | + NODE_OPTIONS: --max-old-space-size=4096 | |
| 81 | + - run: npm test | |
| 82 | + if: \${{ !matrix.coverage }} | |
| 83 | + continue-on-error: \${{ matrix.os == 'windows-latest' }} | |
| 84 | + timeout-minutes: 10 | |
| 85 | + working-directory: ./app | |
| 86 | + shell: bash | |
| 87 | +`); | |
| 88 | + expect(wf.name).toBe("Node CI"); | |
| 89 | + expect(wf.path).toBe(".github/workflows/test.yml"); | |
| 90 | + expect(wf.env).toEqual({ CI: "true", FORCE_COLOR: "1" }); | |
| 91 | + expect(wf.concurrency).toEqual({ group: "ci-${{ github.ref }}", cancelInProgress: true }); | |
| 92 | + expect(wf.permissions).toEqual({ contents: "read" }); | |
| 93 | + expect(Object.keys(wf.on)).toEqual(["push", "pull_request"]); | |
| 94 | + expect(wf.on.push).toEqual({ branches: ["main"] }); | |
| 95 | + | |
| 96 | + const job = wf.jobs.test!; | |
| 97 | + expect(job.key).toBe("test"); | |
| 98 | + expect(job.runsOn).toEqual(["${{ matrix.os }}"]); | |
| 99 | + expect(job.needs).toEqual([]); | |
| 100 | + expect(job.timeoutMinutes).toBe(20); | |
| 101 | + expect(job.continueOnError).toBe(false); | |
| 102 | + expect(job.strategy?.failFast).toBe(false); | |
| 103 | + expect(job.strategy?.maxParallel).toBe(4); | |
| 104 | + expect(job.steps).toHaveLength(6); | |
| 105 | + expect(job.steps[0]).toEqual({ uses: "actions/checkout@v4", with: { "fetch-depth": "0" } }); | |
| 106 | + expect(job.steps[1]?.with).toEqual({ "node-version": "${{ matrix.node-version }}", cache: "npm" }); | |
| 107 | + expect(job.steps[2]?.with?.["restore-keys"]).toBe("${{ runner.os }}-node-\n"); | |
| 108 | + expect(job.steps[4]).toEqual({ | |
| 109 | + run: "npm test -- --coverage", | |
| 110 | + if: "matrix.coverage", | |
| 111 | + env: { NODE_OPTIONS: "--max-old-space-size=4096" }, | |
| 112 | + }); | |
| 113 | + expect(job.steps[5]).toEqual({ | |
| 114 | + run: "npm test", | |
| 115 | + if: "${{ !matrix.coverage }}", | |
| 116 | + "continue-on-error": "${{ matrix.os == 'windows-latest' }}", | |
| 117 | + "timeout-minutes": 10, | |
| 118 | + "working-directory": "./app", | |
| 119 | + shell: "bash", | |
| 120 | + }); | |
| 121 | + | |
| 122 | + const combos = expandMatrix(job.strategy?.matrix); | |
| 123 | + expect(combos).toHaveLength(5); | |
| 124 | + expect(combos).toContainEqual({ os: "ubuntu-latest", "node-version": "22.x", coverage: true }); | |
| 125 | + | |
| 126 | + expect(matchesTrigger(wf, { name: "push", ref: "refs/heads/main" })).toBe(true); | |
| 127 | + expect(matchesTrigger(wf, { name: "push", ref: "refs/heads/feature" })).toBe(false); | |
| 128 | + expect(matchesTrigger(wf, { name: "pull_request", action: "synchronize", baseRef: "main" })).toBe(true); | |
| 129 | + }); | |
| 130 | + | |
| 131 | + it("release workflow on tags", () => { | |
| 132 | + const wf = ok(` | |
| 133 | +name: Release | |
| 134 | +on: | |
| 135 | + push: | |
| 136 | + tags: | |
| 137 | + - 'v[0-9]+.[0-9]+.[0-9]+' | |
| 138 | + - '!v*-rc*' | |
| 139 | +permissions: | |
| 140 | + contents: write | |
| 141 | + id-token: write | |
| 142 | +jobs: | |
| 143 | + build: | |
| 144 | + runs-on: ubuntu-latest | |
| 145 | + outputs: | |
| 146 | + version: \${{ steps.meta.outputs.version }} | |
| 147 | + steps: | |
| 148 | + - uses: actions/checkout@v4 | |
| 149 | + - id: meta | |
| 150 | + run: echo "version=\${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT" | |
| 151 | + publish: | |
| 152 | + needs: build | |
| 153 | + runs-on: [self-hosted, linux, x64] | |
| 154 | + environment: | |
| 155 | + name: production | |
| 156 | + url: https://example.com/releases/\${{ needs.build.outputs.version }} | |
| 157 | + if: startsWith(github.ref, 'refs/tags/') | |
| 158 | + steps: | |
| 159 | + - run: echo publishing \${{ needs.build.outputs.version }} | |
| 160 | + announce: | |
| 161 | + needs: [build, publish] | |
| 162 | + if: always() | |
| 163 | + runs-on: ubuntu-latest | |
| 164 | + steps: | |
| 165 | + - run: echo done | |
| 166 | +`); | |
| 167 | + expect(wf.on.push).toEqual({ tags: ["v[0-9]+.[0-9]+.[0-9]+", "!v*-rc*"] }); | |
| 168 | + expect(wf.permissions).toEqual({ contents: "write", "id-token": "write" }); | |
| 169 | + expect(wf.jobs.build?.outputs).toEqual({ version: "${{ steps.meta.outputs.version }}" }); | |
| 170 | + expect(wf.jobs.build?.steps[1]?.id).toBe("meta"); | |
| 171 | + expect(wf.jobs.publish?.needs).toEqual(["build"]); | |
| 172 | + expect(wf.jobs.publish?.runsOn).toEqual(["self-hosted", "linux", "x64"]); | |
| 173 | + expect(wf.jobs.publish?.environment).toEqual({ name: "production", url: "https://example.com/releases/${{ needs.build.outputs.version }}" }); | |
| 174 | + expect(wf.jobs.publish?.if).toBe("startsWith(github.ref, 'refs/tags/')"); | |
| 175 | + expect(wf.jobs.publish?.timeoutMinutes).toBe(360); | |
| 176 | + expect(wf.jobs.announce?.needs).toEqual(["build", "publish"]); | |
| 177 | + expect(jobOrder(wf)).toEqual({ order: [["build"], ["publish"], ["announce"]], errors: [] }); | |
| 178 | + | |
| 179 | + expect(matchesTrigger(wf, { name: "push", ref: "refs/tags/v1.2.3" })).toBe(true); | |
| 180 | + expect(matchesTrigger(wf, { name: "push", ref: "refs/tags/v1.2.3-rc1" })).toBe(false); | |
| 181 | + expect(matchesTrigger(wf, { name: "push", ref: "refs/heads/main" })).toBe(false); | |
| 182 | + }); | |
| 183 | + | |
| 184 | + it("workflow_dispatch with inputs", () => { | |
| 185 | + const wf = ok(` | |
| 186 | +on: | |
| 187 | + workflow_dispatch: | |
| 188 | + inputs: | |
| 189 | + logLevel: | |
| 190 | + description: 'Log level' | |
| 191 | + required: true | |
| 192 | + default: 'warning' | |
| 193 | + type: choice | |
| 194 | + options: | |
| 195 | + - info | |
| 196 | + - warning | |
| 197 | + - debug | |
| 198 | + print_tags: | |
| 199 | + description: 'True to print to STDOUT' | |
| 200 | + required: true | |
| 201 | + type: boolean | |
| 202 | + environment: | |
| 203 | + description: 'Environment to run tests against' | |
| 204 | + type: environment | |
| 205 | + required: true | |
| 206 | + count: | |
| 207 | + type: number | |
| 208 | + default: 3 | |
| 209 | + free: | |
| 210 | +run-name: Deploy by @\${{ github.actor }} | |
| 211 | +jobs: | |
| 212 | + print-tag: | |
| 213 | + runs-on: ubuntu-latest | |
| 214 | + if: \${{ inputs.print_tags }} | |
| 215 | + steps: | |
| 216 | + - name: Print the input tag to STDOUT | |
| 217 | + run: echo The tags are \${{ inputs.tags }} | |
| 218 | +`); | |
| 219 | + expect(wf.runName).toBe("Deploy by @${{ github.actor }}"); | |
| 220 | + expect(wf.on.workflow_dispatch?.inputs).toEqual({ | |
| 221 | + logLevel: { description: "Log level", required: true, default: "warning", type: "choice", options: ["info", "warning", "debug"] }, | |
| 222 | + print_tags: { description: "True to print to STDOUT", required: true, type: "boolean" }, | |
| 223 | + environment: { description: "Environment to run tests against", type: "environment", required: true }, | |
| 224 | + count: { type: "number", default: 3 }, | |
| 225 | + free: {}, | |
| 226 | + }); | |
| 227 | + expect(matchesTrigger(wf, { name: "workflow_dispatch" })).toBe(true); | |
| 228 | + expect(matchesTrigger(wf, { name: "push", ref: "refs/heads/main" })).toBe(false); | |
| 229 | + }); | |
| 230 | + | |
| 231 | + it("reusable workflow call and definition", () => { | |
| 232 | + const caller = ok(` | |
| 233 | +on: push | |
| 234 | +jobs: | |
| 235 | + call-workflow-passing-data: | |
| 236 | + uses: octo-org/example-repo/.github/workflows/reusable-workflow.yml@main | |
| 237 | + with: | |
| 238 | + config-path: .github/labeler.yml | |
| 239 | + debug: true | |
| 240 | + retries: 2 | |
| 241 | + secrets: inherit | |
| 242 | + local: | |
| 243 | + uses: ./.github/workflows/deploy.yml | |
| 244 | + needs: call-workflow-passing-data | |
| 245 | + secrets: | |
| 246 | + token: \${{ secrets.DEPLOY_TOKEN }} | |
| 247 | + strategy: | |
| 248 | + matrix: | |
| 249 | + target: [dev, prod] | |
| 250 | +`); | |
| 251 | + const j = caller.jobs["call-workflow-passing-data"]!; | |
| 252 | + expect(j.uses).toBe("octo-org/example-repo/.github/workflows/reusable-workflow.yml@main"); | |
| 253 | + expect(j.runsOn).toEqual([]); | |
| 254 | + expect(j.steps).toEqual([]); | |
| 255 | + expect(j.with).toEqual({ "config-path": ".github/labeler.yml", debug: true, retries: 2 }); | |
| 256 | + expect(j.secrets).toBe("inherit"); | |
| 257 | + expect(caller.jobs.local?.secrets).toEqual({ token: "${{ secrets.DEPLOY_TOKEN }}" }); | |
| 258 | + expect(caller.jobs.local?.needs).toEqual(["call-workflow-passing-data"]); | |
| 259 | + | |
| 260 | + const callee = ok(` | |
| 261 | +on: | |
| 262 | + workflow_call: | |
| 263 | + inputs: | |
| 264 | + config-path: | |
| 265 | + required: true | |
| 266 | + type: string | |
| 267 | + debug: | |
| 268 | + type: boolean | |
| 269 | + default: false | |
| 270 | + outputs: | |
| 271 | + firstword: | |
| 272 | + description: "The first output string" | |
| 273 | + value: \${{ jobs.example_job.outputs.output1 }} | |
| 274 | + secrets: | |
| 275 | + token: | |
| 276 | + required: true | |
| 277 | +jobs: | |
| 278 | + example_job: | |
| 279 | + runs-on: ubuntu-latest | |
| 280 | + outputs: | |
| 281 | + output1: \${{ steps.step1.outputs.firstword }} | |
| 282 | + steps: | |
| 283 | + - id: step1 | |
| 284 | + run: echo "firstword=hello" >> $GITHUB_OUTPUT | |
| 285 | +`); | |
| 286 | + expect(callee.on.workflow_call).toEqual({ | |
| 287 | + inputs: { "config-path": { required: true, type: "string" }, debug: { type: "boolean", default: false } }, | |
| 288 | + outputs: { firstword: { description: "The first output string", value: "${{ jobs.example_job.outputs.output1 }}" } }, | |
| 289 | + secrets: { token: { required: true } }, | |
| 290 | + }); | |
| 291 | + }); | |
| 292 | + | |
| 293 | + it("services, container, defaults, runs-on group, concurrency string", () => { | |
| 294 | + const wf = ok(` | |
| 295 | +on: push | |
| 296 | +defaults: | |
| 297 | + run: | |
| 298 | + shell: bash | |
| 299 | + working-directory: scripts | |
| 300 | +concurrency: deploy | |
| 301 | +jobs: | |
| 302 | + db: | |
| 303 | + runs-on: | |
| 304 | + group: big-runners | |
| 305 | + labels: [linux] | |
| 306 | + container: node:20 | |
| 307 | + services: | |
| 308 | + postgres: | |
| 309 | + image: postgres:16 | |
| 310 | + env: | |
| 311 | + POSTGRES_PASSWORD: postgres | |
| 312 | + ports: ['5432:5432'] | |
| 313 | + options: --health-cmd pg_isready | |
| 314 | + redis: redis:7 | |
| 315 | + permissions: read-all | |
| 316 | + concurrency: | |
| 317 | + group: db-\${{ github.ref }} | |
| 318 | + cancel-in-progress: \${{ github.ref != 'refs/heads/main' }} | |
| 319 | + continue-on-error: true | |
| 320 | + steps: | |
| 321 | + - run: psql -h postgres | |
| 322 | +`); | |
| 323 | + expect(wf.defaults).toEqual({ run: { shell: "bash", "working-directory": "scripts" } }); | |
| 324 | + expect(wf.concurrency).toEqual({ group: "deploy", cancelInProgress: false }); | |
| 325 | + const j = wf.jobs.db!; | |
| 326 | + expect(j.runsOn).toEqual(["linux"]); | |
| 327 | + expect(j.runsOnGroup).toBe("big-runners"); | |
| 328 | + expect(j.container).toEqual({ image: "node:20" }); | |
| 329 | + expect(j.services).toEqual({ | |
| 330 | + postgres: { image: "postgres:16", env: { POSTGRES_PASSWORD: "postgres" }, ports: ["5432:5432"], options: "--health-cmd pg_isready" }, | |
| 331 | + redis: { image: "redis:7" }, | |
| 332 | + }); | |
| 333 | + expect(j.permissions).toBe("read-all"); | |
| 334 | + expect(j.concurrency).toEqual({ group: "db-${{ github.ref }}", cancelInProgress: "${{ github.ref != 'refs/heads/main' }}" }); | |
| 335 | + expect(j.continueOnError).toBe(true); | |
| 336 | + }); | |
| 337 | + | |
| 338 | + it("supports YAML anchors and merge keys", () => { | |
| 339 | + const wf = ok(` | |
| 340 | +on: push | |
| 341 | +jobs: | |
| 342 | + a: &base | |
| 343 | + runs-on: ubuntu-latest | |
| 344 | + env: &env | |
| 345 | + A: 1 | |
| 346 | + steps: | |
| 347 | + - run: echo a | |
| 348 | + b: | |
| 349 | + <<: *base | |
| 350 | + env: | |
| 351 | + <<: *env | |
| 352 | + B: 2 | |
| 353 | +`); | |
| 354 | + expect(wf.jobs.b?.runsOn).toEqual(["ubuntu-latest"]); | |
| 355 | + expect(wf.jobs.b?.env).toEqual({ A: "1", B: "2" }); | |
| 356 | + }); | |
| 357 | +}); | |
| 358 | + | |
| 359 | +describe("`on` forms", () => { | |
| 360 | + it("string", () => { | |
| 361 | + expect(ok(`on: push\n${minimalJob}`).on).toEqual({ push: {} }); | |
| 362 | + }); | |
| 363 | + it("list", () => { | |
| 364 | + expect(ok(`on: [push, pull_request, workflow_dispatch]\n${minimalJob}`).on).toEqual({ | |
| 365 | + push: {}, | |
| 366 | + pull_request: {}, | |
| 367 | + workflow_dispatch: {}, | |
| 368 | + }); | |
| 369 | + }); | |
| 370 | + it("map with null values", () => { | |
| 371 | + expect(ok(`on:\n push:\n issues:\n types: opened\n${minimalJob}`).on).toEqual({ push: {}, issues: { types: ["opened"] } }); | |
| 372 | + }); | |
| 373 | + it("map with all push/pull_request filters", () => { | |
| 374 | + const wf = ok(`on: | |
| 375 | + push: | |
| 376 | + branches-ignore: [wip/**] | |
| 377 | + tags-ignore: [nightly] | |
| 378 | + paths-ignore: [docs/**] | |
| 379 | + pull_request: | |
| 380 | + types: [opened] | |
| 381 | + branches-ignore: [gh-pages] | |
| 382 | + paths: [src/**] | |
| 383 | + schedule: | |
| 384 | + - cron: '0 0 * * *' | |
| 385 | + - cron: '*/30 9-17 * * MON-FRI' | |
| 386 | + workflow_run: | |
| 387 | + workflows: ["CI"] | |
| 388 | + types: [completed] | |
| 389 | + release: | |
| 390 | + types: [published] | |
| 391 | +${minimalJob}`); | |
| 392 | + expect(wf.on).toEqual({ | |
| 393 | + push: { branchesIgnore: ["wip/**"], tagsIgnore: ["nightly"], pathsIgnore: ["docs/**"] }, | |
| 394 | + pull_request: { types: ["opened"], branchesIgnore: ["gh-pages"], paths: ["src/**"] }, | |
| 395 | + schedule: [{ cron: "0 0 * * *" }, { cron: "*/30 9-17 * * MON-FRI" }], | |
| 396 | + workflow_run: { workflows: ["CI"], types: ["completed"] }, | |
| 397 | + release: { types: ["published"] }, | |
| 398 | + }); | |
| 399 | + }); | |
| 400 | + it("'on' is not treated as a YAML 1.1 boolean", () => { | |
| 401 | + const res = parseWorkflow(`"on": push\n${minimalJob}`, "w.yml"); | |
| 402 | + expect(res.workflow?.on).toEqual({ push: {} }); | |
| 403 | + }); | |
| 404 | +}); | |
| 405 | + | |
| 406 | +describe("validation errors", () => { | |
| 407 | + it("YAML syntax errors carry a line", () => { | |
| 408 | + const e = fatal(`on: push\njobs:\n build:\n runs-on: [ubuntu\n steps: x: y\n`); | |
| 409 | + expect(e[0]?.message).toMatch(/YAML syntax error/); | |
| 410 | + expect(e[0]?.line).toBeGreaterThan(0); | |
| 411 | + }); | |
| 412 | + | |
| 413 | + it("empty file / non-mapping", () => { | |
| 414 | + expect(fatal("")[0]?.message).toMatch(/empty/); | |
| 415 | + expect(fatal("- a\n- b\n")[0]?.message).toMatch(/must be a mapping/); | |
| 416 | + }); | |
| 417 | + | |
| 418 | + it("missing on", () => { | |
| 419 | + const e = fatal(minimalJob); | |
| 420 | + expect(e).toEqual([expect.objectContaining({ path: "on", message: "workflow is missing the 'on' trigger section" })]); | |
| 421 | + }); | |
| 422 | + | |
| 423 | + it("missing jobs", () => { | |
| 424 | + const e = fatal("name: x\non: push\n"); | |
| 425 | + expect(e).toEqual([expect.objectContaining({ path: "jobs", message: "workflow is missing the 'jobs' section" })]); | |
| 426 | + expect(fatal("on: push\njobs: {}\n")[0]?.message).toMatch(/at least one job/); | |
| 427 | + }); | |
| 428 | + | |
| 429 | + it("job missing runs-on, with line number of the job key", () => { | |
| 430 | + const e = fatal(`on: push\njobs:\n ok:\n runs-on: x\n steps: [{run: a}]\n build:\n steps:\n - run: echo\n`); | |
| 431 | + expect(e).toEqual([{ path: "jobs.build", line: 6, col: 3, message: "job 'build' is missing 'runs-on'" }]); | |
| 432 | + }); | |
| 433 | + | |
| 434 | + it("reusable workflow job does not need runs-on but cannot have steps", () => { | |
| 435 | + expect(errs(`on: push\njobs:\n call:\n uses: ./.github/workflows/x.yml\n`)).toEqual([]); | |
| 436 | + const e = fatal(`on: push\njobs:\n call:\n uses: ./.github/workflows/x.yml\n steps:\n - run: a\n`); | |
| 437 | + expect(e[0]?.message).toMatch(/cannot have 'steps'/); | |
| 438 | + expect(fatal(`on: push\njobs:\n call:\n uses: not-a-workflow\n`)[0]?.message).toMatch(/reusable workflow reference/); | |
| 439 | + }); | |
| 440 | + | |
| 441 | + it("step with both or neither run and uses", () => { | |
| 442 | + const e = fatal(`on: push | |
| 443 | +jobs: | |
| 444 | + build: | |
| 445 | + runs-on: ubuntu-latest | |
| 446 | + steps: | |
| 447 | + - run: echo | |
| 448 | + uses: actions/checkout@v4 | |
| 449 | + - name: nothing | |
| 450 | +`); | |
| 451 | + expect(e).toEqual([ | |
| 452 | + { path: "jobs.build.steps[0]", line: 6, col: 9, message: "step cannot have both 'run' and 'uses'" }, | |
| 453 | + { path: "jobs.build.steps[1]", line: 8, col: 9, message: "step must have either 'run' or 'uses'" }, | |
| 454 | + ]); | |
| 455 | + }); | |
| 456 | + | |
| 457 | + it("job without steps", () => { | |
| 458 | + expect(fatal(`on: push\njobs:\n b:\n runs-on: x\n`)[0]?.message).toMatch(/must have a list of 'steps'/); | |
| 459 | + }); | |
| 460 | + | |
| 461 | + it("duplicate step ids and invalid job ids", () => { | |
| 462 | + const e = fatal(`on: push\njobs:\n 1bad:\n runs-on: x\n steps:\n - id: a\n run: x\n - id: a\n run: y\n`); | |
| 463 | + expect(e.map((x) => x.message)).toEqual([expect.stringMatching(/job id '1bad'/), expect.stringMatching(/duplicate step id 'a'/)]); | |
| 464 | + }); | |
| 465 | + | |
| 466 | + it("unknown needs", () => { | |
| 467 | + const e = fatal(`on: push\njobs:\n a:\n runs-on: x\n needs: [nope]\n steps: [{run: a}]\n`); | |
| 468 | + expect(e).toEqual([{ path: "jobs.a.needs", line: 5, col: 5, message: "Job 'a' depends on unknown job 'nope'" }]); | |
| 469 | + }); | |
| 470 | + | |
| 471 | + it("dependency cycles", () => { | |
| 472 | + const e = fatal(`on: push | |
| 473 | +jobs: | |
| 474 | + a: | |
| 475 | + runs-on: x | |
| 476 | + needs: c | |
| 477 | + steps: [{run: a}] | |
| 478 | + b: | |
| 479 | + runs-on: x | |
| 480 | + needs: a | |
| 481 | + steps: [{run: a}] | |
| 482 | + c: | |
| 483 | + runs-on: x | |
| 484 | + needs: b | |
| 485 | + steps: [{run: a}] | |
| 486 | +`); | |
| 487 | + expect(e).toHaveLength(1); | |
| 488 | + expect(e[0]?.message).toBe("Dependency cycle between jobs: a -> c -> b -> a"); | |
| 489 | + expect(e[0]?.line).toBe(5); | |
| 490 | + }); | |
| 491 | + | |
| 492 | + it("invalid cron", () => { | |
| 493 | + const e = fatal(`on:\n schedule:\n - cron: '0 0 * *'\n${minimalJob}`); | |
| 494 | + expect(e[0]).toMatchObject({ path: "on.schedule[0].cron", line: 3 }); | |
| 495 | + expect(e[0]?.message).toMatch(/Invalid cron: .*5 fields/); | |
| 496 | + expect(fatal(`on:\n schedule:\n - cron: '61 * * * *'\n${minimalJob}`)[0]?.message).toMatch(/out of range/); | |
| 497 | + }); | |
| 498 | + | |
| 499 | + it("unclosed ${{", () => { | |
| 500 | + const e = fatal(`on: push | |
| 501 | +jobs: | |
| 502 | + build: | |
| 503 | + runs-on: ubuntu-latest | |
| 504 | + steps: | |
| 505 | + - run: echo \${{ github.ref | |
| 506 | +`); | |
| 507 | + expect(e).toEqual([ | |
| 508 | + expect.objectContaining({ path: "jobs.build.steps[0].run", line: 6, message: expect.stringMatching(/Unclosed expression/) }), | |
| 509 | + ]); | |
| 510 | + }); | |
| 511 | + | |
| 512 | + it("expression syntax errors, including bare if conditions", () => { | |
| 513 | + const e = fatal(`on: push | |
| 514 | +jobs: | |
| 515 | + build: | |
| 516 | + if: github.event_name == | |
| 517 | + runs-on: ubuntu-latest | |
| 518 | + steps: | |
| 519 | + - run: echo \${{ nosuch(1) }} | |
| 520 | + - run: echo ok | |
| 521 | + if: \${{ (1 }} | |
| 522 | +`); | |
| 523 | + expect(e.map((x) => x.path)).toEqual(["jobs.build.if", "jobs.build.steps[0].run", "jobs.build.steps[1].if"]); | |
| 524 | + expect(e[1]?.message).toMatch(/Unrecognized function 'nosuch'/); | |
| 525 | + }); | |
| 526 | + | |
| 527 | + it("invalid dispatch inputs and push filter conflicts", () => { | |
| 528 | + const e = fatal(`on: | |
| 529 | + push: | |
| 530 | + branches: [main] | |
| 531 | + branches-ignore: [dev] | |
| 532 | + workflow_dispatch: | |
| 533 | + inputs: | |
| 534 | + a: | |
| 535 | + type: chioce | |
| 536 | + b: | |
| 537 | + type: choice | |
| 538 | +${minimalJob}`); | |
| 539 | + expect(e.map((x) => x.message)).toEqual([ | |
| 540 | + "'branches' and 'branches-ignore' cannot be used together for the same event", | |
| 541 | + expect.stringMatching(/invalid type 'chioce'/), | |
| 542 | + expect.stringMatching(/must define 'options'/), | |
| 543 | + ]); | |
| 544 | + }); | |
| 545 | + | |
| 546 | + it("invalid static matrix", () => { | |
| 547 | + const e = fatal(`on: push\njobs:\n b:\n runs-on: x\n strategy:\n matrix:\n os: ubuntu\n steps: [{run: a}]\n`); | |
| 548 | + expect(e[0]?.message).toMatch(/matrix.os must be a list/); | |
| 549 | + }); | |
| 550 | + | |
| 551 | + it("unknown keys are warnings and do not block the workflow", () => { | |
| 552 | + const res = parseWorkflow( | |
| 553 | + `on:\n push:\n pushh:\nfoo: 1\njobs:\n b:\n runs-on: x\n bar: 1\n steps:\n - run: a\n baz: 1\n`, | |
| 554 | + "w.yml", | |
| 555 | + ); | |
| 556 | + expect(res.workflow).toBeDefined(); | |
| 557 | + expect(res.errors.map((e) => [e.path, e.line, e.message])).toEqual([ | |
| 558 | + ["on.pushh", 3, "warning: unknown event 'pushh'"], | |
| 559 | + ["foo", 4, "warning: unknown top-level key 'foo'"], | |
| 560 | + ["jobs.b.bar", 8, "warning: unknown job key 'bar'"], | |
| 561 | + ["jobs.b.steps[0].baz", 11, "warning: unknown step key 'baz'"], | |
| 562 | + ]); | |
| 563 | + expect(res.errors.every(isWarning)).toBe(true); | |
| 564 | + }); | |
| 565 | + | |
| 566 | + it("type errors in env and with", () => { | |
| 567 | + const e = fatal(`on: push\nenv:\n A: [1]\n${minimalJob}`); | |
| 568 | + expect(e[0]).toMatchObject({ path: "env.A", message: "env.A must be a string, number or boolean" }); | |
| 569 | + }); | |
| 570 | +}); | |
| 571 | + | |
| 572 | +describe("jobOrder", () => { | |
| 573 | + it("groups jobs into levels in file order", () => { | |
| 574 | + const wf = ok(`on: push | |
| 575 | +jobs: | |
| 576 | + lint: {runs-on: x, steps: [{run: a}]} | |
| 577 | + test: {runs-on: x, steps: [{run: a}]} | |
| 578 | + build: {runs-on: x, needs: [lint, test], steps: [{run: a}]} | |
| 579 | + docs: {runs-on: x, needs: lint, steps: [{run: a}]} | |
| 580 | + deploy: {runs-on: x, needs: [build, docs], steps: [{run: a}]} | |
| 581 | +`); | |
| 582 | + expect(jobOrder(wf)).toEqual({ order: [["lint", "test"], ["build", "docs"], ["deploy"]], errors: [] }); | |
| 583 | + }); | |
| 584 | + | |
| 585 | + it("reports unknown needs and cycles on a constructed workflow", () => { | |
| 586 | + const wf = ok(`on: push\n${minimalJob}`); | |
| 587 | + const job = wf.jobs.build!; | |
| 588 | + wf.jobs = { | |
| 589 | + a: { ...job, key: "a", needs: ["b"] }, | |
| 590 | + b: { ...job, key: "b", needs: ["a"] }, | |
| 591 | + c: { ...job, key: "c", needs: ["zzz"] }, | |
| 592 | + }; | |
| 593 | + const res = jobOrder(wf); | |
| 594 | + expect(res.order).toEqual([["c"]]); | |
| 595 | + expect(res.errors.map((e) => e.message)).toEqual([ | |
| 596 | + "Job 'c' depends on unknown job 'zzz'", | |
| 597 | + "Dependency cycle between jobs: a -> b -> a", | |
| 598 | + ]); | |
| 599 | + }); | |
| 600 | +}); |