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.

CI: test verdicts that don't depend on which host a runner lands on (#203)

* CI: test verdicts that don't depend on which host a runner lands on
test-bot authored 1 parent c74f242commit e14fcb18a6790fc06cf8119ce8e7487ec6a0faa8Browse files
15 files changed+134−28
Files (15)
addedapps/cli/vitest.config.ts+4−0View file
@@ -1,0 +1,4 @@
1+import { defineConfig } from "vitest/config";
2+import { timeouts } from "../../vitest.shared";
3+
4+export default defineConfig({ test: timeouts });
modifiedapps/diffinery/docs/actions/runners.md+1−1View file
@@ -169,7 +169,7 @@
169169
170170 If the container stops unexpectedly (for example it runs out of memory), the job fails with `The runner stopped unexpectedly (exit code N).` A container that stops before its runner fetched the job is replaced instead, since no step ran.
171171
172-The log's first lines name where the job ran, for example `Runner: image diffinery-runner:96dbbe31790f, in fra06, EEUR, container 6f1c…`. The jobs API returns the same as `runner` (`image`, `location`, `instance`), next to `runner_name`. `GET /meta` names the image the last deploy set as `runner_image`. While a deploy's rollout is updating instances, a new container can still start on the previous image. Diffinery turns that runner away before it runs anything and starts the job again on another container (after 30 s, then 1, 2 and 4 minutes). After four tries, the job runs on whatever image it got rather than wait for a rollout that has stalled. The jobs API's `placementTries` counts the times a job went back in line before a runner took it, and `restarts` counts the times it started again because the platform replaced its runner.
172+The log's first lines name where the job ran, for example `Runner: image diffinery-runner:96dbbe31790f, in fra06, EEUR, container 6f1c…`. On hosted runners a `Host:` line names the host's processor (Cloudflare's hosts mix AMD EPYC generations, so the same job can run up to about twice as fast on one host as on another), and the line before the job's conclusion says how much of the vCPUs' time the host gave to other work (steal). The jobs API returns the same as `runner` (`image`, `location`, `instance`, and `cpu` on hosted runners), next to `runner_name`. `GET /meta` names the image the last deploy set as `runner_image`. While a deploy's rollout is updating instances, a new container can still start on the previous image. Diffinery turns that runner away before it runs anything and starts the job again on another container (after 30 s, then 1, 2 and 4 minutes). After four tries, the job runs on whatever image it got rather than wait for a rollout that has stalled. The jobs API's `placementTries` counts the times a job went back in line before a runner took it, and `restarts` counts the times it started again because the platform replaced its runner.
173173
174174 ### Windows and macOS
175175
modifiedapps/diffinery/src/repo/actions.ts+3−3View file
@@ -231,8 +231,8 @@
231231 usage?: string | null;
232232 };
233233
234-/** Where a job ran, as its runner reported it: the runner image, the container's location and instance. */
235-export type JobRunnerInfo = { image?: string; location?: string; instance?: string };
234+/** Where a job ran, as its runner reported it: the runner image, the container's location and instance, and the host's processor. */
235+export type JobRunnerInfo = { image?: string; location?: string; instance?: string; cpu?: string };
236236
237237 /** Reads the runner's `x-diffinery-runner` header (JSON), keeping only short string fields. */
238238 export function parseRunnerInfo(header: string | null | undefined): JobRunnerInfo | null {
@@ -240,7 +240,7 @@
240240 try {
241241 const raw = JSON.parse(header) as Record<string, unknown>;
242242 const out: JobRunnerInfo = {};
243- for (const k of ["image", "location", "instance"] as const) if (typeof raw[k] === "string" && raw[k]) out[k] = (raw[k] as string).slice(0, 200);
243+ for (const k of ["image", "location", "instance", "cpu"] as const) if (typeof raw[k] === "string" && raw[k]) out[k] = (raw[k] as string).slice(0, 200);
244244 return Object.keys(out).length ? out : null;
245245 } catch {
246246 return null;
modifiedapps/diffinery/test/unit/actions-runtime.test.ts+1−1View file
@@ -976,7 +976,7 @@
976976
977977 describe("runner info", () => {
978978 it("keeps only the runner's short image, location and instance strings", () => {
979- expect(parseRunnerInfo(JSON.stringify({ image: "diffinery-runner:abc", location: "fra06, EEUR", instance: "d0", token: "x", n: 1 }))).toEqual({ image: "diffinery-runner:abc", location: "fra06, EEUR", instance: "d0" });
979+ expect(parseRunnerInfo(JSON.stringify({ image: "diffinery-runner:abc", location: "fra06, EEUR", instance: "d0", cpu: "AMD EPYC (family 25, model 17), 4 vCPUs, 2550 MHz", token: "x", n: 1 }))).toEqual({ image: "diffinery-runner:abc", location: "fra06, EEUR", instance: "d0", cpu: "AMD EPYC (family 25, model 17), 4 vCPUs, 2550 MHz" });
980980 expect(parseRunnerInfo(JSON.stringify({ image: "x".repeat(300) }))?.image).toHaveLength(200);
981981 for (const bad of [null, "", "not json", "{}", JSON.stringify({ image: 5 }), "x".repeat(2000)]) expect(parseRunnerInfo(bad)).toBeNull();
982982 });
modifiedapps/diffinery/vitest.config.ts+2−0View file
@@ -1,10 +1,12 @@
11 import { fileURLToPath } from "node:url";
22 import { defineConfig } from "vitest/config";
3+import { timeouts } from "../../vitest.shared";
34
45 // The path is base64-encoded so the id does not look like a node_modules file, which vitest would hand to Node unprocessed.
56 const PREFIX = "virtual:wasm-module/";
67
78 export default defineConfig({
9+ test: timeouts,
810 resolve: {
911 alias: {
1012 "cloudflare:workflows": fileURLToPath(new URL("./test/unit/stubs/cloudflare-workflows.ts", import.meta.url)),
modifiedcontainers/runner/src/api.ts+34−0View file
@@ -127,9 +127,43 @@
127127 const location = [env.CLOUDFLARE_LOCATION, env.CLOUDFLARE_REGION].filter(Boolean).join(", ");
128128 if (location) out.location = location;
129129 if (env.CLOUDFLARE_DURABLE_OBJECT_ID) out.instance = env.CLOUDFLARE_DURABLE_OBJECT_ID;
130+ const cpu = out.image ? hostCpu() : undefined;
131+ if (cpu) out.cpu = cpu;
130132 return out;
131133 }
132134
135+const readText = (file: string): string => {
136+ try {
137+ return readFileSync(file, "utf8");
138+ } catch {
139+ return "";
140+ }
141+};
142+
143+/**
144+ * The host's processor as the container sees it. Cloudflare's hosts mix AMD EPYC generations whose
145+ * cores differ about 2x in speed (Zen 2, 3 and 4 were all seen on 2026-10-16), so a job's speed
146+ * depends on where it lands; the family and model say which one it got.
147+ */
148+export function hostCpu(cpuinfo = readText("/proc/cpuinfo")): string | undefined {
149+ const field = (k: string) => new RegExp(`^${k}\\s*:\\s*(.*)$`, "m").exec(cpuinfo)?.[1]?.trim();
150+ const name = field("model name");
151+ if (!name) return undefined;
152+ const family = field("cpu family");
153+ const model = field("model");
154+ const mhz = Number(field("cpu MHz"));
155+ const vcpus = cpuinfo.match(/^processor\s*:/gm)?.length ?? 0;
156+ return [`${name}${family && model ? ` (family ${family}, model ${model})` : ""}`, vcpus && `${vcpus} vCPUs`, mhz > 0 && `${Math.round(mhz)} MHz`].filter(Boolean).join(", ");
157+}
158+
159+/** The vCPUs' time so far, from /proc/stat: all of it, and what the hypervisor gave to other work ("steal"). */
160+export function cpuTimes(stat = readText("/proc/stat")): { total: number; steal: number } | null {
161+ const f = /^cpu\s+(.*)$/m.exec(stat)?.[1]?.trim().split(/\s+/).map(Number);
162+ if (!f || f.length < 8 || f.some((n) => !Number.isFinite(n))) return null;
163+ // user nice system idle iowait irq softirq steal (guest time is already in user).
164+ return { total: f.slice(0, 8).reduce((a, b) => a + b, 0), steal: f[7]! };
165+}
166+
133167 function redactUrl(u: string): string {
134168 return u.replace(/([?&](?:token|sig|signature)=)[^&]+/gi, "$1***");
135169 }
modifiedcontainers/runner/src/job.ts+16−1View file
@@ -5,7 +5,7 @@
55 import path from "node:path";
66 import type { Conclusion, ContainerSpec, JobCompletion, JobSpec, StepReport, StepSpec } from "@diffinery/shared";
77 import { evaluate, evaluateIf, interpolate, type StatusFns } from "@diffinery/workflow-yaml";
8-import { runnerInfo, type RunnerApi } from "./api.js";
8+import { cpuTimes, runnerInfo, type RunnerApi } from "./api.js";
99 import { parseKeyValueFile, parsePathFile } from "./commands.js";
1010 import { Docker, type Wrap } from "./docker.js";
1111 import { JobLogger, OutputProcessor, type Annotation, type OutputSink } from "./log.js";
@@ -98,6 +98,8 @@
9898 /** Set when the platform signals that it is replacing this runner (a container rollout). */
9999 replacing = false;
100100 private firstFailure: { step?: number; message: string } | null = null;
101+ /** The vCPUs' time when a hosted job started, to say at the end how much of it the host took (steal). */
102+ private cpuAtStart: { total: number; steal: number } | null = null;
101103 private githubCtx: Record<string, unknown> = {};
102104 private defaultEnv: Record<string, string> = {};
103105 /**
@@ -215,10 +217,19 @@
215217 if (conclusion === "failure" && this.firstFailure) completion.failure = { ...this.firstFailure, message: this.masker.mask(this.firstFailure.message) };
216218 if (conclusion === "cancelled") completion.failure = { message: "The job was cancelled." };
217219 if (this.replacing && conclusion !== "success") completion.replaced = true;
220+ const stolen = this.stolenShare();
221+ if (stolen !== null) this.logger.line(-1, `Host: ${stolen}% of the vCPUs' time went to other work on the host (steal)`);
218222 this.logger.line(-1, `Job completed with conclusion: ${conclusion}`);
219223 return completion;
220224 }
221225
226+ /** The share of the vCPUs' time since the job started that the hypervisor gave to other work, in percent. */
227+ private stolenShare(): number | null {
228+ const end = this.cpuAtStart && cpuTimes();
229+ if (!this.cpuAtStart || !end || end.total <= this.cpuAtStart.total) return null;
230+ return Math.round((100 * (end.steal - this.cpuAtStart.steal)) / (end.total - this.cpuAtStart.total));
231+ }
232+
222233 // ------------------------------------------------------------ setup
223234
224235 private async setup(): Promise<void> {
@@ -323,6 +334,10 @@
323334 const where = runnerInfo();
324335 if (Object.keys(where).length) this.logger.line(-1, `Runner: ${[where.image && `image ${where.image}`, where.location && `in ${where.location}`, where.instance && `container ${where.instance}`].filter(Boolean).join(", ")}`);
325336 if (spec.runnerSize) this.logger.line(-1, spec.runnerSize);
337+ if (where.cpu) {
338+ this.logger.line(-1, `Host: ${where.cpu}`);
339+ this.cpuAtStart = cpuTimes();
340+ }
326341 this.logger.line(-1, `Workspace: ${this.workspace}`);
327342 // job-level env
328343 const ctx = this.contexts({ steps: {}, inputs: spec.inputs ?? {}, failed: false, inheritedEnv: {}, composite: false, depth: 0 }, {});
modifiedcontainers/runner/test/job.test.ts+7−1View file
@@ -785,8 +785,14 @@
785785 DIFFINERY_JOB_TOKEN: api.token,
786786 });
787787 expect(r2.status).toBe(1);
788- expect(JSON.parse(api.requests.find((q) => q.path === "/runner/jobs/job-2")?.runner ?? "{}")).toEqual({ image: "diffinery-runner:abc123", location: "fra06, EEUR", instance: "d0" });
788+ const runner = JSON.parse(api.requests.find((q) => q.path === "/runner/jobs/job-2")?.runner ?? "{}");
789+ expect(runner).toMatchObject({ image: "diffinery-runner:abc123", location: "fra06, EEUR", instance: "d0" });
789790 expect(api.logText()).toContain("Runner: image diffinery-runner:abc123, in fra06, EEUR, container d0");
791+ // A hosted runner also names its host's processor and, at the end, the share of its vCPUs' time the host took.
792+ if (existsSync("/proc/cpuinfo")) {
793+ expect(api.logText()).toContain(`Host: ${runner.cpu}`);
794+ expect(api.logText()).toMatch(/Host: \d+% of the vCPUs' time went to other work on the host \(steal\)/);
795+ }
790796 expect(api.completion).toMatchObject({ conclusion: "failure", failure: { step: 0 } });
791797 expect(readFileSync(path.join(workRoot, "w2/_temp/_github_workflow/event.json"), "utf8")).toContain("pusher");
792798
modifiedcontainers/runner/test/unit.test.ts+11−1View file
@@ -1,7 +1,7 @@
11 import { mkdirSync, writeFileSync } from "node:fs";
22 import path from "node:path";
33 import { describe, expect, it } from "vitest";
4-import { LogBatcher } from "../src/api.js";
4+import { LogBatcher, cpuTimes, hostCpu } from "../src/api.js";
55 import { parseCommand, parseKeyValueFile, parsePathFile } from "../src/commands.js";
66 import { Masker } from "../src/mask.js";
77 import { parseUses, inputEnvName } from "../src/steps/actions.js";
@@ -228,3 +228,13 @@
228228 expect(received).toEqual(Array.from({ length: 10 }, (_, i) => `l${i}`));
229229 });
230230 });
231+
232+describe("host", () => {
233+ it("names the host's processor, and reads the vCPUs' stolen time", () => {
234+ const core = (n: number) => `processor\t: ${n}\nvendor_id\t: AuthenticAMD\ncpu family\t: 25\nmodel\t\t: 17\nmodel name\t: AMD EPYC\ncpu MHz\t\t: 2549.998\n\n`;
235+ expect(hostCpu([0, 1, 2, 3].map(core).join(""))).toBe("AMD EPYC (family 25, model 17), 4 vCPUs, 2550 MHz");
236+ expect(hostCpu("")).toBeUndefined();
237+ expect(cpuTimes("cpu 1498 0 227 267312 359 0 61 514 0 0\ncpu0 1 2 3 4 5 6 7 8 0 0\n")).toEqual({ total: 269971, steal: 514 });
238+ expect(cpuTimes("intr 1 2 3\n")).toBeNull();
239+ });
240+});
addedcontainers/runner/vitest.config.ts+4−0View file
@@ -1,0 +1,4 @@
1+import { defineConfig } from "vitest/config";
2+import { timeouts } from "../../vitest.shared";
3+
4+export default defineConfig({ test: timeouts });
modifiedpackages/git-core/test/pack-limits.test.ts+16−10View file
@@ -51,16 +51,22 @@
5151 const content = (i: number) => Buffer.from(String(i).padStart(8, "0"));
5252 const oids = [await hashObject("blob", content(0))];
5353 for (let i = 1; i <= n; i++) oids.push(await hashObject("blob", content(i)));
54- const entries: Uint8Array[] = [];
55- for (let i = n; i >= 1; i--) entries.push(refDelta(oids[i - 1]!, replace(8, content(i))));
56- entries.push(blob(content(0)));
57- const p = await pack(entries);
58- const started = Date.now();
59- const objects = await parsePack(p);
60- expect(Date.now() - started).toBeLessThan(10_000);
61- expect(objects).toHaveLength(n + 1);
62- expect(new Set(objects.map((o) => o.oid))).toEqual(new Set(oids));
63- }, 30_000);
54+ const deltas: Uint8Array[] = [];
55+ for (let i = 1; i <= n; i++) deltas.push(refDelta(oids[i - 1]!, replace(8, content(i))));
56+ const timed = async (entries: Uint8Array[]) => {
57+ const p = await pack(entries);
58+ const started = performance.now();
59+ const objects = await parsePack(p);
60+ return { ms: performance.now() - started, objects };
61+ };
62+ // Listed base first, every delta is resolvable when it is read: the time one pass takes on this machine.
63+ const onePass = await timed([blob(content(0)), ...deltas]);
64+ const newestFirst = await timed([...deltas.reverse(), blob(content(0))]);
65+ // Compared on the same machine, so a slower CI host doesn't change the verdict. Pass by pass, this took minutes.
66+ expect(newestFirst.ms).toBeLessThan(4 * onePass.ms + 500);
67+ expect(newestFirst.objects).toHaveLength(n + 1);
68+ expect(new Set(newestFirst.objects.map((o) => o.oid))).toEqual(new Set(oids));
69+ }, 120_000);
6470
6571 it("counts what each object costs in memory, not only its content", async () => {
6672 // 20,000 one-byte blobs are a 240 KB pack but cost far more than 240 KB to hold.
modifiedpackages/git-core/test/pack-unpack-stream.test.ts+17−10View file
@@ -64,8 +64,8 @@
6464 return new Uint8Array(Buffer.concat([body, createHash("sha1").update(body).digest()]));
6565 }
6666
67-/** REF_DELTAs, each against the oid given, then one blob. */
68-function refDeltaChain(deltas: { base: string; delta: Uint8Array }[], blob: Uint8Array): Uint8Array {
67+/** REF_DELTAs, each against the oid given, then one blob (or the blob first). */
68+function refDeltaChain(deltas: { base: string; delta: Uint8Array }[], blob: Uint8Array, blobFirst = false): Uint8Array {
6969 const parts: Buffer[] = [Buffer.from("PACK"), Buffer.from([0, 0, 0, 2]), Buffer.alloc(4)];
7070 parts[2]!.writeUInt32BE(deltas.length + 1);
7171 const header = (code: number, size: number) => {
@@ -80,8 +80,10 @@
8080 head.push(c);
8181 return Buffer.from(head);
8282 };
83+ const blobPart = [header(3, blob.length), deflateSync(blob)];
84+ if (blobFirst) parts.push(...blobPart);
8385 for (const d of deltas) parts.push(header(7, d.delta.length), Buffer.from(d.base, "hex"), deflateSync(d.delta));
84- parts.push(header(3, blob.length), deflateSync(blob));
86+ if (!blobFirst) parts.push(...blobPart);
8587 const body = Buffer.concat(parts);
8688 return new Uint8Array(Buffer.concat([body, createHash("sha1").update(body).digest()]));
8789 }
@@ -229,7 +231,7 @@
229231 await expect(parsePack(many, external, { maxBytes: base.length })).rejects.toThrow(/unpacks to more than/);
230232 });
231233
232- it("resolves a long delta chain in one pass", { timeout: 60_000 }, async () => {
234+ it("resolves a long delta chain in one pass", { timeout: 120_000 }, async () => {
233235 // Each delta's base is listed after it: resolving round by round took a round, and a pass over
234236 // every delta left, per link.
235237 const n = 20_000;
@@ -239,12 +241,17 @@
239241 const oids = await Promise.all(contents.map((c) => hashObject("blob", c)));
240242 // Copy the 64 bytes the chain shares, then insert the two that number it.
241243 const deltas = contents.slice(1).map((c, i) => ({ base: oids[i]!, delta: new Uint8Array([...varint(contents[i]!.length), ...varint(c.length), 0x90, 64, 2, c[64]!, c[65]!]) }));
242- const pack = refDeltaChain(deltas.reverse(), blob);
243- const t = Date.now();
244- const seen = new Map<string, Uint8Array>();
245- await StreamedPack.open(source(pack), {}, (o, content) => void seen.set(o.oid, content!));
246- // Round by round, this took most of a minute.
247- expect(Date.now() - t).toBeLessThan(20_000);
244+ const timed = async (pack: Uint8Array) => {
245+ const seen = new Map<string, Uint8Array>();
246+ const t = performance.now();
247+ await StreamedPack.open(source(pack), {}, (o, content) => void seen.set(o.oid, content!));
248+ return { ms: performance.now() - t, seen };
249+ };
250+ // Listed base first, every delta is resolvable when it is read: the time one pass takes on this machine.
251+ const onePass = await timed(refDeltaChain(deltas, blob, true));
252+ const { ms, seen } = await timed(refDeltaChain(deltas.reverse(), blob));
253+ // Compared on the same machine, so a slower CI host doesn't change the verdict. Round by round, this took most of a minute.
254+ expect(ms).toBeLessThan(4 * onePass.ms + 500);
248255 expect(seen.size).toBe(n + 1);
249256 expect(Buffer.from(seen.get(oids[n]!)!).equals(Buffer.from(contents[n]!))).toBe(true);
250257 });
addedpackages/git-core/vitest.config.ts+4−0View file
@@ -1,0 +1,4 @@
1+import { defineConfig } from "vitest/config";
2+import { timeouts } from "../../vitest.shared";
3+
4+export default defineConfig({ test: timeouts });
addedpackages/workflow-yaml/vitest.config.ts+4−0View file
@@ -1,0 +1,4 @@
1+import { defineConfig } from "vitest/config";
2+import { timeouts } from "../../vitest.shared";
3+
4+export default defineConfig({ test: timeouts });
addedvitest.shared.ts+10−0View file
@@ -1,0 +1,10 @@
1+/**
2+ * Unit-test settings every package shares.
3+ *
4+ * CI runs on Cloudflare's hosted runners, and Cloudflare places them on hosts whose cores differ
5+ * about 2x in speed (AMD EPYC Zen 2, 3 and 4 were all seen on 2026-10-16), some of them busy enough
6+ * to stall a container's vCPUs for seconds. A test's timeout only catches a hang, so it allows for
7+ * the slowest of those hosts; a test that checks speed says so itself, comparing against work done
8+ * on the same machine rather than against a fixed number of seconds.
9+ */
10+export const timeouts = { testTimeout: 30_000, hookTimeout: 30_000 };