@mpgd/cli 0.61.1 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/app-store-iap-asc.d.ts +23 -0
  2. package/dist/app-store-iap-asc.js +326 -0
  3. package/dist/app-store-iap-port.d.ts +125 -0
  4. package/dist/app-store-iap-port.js +14 -0
  5. package/dist/capacitor-shell-starter.js +18 -1
  6. package/dist/crash-reporting-starter.d.ts +5 -3
  7. package/dist/crash-reporting-starter.js +10 -7
  8. package/dist/crash-symbols-upload.d.ts +67 -0
  9. package/dist/crash-symbols-upload.js +232 -0
  10. package/dist/index.d.ts +4 -0
  11. package/dist/index.js +143 -2
  12. package/dist/native-build-info.json +2 -2
  13. package/dist/native-build-symbols.d.ts +95 -0
  14. package/dist/native-build-symbols.js +261 -0
  15. package/dist/native-build-target.js +1044 -767
  16. package/dist/native-deploy-run.d.ts +2 -0
  17. package/dist/native-deploy-run.js +60 -10
  18. package/dist/native-deploy-submission.d.ts +8 -0
  19. package/dist/native-deploy-submission.js +50 -8
  20. package/dist/play-product-port.d.ts +101 -0
  21. package/dist/play-product-port.js +7 -0
  22. package/dist/play-sdk-adapter.js +67 -0
  23. package/dist/pnpm-build-approvals.d.ts +59 -0
  24. package/dist/pnpm-build-approvals.js +157 -0
  25. package/dist/release-state.d.ts +69 -0
  26. package/dist/release-state.js +150 -5
  27. package/dist/release-workspace.d.ts +3 -0
  28. package/dist/release-workspace.js +44 -1
  29. package/dist/store-products-app-store.d.ts +144 -0
  30. package/dist/store-products-app-store.js +632 -0
  31. package/dist/store-products-command.d.ts +50 -0
  32. package/dist/store-products-command.js +182 -0
  33. package/dist/store-products-play.d.ts +112 -0
  34. package/dist/store-products-play.js +622 -0
  35. package/dist/store-products.d.ts +46 -0
  36. package/dist/store-products.js +124 -0
  37. package/dist/testflight-submission.d.ts +1 -1
  38. package/package.json +11 -11
  39. package/templates/phaser-game/pnpm-workspace.yaml +2 -0
@@ -0,0 +1,157 @@
1
+ /**
2
+ * pnpm build-script decisions for the dependencies a notifications game adds.
3
+ *
4
+ * pnpm 11 replaced `onlyBuiltDependencies` with `allowBuilds` and fails an
5
+ * install (`ERR_PNPM_IGNORED_BUILDS`) while a dependency's build script is
6
+ * neither allowed (`true`) nor denied (`false`), because `strictDepBuilds`
7
+ * defaults to `true` (https://pnpm.io/settings/build). The notifications
8
+ * starter adds `firebase`, whose tree has two such scripts:
9
+ *
10
+ * - `@firebase/util` `postinstall.js` only matters for Firebase App Hosting:
11
+ * it reads `FIREBASE_WEBAPP_CONFIG`, may fetch the web app config from
12
+ * firebase.googleapis.com, and bakes it into `dist/postinstall.*` for
13
+ * `initializeApp()` without options. The published `dist` already ships the
14
+ * placeholder that returns `undefined`, and the kit never initializes the web
15
+ * SDK (it is there so the messaging plugin's web fallback resolves), so the
16
+ * script is denied: the install stays offline and reproducible.
17
+ * - `protobufjs` `scripts/postinstall.js` only prints a warning when the parent
18
+ * package declares protobufjs with another semver operator. The game
19
+ * template already allows it for the Apps in Toss toolchain, so the same
20
+ * decision is kept here.
21
+ *
22
+ * `packages/cli/test/pnpm-build-approvals.test.ts` records every package with
23
+ * an install script in the starter's Firebase tree and fails when the starter's
24
+ * firebase version moves without that list (and a decision here) being redone.
25
+ */
26
+ export const notificationsBuildApprovals = Object.freeze({
27
+ '@firebase/util': false,
28
+ protobufjs: true,
29
+ });
30
+ const allowBuildsLine = /^allowBuilds:[ \t]*(?:#.*)?$/mu;
31
+ // Any top-level spelling of the key, quoted or not, so an unparsed form is
32
+ // reported instead of getting a second, duplicate mapping appended.
33
+ const allowBuildsKey = /^(?:allowBuilds|'allowBuilds'|"allowBuilds")[ \t]*:/mu;
34
+ const entryKey = /^([ \t]+)(?:'((?:[^']|'')*)'|"((?:[^"\\]|\\.)*)"|([^\s#'"-][^:#]*?))[ \t]*:[ \t]*[^\s#]/u;
35
+ /** The package a matcher names: `name`, `name@range`, `@scope/name@range`, or `name@git-url`. */
36
+ export function allowBuildsPackageName(matcher) {
37
+ const trimmed = matcher.trim();
38
+ const versionAt = trimmed.indexOf('@', trimmed.startsWith('@') ? 1 : 0);
39
+ return versionAt === -1 ? trimmed : trimmed.slice(0, versionAt);
40
+ }
41
+ function quoteKey(name) {
42
+ return /^[a-z0-9][a-z0-9._-]*$/u.test(name) ? name : `'${name.replaceAll("'", "''")}'`;
43
+ }
44
+ /**
45
+ * Adds an explicit decision for each package in `approvals` that the
46
+ * `pnpm-workspace.yaml` content does not list yet, keeping every existing
47
+ * entry (a game's own decision wins). Only the block-mapping form the game
48
+ * template writes is edited; another form is reported as unsupported so the
49
+ * caller can print the entries instead of rewriting YAML it cannot parse.
50
+ */
51
+ export function addAllowBuilds(content, approvals) {
52
+ if (/^dangerouslyAllowAllBuilds:[ \t]*true\b/mu.test(content)
53
+ || /^strictDepBuilds:[ \t]*false\b/mu.test(content)) {
54
+ return { kind: 'covered' };
55
+ }
56
+ const newline = content.includes('\r\n') ? '\r\n' : '\n';
57
+ const lines = content.split(/\r?\n/u);
58
+ const header = lines.findIndex((line) => allowBuildsLine.test(line));
59
+ // A bare name reviews every version; a qualified matcher (`name@1.2.3`,
60
+ // `name@git+…`) reviews only what it names, which may not be what the
61
+ // starter's firebase brings.
62
+ const listed = new Set();
63
+ const qualified = new Set();
64
+ let indent = ' ';
65
+ if (header !== -1) {
66
+ let indentSeen = false;
67
+ for (let index = header + 1; index < lines.length; index += 1) {
68
+ const line = lines[index] ?? '';
69
+ if (line.trim() === '' || /^[ \t]*#/u.test(line)) {
70
+ continue;
71
+ }
72
+ if (!/^[ \t]/u.test(line)) {
73
+ break;
74
+ }
75
+ // Only one-line `key: value` entries at one indentation are read; a
76
+ // sequence item, a value on the next line, or a nested key would make
77
+ // the scan miss an entry and the edit add a duplicate key.
78
+ const match = entryKey.exec(line);
79
+ if (match === null || (indentSeen && match[1] !== indent)) {
80
+ return { kind: 'unsupported', missing: Object.keys(approvals) };
81
+ }
82
+ // A merge key (`<<: *decisions`), an anchor, an alias or a tag brings
83
+ // entries the line scan cannot see, so an inserted entry could override
84
+ // an inherited decision.
85
+ if (/^(?:<<|[&*!])/u.test(match[4] ?? '') || /[&*!]$/u.test(match[0])) {
86
+ return { kind: 'unsupported', missing: Object.keys(approvals) };
87
+ }
88
+ if (!indentSeen) {
89
+ indent = match[1] ?? indent;
90
+ indentSeen = true;
91
+ }
92
+ const key = match[2]?.replaceAll("''", "'") ?? match[3] ?? match[4] ?? '';
93
+ const name = allowBuildsPackageName(key);
94
+ (name === key.trim() ? listed : qualified).add(name);
95
+ }
96
+ }
97
+ else if (allowBuildsKey.test(content)) {
98
+ return { kind: 'unsupported', missing: Object.keys(approvals) };
99
+ }
100
+ const missing = Object.keys(approvals).filter((name) => !listed.has(name)).sort();
101
+ if (missing.length === 0) {
102
+ return { kind: 'covered' };
103
+ }
104
+ if (missing.some((name) => qualified.has(name))) {
105
+ // A bare entry would override the game's version-specific decision.
106
+ return { kind: 'unsupported', missing };
107
+ }
108
+ const entries = missing.map((name) => `${indent}${quoteKey(name)}: ${String(approvals[name])}`);
109
+ if (header !== -1) {
110
+ lines.splice(header + 1, 0, ...entries);
111
+ }
112
+ else {
113
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
114
+ lines.pop();
115
+ }
116
+ lines.push('allowBuilds:', ...entries, '');
117
+ }
118
+ const edited = lines.join(newline);
119
+ return {
120
+ kind: 'edited',
121
+ content: edited.endsWith(newline) ? edited : `${edited}${newline}`,
122
+ added: missing,
123
+ };
124
+ }
125
+ /** The `allowBuilds` block to print when the caller cannot edit the file itself. */
126
+ export function allowBuildsSnippet(approvals) {
127
+ return [
128
+ 'allowBuilds:',
129
+ ...Object.keys(approvals).sort().map((name) => ` ${quoteKey(name)}: ${String(approvals[name])}`),
130
+ ].join('\n');
131
+ }
132
+ /**
133
+ * The `pnpm-workspace.yaml` edit a notifications game needs before its
134
+ * install adds `firebase`: the new content when the file can be edited, or a
135
+ * warning with the entries to add when it cannot (no file at the game root,
136
+ * where a parent workspace may govern the install, or a form this edit does
137
+ * not parse).
138
+ */
139
+ export function planNotificationsBuildApprovals(workspace) {
140
+ const instruction = 'pnpm 11 stops installing firebase until the pnpm-workspace.yaml that '
141
+ + `installs the game lists its build scripts:\n${allowBuildsSnippet(notificationsBuildApprovals)}`;
142
+ if (workspace === undefined) {
143
+ return { warning: `The game has no pnpm-workspace.yaml; ${instruction}` };
144
+ }
145
+ const edit = addAllowBuilds(workspace, notificationsBuildApprovals);
146
+ if (edit.kind === 'edited') {
147
+ return { content: edit.content };
148
+ }
149
+ if (edit.kind === 'unsupported') {
150
+ return {
151
+ warning: 'pnpm-workspace.yaml writes allowBuilds in a form the initializer does not edit '
152
+ + '(a quoted key, a flow mapping, or a version-specific entry for one of these packages); '
153
+ + instruction,
154
+ };
155
+ }
156
+ return {};
157
+ }
@@ -1,4 +1,5 @@
1
1
  import { type PlatformVersionLedger, type PlatformVersionReleasePlan, type PlatformVersionTargetRequest } from '@mpgd/target-config';
2
+ import { type NativeDsymIdentity } from './native-build-symbols.js';
2
3
  export interface NativeReleaseReservationInput {
3
4
  readonly gameRoot: string;
4
5
  readonly releaseKey: string;
@@ -40,6 +41,44 @@ export interface ImmutableNativeBuildRecord {
40
41
  readonly inspectedAppId: string;
41
42
  readonly inspectedSignerSha256?: string | undefined;
42
43
  readonly inspectedTeamId?: string | undefined;
44
+ /** Android: the R8 `mapping.txt` of this bundle, when the build was minified. */
45
+ readonly r8Mapping?: RecordedSymbolFile;
46
+ /** iOS with Firebase Crashlytics: the archive's dSYMs and what uploads them. */
47
+ readonly crashlyticsSymbols?: RecordedCrashlyticsSymbols;
48
+ }
49
+ /** A game-relative file kept next to the recorded artifact, with its SHA-256. */
50
+ export interface RecordedSymbolFile {
51
+ readonly location: string;
52
+ readonly sha256: string;
53
+ }
54
+ export interface RecordedCrashlyticsSymbols {
55
+ /** Zip of the archive's `dSYMs` folder. */
56
+ readonly dsymArchive: RecordedSymbolFile;
57
+ /** Each dSYM bundle and its Mach-O UUIDs. */
58
+ readonly dsyms: readonly NativeDsymIdentity[];
59
+ /** Firebase's `upload-symbols` from the build's package checkout. */
60
+ readonly uploadTool: RecordedSymbolFile;
61
+ /** The `GoogleService-Info.plist` the archived app bundles. */
62
+ readonly googleServiceInfo: RecordedSymbolFile;
63
+ }
64
+ /** A symbol file to copy into the record: its bytes and its game-relative location. */
65
+ export interface RecordSymbolFileInput {
66
+ readonly file: string;
67
+ readonly location: string;
68
+ }
69
+ /**
70
+ * The Crashlytics dSYM upload of one recorded iOS build. It is mutable state
71
+ * next to the immutable build record: `failed` can be retried, `uploaded` is final.
72
+ */
73
+ export interface CrashSymbolUploadRecord {
74
+ readonly releaseKey: string;
75
+ readonly target: 'ios';
76
+ readonly buildRunId: string;
77
+ readonly dsymArchiveSha256: string;
78
+ readonly status: 'uploaded' | 'failed';
79
+ readonly updatedAt: string;
80
+ /** Short reason of the last failed attempt. */
81
+ readonly failure?: string;
43
82
  }
44
83
  export type NativeSubmissionStatus = 'started' | 'edit-open' | 'upload-committed' | 'uploaded' | 'processing' | 'testflight-ready' | 'action-required' | 'failed' | 'unknown' | 'committed';
45
84
  export declare function isNativeSubmissionSettled(status: NativeSubmissionStatus): boolean;
@@ -65,6 +104,8 @@ export interface NativeReleaseStatus {
65
104
  readonly plan: PlatformVersionReleasePlan;
66
105
  readonly builds: Partial<Record<'android' | 'ios', ImmutableNativeBuildRecord>>;
67
106
  readonly submissions: Partial<Record<'android' | 'ios', NativeSubmissionCheckpoint>>;
107
+ /** Crashlytics dSYM uploads of recorded iOS builds. */
108
+ readonly symbolUploads?: Partial<Record<'ios', CrashSymbolUploadRecord>>;
68
109
  readonly stateCommit: string;
69
110
  }
70
111
  export interface RecordNativeBuildInput {
@@ -87,6 +128,15 @@ export interface RecordNativeBuildInput {
87
128
  readonly inspectedAppId: string;
88
129
  readonly inspectedSignerSha256?: string | undefined;
89
130
  readonly inspectedTeamId?: string | undefined;
131
+ /** Android: the R8 mapping file the build wrote, already copied next to the artifact. */
132
+ readonly r8Mapping?: RecordSymbolFileInput;
133
+ /** iOS with Firebase Crashlytics, already copied next to the artifact. */
134
+ readonly crashlyticsSymbols?: {
135
+ readonly dsymArchive: RecordSymbolFileInput;
136
+ readonly dsyms: readonly NativeDsymIdentity[];
137
+ readonly uploadTool: RecordSymbolFileInput;
138
+ readonly googleServiceInfo: RecordSymbolFileInput;
139
+ };
90
140
  readonly environment?: NodeJS.ProcessEnv;
91
141
  readonly signal?: AbortSignal;
92
142
  }
@@ -142,7 +192,26 @@ export declare function releaseNativeSubmissionLease(input: {
142
192
  readonly environment?: NodeJS.ProcessEnv;
143
193
  readonly signal?: AbortSignal;
144
194
  }): Promise<NativeSubmissionCheckpoint>;
195
+ /**
196
+ * Record the outcome of a Crashlytics dSYM upload for one recorded iOS build.
197
+ * A failure can be recorded again and retried; once `uploaded`, it stays so.
198
+ */
199
+ export declare function recordCrashSymbolUpload(input: {
200
+ readonly gameRoot: string;
201
+ readonly gameId: string;
202
+ readonly releaseKey: string;
203
+ readonly buildRunId: string;
204
+ readonly dsymArchiveSha256: string;
205
+ readonly status: CrashSymbolUploadRecord['status'];
206
+ readonly failure?: string;
207
+ readonly environment?: NodeJS.ProcessEnv;
208
+ readonly signal?: AbortSignal;
209
+ }): Promise<CrashSymbolUploadRecord>;
145
210
  /** Exported for tests: the checkpoint status moves a store submission may make. */
146
211
  export declare function allowedSubmissionTransition(from: NativeSubmissionStatus, to: NativeSubmissionStatus): boolean;
147
212
  /** Validate persisted checkpoint shape before any remote mutation or recovery. */
148
213
  export declare function assertNativeSubmissionCheckpoint(value: unknown): asserts value is NativeSubmissionCheckpoint;
214
+ /** A game-relative location without `..` segments. */
215
+ export declare function isRecordedLocation(location: unknown): location is string;
216
+ /** Streamed SHA-256 of a file, hex. */
217
+ export declare function sha256File(file: string): Promise<string>;
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { isDeepStrictEqual } from 'node:util';
7
7
  import { allocatePlatformVersions, assertPlatformVersionLedger, createMpgdReleaseIdentity, formatMpgdReleaseId, } from '@mpgd/target-config';
8
8
  import { runReleaseProcess } from './deploy-process.js';
9
+ import { isDsymIdentityList } from './native-build-symbols.js';
9
10
  import { inspectAndroidBundleSigner } from './android-bundle-signer.js';
10
11
  export function isNativeSubmissionSettled(status) {
11
12
  return status === 'committed' || status === 'testflight-ready';
@@ -195,6 +196,7 @@ export async function reserveNativeRelease(input) {
195
196
  reservations: { ...(game?.reservations ?? {}), [input.releaseKey]: allocation.plan },
196
197
  builds: game?.builds ?? {},
197
198
  ...(game?.submissions === undefined ? {} : { submissions: game.submissions }),
199
+ ...(game?.symbolUploads === undefined ? {} : { symbolUploads: game.symbolUploads }),
198
200
  };
199
201
  const next = {
200
202
  schemaVersion: 1,
@@ -238,7 +240,26 @@ export async function recordNativeReleaseBuild(input) {
238
240
  || !statSync(input.releaseManifestFile).isFile()) {
239
241
  throw new Error('Native build record requires file artifacts.');
240
242
  }
241
- const artifactSha256 = await sha256(input.artifactFile);
243
+ if ((input.r8Mapping !== undefined && input.target !== 'android')
244
+ || (input.crashlyticsSymbols !== undefined && (input.target !== 'ios'
245
+ || !isDsymIdentityList(input.crashlyticsSymbols.dsyms)))) {
246
+ throw new Error('Native build symbols do not match the build target.');
247
+ }
248
+ const r8Mapping = input.r8Mapping === undefined
249
+ ? undefined
250
+ : await recordSymbolFile(input.r8Mapping);
251
+ const crashlyticsSymbols = input.crashlyticsSymbols === undefined
252
+ ? undefined
253
+ : {
254
+ dsymArchive: await recordSymbolFile(input.crashlyticsSymbols.dsymArchive),
255
+ dsyms: input.crashlyticsSymbols.dsyms.map((entry) => ({
256
+ bundle: entry.bundle,
257
+ uuids: [...entry.uuids],
258
+ })),
259
+ uploadTool: await recordSymbolFile(input.crashlyticsSymbols.uploadTool),
260
+ googleServiceInfo: await recordSymbolFile(input.crashlyticsSymbols.googleServiceInfo),
261
+ };
262
+ const artifactSha256 = await sha256File(input.artifactFile);
242
263
  const manifestBytes = readFileSync(input.releaseManifestFile);
243
264
  const releaseManifestSha256 = createHash('sha256').update(manifestBytes).digest('hex');
244
265
  if (artifactSha256 !== input.expectedArtifactSha256
@@ -331,6 +352,8 @@ export async function recordNativeReleaseBuild(input) {
331
352
  ...(input.target === 'android'
332
353
  ? { inspectedSignerSha256: signer }
333
354
  : { inspectedTeamId: input.inspectedTeamId ?? '' }),
355
+ ...(r8Mapping === undefined ? {} : { r8Mapping }),
356
+ ...(crashlyticsSymbols === undefined ? {} : { crashlyticsSymbols }),
334
357
  };
335
358
  const key = `${input.releaseKey}/${input.target}`;
336
359
  const previous = ownValue(game.builds, key);
@@ -377,6 +400,8 @@ export async function readNativeReleaseStatus(input) {
377
400
  ...(game.submissions?.[iosKey] === undefined
378
401
  ? {} : { ios: game.submissions[iosKey] }),
379
402
  },
403
+ symbolUploads: game.symbolUploads?.[iosKey] === undefined
404
+ ? {} : { ios: game.symbolUploads[iosKey] },
380
405
  stateCommit: session.previousCommit,
381
406
  };
382
407
  });
@@ -529,6 +554,57 @@ export async function releaseNativeSubmissionLease(input) {
529
554
  return checkpoint;
530
555
  });
531
556
  }
557
+ /**
558
+ * Record the outcome of a Crashlytics dSYM upload for one recorded iOS build.
559
+ * A failure can be recorded again and retried; once `uploaded`, it stays so.
560
+ */
561
+ export async function recordCrashSymbolUpload(input) {
562
+ assertReleaseKey(input.releaseKey);
563
+ const upload = {
564
+ releaseKey: input.releaseKey,
565
+ target: 'ios',
566
+ buildRunId: input.buildRunId,
567
+ dsymArchiveSha256: input.dsymArchiveSha256,
568
+ status: input.status,
569
+ updatedAt: new Date().toISOString(),
570
+ ...(input.status === 'failed' && input.failure !== undefined
571
+ ? { failure: boundedFailure(input.failure) } : {}),
572
+ };
573
+ assertCrashSymbolUploadRecord(upload);
574
+ return withStateSession(input, async (session) => {
575
+ const game = ownValue(session.state.games, input.gameId);
576
+ const key = `${input.releaseKey}/ios`;
577
+ const build = game === undefined ? undefined : ownValue(game.builds, key);
578
+ if (game === undefined || build === undefined
579
+ || build.buildRunId !== input.buildRunId
580
+ || build.crashlyticsSymbols?.dsymArchive.sha256 !== input.dsymArchiveSha256) {
581
+ throw new Error('A dSYM upload must reference the immutable iOS build record.');
582
+ }
583
+ const previous = ownValue(game.symbolUploads ?? {}, key);
584
+ if (previous?.status === 'uploaded') {
585
+ if (input.status === 'uploaded') {
586
+ return previous;
587
+ }
588
+ throw new Error('The dSYMs of this build were already uploaded.');
589
+ }
590
+ const next = {
591
+ schemaVersion: 1,
592
+ games: {
593
+ ...session.state.games,
594
+ [input.gameId]: {
595
+ ...game,
596
+ symbolUploads: { ...(game.symbolUploads ?? {}), [key]: upload },
597
+ },
598
+ },
599
+ };
600
+ await commitState(session, next, `Symbols ${input.gameId}/${key} ${input.status}`);
601
+ return upload;
602
+ });
603
+ }
604
+ function boundedFailure(text) {
605
+ const line = text.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim();
606
+ return line.length > 300 ? `${line.slice(0, 297)}...` : (line === '' ? 'failed' : line);
607
+ }
532
608
  /** Exported for tests: the checkpoint status moves a store submission may make. */
533
609
  export function allowedSubmissionTransition(from, to) {
534
610
  if (from === to) {
@@ -719,13 +795,14 @@ function parseReleaseState(json) {
719
795
  }
720
796
  const game = rawGame;
721
797
  if (Object.keys(game).some((key) => ![
722
- 'initialLedger', 'ledger', 'reservations', 'builds', 'submissions',
798
+ 'initialLedger', 'ledger', 'reservations', 'builds', 'submissions', 'symbolUploads',
723
799
  ].includes(key))
724
800
  || typeof game.reservations !== 'object' || game.reservations === null
725
801
  || Array.isArray(game.reservations)
726
802
  || typeof game.builds !== 'object' || game.builds === null
727
803
  || Array.isArray(game.builds)
728
- || (game.submissions !== undefined && !isRecord(game.submissions))) {
804
+ || (game.submissions !== undefined && !isRecord(game.submissions))
805
+ || (game.symbolUploads !== undefined && !isRecord(game.symbolUploads))) {
729
806
  throw new Error(`Release state game ${gameId} has invalid entries.`);
730
807
  }
731
808
  const initialLedger = assertPlatformVersionLedger(game.initialLedger);
@@ -789,6 +866,20 @@ function parseReleaseState(json) {
789
866
  throw new Error(`Release state game ${gameId} has an invalid submission ${key}.`);
790
867
  }
791
868
  }
869
+ for (const [key, rawUpload] of Object.entries(game.symbolUploads ?? {})) {
870
+ const build = game.builds[key];
871
+ try {
872
+ assertCrashSymbolUploadRecord(rawUpload);
873
+ }
874
+ catch {
875
+ throw new Error(`Release state game ${gameId} has an invalid dSYM upload ${key}.`);
876
+ }
877
+ if (key !== `${rawUpload.releaseKey}/ios` || build === undefined
878
+ || rawUpload.buildRunId !== build.buildRunId
879
+ || rawUpload.dsymArchiveSha256 !== build.crashlyticsSymbols?.dsymArchive.sha256) {
880
+ throw new Error(`Release state game ${gameId} has an invalid dSYM upload ${key}.`);
881
+ }
882
+ }
792
883
  }
793
884
  return value;
794
885
  }
@@ -813,6 +904,8 @@ const buildRecordFields = [
813
904
  'inspectedAppId',
814
905
  'inspectedSignerSha256',
815
906
  'inspectedTeamId',
907
+ 'r8Mapping',
908
+ 'crashlyticsSymbols',
816
909
  ];
817
910
  function assertStoredBuildRecord(gameId, buildKey, value, reservations) {
818
911
  const malformed = () => {
@@ -853,7 +946,11 @@ function assertStoredBuildRecord(gameId, buildKey, value, reservations) {
853
946
  || !sha256Pattern.test(String(record.inspectedSignerSha256))))
854
947
  || (record.target === 'ios' && (record.inspectedSignerSha256 !== undefined
855
948
  || !teamIdPattern.test(String(record.inspectedTeamId))))
856
- || !isRecord(record.platformVersion)) {
949
+ || !isRecord(record.platformVersion)
950
+ || (record.r8Mapping !== undefined
951
+ && (record.target !== 'android' || !isRecordedSymbolFile(record.r8Mapping)))
952
+ || (record.crashlyticsSymbols !== undefined
953
+ && (record.target !== 'ios' || !isRecordedCrashlyticsSymbols(record.crashlyticsSymbols)))) {
857
954
  malformed();
858
955
  }
859
956
  const plan = ownValue(reservations, record.releaseKey);
@@ -930,6 +1027,53 @@ export function assertNativeSubmissionCheckpoint(value) {
930
1027
  throw new Error('Native submission checkpoint is malformed.');
931
1028
  }
932
1029
  }
1030
+ const recordedLocationPattern = /^[A-Za-z0-9._-][A-Za-z0-9._ +-]*(?:\/[A-Za-z0-9._-][A-Za-z0-9._ +-]*)*$/u;
1031
+ /** A game-relative location without `..` segments. */
1032
+ export function isRecordedLocation(location) {
1033
+ return typeof location === 'string' && location.length <= 512
1034
+ && recordedLocationPattern.test(location)
1035
+ && !location.split('/').some((segment) => segment === '.' || segment === '..');
1036
+ }
1037
+ function isRecordedSymbolFile(value) {
1038
+ return isRecord(value) && Object.keys(value).every((key) => key === 'location' || key === 'sha256')
1039
+ && isRecordedLocation(value.location) && sha256Pattern.test(String(value.sha256));
1040
+ }
1041
+ function isRecordedCrashlyticsSymbols(value) {
1042
+ return isRecord(value)
1043
+ && Object.keys(value).every((key) => ['dsymArchive', 'dsyms', 'uploadTool', 'googleServiceInfo'].includes(key))
1044
+ && isRecordedSymbolFile(value.dsymArchive) && isRecordedSymbolFile(value.uploadTool)
1045
+ && isRecordedSymbolFile(value.googleServiceInfo) && isDsymIdentityList(value.dsyms);
1046
+ }
1047
+ const symbolUploadFields = [
1048
+ 'releaseKey',
1049
+ 'target',
1050
+ 'buildRunId',
1051
+ 'dsymArchiveSha256',
1052
+ 'status',
1053
+ 'updatedAt',
1054
+ 'failure',
1055
+ ];
1056
+ function assertCrashSymbolUploadRecord(value) {
1057
+ if (!isRecord(value)
1058
+ || Object.keys(value).some((key) => !symbolUploadFields.includes(key))
1059
+ || typeof value.releaseKey !== 'string' || !releaseKeyPattern.test(value.releaseKey)
1060
+ || value.target !== 'ios'
1061
+ || typeof value.buildRunId !== 'string' || value.buildRunId.trim() === ''
1062
+ || !sha256Pattern.test(String(value.dsymArchiveSha256))
1063
+ || (value.status !== 'uploaded' && value.status !== 'failed')
1064
+ || typeof value.updatedAt !== 'string' || !Number.isFinite(Date.parse(value.updatedAt))
1065
+ || (value.failure !== undefined && (value.status !== 'failed'
1066
+ || typeof value.failure !== 'string' || value.failure.length > 300))) {
1067
+ throw new Error('Crashlytics dSYM upload record is malformed.');
1068
+ }
1069
+ }
1070
+ async function recordSymbolFile(input) {
1071
+ if (!isRecordedLocation(input.location) || !existsSync(input.file)
1072
+ || !statSync(input.file).isFile() || statSync(input.file).size === 0) {
1073
+ throw new Error('Native build symbol file is missing or has an invalid location.');
1074
+ }
1075
+ return { location: input.location, sha256: await sha256File(input.file) };
1076
+ }
933
1077
  function assertReservationHistory(gameId, initialLedger, ledger, plans) {
934
1078
  if (plans.length === 0) {
935
1079
  throw new Error(`Release state game ${gameId} has no reservation history.`);
@@ -993,7 +1137,8 @@ function assertReleaseKey(key) {
993
1137
  throw new Error('Release key must be a lowercase, path-safe identifier.');
994
1138
  }
995
1139
  }
996
- async function sha256(file) {
1140
+ /** Streamed SHA-256 of a file, hex. */
1141
+ export async function sha256File(file) {
997
1142
  const hash = createHash('sha256');
998
1143
  for await (const chunk of createReadStream(file)) {
999
1144
  hash.update(chunk);
@@ -1,4 +1,5 @@
1
1
  import { type NativeDeploymentPlan } from './deploy-planning.js';
2
+ import { type NativeBuildSymbols } from './native-build-symbols.js';
2
3
  export interface PinnedReleaseInput {
3
4
  readonly gameRoot: string;
4
5
  readonly deploymentProfile: string;
@@ -21,6 +22,8 @@ export interface PinnedNativeBuildResult {
21
22
  readonly artifact: string;
22
23
  readonly releaseManifest: string;
23
24
  readonly runId: string;
25
+ /** Crash symbols of this build, as absolute paths inside the pinned game. */
26
+ readonly symbols?: NativeBuildSymbols;
24
27
  }
25
28
  /** Freeze a read-only deployment plan into the exact inputs used by a release run. */
26
29
  export declare function pinNativeDeploymentPlan(plan: NativeDeploymentPlan, kit: {
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
6
  import { planNativeDeployment } from './deploy-planning.js';
7
+ import { assertNativeBuildSymbols, isNativeOutputPath, } from './native-build-symbols.js';
7
8
  import { runReleaseProcess, } from './deploy-process.js';
8
9
  const gitShaPattern = /^[0-9a-f]{40}$/u;
9
10
  const sha256Pattern = /^[0-9a-f]{64}$/u;
@@ -287,7 +288,49 @@ export async function runPinnedNativeBuild(workspace, input) {
287
288
  || manifest.kitGitSha !== workspace.input.kitGitSha) {
288
289
  throw new Error('Pinned native build manifest provenance does not match its inputs.');
289
290
  }
290
- return { artifact, releaseManifest, runId: status.runId };
291
+ const symbols = status.symbols === undefined
292
+ ? undefined
293
+ : resolvePinnedBuildSymbols(workspace.gameRoot, input.target, status.symbols);
294
+ return {
295
+ artifact,
296
+ releaseManifest,
297
+ runId: status.runId,
298
+ ...(symbols === undefined ? {} : { symbols }),
299
+ };
300
+ }
301
+ /** Resolve the status file's symbol paths to regular files inside the pinned game. */
302
+ function resolvePinnedBuildSymbols(gameRoot, target, value) {
303
+ assertNativeBuildSymbols(value, (file) => isNativeOutputPath(file, target));
304
+ const canonicalGameRoot = realpathSync(gameRoot);
305
+ const resolve = (file) => {
306
+ const absolute = assertInsideWorkspace(gameRoot, file);
307
+ if (!existsSync(absolute) || !lstatSync(absolute).isFile()) {
308
+ throw new Error(`Pinned native build symbol file is missing: ${file}`);
309
+ }
310
+ const relative = path.relative(canonicalGameRoot, realpathSync(absolute));
311
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
312
+ throw new Error('Pinned native build symbol file resolves outside its workspace.');
313
+ }
314
+ return absolute;
315
+ };
316
+ if (value.r8Mapping !== undefined) {
317
+ if (target !== 'android') {
318
+ throw new Error('Only an Android build records an R8 mapping file.');
319
+ }
320
+ return { r8Mapping: resolve(value.r8Mapping) };
321
+ }
322
+ const crashlytics = value.crashlytics;
323
+ if (crashlytics === undefined || target !== 'ios') {
324
+ throw new Error('Only an iOS build records Crashlytics dSYMs.');
325
+ }
326
+ return {
327
+ crashlytics: {
328
+ dsymArchive: resolve(crashlytics.dsymArchive),
329
+ dsyms: crashlytics.dsyms,
330
+ uploadTool: resolve(crashlytics.uploadTool),
331
+ googleServiceInfo: resolve(crashlytics.googleServiceInfo),
332
+ },
333
+ };
291
334
  }
292
335
  function assertPinnedInput(input) {
293
336
  if (!gitShaPattern.test(input.gameGitSha) || !gitShaPattern.test(input.kitGitSha)