cursedops 0.20.1 → 0.21.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/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.20.1",
3
+ "version": "0.21.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 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/smoke.ts CHANGED
@@ -932,6 +932,54 @@ export const HEALTH_READ_MAX_TIME_SEC = 10;
932
932
  /** The init a smoke's `/healthz` read carries — `cursedops/edge-fetch` honors it; a plain `fetch` ignores it. */
933
933
  const healthRead: EdgeRequestInit = { maxTimeSec: HEALTH_READ_MAX_TIME_SEC };
934
934
 
935
+ /** How long one address's between-versions answers are asked again before they are judged. */
936
+ export const BETWEEN_VERSIONS_MS = 60_000;
937
+
938
+ /**
939
+ * One address's allowance for answers of the edge BETWEEN VERSIONS — a 5xx, or no answer at all —
940
+ * after `/healthz` has settled. Shared by every question asked of that address, so a Worker that
941
+ * really 500s costs one window, not one per route.
942
+ *
943
+ * 🔴 Measured 2026-10-28 (forge task 3882): auth's production-first smoke settled `/healthz` on
944
+ * bea42b2f three reads in a row, and in the next seconds — six `secret put`s, six versions —
945
+ * `/account` answered 500 on the preview while the public host gave 200, and five census routes
946
+ * answered 500 on the public host while the preview gave 401. Judged once each, that rolled back a
947
+ * deploy whose stage, on the same bytes, passed all 28 checks minutes later. `/healthz` reads no
948
+ * session; the routes behind the D1 snapshot were still on the turning edge.
949
+ *
950
+ * Only the window's shapes are asked again. A REAL wrong answer — a private route that answers 200,
951
+ * a shell that is JSON — is judged on its first read, and a 5xx that outlasts the window is red.
952
+ */
953
+ interface VersionWindow {
954
+ /** Ask until the answer is not between versions, or the window is spent; the LAST answer is judged. */
955
+ settle<T>(ask: () => Promise<T>, between: (answer: T) => boolean): Promise<{ answer: T; first: T | null; waitedMs: number }>;
956
+ }
957
+
958
+ function versionWindow(options: SteadyOptions = {}): VersionWindow {
959
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((done) => setTimeout(done, ms)));
960
+ const now = options.now ?? Date.now;
961
+ let opened: number | null = null;
962
+ return {
963
+ async settle(ask, between) {
964
+ const asked = now();
965
+ let first: Awaited<ReturnType<typeof ask>> | null = null;
966
+ for (;;) {
967
+ const answer = await ask();
968
+ if (!between(answer)) return { answer, first, waitedMs: now() - asked };
969
+ first ??= answer;
970
+ opened ??= now();
971
+ if (now() - opened >= BETWEEN_VERSIONS_MS) return { answer, first, waitedMs: now() - asked };
972
+ await sleep(options.retryMs ?? 3_000);
973
+ }
974
+ },
975
+ };
976
+ }
977
+
978
+ /** `, after <first> for Ns` — the row's note that an answer was asked again, so a settle is never silent. */
979
+ function settledNote(first: unknown, waitedMs: number): string {
980
+ return first === null ? "" : ` (${first} first, between versions — asked again for ${Math.round(waitedMs / 1000)}s)`;
981
+ }
982
+
935
983
  /** One question a deploy must answer the NEW way — `null` when it does, else why not. */
936
984
  export interface ConvergenceProbe {
937
985
  name: string;
@@ -1154,14 +1202,21 @@ export async function runWorkerSmoke(spec: WorkerSmokeSpec): Promise<number> {
1154
1202
 
1155
1203
  // Each probe is anonymous and refused at the gate, so it is replay-safe whatever its method,
1156
1204
  // and one that gets no answer even then is this check's red — never a crash of the whole smoke.
1205
+ const versions = versionWindow(spec.steady);
1157
1206
  const open: string[] = [];
1207
+ const settled: string[] = [];
1158
1208
  for (const route of spec.census) {
1159
1209
  const probe: EdgeRequestInit = { method: route.method, replaySafe: true };
1160
- const answered = await ask(route.path, probe).then(
1161
- (res) => res.status,
1162
- (error: Error) => `no answer (${error.message})`,
1210
+ const { answer, first, waitedMs } = await versions.settle(
1211
+ () =>
1212
+ ask(route.path, probe).then(
1213
+ (res): number | string => res.status,
1214
+ (error: Error) => `no answer (${error.message})`,
1215
+ ),
1216
+ (a) => typeof a === "string" || a >= 500,
1163
1217
  );
1164
- if (answered !== 401) open.push(`${route.method} ${route.path} → ${answered}`);
1218
+ if (answer !== 401) open.push(`${route.method} ${route.path} → ${answer}${settledNote(first, waitedMs)}`);
1219
+ else if (first !== null) settled.push(`${route.method} ${route.path}${settledNote(first, waitedMs)}`);
1165
1220
  }
1166
1221
  smoke.record(
1167
1222
  `${tag} origin-gate`,
@@ -1169,7 +1224,7 @@ export async function runWorkerSmoke(spec: WorkerSmokeSpec): Promise<number> {
1169
1224
  spec.census.length === 0
1170
1225
  ? "🔴 the route census is EMPTY, so this proves nothing"
1171
1226
  : open.length === 0
1172
- ? `all ${spec.census.length} private routes refuse an anonymous caller`
1227
+ ? `all ${spec.census.length} private routes refuse an anonymous caller${settled.length ? ` — ${settled.join(", ")}` : ""}`
1173
1228
  : `🔴 ${open.join(", ")}`,
1174
1229
  );
1175
1230
 
@@ -1177,15 +1232,25 @@ export async function runWorkerSmoke(spec: WorkerSmokeSpec): Promise<number> {
1177
1232
  await spec.extraChecks?.({ tag, address, ask, health, record: (check, ok, detail) => smoke.record(check, ok, detail) });
1178
1233
 
1179
1234
  for (const path of spec.shellPaths) {
1180
- const res = await ask(path, { headers: { accept: "text/html" } });
1235
+ const { answer: res, first, waitedMs } = await versions.settle(
1236
+ () => ask(path, { headers: { accept: "text/html" } }),
1237
+ (r) => r.status >= 500,
1238
+ );
1181
1239
  const type = res.headers.get("content-type") ?? "";
1182
- smoke.record(`${tag} shell ${path}`, res.status === 200 && type.includes("text/html"), `${res.status} ${type}`);
1240
+ smoke.record(
1241
+ `${tag} shell ${path}`,
1242
+ res.status === 200 && type.includes("text/html"),
1243
+ `${res.status} ${type}${settledNote(first?.status ?? null, waitedMs)}`,
1244
+ );
1183
1245
  }
1184
- const miss = await ask("/assets/index-no-such-bundle.js");
1246
+ const { answer: miss, first, waitedMs } = await versions.settle(
1247
+ () => ask("/assets/index-no-such-bundle.js"),
1248
+ (r) => r.status >= 500,
1249
+ );
1185
1250
  smoke.record(
1186
1251
  `${tag} missing-bundle`,
1187
1252
  miss.status === 404 && !(miss.headers.get("content-type") ?? "").includes("text/html"),
1188
- `a missing bundle is a miss — ${miss.status}`,
1253
+ `a missing bundle is a miss — ${miss.status}${settledNote(first?.status ?? null, waitedMs)}`,
1189
1254
  );
1190
1255
  }
1191
1256
  return await smoke.settle();
@@ -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. {@link workerDeployDeps} takes the
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
- const lease = deps.lease?.(spec.app, `${spec.app} worker:deploy${spec.env === "stage" ? " --env stage" : ""} @ ${commit.slice(0, 8)}`);
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
- if (spec.env === "production") {
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(spec.stageFirst?.length || spec.stageWalk?.length ? "while the stage deployed" : "before the build");
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
  }