@voidbase-cloud/voidbase 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +15 -7
- package/bin/voidbase.ts +58 -6
- package/docs/adapter.md +271 -0
- package/docs/ci.md +195 -0
- package/docs/deploy.md +60 -5
- package/docs/releasing.md +39 -31
- package/hooks-plugin.ts +15 -4
- package/package.json +9 -4
- package/routes/api/[...path].ts +0 -5
- package/scripts/cf-builds.ts +228 -0
- package/scripts/ci-browser.sh +60 -0
- package/scripts/ci-cache.sh +40 -0
- package/scripts/ci-lib.sh +40 -0
- package/scripts/ci-oracles.sh +19 -0
- package/scripts/ci-plan.ts +270 -0
- package/scripts/ci-status.ts +126 -0
- package/scripts/ci-suites.sh +11 -1
- package/scripts/ci.sh +188 -0
- package/scripts/gh-release.ts +48 -0
- package/scripts/release.sh +104 -0
- package/scripts/seed-reference.sh +7 -2
- package/scripts/sync-app.ts +1 -0
- package/src/adapter/bundle.ts +130 -0
- package/src/adapter/codegen.ts +269 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/plugin.ts +117 -0
- package/src/adapter/runtime.ts +325 -0
- package/src/adapter/scan.ts +276 -0
- package/src/cloud/rest.ts +10 -2
- package/src/node/assets.ts +7 -1
- package/src/node/cloud-init.ts +14 -0
- package/src/node/deploy-cf.ts +113 -16
- package/src/node/secrets.ts +169 -0
- package/src/node/serve.ts +15 -2
- package/src/server/api.ts +7 -2
- package/src/server/app.ts +6 -1
- package/src/server/hooks/index.ts +27 -1
- package/src/server/hooks/migrations.ts +6 -1
- package/src/server/hooks/runtime.ts +10 -2
- package/src/server/jobs.ts +3 -1
- package/src/server/webauthn.ts +23 -6
- package/tsconfig.json +4 -0
- package/tsconfig.node.json +2 -1
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// Which steps and suites a CI run needs (docs/ci.md, "Incremental runs" and "Hot mode").
|
|
2
|
+
//
|
|
3
|
+
// Inputs are tracked at file level: every check has a set of files (the import closure of its test entry point, plus
|
|
4
|
+
// the runtime it exercises: the Workers server, the Bun runtime or the CLI, resolved through the same import graph),
|
|
5
|
+
// hashed from git blob ids, and it runs only when that hash differs from what the last green run recorded in the
|
|
6
|
+
// status page's status.json (`verified`; CI_STATUS_URL, or ci/public/status.json on a dev machine).
|
|
7
|
+
//
|
|
8
|
+
// Hot mode (CI_HOT=1 or --hot) keeps a run within a time budget (CI_HOT_BUDGET seconds, default 60): typecheck and
|
|
9
|
+
// unit always, then the checks named by the commits (`Tests:` trailer, changed test files), then the suites of the
|
|
10
|
+
// commits' Conventional Commit scopes, then the cheapest of the rest, using the durations the last run recorded;
|
|
11
|
+
// the Bun pass, the browser suites and the starter wait for a normal run. Deferred checks are never marked verified,
|
|
12
|
+
// so the first run without hot mode does them. `Tests: all` in a commit forces a full run; CI_PLAN=full too.
|
|
13
|
+
// bun scripts/ci-plan.ts [--previous <file or url>] [--full] [--hot] [--budget 60] writes .void/ci-plan.{json,txt}
|
|
14
|
+
// bun scripts/ci-plan.ts explain the checks, their file counts and hashes
|
|
15
|
+
// bun scripts/ci-plan.ts affected <file...> the checks that depend on the given files
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { posix, resolve } from "node:path";
|
|
19
|
+
|
|
20
|
+
const ROOT = resolve(import.meta.dir, "..");
|
|
21
|
+
export const CONFORMANCE = ["auth-flows", "backups", "batch", "cascade", "filter-corpus", "filters-extra", "hardening", "logs-crons", "manage-rule", "oauth2", "otp-mfa", "protected-files", "providers", "rules", "s3", "security", "settings", "sql", "thumbs", "views", "compare", "records", "realtime", "collections"];
|
|
22
|
+
export const BROWSER = ["panel-smoke", "panel-collections", "panel-records", "panel-admin", "panel-login"];
|
|
23
|
+
export const isBrowserKey = (key: string) => key.startsWith("suite:") && BROWSER.includes(key.slice(6));
|
|
24
|
+
|
|
25
|
+
// ---- what each check is: its kind, how hot mode treats it, the test file that names it, a default duration
|
|
26
|
+
export type HotClass = "mandatory" | "candidate" | "deferred";
|
|
27
|
+
export interface KeyMeta { kind: "step" | "suite" | "bun"; hot: HotClass; test?: string; seconds: number }
|
|
28
|
+
export const KEY_META: Record<string, KeyMeta> = {
|
|
29
|
+
"step:typecheck": { kind: "step", hot: "mandatory", seconds: 15 },
|
|
30
|
+
"step:unit": { kind: "step", hot: "mandatory", seconds: 1 },
|
|
31
|
+
"step:deploy-cf": { kind: "step", hot: "candidate", test: "test/deploy-cf.ts", seconds: 8 },
|
|
32
|
+
"step:fresh-db": { kind: "step", hot: "candidate", test: "test/fresh-db.ts", seconds: 13 },
|
|
33
|
+
"step:mail-http": { kind: "step", hot: "candidate", test: "test/mail-http.ts", seconds: 13 },
|
|
34
|
+
"step:exe-smoke": { kind: "step", hot: "candidate", test: "test/exe-smoke.ts", seconds: 11 },
|
|
35
|
+
"step:starter": { kind: "step", hot: "deferred", test: "test/starter-smoke.ts", seconds: 28 },
|
|
36
|
+
"step:adapter": { kind: "step", hot: "candidate", test: "test/adapter.ts", seconds: 20 },
|
|
37
|
+
};
|
|
38
|
+
const SUITE_SECONDS: Record<string, number> = { "auth-flows": 11, backups: 3, batch: 1, cascade: 3, "filter-corpus": 26, "filters-extra": 1, hardening: 57, "logs-crons": 7, "manage-rule": 1, oauth2: 2, "otp-mfa": 20, "protected-files": 1, providers: 1, rules: 3, s3: 5, security: 5, settings: 1, sql: 1, thumbs: 4, views: 1, compare: 1, records: 6, realtime: 1, collections: 2, "sdk-suite": 26, "cloud-rest": 1 };
|
|
39
|
+
for (const s of CONFORMANCE) { KEY_META[`suite:${s}`] = { kind: "suite", hot: "candidate", test: `test/conformance/${s}.ts`, seconds: SUITE_SECONDS[s] ?? 5 }; KEY_META[`bun:${s}`] = { kind: "bun", hot: "deferred", test: `test/conformance/${s}.ts`, seconds: SUITE_SECONDS[s] ?? 5 }; }
|
|
40
|
+
KEY_META["suite:sdk-suite"] = { kind: "suite", hot: "candidate", test: "test/sdk-suite.ts", seconds: 26 };
|
|
41
|
+
KEY_META["bun:sdk-suite"] = { kind: "bun", hot: "deferred", test: "test/sdk-suite.ts", seconds: 26 };
|
|
42
|
+
KEY_META["suite:cloud-rest"] = { kind: "suite", hot: "candidate", test: "test/cloud-rest.ts", seconds: 1 };
|
|
43
|
+
for (const p of BROWSER) KEY_META[`suite:${p}`] = { kind: "suite", hot: "deferred", test: `test/${p}.ts`, seconds: 20 };
|
|
44
|
+
export const KEYS = Object.keys(KEY_META);
|
|
45
|
+
|
|
46
|
+
// the suites a Conventional Commit scope points at (commitlint.config.js lists the scopes)
|
|
47
|
+
export const SCOPE_KEYS: Record<string, string[]> = {
|
|
48
|
+
records: ["suite:records", "suite:batch", "suite:cascade", "suite:filters-extra", "suite:rules", "suite:manage-rule", "suite:filter-corpus"],
|
|
49
|
+
collections: ["suite:collections", "suite:views", "suite:rules"],
|
|
50
|
+
auth: ["suite:auth-flows", "suite:otp-mfa", "suite:security", "suite:providers"],
|
|
51
|
+
oauth2: ["suite:oauth2", "suite:providers"],
|
|
52
|
+
realtime: ["suite:realtime"], hub: ["suite:realtime"],
|
|
53
|
+
jobs: ["suite:auth-flows", "step:mail-http"], mail: ["suite:auth-flows", "step:mail-http"],
|
|
54
|
+
files: ["suite:s3", "suite:thumbs", "suite:protected-files"],
|
|
55
|
+
hooks: ["step:fresh-db"], plugin: ["step:fresh-db"], migrations: ["step:fresh-db", "suite:collections"],
|
|
56
|
+
settings: ["suite:settings"], logs: ["suite:logs-crons"], crons: ["suite:logs-crons"], backups: ["suite:backups"],
|
|
57
|
+
hardening: ["suite:hardening", "suite:security"], deploy: ["step:deploy-cf"], cloud: ["suite:cloud-rest"], bundle: ["suite:cloud-rest"],
|
|
58
|
+
cli: ["step:exe-smoke"], adapter: ["step:adapter"], serve: ["bun:records", "bun:collections", "bun:auth-flows"], panel: ["suite:panel-smoke"], starter: ["step:starter"],
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ---- the decision: what runs, given current hashes and the last green run's verified hashes
|
|
62
|
+
export interface Decision { run: boolean; reason: string }
|
|
63
|
+
export type Decisions = Record<string, Decision>;
|
|
64
|
+
export interface CommitSignals { scopes: string[]; tests: string[]; full: boolean; changed: string[]; releasable: boolean; dryRun: boolean; releaseMerge: boolean }
|
|
65
|
+
export interface DecideOptions { full?: boolean; browser?: boolean; hot?: boolean; budget?: number; seconds?: Record<string, number>; signals?: CommitSignals }
|
|
66
|
+
const suiteName = (key: string) => key.replace(/^(suite|bun|step):/, "");
|
|
67
|
+
export function decide(hashes: Record<string, string>, previous: Record<string, string> | null, opts: DecideOptions = {}): Decisions {
|
|
68
|
+
const d: Decisions = {};
|
|
69
|
+
const full = opts.full || opts.signals?.full;
|
|
70
|
+
for (const key of KEYS) {
|
|
71
|
+
if (opts.browser === false && (isBrowserKey(key) || key === "step:starter")) d[key] = { run: false, reason: "CI_BROWSER=0" };
|
|
72
|
+
else if (full) d[key] = { run: true, reason: opts.signals?.full ? "full run (Tests: all)" : "full run" };
|
|
73
|
+
else if (!previous) d[key] = { run: true, reason: "no previous record" };
|
|
74
|
+
else if (previous[key] !== hashes[key]) d[key] = { run: true, reason: previous[key] ? "inputs changed" : "never verified" };
|
|
75
|
+
else d[key] = { run: false, reason: "inputs unchanged" };
|
|
76
|
+
}
|
|
77
|
+
if (opts.hot && !full) hot(d, opts);
|
|
78
|
+
derive(d);
|
|
79
|
+
return d;
|
|
80
|
+
}
|
|
81
|
+
/** hot mode: keep the selected checks within the budget, in the order the commits suggest */
|
|
82
|
+
function hot(d: Decisions, opts: DecideOptions) {
|
|
83
|
+
const budget = opts.budget ?? 60, s = opts.signals, secs = (k: string) => opts.seconds?.[k] ?? KEY_META[k]!.seconds;
|
|
84
|
+
const named = new Set((s?.tests ?? []).flatMap((t) => (t === "bun" ? KEYS.filter((k) => k.startsWith("bun:")) : t === "browser" ? KEYS.filter(isBrowserKey) : KEYS.filter((k) => suiteName(k) === t))));
|
|
85
|
+
const scoped = new Set((s?.scopes ?? []).flatMap((sc) => SCOPE_KEYS[sc] ?? []));
|
|
86
|
+
const changedTest = (k: string) => !!KEY_META[k]!.test && (s?.changed ?? []).includes(KEY_META[k]!.test!);
|
|
87
|
+
// the minimal set the commits declare always runs: typecheck and unit, the checks named by a Tests: trailer, the
|
|
88
|
+
// suites of the commits' scopes, and a suite whose own file changed; the budget then buys the cheapest of the rest
|
|
89
|
+
const scopeOf = (k: string) => (s?.scopes ?? []).find((sc) => SCOPE_KEYS[sc]?.includes(k));
|
|
90
|
+
let spent = 0;
|
|
91
|
+
const forced = KEYS.filter((k) => d[k]!.run && (KEY_META[k]!.hot === "mandatory" || named.has(k) || changedTest(k) || scoped.has(k)));
|
|
92
|
+
for (const k of forced) { spent += secs(k); d[k] = { run: true, reason: KEY_META[k]!.hot === "mandatory" ? "hot mode: always" : named.has(k) ? "hot mode: named by the commits" : changedTest(k) ? "hot mode: its test changed" : `hot mode: scope ${scopeOf(k)}` }; }
|
|
93
|
+
const rest = KEYS.filter((k) => d[k]!.run && !forced.includes(k));
|
|
94
|
+
for (const k of rest) if (KEY_META[k]!.hot === "deferred") d[k] = { run: false, reason: "deferred (hot mode)" };
|
|
95
|
+
for (const k of rest.filter((x) => KEY_META[x]!.hot === "candidate").sort((a, b) => secs(a) - secs(b))) {
|
|
96
|
+
if (spent + secs(k) <= budget) { spent += secs(k); d[k] = { run: true, reason: "hot mode: within the budget" }; }
|
|
97
|
+
else d[k] = { run: false, reason: `deferred (hot mode, budget ${budget}s)` };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** the infrastructure steps follow from what is selected */
|
|
101
|
+
function derive(d: Decisions) {
|
|
102
|
+
const any = (prefix: string, filter: (k: string) => boolean = () => true) => Object.keys(d).some((k) => k.startsWith(prefix) && filter(k) && d[k]!.run);
|
|
103
|
+
const suites = any("suite:"), bun = any("bun:"), browser = any("suite:", isBrowserKey) || d["step:starter"]!.run;
|
|
104
|
+
const boot = any("suite:", (k) => k !== "suite:cloud-rest") || d["step:starter"]!.run;
|
|
105
|
+
const reference = any("suite:", (k) => k !== "suite:cloud-rest") || bun || d["step:deploy-cf"]!.run;
|
|
106
|
+
d["step:suites"] = { run: suites, reason: suites ? "suites selected" : "no suite selected" };
|
|
107
|
+
d["step:suites-bun"] = { run: bun, reason: bun ? "suites selected" : "no suite selected" };
|
|
108
|
+
d["step:browser"] = { run: browser, reason: browser ? "a browser suite runs" : "no browser suite runs" };
|
|
109
|
+
d["step:boot"] = { run: boot, reason: boot ? "a suite needs the dev server" : "nothing needs the dev server" };
|
|
110
|
+
d["step:reference"] = { run: reference, reason: reference ? "a suite needs the reference and the mocks" : "nothing needs the reference" };
|
|
111
|
+
const oracles = d["step:typecheck"]!.run || boot || bun || d["step:exe-smoke"]!.run || d["step:deploy-cf"]!.run || d["step:starter"]!.run;
|
|
112
|
+
d["step:oracles"] = { run: oracles, reason: oracles ? "a selected step needs the starter, the panel or the generated types" : "nothing needs the oracles" };
|
|
113
|
+
}
|
|
114
|
+
export const selected = (d: Decisions, prefix: string) => Object.keys(d).filter((k) => k.startsWith(prefix) && d[k]!.run).map((k) => k.slice(prefix.length));
|
|
115
|
+
|
|
116
|
+
// ---- files: the tracked tree, the import graph, the file set of every check
|
|
117
|
+
const git = (a: string[]) => { const r = Bun.spawnSync(["git", ...a], { cwd: ROOT, stdout: "pipe", stderr: "ignore" }); return r.exitCode === 0 ? r.stdout.toString() : ""; };
|
|
118
|
+
const short = (s: string) => createHash("sha256").update(s).digest("hex").slice(0, 16);
|
|
119
|
+
export class Tree {
|
|
120
|
+
blobs = new Map<string, string>(); dirty = new Set<string>(); imports: Record<string, { workerd?: string; default?: string }> = {};
|
|
121
|
+
private parsed = new Map<string, string[]>();
|
|
122
|
+
constructor() {
|
|
123
|
+
for (const line of git(["ls-files", "-s"]).split("\n")) { const m = line.match(/^\d+ ([0-9a-f]{40}) \d\t(.+)$/); if (m) this.blobs.set(m[2]!, m[1]!); }
|
|
124
|
+
for (const line of git(["status", "--porcelain", "--untracked-files=all"]).split("\n")) { const p = line.slice(3).trim(); if (p) this.dirty.add(p.includes(" -> ") ? p.split(" -> ")[1]! : p); }
|
|
125
|
+
try { const pkg = JSON.parse(readFileSync(resolve(ROOT, "package.json"), "utf8")) as { imports?: Record<string, { workerd?: string; default?: string }> }; for (const [k, v] of Object.entries(pkg.imports ?? {})) this.imports[k] = { workerd: v.workerd?.replace(/^\.\//, ""), default: v.default?.replace(/^\.\//, "") }; } catch { /* no aliases */ }
|
|
126
|
+
}
|
|
127
|
+
has(f: string) { return this.blobs.has(f); }
|
|
128
|
+
under(...prefixes: string[]): string[] { return [...this.blobs.keys()].filter((f) => prefixes.some((p) => f === p || f.startsWith(p.endsWith("/") ? p : p + "/"))); }
|
|
129
|
+
/** the import specifiers of a file */
|
|
130
|
+
specs(file: string): string[] {
|
|
131
|
+
if (this.parsed.has(file)) return this.parsed.get(file)!;
|
|
132
|
+
let text = ""; try { text = readFileSync(resolve(ROOT, file), "utf8"); } catch { /* deleted */ }
|
|
133
|
+
const out: string[] = []; const re = /\b(?:import|export)\s*(?:[\w*\s{},$]*?\s*from\s*)?["']([^"']+)["']|\bimport\s*\(\s*["']([^"']+)["']\s*\)|\brequire\s*\(\s*["']([^"']+)["']\s*\)/g;
|
|
134
|
+
for (let m = re.exec(text); m; m = re.exec(text)) out.push(m[1] ?? m[2] ?? m[3]!);
|
|
135
|
+
this.parsed.set(file, out); return out;
|
|
136
|
+
}
|
|
137
|
+
resolveSpec(from: string, spec: string, flavour: "workerd" | "bun"): string | null {
|
|
138
|
+
if (spec.startsWith("#")) { const t = this.imports[spec]; const f = (flavour === "workerd" ? t?.workerd : t?.default) ?? t?.default; return f && this.has(f) ? f : null; }
|
|
139
|
+
if (!spec.startsWith(".")) return null; // packages, void/*, node:*
|
|
140
|
+
const base = posix.normalize(posix.join(posix.dirname(from), spec));
|
|
141
|
+
for (const c of [base, `${base}.ts`, `${base}.tsx`, base.replace(/\.js$/, ".ts"), posix.join(base, "index.ts")]) if (this.has(c)) return c;
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
/** every tracked file reachable from the entries through imports */
|
|
145
|
+
closure(entries: string[], flavour: "workerd" | "bun"): Set<string> {
|
|
146
|
+
const seen = new Set<string>(); const queue = entries.filter((e) => this.has(e));
|
|
147
|
+
while (queue.length) { const f = queue.pop()!; if (seen.has(f)) continue; seen.add(f); if (!/\.(ts|tsx|js|mjs)$/.test(f)) continue; for (const spec of this.specs(f)) { const r = this.resolveSpec(f, spec, flavour); if (r && !seen.has(r)) queue.push(r); } }
|
|
148
|
+
return seen;
|
|
149
|
+
}
|
|
150
|
+
hash(files: Iterable<string>): string {
|
|
151
|
+
const lines = [...files].sort().map((f) => `${f}=${this.blobs.get(f) ?? "?"}${this.dirty.has(f) ? `*${Date.now()}` : ""}`);
|
|
152
|
+
return short(lines.join("\n"));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/** the file sets of every check */
|
|
156
|
+
export function keyFiles(t: Tree): Record<string, Set<string>> {
|
|
157
|
+
const union = (...sets: Iterable<string>[]) => { const s = new Set<string>(); for (const x of sets) for (const f of x) s.add(f); return s; };
|
|
158
|
+
const deps = ["package.json", "bun.lock"];
|
|
159
|
+
const harness = ["scripts/ci.sh", "scripts/ci-lib.sh", "scripts/ci-plan.ts", "scripts/ci-status.ts", "scripts/ci-oracles.sh"];
|
|
160
|
+
const harnessSuites = ["scripts/ci-suites.sh", "scripts/dev.sh", "scripts/seed-reference.sh", "scripts/seed-app-user.sh", "scripts/starter.sh", "scripts/sync-panel.ts", "scripts/sync-app.ts"];
|
|
161
|
+
const harnessBrowser = ["scripts/ci-browser.sh"];
|
|
162
|
+
const config = ["vite.config.ts", "void.json", "hooks-plugin.ts", "env.ts", "wrangler.jsonc", "tsconfig.json", "tsconfig.node.json", "tsconfig.scripts.json"];
|
|
163
|
+
const mocks = t.closure(["test/smtp-sink.ts", "test/mock-oidc.ts", "test/s3-mock.ts", "test/cf-mock.ts"], "bun");
|
|
164
|
+
const SERVER = union(t.closure([...t.under("routes", "crons", "queues"), "hooks-plugin.ts", "vite.config.ts"], "workerd"), t.under("db", "types"), config);
|
|
165
|
+
const BUN = union(t.closure(["src/node/serve.ts"], "bun"), ["bin/voidbase.ts"], t.under("db", "types"));
|
|
166
|
+
const CLI = t.closure(["bin/voidbase.ts"], "bun");
|
|
167
|
+
const common = union(deps, harness);
|
|
168
|
+
const out: Record<string, Set<string>> = {};
|
|
169
|
+
out["step:typecheck"] = union(t.under("src", "routes", "bin", "crons", "queues", "db", "types").filter((f) => /\.tsx?$/.test(f)), ["env.ts", "hooks-plugin.ts", "vite.config.ts", "scripts/cf-builds.ts", "scripts/gh-release.ts", "scripts/ci-status.ts", "scripts/ci-plan.ts"], config, deps);
|
|
170
|
+
out["step:unit"] = union(t.closure(t.under("test/unit"), "bun"), common);
|
|
171
|
+
out["step:deploy-cf"] = union(t.closure(["test/deploy-cf.ts"], "bun"), CLI, SERVER, mocks, common, harnessSuites);
|
|
172
|
+
out["step:fresh-db"] = union(t.closure(["test/fresh-db.ts"], "bun"), SERVER, t.under("test/fixtures"), common);
|
|
173
|
+
out["step:mail-http"] = union(t.closure(["test/mail-http.ts"], "bun"), SERVER, common);
|
|
174
|
+
out["step:exe-smoke"] = union(t.closure(["test/exe-smoke.ts"], "bun"), CLI, ["scripts/build-exe.ts"], common);
|
|
175
|
+
out["step:starter"] = union(t.closure(["test/starter-smoke.ts"], "bun"), SERVER, common, harnessSuites, harnessBrowser);
|
|
176
|
+
// the fixture is a whole Void app the test converts, so every file under it counts, not just what a closure reaches
|
|
177
|
+
out["step:adapter"] = union(t.closure(["test/adapter.ts"], "bun"), SERVER, t.under("src/adapter"), t.under("test/fixtures/void-app"), ["hooks-plugin.ts"], common);
|
|
178
|
+
for (const s of CONFORMANCE) { const own = t.closure([`test/conformance/${s}.ts`], "bun"); out[`suite:${s}`] = union(own, SERVER, mocks, common, harnessSuites); out[`bun:${s}`] = union(own, BUN, mocks, common, harnessSuites); }
|
|
179
|
+
{ const own = t.closure(["test/sdk-suite.ts"], "bun"); out["suite:sdk-suite"] = union(own, SERVER, mocks, common, harnessSuites); out["bun:sdk-suite"] = union(own, BUN, mocks, common, harnessSuites); }
|
|
180
|
+
out["suite:cloud-rest"] = union(t.closure(["test/cloud-rest.ts"], "bun"), mocks, common, harnessSuites);
|
|
181
|
+
for (const p of BROWSER) out[`suite:${p}`] = union(t.closure([`test/${p}.ts`], "bun"), SERVER, mocks, common, harnessSuites, harnessBrowser);
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- the previous record and the commits since it
|
|
186
|
+
interface Record_ { source: string; commit: string; verified: Record<string, string>; seconds: Record<string, number> }
|
|
187
|
+
async function previousRecord(source: string | undefined): Promise<Record_ | null> {
|
|
188
|
+
if (!source) return null;
|
|
189
|
+
try {
|
|
190
|
+
let text: string;
|
|
191
|
+
if (/^https?:\/\//.test(source)) { const res = await fetch(source, { signal: AbortSignal.timeout(15000) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); text = await res.text(); }
|
|
192
|
+
else { if (!existsSync(source)) return null; text = readFileSync(source, "utf8"); }
|
|
193
|
+
const j = JSON.parse(text) as { commit?: string; verified?: Record<string, string>; steps?: { name: string; seconds: number; result: string }[]; suites?: { step: string; name: string; seconds?: number }[] };
|
|
194
|
+
if (!j.verified || typeof j.verified !== "object") { console.log(`plan: ${source} has no verified hashes`); return null; }
|
|
195
|
+
const seconds: Record<string, number> = {};
|
|
196
|
+
for (const s of j.steps ?? []) if (s.result === "ok" && s.seconds > 0) seconds[`step:${s.name}`] = s.seconds;
|
|
197
|
+
for (const s of j.suites ?? []) if (s.seconds) seconds[`${s.step === "suites-bun" ? "bun" : "suite"}:${s.name}`] = s.seconds;
|
|
198
|
+
return { source, commit: j.commit ?? "", verified: j.verified, seconds };
|
|
199
|
+
} catch (e) { console.log(`plan: previous record unavailable (${source}: ${e instanceof Error ? e.message : e})`); return null; }
|
|
200
|
+
}
|
|
201
|
+
/** what the commits since the record say: scopes, `Tests:` trailers, whether a release can change (a feat, fix,
|
|
202
|
+
* perf or revert commit, a breaking change), a `Release: dry-run` trailer, the merge of the release PR */
|
|
203
|
+
export function parseCommits(messages: string[]): Omit<CommitSignals, "changed"> {
|
|
204
|
+
const scopes = new Set<string>(), tests = new Set<string>(); let full = false, releasable = false, dryRun = false, releaseMerge = false;
|
|
205
|
+
for (const m of messages) {
|
|
206
|
+
const head = m.split("\n")[0] ?? ""; const sc = head.match(/^\w+\(([^)]+)\)!?:/); if (sc) for (const s of sc[1]!.split(",")) scopes.add(s.trim());
|
|
207
|
+
if (/^(feat|fix|perf|revert)(\(|!|:)|^[a-z]+(\([^)]*\))?!:/.test(head)) releasable = true;
|
|
208
|
+
if (/^chore\(master\): release|^Merge pull request .*release-please/.test(head)) releaseMerge = true;
|
|
209
|
+
for (const line of m.split("\n")) {
|
|
210
|
+
const t = line.match(/^tests?:\s*(.+)$/i); if (t) for (const name of t[1]!.split(/[\s,]+/).filter(Boolean)) { if (name === "all" || name === "full") full = true; else tests.add(name); }
|
|
211
|
+
if (/^release:\s*dry[- ]?run\s*$/i.test(line)) dryRun = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { scopes: [...scopes], tests: [...tests], full, releasable, dryRun, releaseMerge };
|
|
215
|
+
}
|
|
216
|
+
function commitSignals(prevCommit: string): CommitSignals {
|
|
217
|
+
const isAncestor = () => !!prevCommit && Bun.spawnSync(["git", "merge-base", "--is-ancestor", prevCommit, "HEAD"], { cwd: ROOT, stdout: "ignore", stderr: "ignore" }).exitCode === 0;
|
|
218
|
+
let ancestor = isAncestor();
|
|
219
|
+
// a Cloudflare build checks out the one commit it builds. The record's commit is further back, and a push can
|
|
220
|
+
// carry several commits, so the history is deepened until it is reachable: reading the head commit alone would
|
|
221
|
+
// miss the other commits' scopes, their Tests: trailers and whether anything releasable was pushed.
|
|
222
|
+
if (prevCommit && !ancestor && git(["rev-parse", "--is-shallow-repository"]).trim() === "true") {
|
|
223
|
+
for (const depth of ["50", "500"]) {
|
|
224
|
+
Bun.spawnSync(["git", "fetch", "--quiet", `--deepen=${depth}`, "origin"], { cwd: ROOT, stdout: "ignore", stderr: "ignore" });
|
|
225
|
+
if ((ancestor = isAncestor())) break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const range = ancestor ? `${prevCommit}..HEAD` : "-1";
|
|
229
|
+
const messages = git(["log", "--format=%B%x00", range]).split("\0").map((m) => m.trim()).filter(Boolean);
|
|
230
|
+
const changed = ancestor ? git(["diff", "--name-only", prevCommit, "HEAD"]).split("\n").filter(Boolean) : [];
|
|
231
|
+
const parsed = parseCommits(messages);
|
|
232
|
+
// the release merge is a property of the head commit alone, not of anything older in the range
|
|
233
|
+
const head = git(["log", "-1", "--format=%s"]).trim();
|
|
234
|
+
return { ...parsed, releaseMerge: /^chore\(master\): release|^Merge pull request .*release-please/.test(head), changed };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (import.meta.main) {
|
|
238
|
+
const args = process.argv.slice(2);
|
|
239
|
+
const opt = (name: string) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
|
|
240
|
+
const tree = new Tree(); const files = keyFiles(tree);
|
|
241
|
+
const hashes: Record<string, string> = {}; for (const [key, set] of Object.entries(files)) hashes[key] = tree.hash(set);
|
|
242
|
+
if (args[0] === "explain") { for (const key of KEYS) console.log(`${hashes[key]} ${key}: ${files[key]!.size} files`); process.exit(0); }
|
|
243
|
+
if (args[0] === "affected") { const wanted = args.slice(1); for (const key of KEYS) { const hit = wanted.filter((f) => files[key]!.has(f)); if (hit.length) console.log(`${key} <- ${hit.join(" ")}`); } process.exit(0); }
|
|
244
|
+
const full = args.includes("--full") || process.env.CI_PLAN === "full" || process.env.CI_PLAN === "off";
|
|
245
|
+
const hotMode = args.includes("--hot") || process.env.CI_HOT === "1";
|
|
246
|
+
const budget = Number(opt("budget") ?? process.env.CI_HOT_BUDGET ?? 60);
|
|
247
|
+
const source = opt("previous") ?? process.env.CI_STATUS_URL ?? (existsSync(resolve(ROOT, "ci/public/status.json")) ? resolve(ROOT, "ci/public/status.json") : undefined);
|
|
248
|
+
const previous = full ? null : await previousRecord(source);
|
|
249
|
+
const signals = commitSignals(previous?.commit ?? "");
|
|
250
|
+
const decisions = decide(hashes, previous?.verified ?? null, { full, browser: process.env.CI_BROWSER !== "0", hot: hotMode, budget, seconds: previous?.seconds, signals });
|
|
251
|
+
const commit = git(["rev-parse", "HEAD"]).trim();
|
|
252
|
+
const out = resolve(ROOT, opt("out") ?? ".void"); mkdirSync(out, { recursive: true });
|
|
253
|
+
const changedIn = (key: string) => signals.changed.filter((f) => files[key]!.has(f));
|
|
254
|
+
for (const key of KEYS) { const c = changedIn(key); if (decisions[key]!.run && decisions[key]!.reason === "inputs changed" && c.length) decisions[key]!.reason = `inputs changed (${c.slice(0, 3).join(", ")}${c.length > 3 ? ", ..." : ""})`; }
|
|
255
|
+
const deferred = KEYS.filter((k) => decisions[k]!.reason.startsWith("deferred"));
|
|
256
|
+
writeFileSync(resolve(out, "ci-plan.json"), JSON.stringify({ commit, full, hot: hotMode ? { budget, deferred } : null, signals, previous: previous ? { source: previous.source, commit: previous.commit } : null, previousVerified: previous?.verified ?? {}, hashes, decisions }, null, 2) + "\n");
|
|
257
|
+
const lines = Object.entries(decisions).map(([k, v]) => `${k} ${v.run ? "run" : "skip"} ${v.reason}`);
|
|
258
|
+
lines.push(`suites ${selected(decisions, "suite:").join(" ")}`, `bun ${selected(decisions, "bun:").join(" ")}`);
|
|
259
|
+
lines.push(`release-pr ${signals.releasable ? "yes" : "no"}`, `release-merge ${signals.releaseMerge ? "yes" : "no"}`, `release-dry-run ${signals.dryRun ? "yes" : "no"}`);
|
|
260
|
+
writeFileSync(resolve(out, "ci-plan.txt"), lines.join("\n") + "\n");
|
|
261
|
+
const ran = KEYS.filter((k) => decisions[k]!.run);
|
|
262
|
+
const why = full ? "full run requested" : previous ? `against ${previous.source}${previous.commit ? ` (${previous.commit.slice(0, 10)})` : ""}` : "no previous record, everything runs";
|
|
263
|
+
console.log(`plan: ${ran.length} of ${KEYS.length} checks run, ${KEYS.length - ran.length} skipped${hotMode ? `, hot mode (budget ${budget}s, ${deferred.length} deferred)` : ""}; ${why}`);
|
|
264
|
+
if (signals.changed.length) console.log(` changed: ${signals.changed.length} files (${signals.changed.slice(0, 6).join(", ")}${signals.changed.length > 6 ? ", ..." : ""})`);
|
|
265
|
+
if (signals.scopes.length || signals.tests.length) console.log(` commits: scopes ${signals.scopes.join(", ") || "none"}; Tests: ${signals.tests.join(", ") || "none"}`);
|
|
266
|
+
console.log(` release: ${signals.releaseMerge ? "release merge" : signals.releasable ? "releasable commits, the release PR is refreshed" : "nothing releasable"}${signals.dryRun ? "; dry run requested" : ""}`);
|
|
267
|
+
console.log(` steps: ${["oracles", "typecheck", "unit", "browser", "boot", "reference", "suites", "suites-bun", "deploy-cf", "adapter", "fresh-db", "mail-http", "exe-smoke", "starter"].map((s) => `${s}${decisions[`step:${s}`]!.run ? "" : "(skip)"}`).join(" ")}`);
|
|
268
|
+
console.log(` suites: ${selected(decisions, "suite:").join(" ") || "none"}`);
|
|
269
|
+
console.log(` bun: ${selected(decisions, "bun:").join(" ") || "none"}`);
|
|
270
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// The CI status page: what a Workers Builds deploy of the CI Worker publishes after a build (docs/ci.md) and what
|
|
2
|
+
// GitHub Actions keeps as the `ci-status` artifact. scripts/ci-lib.sh records every step in .void/ci-steps.tsv;
|
|
3
|
+
// `render` turns the steps, the per-suite lines of scripts/ci-suites.sh, the screenshots and the logs into ci/public:
|
|
4
|
+
// index.html, status.json (with the `verified` input hashes scripts/ci-plan.ts compares the next run against: what
|
|
5
|
+
// passed now gets this run's hashes, what was skipped keeps the previous record's), badge.svg and logs/.
|
|
6
|
+
// bun scripts/ci-status.ts render [--kind ci|release] [--out ci/public]
|
|
7
|
+
// bun scripts/ci-status.ts placeholder [--out ci/public] the page before any build ran (the first deploy)
|
|
8
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
interface Step { name: string; result: "ok" | "fail" | "skip"; seconds: number; log: string }
|
|
12
|
+
interface Suite { step: string; name: string; result: "PASS" | "FAIL"; detail: string; seconds?: number }
|
|
13
|
+
|
|
14
|
+
const ROOT = resolve(import.meta.dir, "..");
|
|
15
|
+
const [cmd = "render", ...rest] = process.argv.slice(2);
|
|
16
|
+
const args: Record<string, string> = {};
|
|
17
|
+
for (let i = 0; i < rest.length; i++) if (rest[i]!.startsWith("--")) args[rest[i]!.slice(2)] = rest[i + 1] ?? "1", i++;
|
|
18
|
+
const out = resolve(ROOT, args.out ?? "ci/public");
|
|
19
|
+
const kind = args.kind ?? "ci";
|
|
20
|
+
const git = (a: string[]) => { const r = Bun.spawnSync(["git", ...a], { cwd: ROOT, stdout: "pipe", stderr: "ignore" }); return r.exitCode === 0 ? r.stdout.toString().trim() : ""; };
|
|
21
|
+
const env = process.env;
|
|
22
|
+
const backend = env.CI_BACKEND_NAME ?? (env.WORKERS_CI_BUILD_UUID ? "cloudflare" : env.GITHUB_ACTIONS ? "github" : "local");
|
|
23
|
+
const meta = {
|
|
24
|
+
kind,
|
|
25
|
+
title: kind === "release" ? "voidbase release" : "voidbase CI",
|
|
26
|
+
repository: env.GITHUB_REPOSITORY ?? "voidbase-cloud/voidbase",
|
|
27
|
+
commit: env.WORKERS_CI_COMMIT_SHA ?? env.GITHUB_SHA ?? git(["rev-parse", "HEAD"]),
|
|
28
|
+
subject: git(["log", "-1", "--format=%s"]),
|
|
29
|
+
branch: env.WORKERS_CI_BRANCH ?? env.GITHUB_HEAD_REF ?? env.GITHUB_REF_NAME ?? git(["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
30
|
+
backend,
|
|
31
|
+
build: env.WORKERS_CI_BUILD_UUID ?? env.GITHUB_RUN_ID ?? "",
|
|
32
|
+
finished: new Date().toISOString(),
|
|
33
|
+
};
|
|
34
|
+
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
|
35
|
+
const fmt = (s: number) => (s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`);
|
|
36
|
+
const badge = (label: string, value: string, color: string) => {
|
|
37
|
+
const lw = Math.round(6.5 * label.length) + 12, vw = Math.round(6.5 * value.length) + 12;
|
|
38
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${lw + vw}" height="20" role="img" aria-label="${label}: ${value}"><rect width="${lw}" height="20" rx="3" fill="#555"/><rect x="${lw}" width="${vw}" height="20" rx="3" fill="${color}"/><rect x="${lw}" width="4" height="20" fill="${color}"/><g fill="#fff" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle"><text x="${lw / 2}" y="14">${label}</text><text x="${lw + vw / 2}" y="14">${value}</text></g></svg>\n`;
|
|
39
|
+
};
|
|
40
|
+
const css = `:root{color-scheme:light dark;--bg:#fbfaf7;--fg:#1f2320;--muted:#6b7068;--line:#e3e1da;--ok:#1a7f4b;--fail:#b3261e;--skip:#8a8f88;--card:#ffffff}
|
|
41
|
+
@media (prefers-color-scheme: dark){:root{--bg:#161815;--fg:#e8e6df;--muted:#9a9e95;--line:#2c2f2a;--ok:#4cc38a;--fail:#ff7b72;--skip:#8a8f88;--card:#1e211d}}
|
|
42
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 ui-sans-serif,system-ui,sans-serif}
|
|
43
|
+
main{max-width:64rem;margin:0 auto;padding:2rem 1.25rem 4rem}h1{font-size:1.6rem;margin:0 0 .25rem}h2{font-size:1.05rem;margin:2rem 0 .5rem;letter-spacing:.02em;text-transform:uppercase;color:var(--muted)}
|
|
44
|
+
.state{display:inline-block;padding:.15rem .6rem;border-radius:.4rem;font-weight:600;color:#fff}.state.ok{background:var(--ok)}.state.fail{background:var(--fail)}.state.none{background:var(--skip)}
|
|
45
|
+
dl{display:grid;grid-template-columns:max-content 1fr;gap:.25rem 1rem;margin:1rem 0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.85rem}dt{color:var(--muted)}dd{margin:0;overflow-wrap:anywhere}
|
|
46
|
+
table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:.5rem;overflow:hidden}th,td{text-align:left;padding:.45rem .7rem;border-top:1px solid var(--line);font-size:.9rem;vertical-align:top}th{border-top:0;color:var(--muted);font-weight:600;font-size:.8rem}
|
|
47
|
+
td.r{font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}.ok{color:var(--ok)}.fail{color:var(--fail)}.skip{color:var(--skip)}a{color:inherit}
|
|
48
|
+
.shots{display:grid;grid-template-columns:repeat(auto-fill,minmax(14rem,1fr));gap:1rem}.shots img{width:100%;border:1px solid var(--line);border-radius:.4rem;background:#fff}.shots figcaption{font-size:.8rem;color:var(--muted)}
|
|
49
|
+
details{margin:.5rem 0}summary{cursor:pointer;color:var(--muted)}`;
|
|
50
|
+
|
|
51
|
+
function readSteps(): Step[] {
|
|
52
|
+
const f = resolve(ROOT, ".void/ci-steps.tsv");
|
|
53
|
+
if (!existsSync(f)) return [];
|
|
54
|
+
return readFileSync(f, "utf8").split("\n").filter(Boolean).map((l) => { const [name, result, seconds, log] = l.split("\t"); return { name: name!, result: result as Step["result"], seconds: Number(seconds ?? 0), log: log ?? "" }; });
|
|
55
|
+
}
|
|
56
|
+
function readSuites(steps: Step[]): Suite[] {
|
|
57
|
+
const suites: Suite[] = [];
|
|
58
|
+
for (const s of steps) {
|
|
59
|
+
if (!s.name.startsWith("suites") || !s.log || !existsSync(s.log)) continue;
|
|
60
|
+
for (const line of readFileSync(s.log, "utf8").split("\n")) {
|
|
61
|
+
const m = line.match(/^(PASS|FAIL)\s{2}(\S+)\s*(.*?)\s*(?:\[(\d+)s\])?$/);
|
|
62
|
+
if (m) suites.push({ step: s.name, name: m[2]!, result: m[1] as Suite["result"], detail: m[3]!.trim(), ...(m[4] ? { seconds: Number(m[4]) } : {}) });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return suites;
|
|
66
|
+
}
|
|
67
|
+
interface Plan { commit?: string; full?: boolean; hot?: { budget: number; deferred: string[] } | null; signals?: { scopes: string[]; tests: string[]; changed: string[] }; previous?: { source: string; commit: string } | null; previousVerified?: Record<string, string>; hashes?: Record<string, string>; decisions?: Record<string, { run: boolean; reason: string }> }
|
|
68
|
+
function readPlan(): Plan | null { const f = resolve(ROOT, ".void/ci-plan.json"); return existsSync(f) ? (JSON.parse(readFileSync(f, "utf8")) as Plan) : null; }
|
|
69
|
+
/** the input hashes the next run may trust: this run's for what passed, the previous record's for what was skipped */
|
|
70
|
+
function verifiedHashes(plan: Plan | null, steps: Step[], suites: Suite[]): Record<string, string> {
|
|
71
|
+
if (!plan?.hashes) return {};
|
|
72
|
+
const out: Record<string, string> = {}; const prev = plan.previousVerified ?? {};
|
|
73
|
+
const stepResult = (name: string) => steps.find((x) => x.name === name)?.result;
|
|
74
|
+
const passed = new Set(suites.filter((x) => x.result === "PASS").map((x) => `${x.step === "suites-bun" ? "bun" : "suite"}:${x.name}`));
|
|
75
|
+
const failed = new Set(suites.filter((x) => x.result === "FAIL").map((x) => `${x.step === "suites-bun" ? "bun" : "suite"}:${x.name}`));
|
|
76
|
+
for (const [key, hash] of Object.entries(plan.hashes)) {
|
|
77
|
+
const ran = plan.decisions?.[key]?.run ?? true;
|
|
78
|
+
if (key.startsWith("step:")) { const r = stepResult(key.slice(5)); if (r === "ok") out[key] = hash; else if (r === "skip" && prev[key]) out[key] = prev[key]!; }
|
|
79
|
+
else if (passed.has(key)) out[key] = hash;
|
|
80
|
+
else if (!failed.has(key) && !ran && prev[key]) out[key] = prev[key]!;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
function page(body: string, state: "ok" | "fail" | "none", title: string) {
|
|
85
|
+
return `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)}</title><style>${css}</style></head><body><main>${body}</main></body></html>\n`;
|
|
86
|
+
}
|
|
87
|
+
function render() {
|
|
88
|
+
const steps = readSteps(), suites = readSuites(steps), plan = readPlan();
|
|
89
|
+
const ok = steps.length > 0 && steps.every((s) => s.result !== "fail");
|
|
90
|
+
const verified = verifiedHashes(plan, steps, suites);
|
|
91
|
+
const decided = Object.values(plan?.decisions ?? {}); const ranCount = decided.filter((d) => d.run).length;
|
|
92
|
+
const hotLine = plan?.hot ? `; hot mode (budget ${plan.hot.budget}s): ${plan.hot.deferred.length} deferred` : "";
|
|
93
|
+
const planLine = plan ? (plan.full ? "full run" : plan.previous ? `${ranCount} of ${decided.length} checks ran; the rest unchanged since ${plan.previous.commit ? plan.previous.commit.slice(0, 10) : "the last green run"}${hotLine}` : `${ranCount} checks ran (no previous record)${hotLine}`) : "";
|
|
94
|
+
const total = steps.reduce((a, s) => a + s.seconds, 0);
|
|
95
|
+
rmSync(out, { recursive: true, force: true }); mkdirSync(out, { recursive: true });
|
|
96
|
+
const logsDir = resolve(ROOT, ".void/ci-logs");
|
|
97
|
+
if (existsSync(logsDir)) cpSync(logsDir, resolve(out, "logs"), { recursive: true });
|
|
98
|
+
const shots = existsSync(resolve(out, "logs")) ? readdirSync(resolve(out, "logs"), { recursive: true }).map(String).filter((f) => f.endsWith(".png")) : [];
|
|
99
|
+
const status = { ...meta, ok, seconds: total, plan: planLine, hot: plan?.hot ?? null, steps, suites: suites.map(({ step, name, result, detail, seconds }) => ({ step, name, result, detail, ...(seconds !== undefined ? { seconds } : {}) })), screenshots: shots.map((f) => `logs/${f}`), verified };
|
|
100
|
+
writeFileSync(resolve(out, "status.json"), JSON.stringify(status, null, 2) + "\n");
|
|
101
|
+
writeFileSync(resolve(out, "badge.svg"), badge(kind === "release" ? "release" : "ci", ok ? "passing" : "failing", ok ? "#1a7f4b" : "#b3261e"));
|
|
102
|
+
const commitUrl = `https://github.com/${meta.repository}/commit/${meta.commit}`;
|
|
103
|
+
const logLink = (s: Step) => (s.log ? `<a href="logs/steps/${esc(s.name)}.log">log</a>` : "");
|
|
104
|
+
const body = `<h1>${esc(meta.title)} <span class="state ${ok ? "ok" : "fail"}">${ok ? "passing" : "failing"}</span></h1>
|
|
105
|
+
<p>${esc(meta.subject)}</p>
|
|
106
|
+
<dl><dt>commit</dt><dd><a href="${commitUrl}">${esc(meta.commit.slice(0, 12))}</a> on ${esc(meta.branch)}</dd><dt>ran on</dt><dd>${esc(meta.backend)}${meta.build ? ` (build ${esc(meta.build)})` : ""}</dd><dt>finished</dt><dd>${esc(meta.finished)} after ${fmt(total)}</dd>${planLine ? `<dt>plan</dt><dd>${esc(planLine)}</dd>` : ""}</dl>
|
|
107
|
+
<h2>Steps</h2>
|
|
108
|
+
<table><tr><th>step</th><th>result</th><th class="r">time</th><th></th></tr>${steps.map((s) => `<tr><td>${esc(s.name)}</td><td class="${s.result}">${s.result === "ok" ? "passed" : s.result === "fail" ? "failed" : `skipped${plan?.decisions?.[`step:${s.name}`]?.reason ? ` (${esc(plan.decisions[`step:${s.name}`]!.reason)})` : ""}`}</td><td class="r">${s.result === "skip" ? "" : fmt(s.seconds)}</td><td>${logLink(s)}</td></tr>`).join("")}</table>
|
|
109
|
+
${suites.length ? `<h2>Suites</h2>
|
|
110
|
+
<table><tr><th>suite</th><th>runtime</th><th>result</th><th class="r">time</th><th>last line</th></tr>${suites.map((s) => `<tr><td>${esc(s.name)}</td><td>${s.step === "suites" ? "Workers (dev)" : s.step === "suites-bun" ? "Bun" : esc(s.step)}</td><td class="${s.result === "PASS" ? "ok" : "fail"}">${s.result}</td><td class="r">${s.seconds !== undefined ? fmt(s.seconds) : ""}</td><td>${esc(s.detail)}</td></tr>`).join("")}</table>` : ""}
|
|
111
|
+
${plan?.hot?.deferred.length ? `<h2>Deferred by hot mode</h2><p>${plan.hot.deferred.map(esc).join(", ")}: not verified for this commit; they run when hot mode is off or when the budget allows.</p>` : ""}
|
|
112
|
+
${shots.length ? `<h2>Screenshots</h2>
|
|
113
|
+
<div class="shots">${shots.map((f) => `<figure><a href="logs/${esc(f)}"><img src="logs/${esc(f)}" alt="${esc(f)}" loading="lazy"></a><figcaption>${esc(f)}</figcaption></figure>`).join("")}</div>` : ""}
|
|
114
|
+
<h2>Files</h2>
|
|
115
|
+
<p><a href="status.json">status.json</a> · <a href="badge.svg">badge.svg</a> · <a href="logs/">logs/</a></p>`;
|
|
116
|
+
writeFileSync(resolve(out, "index.html"), page(body, ok ? "ok" : "fail", `${meta.title}: ${ok ? "passing" : "failing"}`));
|
|
117
|
+
console.log(`status page: ${out} (${ok ? "passing" : "failing"}, ${steps.length} steps, ${suites.length} suites, ${shots.length} screenshots)`);
|
|
118
|
+
}
|
|
119
|
+
function placeholder() {
|
|
120
|
+
rmSync(out, { recursive: true, force: true }); mkdirSync(out, { recursive: true });
|
|
121
|
+
writeFileSync(resolve(out, "status.json"), JSON.stringify({ ...meta, ok: null, steps: [], suites: [] }, null, 2) + "\n");
|
|
122
|
+
writeFileSync(resolve(out, "badge.svg"), badge(kind === "release" ? "release" : "ci", "no build yet", "#8a8f88"));
|
|
123
|
+
writeFileSync(resolve(out, "index.html"), page(`<h1>${esc(meta.title)} <span class="state none">no build yet</span></h1><p>Workers Builds replaces this page with the results of the first build.</p>`, "none", meta.title));
|
|
124
|
+
console.log(`placeholder page: ${out}`);
|
|
125
|
+
}
|
|
126
|
+
if (cmd === "render") render(); else if (cmd === "placeholder") placeholder(); else { console.error("usage: bun scripts/ci-status.ts render|placeholder [--kind ci|release] [--out dir]"); process.exit(2); }
|
package/scripts/ci-suites.sh
CHANGED
|
@@ -8,7 +8,17 @@ PB="${1:-http://127.0.0.1:8090}"; VB="${2:-http://127.0.0.1:5180}"; shift 2 2>/d
|
|
|
8
8
|
LOGS="${CI_LOGS:-.void/ci-logs}"; mkdir -p "$LOGS"
|
|
9
9
|
POSITIONAL="auth-flows backups batch cascade filter-corpus filters-extra hardening logs-crons manage-rule oauth2 otp-mfa protected-files providers rules s3 security settings sql thumbs views"
|
|
10
10
|
FLAGGED="compare records realtime collections"
|
|
11
|
-
|
|
11
|
+
DEVLOG="${CI_DEV_LOG:-.void/dev.log}"
|
|
12
|
+
optimizations() { grep -cE "optimized|program reload" "$DEVLOG" 2>/dev/null || echo 0; }
|
|
13
|
+
fail=0; run() { # a suite that failed while the dev server re-optimized a dependency (a reload) gets one more attempt
|
|
14
|
+
local name="$1" t0=$SECONDS o1; shift; o1=$(optimizations)
|
|
15
|
+
if timeout 900 "$@" > "$LOGS/$name.log" 2>&1; then echo "PASS $name $(tail -1 "$LOGS/$name.log" | cut -c1-90) [$((SECONDS - t0))s]"; return; fi
|
|
16
|
+
if [ "$(optimizations)" != "$o1" ]; then
|
|
17
|
+
echo "RETRY $name (the dev server optimized a dependency during the run)"
|
|
18
|
+
if timeout 900 "$@" > "$LOGS/$name.log" 2>&1; then echo "PASS $name $(tail -1 "$LOGS/$name.log" | cut -c1-90) [$((SECONDS - t0))s] (second attempt)"; return; fi
|
|
19
|
+
fi
|
|
20
|
+
fail=$((fail+1)); echo "FAIL $name (see $LOGS/$name.log) [$((SECONDS - t0))s]"; grep -A1 -E "^FAIL|Error|error:" "$LOGS/$name.log" | grep -vE "^--$" | head -12 | cut -c1-1500 | sed 's/^/ /'
|
|
21
|
+
}
|
|
12
22
|
SEL="${*:-all}"
|
|
13
23
|
want() { [ "$SEL" = "all" ] || [[ " $SEL " == *" $1 "* ]]; }
|
|
14
24
|
for s in $POSITIONAL; do want "$s" && run "$s" bun "test/conformance/$s.ts" "$PB" "$VB"; done
|