cursedops 0.15.0 → 0.16.1
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/package.json +11 -1
- package/src/stageLease.ts +176 -0
- package/src/workerDeploy.ts +90 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots — and printing a command that runs when pasted — without knowing a path, the generation's whole-tree laws run over one repo from a checkout or a worktree, macOS launchd agent install/replace/remove and the live port a job serves, the scaffolding and verdicts of a deployed smoke (origin probe, the smoke's own environment, a network that lies about DNS, a settled version), the static-serving helpers eight apps copied — the path-traversal guard among them — the API floor that keeps an unmatched /api/... from ever being answered with the app shell, the commit and dirty flag a checkout-served process reports, the Cloudflare Worker deploy toolkit four apps copied (the deploy sequence, exact-set secrets over a pipe, origin-first rollback, the curl edge fetch, the row-for-row D1 import proof, the billed-CPU tail check around a deploy's walk and smoke, and each app's worker:secrets and worker:smoke main as one function of its data), the relay a Worker fronts a Mac-bound app with (the Durable Object, the frames, the Mac's dialer and key rotation — lifted from station for roms — and the signed-in stage walk's skeleton and the relay app's whole worker:deploy), the Worker import-graph and await-port checks every Worker app's suite runs over its own source, and the public-surface ratchet three published libraries each carried a forked copy of. Mechanism only — no app knows its name from here. Bun, zero runtime dependencies (typescript is an optional peer, for public-surface only), ships source.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -163,6 +163,12 @@
|
|
|
163
163
|
"bun": "./src/publicClient.ts",
|
|
164
164
|
"source": "./src/publicClient.ts",
|
|
165
165
|
"import": "./src/publicClient.ts"
|
|
166
|
+
},
|
|
167
|
+
"./stage-lease": {
|
|
168
|
+
"types": "./src/stageLease.ts",
|
|
169
|
+
"bun": "./src/stageLease.ts",
|
|
170
|
+
"source": "./src/stageLease.ts",
|
|
171
|
+
"import": "./src/stageLease.ts"
|
|
166
172
|
}
|
|
167
173
|
},
|
|
168
174
|
"bin": {
|
|
@@ -207,5 +213,9 @@
|
|
|
207
213
|
"script": "test"
|
|
208
214
|
}
|
|
209
215
|
]
|
|
216
|
+
},
|
|
217
|
+
"repository": {
|
|
218
|
+
"type": "git",
|
|
219
|
+
"url": "git+https://github.com/wizardmordamir/cursedforge-cursedops.git"
|
|
210
220
|
}
|
|
211
221
|
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedops/stage-lease` — an app's hosted stage has ONE holder at a time.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 Why (forge task 3048, 2026-10-11 02:26–02:40Z): the ship was in roms' `test:browser`, which
|
|
5
|
+
* HOLDS `roms-stage` (a key minted for that run, its digest on the Worker, a throwaway Mac end
|
|
6
|
+
* linked). A batch in the same minutes ran `worker:deploy -- --env stage` twice and its own
|
|
7
|
+
* `test:browser` twice — each a new link digest on the Worker, which disconnected the ship's Mac
|
|
8
|
+
* end. The ship's suite went red on 12 specs that pass alone, its alone-retry refused because the
|
|
9
|
+
* stage now served the batch's commit, and roms did not ship. Nothing refused either side: the
|
|
10
|
+
* ship's lane record guards PRODUCTION deploys, not the stage.
|
|
11
|
+
*
|
|
12
|
+
* The lease is a file, `$FORGE_STATE/locks/stage/<app>.lock`, created exclusively. Whoever holds the
|
|
13
|
+
* stage takes it — {@link runWorkerDeploy} for every deploy (a production deploy deploys and walks
|
|
14
|
+
* the stage first), and each app script that holds or walks its stage on its own — and anyone else
|
|
15
|
+
* is REFUSED with who holds it and since when.
|
|
16
|
+
*
|
|
17
|
+
* · The holder id rides `FORGE_STAGE_LEASE` into every child, so a `test:browser` that spawns its
|
|
18
|
+
* own `worker-stage.ts --hold` is let through by the lease it already holds.
|
|
19
|
+
* · A process that runs several holders in a row (`autopilot ship run`'s lane: `worker:deploy
|
|
20
|
+
* -- --env stage`, then `test:browser`, then `worker:deploy`) sets `FORGE_STAGE_LEASE` AND
|
|
21
|
+
* `FORGE_STAGE_LEASE_PID` (its own pid) for each step: the first step's lease is recorded
|
|
22
|
+
* against the LANE's pid and outlives the step, so no stranger slips in between steps. The lane
|
|
23
|
+
* removes it when it ends ({@link dropStageLease}); a lane that dies leaves a dead pid.
|
|
24
|
+
* · A lease whose pid is dead, or older than {@link STAGE_LEASE_MAX_MS}, is stale and taken over.
|
|
25
|
+
*/
|
|
26
|
+
import { randomUUID } from "node:crypto";
|
|
27
|
+
import { mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { dirname, join } from "node:path";
|
|
29
|
+
|
|
30
|
+
/** The holder id, inherited by every child of the process that took the lease. */
|
|
31
|
+
export const STAGE_LEASE_VAR = "FORGE_STAGE_LEASE";
|
|
32
|
+
/** The pid that OWNS a lease taken under {@link STAGE_LEASE_VAR} — a lane running several holders. */
|
|
33
|
+
export const STAGE_LEASE_PID_VAR = "FORGE_STAGE_LEASE_PID";
|
|
34
|
+
/**
|
|
35
|
+
* Past this, a lease is stale even with a live pid — a recycled pid must not hold a stage forever.
|
|
36
|
+
* The longest honest holder is a ship lane: roms' suite is ~8 min, a ship run ~25 min.
|
|
37
|
+
*/
|
|
38
|
+
export const STAGE_LEASE_MAX_MS = 2 * 60 * 60 * 1000;
|
|
39
|
+
/** A lease file that does not parse is a writer mid-write — stale only after this long. */
|
|
40
|
+
const UNREADABLE_GRACE_MS = 10_000;
|
|
41
|
+
|
|
42
|
+
export interface StageLease {
|
|
43
|
+
holder: string;
|
|
44
|
+
pid: number;
|
|
45
|
+
/** ISO time it was taken. */
|
|
46
|
+
started: string;
|
|
47
|
+
/** What took it — `roms worker:deploy --env stage`, `roms test:browser`. */
|
|
48
|
+
what: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface StageLeaseSpec {
|
|
52
|
+
/** The generation's state root (`forgeState`). */
|
|
53
|
+
state: string;
|
|
54
|
+
/** The app whose stage this is — `roms`. */
|
|
55
|
+
app: string;
|
|
56
|
+
what: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface StageLeaseDeps {
|
|
60
|
+
/** Read for the holder id, and WRITTEN with it when a fresh lease is taken. Default `process.env`. */
|
|
61
|
+
env?: NodeJS.ProcessEnv;
|
|
62
|
+
pid?: number;
|
|
63
|
+
now?: () => number;
|
|
64
|
+
alive?: (pid: number) => boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type StageLeaseResult =
|
|
68
|
+
| { ok: true; holder: string; inherited: boolean; release: () => void }
|
|
69
|
+
| { ok: false; refusal: string; held: StageLease | null };
|
|
70
|
+
|
|
71
|
+
export function stageLeasePath(state: string, app: string): string {
|
|
72
|
+
return join(state, "locks", "stage", `${app}.lock`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `kill -0`: ESRCH is dead; EPERM is a live process that is not ours. */
|
|
76
|
+
export function pidAlive(pid: number): boolean {
|
|
77
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
78
|
+
try {
|
|
79
|
+
process.kill(pid, 0);
|
|
80
|
+
return true;
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return (e as NodeJS.ErrnoException).code === "EPERM";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readLease(path: string): StageLease | null {
|
|
87
|
+
try {
|
|
88
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<StageLease>;
|
|
89
|
+
if (typeof parsed.holder !== "string" || typeof parsed.pid !== "number" || typeof parsed.started !== "string") return null;
|
|
90
|
+
return { holder: parsed.holder, pid: parsed.pid, started: parsed.started, what: String(parsed.what ?? "") };
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function minutes(ms: number): string {
|
|
97
|
+
return ms < 60_000 ? `${Math.max(0, Math.round(ms / 1000))}s` : `${Math.round(ms / 60_000)}m`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Take `<app>`'s stage, or be refused. A fresh lease puts its holder id in `deps.env` so children
|
|
102
|
+
* inherit it; `release` removes the file only when this process created it for itself (a lease
|
|
103
|
+
* recorded against a lane's pid is the lane's to drop).
|
|
104
|
+
*/
|
|
105
|
+
export function takeStageLease(spec: StageLeaseSpec, deps: StageLeaseDeps = {}): StageLeaseResult {
|
|
106
|
+
const env = deps.env ?? process.env;
|
|
107
|
+
const now = deps.now ?? Date.now;
|
|
108
|
+
const alive = deps.alive ?? pidAlive;
|
|
109
|
+
const self = deps.pid ?? process.pid;
|
|
110
|
+
const named = env[STAGE_LEASE_VAR]?.trim() || "";
|
|
111
|
+
const lanePid = Number(env[STAGE_LEASE_PID_VAR]);
|
|
112
|
+
const owner = named && Number.isInteger(lanePid) && lanePid > 0 ? lanePid : self;
|
|
113
|
+
const path = stageLeasePath(spec.state, spec.app);
|
|
114
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
115
|
+
|
|
116
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
117
|
+
const holder = named || randomUUID();
|
|
118
|
+
const lease: StageLease = { holder, pid: owner, started: new Date(now()).toISOString(), what: spec.what };
|
|
119
|
+
try {
|
|
120
|
+
writeFileSync(path, `${JSON.stringify(lease)}\n`, { flag: "wx" });
|
|
121
|
+
env[STAGE_LEASE_VAR] = holder;
|
|
122
|
+
const ours = owner === self;
|
|
123
|
+
return {
|
|
124
|
+
ok: true,
|
|
125
|
+
holder,
|
|
126
|
+
inherited: false,
|
|
127
|
+
release: () => {
|
|
128
|
+
if (ours) dropStageLease(spec.state, spec.app, holder);
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
} catch (e) {
|
|
132
|
+
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
|
|
133
|
+
}
|
|
134
|
+
const held = readLease(path);
|
|
135
|
+
if (!held) {
|
|
136
|
+
let age = 0;
|
|
137
|
+
try {
|
|
138
|
+
age = now() - statSync(path).mtimeMs;
|
|
139
|
+
} catch {
|
|
140
|
+
continue; // gone between the two calls — try again
|
|
141
|
+
}
|
|
142
|
+
if (age < UNREADABLE_GRACE_MS) {
|
|
143
|
+
return { ok: false, held: null, refusal: `${spec.app}'s stage lease (${path}) is being written right now — try again in a few seconds.` };
|
|
144
|
+
}
|
|
145
|
+
dropStageLease(spec.state, spec.app, null);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (named && held.holder === named) return { ok: true, holder: named, inherited: true, release: () => {} };
|
|
149
|
+
const age = now() - Date.parse(held.started);
|
|
150
|
+
if (!alive(held.pid) || !(age < STAGE_LEASE_MAX_MS)) {
|
|
151
|
+
dropStageLease(spec.state, spec.app, held.holder);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
held,
|
|
157
|
+
refusal:
|
|
158
|
+
`${spec.app}'s hosted stage is held by \`${held.what || "an unnamed holder"}\` (pid ${held.pid}, since ${held.started}, ${minutes(age)} ago). ` +
|
|
159
|
+
`A second holder would replace its Worker or its link key under it and turn its run red for nothing. ` +
|
|
160
|
+
`Wait for it to finish, then run this again. (The lease: ${path} — it is taken over by itself once pid ${held.pid} has exited.)`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return { ok: false, held: readLease(path), refusal: `${spec.app}'s stage lease (${path}) changed hands three times while this run tried to take it — try again.` };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Remove `<app>`'s lease — only if `holder` still holds it (`null`: whatever is there). */
|
|
167
|
+
export function dropStageLease(state: string, app: string, holder: string | null): boolean {
|
|
168
|
+
const path = stageLeasePath(state, app);
|
|
169
|
+
if (holder !== null && readLease(path)?.holder !== holder) return false;
|
|
170
|
+
try {
|
|
171
|
+
unlinkSync(path);
|
|
172
|
+
return true;
|
|
173
|
+
} catch {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
package/src/workerDeploy.ts
CHANGED
|
@@ -54,12 +54,14 @@
|
|
|
54
54
|
* refusal without wrangler, git or a network.
|
|
55
55
|
*/
|
|
56
56
|
import { spawn, spawnSync } from "node:child_process";
|
|
57
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
57
|
+
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
58
|
+
import { tmpdir } from "node:os";
|
|
58
59
|
import { join } from "node:path";
|
|
59
60
|
import { liveColumns, liveColumnsQueries, missingColumnStatements, schemaTables } from "cursedops/d1-columns";
|
|
60
61
|
import { wranglerRows } from "cursedops/d1-import";
|
|
61
62
|
import { forgeCodeRoot, forgeState } from "cursedops/roots";
|
|
62
63
|
import { appendLedger, deferral, readShipSetting, type ShipEventLine, type ShipNowWhere, type ShipSetting } from "cursedops/ship";
|
|
64
|
+
import { type StageLeaseResult, takeStageLease } from "cursedops/stage-lease";
|
|
63
65
|
|
|
64
66
|
/** The two deployments every Worker app here has. */
|
|
65
67
|
export type WorkerEnv = "production" | "stage";
|
|
@@ -338,6 +340,25 @@ export interface DeployDeps {
|
|
|
338
340
|
shipWhere?: ShipNowWhere;
|
|
339
341
|
/** Where a deferred production deploy's `ship` line goes. {@link workerDeployDeps} appends it to the ledger. */
|
|
340
342
|
recordShip?: (line: ShipEventLine) => void;
|
|
343
|
+
/**
|
|
344
|
+
* Take the app's hosted stage for the whole deploy (`./stageLease.ts`) — every deploy touches the
|
|
345
|
+
* stage, a production one deploys and walks it first. {@link workerDeployDeps} takes the
|
|
346
|
+
* generation's lease; absent (or `null` with no state root), nothing is leased.
|
|
347
|
+
*/
|
|
348
|
+
lease?: (app: string, what: string) => StageLeaseResult | null;
|
|
349
|
+
/**
|
|
350
|
+
* A fresh file for wrangler's ND-JSON output (`WRANGLER_OUTPUT_FILE_PATH`), which is where a failed
|
|
351
|
+
* `wrangler deploy` says WHY in a form a program can read ({@link transientDeployFailure}).
|
|
352
|
+
* {@link workerDeployDeps} makes one under the OS temp dir; absent, a failed deploy is not retried.
|
|
353
|
+
*/
|
|
354
|
+
wranglerOutput?: () => WranglerOutputFile;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** One `wrangler` run's output file: the env that points wrangler at it, its text, and its removal. */
|
|
358
|
+
export interface WranglerOutputFile {
|
|
359
|
+
env: Record<string, string>;
|
|
360
|
+
read: () => string;
|
|
361
|
+
dispose: () => void;
|
|
341
362
|
}
|
|
342
363
|
|
|
343
364
|
/**
|
|
@@ -413,6 +434,15 @@ export function workerDeployDeps(cwd: string): DeployDeps {
|
|
|
413
434
|
},
|
|
414
435
|
git: (args) => (spawnSync("git", [...args], { cwd, encoding: "utf8" }).stdout ?? "").trim(),
|
|
415
436
|
read: (path) => readFileSync(join(cwd, path), "utf8"),
|
|
437
|
+
wranglerOutput: () => {
|
|
438
|
+
const dir = mkdtempSync(join(tmpdir(), "cursedops-wrangler-"));
|
|
439
|
+
const path = join(dir, "output.ndjson");
|
|
440
|
+
return {
|
|
441
|
+
env: { WRANGLER_OUTPUT_FILE_PATH: path },
|
|
442
|
+
read: () => (existsSync(path) ? readFileSync(path, "utf8") : ""),
|
|
443
|
+
dispose: () => rmSync(dir, { recursive: true, force: true }),
|
|
444
|
+
};
|
|
445
|
+
},
|
|
416
446
|
record: (line) => {
|
|
417
447
|
recordDeployEvent(forgeState(cwd), line);
|
|
418
448
|
},
|
|
@@ -421,6 +451,10 @@ export function workerDeployDeps(cwd: string): DeployDeps {
|
|
|
421
451
|
recordShip: (line) => {
|
|
422
452
|
appendLedger(forgeState(cwd), line);
|
|
423
453
|
},
|
|
454
|
+
lease: (app, what) => {
|
|
455
|
+
const state = forgeState(cwd);
|
|
456
|
+
return state ? takeStageLease({ state, app, what }) : null;
|
|
457
|
+
},
|
|
424
458
|
};
|
|
425
459
|
}
|
|
426
460
|
|
|
@@ -561,6 +595,38 @@ export function wranglerError(stdout: string): { reason: string; api: boolean }
|
|
|
561
595
|
return { reason, api: name === "APIError" || (kind === "error" && /cloudflare api/i.test(reason)) };
|
|
562
596
|
}
|
|
563
597
|
|
|
598
|
+
/**
|
|
599
|
+
* Why a failed `wrangler deploy` is worth ONE more try — or `null` when it would fail the same way.
|
|
600
|
+
*
|
|
601
|
+
* Measured 2026-10-11 (forge task 3053): roms' production deploy uploaded the Worker and its routes,
|
|
602
|
+
* then Cloudflare answered the cron-schedules PUT with `Internal authentication error: internal
|
|
603
|
+
* server error [code: 10500]`. wrangler exited 1, the ship's roms lane went red, and the ship batch
|
|
604
|
+
* re-ran a 50-minute `ship run` to redo a step that a second request would have done. A deploy is
|
|
605
|
+
* idempotent — the same script, the same triggers — so a Cloudflare-side failure is retried once.
|
|
606
|
+
* A bundling error, a refused binding or a script that throws at startup (10021) is not: wrangler's
|
|
607
|
+
* own `command-failed` line ({@link WranglerOutputFile}) must name the server's fault or a retry-after.
|
|
608
|
+
*/
|
|
609
|
+
export function transientDeployFailure(ndjson: string): string | null {
|
|
610
|
+
type Failed = { code?: unknown; message?: unknown; retry_after_ms?: unknown };
|
|
611
|
+
let failed: Failed | null = null;
|
|
612
|
+
for (const line of ndjson.split("\n")) {
|
|
613
|
+
if (!line.trim()) continue;
|
|
614
|
+
try {
|
|
615
|
+
const entry = JSON.parse(line) as (Failed & { type?: unknown }) | null;
|
|
616
|
+
if (entry?.type === "command-failed") failed = entry;
|
|
617
|
+
} catch {
|
|
618
|
+
// a torn line from a killed run — the ones around it still count
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
if (!failed) return null;
|
|
622
|
+
const message = typeof failed.message === "string" ? failed.message : "";
|
|
623
|
+
const first = message.split("\n").find((l) => l.trim()) ?? "";
|
|
624
|
+
const reason = (detail: string) => `${first.trim() || "wrangler deploy failed"} — ${detail}`;
|
|
625
|
+
if (typeof failed.retry_after_ms === "number") return reason(`Cloudflare asked for a retry after ${failed.retry_after_ms} ms`);
|
|
626
|
+
const server = message.match(/internal server error|\[code: (?:10500|10013|7403)\]|\b50[234]\b|ETIMEDOUT|ECONNRESET|socket hang up|fetch failed/i);
|
|
627
|
+
return server ? reason(`"${server[0]}" is Cloudflare's side`) : null;
|
|
628
|
+
}
|
|
629
|
+
|
|
564
630
|
/**
|
|
565
631
|
* Did one schema chunk APPLY? Its exit code alone does not say.
|
|
566
632
|
*
|
|
@@ -707,7 +773,9 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
707
773
|
const commit = deps.git(["rev-parse", "HEAD"]);
|
|
708
774
|
const porcelain = deps.git(["status", "--porcelain"]);
|
|
709
775
|
const dirty = porcelain.length > 0;
|
|
776
|
+
let release = (): void => {};
|
|
710
777
|
const finish = (result: DeployResult): DeployResult => {
|
|
778
|
+
release();
|
|
711
779
|
enter("");
|
|
712
780
|
try {
|
|
713
781
|
deps.record?.({
|
|
@@ -755,6 +823,11 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
755
823
|
if (spec.env === "production" && dirty) {
|
|
756
824
|
return stop("clean-tree", `the tree is dirty — production would report ${commit.slice(0, 8)} and run something else:\n${porcelain}`);
|
|
757
825
|
}
|
|
826
|
+
// 🔴 One holder per hosted stage (task 3048): a stage deploy under the ship's held suite swapped
|
|
827
|
+
// the Worker and its link key under it. Taken before anything builds, held until `finish`.
|
|
828
|
+
const lease = deps.lease?.(spec.app, `${spec.app} worker:deploy${spec.env === "stage" ? " --env stage" : ""} @ ${commit.slice(0, 8)}`);
|
|
829
|
+
if (lease && !lease.ok) return stop("stage-lease", lease.refusal);
|
|
830
|
+
if (lease?.ok) release = lease.release;
|
|
758
831
|
for (const refusal of spec.beforeStage ?? []) {
|
|
759
832
|
const why = refusal();
|
|
760
833
|
if (why) return stop("refusal", why);
|
|
@@ -877,7 +950,22 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
877
950
|
? ["--var", `${spec.stampVars.commit}:${commit}`, "--var", `${spec.stampVars.dirty}:${shipsDirty ? "true" : "false"}`]
|
|
878
951
|
: [];
|
|
879
952
|
enter("upload");
|
|
880
|
-
|
|
953
|
+
const deployArgv = ["bunx", "wrangler", "deploy", ...envArgs, ...stamp];
|
|
954
|
+
const deployOnce = (): { code: number; transient: string | null } => {
|
|
955
|
+
const output = deps.wranglerOutput?.();
|
|
956
|
+
try {
|
|
957
|
+
const code = deps.run(deployArgv, { ...spec.credential, ...output?.env });
|
|
958
|
+
return { code, transient: code !== 0 && output ? transientDeployFailure(output.read()) : null };
|
|
959
|
+
} finally {
|
|
960
|
+
output?.dispose();
|
|
961
|
+
}
|
|
962
|
+
};
|
|
963
|
+
let deployed = deployOnce();
|
|
964
|
+
if (deployed.code !== 0 && deployed.transient) {
|
|
965
|
+
error(` 🟡 ${deployed.transient} — retrying the deploy once`);
|
|
966
|
+
deployed = deployOnce();
|
|
967
|
+
}
|
|
968
|
+
if (deployed.code !== 0) return stop("deploy", "wrangler deploy failed.");
|
|
881
969
|
if (spec.secrets) {
|
|
882
970
|
step("attach the secrets — exactly this deployment's set, over a pipe");
|
|
883
971
|
enter("secrets");
|