@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.
@@ -24,6 +24,8 @@ export interface RunNativeDeploymentInput {
24
24
  readonly signal?: AbortSignal;
25
25
  /** Called only after a newly built target has an immutable, committed build record. */
26
26
  readonly onBuildRecorded?: (target: NativeDeployTarget) => void;
27
+ /** Receives the Crashlytics dSYM upload outcome; defaults to `console.warn`. */
28
+ readonly onCrashSymbolsReport?: (message: string) => void;
27
29
  }
28
30
  /** Reserve once, build only missing targets, then submit the recorded binaries. */
29
31
  export declare function runNativeDeployment(input: RunNativeDeploymentInput): Promise<NativeReleaseStatus>;
@@ -4,10 +4,11 @@ import path from 'node:path';
4
4
  import { doctorNativeDeployment, readNativeDeployCredentialNames, readNativeDeployTargetProfile, } from './deploy-planning.js';
5
5
  import { withAndroidUploadSigningSession } from './android-signing-session.js';
6
6
  import { withIosSigningSession } from './ios-signing-session.js';
7
- import { assertSettledAndroidReleaseOptions, submitRecordedNativeTarget, } from './native-deploy-submission.js';
7
+ import { assertSettledAndroidReleaseOptions, submitRecordedNativeTarget, withRecordedR8Mapping, } from './native-deploy-submission.js';
8
8
  import { isNativeSubmissionSettled, readNativeReleaseStatus, recordNativeReleaseBuild, reserveNativeRelease, } from './release-state.js';
9
9
  import { installPinnedReleaseDependencies, pinNativeDeploymentPlan, runPinnedNativeBuild, withPinnedReleaseWorkspace, } from './release-workspace.js';
10
10
  import { inspectAndroidBundleSigner } from './android-bundle-signer.js';
11
+ import { uploadCrashSymbolsForDeployRun } from './crash-symbols-upload.js';
11
12
  import { assertPlayReleaseConfirmed, assertPlayReleaseRequest, } from './play-release-options.js';
12
13
  const gitAuthEnvironmentNames = ['GITHUB_TOKEN', 'GH_TOKEN', 'GIT_ASKPASS'];
13
14
  /** Reserve once, build only missing targets, then submit the recorded binaries. */
@@ -45,7 +46,7 @@ export async function runNativeDeployment(input) {
45
46
  }
46
47
  }
47
48
  // Refuse changed Play options for a committed release before any build work.
48
- assertSettledAndroidReleaseOptions(existing?.submissions.android, input.androidRelease);
49
+ assertSettledAndroidReleaseOptions(existing?.submissions.android, withRecordedR8Mapping(input.androidRelease, existing?.builds.android, input.plan.gameRoot));
49
50
  const pendingSteps = existing === undefined
50
51
  ? {
51
52
  buildTargets: input.plan.targets.map((entry) => entry.target),
@@ -123,20 +124,35 @@ export async function runNativeDeployment(input) {
123
124
  ...(input.signal === undefined ? {} : { signal: input.signal }),
124
125
  });
125
126
  }
127
+ // A --mapping-file that differs from the mapping recorded with the Android
128
+ // build is refused here, before any target (iOS may come first) is submitted.
129
+ status = await readNativeReleaseStatus({
130
+ gameRoot: input.plan.gameRoot,
131
+ gameId: input.gameId,
132
+ releaseKey: input.releaseKey,
133
+ environment,
134
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
135
+ });
136
+ withRecordedR8Mapping(input.androidRelease, status.builds.android, input.plan.gameRoot);
137
+ let fresh = true;
126
138
  for (const entry of input.plan.targets) {
127
- status = await readNativeReleaseStatus({
128
- gameRoot: input.plan.gameRoot,
129
- gameId: input.gameId,
130
- releaseKey: input.releaseKey,
131
- environment,
132
- ...(input.signal === undefined ? {} : { signal: input.signal }),
133
- });
139
+ // The status read above serves the first target; later ones re-read it.
140
+ if (!fresh) {
141
+ status = await readNativeReleaseStatus({
142
+ gameRoot: input.plan.gameRoot,
143
+ gameId: input.gameId,
144
+ releaseKey: input.releaseKey,
145
+ environment,
146
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
147
+ });
148
+ }
149
+ fresh = false;
134
150
  if (status.builds[entry.target] === undefined) {
135
151
  throw new Error(`${entry.target} has no verified build after the native build stage.`);
136
152
  }
137
153
  const submission = status.submissions[entry.target];
138
154
  if (submission !== undefined && isNativeSubmissionSettled(submission.status)) {
139
- assertSettledAndroidReleaseOptions(submission, input.androidRelease);
155
+ assertSettledAndroidReleaseOptions(submission, withRecordedR8Mapping(input.androidRelease, status.builds.android, input.plan.gameRoot));
140
156
  continue;
141
157
  }
142
158
  await submitRecordedNativeTarget({
@@ -156,6 +172,21 @@ export async function runNativeDeployment(input) {
156
172
  ...(input.signal === undefined ? {} : { signal: input.signal }),
157
173
  });
158
174
  }
175
+ // Crashlytics dSYMs of the recorded iOS build, once, only after every store
176
+ // submission went through (a failed or cancelled run reports its own error
177
+ // at once; the next run or `mpgd deploy symbols` uploads them). A slow upload
178
+ // never delays a submission, and the step never throws: a failure is
179
+ // reported and retryable.
180
+ if (input.plan.targets.some((entry) => entry.target === 'ios')
181
+ && input.signal?.aborted !== true) {
182
+ await uploadCrashSymbolsForDeployRun({
183
+ gameRoot: input.plan.gameRoot,
184
+ gameId: input.gameId,
185
+ releaseKey: input.releaseKey,
186
+ environment,
187
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
188
+ }, input.onCrashSymbolsReport);
189
+ }
159
190
  return readNativeReleaseStatus({
160
191
  gameRoot: input.plan.gameRoot,
161
192
  gameId: input.gameId,
@@ -232,6 +263,23 @@ async function buildAndRecordTarget(input, workspace, target, reserved, environm
232
263
  const manifestLocation = `.mpgd/releases/${input.releaseKey}/${target}-manifest-${sha256(built.releaseManifest)}.json`;
233
264
  const artifactFile = persistImmutableFile(input.plan.gameRoot, artifactLocation, built.artifact);
234
265
  const manifestFile = persistImmutableFile(input.plan.gameRoot, manifestLocation, built.releaseManifest);
266
+ // Crash symbols travel with the artifact (and the workflow's release bundle).
267
+ const persistSymbol = (source, name) => {
268
+ const location = `.mpgd/releases/${input.releaseKey}/${target}-${name.replace('{sha}', sha256(source))}`;
269
+ return { file: persistImmutableFile(input.plan.gameRoot, location, source), location };
270
+ };
271
+ const r8Mapping = built.symbols?.r8Mapping === undefined
272
+ ? undefined
273
+ : persistSymbol(built.symbols.r8Mapping, 'mapping-{sha}.txt');
274
+ const crashlytics = built.symbols?.crashlytics;
275
+ const crashlyticsSymbols = crashlytics === undefined
276
+ ? undefined
277
+ : {
278
+ dsymArchive: persistSymbol(crashlytics.dsymArchive, 'dsyms-{sha}.zip'),
279
+ dsyms: crashlytics.dsyms,
280
+ uploadTool: persistSymbol(crashlytics.uploadTool, 'upload-symbols-{sha}'),
281
+ googleServiceInfo: persistSymbol(crashlytics.googleServiceInfo, 'GoogleService-Info-{sha}.plist'),
282
+ };
235
283
  const configDigest = createHash('sha256').update(JSON.stringify({
236
284
  gameGitSha: workspace.input.gameGitSha,
237
285
  lockfileSha256: workspace.input.lockfileSha256,
@@ -268,6 +316,8 @@ async function buildAndRecordTarget(input, workspace, target, reserved, environm
268
316
  expectedReleaseManifestSha256: sha256(manifestFile),
269
317
  inspectedAppId: planned.appId,
270
318
  ...(target === 'android' ? { inspectedSignerSha256 } : { inspectedTeamId: signerOrTeam }),
319
+ ...(r8Mapping === undefined ? {} : { r8Mapping }),
320
+ ...(crashlyticsSymbols === undefined ? {} : { crashlyticsSymbols }),
271
321
  environment,
272
322
  ...(input.signal === undefined ? {} : { signal: input.signal }),
273
323
  });
@@ -50,6 +50,12 @@ export declare function isNativeDeployApproved(plan: Pick<NativeDeploymentPlan,
50
50
  * Google Play options must fail instead of being skipped as done.
51
51
  */
52
52
  export declare function assertSettledAndroidReleaseOptions(checkpoint: NativeSubmissionCheckpoint | undefined, androidRelease: PlayReleaseRequest | undefined): void;
53
+ /**
54
+ * The Google Play request with the R8 mapping recorded with the build: it is
55
+ * used when no `--mapping-file` is given, and an explicit one must have the
56
+ * same bytes. A build without a recorded mapping keeps the request as it is.
57
+ */
58
+ export declare function withRecordedR8Mapping(request: PlayReleaseRequest | undefined, record: Pick<ImmutableNativeBuildRecord, 'r8Mapping'> | undefined, gameRoot: string): PlayReleaseRequest | undefined;
53
59
  /** Resume exactly one verified build; never rebuild or allocate another version here. */
54
60
  export declare function submitRecordedNativeTarget(input: SubmitRecordedNativeTargetInput): Promise<NativeSubmissionCheckpoint>;
55
61
  /** Verify one recorded Android build and print its Play edit without any store call. */
@@ -57,3 +63,5 @@ export declare function planRecordedAndroidSubmission(input: Omit<SubmitRecorded
57
63
  export declare function submitRecordedNativeTargetWithPorts(input: SubmitRecordedNativeTargetInput, ports: NativeSubmissionPorts): Promise<NativeSubmissionCheckpoint>;
58
64
  /** Validate the workflow-restored manifest against the immutable release-state hash. */
59
65
  export declare function verifyRecordedReleaseManifest(gameRoot: string, record: Pick<ImmutableNativeBuildRecord, 'artifactLocation' | 'releaseManifestSha256' | 'target'>): void;
66
+ /** A recorded game-relative file, resolved without leaving the game directory. */
67
+ export declare function resolveRecordedArtifact(gameRoot: string, recordedLocation: string): string;
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
- import { readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { checkpointNativeSubmission, freshSubmissionLeaseExpiresAt, isNativeSubmissionSettled, readNativeReleaseStatus, reclaimNativeSubmission, releaseNativeSubmissionLease, } from './release-state.js';
5
5
  import { planVerifiedAndroidBundleSubmission, PlaySubmissionUncertainError, submitVerifiedAndroidBundle, } from './play-internal-submission.js';
@@ -33,6 +33,35 @@ export function assertSettledAndroidReleaseOptions(checkpoint, androidRelease) {
33
33
  + 'change it with "mpgd deploy rollout" or submit a new release.');
34
34
  }
35
35
  }
36
+ /**
37
+ * The Google Play request with the R8 mapping recorded with the build: it is
38
+ * used when no `--mapping-file` is given, and an explicit one must have the
39
+ * same bytes. A build without a recorded mapping keeps the request as it is.
40
+ */
41
+ export function withRecordedR8Mapping(request, record, gameRoot) {
42
+ const recorded = record?.r8Mapping;
43
+ if (recorded === undefined) {
44
+ return request;
45
+ }
46
+ if (request?.mappingSha256 !== undefined) {
47
+ if (request.mappingSha256 !== recorded.sha256) {
48
+ throw new Error(`--mapping-file differs from the R8 mapping recorded with this build (${recorded.location}, `
49
+ + `sha256 ${recorded.sha256}); omit it to use the recorded mapping.`);
50
+ }
51
+ return request;
52
+ }
53
+ // A missing file is reported when the submission hashes it; an existing one
54
+ // must not resolve outside the game through a symlink.
55
+ const candidate = path.resolve(gameRoot, recorded.location);
56
+ const mappingFile = existsSync(candidate)
57
+ ? resolveRecordedArtifact(gameRoot, recorded.location)
58
+ : candidate;
59
+ return {
60
+ ...(request ?? defaultPlayReleaseRequest),
61
+ mappingFile,
62
+ mappingSha256: recorded.sha256,
63
+ };
64
+ }
36
65
  /** Resume exactly one verified build; never rebuild or allocate another version here. */
37
66
  export async function submitRecordedNativeTarget(input) {
38
67
  return submitRecordedNativeTargetWithPorts(input, productionPorts);
@@ -43,11 +72,12 @@ export async function planRecordedAndroidSubmission(input, readStatus = readNati
43
72
  if (input.environment?.MPGD_VERIFY_RELEASE_MANIFEST === '1') {
44
73
  verifyRecordedReleaseManifest(input.plan.gameRoot, recorded.record);
45
74
  }
75
+ const release = withRecordedR8Mapping(input.androidRelease, recorded.record, input.plan.gameRoot);
46
76
  return planVerifiedAndroidBundleSubmission({
47
77
  record: recorded.record,
48
78
  aabFile: recorded.artifactFile,
49
79
  packageName: recorded.planned.appId,
50
- ...(input.androidRelease === undefined ? {} : { release: input.androidRelease }),
80
+ ...(release === undefined ? {} : { release }),
51
81
  ...(input.signal === undefined ? {} : { signal: input.signal }),
52
82
  });
53
83
  }
@@ -86,14 +116,13 @@ export async function submitRecordedNativeTargetWithPorts(input, ports) {
86
116
  if (planned === undefined || !input.approved) {
87
117
  throw new Error('Native store submission requires a planned target and explicit approval.');
88
118
  }
89
- const androidRelease = input.androidRelease ?? defaultPlayReleaseRequest;
90
119
  if (target === 'android') {
91
120
  // Refuse an invalid or unconfirmed production/staged request before the
92
121
  // release state is read or a checkpoint is written.
93
- assertPlayReleaseRequest(androidRelease);
94
- assertPlayReleaseConfirmed(androidRelease, input.confirmProduction === true);
122
+ const requested = input.androidRelease ?? defaultPlayReleaseRequest;
123
+ assertPlayReleaseRequest(requested);
124
+ assertPlayReleaseConfirmed(requested, input.confirmProduction === true);
95
125
  }
96
- const releaseDigest = target === 'android' ? playReleaseRequestDigest(androidRelease) : undefined;
97
126
  const stateInput = {
98
127
  gameRoot: input.plan.gameRoot,
99
128
  gameId: input.gameId,
@@ -101,6 +130,12 @@ export async function submitRecordedNativeTargetWithPorts(input, ports) {
101
130
  ...(input.signal === undefined ? {} : { signal: input.signal }),
102
131
  };
103
132
  const { record, artifactFile, status } = await readRecordedSubmission(input, target, ports.readStatus);
133
+ // The build's recorded R8 mapping goes with it, the same way --mapping-file does.
134
+ const effectiveAndroidRelease = target === 'android'
135
+ ? withRecordedR8Mapping(input.androidRelease, record, input.plan.gameRoot)
136
+ : undefined;
137
+ const androidRelease = effectiveAndroidRelease ?? defaultPlayReleaseRequest;
138
+ const releaseDigest = target === 'android' ? playReleaseRequestDigest(androidRelease) : undefined;
104
139
  let checkpoint = status.submissions[target];
105
140
  assertSettledAndroidReleaseOptions(checkpoint, androidRelease);
106
141
  // An attempt that never checkpointed a Play edit changed nothing remotely,
@@ -116,6 +151,12 @@ export async function submitRecordedNativeTargetWithPorts(input, ports) {
116
151
  if (checkpoint !== undefined && isNativeSubmissionSettled(checkpoint.status)) {
117
152
  return checkpoint;
118
153
  }
154
+ if (record.r8Mapping !== undefined && effectiveAndroidRelease?.mappingFile !== undefined
155
+ && !existsSync(effectiveAndroidRelease.mappingFile)) {
156
+ throw new Error(`The R8 mapping recorded with this build is missing: ${record.r8Mapping.location}. Restore `
157
+ + 'the release bundle (.mpgd/releases/<release>) next to the bundle, or pass the same file '
158
+ + 'with --mapping-file.');
159
+ }
119
160
  if (input.environment?.MPGD_VERIFY_RELEASE_MANIFEST === '1') {
120
161
  verifyRecordedReleaseManifest(input.plan.gameRoot, record);
121
162
  }
@@ -202,7 +243,7 @@ export async function submitRecordedNativeTargetWithPorts(input, ports) {
202
243
  onEditCreated: async (editId) => {
203
244
  await save({ status: 'edit-open', remoteEditId: editId });
204
245
  },
205
- ...(input.androidRelease === undefined ? {} : { release: input.androidRelease }),
246
+ ...(effectiveAndroidRelease === undefined ? {} : { release: effectiveAndroidRelease }),
206
247
  ...(input.confirmProduction === undefined ? {} : {
207
248
  confirmProduction: input.confirmProduction,
208
249
  }),
@@ -316,7 +357,8 @@ export function verifyRecordedReleaseManifest(gameRoot, record) {
316
357
  throw new Error('Restored release manifest differs from the immutable build record.');
317
358
  }
318
359
  }
319
- function resolveRecordedArtifact(gameRoot, recordedLocation) {
360
+ /** A recorded game-relative file, resolved without leaving the game directory. */
361
+ export function resolveRecordedArtifact(gameRoot, recordedLocation) {
320
362
  if (path.isAbsolute(recordedLocation)) {
321
363
  throw new Error('Native artifact location must be game-relative.');
322
364
  }
@@ -0,0 +1,59 @@
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 declare const notificationsBuildApprovals: Readonly<Record<string, boolean>>;
27
+ export type AllowBuildsEdit = {
28
+ readonly kind: 'covered';
29
+ } | {
30
+ readonly kind: 'edited';
31
+ readonly content: string;
32
+ readonly added: readonly string[];
33
+ } | {
34
+ readonly kind: 'unsupported';
35
+ readonly missing: readonly string[];
36
+ };
37
+ /** The package a matcher names: `name`, `name@range`, `@scope/name@range`, or `name@git-url`. */
38
+ export declare function allowBuildsPackageName(matcher: string): string;
39
+ /**
40
+ * Adds an explicit decision for each package in `approvals` that the
41
+ * `pnpm-workspace.yaml` content does not list yet, keeping every existing
42
+ * entry (a game's own decision wins). Only the block-mapping form the game
43
+ * template writes is edited; another form is reported as unsupported so the
44
+ * caller can print the entries instead of rewriting YAML it cannot parse.
45
+ */
46
+ export declare function addAllowBuilds(content: string, approvals: Readonly<Record<string, boolean>>): AllowBuildsEdit;
47
+ /** The `allowBuilds` block to print when the caller cannot edit the file itself. */
48
+ export declare function allowBuildsSnippet(approvals: Readonly<Record<string, boolean>>): string;
49
+ /**
50
+ * The `pnpm-workspace.yaml` edit a notifications game needs before its
51
+ * install adds `firebase`: the new content when the file can be edited, or a
52
+ * warning with the entries to add when it cannot (no file at the game root,
53
+ * where a parent workspace may govern the install, or a form this edit does
54
+ * not parse).
55
+ */
56
+ export declare function planNotificationsBuildApprovals(workspace: string | undefined): {
57
+ readonly content?: string;
58
+ readonly warning?: string;
59
+ };
@@ -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>;