@voidbase-cloud/voidbase 0.2.2 → 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 +47 -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 +4 -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,228 @@
|
|
|
1
|
+
// Cloudflare Workers Builds for this repository through the Builds REST API (docs/ci.md): connects the GitHub
|
|
2
|
+
// repository, creates the CI project (a Worker that serves its status page) with its two triggers, build variables
|
|
3
|
+
// and secrets, triggers builds and follows their logs. The release flow runs inside the CI build (scripts/ci.sh).
|
|
4
|
+
// bun scripts/cf-builds.ts setup [--repo voidbase-cloud/voidbase] [--ci voidbase-ci] [--branch master] [--domain release.voidbase.cloud] [--github]
|
|
5
|
+
// bun scripts/cf-builds.ts status [--ci voidbase-ci]
|
|
6
|
+
// bun scripts/cf-builds.ts build [--worker voidbase-ci] [--branch master | --commit <sha>] [--trigger <name>] [--follow]
|
|
7
|
+
// bun scripts/cf-builds.ts builds [--worker voidbase-ci]
|
|
8
|
+
// bun scripts/cf-builds.ts logs <build-uuid> [--follow]
|
|
9
|
+
// bun scripts/cf-builds.ts cancel <build-uuid>
|
|
10
|
+
// bun scripts/cf-builds.ts env [--worker voidbase-ci] [--trigger <name>] KEY=value ... [--secret KEY=value] ...
|
|
11
|
+
// bun scripts/cf-builds.ts hot on|off [--budget 60] hot mode on the CI triggers (docs/ci.md): CI_HOT and CI_HOT_BUDGET
|
|
12
|
+
// bun scripts/cf-builds.ts remove <worker> deletes a project's triggers and its Worker
|
|
13
|
+
// Auth: CLOUDFLARE_BUILDS_TOKEN, a *user* API token (My Profile > API Tokens) with "Workers Builds Configuration: Edit"
|
|
14
|
+
// and "Workers Scripts: Edit"; the Builds API rejects account-owned tokens. CLOUDFLARE_ACCOUNT_ID picks the account when
|
|
15
|
+
// the token reaches several. `setup` stores the release secrets from the environment on the master trigger when they
|
|
16
|
+
// are set: GH_TOKEN (release-please, release assets), NPM_TOKEN or VOIDBASE_NPM_TOKEN, GH_PACKAGES_TOKEN. With CI_CACHE_TOKEN (an
|
|
17
|
+
// API token with Workers R2 Storage edit; VOIDBASE_DEPLOY_CF_API_KEY is accepted) it creates the R2 bucket the builds
|
|
18
|
+
// keep their downloads in (--cache-bucket, default voidbase-ci-cache) and stores the token on every trigger. Push events never
|
|
19
|
+
// build (the triggers' watch paths exclude everything): .github/workflows/cloudflare.yml starts builds through this
|
|
20
|
+
// API, and `setup --github` stores what it needs in the repository (`gh variable set` / `gh secret set`; GH_BIN
|
|
21
|
+
// overrides the gh binary). CLOUDFLARE_API_BASE and GITHUB_API_URL point everything at test/cf-mock.ts.
|
|
22
|
+
import { CfApi, CfError, ensureR2, resolveAccount, workersSubdomain } from "../src/cloud/rest";
|
|
23
|
+
|
|
24
|
+
const [cmd = "status", ...rest] = process.argv.slice(2);
|
|
25
|
+
const args: Record<string, string> = {}; const positional: string[] = []; const secretArgs: string[] = [];
|
|
26
|
+
for (let i = 0; i < rest.length; i++) {
|
|
27
|
+
const a = rest[i]!;
|
|
28
|
+
if (a === "--secret") secretArgs.push(rest[++i] ?? "");
|
|
29
|
+
else if (a.startsWith("--")) { const v = rest[i + 1]; if (v !== undefined && !v.startsWith("--")) { args[a.slice(2)] = v; i++; } else args[a.slice(2)] = "1"; }
|
|
30
|
+
else positional.push(a);
|
|
31
|
+
}
|
|
32
|
+
const token = process.env.CLOUDFLARE_BUILDS_TOKEN;
|
|
33
|
+
if (!token) {
|
|
34
|
+
console.error("CLOUDFLARE_BUILDS_TOKEN is not set. The Builds API takes a user API token (dash.cloudflare.com/profile/api-tokens) with\n Workers Builds Configuration: Edit and Workers Scripts: Edit; account-owned tokens (VOIDBASE_DEPLOY_CF_API_KEY) are rejected.");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
const cf = new CfApi(token, process.env.CLOUDFLARE_API_BASE);
|
|
38
|
+
const GITHUB_API = (process.env.GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/, "");
|
|
39
|
+
const CI = args.ci ?? "voidbase-ci", BRANCH = args.branch ?? "master";
|
|
40
|
+
const BUN_VERSION = "1.3.14"; // the version CI pins (setup-bun in the workflows); the image's default is older
|
|
41
|
+
const DEPLOY = "./node_modules/.bin/wrangler deploy -c ci/wrangler.jsonc";
|
|
42
|
+
const PREVIEW = "./node_modules/.bin/wrangler versions upload -c ci/wrangler.jsonc";
|
|
43
|
+
const CI_BUILD = "bash scripts/ci.sh";
|
|
44
|
+
const triggerNames = { ciMaster: `${CI} (${BRANCH})`, ciBranches: `${CI} (branches)` };
|
|
45
|
+
|
|
46
|
+
interface Trigger { trigger_uuid: string; trigger_name: string; external_script_id?: string; build_command?: string; deploy_command?: string; root_directory?: string; branch_includes?: string[]; branch_excludes?: string[]; path_includes?: string[]; path_excludes?: string[]; build_caching_enabled?: boolean; [k: string]: unknown }
|
|
47
|
+
interface Build { build_uuid: string; status?: string; build_outcome?: string; created_on?: string; created_at?: string; stopped_on?: string; build_trigger_metadata?: { branch?: string; commit_hash?: string; [k: string]: unknown }; [k: string]: unknown }
|
|
48
|
+
type EnvVars = Record<string, { value: string; is_secret: boolean }>;
|
|
49
|
+
|
|
50
|
+
const die: (m: string) => never = (m) => { console.error(m); process.exit(1); };
|
|
51
|
+
const guide: (e: unknown) => never = (e) => {
|
|
52
|
+
if (e instanceof CfError && e.path.includes("/builds/") && (e.has(10000) || e.status === 401 || e.status === 403))
|
|
53
|
+
die(`${e.message}\n The Builds API accepts user tokens only: create one at dash.cloudflare.com/profile/api-tokens with Workers Builds\n Configuration: Edit and Workers Scripts: Edit, and put it in CLOUDFLARE_BUILDS_TOKEN (account-owned tokens are rejected).`);
|
|
54
|
+
die(e instanceof Error ? e.message : String(e));
|
|
55
|
+
};
|
|
56
|
+
const account = await resolveAccount(cf, process.env.CLOUDFLARE_ACCOUNT_ID).catch(guide);
|
|
57
|
+
const A = `/accounts/${account.id}`;
|
|
58
|
+
const dash = (name: string) => `https://dash.cloudflare.com/${account.id}/workers/services/view/${name}`;
|
|
59
|
+
|
|
60
|
+
async function workers(): Promise<{ id: string; tag?: string }[]> { return (await cf.json<{ id: string; tag?: string }[]>("GET", `${A}/workers/scripts`)).result ?? []; }
|
|
61
|
+
async function workerTag(name: string): Promise<string> {
|
|
62
|
+
const w = (await workers()).find((s) => s.id === name);
|
|
63
|
+
return w?.tag ?? die(`no Worker ${name} on account ${account.name}; run \`bun scripts/cf-builds.ts setup\` first`);
|
|
64
|
+
}
|
|
65
|
+
async function ensureWorker(name: string): Promise<{ tag: string; created: boolean }> {
|
|
66
|
+
const found = (await workers()).find((s) => s.id === name);
|
|
67
|
+
if (found?.tag) return { tag: found.tag, created: false };
|
|
68
|
+
// a placeholder Worker so the project exists; the first build replaces it with the status page (assets only)
|
|
69
|
+
const form = new FormData();
|
|
70
|
+
form.set("metadata", new Blob([JSON.stringify({ main_module: "index.js", compatibility_date: "2026-01-01" })], { type: "application/json" }));
|
|
71
|
+
form.set("index.js", new File([`export default { fetch: () => new Response("${name}: no build yet\\n") };`], "index.js", { type: "application/javascript+module" }));
|
|
72
|
+
await cf.form("PUT", `${A}/workers/scripts/${name}`, form);
|
|
73
|
+
const tag = (await workers()).find((s) => s.id === name)?.tag ?? die(`Worker ${name} uploaded but not listed with a tag`);
|
|
74
|
+
return { tag, created: true };
|
|
75
|
+
}
|
|
76
|
+
async function triggers(tag: string): Promise<Trigger[]> { return (await cf.json<Trigger[]>("GET", `${A}/builds/workers/${tag}/triggers`)).result ?? []; }
|
|
77
|
+
async function ensureTrigger(tag: string, connection: string, buildToken: string, want: Omit<Trigger, "trigger_uuid">): Promise<{ uuid: string; created: boolean }> {
|
|
78
|
+
// adopt a trigger by name, else by shape (the dashboard wizard names its production and preview triggers itself)
|
|
79
|
+
const same = (a: unknown, b: unknown) => JSON.stringify([...((a as string[] | undefined) ?? [])].sort()) === JSON.stringify([...((b as string[] | undefined) ?? [])].sort());
|
|
80
|
+
const all = await triggers(tag);
|
|
81
|
+
const existing = all.find((t) => t.trigger_name === want.trigger_name) ?? all.find((t) => same(t.branch_includes, want.branch_includes) && same(t.branch_excludes, want.branch_excludes));
|
|
82
|
+
if (existing) { await cf.json("PATCH", `${A}/builds/triggers/${existing.trigger_uuid}`, { ...want, build_token_uuid: buildToken }); return { uuid: existing.trigger_uuid, created: false }; }
|
|
83
|
+
const r = await cf.json<Trigger>("POST", `${A}/builds/triggers`, { ...want, external_script_id: tag, repo_connection_uuid: connection, build_token_uuid: buildToken });
|
|
84
|
+
return { uuid: r.result.trigger_uuid, created: true };
|
|
85
|
+
}
|
|
86
|
+
async function setEnv(trigger: string, vars: EnvVars): Promise<void> { if (Object.keys(vars).length) await cf.json("PATCH", `${A}/builds/triggers/${trigger}/environment_variables`, vars); }
|
|
87
|
+
async function latestBuild(tag: string): Promise<Build | null> { const r = await cf.json<Build[]>("GET", `${A}/builds/workers/${tag}/builds`); return (r.result ?? [])[0] ?? null; }
|
|
88
|
+
const when = (b: Build) => b.created_on ?? b.created_at ?? "";
|
|
89
|
+
// the live API ends a build with status "stopped" and build_outcome "success" | "fail"; older shapes put the outcome in status
|
|
90
|
+
const outcome = (b: Build | null | undefined): string => { if (!b) return ""; const st = b.status ?? ""; if (st === "stopped") return b.build_outcome === "success" ? "success" : b.build_outcome ? `failed (${b.build_outcome})` : "stopped"; return FINAL.has(st) ? st : ""; };
|
|
91
|
+
const describe = (b: Build) => `${b.build_uuid} ${(outcome(b) || b.status || "?").padEnd(16)} ${(b.build_trigger_metadata?.branch ?? "").padEnd(12)} ${(b.build_trigger_metadata?.commit_hash ?? "").slice(0, 10).padEnd(10)} ${when(b)}`;
|
|
92
|
+
type LogLine = { line?: string; message?: string; ts?: string } | string | [number, string];
|
|
93
|
+
interface LogsResult { lines?: LogLine[]; status?: string; build?: { status?: string } }
|
|
94
|
+
async function printLogs(uuid: string, from = 0): Promise<{ next: number; status: string }> {
|
|
95
|
+
// a build that is still queued has no log yet: treat a failed fetch as "nothing so far" and keep polling
|
|
96
|
+
const r = await cf.json<LogsResult>("GET", `${A}/builds/builds/${uuid}/logs`).catch((): { result: LogsResult } => ({ result: { lines: [] } }));
|
|
97
|
+
const lines = r.result?.lines ?? [];
|
|
98
|
+
if (lines.length < from) return { next: from, status: "" };
|
|
99
|
+
// the live API returns [unix ms, text] pairs; the mock returns {ts, line}
|
|
100
|
+
const text = (l: LogLine) => (typeof l === "string" ? l : Array.isArray(l) ? `${new Date(l[0]).toISOString().slice(11, 19)} ${l[1]}` : `${l.ts ? l.ts + " " : ""}${l.line ?? l.message ?? JSON.stringify(l)}`);
|
|
101
|
+
for (const l of lines.slice(from)) console.log(text(l));
|
|
102
|
+
let status = outcome({ build_uuid: uuid, status: r.result?.status ?? r.result?.build?.status, build_outcome: (r.result as { build_outcome?: string } | undefined)?.build_outcome });
|
|
103
|
+
if (!status) { const b = await cf.json<Build>("GET", `${A}/builds/builds/${uuid}`, undefined, [10000]).catch(() => null); status = outcome(b?.result); if (!status && b?.result?.status) status = ""; }
|
|
104
|
+
return { next: lines.length, status };
|
|
105
|
+
}
|
|
106
|
+
const FINAL = new Set(["success", "failure", "failed", "canceled", "cancelled", "timed_out", "error"]);
|
|
107
|
+
async function follow(uuid: string): Promise<void> {
|
|
108
|
+
let from = 0, status = "";
|
|
109
|
+
for (;;) {
|
|
110
|
+
const r = await printLogs(uuid, from); from = r.next; status = r.status;
|
|
111
|
+
if (status) break;
|
|
112
|
+
await Bun.sleep(Number(process.env.CF_BUILDS_POLL_MS ?? 5000));
|
|
113
|
+
}
|
|
114
|
+
console.log(`build ${uuid}: ${status}`);
|
|
115
|
+
if (status !== "success") process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
if (cmd === "setup") {
|
|
120
|
+
const repo = args.repo ?? "voidbase-cloud/voidbase";
|
|
121
|
+
const gh = await fetch(`${GITHUB_API}/repos/${repo}`, { headers: { accept: "application/vnd.github+json", "user-agent": "voidbase-cf-builds", ...(process.env.GH_TOKEN ? { authorization: `Bearer ${process.env.GH_TOKEN}` } : {}) } });
|
|
122
|
+
if (!gh.ok) die(`GitHub: ${gh.status} for ${repo}`);
|
|
123
|
+
const info = (await gh.json()) as { id: number; name: string; owner: { id: number; login: string }; default_branch: string };
|
|
124
|
+
console.log(`account ${account.name} (${account.id}); repository ${repo} (id ${info.id}, owner ${info.owner.login} ${info.owner.id})`);
|
|
125
|
+
// 1. the repository connection (needs the "Cloudflare Workers and Pages" GitHub App installed for the repository)
|
|
126
|
+
const conn = await cf.json<{ repo_connection_uuid?: string; uuid?: string; id?: string }>("PUT", `${A}/builds/repos/connections`, { provider_type: "github", provider_account_id: String(info.owner.id), provider_account_name: info.owner.login, repo_id: String(info.id), repo_name: info.name });
|
|
127
|
+
const connection = conn.result?.repo_connection_uuid ?? conn.result?.uuid ?? conn.result?.id ?? die(`connection created but no uuid in ${JSON.stringify(conn.result)}`);
|
|
128
|
+
console.log(`repository connection ${connection}`);
|
|
129
|
+
// 2. the projects' Workers first, so the dashboard link below points at something that exists
|
|
130
|
+
const ci = await ensureWorker(CI);
|
|
131
|
+
console.log(`Worker ${CI}: ${ci.created ? "created" : "exists"} (tag ${ci.tag})`);
|
|
132
|
+
// 3. the build token Workers Builds deploys with (the dashboard creates one under Settings > Builds > API token)
|
|
133
|
+
const tokens = (await cf.json<{ build_token_uuid: string; build_token_name?: string }[]>("GET", `${A}/builds/tokens`)).result ?? [];
|
|
134
|
+
const buildToken = tokens[0]?.build_token_uuid ?? die(`no build token on the account yet: open ${dash(CI)} > Settings > Builds > API token > Create new token once, then rerun setup`);
|
|
135
|
+
console.log(`build token ${buildToken}${tokens[0]?.build_token_name ? ` (${tokens[0].build_token_name})` : ""}`);
|
|
136
|
+
// watch paths that exclude everything: a push event never builds by itself; GitHub Actions (cloudflare.yml) and
|
|
137
|
+
// `build` start builds through the API, which the watch paths do not filter
|
|
138
|
+
const common = { root_directory: "/", path_includes: ["*"], path_excludes: ["*"], build_caching_enabled: true };
|
|
139
|
+
const prod = await ensureTrigger(ci.tag, connection, buildToken, { trigger_name: triggerNames.ciMaster, build_command: CI_BUILD, deploy_command: DEPLOY, branch_includes: [BRANCH], branch_excludes: [], ...common });
|
|
140
|
+
const preview = await ensureTrigger(ci.tag, connection, buildToken, { trigger_name: triggerNames.ciBranches, build_command: CI_BUILD, deploy_command: PREVIEW, branch_includes: ["*"], branch_excludes: [BRANCH], ...common });
|
|
141
|
+
// the record of the last green run of master, which scripts/ci-plan.ts compares the inputs against: the status
|
|
142
|
+
// page's canonical address (--domain, the custom domain ci/wrangler.jsonc declares), else the workers.dev one
|
|
143
|
+
const domain = args.domain ?? process.env.CI_DOMAIN ?? "release.voidbase.cloud";
|
|
144
|
+
const sub = domain ? null : await workersSubdomain(cf, account.id).catch(() => null);
|
|
145
|
+
const statusUrl = domain ? `https://${domain}/status.json` : sub ? `https://${CI}.${sub}.workers.dev/status.json` : "";
|
|
146
|
+
// the bucket scripts/ci-cache.sh keeps the downloads in between builds (Workers Builds keeps nothing else)
|
|
147
|
+
const cacheToken = process.env.CI_CACHE_TOKEN ?? process.env.VOIDBASE_DEPLOY_CF_API_KEY; const bucket = args["cache-bucket"] ?? "voidbase-ci-cache";
|
|
148
|
+
let cacheVars: EnvVars = {};
|
|
149
|
+
if (cacheToken) {
|
|
150
|
+
const r = await ensureR2(new CfApi(cacheToken, process.env.CLOUDFLARE_API_BASE), account.id, bucket).catch((e: unknown) => { console.log(`cache bucket ${bucket}: ${e instanceof Error ? e.message : e}`); return null; });
|
|
151
|
+
if (r) { console.log(`cache bucket ${bucket}: ${r.created ? "created" : "exists"}`); cacheVars = { CI_CACHE_BUCKET: { value: bucket, is_secret: false }, CI_CACHE_ACCOUNT: { value: account.id, is_secret: false }, CI_CACHE_TOKEN: { value: cacheToken, is_secret: true } }; }
|
|
152
|
+
} else console.log("cache bucket: skipped (set CI_CACHE_TOKEN, an API token with Workers R2 Storage edit, to keep downloads between builds)");
|
|
153
|
+
const vars: EnvVars = { BUN_VERSION: { value: BUN_VERSION, is_secret: false }, CI_BROWSER: { value: "1", is_secret: false }, ...(statusUrl ? { CI_STATUS_URL: { value: statusUrl, is_secret: false } } : {}), ...cacheVars };
|
|
154
|
+
// the release secrets on the master trigger only: builds of other branches never carry them
|
|
155
|
+
const secrets: EnvVars = {};
|
|
156
|
+
const ghTok = process.env.GH_TOKEN, npmTok = process.env.NPM_TOKEN ?? process.env.VOIDBASE_NPM_TOKEN, ghpTok = process.env.GH_PACKAGES_TOKEN;
|
|
157
|
+
if (ghTok) secrets.GH_TOKEN = { value: ghTok, is_secret: true };
|
|
158
|
+
if (npmTok) secrets.NPM_TOKEN = { value: npmTok, is_secret: true };
|
|
159
|
+
if (ghpTok) secrets.GH_PACKAGES_TOKEN = { value: ghpTok, is_secret: true };
|
|
160
|
+
await setEnv(prod.uuid, { ...vars, ...secrets }); await setEnv(preview.uuid, vars);
|
|
161
|
+
if (statusUrl) console.log(` CI_STATUS_URL ${statusUrl} (the last green run's record, for incremental runs)`);
|
|
162
|
+
console.log(` release secrets on the ${BRANCH} trigger: ${Object.keys(secrets).join(", ") || "none (set GH_TOKEN and NPM_TOKEN in the environment and rerun, or `env --trigger \"" + triggerNames.ciMaster + "\" --secret GH_TOKEN=...`)"}`);
|
|
163
|
+
console.log(` trigger ${prod.uuid} ${BRANCH}: ${prod.created ? "created" : "updated"}; build \`${CI_BUILD}\`, deploy \`${DEPLOY}\``);
|
|
164
|
+
console.log(` trigger ${preview.uuid} other branches: ${preview.created ? "created" : "updated"}; deploy \`${PREVIEW}\` (preview URL on the pull request)`);
|
|
165
|
+
const github: Record<string, string> = { CF_ACCOUNT_ID: account.id, CF_CI_TRIGGER_MASTER: prod.uuid, CF_CI_TRIGGER_BRANCHES: preview.uuid };
|
|
166
|
+
if (args.github) {
|
|
167
|
+
// what .github/workflows/cloudflare.yml reads: the trigger uuids as variables, the user token as the secret (over stdin, never an argument)
|
|
168
|
+
const gh = process.env.GH_BIN ?? "gh";
|
|
169
|
+
const ghRun = (a: string[], stdin?: string, tolerate = false) => { const p = Bun.spawnSync([gh, ...a], { stdin: stdin === undefined ? "ignore" : new TextEncoder().encode(stdin), stdout: "pipe", stderr: "pipe" }); if (p.exitCode !== 0 && !tolerate) die(`${gh} ${a.slice(0, 3).join(" ")} failed: ${p.stderr.toString().trim() || p.stdout.toString().trim()}`); };
|
|
170
|
+
for (const [k, v] of Object.entries(github)) ghRun(["variable", "set", k, "--repo", repo, "--body", v]);
|
|
171
|
+
for (const stale of ["CF_RELEASE_TRIGGER_MASTER", "CF_RELEASE_TRIGGER_DRY_RUN"]) ghRun(["variable", "delete", stale, "--repo", repo], undefined, true); // from the time the release flow had its own project
|
|
172
|
+
ghRun(["secret", "set", "CLOUDFLARE_BUILDS_TOKEN", "--repo", repo], token);
|
|
173
|
+
console.log(`GitHub ${repo}: variables ${Object.keys(github).join(", ")} and the secret CLOUDFLARE_BUILDS_TOKEN stored`);
|
|
174
|
+
} else console.log(`\nrepository variables for .github/workflows/cloudflare.yml (or rerun with --github to store them):\n${Object.entries(github).map(([k, v]) => ` ${k}=${v}`).join("\n")}\n secret CLOUDFLARE_BUILDS_TOKEN=<this token>`);
|
|
175
|
+
console.log(`\ndone. Push events do not build by themselves; the workflow starts builds on ${dash(CI)}. First build: bun scripts/cf-builds.ts build --branch ${BRANCH} --follow`);
|
|
176
|
+
} else if (cmd === "status") {
|
|
177
|
+
for (const name of [CI]) {
|
|
178
|
+
const w = (await workers()).find((s) => s.id === name);
|
|
179
|
+
if (!w?.tag) { console.log(`${name}: no such Worker`); continue; }
|
|
180
|
+
const ts = await triggers(w.tag); const last = await latestBuild(w.tag);
|
|
181
|
+
console.log(`${name} (tag ${w.tag}) ${dash(name)}`);
|
|
182
|
+
for (const t of ts) console.log(` trigger ${t.trigger_uuid} ${t.trigger_name}: branches ${JSON.stringify(t.branch_includes ?? [])}${t.branch_excludes?.length ? ` minus ${JSON.stringify(t.branch_excludes)}` : ""}; build \`${t.build_command ?? ""}\`; deploy \`${t.deploy_command ?? ""}\``);
|
|
183
|
+
console.log(last ? ` latest build: ${describe(last)}` : " no builds yet");
|
|
184
|
+
}
|
|
185
|
+
} else if (cmd === "build") {
|
|
186
|
+
const name = args.worker ?? CI; const tag = await workerTag(name);
|
|
187
|
+
const ts = await triggers(tag); const branch = args.commit ? undefined : args.branch ?? BRANCH;
|
|
188
|
+
const wanted = args.trigger;
|
|
189
|
+
const t = wanted ? ts.find((x) => x.trigger_name === wanted) : ts.find((x) => (branch ? (x.branch_includes ?? []).includes(branch) : true)) ?? ts[0];
|
|
190
|
+
if (!t) die(`no trigger on ${name}`);
|
|
191
|
+
const body = args.commit ? { commit_hash: args.commit, ...(args.branch ? { branch: args.branch } : {}) } : { branch };
|
|
192
|
+
const r = await cf.json<{ build_uuid: string; status?: string; already_exists?: boolean }>("POST", `${A}/builds/triggers/${t.trigger_uuid}/builds`, body);
|
|
193
|
+
console.log(`build ${r.result.build_uuid} ${r.result.status ?? "queued"} on ${name} via trigger "${t.trigger_name}" (${JSON.stringify(body)})${r.result.already_exists ? " (already pending)" : ""}`);
|
|
194
|
+
if (args.follow) await follow(r.result.build_uuid);
|
|
195
|
+
} else if (cmd === "builds") {
|
|
196
|
+
const name = args.worker ?? CI; const tag = await workerTag(name);
|
|
197
|
+
const list = (await cf.json<Build[]>("GET", `${A}/builds/workers/${tag}/builds`)).result ?? [];
|
|
198
|
+
if (args.json) console.log(JSON.stringify(list, null, 2)); else { console.log(`${name}: ${list.length} builds`); for (const b of list) console.log(" " + describe(b)); }
|
|
199
|
+
} else if (cmd === "logs") {
|
|
200
|
+
const uuid = positional[0] ?? die("logs: build uuid missing");
|
|
201
|
+
if (args.follow) await follow(uuid); else { const r = await printLogs(uuid); const b = await cf.json<Build>("GET", `${A}/builds/builds/${uuid}`, undefined, [10000]).catch(() => null); console.log(`status: ${r.status || b?.result?.status || "unknown"}`); }
|
|
202
|
+
} else if (cmd === "cancel") {
|
|
203
|
+
const uuid = positional[0] ?? die("cancel: build uuid missing");
|
|
204
|
+
await cf.json("PUT", `${A}/builds/builds/${uuid}/cancel`); console.log(`build ${uuid} cancelled`);
|
|
205
|
+
} else if (cmd === "env") {
|
|
206
|
+
const name = args.worker ?? CI; const tag = await workerTag(name); const ts = await triggers(tag);
|
|
207
|
+
const targets = args.trigger ? ts.filter((t) => t.trigger_name === args.trigger) : ts;
|
|
208
|
+
if (!targets.length) die(`no trigger${args.trigger ? ` named ${args.trigger}` : ""} on ${name}`);
|
|
209
|
+
const vars: EnvVars = {};
|
|
210
|
+
for (const kv of positional) { const i = kv.indexOf("="); if (i < 1) die(`expected KEY=value, got ${kv}`); vars[kv.slice(0, i)] = { value: kv.slice(i + 1), is_secret: false }; }
|
|
211
|
+
for (const kv of secretArgs) { const i = kv.indexOf("="); if (i < 1) die(`expected --secret KEY=value, got ${kv}`); vars[kv.slice(0, i)] = { value: kv.slice(i + 1), is_secret: true }; }
|
|
212
|
+
for (const t of targets) {
|
|
213
|
+
if (Object.keys(vars).length) await setEnv(t.trigger_uuid, vars);
|
|
214
|
+
const now = (await cf.json<EnvVars>("GET", `${A}/builds/triggers/${t.trigger_uuid}/environment_variables`)).result ?? {};
|
|
215
|
+
console.log(`${name} "${t.trigger_name}": ${Object.entries(now).map(([k, v]) => `${k}=${v.is_secret ? "(secret)" : v.value}`).join(" ") || "(no variables)"}`);
|
|
216
|
+
}
|
|
217
|
+
} else if (cmd === "remove") {
|
|
218
|
+
const name = positional[0] ?? die("remove: worker name missing");
|
|
219
|
+
const w = (await workers()).find((s) => s.id === name); if (!w) die(`no Worker ${name}`);
|
|
220
|
+
if (w.tag) for (const t of await triggers(w.tag)) { await cf.json("DELETE", `${A}/builds/triggers/${t.trigger_uuid}`, undefined, [10000, 12000]).catch(() => null); console.log(`trigger ${t.trigger_uuid} "${t.trigger_name}" removed`); }
|
|
221
|
+
await cf.json("DELETE", `${A}/workers/scripts/${name}`); console.log(`Worker ${name} removed`);
|
|
222
|
+
} else if (cmd === "hot") {
|
|
223
|
+
const on = positional[0] === "on"; if (!on && positional[0] !== "off") die("usage: bun scripts/cf-builds.ts hot on|off [--budget 60]");
|
|
224
|
+
const tag = await workerTag(CI); const vars: EnvVars = { CI_HOT: { value: on ? "1" : "0", is_secret: false }, ...(args.budget ? { CI_HOT_BUDGET: { value: args.budget, is_secret: false } } : {}) };
|
|
225
|
+
for (const t of await triggers(tag)) await setEnv(t.trigger_uuid, vars);
|
|
226
|
+
console.log(`hot mode ${on ? "on" : "off"} for ${CI}${args.budget ? ` (budget ${args.budget}s)` : ""}: the next builds ${on ? "keep the checks within the budget and defer the rest" : "run every check the changes reach"}`);
|
|
227
|
+
} else die("usage: bun scripts/cf-builds.ts setup|status|build|builds|logs|cancel|env|hot|remove ... (see the header of the script)");
|
|
228
|
+
} catch (e) { guide(e); }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Finds or provisions the Chrome the browser suites launch (playwright's chromium.launch with CHROME_PATH):
|
|
3
|
+
# 1. $CHROME_PATH when it is an executable
|
|
4
|
+
# 2. google-chrome / chromium on PATH (GitHub's runners, dev machines)
|
|
5
|
+
# 3. otherwise Playwright's chromium-headless-shell under .void/browsers and, when the machine lacks them, the shared
|
|
6
|
+
# libraries it needs, taken from Ubuntu's packages into .void/chrome-libs without root: the Workers Builds image
|
|
7
|
+
# (Ubuntu 24.04) ships neither Chrome nor sudo, so the packages are fetched into a private apt root and unpacked
|
|
8
|
+
# Prints `export CHROME_PATH=...` (plus LD_LIBRARY_PATH when the unpacked libraries are needed) for
|
|
9
|
+
# `eval "$(scripts/ci-browser.sh)"`; progress goes to stderr. The download, the packages and the unpacked libraries
|
|
10
|
+
# live under CI_CACHE_DIR (else .void) and are reused by later runs. CI_BROWSER_DOWNLOAD=1 forces step 3,
|
|
11
|
+
# CI_BROWSER_LIBS=always forces the package unpacking (both for testing the path on a machine that has Chrome).
|
|
12
|
+
set -u
|
|
13
|
+
cd "$(dirname "$0")/.."
|
|
14
|
+
log() { echo "browser: $*" >&2; }
|
|
15
|
+
found() { echo "export CHROME_PATH='$1'"; [ -n "${2:-}" ] && echo "export LD_LIBRARY_PATH='$2${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}'"; exit 0; }
|
|
16
|
+
if [ "${CI_BROWSER_DOWNLOAD:-0}" != "1" ]; then
|
|
17
|
+
if [ -n "${CHROME_PATH:-}" ] && [ -x "$CHROME_PATH" ]; then log "using CHROME_PATH $CHROME_PATH"; found "$CHROME_PATH"; fi
|
|
18
|
+
for c in google-chrome google-chrome-stable chromium chromium-browser; do p=$(command -v "$c" 2>/dev/null) && { log "using $p"; found "$p"; }; done
|
|
19
|
+
fi
|
|
20
|
+
CACHE="${CI_CACHE_DIR:-$PWD/.void}"; mkdir -p "$CACHE"
|
|
21
|
+
export PLAYWRIGHT_BROWSERS_PATH="$CACHE/browsers"
|
|
22
|
+
find_shell() { find "$PLAYWRIGHT_BROWSERS_PATH" -type f -name chrome-headless-shell 2>/dev/null | head -1; }
|
|
23
|
+
CHROME=$(find_shell)
|
|
24
|
+
if [ -z "$CHROME" ]; then
|
|
25
|
+
log "downloading Playwright's chromium-headless-shell into $PLAYWRIGHT_BROWSERS_PATH"
|
|
26
|
+
./node_modules/.bin/playwright install chromium-headless-shell >&2 || { log "playwright install failed"; exit 1; }
|
|
27
|
+
CHROME=$(find_shell)
|
|
28
|
+
fi
|
|
29
|
+
[ -n "$CHROME" ] || { log "no chrome-headless-shell under $PLAYWRIGHT_BROWSERS_PATH"; exit 1; }
|
|
30
|
+
LIBS="$CACHE/chrome-libs"; LIBDIR="$LIBS/usr/lib/x86_64-linux-gnu"
|
|
31
|
+
missing() { LD_LIBRARY_PATH="$LIBDIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" ldd "$CHROME" 2>/dev/null | awk '/not found/ { print $1 }'; }
|
|
32
|
+
if [ -n "$(missing)" ] || [ "${CI_BROWSER_LIBS:-}" = "always" ]; then
|
|
33
|
+
# what Playwright's `install-deps` would apt-get for chromium on Ubuntu 24.04 (playwright-core's nativeDeps table),
|
|
34
|
+
# plus every package they depend on that the machine lacks (libxtst6 pulls libxi6, and so on): resolved with apt-cache
|
|
35
|
+
# against a private apt root, fetched into it and unpacked, no root needed, nothing outside .void touched
|
|
36
|
+
PKGS="libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 libcairo2 libcups2t64 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0t64 libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 libfontconfig1 libfreetype6 libxi6 libxtst6"
|
|
37
|
+
log "shared libraries missing: $(missing | tr '\n' ' ')- unpacking Ubuntu packages into $LIBS"
|
|
38
|
+
# the package indexes are needed only to resolve and download; the downloaded packages are what the cache keeps
|
|
39
|
+
APTROOT="$PWD/.void/apt"; DEBS="$CACHE/debs"; mkdir -p "$APTROOT/state/lists/partial" "$APTROOT/cache/archives/partial" "$DEBS" "$LIBS"
|
|
40
|
+
if ls "$DEBS"/*.deb >/dev/null 2>&1 && [ "${CI_BROWSER_LIBS:-}" != "refresh" ]; then log "packages already in the cache ($(ls "$DEBS"/*.deb | wc -l))"
|
|
41
|
+
else
|
|
42
|
+
APT_OPTS=(-q -o "Dir::State=$APTROOT/state" -o "Dir::Cache=$APTROOT/cache" -o Dir::State::status=/var/lib/dpkg/status -o Debug::NoLocking=1 -o APT::Sandbox::User=root) # the image's docker-clean hook then fails to purge the system cache, harmlessly
|
|
43
|
+
apt-get "${APT_OPTS[@]}" update >&2 2>&1 || log "apt-get update reported errors (continuing with what it fetched)"
|
|
44
|
+
installed() { dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null | grep -q '^installed$'; }
|
|
45
|
+
# the transitive closure of PKGS, minus what the machine has (virtual packages in <angle brackets> are skipped)
|
|
46
|
+
# shellcheck disable=SC2086
|
|
47
|
+
WANT=$(apt-cache "${APT_OPTS[@]}" depends --recurse --no-recommends --no-suggests --no-conflicts --no-breaks --no-replaces --no-enhances $PKGS 2>/dev/null | grep -oE "^[a-z0-9][a-z0-9+.-]*|Depends: [a-z0-9][a-z0-9+.-]*" | sed 's/^.*Depends: //' | sort -u)
|
|
48
|
+
[ -n "$WANT" ] || WANT="$PKGS"
|
|
49
|
+
NEED=""; for p in $WANT; do installed "$p" || NEED="$NEED $p"; done
|
|
50
|
+
log "packages to fetch: $(echo $NEED | wc -w) ($(echo $NEED | cut -c1-160)...)"
|
|
51
|
+
# shellcheck disable=SC2086
|
|
52
|
+
(cd "$DEBS" && apt-get "${APT_OPTS[@]}" download $NEED >&2 2>&1) || log "some packages did not download"
|
|
53
|
+
fi
|
|
54
|
+
for d in "$DEBS"/*.deb; do [ -f "$d" ] && dpkg-deb -x "$d" "$LIBS"; done
|
|
55
|
+
still=$(missing); if [ -n "$still" ]; then log "still missing after unpacking: $(echo $still)"; exit 1; fi
|
|
56
|
+
log "libraries unpacked under $LIBS ($(find "$LIBDIR" -name '*.so*' | wc -l) files)"
|
|
57
|
+
fi
|
|
58
|
+
if [ -d "$LIBDIR" ] && ldd "$CHROME" 2>/dev/null | grep -q "not found"; then log "using $CHROME with $LIBS"; found "$CHROME" "$LIBDIR"; fi
|
|
59
|
+
if [ -d "$LIBDIR" ] && [ "${CI_BROWSER_LIBS:-}" = "always" ]; then log "using $CHROME with $LIBS (forced)"; found "$CHROME" "$LIBDIR"; fi
|
|
60
|
+
log "using $CHROME"; found "$CHROME"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The cache Workers Builds does not keep between builds (docs/ci.md, "What is kept between runs"): CI_CACHE_DIR is
|
|
3
|
+
# restored from and saved to an R2 bucket through Cloudflare's API, one archive per component, uploaded only when its
|
|
4
|
+
# content changed since it was restored. Without CI_CACHE_TOKEN (an API token with Workers R2 Storage edit),
|
|
5
|
+
# CI_CACHE_ACCOUNT and CI_CACHE_BUCKET the script does nothing, so a dev machine just keeps its local directory.
|
|
6
|
+
# scripts/ci-cache.sh restore the components missing locally
|
|
7
|
+
# scripts/ci-cache.sh save the components whose content changed
|
|
8
|
+
# Components are the directories of CI_CACHE_DIR: browsers, chrome-libs, debs (scripts/ci-browser.sh), oracles
|
|
9
|
+
# (scripts/ci-oracles.sh), xdg (the panel, scripts/sync-panel.ts), archives (the reference PocketBase).
|
|
10
|
+
set -u
|
|
11
|
+
COMPONENTS="browsers chrome-libs debs oracles xdg archives"
|
|
12
|
+
DIR="${CI_CACHE_DIR:?CI_CACHE_DIR}"; mkdir -p "$DIR/.stamps"
|
|
13
|
+
enabled() { [ -n "${CI_CACHE_TOKEN:-}" ] && [ -n "${CI_CACHE_ACCOUNT:-}" ] && [ -n "${CI_CACHE_BUCKET:-}" ]; }
|
|
14
|
+
if command -v zstd >/dev/null 2>&1; then EXT="tar.zst"; TAR_C=(tar -I zstd -cf); TAR_X=(tar -I zstd -xf); else EXT="tar.gz"; TAR_C=(tar -czf); TAR_X=(tar -xzf); fi
|
|
15
|
+
url() { echo "https://api.cloudflare.com/client/v4/accounts/$CI_CACHE_ACCOUNT/r2/buckets/$CI_CACHE_BUCKET/objects/ci-cache/$1.$EXT"; }
|
|
16
|
+
stamp() { (cd "$DIR" && find "$1" -type f -printf '%p %s %T@\n' 2>/dev/null | sort | sha256sum | cut -c1-16); }
|
|
17
|
+
restore() {
|
|
18
|
+
for c in $COMPONENTS; do
|
|
19
|
+
if [ -d "$DIR/$c" ]; then echo "cache: $c present locally"; continue; fi
|
|
20
|
+
tmp="$DIR/.$c.$EXT"
|
|
21
|
+
if curl -fsS -o "$tmp" -H "Authorization: Bearer $CI_CACHE_TOKEN" "$(url "$c")" 2>/dev/null; then
|
|
22
|
+
if "${TAR_X[@]}" "$tmp" -C "$DIR"; then stamp "$c" > "$DIR/.stamps/$c"; echo "cache: $c restored ($(du -sh "$DIR/$c" 2>/dev/null | cut -f1))"; else echo "cache: $c archive unreadable, ignored"; rm -rf "$DIR/$c"; fi
|
|
23
|
+
else echo "cache: $c not in the bucket yet"; fi
|
|
24
|
+
rm -f "$tmp"
|
|
25
|
+
done
|
|
26
|
+
}
|
|
27
|
+
save() {
|
|
28
|
+
for c in $COMPONENTS; do
|
|
29
|
+
[ -d "$DIR/$c" ] || continue
|
|
30
|
+
now=$(stamp "$c"); if [ "$now" = "$(cat "$DIR/.stamps/$c" 2>/dev/null)" ]; then echo "cache: $c unchanged"; continue; fi
|
|
31
|
+
tmp="$DIR/.$c.$EXT"
|
|
32
|
+
(cd "$DIR" && "${TAR_C[@]}" "$tmp" "$c") || { echo "cache: $c could not be archived"; rm -f "$tmp"; continue; }
|
|
33
|
+
if curl -fsS -o /dev/null -X PUT -H "Authorization: Bearer $CI_CACHE_TOKEN" --data-binary "@$tmp" "$(url "$c")"; then echo "$now" > "$DIR/.stamps/$c"; echo "cache: $c saved ($(du -h "$tmp" | cut -f1))"; else echo "cache: $c upload failed"; fi
|
|
34
|
+
rm -f "$tmp"
|
|
35
|
+
done
|
|
36
|
+
}
|
|
37
|
+
case "${1:-}" in
|
|
38
|
+
restore|save) if enabled; then "$1"; else echo "cache: no bucket configured (CI_CACHE_TOKEN, CI_CACHE_ACCOUNT, CI_CACHE_BUCKET), local directory only"; fi ;;
|
|
39
|
+
*) echo "usage: scripts/ci-cache.sh restore|save"; exit 2 ;;
|
|
40
|
+
esac
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Shared by scripts/ci.sh and scripts/release.sh: the step runner that records every step for the status page
|
|
3
|
+
# (scripts/ci-status.ts), backend detection, and daemon helpers that need nothing beyond bash and curl (the Workers
|
|
4
|
+
# Builds image has neither lsof nor jq). Source it after `cd` to the repository root.
|
|
5
|
+
ci_backend() { if [ -n "${WORKERS_CI_BUILD_UUID:-}" ] || [ "${WORKERS_CI:-}" = "1" ]; then echo cloudflare; elif [ "${GITHUB_ACTIONS:-}" = "true" ]; then echo github; else echo local; fi; }
|
|
6
|
+
CI_STEPS_DIR="${CI_STEPS_DIR:-.void/ci-logs/steps}"; CI_STEPS_TSV="${CI_STEPS_TSV:-.void/ci-steps.tsv}"
|
|
7
|
+
ci_failed=0
|
|
8
|
+
# step <name> <command...>: runs the command in this shell (a function may export variables), shows its output live,
|
|
9
|
+
# keeps it in $CI_STEPS_DIR/<name>.log, records name / ok|fail / seconds, and returns the command's status
|
|
10
|
+
step() {
|
|
11
|
+
local name="$1"; shift; local t0 rc log tp secs; t0=$(date +%s); log="$CI_STEPS_DIR/$name.log"; mkdir -p "$CI_STEPS_DIR"
|
|
12
|
+
printf '\n=== %s\n' "$name"
|
|
13
|
+
: > "$log"; tail -n +1 -f "$log" & tp=$!
|
|
14
|
+
"$@" > "$log" 2>&1; rc=$?
|
|
15
|
+
sleep 0.3; kill "$tp" 2>/dev/null; wait "$tp" 2>/dev/null
|
|
16
|
+
secs=$(( $(date +%s) - t0 ))
|
|
17
|
+
if [ "$rc" -eq 0 ]; then printf -- '--- %s: ok (%ss)\n' "$name" "$secs"; else ci_failed=$((ci_failed + 1)); printf -- '--- %s: FAILED (exit %s, %ss)\n' "$name" "$rc" "$secs"; fi
|
|
18
|
+
printf '%s\t%s\t%s\t%s\n' "$name" "$([ "$rc" -eq 0 ] && echo ok || echo fail)" "$secs" "$log" >> "$CI_STEPS_TSV"
|
|
19
|
+
return "$rc"
|
|
20
|
+
}
|
|
21
|
+
skip_step() { printf '%s\tskip\t0\t\n' "$1" >> "$CI_STEPS_TSV"; printf '\n=== %s: skipped%s\n' "$1" "${2:+ ($2)}"; }
|
|
22
|
+
# the plan scripts/ci-plan.ts wrote (.void/ci-plan.txt): plan_run <key> succeeds when the key runs, plan_reason <key>
|
|
23
|
+
# prints why, plan_list suites|bun prints the selected suites
|
|
24
|
+
plan_run() { grep -qE "^$1 run " .void/ci-plan.txt 2>/dev/null; }
|
|
25
|
+
plan_reason() { sed -n "s/^$1 [a-z]* //p" .void/ci-plan.txt 2>/dev/null | head -n 1; }
|
|
26
|
+
plan_list() { sed -n "s/^$1 //p" .void/ci-plan.txt 2>/dev/null | head -n 1; }
|
|
27
|
+
plan_flag() { grep -qE "^$1 yes" .void/ci-plan.txt 2>/dev/null; }
|
|
28
|
+
# ci_cache_dir: the directory kept between runs; on Workers Builds only the package manager cache survives a build,
|
|
29
|
+
# so it lives inside bun's (the dependencies cache the build system restores and uploads)
|
|
30
|
+
ci_cache_dir() { if [ -n "${CI_CACHE_DIR:-}" ]; then echo "$CI_CACHE_DIR"; elif [ "$(ci_backend)" = cloudflare ]; then echo "$HOME/.bun/install/cache/voidbase-ci"; else echo "${XDG_CACHE_HOME:-$HOME/.cache}/voidbase-ci"; fi; }
|
|
31
|
+
# port_busy <port>: something listens on 127.0.0.1:<port>
|
|
32
|
+
port_busy() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null; }
|
|
33
|
+
# wait_http <url> [tries=60]: until the URL answers
|
|
34
|
+
wait_http() { curl --retry "${2:-60}" --retry-delay 1 --retry-all-errors -s -o /dev/null "$1"; }
|
|
35
|
+
# daemon <name> <log> <command...>: a detached process group whose pid is kept in .void/ci-<name>.pid
|
|
36
|
+
daemon() { local name="$1" log="$2"; shift 2; ( setsid nohup "$@" > "$log" 2>&1 < /dev/null & echo $! > ".void/ci-$name.pid" ); }
|
|
37
|
+
# stop_daemon <name>: ends the process group started by daemon
|
|
38
|
+
stop_daemon() { local f=".void/ci-$1.pid" pid; [ -f "$f" ] || return 0; pid=$(cat "$f"); kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null; rm -f "$f"; }
|
|
39
|
+
# render_status [--kind ci|release]: the status page from the recorded steps (ci/public)
|
|
40
|
+
render_status() { bun scripts/ci-status.ts render "$@"; }
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The oracle every CI run needs, resolved the same way on a dev machine, GitHub Actions and Workers Builds: the
|
|
3
|
+
# unmodified pocketbase-sveltekit-starter (pb_hooks, pb_migrations, the sk frontend). $STARTER_DIR when set, else the
|
|
4
|
+
# sibling checkout ../pocketbase-sveltekit-starter when it exists, else a shallow clone under .void/oracles.
|
|
5
|
+
# (The panel needs no step here: scripts/sync-panel.ts reads POCKETBASE_UI_DIST, else ../pocketbase/ui/dist, else
|
|
6
|
+
# downloads the pinned PocketBase tag.) Source it: `. scripts/ci-oracles.sh`; it exports STARTER_DIR, absolute.
|
|
7
|
+
_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
8
|
+
STARTER_REPO="${STARTER_REPO:-https://github.com/spinspire/pocketbase-sveltekit-starter}"
|
|
9
|
+
if [ -z "${STARTER_DIR:-}" ]; then
|
|
10
|
+
if [ -d "$_root/../pocketbase-sveltekit-starter/pb/pb_hooks" ]; then STARTER_DIR="$_root/../pocketbase-sveltekit-starter"
|
|
11
|
+
else
|
|
12
|
+
_oracles="${CI_CACHE_DIR:-$_root/.void}/oracles"; STARTER_DIR="$_oracles/pocketbase-sveltekit-starter"
|
|
13
|
+
if [ -d "$STARTER_DIR/.git" ]; then git -C "$STARTER_DIR" pull --quiet --ff-only 2>/dev/null || echo "starter: pull failed, keeping the cached clone"
|
|
14
|
+
else echo "cloning $STARTER_REPO into $_oracles"; mkdir -p "$_oracles"; git clone --quiet --depth 1 "$STARTER_REPO" "$STARTER_DIR" || { echo "clone failed"; return 1 2>/dev/null || exit 1; }; fi
|
|
15
|
+
fi
|
|
16
|
+
fi
|
|
17
|
+
[ -d "$STARTER_DIR/pb/pb_hooks" ] || { echo "STARTER_DIR $STARTER_DIR has no pb/pb_hooks"; return 1 2>/dev/null || exit 1; }
|
|
18
|
+
STARTER_DIR="$(cd "$STARTER_DIR" && pwd)"; export STARTER_DIR
|
|
19
|
+
echo "starter: $STARTER_DIR"
|