@teamscala/service-boot 0.23.536 → 0.23.537

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.
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { setMinLevel } from "@teamscala/logger/creator";
7
7
  import { configureFileSink } from "@teamscala/logger/file-sink";
8
+ import { substituteHostPaths } from "@teamscala/os/host-paths";
8
9
 
9
10
  export function applyLoggerConfig(config: {
10
11
  minLevel?: "debug" | "info" | "warn" | "error";
@@ -16,7 +17,7 @@ export function applyLoggerConfig(config: {
16
17
  setMinLevel(config.minLevel);
17
18
  }
18
19
  configureFileSink({
19
- logDir: config.logDir,
20
+ logDir: config.logDir ? substituteHostPaths(config.logDir) : undefined,
20
21
  maxFileSize: config.maxFileSize,
21
22
  maxFiles: config.maxFiles,
22
23
  });
@@ -8,6 +8,7 @@ import { join } from "node:path";
8
8
  import { getAppLogger } from "@teamscala/logger/app-loggers";
9
9
  import { removeStaleHashedFiles } from "./hashed-output.ts";
10
10
  import {
11
+ ASSET_PUBLIC_PREFIX,
11
12
  uploadSourceMaps,
12
13
  type SourceMapUploadResult,
13
14
  } from "./sourcemaps-upload.ts";
@@ -71,7 +72,7 @@ export async function buildHydrateBundle(
71
72
  plugins: [resilientBlockPlugin],
72
73
  splitting: true,
73
74
  sourcemap: "external",
74
- publicPath: "/assets/",
75
+ publicPath: ASSET_PUBLIC_PREFIX,
75
76
  external: ["*.css"],
76
77
  });
77
78
  } catch (e) {
@@ -195,7 +196,7 @@ export async function buildHydrateBundle(
195
196
  .map((o) => o.path.split("/").pop() as string),
196
197
  );
197
198
  const sourceMaps = await uploadSourceMaps(cwd, outDir, hash, log, currentOutputs);
198
- return { hydratePath: `/assets/${hashedName}`, sourceMaps };
199
+ return { hydratePath: `${ASSET_PUBLIC_PREFIX}${hashedName}`, sourceMaps };
199
200
  }
200
201
 
201
202
  async function removeStaleChunks(
@@ -4,57 +4,19 @@
4
4
  */
5
5
 
6
6
  import { join } from "node:path";
7
- import { getAppLogger } from "@teamscala/logger/app-loggers";
8
7
  import type { SourceMapUploadResult } from "./sourcemaps-upload.ts";
9
8
 
10
9
  export const MANIFEST_FILE = "asset-manifest.json";
11
10
 
12
11
  export interface AssetManifest {
13
- // Optional because a TOMBSTONE from a build that failed with no prior
14
- // success carries NO paths — there are no real assets to name, and inventing
15
- // URLs nothing built is the deleted fallback class (no-shims).
16
- // `readManifest` returns null unless both are present, so a path-less
17
- // tombstone reads as unbuilt everywhere (verification, readiness, render).
18
12
  stylesPath?: string;
19
13
  hydratePath?: string;
20
- // TOMBSTONE fields — present iff the last build FAILED (no-silent-reverts).
21
- // `buildFailed: true` + reason + timestamp are written by performBuild
22
- // before it re-throws; every boot rebuilds unconditionally, so the failed
23
- // build is retried by construction. read-html-template reads these to stamp
24
- // the served HTML with the failure status (operator-visible signal that
25
- // the bundle is stale, not fresh).
26
14
  buildFailed?: boolean;
27
15
  buildFailedReason?: string;
28
16
  buildFailedAt?: string;
29
- // VERIFIED outcome of the last source-map upload attempt for THIS build's
30
- // release (`uploaded` = every response checked 2xx; `failed` names each
31
- // rejection in `failures`; `skipped` = no SENTRY_AUTH_TOKEN/SENTRY_URL).
32
- // Every boot rebuilds and re-attempts the upload, so a failed upload is
33
- // retried until it verifies — an unuploaded release must not silently
34
- // persist forever.
35
17
  sourceMaps?: SourceMapUploadResult;
36
18
  }
37
19
 
38
- export async function readClientBuildStatus(
39
- cwd = process.cwd(),
40
- ): Promise<{ failed: true; reason?: string; at?: string } | { failed: false }> {
41
- const path = join(cwd, "dist", "client", MANIFEST_FILE);
42
- if (!(await Bun.file(path).exists())) return { failed: false };
43
- try {
44
- const parsed = (await Bun.file(path).json()) as Partial<AssetManifest>;
45
- if (parsed.buildFailed === true) {
46
- return {
47
- failed: true,
48
- reason: parsed.buildFailedReason,
49
- at: parsed.buildFailedAt,
50
- };
51
- }
52
- } catch (error) {
53
- getAppLogger().warn("asset manifest unreadable — reporting not-failed", error);
54
- }
55
- return { failed: false };
56
- }
57
-
58
20
  export async function readManifest(outDir: string): Promise<AssetManifest | null> {
59
21
  const path = join(outDir, MANIFEST_FILE);
60
22
  if (!(await Bun.file(path).exists())) return null;
@@ -44,8 +44,7 @@ afterAll(async () => {
44
44
  });
45
45
 
46
46
  afterEach(() => {
47
- delete process.env.SENTRY_AUTH_TOKEN;
48
- delete process.env.SENTRY_URL;
47
+ delete process.env.SENTRY_DSN;
49
48
  });
50
49
 
51
50
  describe("uploadSourceMaps — skip path (absent env)", () => {
@@ -81,8 +80,7 @@ describe("uploadSourceMaps — skip path (absent env)", () => {
81
80
 
82
81
  describe("uploadCachedSourceMaps — the fingerprint-skip retry path", () => {
83
82
  test("no cached maps → failed, naming the release and the reason", async () => {
84
- process.env.SENTRY_AUTH_TOKEN = "test-token";
85
- process.env.SENTRY_URL = "https://glitchtip.test";
83
+ process.env.SENTRY_DSN = "https://cred@glitchtip.test/scala";
86
84
  const cwd = await makeDir();
87
85
  const result = await uploadCachedSourceMaps(cwd, "deadbeef", noopLog);
88
86
  expect(result.status).toBe("failed");
@@ -3,12 +3,11 @@
3
3
  * @status handwritten
4
4
  */
5
5
 
6
-
7
6
  import { mkdir, readdir, rename, rm } from "node:fs/promises";
8
7
  import { join } from "node:path";
8
+ import { resilientFetch } from "@teamscala/http/resilient-fetch";
9
9
  import { getAppLogger } from "@teamscala/logger/app-loggers";
10
- import { userHome } from "@teamscala/os/host-paths";
11
- import { spawnSync } from "@teamscala/os/spawn";
10
+ import { createSafeFallback } from "@teamscala/safe-fallback/create-safe-fallback";
12
11
 
13
12
  export type SourceMapUploadStatus = "uploaded" | "failed" | "skipped";
14
13
 
@@ -20,6 +19,8 @@ export interface SourceMapUploadResult {
20
19
  at: string;
21
20
  }
22
21
 
22
+ export const ASSET_PUBLIC_PREFIX = "/assets/";
23
+
23
24
  const UPLOAD_TIMEOUT_MS = 30_000;
24
25
 
25
26
  export function sourceMapsCacheDir(cwd: string, release: string): string {
@@ -30,6 +31,183 @@ export function releaseFromHydratePath(hydratePath: string): string | null {
30
31
  return /hydrate-([a-z0-9]+)\.js/.exec(hydratePath)?.[1] ?? null;
31
32
  }
32
33
 
34
+ export function sourceMapArtifactName(mapFileName: string): string {
35
+ return `~${ASSET_PUBLIC_PREFIX}${mapFileName.replace(/\.map$/, "")}`;
36
+ }
37
+
38
+ export interface ReleaseTarget {
39
+ origin: string;
40
+ credential: string;
41
+ project: string;
42
+ }
43
+
44
+ export function sentryTarget(dsn: string | undefined = process.env.SENTRY_DSN): ReleaseTarget | null {
45
+ const value = dsn?.trim();
46
+ if (!value) return null;
47
+ const schemeEnd = value.indexOf("://");
48
+ if (schemeEnd <= 0) return null;
49
+ const remainder = value.slice(schemeEnd + 3);
50
+ const at = remainder.indexOf("@");
51
+ if (at <= 0) return null;
52
+ const hostAndProject = remainder.slice(at + 1);
53
+ const slash = hostAndProject.lastIndexOf("/");
54
+ if (slash <= 0 || slash === hostAndProject.length - 1) return null;
55
+ return {
56
+ origin: `${value.slice(0, schemeEnd)}://${hostAndProject.slice(0, slash)}`,
57
+ credential: remainder.slice(0, at),
58
+ project: hostAndProject.slice(slash + 1),
59
+ };
60
+ }
61
+
62
+ export class SourceMapTransportError extends Error {
63
+ readonly cause: unknown;
64
+
65
+ constructor(message: string, cause: unknown) {
66
+ super(message);
67
+ this.name = "SourceMapTransportError";
68
+ this.cause = cause;
69
+ }
70
+ }
71
+
72
+ function describe(error: unknown): string {
73
+ return error instanceof Error ? error.message : String(error);
74
+ }
75
+
76
+ function releaseFilesUrl(target: ReleaseTarget, release: string): string {
77
+ const org = process.env.SENTRY_ORG ?? "scala";
78
+ return `${target.origin}/api/0/projects/${encodeURIComponent(org)}/${encodeURIComponent(target.project)}/releases/${encodeURIComponent(release)}/files/`;
79
+ }
80
+
81
+ async function readReleaseFiles(target: ReleaseTarget, release: string): Promise<Response> {
82
+ return resilientFetch("temps", releaseFilesUrl(target, release), {
83
+ method: "GET",
84
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
85
+ headers: { Authorization: `Bearer ${target.credential}` },
86
+ });
87
+ }
88
+
89
+ async function postReleaseFile(
90
+ target: ReleaseTarget,
91
+ release: string,
92
+ artifactName: string,
93
+ data: ArrayBuffer,
94
+ ): Promise<Response> {
95
+ const form = new FormData();
96
+ form.append("name", artifactName);
97
+ form.append("file", new Blob([data]), artifactName.slice(artifactName.lastIndexOf("/") + 1));
98
+ return resilientFetch("temps", releaseFilesUrl(target, release), {
99
+ method: "POST",
100
+ body: form,
101
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
102
+ headers: { Authorization: `Bearer ${target.credential}` },
103
+ });
104
+ }
105
+
106
+ const readBackFallback = createSafeFallback<{ names: string[] }>({
107
+ name: "sourcemaps:read-back",
108
+ onFailure: "fail-closed-error",
109
+ });
110
+
111
+ const transportFallback = createSafeFallback<Response>({
112
+ name: "sourcemaps:transport",
113
+ onFailure: "fail-closed-error",
114
+ });
115
+
116
+ const mapTransferFallback = createSafeFallback<void>({
117
+ name: "sourcemaps:map-transfer",
118
+ onFailure: "fail-closed-warn",
119
+ degradedResult: undefined,
120
+ });
121
+
122
+ function isTransportFailure(error: unknown): boolean {
123
+ return error instanceof SourceMapTransportError;
124
+ }
125
+
126
+ const transportCallParams = { isExpectedOutcome: isTransportFailure };
127
+
128
+ async function postMap(
129
+ target: ReleaseTarget,
130
+ release: string,
131
+ artifactName: string,
132
+ data: ArrayBuffer,
133
+ ): Promise<string | null> {
134
+ const response = await postTransportCall(
135
+ `POST ${artifactName}`,
136
+ () => postReleaseFile(target, release, artifactName, data),
137
+ );
138
+ if (response.ok) return null;
139
+ return `${artifactName} → HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`;
140
+ }
141
+
142
+ async function releaseFileNames(target: ReleaseTarget, release: string): Promise<string[]> {
143
+ const response = await postTransportCall(
144
+ "GET release files",
145
+ () => readReleaseFiles(target, release),
146
+ );
147
+ if (!response.ok) {
148
+ throw new SourceMapTransportError(
149
+ `GET release files → HTTP ${response.status}: ${(await response.text()).slice(0, 300)}`,
150
+ null,
151
+ );
152
+ }
153
+ return readBackFallback.call(async () => {
154
+ const body: unknown = await response.json();
155
+ if (!Array.isArray(body)) throw new Error("release file list is not an array");
156
+ return {
157
+ names: body.flatMap((row) =>
158
+ row && typeof row === "object" && typeof (row as { name?: unknown }).name === "string"
159
+ ? [(row as { name: string }).name]
160
+ : [],
161
+ ),
162
+ };
163
+ });
164
+ }
165
+
166
+ async function postTransportCall(
167
+ label: string,
168
+ operation: () => Promise<Response>,
169
+ ): Promise<Response> {
170
+ return transportFallback
171
+ .call(
172
+ async () => {
173
+ const response = await operation();
174
+ if (response.status >= 500) {
175
+ throw new SourceMapTransportError(`${label} → HTTP ${response.status}`, null);
176
+ }
177
+ return response;
178
+ },
179
+ transportCallParams,
180
+ )
181
+ .catch((error: unknown) => {
182
+ throw new SourceMapTransportError(
183
+ error instanceof SourceMapTransportError
184
+ ? `${label} → ${error.message}`
185
+ : `${label} → ${describe(error)}`,
186
+ error,
187
+ );
188
+ });
189
+ }
190
+
191
+ async function retainOrDeleteMap(
192
+ outDir: string,
193
+ name: string,
194
+ cacheDir: string,
195
+ keep: boolean,
196
+ log: ReturnType<typeof getAppLogger>,
197
+ ): Promise<void> {
198
+ await mapTransferFallback
199
+ .call(async () => {
200
+ if (keep) {
201
+ await rename(join(outDir, name), join(cacheDir, name));
202
+ return;
203
+ }
204
+ await rm(join(outDir, name), { force: true });
205
+ })
206
+ .catch(async () => {
207
+ await rm(join(outDir, name), { force: true }).catch(() => {});
208
+ });
209
+ }
210
+
33
211
  async function moveMapsToCache(
34
212
  cwd: string,
35
213
  outDir: string,
@@ -39,37 +217,28 @@ async function moveMapsToCache(
39
217
  ): Promise<string[]> {
40
218
  const cacheDir = sourceMapsCacheDir(cwd, release);
41
219
  const moved: string[] = [];
42
- for (const name of await readdir(outDir, "utf-8")) {
43
- if (!name.endsWith(".map")) continue;
44
- const base = name.replace(/\.map$/, "");
45
- try {
46
- await mkdir(cacheDir, { recursive: true });
47
- if (currentOutputs.has(base)) {
48
- await rename(join(outDir, name), join(cacheDir, name));
49
- moved.push(name);
50
- } else {
51
- // Stale map (older release). The serve-safety guarantee still
52
- // requires it NOT live under the served root; delete rather than
53
- // pollute this release's cache with another release's maps.
54
- await rm(join(outDir, name), { force: true });
55
- log.warn("stale sourcemap removed from served root (older release)", { name });
56
- }
57
- } catch (error) {
58
- log.warn("sourcemap cache move failed — map deleted (never serve source)", {
59
- name,
60
- error: error instanceof Error ? error.message : String(error),
61
- });
62
- await rm(join(outDir, name), { force: true });
63
- }
220
+ const entries = await readdir(outDir, "utf-8");
221
+ const maps = entries.filter((name) => name.endsWith(".map"));
222
+ if (maps.length === 0) return moved;
223
+ await mkdir(cacheDir, { recursive: true });
224
+ for (const name of maps) {
225
+ const keep = currentOutputs.has(name.replace(/\.map$/, ""));
226
+ await retainOrDeleteMap(outDir, name, cacheDir, keep, log).then(
227
+ () => {
228
+ if (keep) moved.push(name);
229
+ else log.warn("stale sourcemap removed from served root (older release)", { name });
230
+ },
231
+ (error: unknown) => {
232
+ log.warn("sourcemap cache move failed", { name, error: describe(error) });
233
+ },
234
+ );
64
235
  }
65
236
  return moved;
66
237
  }
67
238
 
68
- function authEnv(): { token: string; url: string } | null {
69
- const token = process.env.SENTRY_AUTH_TOKEN;
70
- const url = process.env.SENTRY_URL;
71
- if (!token || !url) return null;
72
- return { token, url };
239
+ async function readCachedMapNames(cacheDir: string): Promise<string[]> {
240
+ const entries = await readdir(cacheDir, "utf-8");
241
+ return entries.filter((name) => name.endsWith(".map"));
73
242
  }
74
243
 
75
244
  export async function uploadCachedSourceMaps(
@@ -78,14 +247,14 @@ export async function uploadCachedSourceMaps(
78
247
  log: ReturnType<typeof getAppLogger>,
79
248
  ): Promise<SourceMapUploadResult> {
80
249
  const at = new Date().toISOString();
81
- const auth = authEnv();
82
250
  const cacheDir = sourceMapsCacheDir(cwd, release);
83
- if (!auth) {
251
+ const target = sentryTarget();
252
+ if (!target) {
84
253
  return { status: "skipped", uploaded: 0, total: 0, failures: [], at };
85
254
  }
86
255
  let names: string[] = [];
87
256
  try {
88
- names = (await readdir(cacheDir, "utf-8")).filter((n) => n.endsWith(".map"));
257
+ names = await readCachedMapNames(cacheDir);
89
258
  } catch {
90
259
  names = [];
91
260
  }
@@ -95,12 +264,12 @@ export async function uploadCachedSourceMaps(
95
264
  uploaded: 0,
96
265
  total: 0,
97
266
  failures: [
98
- `no cached source maps for release ${release} at ${cacheDir} — the build that produced them predates retention or they were removed; next bundle rebuild regenerates them`,
267
+ `no cached source maps for release ${release} at ${cacheDir} — the build that produced them predates retention or they were removed; the next bundle rebuild regenerates them`,
99
268
  ],
100
269
  at,
101
270
  };
102
271
  }
103
- return uploadMapsFromCache(cacheDir, names, release, auth, log, at);
272
+ return uploadMapsFromCache(cacheDir, names, release, target, log, at);
104
273
  }
105
274
 
106
275
  export async function uploadSourceMaps(
@@ -111,137 +280,74 @@ export async function uploadSourceMaps(
111
280
  currentOutputs: ReadonlySet<string> = new Set(),
112
281
  ): Promise<SourceMapUploadResult> {
113
282
  const at = new Date().toISOString();
114
- const auth = authEnv();
115
- // Inject debug IDs into the served bundles + maps (if glitchtip-cli is
116
- // present). Runs on outDir BEFORE the cache move so both the served
117
- // bundle and its retained map carry the same debug id. The CLI is
118
- // optional — only used for the inject step.
119
- const cli = join(userHome(), ".local", "bin", "glitchtip-cli");
120
- if (await Bun.file(cli).exists()) {
121
- spawnSync({
122
- name: "glitchtip-cli-sourcemaps-inject",
123
- command: [cli, "sourcemaps", "inject", outDir],
124
- env: { ...process.env, SENTRY_AUTH_TOKEN: auth?.token ?? "" },
125
- timeoutMs: 60_000,
126
- captureStderr: true,
127
- });
128
- }
129
- // The move is unconditional — the served root must never hold `.map`
130
- // files, whether or not an upload will follow.
131
283
  const names = await moveMapsToCache(cwd, outDir, release, currentOutputs, log);
132
- if (!auth) {
133
- // Best-effort by design, but VISIBLE: one warn per boot names the
134
- // missing env instead of a silent skip that reads as success.
284
+ const cacheDir = sourceMapsCacheDir(cwd, release);
285
+ if (names.length === 0) {
286
+ log.warn("[build-client-assets] no source maps to upload for this build", { release, cacheDir });
287
+ return { status: "skipped", uploaded: 0, total: 0, failures: [], at };
288
+ }
289
+ const target = sentryTarget();
290
+ if (!target) {
135
291
  log.warn(
136
- `[build-client-assets] source maps NOT uploaded — SENTRY_AUTH_TOKEN/SENTRY_URL absent (maps retained at ${sourceMapsCacheDir(cwd, release)} for retry once configured)`,
292
+ `[build-client-assets] source maps NOT uploaded — SENTRY_DSN absent (maps retained at ${cacheDir} for retry once configured)`,
137
293
  { release, mapsRetained: names.length },
138
294
  );
139
295
  return { status: "skipped", uploaded: 0, total: names.length, failures: [], at };
140
296
  }
141
- return uploadMapsFromCache(
142
- sourceMapsCacheDir(cwd, release),
143
- names,
144
- release,
145
- auth,
146
- log,
147
- at,
148
- );
297
+ return uploadMapsFromCache(cacheDir, names, release, target, log, at);
149
298
  }
150
299
 
151
300
  async function uploadMapsFromCache(
152
301
  cacheDir: string,
153
302
  names: string[],
154
303
  release: string,
155
- auth: { token: string; url: string },
304
+ target: ReleaseTarget,
156
305
  log: ReturnType<typeof getAppLogger>,
157
306
  at: string,
158
307
  ): Promise<SourceMapUploadResult> {
159
- const org = process.env.SENTRY_ORG ?? "scala";
160
- const project = process.env.SENTRY_PROJECT ?? "scala-hub";
161
- const headers = { Authorization: `Bearer ${auth.token}` };
308
+ const expected = names.map(sourceMapArtifactName);
162
309
  const failures: string[] = [];
163
- try {
164
- // Create the release (2xx or 208 already-exists are both "exists now").
165
- // The direct release API posts to SENTRY_URL (which we control).
166
- const releaseRes = await fetch(`${auth.url}/api/0/organizations/${org}/releases/`, {
167
- method: "POST",
168
- signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
169
- headers: { ...headers, "Content-Type": "application/json" },
170
- body: JSON.stringify({ version: release, projects: [project] }),
171
- });
172
- if (!(releaseRes.ok || releaseRes.status === 208)) {
173
- const body = (await releaseRes.text()).slice(0, 300);
174
- failures.push(
175
- `release create ${release} → HTTP ${releaseRes.status}: ${body}`,
176
- );
177
- }
178
- } catch (err) {
179
- failures.push(
180
- `release create ${release} → ${err instanceof Error ? err.message : String(err)}`,
181
- );
310
+ for (const name of names) {
311
+ const artifactName = sourceMapArtifactName(name);
312
+ const data = await Bun.file(join(cacheDir, name))
313
+ .arrayBuffer()
314
+ .catch((error: unknown) => {
315
+ failures.push(`${artifactName} → cached map unreadable: ${describe(error)}`);
316
+ return null;
317
+ });
318
+ if (data === null) continue;
319
+ const failure = await postMap(target, release, artifactName, data);
320
+ if (failure) failures.push(failure);
182
321
  }
183
- // Upload through the OFFICIAL glitchtip-cli (chunk-upload + assemble
184
- // protocol, debug-id matching). The plain release-files POST endpoints
185
- // return 405 on GlitchTip — measured live 2026-09-01 against
186
- // glitchtip.scala.business: every map failed HTTP 405 while the same 57
187
- // maps uploaded clean through the CLI, which is why client stacks had
188
- // never resolved despite the pipeline "running". The CLI reads
189
- // SENTRY_URL/SENTRY_AUTH_TOKEN from its env; verify its own summary line
190
- // rather than assuming the exit code alone.
191
- const cli = join(userHome(), ".local", "bin", "glitchtip-cli");
192
- if (!(await Bun.file(cli).exists())) {
322
+ const stored = await releaseFileNames(target, release).catch((error: unknown) => {
193
323
  failures.push(
194
- `glitchtip-cli not installed at ${cli} — install it (dev host) or add it to the image (Railway); maps retained for retry`,
324
+ `${describe(error)} (release ${release}, ${releaseFilesUrl(target, release)})`,
195
325
  );
196
- logNamedFailures(log, release, project, names.length, 0, failures, cacheDir);
197
- return { status: "failed", uploaded: 0, total: names.length, failures, at };
198
- }
199
- const proc = spawnSync({
200
- name: "glitchtip-cli-sourcemaps-upload",
201
- command: [
202
- cli,
203
- "sourcemaps",
204
- "upload",
205
- "--org",
206
- org,
207
- "--project",
208
- project,
209
- "--release",
210
- release,
211
- "--url-prefix",
212
- "~",
213
- cacheDir,
214
- ],
215
- env: { ...process.env, SENTRY_AUTH_TOKEN: auth.token, SENTRY_URL: auth.url },
216
- timeoutMs: 600_000,
217
- captureStderr: true,
326
+ return null;
218
327
  });
219
- const summary = /Upload complete:\s*(\d+) file\(s\) uploaded,\s*(\d+) error\(s\)/.exec(
220
- `${proc.stdout}\n${proc.stderr}`,
221
- );
222
- const uploaded = summary ? Number(summary[1]) : 0;
223
- const errored = summary ? Number(summary[2]) : 0;
224
- if (!proc.success || !summary || errored > 0) {
328
+ if (stored === null) {
329
+ logNamedFailures(log, release, target.project, expected.length, 0, failures, cacheDir);
330
+ return { status: "failed", uploaded: 0, total: expected.length, failures, at };
331
+ }
332
+ const missing = expected.filter((name) => !stored.includes(name));
333
+ for (const name of missing) {
225
334
  failures.push(
226
- `glitchtip-cli upload → exit ${proc.exitCode}${proc.timedOut ? " (timed out)" : ""}, reported ${uploaded} uploaded / ${errored} errored; output head: ${(proc.stderr || proc.stdout).slice(0, 500)}`,
335
+ `${name} → absent from release ${release} after upload; temps stores the name verbatim and resolves stacks by it, so a map stored under another name cannot symbolicate`,
227
336
  );
228
337
  }
229
- const status: SourceMapUploadStatus = failures.length === 0 && uploaded === names.length
230
- ? "uploaded"
231
- : "failed";
338
+ const uploaded = expected.length - missing.length;
339
+ const status: SourceMapUploadStatus = failures.length === 0 ? "uploaded" : "failed";
232
340
  if (status === "uploaded") {
233
- log.info("[build-client-assets] source maps uploaded to GlitchTip (verified)", {
341
+ log.info("[build-client-assets] source maps uploaded to temps (read back)", {
234
342
  release,
235
- project,
343
+ project: target.project,
236
344
  uploaded,
237
- total: names.length,
345
+ total: expected.length,
238
346
  });
239
347
  } else {
240
- // Name every failure — a report that cannot answer "what do I do" is
241
- // unfinished. Each failure carries its cause and the named remedy.
242
- logNamedFailures(log, release, project, names.length, uploaded, failures, cacheDir);
348
+ logNamedFailures(log, release, target.project, expected.length, uploaded, failures, cacheDir);
243
349
  }
244
- return { status, uploaded, total: names.length, failures, at };
350
+ return { status, uploaded, total: expected.length, failures, at };
245
351
  }
246
352
 
247
353
  function logNamedFailures(
@@ -261,4 +367,4 @@ function logNamedFailures(
261
367
  failures,
262
368
  cacheDir,
263
369
  });
264
- }
370
+ }