@mpgd/cli 0.61.1 → 0.62.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.
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/cli",
3
- "version": "0.61.1",
3
+ "version": "0.62.0",
4
4
  "description": "Gunshi CLI for mpgd-kit starter generation and target workflow orchestration.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -71,9 +71,9 @@
71
71
  "postcss": "8.5.28",
72
72
  "postcss-value-parser": "4.2.0",
73
73
  "@mpgd/catalog": "0.11.3",
74
- "@mpgd/platform": "0.32.0",
74
+ "@mpgd/phaser-assets": "0.9.1",
75
75
  "@mpgd/target-config": "0.24.1",
76
- "@mpgd/phaser-assets": "0.9.1"
76
+ "@mpgd/platform": "0.32.0"
77
77
  },
78
78
  "devDependencies": {
79
79
  "@types/node": "^24.0.0",
@@ -81,17 +81,17 @@
81
81
  "@types/yauzl": "3.4.0",
82
82
  "ttsc": "0.32.1",
83
83
  "typescript": "7.0.2",
84
- "@mpgd/adapter-ait": "0.21.2",
84
+ "@mpgd/adapter-ait": "0.21.3",
85
85
  "@mpgd/adapter-browser": "0.12.1",
86
- "@mpgd/adapter-devvit": "0.14.4",
87
- "@mpgd/adapter-verse8": "0.7.13",
88
- "@mpgd/analytics": "0.5.5",
89
- "@mpgd/adapter-capacitor": "0.16.0",
86
+ "@mpgd/adapter-capacitor": "0.16.1",
87
+ "@mpgd/adapter-verse8": "0.7.14",
90
88
  "@mpgd/bridge": "0.16.4",
91
- "@mpgd/capacitor-storekit": "0.4.2",
92
- "@mpgd/game-runtime": "0.8.6",
93
- "@mpgd/capacitor-play-billing": "0.4.2",
94
- "@mpgd/game-services": "0.30.2",
89
+ "@mpgd/capacitor-play-billing": "0.4.3",
90
+ "@mpgd/adapter-devvit": "0.14.5",
91
+ "@mpgd/capacitor-storekit": "0.4.3",
92
+ "@mpgd/analytics": "0.5.5",
93
+ "@mpgd/game-runtime": "0.8.7",
94
+ "@mpgd/game-services": "0.30.3",
95
95
  "@mpgd/i18n": "0.6.26",
96
96
  "@mpgd/lite-shell": "0.1.0"
97
97
  },
@@ -5,6 +5,8 @@ packages:
5
5
  - 'apps/*'
6
6
  __PNPM_WORKSPACE_KIT_PACKAGES__
7
7
  allowBuilds:
8
+ # Its postinstall only bakes in Firebase App Hosting config (FIREBASE_WEBAPP_CONFIG).
9
+ '@firebase/util': false
8
10
  '@sentry/cli': true
9
11
  '@swc/core': true
10
12
  esbuild: true