cursedops 0.10.24 → 0.10.26
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/relayLink.ts +1 -1
- package/src/relayStage.ts +34 -2
- package/src/workerDeploy.ts +202 -9
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ bun add cursedops
|
|
|
12
12
|
| `cursedops/paths` | the generation's whole-tree laws (`check-paths`, `check-doc-citations`, plus any `--also`) run over ONE repo, from a checkout or a worktree — and its `forge-paths` bin (0.5.0) |
|
|
13
13
|
| `cursedops/launchd` | installing, replacing and removing a macOS launchd user agent, and the port a LIVE job serves on (`livePort`, 0.5.0) |
|
|
14
14
|
| `cursedops/smoke` | the scaffolding of a deployed smoke — the ledger, the fetch, the DNS hint, the exit code — the one check no app owns (every address of a deployment serving the same built client), and since 0.5.0 its VERDICTS: the origin asked on loopback, the smoke's own environment, a network that lies about DNS, and a version that has settled |
|
|
15
|
-
| `cursedops/worker-deploy` | the sequence that ships a Cloudflare Worker — clean tree, stage first, schema, `--var` stamp, secrets, smoke — and the readers it needs (0.5.0); `readWranglerFile(root)` since 0.9.0, lifted from four apps' `readWrangler` (task 2153) |
|
|
15
|
+
| `cursedops/worker-deploy` | the sequence that ships a Cloudflare Worker — clean tree, stage first, schema, `--var` stamp, secrets, smoke — and the readers it needs (0.5.0); `readWranglerFile(root)` since 0.9.0, lifted from four apps' `readWrangler` (task 2153); since 0.10.25 every run, red or green, appends a `kind: "deploy"` line with each leg's wall clock (`install`, `stage`, `walk`, `build`, `schema`, `upload`, `secrets`, `smoke`) to the generation's ledger (task 2246) |
|
|
16
16
|
| `cursedops/worker-deploy` (CPU) | a `wrangler tail` around the traffic a deploy already sends, each request's BILLED CPU charged to its declared route, red on an undeclared, over-budget or uncapped route — and `runWorkerDeploy`'s `stageWalk` + `cpuTail` that wrap the walk and the smoke in it (0.7.0, task 2134, lifted from collections) |
|
|
17
17
|
| `cursedops/worker-secrets` | a Worker holding EXACTLY its deployment's secrets, uploaded over a pipe, read back (0.5.0) |
|
|
18
18
|
| `cursedops/worker-rollback` | a hostname back on its Mac origin: origin first, route second, the route found rather than typed (0.5.0) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.26",
|
|
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": {
|
package/src/relayLink.ts
CHANGED
|
@@ -318,7 +318,7 @@ export async function rotateLinkKey(spec: RotateSpec): Promise<{ ok: true; file:
|
|
|
318
318
|
const put =
|
|
319
319
|
spec.putSecret ??
|
|
320
320
|
((name: string, value: string) => {
|
|
321
|
-
const r = spawnSync("bunx", ["wrangler
|
|
321
|
+
const r = spawnSync("bunx", ["wrangler", "secret", "put", name, ...(spec.env ? ["--env", spec.env] : [])], {
|
|
322
322
|
cwd: spec.appDir,
|
|
323
323
|
input: value,
|
|
324
324
|
env: { ...process.env, ...spec.credential },
|
package/src/relayStage.ts
CHANGED
|
@@ -54,6 +54,37 @@ export async function until(ms: number, ok: () => Promise<boolean>, clock: Clock
|
|
|
54
54
|
return false;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* True once `isLinked` has said yes on every ask for a whole `windowMs` (default 15 s) — false if that
|
|
59
|
+
* never happens within `maxMs` (default 2 min). A throw counts as no, and restarts the window.
|
|
60
|
+
*
|
|
61
|
+
* 🔴 Why not a sleep and then {@link until}: a digest put is a new Worker version that reaches the
|
|
62
|
+
* edge in its own time, and `until` passes the first instant the link is up. Measured 2026-09-28 on
|
|
63
|
+
* station, beside three other deploys: the walk slept its fixed 10 s, the link came up, then flapped
|
|
64
|
+
* (`1002 Expected 101` — an edge still on the old digest refusing the new key) and the first
|
|
65
|
+
* signed-in read got the offline 503. `oldKeyRefused` already records that 15 s was not always
|
|
66
|
+
* enough. So the walk waits for a link that STAYS up, and how long that took is printed.
|
|
67
|
+
*/
|
|
68
|
+
export async function linkedSteadily(spec: {
|
|
69
|
+
isLinked: () => Promise<boolean>;
|
|
70
|
+
windowMs?: number;
|
|
71
|
+
maxMs?: number;
|
|
72
|
+
clock?: Clock;
|
|
73
|
+
}): Promise<{ steady: boolean; seconds: number }> {
|
|
74
|
+
const clock = spec.clock ?? realClock;
|
|
75
|
+
const window = spec.windowMs ?? 15_000;
|
|
76
|
+
const started = clock.now();
|
|
77
|
+
let upSince: number | null = null;
|
|
78
|
+
while (clock.now() - started < (spec.maxMs ?? 120_000)) {
|
|
79
|
+
const up = await spec.isLinked().catch(() => false);
|
|
80
|
+
if (!up) upSince = null;
|
|
81
|
+
else if (upSince === null) upSince = clock.now();
|
|
82
|
+
else if (clock.now() - upSince >= window) return { steady: true, seconds: Math.round((clock.now() - started) / 1000) };
|
|
83
|
+
await clock.sleep(1_000);
|
|
84
|
+
}
|
|
85
|
+
return { steady: false, seconds: Math.round((clock.now() - started) / 1000) };
|
|
86
|
+
}
|
|
87
|
+
|
|
57
88
|
/** The Access service token, as the pair a Mac end's two variables and a roster socket carry. */
|
|
58
89
|
export function accessPair(headers: Record<string, string>): { id: string; secret: string } {
|
|
59
90
|
return { id: headers["CF-Access-Client-Id"] ?? "", secret: headers["CF-Access-Client-Secret"] ?? "" };
|
|
@@ -142,10 +173,11 @@ export interface DigestPut {
|
|
|
142
173
|
/**
|
|
143
174
|
* Put `key`'s digest on the STAGE Worker (`--env stage`, always — this module has no production
|
|
144
175
|
* path). A per-run key for the walk, and again for the rotation leg. `ok: false` carries wrangler's
|
|
145
|
-
* stderr. A secret put is a new version
|
|
176
|
+
* stderr. A secret put is a new version that reaches the edge in its own time: the caller waits for
|
|
177
|
+
* a link that stays up ({@link linkedSteadily}), never a fixed sleep.
|
|
146
178
|
*/
|
|
147
179
|
export async function putStageLinkDigest(put: DigestPut): Promise<{ ok: boolean; stderr: string }> {
|
|
148
|
-
const done = (put.run ?? realRun)(["bunx", "wrangler
|
|
180
|
+
const done = (put.run ?? realRun)(["bunx", "wrangler", "secret", "put", put.secretName, "--env", "stage"], {
|
|
149
181
|
cwd: put.cwd,
|
|
150
182
|
input: await sha256Hex(put.key),
|
|
151
183
|
env: put.credential,
|
package/src/workerDeploy.ts
CHANGED
|
@@ -53,8 +53,9 @@
|
|
|
53
53
|
* refusal without wrangler, git or a network.
|
|
54
54
|
*/
|
|
55
55
|
import { spawn, spawnSync } from "node:child_process";
|
|
56
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
57
|
-
import { join } from "node:path";
|
|
56
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
57
|
+
import { dirname, join } from "node:path";
|
|
58
|
+
import { forgeState } from "cursedops/roots";
|
|
58
59
|
|
|
59
60
|
/** The two deployments every Worker app here has. */
|
|
60
61
|
export type WorkerEnv = "production" | "stage";
|
|
@@ -305,6 +306,62 @@ export interface DeployDeps {
|
|
|
305
306
|
read?: (path: string) => string;
|
|
306
307
|
log?: (line: string) => void;
|
|
307
308
|
error?: (line: string) => void;
|
|
309
|
+
/** The clock the legs are timed on. Default `Date.now`. */
|
|
310
|
+
now?: () => number;
|
|
311
|
+
/** Whether a path in the checkout exists — the local wrangler. Default `existsSync` against the process's cwd. */
|
|
312
|
+
exists?: (path: string) => boolean;
|
|
313
|
+
/** The unpublished library builds in `node_modules` ({@link previewMarks}). Default: read the process's cwd. */
|
|
314
|
+
previews?: () => PreviewMark[];
|
|
315
|
+
/**
|
|
316
|
+
* Where the run's {@link DeployEventLine} goes, on EVERY exit — refused, red or green.
|
|
317
|
+
* {@link workerDeployDeps} appends it to the generation's ledger; absent, nothing is recorded.
|
|
318
|
+
* A throw here is swallowed: a ledger that can fail a deploy is worse than none.
|
|
319
|
+
*/
|
|
320
|
+
record?: (line: DeployEventLine) => void;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* One line per deploy into the generation's ledger (`$FORGE_STATE/state/autopilot/events.jsonl`,
|
|
325
|
+
* the file `autopilot timings` reads), with the wall clock of every LEG.
|
|
326
|
+
*
|
|
327
|
+
* 🔴 Why here: until 0.10.25 the whole ledger held 3 `deploy` events, all backfilled by hand, while
|
|
328
|
+
* `autopilot phases` measured deploys at ~110 agent-minutes a day (2026-09-24 → 28) — the slowest
|
|
329
|
+
* thing a task did once gates stopped re-proving unchanged bytes. Every app's `worker:deploy`,
|
|
330
|
+
* stage and production, passes through {@link runWorkerDeploy}, so this is the one seam that sees
|
|
331
|
+
* all of them. A production deploy runs its stage deploy as a child, which records its OWN line
|
|
332
|
+
* (`env: "stage"`) — so the `stage` leg here and that line's legs together say where it went.
|
|
333
|
+
*/
|
|
334
|
+
export interface DeployEventLine {
|
|
335
|
+
at: string;
|
|
336
|
+
kind: "deploy";
|
|
337
|
+
what: "cursedops/worker-deploy";
|
|
338
|
+
repo: string;
|
|
339
|
+
env: WorkerEnv;
|
|
340
|
+
ms: number;
|
|
341
|
+
ok: boolean;
|
|
342
|
+
/** The step it stopped at, or `done` / `cpu-unmeasured` — {@link DeployResult.step}. */
|
|
343
|
+
outcome: string;
|
|
344
|
+
commit: string;
|
|
345
|
+
dirty: boolean;
|
|
346
|
+
/**
|
|
347
|
+
* Milliseconds per leg, in the order they ran: `prepare` (git and the refusals), `install`,
|
|
348
|
+
* `stage` (the stage deploy a production deploy runs first), `walk`, `build`, `schema`,
|
|
349
|
+
* `upload` (`wrangler deploy`), `secrets`, `smoke`. A leg that did not run is absent.
|
|
350
|
+
*/
|
|
351
|
+
legs: Record<string, number>;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Append one line to the ledger under a state root. Never throws; `false` when it did not write. */
|
|
355
|
+
export function recordDeployEvent(state: string | null, line: DeployEventLine): boolean {
|
|
356
|
+
if (!state) return false;
|
|
357
|
+
try {
|
|
358
|
+
const file = join(state, "state", "autopilot", "events.jsonl");
|
|
359
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
360
|
+
appendFileSync(file, `${JSON.stringify(line)}\n`);
|
|
361
|
+
return true;
|
|
362
|
+
} catch {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
308
365
|
}
|
|
309
366
|
|
|
310
367
|
export interface DeployResult {
|
|
@@ -344,6 +401,9 @@ export function workerDeployDeps(cwd: string): DeployDeps {
|
|
|
344
401
|
},
|
|
345
402
|
git: (args) => (spawnSync("git", [...args], { cwd, encoding: "utf8" }).stdout ?? "").trim(),
|
|
346
403
|
read: (path) => readFileSync(join(cwd, path), "utf8"),
|
|
404
|
+
record: (line) => {
|
|
405
|
+
recordDeployEvent(forgeState(cwd), line);
|
|
406
|
+
},
|
|
347
407
|
};
|
|
348
408
|
}
|
|
349
409
|
|
|
@@ -492,19 +552,136 @@ export function judgeSchemaApply(code: number, stdout: string): { applied: boole
|
|
|
492
552
|
* Run the sequence in the header's order, stopping at the first red with a sentence. Never
|
|
493
553
|
* exits — the caller does, with {@link DeployResult.code}.
|
|
494
554
|
*/
|
|
555
|
+
/** Where an app's OWN wrangler is, once `bun install` has run. */
|
|
556
|
+
export const LOCAL_WRANGLER = "node_modules/.bin/wrangler";
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Refuse a deploy whose app has no wrangler of its own — `null` when it has one.
|
|
560
|
+
*
|
|
561
|
+
* 🔴 Why (task 2260): with no local install, `bunx wrangler` resolves into ONE directory shared by
|
|
562
|
+
* every process on the Mac (`$TMPDIR/bunx-<uid>-wrangler@latest`), and bunx refreshes it in place.
|
|
563
|
+
* On 2026-09-28 it was rewritten at 12:17 while four deploys ran, and station's stage walk died on
|
|
564
|
+
* `Cannot find module './lib/websocket-server'` — a half-extracted `ws`. A version spec
|
|
565
|
+
* (`wrangler@4`) is worse: bunx then skips a local install even when there is one. So every app
|
|
566
|
+
* declares `wrangler` in its own `devDependencies`, locked by its own `bun.lock`, and nothing here
|
|
567
|
+
* passes bunx a version (`workerDeploy.test.ts` scans `src/` for one).
|
|
568
|
+
*/
|
|
569
|
+
export function localWranglerRefusal(exists: (path: string) => boolean): string | null {
|
|
570
|
+
if (exists(LOCAL_WRANGLER)) return null;
|
|
571
|
+
return `this app has no wrangler of its own (${LOCAL_WRANGLER} is missing), so \`bunx wrangler\` would use the one install every deploy on this Mac shares — and concurrent deploys have corrupted it mid-run. Run \`bun add -d wrangler@^4\` in the app and commit package.json and bun.lock. Nothing was deployed.`;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** The file `tools/lib-preview.ts` writes INSIDE a package it overlaid with an unpublished build. */
|
|
575
|
+
export const PREVIEW_MARK = ".lib-preview.json";
|
|
576
|
+
|
|
577
|
+
/** One unpublished library build sitting in `node_modules` — what its {@link PREVIEW_MARK} says. */
|
|
578
|
+
export interface PreviewMark {
|
|
579
|
+
lib: string;
|
|
580
|
+
/** The version in the library's own tree — never the one `bun.lock` names. */
|
|
581
|
+
version: string;
|
|
582
|
+
/** The library tree's HEAD when it was packed, `-dirty` when it had uncommitted changes. */
|
|
583
|
+
commit: string;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Every package under `nodeModules` (top level and `@scope/name`) carrying a {@link PREVIEW_MARK}.
|
|
588
|
+
*
|
|
589
|
+
* The mark lives inside the package's own directory, never beside it, so it cannot outlive the bytes
|
|
590
|
+
* it describes: an install that replaces the package (a lockfile bump, `--force`) removes it too.
|
|
591
|
+
* `bun install --frozen-lockfile` leaves an overlay whose `package.json` version matches the lock, so
|
|
592
|
+
* the preview writes the locked version there and its real one here (measured 2026-09-28).
|
|
593
|
+
*/
|
|
594
|
+
export function previewMarks(nodeModules = "node_modules"): PreviewMark[] {
|
|
595
|
+
const marks: PreviewMark[] = [];
|
|
596
|
+
const read = (dir: string): void => {
|
|
597
|
+
const file = join(dir, PREVIEW_MARK);
|
|
598
|
+
if (!existsSync(file)) return;
|
|
599
|
+
try {
|
|
600
|
+
const mark = JSON.parse(readFileSync(file, "utf8")) as Partial<PreviewMark>;
|
|
601
|
+
marks.push({ lib: mark.lib ?? dir, version: mark.version ?? "?", commit: mark.commit ?? "?" });
|
|
602
|
+
} catch {
|
|
603
|
+
marks.push({ lib: dir, version: "?", commit: "unreadable mark" });
|
|
604
|
+
}
|
|
605
|
+
};
|
|
606
|
+
let names: string[] = [];
|
|
607
|
+
try {
|
|
608
|
+
names = readdirSync(nodeModules);
|
|
609
|
+
} catch {
|
|
610
|
+
return marks;
|
|
611
|
+
}
|
|
612
|
+
for (const name of names) {
|
|
613
|
+
if (name.startsWith("@")) {
|
|
614
|
+
let scoped: string[] = [];
|
|
615
|
+
try {
|
|
616
|
+
scoped = readdirSync(join(nodeModules, name));
|
|
617
|
+
} catch {}
|
|
618
|
+
for (const inner of scoped) read(join(nodeModules, name, inner));
|
|
619
|
+
} else if (!name.startsWith(".")) read(join(nodeModules, name));
|
|
620
|
+
}
|
|
621
|
+
return marks;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** `<lib>@<version> (<commit>)`, joined — how a deploy names what it would ship unpublished. */
|
|
625
|
+
export function describePreviews(marks: readonly PreviewMark[]): string {
|
|
626
|
+
return marks.map((m) => `${m.lib}@${m.version} (${m.commit})`).join(", ");
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Production never ships an unpublished library (task 2252) — `null` when nothing is previewed. A
|
|
631
|
+
* stage deploy ships it, says so, and stamps itself dirty: `/healthz` then says the bytes are not
|
|
632
|
+
* the commit's, which is true.
|
|
633
|
+
*/
|
|
634
|
+
export function previewRefusal(env: WorkerEnv, marks: readonly PreviewMark[]): string | null {
|
|
635
|
+
if (env !== "production" || marks.length === 0) return null;
|
|
636
|
+
return `node_modules holds an UNPUBLISHED build of ${describePreviews(marks)} — production ships only published libraries. Publish it (\`tools/publish-libs.sh\`), bump this app to it, or put the locked version back with \`bun $FORGE/tools/lib-preview.ts --restore\`. Nothing was deployed.`;
|
|
637
|
+
}
|
|
638
|
+
|
|
495
639
|
/** What {@link runWorkerDeploy} runs first: `node_modules` exactly as `bun.lock` says. */
|
|
496
640
|
export const INSTALL: readonly string[] = ["bun", "install", "--frozen-lockfile"];
|
|
497
641
|
|
|
498
642
|
export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): DeployResult {
|
|
499
643
|
const log = deps.log ?? ((line: string) => console.log(line));
|
|
500
644
|
const error = deps.error ?? ((line: string) => console.error(line));
|
|
645
|
+
const now = deps.now ?? Date.now;
|
|
646
|
+
const started = now();
|
|
647
|
+
const legs: Record<string, number> = {};
|
|
648
|
+
let leg = "prepare";
|
|
649
|
+
let legFrom = started;
|
|
650
|
+
/** Close the leg in flight and open `next` — see {@link DeployEventLine.legs}. */
|
|
651
|
+
const enter = (next: string): void => {
|
|
652
|
+
const at = now();
|
|
653
|
+
legs[leg] = (legs[leg] ?? 0) + (at - legFrom);
|
|
654
|
+
leg = next;
|
|
655
|
+
legFrom = at;
|
|
656
|
+
};
|
|
501
657
|
const tag = `[${spec.app}]`;
|
|
502
658
|
const commit = deps.git(["rev-parse", "HEAD"]);
|
|
503
659
|
const porcelain = deps.git(["status", "--porcelain"]);
|
|
504
660
|
const dirty = porcelain.length > 0;
|
|
661
|
+
const finish = (result: DeployResult): DeployResult => {
|
|
662
|
+
enter("");
|
|
663
|
+
try {
|
|
664
|
+
deps.record?.({
|
|
665
|
+
at: new Date(started).toISOString(),
|
|
666
|
+
kind: "deploy",
|
|
667
|
+
what: "cursedops/worker-deploy",
|
|
668
|
+
repo: spec.app,
|
|
669
|
+
env: spec.env,
|
|
670
|
+
ms: now() - started,
|
|
671
|
+
ok: result.code !== 1,
|
|
672
|
+
outcome: result.step,
|
|
673
|
+
commit: commit.slice(0, 8),
|
|
674
|
+
dirty,
|
|
675
|
+
legs,
|
|
676
|
+
});
|
|
677
|
+
} catch {
|
|
678
|
+
// never the deploy's verdict — see DeployDeps.record
|
|
679
|
+
}
|
|
680
|
+
return result;
|
|
681
|
+
};
|
|
505
682
|
const stop = (step: string, detail: string): DeployResult => {
|
|
506
683
|
error(`\n🔴 ${tag} ${detail}`);
|
|
507
|
-
return { code: 1, step, detail, commit, dirty };
|
|
684
|
+
return finish({ code: 1, step, detail, commit, dirty });
|
|
508
685
|
};
|
|
509
686
|
const step = (message: string): void => log(`\n▸ ${message}`);
|
|
510
687
|
/** What passed under a CPU tail that never attached — decision 6 of the CPU check. */
|
|
@@ -538,17 +715,27 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
538
715
|
* no-op when they already agree, and refuses, rather than rewriting the lock, when they cannot.
|
|
539
716
|
*/
|
|
540
717
|
step("make node_modules match bun.lock");
|
|
718
|
+
enter("install");
|
|
541
719
|
if (deps.run(INSTALL, {}) !== 0) {
|
|
542
720
|
return stop("install", `\`${INSTALL.join(" ")}\` failed — node_modules cannot be made to match bun.lock, so nothing was built or deployed.`);
|
|
543
721
|
}
|
|
722
|
+
const noWrangler = localWranglerRefusal(deps.exists ?? existsSync);
|
|
723
|
+
if (noWrangler) return stop("wrangler", noWrangler);
|
|
724
|
+
const previews = (deps.previews ?? previewMarks)();
|
|
725
|
+
const previewed = previewRefusal(spec.env, previews);
|
|
726
|
+
if (previewed) return stop("preview", previewed);
|
|
727
|
+
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`);
|
|
728
|
+
const shipsDirty = dirty || previews.length > 0;
|
|
544
729
|
if (spec.env === "production") {
|
|
545
730
|
for (const command of spec.stageFirst ?? []) {
|
|
546
731
|
step(`the stage first — production waits on it: ${command.join(" ")}`);
|
|
732
|
+
enter("stage");
|
|
547
733
|
if (deps.run(command, {}) !== 0) return stop("stage", `\`${command.join(" ")}\` failed — production was NOT touched.`);
|
|
548
734
|
}
|
|
549
735
|
for (const walk of spec.stageWalk ?? []) {
|
|
550
736
|
const command = spec.cpuTail ? underCpuTail(spec.cpuTail, "stage", walk) : [...walk];
|
|
551
737
|
step(`walk the stage signed in${spec.cpuTail ? ", under a CPU tail" : ""}: ${command.join(" ")}`);
|
|
738
|
+
enter("walk");
|
|
552
739
|
const walked = deps.run(command, {});
|
|
553
740
|
if (spec.cpuTail && walked === TAIL_UNATTACHED_EXIT) unmeasured.push("the stage walk");
|
|
554
741
|
else if (walked !== 0) return stop("stage", `\`${command.join(" ")}\` failed — production was NOT touched.`);
|
|
@@ -556,8 +743,10 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
556
743
|
}
|
|
557
744
|
if (spec.build) {
|
|
558
745
|
step("build");
|
|
746
|
+
enter("build");
|
|
559
747
|
if (deps.run(spec.build, {}) !== 0) return stop("build", "the build failed — nothing was deployed.");
|
|
560
748
|
}
|
|
749
|
+
enter("prepare");
|
|
561
750
|
for (const refusal of spec.beforeShip ?? []) {
|
|
562
751
|
const why = refusal();
|
|
563
752
|
if (why) return stop("refusal", `${why} — nothing was deployed.`);
|
|
@@ -566,6 +755,7 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
566
755
|
if (spec.databaseName) {
|
|
567
756
|
const schema = spec.schemaFile ?? "db/schema.sql";
|
|
568
757
|
step(`apply ${schema} to D1 \`${spec.databaseName}\``);
|
|
758
|
+
enter("schema");
|
|
569
759
|
let commands: string[];
|
|
570
760
|
try {
|
|
571
761
|
commands = schemaCommands((deps.read ?? ((path: string) => readFileSync(path, "utf8")))(schema));
|
|
@@ -588,19 +778,22 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
588
778
|
log(` ${verdict.line}`);
|
|
589
779
|
}
|
|
590
780
|
}
|
|
591
|
-
step(`deploy ${spec.workerName} @ ${commit.slice(0, 8)}${
|
|
781
|
+
step(`deploy ${spec.workerName} @ ${commit.slice(0, 8)}${shipsDirty ? " (dirty — stage only)" : ""}`);
|
|
592
782
|
const stamp = spec.stampVars
|
|
593
|
-
? ["--var", `${spec.stampVars.commit}:${commit}`, "--var", `${spec.stampVars.dirty}:${
|
|
783
|
+
? ["--var", `${spec.stampVars.commit}:${commit}`, "--var", `${spec.stampVars.dirty}:${shipsDirty ? "true" : "false"}`]
|
|
594
784
|
: [];
|
|
785
|
+
enter("upload");
|
|
595
786
|
if (deps.run(["bunx", "wrangler", "deploy", ...envArgs, ...stamp], spec.credential) !== 0) return stop("deploy", "wrangler deploy failed.");
|
|
596
787
|
if (spec.secrets) {
|
|
597
788
|
step("attach the secrets — exactly this deployment's set, over a pipe");
|
|
789
|
+
enter("secrets");
|
|
598
790
|
if (deps.run(spec.secrets, spec.credential) !== 0) {
|
|
599
791
|
return stop("secrets", "the Worker is deployed and its secrets are NOT attached — it serves nothing until they are.");
|
|
600
792
|
}
|
|
601
793
|
}
|
|
602
794
|
if (spec.smoke) {
|
|
603
795
|
step(`smoke every address this deployment answers on${spec.cpuTail ? ", under a CPU tail" : ""}`);
|
|
796
|
+
enter("smoke");
|
|
604
797
|
const smoke = spec.cpuTail ? underCpuTail(spec.cpuTail, spec.env, spec.smoke) : spec.smoke;
|
|
605
798
|
const smoked = deps.run(smoke, {});
|
|
606
799
|
if (spec.cpuTail && smoked === TAIL_UNATTACHED_EXIT) unmeasured.push("the smoke");
|
|
@@ -617,10 +810,10 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
|
|
|
617
810
|
`${detail} Worker CPU UNMEASURED — the tail never attached around ${unmeasured.join(" and ")}, which PASSED. ` +
|
|
618
811
|
"Nothing is wrong with the deployment and nothing needs rolling back; rerun the smoke under the app's CPU wrapper to measure it.";
|
|
619
812
|
error(`\n🟡 ${tag} ${why}`);
|
|
620
|
-
return { code: TAIL_UNATTACHED_EXIT, step: "cpu-unmeasured", detail: why, commit, dirty };
|
|
813
|
+
return finish({ code: TAIL_UNATTACHED_EXIT, step: "cpu-unmeasured", detail: why, commit, dirty });
|
|
621
814
|
}
|
|
622
815
|
log(`\n✅ ${detail}`);
|
|
623
|
-
return { code: 0, step: "done", detail, commit, dirty };
|
|
816
|
+
return finish({ code: 0, step: "done", detail, commit, dirty });
|
|
624
817
|
}
|
|
625
818
|
|
|
626
819
|
/**
|
|
@@ -1046,13 +1239,13 @@ export interface WorkerCpuDeps {
|
|
|
1046
1239
|
error: (line: string) => void;
|
|
1047
1240
|
}
|
|
1048
1241
|
|
|
1049
|
-
/** The real {@link WorkerCpuDeps}: `bunx wrangler
|
|
1242
|
+
/** The real {@link WorkerCpuDeps}: `bunx wrangler tail` — the APP's own wrangler, whose major (4) its `devDependencies` pin: {@link localWranglerRefusal}. */
|
|
1050
1243
|
export function workerCpuDeps(): WorkerCpuDeps {
|
|
1051
1244
|
return {
|
|
1052
1245
|
startTail: (workerName, cwd, env) => {
|
|
1053
1246
|
let out = "";
|
|
1054
1247
|
let err = "";
|
|
1055
|
-
const child = spawn("bunx", ["wrangler
|
|
1248
|
+
const child = spawn("bunx", ["wrangler", "tail", workerName, "--format", "json"], {
|
|
1056
1249
|
cwd,
|
|
1057
1250
|
env: { ...process.env, ...env },
|
|
1058
1251
|
stdio: ["ignore", "pipe", "pipe"],
|