Diffinery

codician/diffinerypublic

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

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
Claude authored signed1 parent 5842b9dcommit 5b42824ad75ea2acf5b036d9a724c2847bc1ab11Browse files
44 files changed+8687−9
Files (44)
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 @@
44 "private": true,
55 "type": "module",
66 "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);\"",
88 "typecheck": "tsc --noEmit",
99 "test": "vitest run"
1010 },
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 @@
1919 method?: string;
2020 /** JSON body, or a factory producing a raw body (re-invoked on retry). */
2121 json?: unknown;
22- body?: () => Promise<{ body: BodyInit; length?: number; contentType?: string }>;
22+ body?: () => Promise<{ body: NonNullable<RequestInit["body"]>; length?: number; contentType?: string }>;
2323 retries?: number;
2424 /** Return the response for these statuses instead of throwing. */
2525 allow?: number[];
@@ -134,7 +134,7 @@
134134 method: "PUT",
135135 body: async () => {
136136 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 };
138138 },
139139 allow: [409],
140140 });
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 @@
107107 for (const entry of include) {
108108 let added = false;
109109 for (const combo of combos) {
110- // Original vector values are never overwritten, so checking `combo` is enough.
111110 const fits = Object.keys(entry).every((k) => !originalKeys.has(k) || deepEqual(combo[k], entry[k]));
112111 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;
114114 added = true;
115115 }
116116 }
modifiedpackages/workflow-yaml/src/parse.ts+4−4View file
@@ -637,7 +637,7 @@
637637 /** Checks every string for unclosed `${{` and expression syntax errors. */
638638 function checkExpressions(c: Context, value: unknown, path: Seg[]): void {
639639 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"));
641641 if (isIf && !value.includes("${{")) {
642642 if (value.trim() !== "") {
643643 const err = checkExpressionSyntax(value);
@@ -676,7 +676,7 @@
676676 if (doc.errors.length > 0) {
677677 return {
678678 errors: doc.errors.map((e) => {
679- const pos = e.linePos?.[0];
679+ const pos = e.linePos?.[0] ?? lc.linePos(e.pos[0]);
680680 return { path: "", ...(pos ? { line: pos.line, col: pos.col } : {}), message: `YAML syntax error: ${e.message.split("\n")[0]}` };
681681 }),
682682 };
@@ -707,7 +707,7 @@
707707 const runName = c.str(raw["run-name"], ["run-name"]);
708708 if (runName !== undefined) wf.runName = runName;
709709
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");
711711 else wf.on = normalizeOn(c, raw.on);
712712
713713 const permissions = normalizePermissions(c, raw.permissions, ["permissions"]);
@@ -716,7 +716,7 @@
716716 if (concurrency) wf.concurrency = concurrency;
717717
718718 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");
720720 } else if (!isObj(raw.jobs)) {
721721 c.error(["jobs"], "'jobs' must be a mapping of job id to job");
722722 } 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+});