@mpgd/cli 0.61.0 → 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.
- package/dist/capacitor-shell-starter.js +18 -1
- package/dist/crash-reporting-starter.d.ts +5 -3
- package/dist/crash-reporting-starter.js +10 -7
- package/dist/crash-symbols-upload.d.ts +67 -0
- package/dist/crash-symbols-upload.js +232 -0
- package/dist/index.js +48 -1
- package/dist/native-build-info.json +2 -2
- package/dist/native-build-symbols.d.ts +95 -0
- package/dist/native-build-symbols.js +261 -0
- package/dist/native-build-target.js +700 -428
- package/dist/native-deploy-run.d.ts +2 -0
- package/dist/native-deploy-run.js +60 -10
- package/dist/native-deploy-submission.d.ts +8 -0
- package/dist/native-deploy-submission.js +50 -8
- package/dist/pnpm-build-approvals.d.ts +59 -0
- package/dist/pnpm-build-approvals.js +157 -0
- package/dist/release-state.d.ts +69 -0
- package/dist/release-state.js +150 -5
- package/dist/release-workspace.d.ts +3 -0
- package/dist/release-workspace.js +44 -1
- package/package.json +11 -11
- package/templates/phaser-game/apps/target-ait/package.json +2 -2
- package/templates/phaser-game/package.json +1 -1
- package/templates/phaser-game/pnpm-workspace.yaml +2 -0
|
@@ -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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/release-state.d.ts
CHANGED
|
@@ -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>;
|