@teamscala/service-boot 0.23.440 → 0.23.442
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 +1 -1
- package/src/build-client-assets/hydrate-bundle.ts +9 -2
- package/src/build-client-assets/manifest.ts +40 -1
- package/src/build-client-assets/sourcemaps-upload.test.ts +34 -100
- package/src/build-client-assets/sourcemaps-upload.ts +89 -57
- package/src/read-html-template.test.ts +20 -0
- package/src/read-html-template.ts +34 -6
package/package.json
CHANGED
|
@@ -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.
|
|
280
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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("
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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("
|
|
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`
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
-
|
|
93
|
-
|
|
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)
|
|
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
|
|
273
|
-
log
|
|
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
|
+
}
|
|
@@ -73,4 +73,24 @@ describe("readHtmlTemplate manifest-keyed cache", () => {
|
|
|
73
73
|
expect(html).toContain("/assets/hydrate.js");
|
|
74
74
|
expect(html).toContain("/assets/styles.css");
|
|
75
75
|
});
|
|
76
|
+
|
|
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"');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("does not stamp missing-manifest once a real manifest exists", async () => {
|
|
91
|
+
await writeManifest("/assets/hydrate-abc.js", "/assets/styles-abc.css");
|
|
92
|
+
const html = await readHtmlTemplate();
|
|
93
|
+
expect(html).toContain("/assets/hydrate-abc.js");
|
|
94
|
+
expect(html).not.toContain('content="missing-manifest"');
|
|
95
|
+
});
|
|
76
96
|
});
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
42
|
import { createCache } from "@teamscala/cache/create-cache";
|
|
43
|
+
import { getAppLogger } from "@teamscala/logger/app-loggers";
|
|
43
44
|
import { readClientBuildStatus } from "./build-client-assets/manifest.ts";
|
|
44
45
|
import { join } from "node:path";
|
|
45
46
|
|
|
@@ -93,7 +94,7 @@ async function manifestMtimeMs(): Promise<number> {
|
|
|
93
94
|
}
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
async function loadManifest(): Promise<AssetManifest> {
|
|
97
|
+
async function loadManifest(): Promise<AssetManifest | null> {
|
|
97
98
|
const path = manifestPath();
|
|
98
99
|
if (await Bun.file(path).exists()) {
|
|
99
100
|
try {
|
|
@@ -101,10 +102,21 @@ async function loadManifest(): Promise<AssetManifest> {
|
|
|
101
102
|
if (parsed.stylesPath && parsed.hydratePath) {
|
|
102
103
|
return { stylesPath: parsed.stylesPath, hydratePath: parsed.hydratePath };
|
|
103
104
|
}
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
106
115
|
}
|
|
107
|
-
|
|
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
|
+
return null;
|
|
108
120
|
}
|
|
109
121
|
|
|
110
122
|
export async function readHtmlTemplate(): Promise<string> {
|
|
@@ -113,8 +125,24 @@ export async function readHtmlTemplate(): Promise<string> {
|
|
|
113
125
|
if (cached && cached.manifestMtime === manifestMtime) return cached.html;
|
|
114
126
|
const manifest = await loadManifest();
|
|
115
127
|
let html = PAGE_DOCUMENT
|
|
116
|
-
.replace(/\{\{stylesPath\}\}/g, manifest.stylesPath)
|
|
117
|
-
.replace(/\{\{hydratePath\}\}/g, manifest.hydratePath);
|
|
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
|
+
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}`;
|
|
145
|
+
}
|
|
118
146
|
// Tombstone stamp — surfaces a failed client build at the served layer.
|
|
119
147
|
// `<meta>` in <head> is the minimum-invasive vehicle (no layout shift, no
|
|
120
148
|
// JS dependency, visible via view-source / devtools / a future status
|