Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 1 | /** |
| 2 | * The whole service, on Node, opening builds for g1t Actions jobs | |
| 3 | * (`start_from_workflow`): a D1 made of node:sqlite with the service's | |
| 4 | * migrations, the other services as fakes that answer by method, and | |
| 5 | * Cloudflare's API as a recorded fetch. See moves.flow.test.ts. | |
| 6 | */ | |
| 7 | ||
| 8 | import assert from "node:assert/strict"; | |
| 9 | import { readdirSync, readFileSync } from "node:fs"; | |
| 10 | import { registerHooks } from "node:module"; | |
| 11 | import { DatabaseSync } from "node:sqlite"; | |
| 12 | import { beforeEach, test } from "node:test"; | |
| 13 | ||
| 14 | registerHooks({ | |
| 15 | resolve(specifier, context, next) { | |
| 16 | try { | |
| 17 | return next(specifier, context); | |
| 18 | } catch (error) { | |
| 19 | if (specifier.startsWith(".")) return next(`${specifier}.ts`, context); | |
| 20 | throw error; | |
| 21 | } | |
| 22 | }, | |
| 23 | }); | |
| 24 | const { default: worker } = await import("./index.ts"); | |
| 25 | ||
| 26 | function d1(db: DatabaseSync) { | |
| 27 | const value = (v: unknown) => (v === undefined ? null : typeof v === "boolean" ? Number(v) : v); | |
| 28 | const statement = (sql: string, params: unknown[] = []) => ({ | |
| 29 | sql, | |
| 30 | params, | |
| 31 | bind: (...args: unknown[]) => statement(sql, args.map(value)), | |
| 32 | async first<T>() { | |
| 33 | return (db.prepare(sql).get(...(params as never[])) as T) ?? null; | |
| 34 | }, | |
| 35 | async all<T>() { | |
| 36 | return { results: db.prepare(sql).all(...(params as never[])) as T[], success: true }; | |
| 37 | }, | |
| 38 | async run() { | |
| 39 | const r = db.prepare(sql).run(...(params as never[])); | |
| 40 | return { success: true, meta: { changes: Number(r.changes) } }; | |
| 41 | }, | |
| 42 | }); | |
| 43 | return { | |
| 44 | prepare: (sql: string) => statement(sql), | |
| 45 | async batch(statements: ReturnType<typeof statement>[]) { | |
| 46 | db.exec("BEGIN"); | |
| 47 | try { | |
| 48 | const out = statements.map((s) => ({ results: db.prepare(s.sql).all(...(s.params as never[])) })); | |
| 49 | db.exec("COMMIT"); | |
| 50 | return out; | |
| 51 | } catch (error) { | |
| 52 | db.exec("ROLLBACK"); | |
| 53 | throw error; | |
| 54 | } | |
| 55 | }, | |
| 56 | }; | |
| 57 | } | |
| 58 | ||
| 59 | const REPO = "rep_web"; | |
| 60 | const PROJECT = "prj_web"; | |
| 61 | const SHA = "4b8d0f2a6c1e3579bd02468ace13579bdf02468a"; | |
| 62 | ||
| 63 | function world() { | |
| 64 | const sqlite = new DatabaseSync(":memory:"); | |
| 65 | const dir = new URL("../migrations/", import.meta.url); | |
| 66 | for (const file of readdirSync(dir).sort()) sqlite.exec(readFileSync(new URL(file, dir), "utf8")); | |
| 67 | const state = { | |
| 68 | sqlite, | |
| 69 | run: { id: "run_1", event: "push", ref: "refs/heads/main", sha: SHA, pull: null as number | null, status: "in_progress", actor: "ada" }, | |
| 70 | jobStatus: "in_progress", | |
| 71 | approval: null as null | { state: string }, | |
| 72 | pull: { status: "open", headCommit: SHA, branch: "feature" as string | null, fork: null as null | { namespace: string; name: string } }, | |
| 73 | collaborator: "write", | |
| 74 | plan: true, | |
| 75 | limit: "ok", | |
| Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API. | 76 | /** Billing does not take charges (it is down, say). */ |
| 77 | chargeFails: false, | |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 78 | /** What other services were asked, as `SERVICE method`. */ |
| 79 | calls: [] as string[], | |
| 80 | /** What `resolve_settings` was asked for. */ | |
| 81 | resolved: [] as { environment: string; trusted: boolean }[], | |
| 82 | }; | |
| 83 | const project = () => ({ | |
| 84 | id: PROJECT, | |
| 85 | workspace: "acme", | |
| 86 | slug: "web", | |
| 87 | name: "web", | |
| 88 | primary: true, | |
| 89 | source: { kind: "hosted", repoId: REPO, repo: { namespace: "acme", name: "web" }, defaultBranch: "main", rootDir: "site" }, | |
| 90 | }); | |
| 91 | const ok = (value: unknown) => ({ ok: true, value }); | |
| 92 | const service = (name: string, answer: (method: string, args: any) => unknown) => ({ | |
| 93 | async fetch(input: string | Request, init?: RequestInit) { | |
| 94 | const url = typeof input === "string" ? input : input.url; | |
| 95 | const method = new URL(url).pathname.split("/").pop()!; | |
| 96 | state.calls.push(`${name} ${method}`); | |
| 97 | const args = init?.body ? JSON.parse(String(init.body)) : {}; | |
| 98 | return Response.json((await answer(method, args)) ?? ok(null)); | |
| 99 | }, | |
| 100 | }); | |
| 101 | const env = { | |
| 102 | DB: d1(sqlite), | |
| 103 | CLOUDFLARE_API_TOKEN: "test", | |
| 104 | CLOUDFLARE_ACCOUNT_ID: "acct", | |
| 105 | DISPATCH_NAMESPACE: "g1t-deployments", | |
| 106 | SITE: "https://g1t.sh", | |
| 107 | REPOS: service("REPOS", (method) => { | |
| 108 | if (method === "get") return ok({ id: REPO, namespace: "acme", name: "web", isPrivate: true, defaultBranch: "main" }); | |
| 109 | return ok([]); | |
| 110 | }), | |
| 111 | PROJECTS: service("PROJECTS", (method) => (method === "by_repo" ? [project()] : ok(project()))), | |
| 112 | IDENTITY: service("IDENTITY", (method, args) => { | |
| 113 | if (method === "get_workspace") return { id: `wsp_${args.slug}`, slug: args.slug }; | |
| 114 | if (method === "collaborator_permission") return ok({ role: state.collaborator }); | |
| 115 | return ok(null); | |
| 116 | }), | |
| 117 | BILLING: service("BILLING", (method) => { | |
| 118 | if (method === "has_feature") return state.plan ? ok(true) : { ok: false, error: { code: "payment_required", message: "Deployments need the Team plan." } }; | |
| 119 | if (method === "check_limit") return ok({ state: state.limit, message: state.limit === "stopped" ? "The workspace reached its spend limit." : null }); | |
| 120 | if (method === "prices") return { prices: [] }; | |
| Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API. | 121 | if (method === "charge_feature" && state.chargeFails) return { ok: false, error: { code: "unavailable", message: "billing is down" } }; |
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 122 | return ok(null); |
| 123 | }), | |
| 124 | WORK: service("WORK", (method) => { | |
| 125 | if (method === "get_pull") return ok({ pull: { ...state.pull, number: 9, author: { username: "eve", kind: "user" } } }); | |
| 126 | return ok(null); | |
| 127 | }), | |
| 128 | ACTIONS: service("ACTIONS", (method, args) => { | |
| 129 | if (method === "run") { | |
| 130 | return ok({ run: state.run, jobs: [{ id: "job_1", status: state.jobStatus }], notes: [], approval: state.approval }); | |
| 131 | } | |
| 132 | if (method === "resolve_settings") { | |
| 133 | state.resolved.push({ environment: args.environment, trusted: args.trusted }); | |
| 134 | return { secrets: { API_KEY: "s3cret-value" }, variables: { PUBLIC_URL: "https://example.com" } }; | |
| 135 | } | |
| 136 | return ok(null); | |
| 137 | }), | |
| 138 | RUNNER: service("RUNNER", () => ok(true)), | |
| 139 | }; | |
| 140 | return { state, env }; | |
| 141 | } | |
| 142 | ||
| 143 | let w: ReturnType<typeof world>; | |
| 144 | const realFetch = globalThis.fetch; | |
| 145 | ||
| 146 | beforeEach(() => { | |
| 147 | w = world(); | |
| 148 | globalThis.fetch = (async (input: string | Request) => { | |
| 149 | const url = new URL(typeof input === "string" ? input : input.url); | |
| 150 | w.state.calls.push(`CLOUDFLARE ${url.pathname}`); | |
| 151 | if (url.pathname.endsWith("/graphql")) return Response.json({ data: { viewer: { accounts: [{}] } } }); | |
| 152 | return Response.json({ success: true, result: { jwt: "done", buckets: [] }, result_info: { total_pages: 1 } }); | |
| 153 | }) as typeof fetch; | |
| 154 | w.state.sqlite | |
| 155 | .prepare( | |
| 156 | `INSERT INTO settings (project_id, repo_id, workspace, slug, enabled, previews, production, idle_days, updated_at) | |
| 157 | VALUES (?, ?, 'acme', 'web', 1, 1, 1, 7, '2026-10-05T00:00:00Z')`, | |
| 158 | ) | |
| 159 | .run(PROJECT, REPO); | |
| 160 | }); | |
| 161 | ||
| 162 | process.on("exit", () => { | |
| 163 | globalThis.fetch = realFetch; | |
| 164 | }); | |
| 165 | ||
| 166 | const jobActor = (scopes: string[] = ["contents:read", "deployments:write"]) => ({ | |
| 167 | id: "wsp_acme", | |
| 168 | username: "acme", | |
| 169 | kind: "workspace", | |
| 170 | verified: true, | |
| 171 | workspaces: [{ slug: "acme", role: "member" }], | |
| 172 | token: { token_id: "tok_job", repo: "acme/web", job: { run_id: "run_1", job_id: "job_1" }, scopes }, | |
| 173 | }); | |
| 174 | ||
| 175 | async function post(path: string, body: unknown): Promise<any> { | |
| 176 | const response = await worker.fetch( | |
| 177 | new Request(`https://deployments${path}`, { method: "POST", body: JSON.stringify(body) }), | |
| 178 | w.env as never, | |
| 179 | {} as never, | |
| 180 | ); | |
| 181 | return response.json(); | |
| 182 | } | |
| 183 | ||
| 184 | const open = (body: Record<string, unknown> = {}) => | |
| 185 | post("/rpc/start_from_workflow", { actor: jobActor(), repo: { namespace: "acme", name: "web" }, ...body }); | |
| 186 | ||
| 187 | const rows = (sql: string, ...params: unknown[]) => | |
| 188 | (w.state.sqlite.prepare(sql).all(...(params as never[])) as any[]).map((row) => ({ ...row })); | |
| 189 | ||
| 190 | test("a push to the production branch opens a production build the job runs, with no sandbox and nothing reserved", async () => { | |
| 191 | const answer = await open(); | |
| 192 | assert.equal(answer.ok, true, JSON.stringify(answer)); | |
| 193 | const build = answer.value; | |
| 194 | assert.equal(build.kind, "production"); | |
| 195 | assert.equal(build.project, "web"); | |
| 196 | assert.equal(build.commit, SHA); | |
| 197 | assert.equal(build.url, "https://web-acme.g1t.page"); | |
| 198 | assert.equal(build.trusted, true); | |
| 199 | assert.equal(build.root_dir, "site"); | |
| 200 | assert.deepEqual(build.build_secrets, { API_KEY: "s3cret-value" }); | |
| 201 | assert.deepEqual(build.build_env, { PUBLIC_URL: "https://example.com" }); | |
| 202 | assert.match(build.token, /^[0-9a-f]{64}$/); | |
| 203 | assert.deepEqual(rows("SELECT kind, status, run_id, job_id, created_by, trusted FROM deployments"), [ | |
| 204 | { kind: "production", status: "queued", run_id: "run_1", job_id: "job_1", created_by: "ada", trusted: 1 }, | |
| 205 | ]); | |
| 206 | // The job runs it: no sandbox, and no compute reserved for it. | |
| 207 | assert.ok(!w.state.calls.includes("RUNNER start_deploy")); | |
| 208 | assert.ok(!w.state.calls.some((call) => call === "BILLING reserve" || call === "BILLING entitlements")); | |
| 209 | // The commit's status says it is building. | |
| 210 | assert.ok(w.state.calls.includes("WORK set_commit_status")); | |
| 211 | }); | |
| 212 | ||
| 213 | test("the job reports with the build's token, and its seconds are not charged again", async () => { | |
| 214 | const { value: build } = await open(); | |
| 215 | assert.equal((await post(`/jobs/${build.deploy_id}/started`, { token: build.token })).ok, true); | |
| 216 | const finished = await post(`/jobs/${build.deploy_id}/finish`, { | |
| 217 | token: build.token, | |
| 218 | worker: { compatibilityDate: "2026-09-26", vars: {}, notFoundHandling: "single-page-application" }, | |
| 219 | completionJwt: "done", | |
| 220 | buildSeconds: 42, | |
| 221 | detected: "static", | |
| 222 | }); | |
| 223 | assert.equal(finished.ok, true, JSON.stringify(finished)); | |
| 224 | assert.equal(rows("SELECT error FROM deployments")[0].error, null); | |
| 225 | assert.deepEqual(rows("SELECT status, build_seconds, detected FROM deployments"), [{ status: "ready", build_seconds: 42, detected: "static" }]); | |
| 226 | assert.deepEqual(rows("SELECT script, kind FROM apps"), [{ script: "web-acme", kind: "production" }]); | |
| 227 | assert.ok(!w.state.calls.includes("BILLING charge_feature")); | |
| 228 | assert.deepEqual(rows("SELECT build_seconds FROM meters WHERE build_seconds > 0"), []); | |
| 229 | // The token was for that build alone, until it finished. | |
| 230 | assert.equal((await post(`/jobs/${build.deploy_id}/started`, { token: build.token })).ok, false); | |
| 231 | }); | |
| 232 | ||
| 233 | test("a sandbox's build is still charged by the second; a workflow job's never is", async () => { | |
| 234 | // A classic build: what a push opens, built by g1t's sandbox. | |
| 235 | const { createHash } = await import("node:crypto"); | |
| 236 | const token = "f".repeat(64); | |
| 237 | w.state.sqlite | |
| 238 | .prepare( | |
| 239 | `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha, script, status, | |
| 240 | token_hash, trusted, created_by, created_at) | |
| 241 | VALUES ('dpl_sandbox', ?, 'acme', 'web', ?, 'acme/web', 'preview', 'other', NULL, ?, 'web-git-other-acme', 'building', ?, 1, 'g1t', '2026-10-12T00:00:00Z')`, | |
| 242 | ) | |
| 243 | .run(PROJECT, REPO, SHA, createHash("sha256").update(token).digest("hex")); | |
| 244 | const finish = { worker: { compatibilityDate: "2026-09-26", vars: {} }, completionJwt: "done", buildSeconds: 30 }; | |
| 245 | assert.equal((await post("/jobs/dpl_sandbox/finish", { token, ...finish })).ok, true); | |
| 246 | assert.equal(w.state.calls.filter((call) => call === "BILLING charge_feature").length, 1); | |
| 247 | assert.deepEqual(rows("SELECT build_seconds FROM meters"), [{ build_seconds: 30 }]); | |
| 248 | // A workflow job's build of the same project, finished and then failed: not charged. | |
| 249 | const { value: done } = await open(); | |
| 250 | assert.equal((await post(`/jobs/${done.deploy_id}/finish`, { token: done.token, ...finish })).ok, true); | |
| 251 | const { value: failed } = await open(); | |
| 252 | await post(`/jobs/${failed.deploy_id}/fail`, { token: failed.token, message: "npm run build failed", buildSeconds: 99 }); | |
| 253 | assert.equal(w.state.calls.filter((call) => call === "BILLING charge_feature").length, 1); | |
| 254 | assert.deepEqual(rows("SELECT build_seconds FROM meters"), [{ build_seconds: 30 }]); | |
| 255 | // What runs after the build is the same app as any: in apps, which the sweep meters. | |
| 256 | assert.deepEqual(rows("SELECT script, kind FROM apps ORDER BY script"), [ | |
| 257 | { script: "web-acme", kind: "production" }, | |
| 258 | { script: "web-git-other-acme", kind: "preview" }, | |
| 259 | ]); | |
| 260 | }); | |
| 261 | ||
| Billing is ready to take real money, and stays on Stripe's test key until the switch. The g1t plan is $21 a month, all-in, with $10 of usage included: the card processing fee is part of the price, and no payment carries a fee line any more (old fee rows still read as they were). Card, billing address, tax IDs, invoices and cancelling are on Stripe's billing page (Manage billing), whose configuration billing keeps in step; Checkout asks for the billing address and a tax ID; g1t's Billing page keeps usage, the spend limit, budget alerts and prepaying. Members other than owners and billing managers no longer see billing details or invoices, from the page or the API. | 262 | test("a build billing cannot charge still goes live and is announced; the sweep charges it once, later", async () => { |
| 263 | const { createHash } = await import("node:crypto"); | |
| 264 | const token = "e".repeat(64); | |
| 265 | w.state.sqlite | |
| 266 | .prepare( | |
| 267 | `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha, script, status, | |
| 268 | token_hash, trusted, created_by, created_at) | |
| 269 | VALUES ('dpl_owed', ?, 'acme', 'web', ?, 'acme/web', 'preview', 'other', NULL, ?, 'web-git-other-acme', 'building', ?, 1, 'g1t', '2026-10-12T00:00:00Z')`, | |
| 270 | ) | |
| 271 | .run(PROJECT, REPO, SHA, createHash("sha256").update(token).digest("hex")); | |
| 272 | w.state.chargeFails = true; | |
| 273 | const finished = await post("/jobs/dpl_owed/finish", { token, worker: { compatibilityDate: "2026-09-26", vars: {} }, completionJwt: "done", buildSeconds: 30 }); | |
| 274 | assert.equal(finished.ok, true, JSON.stringify(finished)); | |
| 275 | // Live and said so, though billing refused the charge. | |
| 276 | assert.deepEqual(rows("SELECT status, build_seconds, build_charged_at FROM deployments"), [{ status: "ready", build_seconds: 30, build_charged_at: null }]); | |
| 277 | assert.ok(w.state.calls.includes("WORK set_commit_status")); | |
| 278 | assert.deepEqual(rows("SELECT build_seconds FROM meters WHERE build_seconds > 0"), []); | |
| 279 | // Still down: the sweep tries and keeps it. | |
| 280 | await worker.scheduled({} as never, w.env as never); | |
| 281 | assert.equal(rows("SELECT build_charged_at FROM deployments")[0].build_charged_at, null); | |
| 282 | // Back: charged once, counted once, and never again. | |
| 283 | w.state.chargeFails = false; | |
| 284 | const before = w.state.calls.filter((call) => call === "BILLING charge_feature").length; | |
| 285 | await worker.scheduled({} as never, w.env as never); | |
| 286 | await worker.scheduled({} as never, w.env as never); | |
| 287 | assert.equal(w.state.calls.filter((call) => call === "BILLING charge_feature").length, before + 1); | |
| 288 | assert.notEqual(rows("SELECT build_charged_at FROM deployments")[0].build_charged_at, null); | |
| 289 | assert.deepEqual(rows("SELECT build_seconds FROM meters WHERE build_seconds > 0"), [{ build_seconds: 30 }]); | |
| 290 | }); | |
| 291 | ||
| Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9 | 292 | test("another branch builds its preview; a pull request's run builds the pull request's", async () => { |
| 293 | w.state.run = { ...w.state.run, ref: "refs/heads/feature/x" }; | |
| 294 | const branch = (await open()).value; | |
| 295 | assert.deepEqual([branch.kind, branch.branch, branch.number], ["preview", "feature/x", null]); | |
| 296 | w.state.run = { ...w.state.run, event: "pull_request", ref: "refs/pull/9/merge", pull: 9 }; | |
| 297 | const pull = (await open()).value; | |
| 298 | assert.deepEqual([pull.kind, pull.branch, pull.number, pull.trusted], ["preview", "feature", 9, true]); | |
| 299 | assert.deepEqual(w.state.resolved.map((r) => r.environment), ["preview", "preview"]); | |
| 300 | }); | |
| 301 | ||
| 302 | test("a pull request from a fork, or one that had to be approved, gets no build secrets", async () => { | |
| 303 | w.state.run = { ...w.state.run, event: "pull_request", ref: "refs/pull/9/merge", pull: 9 }; | |
| 304 | w.state.pull = { ...w.state.pull, branch: null, fork: { namespace: "eve", name: "web" } }; | |
| 305 | const fork = (await open()).value; | |
| 306 | assert.deepEqual([fork.branch, fork.trusted, fork.build_secrets], ["pr-9", false, {}]); | |
| 307 | w.state.pull = { ...w.state.pull, branch: "feature", fork: null }; | |
| 308 | w.state.approval = { state: "approved" }; | |
| 309 | const approved = (await open()).value; | |
| 310 | assert.deepEqual([approved.trusted, approved.build_secrets], [false, {}]); | |
| 311 | assert.deepEqual(w.state.resolved.map((r) => r.trusted), [false, false]); | |
| 312 | }); | |
| 313 | ||
| 314 | test("it is refused without a job's write token, after the job, without the plan, or with previews off", async () => { | |
| 315 | const refused = async (body: Record<string, unknown>) => (await open(body)).error?.code; | |
| 316 | assert.equal(await refused({ actor: jobActor(["deployments:read"]) }), "forbidden"); | |
| 317 | assert.equal(await refused({ actor: { ...jobActor(), token: { token_id: "tok_person", scopes: null } } }), "forbidden"); | |
| 318 | assert.equal(await refused({ repo: { namespace: "acme", name: "api" } }), "forbidden"); | |
| 319 | assert.equal(await refused({ project: "docs" }), "not_found"); | |
| 320 | w.state.jobStatus = "completed"; | |
| 321 | assert.equal(await refused({}), "conflict"); | |
| 322 | w.state.jobStatus = "in_progress"; | |
| 323 | w.state.run = { ...w.state.run, ref: "refs/tags/v1.0.0" }; | |
| 324 | assert.equal(await refused({}), "invalid"); | |
| 325 | w.state.run = { ...w.state.run, ref: "refs/heads/main" }; | |
| 326 | w.state.plan = false; | |
| 327 | const unpaid = await open(); | |
| 328 | assert.deepEqual([unpaid.ok, unpaid.error.code], [false, "payment_required"]); | |
| 329 | assert.deepEqual(rows("SELECT status FROM deployments"), [{ status: "skipped" }]); | |
| 330 | w.state.plan = true; | |
| 331 | w.state.limit = "stopped"; | |
| 332 | const stopped = await open(); | |
| 333 | assert.deepEqual([stopped.ok, stopped.error.code, stopped.error.message], [false, "payment_required", "The workspace reached its spend limit."]); | |
| 334 | w.state.limit = "ok"; | |
| 335 | w.state.sqlite.prepare("UPDATE settings SET previews = 0").run(); | |
| 336 | w.state.run = { ...w.state.run, ref: "refs/heads/feature" }; | |
| 337 | assert.equal(await refused({}), "conflict"); | |
| 338 | assert.ok(!w.state.calls.includes("RUNNER start_deploy")); | |
| 339 | }); | |
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 340 | |
| 341 | test("a job that ends without finishing its build fails the build at once, and leaves a finished one be", async () => { | |
| 342 | const build = (await open()).value; | |
| 343 | const ended = await post("/rpc/workflow_job_ended", { jobId: "job_1", conclusion: "cancelled" }); | |
| 344 | assert.deepEqual(ended, { ok: true, value: 1 }); | |
| 345 | assert.deepEqual(rows("SELECT id, status, error FROM deployments"), [ | |
| 346 | { id: build.deploy_id, status: "failed", error: "The workflow job was cancelled before the build finished." }, | |
| 347 | ]); | |
| 348 | // Another job's build, and one that is no longer open, are left alone. | |
| 349 | w.state.sqlite.prepare("UPDATE deployments SET job_id = 'job_2', status = 'building', error = NULL").run(); | |
| 350 | assert.deepEqual(await post("/rpc/workflow_job_ended", { jobId: "job_1", conclusion: "success" }), { ok: true, value: 0 }); | |
| 351 | assert.deepEqual(rows("SELECT status FROM deployments"), [{ status: "building" }]); | |
| 352 | w.state.sqlite.prepare("UPDATE deployments SET status = 'ready'").run(); | |
| 353 | assert.deepEqual(await post("/rpc/workflow_job_ended", { jobId: "job_2", conclusion: "success" }), { ok: true, value: 0 }); | |
| 354 | assert.deepEqual(rows("SELECT status FROM deployments"), [{ status: "ready" }]); | |
| 355 | assert.equal((await post("/rpc/workflow_job_ended", {})).error.code, "invalid"); | |
| 356 | }); |
This file's history is long; its oldest lines are credited to the oldest commit read.