cursedops 0.14.0 → 0.16.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 CHANGED
@@ -35,6 +35,7 @@ import shape, worked example, and the incident each one is pinned to.
35
35
  | `cursedops/relay-link` | the MAC half — `dialLink`, `readLinkSettings`, `rotateLinkKey` |
36
36
  | `cursedops/relay-stage` | the signed-in STAGE WALK's skeleton both relay apps shared |
37
37
  | `cursedops/public-client` | does a relay app's PUBLIC hostname serve the client this release built? — `comparePublicClient`, `recordPublicClient`, `shipClientIfBehind` |
38
+ | `cursedops/container-deploy` | a Cloudflare CONTAINER Worker deployed and proven — the env-file secret lines, Docker on PATH and up, `/healthz`, and `awaitRollout`: the container on the new image, not just the Worker answering |
38
39
  | `cursedops/public-surface` | the ratchet on a LIBRARY's public surface — a symbol count per export subpath against a committed baseline that may only fall |
39
40
 
40
41
  Bun, zero runtime dependencies, ships TypeScript source. Nothing here knows an app's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedops",
3
- "version": "0.14.0",
3
+ "version": "0.16.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 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": {
@@ -152,11 +152,23 @@
152
152
  "source": "./src/relayStage.ts",
153
153
  "import": "./src/relayStage.ts"
154
154
  },
155
+ "./container-deploy": {
156
+ "types": "./src/containerDeploy.ts",
157
+ "bun": "./src/containerDeploy.ts",
158
+ "source": "./src/containerDeploy.ts",
159
+ "import": "./src/containerDeploy.ts"
160
+ },
155
161
  "./public-client": {
156
162
  "types": "./src/publicClient.ts",
157
163
  "bun": "./src/publicClient.ts",
158
164
  "source": "./src/publicClient.ts",
159
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"
160
172
  }
161
173
  },
162
174
  "bin": {
@@ -201,5 +213,9 @@
201
213
  "script": "test"
202
214
  }
203
215
  ]
216
+ },
217
+ "repository": {
218
+ "type": "git",
219
+ "url": "git+https://github.com/wizardmordamir/cursedforge-cursedops.git"
204
220
  }
205
221
  }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `cursedops/container-deploy` — the pieces of deploying a Cloudflare CONTAINER Worker (a Worker
3
+ * fronting a Docker image) and proving it landed, which binary-server's `runner:deploy` (task 2723)
4
+ * wrote and music's `agent:deploy` (task 2816) copied body for body.
5
+ *
6
+ * ```ts
7
+ * import { awaitRollout, dockerUp, healthz, newestRollout, readEnvLine, withDockerBin, workersDevUrl, writeEnvLine } from "cursedops/container-deploy";
8
+ *
9
+ * const env = { ...process.env, PATH: withDockerBin(process.env.PATH) };
10
+ * const url = await workersDevUrl(account, apiToken, "thing-runner");
11
+ * const app = { account, token: apiToken, application: "thing-runner-encoder" };
12
+ * const before = await newestRollout(app);
13
+ * await dockerUp(env, "the runner");
14
+ * // … wrangler deploy, then healthz(url, token) until it answers at a new version …
15
+ * const { verdict } = await awaitRollout({ ...app, before, poke: () => startAnInstance(url), label: "encoder" });
16
+ * ```
17
+ *
18
+ * 🔴 **A Worker answering is not the container being on the new image.** Cloudflare rolls the
19
+ * container out separately and gradually, and a rollout only moves while an instance starts — so
20
+ * `awaitRollout` pokes one every time it looks (measured 2026-10-05: the Worker's version was live
21
+ * while the instances still ran the previous image).
22
+ *
23
+ * 🔴 **An application the deploy CREATED has no rollout at all** — it is born at version 1 with the
24
+ * image (`/rollouts` answered `[]` on music's first deploy), so waiting for one would wait the whole
25
+ * twenty minutes and then call a good deploy failed. `rolloutVerdict` says `created`.
26
+ *
27
+ * 🔴 **A network blip while looking is a look that failed, not a deploy that failed** — one
28
+ * "unknown certificate verification error" threw a landed deploy red. `awaitRollout` looks again.
29
+ */
30
+ import { spawnSync } from "node:child_process";
31
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
32
+
33
+ /** The value of `key` in an env file's text, unquoted; `""` when it is not there. Pure. */
34
+ export function readEnvLine(text: string, key: string): string {
35
+ return new RegExp(`^${key}=(.*)$`, "m").exec(text)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
36
+ }
37
+
38
+ /** Set `key=value` in an env file's text, replacing a line already there. Pure. */
39
+ export function withEnvLine(text: string, key: string, value: string): string {
40
+ const line = `${key}=${value}`;
41
+ const re = new RegExp(`^${key}=.*$`, "m");
42
+ if (re.test(text)) return text.replace(re, line);
43
+ return `${text}${text.endsWith("\n") || text === "" ? "" : "\n"}${line}\n`;
44
+ }
45
+
46
+ /** Set `key=value` in the secrets file at `path` (created if missing), owner-only. */
47
+ export function writeEnvLine(path: string, key: string, value: string): void {
48
+ writeFileSync(path, withEnvLine(existsSync(path) ? readFileSync(path, "utf8") : "", key, value));
49
+ chmodSync(path, 0o600);
50
+ }
51
+
52
+ export const DOCKER_BUNDLE_BIN = "/Applications/Docker.app/Contents/Resources/bin";
53
+
54
+ /**
55
+ * Docker Desktop's own helpers (`docker-credential-desktop`, which every image pull asks for) live
56
+ * in its app bundle, which a non-login shell's PATH does not hold — measured 2026-10-05: the first
57
+ * build failed "executable file not found in $PATH" on its first `FROM`. Pure.
58
+ */
59
+ export function withDockerBin(path: string | undefined, bundleBin = DOCKER_BUNDLE_BIN): string {
60
+ const parts = (path ?? "").split(":").filter(Boolean);
61
+ return parts.includes(bundleBin) ? parts.join(":") : [...parts, bundleBin].join(":");
62
+ }
63
+
64
+ /** Docker up, opening Docker Desktop if it is not — wrangler builds the image with it. `what` names the image for the error. */
65
+ export async function dockerUp(env: NodeJS.ProcessEnv, what: string): Promise<void> {
66
+ const up = (): boolean => spawnSync("docker", ["info"], { stdio: "ignore", env }).status === 0;
67
+ if (up()) return;
68
+ console.log("Docker is not running — opening it…");
69
+ spawnSync("open", ["-a", "Docker"]);
70
+ for (let i = 0; i < 60 && !up(); i++) await Bun.sleep(2000);
71
+ if (!up()) throw new Error(`Docker did not come up in two minutes; wrangler builds ${what}'s image with it`);
72
+ }
73
+
74
+ /** `GET /healthz`, with the bearer token or without. Status 0 when nothing answered. */
75
+ export async function healthz(url: string, token: string | null): Promise<{ status: number; version: string | null }> {
76
+ try {
77
+ const res = await fetch(`${url}/healthz`, { headers: token ? { authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(10_000) });
78
+ const body = (await res.json().catch(() => null)) as { version?: string } | null;
79
+ return { status: res.status, version: body?.version ?? null };
80
+ } catch {
81
+ return { status: 0, version: null };
82
+ }
83
+ }
84
+
85
+ /** The Worker's `https://<script>.<account subdomain>.workers.dev` address. */
86
+ export async function workersDevUrl(account: string, token: string, script: string): Promise<string> {
87
+ const sub = (await (
88
+ await fetch(`https://api.cloudflare.com/client/v4/accounts/${account}/workers/subdomain`, { headers: { authorization: `Bearer ${token}` } })
89
+ ).json()) as { result?: { subdomain?: string } };
90
+ if (!sub.result?.subdomain) throw new Error("could not read the account's workers.dev subdomain");
91
+ return `https://${script}.${sub.result.subdomain}.workers.dev`;
92
+ }
93
+
94
+ export type ContainerRollout = { id: string; created_at: string; status: string; target_configuration?: { image?: string } };
95
+
96
+ /** Which container application, on which account, read with which API token. */
97
+ export interface ContainerApplication {
98
+ account: string;
99
+ token: string;
100
+ /** The application's name — wrangler names it `<script>-<class name, lowercased>`. */
101
+ application: string;
102
+ }
103
+
104
+ /** The most recently created rollout. Pure. */
105
+ export function newestOf(rollouts: readonly ContainerRollout[]): ContainerRollout | null {
106
+ return [...rollouts].sort((a, b) => b.created_at.localeCompare(a.created_at))[0] ?? null;
107
+ }
108
+
109
+ /** The application's newest rollout: `undefined` before the application exists, `null` for one with none. */
110
+ export async function newestRollout(app: ContainerApplication, fetchImpl: typeof fetch = fetch): Promise<ContainerRollout | null | undefined> {
111
+ const api = `https://api.cloudflare.com/client/v4/accounts/${app.account}/containers/applications`;
112
+ const headers = { authorization: `Bearer ${app.token}` };
113
+ const apps = (await (await fetchImpl(api, { headers })).json()) as { result?: { id: string; name: string }[] };
114
+ const found = apps.result?.find((a) => a.name === app.application);
115
+ if (!found) return undefined;
116
+ const rollouts = (await (await fetchImpl(`${api}/${found.id}/rollouts`, { headers })).json()) as { result?: ContainerRollout[] };
117
+ return newestOf(rollouts.result ?? []);
118
+ }
119
+
120
+ export type RolloutVerdict = "new-image" | "unchanged" | "created" | "waiting";
121
+
122
+ /** Is the container on the image this deploy pushed, given the newest rollout before it and now? Pure. */
123
+ export function rolloutVerdict(before: ContainerRollout | null | undefined, now: ContainerRollout | null | undefined): RolloutVerdict {
124
+ if (before === undefined && now === null) return "created";
125
+ // An application that has never rolled out, and still has not: the deploy changed no image.
126
+ if (before === null && now === null) return "unchanged";
127
+ if (!now) return "waiting";
128
+ if (now.id === before?.id) return now.status === "completed" ? "unchanged" : "waiting";
129
+ return now.status === "completed" ? "new-image" : "waiting";
130
+ }
131
+
132
+ export interface AwaitRolloutOptions extends ContainerApplication {
133
+ /** The newest rollout read BEFORE `wrangler deploy`. */
134
+ before: ContainerRollout | null | undefined;
135
+ /** Start an instance — a rollout only moves while one starts. Must not throw. */
136
+ poke: () => Promise<void>;
137
+ /** What the container is called in the log and the error ("encoder", "recorder"). */
138
+ label: string;
139
+ timeoutMs?: number;
140
+ intervalMs?: number;
141
+ /** For tests. */
142
+ read?: (app: ContainerApplication) => Promise<ContainerRollout | null | undefined>;
143
+ sleep?: (ms: number) => Promise<void>;
144
+ now?: () => number;
145
+ log?: (line: string) => void;
146
+ }
147
+
148
+ /** Wait until the rollout settles; throws when it has not in `timeoutMs` (twenty minutes). */
149
+ export async function awaitRollout(o: AwaitRolloutOptions): Promise<{ verdict: Exclude<RolloutVerdict, "waiting">; rollout: ContainerRollout | null | undefined }> {
150
+ const read = o.read ?? ((app: ContainerApplication) => newestRollout(app));
151
+ const sleep = o.sleep ?? ((ms: number) => Bun.sleep(ms));
152
+ const now = o.now ?? Date.now;
153
+ const log = o.log ?? console.log;
154
+ const interval = o.intervalMs ?? 10_000;
155
+ const until = now() + (o.timeoutMs ?? 20 * 60_000);
156
+ let last = "";
157
+ while (now() < until) {
158
+ let r: ContainerRollout | null | undefined;
159
+ try {
160
+ r = await read(o);
161
+ } catch (e) {
162
+ log(` (could not read the ${o.label} rollout: ${e instanceof Error ? e.message : String(e)} — looking again)`);
163
+ await sleep(interval);
164
+ continue;
165
+ }
166
+ const verdict = rolloutVerdict(o.before, r);
167
+ if (verdict !== "waiting") return { verdict, rollout: r };
168
+ if (r && r.status !== last) log(` ${o.label} rollout ${r.id.slice(0, 8)}: ${r.status}`);
169
+ last = r?.status ?? "";
170
+ await o.poke();
171
+ await sleep(interval);
172
+ }
173
+ throw new Error(`the ${o.label}'s rollout did not complete in ${Math.round((o.timeoutMs ?? 20 * 60_000) / 60_000)} minutes — the instances may still run the previous image`);
174
+ }
@@ -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
+ }
@@ -60,6 +60,7 @@ import { liveColumns, liveColumnsQueries, missingColumnStatements, schemaTables
60
60
  import { wranglerRows } from "cursedops/d1-import";
61
61
  import { forgeCodeRoot, forgeState } from "cursedops/roots";
62
62
  import { appendLedger, deferral, readShipSetting, type ShipEventLine, type ShipNowWhere, type ShipSetting } from "cursedops/ship";
63
+ import { type StageLeaseResult, takeStageLease } from "cursedops/stage-lease";
63
64
 
64
65
  /** The two deployments every Worker app here has. */
65
66
  export type WorkerEnv = "production" | "stage";
@@ -338,6 +339,12 @@ export interface DeployDeps {
338
339
  shipWhere?: ShipNowWhere;
339
340
  /** Where a deferred production deploy's `ship` line goes. {@link workerDeployDeps} appends it to the ledger. */
340
341
  recordShip?: (line: ShipEventLine) => void;
342
+ /**
343
+ * Take the app's hosted stage for the whole deploy (`./stageLease.ts`) — every deploy touches the
344
+ * stage, a production one deploys and walks it first. {@link workerDeployDeps} takes the
345
+ * generation's lease; absent (or `null` with no state root), nothing is leased.
346
+ */
347
+ lease?: (app: string, what: string) => StageLeaseResult | null;
341
348
  }
342
349
 
343
350
  /**
@@ -421,6 +428,10 @@ export function workerDeployDeps(cwd: string): DeployDeps {
421
428
  recordShip: (line) => {
422
429
  appendLedger(forgeState(cwd), line);
423
430
  },
431
+ lease: (app, what) => {
432
+ const state = forgeState(cwd);
433
+ return state ? takeStageLease({ state, app, what }) : null;
434
+ },
424
435
  };
425
436
  }
426
437
 
@@ -707,7 +718,9 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
707
718
  const commit = deps.git(["rev-parse", "HEAD"]);
708
719
  const porcelain = deps.git(["status", "--porcelain"]);
709
720
  const dirty = porcelain.length > 0;
721
+ let release = (): void => {};
710
722
  const finish = (result: DeployResult): DeployResult => {
723
+ release();
711
724
  enter("");
712
725
  try {
713
726
  deps.record?.({
@@ -755,6 +768,11 @@ export function runWorkerDeploy(spec: WorkerDeploySpec, deps: DeployDeps): Deplo
755
768
  if (spec.env === "production" && dirty) {
756
769
  return stop("clean-tree", `the tree is dirty — production would report ${commit.slice(0, 8)} and run something else:\n${porcelain}`);
757
770
  }
771
+ // 🔴 One holder per hosted stage (task 3048): a stage deploy under the ship's held suite swapped
772
+ // the Worker and its link key under it. Taken before anything builds, held until `finish`.
773
+ const lease = deps.lease?.(spec.app, `${spec.app} worker:deploy${spec.env === "stage" ? " --env stage" : ""} @ ${commit.slice(0, 8)}`);
774
+ if (lease && !lease.ok) return stop("stage-lease", lease.refusal);
775
+ if (lease?.ok) release = lease.release;
758
776
  for (const refusal of spec.beforeStage ?? []) {
759
777
  const why = refusal();
760
778
  if (why) return stop("refusal", why);