cursedops 0.20.0 → 0.21.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/README.md +1 -1
- package/package.json +1 -1
- package/src/ship.ts +52 -22
- package/src/workerDeploy.ts +110 -6
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ import shape, worked example, and the incident each one is pinned to.
|
|
|
17
17
|
| `cursedops/launchd` | installing, replacing and removing a macOS launchd user agent, and the port a LIVE job serves on (`livePort`) |
|
|
18
18
|
| `cursedops/launchd-imports` | the check that every launchd job — run straight out of an app's main checkout — imports only what the LOCKED versions of its packages export, so a lib-preview overlay's extra subpath or name cannot kill it at the next `bun install --force` (`launchdImportFaults`) |
|
|
19
19
|
| `cursedops/smoke` | the scaffolding of a deployed smoke (ledger, fetch, DNS hint, exit code) — the one check no app owns — plus its VERDICTS: the origin asked on loopback, the smoke's own environment, a network that lies about DNS, and a version that has settled |
|
|
20
|
-
| `cursedops/worker-deploy` | the sequence that ships a Cloudflare Worker — clean tree, stage first, schema, `--var` stamp, secrets, smoke — plus a billed-CPU tail wrapped around the walk and the smoke, and a ledger line per run |
|
|
20
|
+
| `cursedops/worker-deploy` | the sequence that ships a Cloudflare Worker — clean tree, stage first (or `--prod-first`: production alone, rolled back on a red smoke), schema, `--var` stamp, secrets, smoke — plus a billed-CPU tail wrapped around the walk and the smoke, and a ledger line per run |
|
|
21
21
|
| `cursedops/worker-secrets` | a Worker holding EXACTLY its deployment's secrets, uploaded over a pipe, read back up to five times before an inexact set is believed |
|
|
22
22
|
| `cursedops/worker-rollback` | a hostname back on its Mac origin — origin first, route second, the route found rather than typed |
|
|
23
23
|
| `cursedops/edge-fetch` | a request to a deployed Worker the way a script must make it — curl pinned past the Mac's negative DNS cache |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
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 check that a launchd job imports only what the LOCKED versions of its packages export (a lib-preview overlay's extra subpath or name is a job that dies at the next forced reinstall), 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": {
|
package/src/ship.ts
CHANGED
|
@@ -160,11 +160,12 @@ export interface ShipNowWhere {
|
|
|
160
160
|
|
|
161
161
|
/**
|
|
162
162
|
* Is `FORGE_SHIP_NOW` honored here? Outside a batch, whenever it is asked: that is the owner's own
|
|
163
|
-
* shell. Under `$AUTOPILOT_BATCH`, only when that batch holds a live
|
|
164
|
-
* (`$FORGE_STATE/state/board/*.json`, the pid alive and not expired) on a task in
|
|
163
|
+
* shell. Under `$AUTOPILOT_BATCH`, only when that batch holds a live task claim on a task in
|
|
165
164
|
* `$FORGE/tasks/In_Progress/` whose `escalation-key:` is one of {@link SHIP_NOW_KEYS}, AND
|
|
166
165
|
* `FORGE_SHIP_NOW_WHY` says why. Anything it cannot read is a refusal — a refused ship-now only
|
|
167
166
|
* makes the deploy later, which is the safe direction.
|
|
167
|
+
*
|
|
168
|
+
* A claim is read from either record a batch's task claim lives in ({@link heldClaims}).
|
|
168
169
|
*/
|
|
169
170
|
export function shipNowVerdict(where: ShipNowWhere, env: NodeJS.ProcessEnv = process.env): ShipNowVerdict {
|
|
170
171
|
const asked = shipNowAsked(env);
|
|
@@ -176,30 +177,15 @@ export function shipNowVerdict(where: ShipNowWhere, env: NodeJS.ProcessEnv = pro
|
|
|
176
177
|
const refuse = (why: string): ShipNowVerdict => ({ ...base, honoured: false, why });
|
|
177
178
|
if (!reason) return refuse(`under batch ${batch}, ${SHIP_NOW_VAR} needs ${SHIP_NOW_WHY_VAR}="<why this cannot wait for the ship>"`);
|
|
178
179
|
if (!where.state || !where.forge) return refuse(`under batch ${batch}, no generation to read the board from`);
|
|
179
|
-
const board = join(where.state, "state", "board");
|
|
180
|
-
const now = where.now ?? Date.now();
|
|
181
|
-
const isAlive = where.isAlive ?? pidAlive;
|
|
182
|
-
let files: string[] = [];
|
|
183
|
-
try {
|
|
184
|
-
files = readdirSync(board).filter((f) => f.startsWith("task-") && f.endsWith(".json"));
|
|
185
|
-
} catch {
|
|
186
|
-
/* no board → no claim */
|
|
187
|
-
}
|
|
188
180
|
const held: string[] = [];
|
|
189
|
-
for (const
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
c = JSON.parse(readFileSync(join(board, f), "utf8")) as BoardClaim;
|
|
193
|
-
} catch {
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
if (c.kind !== "task" || c.batch !== batch || typeof c.task !== "string" || typeof c.pid !== "number") continue;
|
|
197
|
-
if (typeof c.expiresAt === "string" && Date.parse(c.expiresAt) < now) continue;
|
|
198
|
-
if (!isAlive(c.pid)) continue;
|
|
181
|
+
for (const c of heldClaims(where.state, batch, where.now ?? Date.now(), where.isAlive ?? pidAlive)) {
|
|
182
|
+
if (held.includes(c.task)) continue;
|
|
199
183
|
held.push(c.task);
|
|
184
|
+
const stem = c.task.slice(0, -".md".length);
|
|
185
|
+
const text = [c.task, join(stem, c.task)].map((f) => join(where.forge as string, "tasks", "In_Progress", f)).find((f) => existsSync(f));
|
|
200
186
|
let key: string | null = null;
|
|
201
187
|
try {
|
|
202
|
-
key = escalationKey(readFileSync(
|
|
188
|
+
key = text ? escalationKey(readFileSync(text, "utf8")) : null;
|
|
203
189
|
} catch {
|
|
204
190
|
continue;
|
|
205
191
|
}
|
|
@@ -210,6 +196,50 @@ export function shipNowVerdict(where: ShipNowWhere, env: NodeJS.ProcessEnv = pro
|
|
|
210
196
|
);
|
|
211
197
|
}
|
|
212
198
|
|
|
199
|
+
/**
|
|
200
|
+
* The live task claims `batch` holds, from both records a claim lives in — the file shapes are the
|
|
201
|
+
* interface, so this never imports the runner:
|
|
202
|
+
* · autopilot-runner's claim record, `$FORGE_STATE/state/autopilot/claims/<task>.json`
|
|
203
|
+
* (`{pid, batch, at}`), where `autopilot mine` claims since forge task 3738. It holds while its
|
|
204
|
+
* pid lives; it names a task DIRECTORY without its `.md`. Until 3791 this was not read, and every
|
|
205
|
+
* batch's ship-now — the ship task's own publish — was refused;
|
|
206
|
+
* · the board's `state/board/task-*.json` (`kind: "task"`, an `expiresAt`), which a `finish`
|
|
207
|
+
* hand-off still writes.
|
|
208
|
+
* Each `task` is named with its `.md`; a task directory's file is `In_Progress/<stem>/<task>`.
|
|
209
|
+
*/
|
|
210
|
+
function heldClaims(state: string, batch: string, now: number, isAlive: (pid: number) => boolean): { task: string }[] {
|
|
211
|
+
const out: { task: string }[] = [];
|
|
212
|
+
const read = (dir: string, keep: (name: string) => boolean): [string, BoardClaim][] => {
|
|
213
|
+
let names: string[] = [];
|
|
214
|
+
try {
|
|
215
|
+
names = readdirSync(dir).filter((n) => n.endsWith(".json") && keep(n));
|
|
216
|
+
} catch {
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
const claims: [string, BoardClaim][] = [];
|
|
220
|
+
for (const n of names) {
|
|
221
|
+
try {
|
|
222
|
+
claims.push([n, JSON.parse(readFileSync(join(dir, n), "utf8")) as BoardClaim]);
|
|
223
|
+
} catch {
|
|
224
|
+
/* unreadable → no claim */
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return claims;
|
|
228
|
+
};
|
|
229
|
+
for (const [name, c] of read(join(state, "state", "autopilot", "claims"), () => true)) {
|
|
230
|
+
if (c.batch !== batch || typeof c.pid !== "number" || !isAlive(c.pid)) continue;
|
|
231
|
+
const stem = name.slice(0, -".json".length);
|
|
232
|
+
out.push({ task: stem.endsWith(".md") ? stem : `${stem}.md` });
|
|
233
|
+
}
|
|
234
|
+
for (const [, c] of read(join(state, "state", "board"), (n) => n.startsWith("task-"))) {
|
|
235
|
+
if (c.kind !== "task" || c.batch !== batch || typeof c.task !== "string" || typeof c.pid !== "number") continue;
|
|
236
|
+
if (typeof c.expiresAt === "string" && Date.parse(c.expiresAt) < now) continue;
|
|
237
|
+
if (!isAlive(c.pid)) continue;
|
|
238
|
+
out.push({ task: c.task });
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
213
243
|
/** The ledger line every ship-now ask leaves — honored or refused. */
|
|
214
244
|
export interface ShipNowEventLine {
|
|
215
245
|
at: string;
|
package/src/workerDeploy.ts
CHANGED
|
@@ -50,6 +50,14 @@
|
|
|
50
50
|
* rollback` puts the previous one back in one command — the message says so. The Mac-side
|
|
51
51
|
* rollback, which is a different thing, is `cursedops/worker-rollback`.
|
|
52
52
|
*
|
|
53
|
+
* ⏩ **Production first** ({@link PROD_FIRST_FLAG}, 0.21.0, forge task 3845) leaves out step 4 — the
|
|
54
|
+
* stage deploy and its signed-in walk — for the owner's Deploy now, which runs them BEHIND production
|
|
55
|
+
* instead. Station's Deploy now measured 210–252 s in `worker:deploy`, ~150–180 s of it the stage leg;
|
|
56
|
+
* production alone is install, build, upload and smoke, about 40 s. Everything else above still runs
|
|
57
|
+
* first. With no stage in front of it the smoke is production's only check, so under this flag a red
|
|
58
|
+
* smoke DOES roll back — to the version {@link liveVersionOf} read just before the upload, never the
|
|
59
|
+
* blind "previous" one (a secrets step makes a version of its own).
|
|
60
|
+
*
|
|
53
61
|
* Everything that shells out is behind {@link DeployDeps}, so the suite proves the order and every
|
|
54
62
|
* refusal without wrangler, git or a network.
|
|
55
63
|
*/
|
|
@@ -276,6 +284,11 @@ export interface WorkerDeploySpec {
|
|
|
276
284
|
build: readonly string[] | null;
|
|
277
285
|
/** Run in order BEFORE a production deploy; any red and production is not touched. Ignored for the stage. */
|
|
278
286
|
stageFirst?: readonly (readonly string[])[];
|
|
287
|
+
/**
|
|
288
|
+
* ⏩ Production without {@link stageFirst} and {@link stageWalk} — the caller runs them after
|
|
289
|
+
* (see {@link PROD_FIRST_FLAG}). Absent, {@link DeployDeps.argv} decides: `--prod-first` there.
|
|
290
|
+
*/
|
|
291
|
+
prodFirst?: boolean;
|
|
279
292
|
/**
|
|
280
293
|
* The signed-in walk of the stage, run after `stageFirst` and before production is touched — and,
|
|
281
294
|
* unlike `stageFirst`, under {@link WorkerDeploySpec.cpuTail} when that is set. Ignored for the stage.
|
|
@@ -343,7 +356,8 @@ export interface DeployDeps {
|
|
|
343
356
|
recordShip?: (line: ShipEventLine) => void;
|
|
344
357
|
/**
|
|
345
358
|
* Take the app's hosted stage for the whole deploy (`./stageLease.ts`) — every deploy touches the
|
|
346
|
-
* stage, a production one deploys and walks it first
|
|
359
|
+
* stage, a production one deploys and walks it first — all but a production-first one, which
|
|
360
|
+
* never touches it and takes none. {@link workerDeployDeps} takes the
|
|
347
361
|
* generation's lease; absent (or `null` with no state root), nothing is leased.
|
|
348
362
|
*/
|
|
349
363
|
lease?: (app: string, what: string) => StageLeaseResult | null;
|
|
@@ -353,6 +367,47 @@ export interface DeployDeps {
|
|
|
353
367
|
* {@link workerDeployDeps} makes one under the OS temp dir; absent, a failed deploy is not retried.
|
|
354
368
|
*/
|
|
355
369
|
wranglerOutput?: () => WranglerOutputFile;
|
|
370
|
+
/**
|
|
371
|
+
* The deploy's own command line — where {@link PROD_FIRST_FLAG} is read when the spec does not say.
|
|
372
|
+
* {@link workerDeployDeps} hands `process.argv.slice(2)`, so every app's `worker:deploy` takes the
|
|
373
|
+
* flag with no edit of its own; absent, nothing is read.
|
|
374
|
+
*/
|
|
375
|
+
argv?: readonly string[];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* ⏩ `bun run worker:deploy -- --prod-first`: production without the stage deploy and the signed-in
|
|
380
|
+
* stage walk in front of it ({@link WorkerDeploySpec.prodFirst}). The frozen install, every refusal,
|
|
381
|
+
* the build, the schema, the stamped upload, the secrets and the smoke still run, in order — and a
|
|
382
|
+
* red smoke rolls production back ({@link liveVersionOf}). Whoever passes it owes the stage after:
|
|
383
|
+
* autopilot's Deploy now runs `worker:deploy -- --env stage` and `worker:stage-walk` in its follow-up.
|
|
384
|
+
* An older cursedops ignores the word (no app's argv parsing refuses an unknown one), so it is safe
|
|
385
|
+
* to pass to any version — it only goes faster from 0.21.0.
|
|
386
|
+
*/
|
|
387
|
+
export const PROD_FIRST_FLAG = "--prod-first";
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The Worker version serving production right now, from `wrangler deployments status --json` — the
|
|
391
|
+
* version a production-first deploy rolls back TO. The one taking the most traffic; `null` when the
|
|
392
|
+
* answer is not a deployment (a first deploy, an API error), and then nothing is rolled back.
|
|
393
|
+
*/
|
|
394
|
+
export function liveVersionOf(stdout: string): string | null {
|
|
395
|
+
let doc: unknown;
|
|
396
|
+
try {
|
|
397
|
+
doc = JSON.parse(stdout);
|
|
398
|
+
} catch {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
const deployment = Array.isArray(doc) ? doc[0] : doc;
|
|
402
|
+
const versions = (deployment as { versions?: unknown } | null)?.versions;
|
|
403
|
+
if (!Array.isArray(versions)) return null;
|
|
404
|
+
let best: { id: string; pct: number } | null = null;
|
|
405
|
+
for (const v of versions as Array<{ version_id?: unknown; percentage?: unknown }>) {
|
|
406
|
+
if (typeof v?.version_id !== "string" || !v.version_id) continue;
|
|
407
|
+
const pct = typeof v.percentage === "number" ? v.percentage : 0;
|
|
408
|
+
if (!best || pct > best.pct) best = { id: v.version_id, pct };
|
|
409
|
+
}
|
|
410
|
+
return best?.id ?? null;
|
|
356
411
|
}
|
|
357
412
|
|
|
358
413
|
/** One `wrangler` run's output file: the env that points wrangler at it, its text, and its removal. */
|
|
@@ -388,9 +443,11 @@ export interface DeployEventLine {
|
|
|
388
443
|
/**
|
|
389
444
|
* Milliseconds per leg, in the order they ran: `prepare` (git and the refusals), `install`,
|
|
390
445
|
* `stage` (the stage deploy a production deploy runs first), `walk`, `build`, `schema`,
|
|
391
|
-
* `upload` (`wrangler deploy`), `secrets`, `smoke`. A leg that did not run is absent.
|
|
446
|
+
* `upload` (`wrangler deploy`), `secrets`, `smoke`, `rollback`. A leg that did not run is absent.
|
|
392
447
|
*/
|
|
393
448
|
legs: Record<string, number>;
|
|
449
|
+
/** ⏩ Production first ({@link PROD_FIRST_FLAG}): no stage leg ran, and the stage is owed after. */
|
|
450
|
+
prodFirst?: true;
|
|
394
451
|
}
|
|
395
452
|
|
|
396
453
|
/** Append one line to the ledger under a state root. Never throws; `false` when it did not write. */
|
|
@@ -456,6 +513,7 @@ export function workerDeployDeps(cwd: string): DeployDeps {
|
|
|
456
513
|
const state = forgeState(cwd);
|
|
457
514
|
return state ? takeStageLease({ state, app, what }) : null;
|
|
458
515
|
},
|
|
516
|
+
argv: process.argv.slice(2),
|
|
459
517
|
};
|
|
460
518
|
}
|
|
461
519
|
|
|
@@ -771,6 +829,8 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
771
829
|
legFrom = at;
|
|
772
830
|
};
|
|
773
831
|
const tag = `[${spec.app}]`;
|
|
832
|
+
// ⏩ Production first — the stage leg runs behind it, the caller's to run (see PROD_FIRST_FLAG).
|
|
833
|
+
const prodFirst = spec.env === "production" && (spec.prodFirst ?? (deps.argv ?? []).includes(PROD_FIRST_FLAG));
|
|
774
834
|
const commit = deps.git(["rev-parse", "HEAD"]);
|
|
775
835
|
const porcelain = deps.git(["status", "--porcelain"]);
|
|
776
836
|
const dirty = porcelain.length > 0;
|
|
@@ -791,6 +851,7 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
791
851
|
commit: commit.slice(0, 8),
|
|
792
852
|
dirty,
|
|
793
853
|
legs,
|
|
854
|
+
...(prodFirst ? { prodFirst: true as const } : {}),
|
|
794
855
|
});
|
|
795
856
|
} catch {
|
|
796
857
|
// never the deploy's verdict — see DeployDeps.record
|
|
@@ -843,7 +904,9 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
843
904
|
}
|
|
844
905
|
// 🔴 One holder per hosted stage (task 3048): a stage deploy under the ship's held suite swapped
|
|
845
906
|
// the Worker and its link key under it. Taken before anything builds, held until `finish`.
|
|
846
|
-
|
|
907
|
+
// ⏩ Not by a production-first deploy: it never touches the stage, so a stranger walking the stage
|
|
908
|
+
// is no reason for production to wait.
|
|
909
|
+
const lease = prodFirst ? null : deps.lease?.(spec.app,`${spec.app} worker:deploy${spec.env === "stage" ? " --env stage" : ""} @ ${commit.slice(0, 8)}`);
|
|
847
910
|
if (lease && !lease.ok) return stop("stage-lease", lease.refusal);
|
|
848
911
|
if (lease?.ok) release = lease.release;
|
|
849
912
|
for (const refusal of spec.beforeStage ?? []) {
|
|
@@ -881,7 +944,11 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
881
944
|
if (previewed) return stop("preview", previewed);
|
|
882
945
|
if (previews.length > 0) log(`\n🟡 ${tag} this stage deploy ships an UNPUBLISHED ${describePreviews(previews)} — stamped dirty; production will refuse it until it is published`);
|
|
883
946
|
const shipsDirty = dirty || previews.length > 0;
|
|
884
|
-
|
|
947
|
+
const staged = spec.env === "production" && !prodFirst && Boolean(spec.stageFirst?.length || spec.stageWalk?.length);
|
|
948
|
+
if (prodFirst && (spec.stageFirst?.length || spec.stageWalk?.length)) {
|
|
949
|
+
log(`\n⏩ ${tag} production first (${PROD_FIRST_FLAG}) — the stage deploy and its signed-in walk are NOT run here; they are owed after, by whoever asked for this`);
|
|
950
|
+
}
|
|
951
|
+
if (spec.env === "production" && !prodFirst) {
|
|
885
952
|
for (const command of spec.stageFirst ?? []) {
|
|
886
953
|
step(`the stage first — production waits on it: ${command.join(" ")}`);
|
|
887
954
|
enter("stage");
|
|
@@ -896,7 +963,7 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
896
963
|
else if (walked !== 0) return stop("stage", `\`${command.join(" ")}\` failed — production was NOT touched.`);
|
|
897
964
|
}
|
|
898
965
|
}
|
|
899
|
-
const beforeBuild = moved(
|
|
966
|
+
const beforeBuild = moved(staged ? "while the stage deployed" : "before the build");
|
|
900
967
|
if (beforeBuild) return beforeBuild;
|
|
901
968
|
if (spec.build) {
|
|
902
969
|
step("build");
|
|
@@ -971,6 +1038,40 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
971
1038
|
const stamp = spec.stampVars
|
|
972
1039
|
? ["--var", `${spec.stampVars.commit}:${commit}`, "--var", `${spec.stampVars.dirty}:${shipsDirty ? "true" : "false"}`]
|
|
973
1040
|
: [];
|
|
1041
|
+
// ⏩ With no stage in front of it, a production-first deploy reads what production serves BEFORE it
|
|
1042
|
+
// uploads, so a red smoke can put exactly that back. Unreadable is a line, never a refusal: the
|
|
1043
|
+
// owner asked for production now, and the smoke still says whether it is broken.
|
|
1044
|
+
let rollbackTo: string | null = null;
|
|
1045
|
+
if (prodFirst) {
|
|
1046
|
+
const status = deps.capture(["bunx", "wrangler", "deployments", "status", ...envArgs, "--name", spec.workerName, "--json"], spec.credential);
|
|
1047
|
+
rollbackTo = status.code === 0 ? liveVersionOf(status.stdout) : null;
|
|
1048
|
+
log(
|
|
1049
|
+
rollbackTo
|
|
1050
|
+
? ` production serves version ${rollbackTo} — a red smoke or secrets step rolls back to it`
|
|
1051
|
+
: ` 🟡 ${tag} what production serves could not be read (wrangler exited ${status.code}) — a red smoke will NOT be rolled back automatically`,
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
/** ⏩ A production-first deploy that went red AFTER the upload puts back what production served before it. */
|
|
1055
|
+
const rollBack = (at: string, what: string): DeployResult => {
|
|
1056
|
+
if (!rollbackTo) {
|
|
1057
|
+
return stop(
|
|
1058
|
+
at,
|
|
1059
|
+
`the Worker is deployed production-first and ${what}. Nothing was rolled back — what production served before could not be read; \`bunx wrangler rollback\` puts the previous version back in one command.`,
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
step(`roll production back to ${rollbackTo} — ${what}, and no stage went first`);
|
|
1063
|
+
enter("rollback");
|
|
1064
|
+
const rolled = deps.run(
|
|
1065
|
+
["bunx", "wrangler", "rollback", rollbackTo, ...envArgs, "--name", spec.workerName, "--yes", "--message", `${spec.app} ${commit.slice(0, 8)}: production-first ${at} red`],
|
|
1066
|
+
spec.credential,
|
|
1067
|
+
);
|
|
1068
|
+
return stop(
|
|
1069
|
+
at,
|
|
1070
|
+
rolled === 0
|
|
1071
|
+
? `the Worker was deployed production-first and ${what} — production is ROLLED BACK to ${rollbackTo}, the version it served before. Fix it, then deploy again.`
|
|
1072
|
+
: `the Worker was deployed production-first and ${what}, and the rollback to ${rollbackTo} FAILED too (wrangler exited ${rolled}) — production serves the red version. \`bunx wrangler rollback ${rollbackTo}\` now.`,
|
|
1073
|
+
);
|
|
1074
|
+
};
|
|
974
1075
|
enter("upload");
|
|
975
1076
|
const deployArgv = ["bunx", "wrangler", "deploy", ...envArgs, ...stamp];
|
|
976
1077
|
const deployOnce = (): { code: number; transient: string | null } => {
|
|
@@ -992,6 +1093,7 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
992
1093
|
step("attach the secrets — exactly this deployment's set, over a pipe");
|
|
993
1094
|
enter("secrets");
|
|
994
1095
|
if (deps.run(spec.secrets, spec.credential) !== 0) {
|
|
1096
|
+
if (prodFirst) return rollBack("secrets", "its secrets are NOT attached, so it serves nothing");
|
|
995
1097
|
return stop("secrets", "the Worker is deployed and its secrets are NOT attached — it serves nothing until they are.");
|
|
996
1098
|
}
|
|
997
1099
|
}
|
|
@@ -1003,6 +1105,7 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
1003
1105
|
const smoked = deps.run(smoke, { [DEPLOYED_COMMIT_ENV]: commit });
|
|
1004
1106
|
if (spec.cpuTail && smoked === TAIL_UNATTACHED_EXIT) unmeasured.push("the smoke");
|
|
1005
1107
|
else if (smoked !== 0) {
|
|
1108
|
+
if (prodFirst) return rollBack("smoke", "its smoke FAILED");
|
|
1006
1109
|
return stop(
|
|
1007
1110
|
"smoke",
|
|
1008
1111
|
"the Worker is deployed and its smoke FAILED. Nothing was rolled back: `bunx wrangler rollback` puts the previous version back in one command.",
|
|
@@ -1035,6 +1138,7 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
1035
1138
|
* bun run worker:deploy # the stage, walked signed in; then build, ship, smoke production
|
|
1036
1139
|
* bun run worker:deploy -- --env stage # the stage alone: build, ship, smoke
|
|
1037
1140
|
* bun run worker:deploy -- --no-build # ship what is already in dist/
|
|
1141
|
+
* bun run worker:deploy -- --prod-first # ⏩ production alone — the stage and its walk are owed after (PROD_FIRST_FLAG)
|
|
1038
1142
|
*
|
|
1039
1143
|
* No secrets step: the one secret, the link key's digest, is the app's `link:key` to set, because
|
|
1040
1144
|
* minting it is also rotating it. Returns the exit code — 2 for a missing credential or a bad
|
|
@@ -1105,7 +1209,7 @@ export function relayWorkerDeployCli(
|
|
|
1105
1209
|
error(`[${spec.app}] ${why instanceof Error ? why.message : why}`);
|
|
1106
1210
|
return 2;
|
|
1107
1211
|
}
|
|
1108
|
-
const result = runWorkerDeploy(relayWorkerDeploySpec(spec, env, argv.includes("--no-build"), credential), deps);
|
|
1212
|
+
const result = runWorkerDeploy({ ...relayWorkerDeploySpec(spec, env, argv.includes("--no-build"), credential), prodFirst: argv.includes(PROD_FIRST_FLAG) }, deps);
|
|
1109
1213
|
if (result.code === 0) log(` ${env === "stage" ? spec.stageUrl : spec.previewUrl}`);
|
|
1110
1214
|
return result.code;
|
|
1111
1215
|
}
|