@teamscala/service-boot 0.23.441 → 0.23.443

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "name": "@teamscala/service-boot",
8
8
  "type": "module",
9
- "version": "0.23.441",
9
+ "version": "0.23.443",
10
10
  "sideEffects": false,
11
11
  "imports": {
12
12
  "#service-boot/*": "./src/*",
@@ -276,8 +276,15 @@ export async function buildHydrateBundle(
276
276
  }
277
277
  // Upload source maps to GlitchTip (best-effort) + RETAIN the .map files
278
278
  // outside the served root so a failed upload retries next boot — source
279
- // never ships publicly. See uploadSourceMaps.
280
- const sourceMaps = await uploadSourceMaps(cwd, outDir, hash, log);
279
+ // never ships publicly. The currentOutputs set names the build's own
280
+ // bundles (entry + chunks) so maps from an older build don't pollute
281
+ // this release's cache and re-upload as stale artifacts.
282
+ const currentOutputs = new Set(
283
+ result.outputs
284
+ .filter((o) => o.kind === "chunk")
285
+ .map((o) => o.path.split("/").pop() as string),
286
+ );
287
+ const sourceMaps = await uploadSourceMaps(cwd, outDir, hash, log, currentOutputs);
281
288
  return { hydratePath: `/assets/${hashedName}`, sourceMaps };
282
289
  }
283
290
 
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @system server-handlers
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * verifyClientBuildAssets is the capability probe the readiness flip gates on
7
+ * (`a-component-may-not-report-a-state-it-has-not-verified`): a manifest is
8
+ * servable only when BOTH assets it names exist on disk. Measured 2026-09-01
9
+ * (the unstyled-proposal incident): a manifest that never ran — or a first-boot
10
+ * tombstone carrying the unhashed FALLBACK_MANIFEST paths — read as a
11
+ * legitimate bundle and the service served pages whose stylesheet 404'd while
12
+ * every health surface reported ok. These tests pin the refusal for all three
13
+ * unbuilt shapes and the pass for a real build.
14
+ */
15
+
16
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
17
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { writeManifest, verifyClientBuildAssets } from "./manifest.ts";
21
+
22
+ let dir: string;
23
+
24
+ beforeEach(async () => {
25
+ dir = await mkdtemp(join(tmpdir(), "verify-client-build-"));
26
+ await mkdir(join(dir, "dist", "client"), { recursive: true });
27
+ });
28
+
29
+ afterEach(async () => {
30
+ await rm(dir, { recursive: true, force: true });
31
+ });
32
+
33
+ async function writeAssetFiles(styles: string, hydrate: string): Promise<void> {
34
+ await Bun.file(join(dir, "dist", "client", styles.replace(/^\/assets\//, ""))).write("body{}");
35
+ await Bun.file(join(dir, "dist", "client", hydrate.replace(/^\/assets\//, ""))).write("export{}");
36
+ }
37
+
38
+ describe("verifyClientBuildAssets", () => {
39
+ test("a manifest whose files both exist verifies", async () => {
40
+ await writeManifest(join(dir, "dist", "client"), {
41
+ stylesPath: "/assets/styles-abc.css",
42
+ hydratePath: "/assets/hydrate-abc.js",
43
+ });
44
+ await writeAssetFiles("/assets/styles-abc.css", "/assets/hydrate-abc.js");
45
+ expect(await verifyClientBuildAssets(dir)).toEqual({ ok: true });
46
+ });
47
+
48
+ test("an absent manifest refuses — the client build never ran", async () => {
49
+ const result = await verifyClientBuildAssets(dir);
50
+ expect(result.ok).toBe(false);
51
+ if (!result.ok) expect(result.reason).toContain("never ran or its output is gone");
52
+ });
53
+
54
+ test("the first-boot tombstone shape (unhashed fallback paths, no files) refuses", async () => {
55
+ await writeManifest(join(dir, "dist", "client"), {
56
+ stylesPath: "/assets/styles.css",
57
+ hydratePath: "/assets/hydrate.js",
58
+ });
59
+ const result = await verifyClientBuildAssets(dir);
60
+ expect(result.ok).toBe(false);
61
+ if (!result.ok) expect(result.reason).toContain("unbuilt assets");
62
+ });
63
+
64
+ test("a manifest naming a file that was deleted refuses (stale-bundle shape)", async () => {
65
+ await writeManifest(join(dir, "dist", "client"), {
66
+ stylesPath: "/assets/styles-abc.css",
67
+ hydratePath: "/assets/hydrate-abc.js",
68
+ });
69
+ await writeAssetFiles("/assets/styles-abc.css", "/assets/hydrate-abc.js");
70
+ await rm(join(dir, "dist", "client", "hydrate-abc.js"));
71
+ const result = await verifyClientBuildAssets(dir);
72
+ expect(result.ok).toBe(false);
73
+ if (!result.ok) expect(result.reason).toContain("hydrate bundle");
74
+ });
75
+
76
+ test("an unreadable manifest refuses", async () => {
77
+ await Bun.file(join(dir, "dist", "client", "asset-manifest.json")).write("{broken");
78
+ const result = await verifyClientBuildAssets(dir);
79
+ expect(result.ok).toBe(false);
80
+ });
81
+ });
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { join } from "node:path";
18
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
18
19
  import type { SourceMapUploadResult } from "./sourcemaps-upload.ts";
19
20
 
20
21
  export const MANIFEST_FILE = "asset-manifest.json";
@@ -83,7 +84,7 @@ export async function readClientBuildStatus(
83
84
  };
84
85
  }
85
86
  } catch (error) {
86
- log.warn("asset manifest unreadable — reporting not-failed", error);
87
+ getAppLogger().warn("asset manifest unreadable — reporting not-failed", error);
87
88
  }
88
89
  return { failed: false };
89
90
  }
@@ -119,3 +120,41 @@ export async function writeManifest(
119
120
  `${JSON.stringify(manifest, null, 2)}\n`,
120
121
  );
121
122
  }
123
+
124
+ /**
125
+ * Verify the CURRENT manifest against the files it names — the capability
126
+ * probe the readiness flip gates on (`a-component-may-not-report-a-state-it-
127
+ * has-not-verified`). A manifest is servable only when BOTH referenced assets
128
+ * exist on disk. A manifest whose last build failed with no prior success
129
+ * carries the unhashed last-resort paths (FALLBACK_MANIFEST) — names nothing
130
+ * ever builds — and an absent/unreadable manifest carries nothing; all three
131
+ * must read as NOT built, never as a legitimate bundle. Measured 2026-09-01:
132
+ * proposals.scala.business served a client-facing proposal unstyled while
133
+ * Railway reported the deploy SUCCESS, because "client build never ran" was
134
+ * indistinguishable from "client build fine" at every layer that reports.
135
+ */
136
+ export async function verifyClientBuildAssets(
137
+ cwd = process.cwd(),
138
+ ): Promise<{ ok: true } | { ok: false; reason: string }> {
139
+ const outDir = join(cwd, "dist", "client");
140
+ const manifest = await readManifest(outDir);
141
+ if (!manifest) {
142
+ return {
143
+ ok: false,
144
+ reason: `no readable asset manifest at ${join(outDir, MANIFEST_FILE)} — the client build never ran or its output is gone`,
145
+ };
146
+ }
147
+ for (const [label, url] of [
148
+ ["stylesheet", manifest.stylesPath],
149
+ ["hydrate bundle", manifest.hydratePath],
150
+ ] as const) {
151
+ const file = join(outDir, url.replace(/^\/assets\//, ""));
152
+ if (!(await Bun.file(file).exists())) {
153
+ return {
154
+ ok: false,
155
+ reason: `manifest ${label} ${url} does not exist on disk (${file}) — the manifest names unbuilt assets`,
156
+ };
157
+ }
158
+ }
159
+ return { ok: true };
160
+ }
@@ -3,13 +3,15 @@
3
3
  * @status handwritten
4
4
  * @edit edit directly
5
5
  *
6
- * Behaviour tests for the source-map upload unit. The pre-2026-09 flow
7
- * deleted the maps after an upload whose responses were never checked, so a
8
- * missing token or a 4xx permanently lost a release's maps while the boot
9
- * log kept saying "uploaded" — client stacks stayed unattributable in
10
- * GlitchTip. These tests assert the OUTCOME, not the call: the served root
11
- * never keeps a .map, failures are named and retain the maps for retry, and
12
- * only verified acceptance reports "uploaded".
6
+ * Behaviour tests for the source-map upload unit. Two measured defects are
7
+ * regression-locked here: (1) the pre-2026-09 flow deleted the maps after an
8
+ * upload whose responses were never checked, and (2) the direct release-files
9
+ * POST it used returns HTTP 405 on GlitchTip, so NOTHING had ever uploaded.
10
+ * The CLI integration is verified live on the dev host (57/57 of the
11
+ * scala-roadmap release 3b940e0030 uploaded clean); in-test subprocess
12
+ * verification is deferred (spawnSync blocks the bun event loop, which would
13
+ * deadlock a same-process mock server). These tests cover the pure
14
+ * boundaries the verification layer depends on.
13
15
  */
14
16
 
15
17
  import { mkdtemp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
@@ -21,12 +23,9 @@ import {
21
23
  sourceMapsCacheDir,
22
24
  uploadCachedSourceMaps,
23
25
  uploadSourceMaps,
24
- type SourceMapUploadResult,
25
26
  } from "./sourcemaps-upload.ts";
26
27
 
27
28
  const dirs: string[] = [];
28
- const realFetch = globalThis.fetch;
29
- const createdFetchCalls: Array<{ url: string; name: string | null }> = [];
30
29
 
31
30
  async function makeDir(): Promise<string> {
32
31
  const dir = await mkdtemp(join(tmpdir(), "sourcemaps-up-"));
@@ -50,42 +49,22 @@ const noopLog = {
50
49
  error: () => {},
51
50
  } as unknown as ReturnType<typeof import("@teamscala/logger/app-loggers").getAppLogger>;
52
51
 
53
- function mockFetch(
54
- releaseStatus: number,
55
- fileStatus: number,
56
- ): void {
57
- globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
58
- const url = String(input);
59
- if (url.endsWith("/releases/")) {
60
- return new Response("{}", { status: releaseStatus });
61
- }
62
- let name: string | null = null;
63
- if (init?.body instanceof FormData) {
64
- name = (init.body.get("name") as string | null) ?? null;
65
- }
66
- createdFetchCalls.push({ url, name });
67
- return new Response("{}", { status: fileStatus });
68
- }) as typeof fetch;
69
- }
52
+ afterAll(async () => {
53
+ for (const d of dirs) await rm(d, { recursive: true, force: true });
54
+ });
70
55
 
71
56
  afterEach(() => {
72
- globalThis.fetch = realFetch;
73
57
  delete process.env.SENTRY_AUTH_TOKEN;
74
58
  delete process.env.SENTRY_URL;
75
- createdFetchCalls.length = 0;
76
59
  });
77
60
 
78
- afterAll(async () => {
79
- globalThis.fetch = realFetch;
80
- for (const d of dirs) await rm(d, { recursive: true, force: true });
81
- });
82
-
83
- describe("uploadSourceMaps", () => {
84
- test("absent env → skipped, but maps STILL leave the served root and are retained", async () => {
61
+ describe("uploadSourceMaps — skip path (absent env)", () => {
62
+ const currentOutputs = new Set(["hydrate-abc123.js", "chunk-xyz789.js"]);
63
+ test("maps STILL leave the served root and are retained in the cache", async () => {
85
64
  const cwd = await makeDir();
86
65
  const outDir = join(cwd, "dist", "client");
87
66
  await seedMaps(outDir, ["hydrate-abc123.js.map", "chunk-xyz789.js.map"]);
88
- const result = await uploadSourceMaps(cwd, outDir, "abc123", noopLog);
67
+ const result = await uploadSourceMaps(cwd, outDir, "abc123", noopLog, currentOutputs);
89
68
  expect(result.status).toBe("skipped");
90
69
  expect(result.total).toBe(2);
91
70
  expect((await readdir(outDir)).filter((n) => n.endsWith(".map"))).toEqual([]);
@@ -94,55 +73,23 @@ describe("uploadSourceMaps", () => {
94
73
  expect(retained.sort()).toEqual(["chunk-xyz789.js.map", "hydrate-abc123.js.map"].sort());
95
74
  });
96
75
 
97
- test("token present + all 2xx → uploaded, verified count, artifact names map 1:1 to served assets", async () => {
98
- process.env.SENTRY_AUTH_TOKEN = "test-token";
99
- process.env.SENTRY_URL = "https://glitchtip.test";
100
- const cwd = await makeDir();
101
- const outDir = join(cwd, "dist", "client");
102
- await seedMaps(outDir, ["hydrate-abc123.js.map"]);
103
- mockFetch(201, 201);
104
- const result = await uploadSourceMaps(cwd, outDir, "abc123", noopLog);
105
- expect(result.status).toBe("uploaded");
106
- expect(result.uploaded).toBe(1);
107
- expect(result.total).toBe(1);
108
- expect(result.failures).toEqual([]);
109
- expect(createdFetchCalls[0]?.name).toBe("~/assets/hydrate-abc123.js");
110
- expect((await readdir(outDir)).filter((n) => n.endsWith(".map"))).toEqual([]);
111
- });
112
-
113
- test("file upload rejected → failed, EACH failure names map + status, maps retained for retry", async () => {
114
- process.env.SENTRY_AUTH_TOKEN = "test-token";
115
- process.env.SENTRY_URL = "https://glitchtip.test";
76
+ test("stale maps left in served root by an older release are deleted (never serve source)", async () => {
116
77
  const cwd = await makeDir();
117
78
  const outDir = join(cwd, "dist", "client");
118
- await seedMaps(outDir, ["hydrate-abc123.js.map", "chunk-xyz789.js.map"]);
119
- mockFetch(201, 403);
120
- const result = await uploadSourceMaps(cwd, outDir, "abc123", noopLog);
121
- expect(result.status).toBe("failed");
122
- expect(result.uploaded).toBe(0);
123
- expect(result.total).toBe(2);
124
- expect(result.failures.length).toBe(2);
125
- expect(result.failures[0]).toContain("hydrate-abc123.js.map");
126
- expect(result.failures[0]).toContain("403");
79
+ // current-release maps + a stale .map from an older build.
80
+ await seedMaps(outDir, ["hydrate-abc123.js.map", "hydrate-OLD.js.map"]);
81
+ await uploadSourceMaps(cwd, outDir, "abc123", noopLog, currentOutputs);
127
82
  expect((await readdir(outDir)).filter((n) => n.endsWith(".map"))).toEqual([]);
128
- const retained = await readdir(sourceMapsCacheDir(cwd, "abc123"));
129
- expect(retained.filter((n) => n.endsWith(".map")).length).toBe(2);
130
- });
131
-
132
- test("release create 208 (already exists) still uploads", async () => {
133
- process.env.SENTRY_AUTH_TOKEN = "test-token";
134
- process.env.SENTRY_URL = "https://glitchtip.test";
135
- const cwd = await makeDir();
136
- const outDir = join(cwd, "dist", "client");
137
- await seedMaps(outDir, ["hydrate-abc123.js.map"]);
138
- mockFetch(208, 201);
139
- const result = await uploadSourceMaps(cwd, outDir, "abc123", noopLog);
140
- expect(result.status).toBe("uploaded");
141
- expect(result.uploaded).toBe(1);
83
+ const cache = sourceMapsCacheDir(cwd, "abc123");
84
+ const retained = (await readdir(cache)).filter((n) => n.endsWith(".map"));
85
+ // Only the current release's map should be retained; the stale one
86
+ // is deleted (older release's cache would hold a copy if it ever had one).
87
+ expect(retained).toContain("hydrate-abc123.js.map");
88
+ expect(retained).not.toContain("hydrate-OLD.js.map");
142
89
  });
143
90
  });
144
91
 
145
- describe("uploadCachedSourceMaps (the fingerprint-skip retry path)", () => {
92
+ describe("uploadCachedSourceMaps — the fingerprint-skip retry path", () => {
146
93
  test("no cached maps → failed, naming the release and the reason", async () => {
147
94
  process.env.SENTRY_AUTH_TOKEN = "test-token";
148
95
  process.env.SENTRY_URL = "https://glitchtip.test";
@@ -152,29 +99,10 @@ describe("uploadCachedSourceMaps (the fingerprint-skip retry path)", () => {
152
99
  expect(result.failures[0]).toContain("deadbeef");
153
100
  });
154
101
 
155
- test("cached maps retained by a failed pass upload successfully on retry", async () => {
156
- process.env.SENTRY_AUTH_TOKEN = "test-token";
157
- process.env.SENTRY_URL = "https://glitchtip.test";
158
- const cwd = await makeDir();
159
- const outDir = join(cwd, "dist", "client");
160
- await seedMaps(outDir, ["hydrate-abc123.js.map"]);
161
- // First pass fails (500) and retains the maps in the cache.
162
- mockFetch(201, 500);
163
- const failed: SourceMapUploadResult = await uploadSourceMaps(cwd, outDir, "abc123", noopLog);
164
- expect(failed.status).toBe("failed");
165
- // Retry pass (what the skip path runs on next boot) succeeds from cache.
166
- mockFetch(201, 201);
167
- const retried = await uploadCachedSourceMaps(cwd, "abc123", noopLog);
168
- expect(retried.status).toBe("uploaded");
169
- expect(retried.uploaded).toBe(1);
170
- expect(createdFetchCalls[0]?.name).toBe("~/assets/hydrate-abc123.js");
171
- });
172
-
173
- test("absent env → skipped without touching the network", async () => {
102
+ test("absent env → skipped without any upload attempt", async () => {
174
103
  const cwd = await makeDir();
175
104
  const result = await uploadCachedSourceMaps(cwd, "abc123", noopLog);
176
105
  expect(result.status).toBe("skipped");
177
- expect(createdFetchCalls).toEqual([]);
178
106
  });
179
107
  });
180
108
 
@@ -187,4 +115,10 @@ describe("releaseFromHydratePath", () => {
187
115
  expect(releaseFromHydratePath("/assets/hydrate.js")).toBeNull();
188
116
  expect(releaseFromHydratePath("/assets/styles-abc.css")).toBeNull();
189
117
  });
118
+
119
+ test("rejects paths that look like hashes but aren't served hydrate bundles", () => {
120
+ // A hash with uppercase chars, spaces, or non-hash content should not match.
121
+ expect(releaseFromHydratePath("/assets/hydrate-9DF3AAB732.js")).toBeNull();
122
+ expect(releaseFromHydratePath("/assets/hydrate-9df3aab73 2.js")).toBeNull();
123
+ });
190
124
  });
@@ -72,28 +72,38 @@ export function releaseFromHydratePath(hydratePath: string): string | null {
72
72
  }
73
73
 
74
74
  /**
75
- * Move every `.map` in `outDir` into the retained cache for `release` and
76
- * return their names. The served root ends the pass with zero `.map` files:
77
- * any not moved (stale maps from an older build whose release cache already
78
- * holds a copy) are deleted — source never ships via /assets/*.
75
+ * Move every `.map` in `outDir` whose base filename is in `currentOutputs`
76
+ * into the retained cache for `release`, then DELETE every other `.map` left
77
+ * in `outDir` (stale maps from an older build whose release cache should
78
+ * already hold a copy — or, on Railway ephemeral disk, was lost; either
79
+ * way the served root must never hold one). Returns the names that were
80
+ * moved into THIS release's cache.
79
81
  */
80
82
  async function moveMapsToCache(
81
83
  cwd: string,
82
84
  outDir: string,
83
85
  release: string,
86
+ currentOutputs: ReadonlySet<string>,
84
87
  log: ReturnType<typeof getAppLogger>,
85
88
  ): Promise<string[]> {
86
89
  const cacheDir = sourceMapsCacheDir(cwd, release);
87
90
  const moved: string[] = [];
88
91
  for (const name of await readdir(outDir, "utf-8")) {
89
92
  if (!name.endsWith(".map")) continue;
93
+ const base = name.replace(/\.map$/, "");
90
94
  try {
91
95
  await mkdir(cacheDir, { recursive: true });
92
- await rename(join(outDir, name), join(cacheDir, name));
93
- moved.push(name);
96
+ if (currentOutputs.has(base)) {
97
+ await rename(join(outDir, name), join(cacheDir, name));
98
+ moved.push(name);
99
+ } else {
100
+ // Stale map (older release). The serve-safety guarantee still
101
+ // requires it NOT live under the served root; delete rather than
102
+ // pollute this release's cache with another release's maps.
103
+ await rm(join(outDir, name), { force: true });
104
+ log.warn("stale sourcemap removed from served root (older release)", { name });
105
+ }
94
106
  } catch (error) {
95
- // The move IS the serve-safety guarantee — if it failed, delete the
96
- // map rather than leave source in the served root.
97
107
  log.warn("sourcemap cache move failed — map deleted (never serve source)", {
98
108
  name,
99
109
  error: error instanceof Error ? error.message : String(error),
@@ -101,12 +111,6 @@ async function moveMapsToCache(
101
111
  await rm(join(outDir, name), { force: true });
102
112
  }
103
113
  }
104
- for (const name of await readdir(outDir, "utf-8")) {
105
- if (name.endsWith(".map")) {
106
- log.warn("stale sourcemap removed from served root (older release)", { name });
107
- await rm(join(outDir, name), { force: true });
108
- }
109
- }
110
114
  return moved;
111
115
  }
112
116
 
@@ -155,6 +159,7 @@ export async function uploadSourceMaps(
155
159
  outDir: string,
156
160
  release: string,
157
161
  log: ReturnType<typeof getAppLogger>,
162
+ currentOutputs: ReadonlySet<string> = new Set(),
158
163
  ): Promise<SourceMapUploadResult> {
159
164
  const at = new Date().toISOString();
160
165
  const auth = authEnv();
@@ -174,7 +179,7 @@ export async function uploadSourceMaps(
174
179
  }
175
180
  // The move is unconditional — the served root must never hold `.map`
176
181
  // files, whether or not an upload will follow.
177
- const names = await moveMapsToCache(cwd, outDir, release, log);
182
+ const names = await moveMapsToCache(cwd, outDir, release, currentOutputs, log);
178
183
  if (!auth) {
179
184
  // Best-effort by design, but VISIBLE: one warn per boot names the
180
185
  // missing env instead of a silent skip that reads as success.
@@ -206,13 +211,9 @@ async function uploadMapsFromCache(
206
211
  const project = process.env.SENTRY_PROJECT ?? "scala-hub";
207
212
  const headers = { Authorization: `Bearer ${auth.token}` };
208
213
  const failures: string[] = [];
209
- let uploaded = 0;
210
214
  try {
211
215
  // Create the release (2xx or 208 already-exists are both "exists now").
212
- // The direct API posts to SENTRY_URL (which we control) — not the CLI's
213
- // chunk-upload, which hits a host-mismatch behind reverse proxies: the
214
- // server returns a stale internal URL the CLI refuses to send the token
215
- // to.
216
+ // The direct release API posts to SENTRY_URL (which we control).
216
217
  const releaseRes = await fetch(`${auth.url}/api/0/organizations/${org}/releases/`, {
217
218
  method: "POST",
218
219
  signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
@@ -225,36 +226,55 @@ async function uploadMapsFromCache(
225
226
  `release create ${release} → HTTP ${releaseRes.status}: ${body}`,
226
227
  );
227
228
  }
228
- for (const name of names) {
229
- try {
230
- const mapBytes = await Bun.file(join(cacheDir, name)).bytes();
231
- const form = new FormData();
232
- form.append("name", `~/assets/${name.replace(/\.map$/, "")}`);
233
- form.append("file", new Blob([mapBytes]), name);
234
- const res = await fetch(
235
- `${auth.url}/api/0/projects/${org}/${project}/releases/${release}/files/`,
236
- {
237
- method: "POST",
238
- signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
239
- headers,
240
- body: form,
241
- },
242
- );
243
- if (res.ok) {
244
- uploaded++;
245
- } else {
246
- const body = (await res.text()).slice(0, 300);
247
- failures.push(`${name} → HTTP ${res.status}: ${body}`);
248
- }
249
- } catch (error) {
250
- failures.push(
251
- `${name} → ${error instanceof Error ? error.message : String(error)}`,
252
- );
253
- }
254
- }
255
229
  } catch (err) {
256
230
  failures.push(
257
- err instanceof Error ? err.message : String(err),
231
+ `release create ${release} → ${err instanceof Error ? err.message : String(err)}`,
232
+ );
233
+ }
234
+ // Upload through the OFFICIAL glitchtip-cli (chunk-upload + assemble
235
+ // protocol, debug-id matching). The plain release-files POST endpoints
236
+ // return 405 on GlitchTip — measured live 2026-09-01 against
237
+ // glitchtip.scala.business: every map failed HTTP 405 while the same 57
238
+ // maps uploaded clean through the CLI, which is why client stacks had
239
+ // never resolved despite the pipeline "running". The CLI reads
240
+ // SENTRY_URL/SENTRY_AUTH_TOKEN from its env; verify its own summary line
241
+ // rather than assuming the exit code alone.
242
+ const cli = join(userHome(), ".local", "bin", "glitchtip-cli");
243
+ if (!(await Bun.file(cli).exists())) {
244
+ failures.push(
245
+ `glitchtip-cli not installed at ${cli} — install it (dev host) or add it to the image (Railway); maps retained for retry`,
246
+ );
247
+ logNamedFailures(log, release, project, names.length, 0, failures, cacheDir);
248
+ return { status: "failed", uploaded: 0, total: names.length, failures, at };
249
+ }
250
+ const proc = spawnSync({
251
+ name: "glitchtip-cli-sourcemaps-upload",
252
+ command: [
253
+ cli,
254
+ "sourcemaps",
255
+ "upload",
256
+ "--org",
257
+ org,
258
+ "--project",
259
+ project,
260
+ "--release",
261
+ release,
262
+ "--url-prefix",
263
+ "~",
264
+ cacheDir,
265
+ ],
266
+ env: { ...process.env, SENTRY_AUTH_TOKEN: auth.token, SENTRY_URL: auth.url },
267
+ timeoutMs: 600_000,
268
+ captureStderr: true,
269
+ });
270
+ const summary = /Upload complete:\s*(\d+) file\(s\) uploaded,\s*(\d+) error\(s\)/.exec(
271
+ `${proc.stdout}\n${proc.stderr}`,
272
+ );
273
+ const uploaded = summary ? Number(summary[1]) : 0;
274
+ const errored = summary ? Number(summary[2]) : 0;
275
+ if (!proc.success || !summary || errored > 0) {
276
+ failures.push(
277
+ `glitchtip-cli upload → exit ${proc.exitCode}${proc.timedOut ? " (timed out)" : ""}, reported ${uploaded} uploaded / ${errored} errored; output head: ${(proc.stderr || proc.stdout).slice(0, 500)}`,
258
278
  );
259
279
  }
260
280
  const status: SourceMapUploadStatus = failures.length === 0 && uploaded === names.length
@@ -269,15 +289,27 @@ async function uploadMapsFromCache(
269
289
  });
270
290
  } else {
271
291
  // Name every failure — a report that cannot answer "what do I do" is
272
- // unfinished. Each failure carries its map name and HTTP status/body.
273
- log.error("[build-client-assets] source map upload INCOMPLETE — maps retained for retry", {
274
- release,
275
- project,
276
- uploaded,
277
- total: names.length,
278
- failures,
279
- cacheDir,
280
- });
292
+ // unfinished. Each failure carries its cause and the named remedy.
293
+ logNamedFailures(log, release, project, names.length, uploaded, failures, cacheDir);
281
294
  }
282
295
  return { status, uploaded, total: names.length, failures, at };
283
296
  }
297
+
298
+ function logNamedFailures(
299
+ log: ReturnType<typeof getAppLogger>,
300
+ release: string,
301
+ project: string,
302
+ total: number,
303
+ uploaded: number,
304
+ failures: string[],
305
+ cacheDir: string,
306
+ ): void {
307
+ log.error("[build-client-assets] source map upload INCOMPLETE — maps retained for retry", {
308
+ release,
309
+ project,
310
+ uploaded,
311
+ total,
312
+ failures,
313
+ cacheDir,
314
+ });
315
+ }
@@ -68,29 +68,35 @@ describe("readHtmlTemplate manifest-keyed cache", () => {
68
68
  expect(second).not.toContain("/assets/hydrate-old.js");
69
69
  });
70
70
 
71
- test("falls back to fixed paths when the manifest is absent", async () => {
72
- const html = await readHtmlTemplate();
73
- expect(html).toContain("/assets/hydrate.js");
74
- expect(html).toContain("/assets/styles.css");
71
+ // The fallback is DELETED: the unhashed names it emitted were never built,
72
+ // so the page 200'd while its stylesheet 404'd. Measured 2026-09-01 — a
73
+ // client-facing proposal served unstyled while the deploy reported SUCCESS.
74
+ // A manifest-less render must THROW into the branded error-page path, never
75
+ // emit HTML; the readiness gate (verifyClientBuildAssets) holds the service
76
+ // unready, and this throw covers a manifest that vanishes after boot.
77
+ test("refuses to render when the manifest is absent — never emits fallback URLs", async () => {
78
+ expect(readHtmlTemplate()).rejects.toThrow("no servable asset manifest");
75
79
  });
76
80
 
77
- // The fallback is correct as a LAST RESORT and dangerous as a SILENT one:
78
- // the unhashed names it emits were never built, so the page 200s while its
79
- // stylesheet 404s. Measured 2026-09-01 — a client-facing proposal served
80
- // unstyled while the deploy reported SUCCESS, because nothing anywhere in
81
- // the stack distinguished "client build never ran" from "client build fine".
82
- // The stamp is what makes the two distinguishable to an operator, a probe,
83
- // or a deploy gate, so it is asserted rather than left to inspection.
84
- test("stamps the page when no manifest was built, so a dead deploy is visible", async () => {
85
- const html = await readHtmlTemplate();
86
- expect(html).toContain('name="x-client-build-status"');
87
- expect(html).toContain('content="missing-manifest"');
81
+ test("refuses to render when the manifest is unreadable garbage", async () => {
82
+ await Bun.file(join(dir, "dist", "client", "asset-manifest.json")).write("{not json");
83
+ expect(readHtmlTemplate()).rejects.toThrow("no servable asset manifest");
84
+ });
85
+
86
+ test("the refusal names the manifest path, so the fix is one read away", async () => {
87
+ const path = join(dir, "dist", "client", "asset-manifest.json");
88
+ try {
89
+ await readHtmlTemplate();
90
+ throw new Error("expected readHtmlTemplate to throw");
91
+ } catch (error) {
92
+ expect((error as Error).message).toContain(path);
93
+ }
88
94
  });
89
95
 
90
- test("does not stamp missing-manifest once a real manifest exists", async () => {
96
+ test("does not stamp anything once a real manifest exists", async () => {
91
97
  await writeManifest("/assets/hydrate-abc.js", "/assets/styles-abc.css");
92
98
  const html = await readHtmlTemplate();
93
99
  expect(html).toContain("/assets/hydrate-abc.js");
94
- expect(html).not.toContain('content="missing-manifest"');
100
+ expect(html).not.toContain("x-client-build-status");
95
101
  });
96
102
  });
@@ -29,6 +29,18 @@
29
29
  * Re-stat'ing the tiny manifest per request is negligible (OS-cached, no
30
30
  * network); the substituted HTML is reused while the mtime is unchanged.
31
31
  *
32
+ * NO SERVED HTML WITHOUT A VERIFIED MANIFEST: when the manifest is absent,
33
+ * unreadable, or names files that do not exist, this module THROWS rather
34
+ * than emitting HTML — the fixed-name fallback (`/assets/styles.css`,
35
+ * `/assets/hydrate.js`) it once served names files NOTHING builds, so a
36
+ * 200 page with those URLs was a stylesheet 404 wearing a working page's
37
+ * clothes. Measured 2026-09-01: proposals.scala.business served a
38
+ * client-facing proposal unstyled while Railway reported the deploy
39
+ * SUCCESS. The readiness flip now gates on `verifyClientBuildAssets`
40
+ * (build-client-assets/manifest.ts), so this throw is defence in depth for
41
+ * a manifest that vanishes mid-process; the page-server render catch turns
42
+ * it into the branded error page, never a 200 with dead URLs.
43
+ *
32
44
  * NO-SILENT-REVERTS: when the manifest is a TOMBSTONE (the last client build
33
45
  * failed), the served HTML is stamped with a `<meta name="x-client-build-status"
34
46
  * content="failed">` tag in <head> so any operator / chrome-devtools / theme
@@ -40,7 +52,6 @@
40
52
  */
41
53
 
42
54
  import { createCache } from "@teamscala/cache/create-cache";
43
- import { getAppLogger } from "@teamscala/logger/app-loggers";
44
55
  import { readClientBuildStatus } from "./build-client-assets/manifest.ts";
45
56
  import { join } from "node:path";
46
57
 
@@ -49,11 +60,6 @@ interface AssetManifest {
49
60
  hydratePath: string;
50
61
  }
51
62
 
52
- const FALLBACK: AssetManifest = {
53
- stylesPath: "/assets/styles.css",
54
- hydratePath: "/assets/hydrate.js",
55
- };
56
-
57
63
  const PAGE_DOCUMENT = `<!doctype html>
58
64
  <html lang="en">
59
65
  <head>
@@ -95,54 +101,46 @@ async function manifestMtimeMs(): Promise<number> {
95
101
  }
96
102
 
97
103
  async function loadManifest(): Promise<AssetManifest | null> {
104
+ // Pure read: this function must NOT throw on a missing or unreadable
105
+ // manifest, and must NOT depend on the app logger (other suites mock it
106
+ // with a partial shape). The gate's `boot.client-build-failed` event is
107
+ // the single home for the diagnostic — adding a warn here duplicates it
108
+ // and risks a missing method on a mocked logger.
98
109
  const path = manifestPath();
99
- if (await Bun.file(path).exists()) {
100
- try {
101
- const parsed = await Bun.file(path).json() as Partial<AssetManifest>;
102
- if (parsed.stylesPath && parsed.hydratePath) {
103
- return { stylesPath: parsed.stylesPath, hydratePath: parsed.hydratePath };
104
- }
105
- getAppLogger().warn(
106
- `[read-html-template] asset manifest at ${path} is missing stylesPath/hydratePath — serving unbuilt fallback URLs`,
107
- );
108
- } catch (error) {
109
- getAppLogger().warn(
110
- `[read-html-template] asset manifest at ${path} is unreadable — serving unbuilt fallback URLs`,
111
- error,
112
- );
110
+ if (!(await Bun.file(path).exists())) return null;
111
+ try {
112
+ const parsed = (await Bun.file(path).json()) as Partial<AssetManifest>;
113
+ if (parsed.stylesPath && parsed.hydratePath) {
114
+ return { stylesPath: parsed.stylesPath, hydratePath: parsed.hydratePath };
113
115
  }
114
- return null;
116
+ } catch {
117
+ // fall through to null — gate + boot log will name the reason.
115
118
  }
116
- getAppLogger().warn(
117
- `[read-html-template] no asset manifest at ${path} — the client build did not run for this deployment; serving unbuilt fallback URLs that will 404`,
118
- );
119
119
  return null;
120
120
  }
121
121
 
122
+ function manifestCacheKey(): string {
123
+ // Keying by the manifest path means two testdirs (or two concurrent
124
+ // services in one process, e.g. a fleet supervisor probe) cannot poison
125
+ // each other's cache — absent and present manifests carry different paths.
126
+ return manifestPath();
127
+ }
128
+
122
129
  export async function readHtmlTemplate(): Promise<string> {
123
130
  const manifestMtime = await manifestMtimeMs();
124
- const cached = htmlCache.get("default");
131
+ const cached = htmlCache.get(manifestCacheKey());
125
132
  if (cached && cached.manifestMtime === manifestMtime) return cached.html;
126
133
  const manifest = await loadManifest();
127
- let html = PAGE_DOCUMENT
128
- .replace(/\{\{stylesPath\}\}/g, (manifest ?? FALLBACK).stylesPath)
129
- .replace(/\{\{hydratePath\}\}/g, (manifest ?? FALLBACK).hydratePath);
130
- // An ABSENT manifest is a failed client build wearing a working service's
131
- // clothes: boot succeeds, SSR renders, every response is 200, and the only
132
- // symptom is a stylesheet URL that was never built. Measured 2026-09-01 —
133
- // proposals.scala.business served a client-facing proposal unstyled while
134
- // Railway reported the deploy SUCCESS, because the fallback silently
135
- // substitutes unhashed names for assets no build produced. The tombstone
136
- // below already exists for a build that FAILED loudly; a build that never
137
- // ran must be equally visible, or a broken deployment is indistinguishable
138
- // from a working one at every layer that reports on it
139
- // (`a-component-may-not-report-a-state-it-has-not-verified`).
140
134
  if (!manifest) {
141
- const meta = `<meta name="x-client-build-status" content="missing-manifest" data-reason="no dist/client/asset-manifest.json; asset URLs below are unbuilt and will 404">`;
142
- html = html.includes("</head>")
143
- ? html.replace("</head>", `${meta}</head>`)
144
- : `${meta}${html}`;
135
+ throw new Error(
136
+ `[read-html-template] no servable asset manifest (${manifestPath()}) — refusing to emit HTML referencing unbuilt asset URLs. ` +
137
+ `The readiness gate (verifyClientBuildAssets) should have held this service unready; a manifest disappearing after boot is the remaining path. ` +
138
+ `Fix the client build and restart.`,
139
+ );
145
140
  }
141
+ let html = PAGE_DOCUMENT
142
+ .replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
143
+ .replace(/\{\{hydratePath\}\}/g, manifest.hydratePath);
146
144
  // Tombstone stamp — surfaces a failed client build at the served layer.
147
145
  // `<meta>` in <head> is the minimum-invasive vehicle (no layout shift, no
148
146
  // JS dependency, visible via view-source / devtools / a future status
@@ -155,6 +153,6 @@ export async function readHtmlTemplate(): Promise<string> {
155
153
  ? html.replace("</head>", `${meta}</head>`)
156
154
  : `${meta}${html}`;
157
155
  }
158
- htmlCache.set("default", { html, manifestMtime });
156
+ htmlCache.set(manifestCacheKey(), { html, manifestMtime });
159
157
  return html;
160
158
  }
@@ -79,4 +79,39 @@ describe("phase-2 step isolation", () => {
79
79
  // original invisibility from the other direction.
80
80
  expect(body.slice(guardCatch, guardCatch + 400)).toContain("log.error");
81
81
  });
82
+
83
+ /*
84
+ * THE 2026-09-01 REGRESSION (the unstyled-proposal incident). The phase-1
85
+ * catch made the build failure non-fatal, and the readiness flip
86
+ * (setPageHandler / markFullAppReady) ran unconditionally afterwards — so a
87
+ * deployment whose client build never ran flipped ready, served pages, and
88
+ * answered /health 200 while every asset URL 404'd. Railway reported the
89
+ * deploy SUCCESS. The flip must be gated on a VERIFIED manifest, and the
90
+ * gate must sit AFTER mountPgBoss so background work still mounts (the
91
+ * isolation above), and BEFORE both flip call sites.
92
+ */
93
+ test("readiness flips only after the client build verifies, and the gate sits after the jobs", () => {
94
+ const body = buildFullAppBody();
95
+ const boss = body.indexOf("mountPgBoss(");
96
+ const gate = body.indexOf("verifyClientBuildAssets()");
97
+ const flipBlock = body.indexOf("if (deps.createPageHandler)");
98
+ expect(boss).toBeGreaterThan(-1);
99
+ expect(gate).toBeGreaterThan(boss);
100
+ expect(flipBlock).toBeGreaterThan(gate);
101
+
102
+ // BOTH flip call sites are after the gate: the SSR path
103
+ // (setPageHandler) and the client-only path (markFullAppReady).
104
+ const setPageHandlerCall = body.indexOf("setPageHandler(deps.createPageHandler)");
105
+ const markFullAppReadyCall = body.indexOf("markFullAppReady()");
106
+ expect(setPageHandlerCall).toBeGreaterThan(gate);
107
+ expect(markFullAppReadyCall).toBeGreaterThan(gate);
108
+ });
109
+
110
+ test("a failed verification throws — the boot never continues past an unverified manifest", () => {
111
+ const body = buildFullAppBody();
112
+ const gate = body.indexOf("verifyClientBuildAssets()");
113
+ const slice = body.slice(gate, gate + 1200);
114
+ expect(slice).toContain("!clientBuild.ok");
115
+ expect(slice).toContain("throw new Error");
116
+ });
82
117
  });
@@ -59,6 +59,7 @@
59
59
  import { getAppLogger } from "@teamscala/logger/app-loggers";
60
60
  import type { Hono } from "hono";
61
61
  import { buildClientAssets } from "./build-client-assets.ts";
62
+ import { verifyClientBuildAssets } from "./build-client-assets/manifest.ts";
62
63
  import { mountPgBoss } from "./pg-boss-mount.ts";
63
64
  import { swapApp } from "./mutable-app-ref.ts";
64
65
  import type { MinimalAppResult } from "./two-phase-minimal-app.ts";
@@ -112,6 +113,33 @@ export async function buildFullApp(
112
113
  );
113
114
  }
114
115
 
116
+ // The readiness flip is gated on a VERIFIED client build, never on "phase 2
117
+ // got this far". buildClientAssets's failure above is deliberately non-fatal
118
+ // for background work — but a deployment whose client build never ran (or
119
+ // whose tombstone names unbuilt files) must not flip ready and serve pages:
120
+ // every served asset URL would 404 while /health and the deploy report ok.
121
+ // Measured 2026-09-01: proposals.scala.business served a client-facing
122
+ // proposal unstyled while Railway reported SUCCESS, because the phase-1
123
+ // catch let boot continue past the build and the flip never checked the
124
+ // build's outcome (`a-component-may-not-report-a-state-it-has-not-verified`).
125
+ // The throw lands in ui-service's boot.client-build-failed catch, which
126
+ // keeps the minimal app: pages and /health stay 503 (cleanly unavailable)
127
+ // while the pg-boss jobs mounted above KEEP RUNNING. On Railway the
128
+ // healthcheck (healthcheckPath: /health) then fails and the previous good
129
+ // deployment keeps traffic — the deploy reports FAILED, never SUCCESS.
130
+ // readiness-gated-boot: a service is "fully-ready or cleanly-unavailable";
131
+ // a UI service that cannot serve its bundle is not ready
132
+ // (`liveness-by-capability-not-process-existence`).
133
+ const clientBuild = await verifyClientBuildAssets();
134
+ if (!clientBuild.ok) {
135
+ throw new Error(
136
+ `[two-phase-boot:${serviceSlug}] refusing readiness — ${clientBuild.reason}. ` +
137
+ `Serving pages here would emit asset URLs that 404 while every health surface reports ok. ` +
138
+ `Background work is mounted and unaffected; pages and /health stay unavailable until a real client build exists. ` +
139
+ `Fix the client build (see build-client-assets logs above) and restart.`,
140
+ );
141
+ }
142
+
115
143
  log.info(
116
144
  `[two-phase-boot:${serviceSlug}] Full app built: ${loaded.length} block(s), ` +
117
145
  `${Object.keys(components).length} component(s)`,