@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.
- 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 +12 -12
- package/templates/phaser-game/pnpm-workspace.yaml +2 -0
|
@@ -11,6 +11,7 @@ import { addIosAppResource, applyAndroidManifestAdId, applyAndroidManifestOrient
|
|
|
11
11
|
import { readIosPushWiringInput } from './native-shell-ios-push-files.js';
|
|
12
12
|
import { isNonPublicServiceHostname } from './production-target-readiness.js';
|
|
13
13
|
import { assertAndroidSettingsAppProject, assertAndroidSettingsNoAppRemap, assertIosReleaseInfoPlistExpansion, assertIosReleasePackagingSettings, assertIosReleaseProductName, assertNativeShellIdentity, hasAndroidDisplayNameResourceOverride, hasAndroidManifestSourceSetOverride, hasAndroidResourceSourceSetOverride, hasGradleIdentityMutation, hasGradleTaskAction, maskGradleStrings, readIosAppTargetId, readIosReleaseInfoPlist, stripGradleComments, } from './native-shell-identity.js';
|
|
14
|
+
import { planNotificationsBuildApprovals } from './pnpm-build-approvals.js';
|
|
14
15
|
const shellPath = 'apps/mobile-capacitor';
|
|
15
16
|
const webPath = `${shellPath}/www`;
|
|
16
17
|
const referenceShellPath = '${MPGD_KIT_PATH}/apps/mobile-capacitor';
|
|
@@ -500,6 +501,17 @@ export function planCapacitorShellStarter(input) {
|
|
|
500
501
|
});
|
|
501
502
|
}
|
|
502
503
|
requestedFiles.push(...gatewayFiles);
|
|
504
|
+
if (notifications !== undefined) {
|
|
505
|
+
// pnpm 11 fails an install on an unreviewed dependency build script, and
|
|
506
|
+
// the firebase package notifications add brings two.
|
|
507
|
+
const approvals = planNotificationsBuildApprovals(readExisting(safeDestination(gameRoot, 'pnpm-workspace.yaml')));
|
|
508
|
+
if (approvals.content !== undefined) {
|
|
509
|
+
requestedFiles.push({ path: 'pnpm-workspace.yaml', content: approvals.content });
|
|
510
|
+
}
|
|
511
|
+
if (approvals.warning !== undefined) {
|
|
512
|
+
warnings.push(approvals.warning);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
503
515
|
// The game bundle imports the plugin from its gateway file, so the game
|
|
504
516
|
// declares it too, at the shell's exact version; the materializer's game
|
|
505
517
|
// install picks it up.
|
|
@@ -668,7 +680,12 @@ export function materializeCapacitorShellStarter(plan, runner = { run: runComman
|
|
|
668
680
|
// workspace globs. Always retry its standalone install: a CLI shim alone
|
|
669
681
|
// does not prove that an interrupted dependency install completed.
|
|
670
682
|
runner.run('pnpm', ['install', '--no-frozen-lockfile'], plan.gameRoot);
|
|
671
|
-
runner.run('pnpm',
|
|
683
|
+
runner.run('pnpm',
|
|
684
|
+
// --ignore-workspace also drops every pnpm-workspace.yaml setting, so no
|
|
685
|
+
// allowBuilds entry can review a dependency build script here and pnpm 11
|
|
686
|
+
// fails on any (firebase brings two when the shell resolves it). The
|
|
687
|
+
// shell's packages need none: Gradle and Xcode build the native code.
|
|
688
|
+
['--dir', shellPath, 'install', '--ignore-workspace', '--ignore-scripts', '--no-frozen-lockfile'], plan.gameRoot);
|
|
672
689
|
for (const platform of plan.nativePlatformsToAdd) {
|
|
673
690
|
const generatedNow = !existsSync(path.join(plan.gameRoot, shellPath, platform));
|
|
674
691
|
if (generatedNow) {
|
|
@@ -14,9 +14,11 @@ export declare const crashlyticsGradlePluginVersion = "3.0.8";
|
|
|
14
14
|
/** The manual Android steps for a shell whose Gradle files the CLI does not rewrite. */
|
|
15
15
|
export declare const crashReportingAndroidSteps: readonly string[];
|
|
16
16
|
/**
|
|
17
|
-
* The iOS
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* The iOS dSYM note: the plugin captures crashes once `GoogleService-Info.plist`
|
|
18
|
+
* is in the App target, and readable native stack traces need the dSYMs
|
|
19
|
+
* uploaded. `mpgd deploy run` records and uploads them for the archives it
|
|
20
|
+
* builds; an archive made another way needs Firebase's run script or
|
|
21
|
+
* `upload-symbols` (docs/CRASH_REPORTING.md#symbols).
|
|
20
22
|
*/
|
|
21
23
|
export declare const crashReportingIosSteps: readonly string[];
|
|
22
24
|
export interface PlannedCrashReporting {
|
|
@@ -31,15 +31,18 @@ export const crashReportingAndroidSteps = Object.freeze([
|
|
|
31
31
|
`In android/app/build.gradle, add apply plugin: '${crashlyticsGradlePluginId}' right after apply plugin: 'com.google.gms.google-services', inside the same google-services.json check.`,
|
|
32
32
|
]);
|
|
33
33
|
/**
|
|
34
|
-
* The iOS
|
|
35
|
-
*
|
|
36
|
-
*
|
|
34
|
+
* The iOS dSYM note: the plugin captures crashes once `GoogleService-Info.plist`
|
|
35
|
+
* is in the App target, and readable native stack traces need the dSYMs
|
|
36
|
+
* uploaded. `mpgd deploy run` records and uploads them for the archives it
|
|
37
|
+
* builds; an archive made another way needs Firebase's run script or
|
|
38
|
+
* `upload-symbols` (docs/CRASH_REPORTING.md#symbols).
|
|
37
39
|
*/
|
|
38
40
|
export const crashReportingIosSteps = Object.freeze([
|
|
39
|
-
'
|
|
40
|
-
+ '
|
|
41
|
-
+ '
|
|
42
|
-
+ '
|
|
41
|
+
'dSYMs: mpgd deploy run uploads them for every App Store build it makes (retry with mpgd '
|
|
42
|
+
+ 'deploy symbols). For an archive made in Xcode or another pipeline, add the Crashlytics run '
|
|
43
|
+
+ 'script build phase ("${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/'
|
|
44
|
+
+ 'Crashlytics/run") as the last phase of the App target, or run upload-symbols after the '
|
|
45
|
+
+ 'archive; see docs/CRASH_REPORTING.md in mpgd-kit.',
|
|
43
46
|
]);
|
|
44
47
|
/**
|
|
45
48
|
* Plan the crash-reporting provider before anything is written. Refused when
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type ReleaseProcessInput, type ReleaseProcessResult } from './deploy-process.js';
|
|
2
|
+
import type { NativeDsymIdentity } from './native-build-symbols.js';
|
|
3
|
+
import { readNativeReleaseStatus, recordCrashSymbolUpload, type CrashSymbolUploadRecord } from './release-state.js';
|
|
4
|
+
export interface CrashSymbolUploadPlan {
|
|
5
|
+
readonly releaseKey: string;
|
|
6
|
+
readonly buildRunId: string;
|
|
7
|
+
readonly dsymArchiveSha256: string;
|
|
8
|
+
readonly dsyms: readonly NativeDsymIdentity[];
|
|
9
|
+
/** `upload-symbols` and its arguments, with the recorded (game-relative) files. */
|
|
10
|
+
readonly command: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
export type CrashSymbolUploadResult = {
|
|
13
|
+
readonly status: 'not-applicable';
|
|
14
|
+
readonly reason: string;
|
|
15
|
+
} | {
|
|
16
|
+
readonly status: 'already-uploaded';
|
|
17
|
+
readonly upload: CrashSymbolUploadRecord;
|
|
18
|
+
} | {
|
|
19
|
+
readonly status: 'planned';
|
|
20
|
+
readonly plan: CrashSymbolUploadPlan;
|
|
21
|
+
} | {
|
|
22
|
+
readonly status: 'uploaded';
|
|
23
|
+
readonly plan: CrashSymbolUploadPlan;
|
|
24
|
+
/** Absent when the upload succeeded but its outcome could not be recorded. */
|
|
25
|
+
readonly upload?: CrashSymbolUploadRecord;
|
|
26
|
+
/** Why the successful upload could not be recorded; a retry uploads again. */
|
|
27
|
+
readonly recordError?: string;
|
|
28
|
+
readonly output: string;
|
|
29
|
+
} | {
|
|
30
|
+
readonly status: 'failed';
|
|
31
|
+
readonly plan: CrashSymbolUploadPlan;
|
|
32
|
+
readonly error: string;
|
|
33
|
+
readonly output: string;
|
|
34
|
+
/** Absent when the failure itself could not be recorded. */
|
|
35
|
+
readonly upload?: CrashSymbolUploadRecord;
|
|
36
|
+
};
|
|
37
|
+
export interface UploadRecordedCrashSymbolsInput {
|
|
38
|
+
readonly gameRoot: string;
|
|
39
|
+
readonly gameId: string;
|
|
40
|
+
readonly releaseKey: string;
|
|
41
|
+
/** Print the planned upload; run nothing and record nothing. */
|
|
42
|
+
readonly dryRun?: boolean;
|
|
43
|
+
readonly environment?: NodeJS.ProcessEnv;
|
|
44
|
+
readonly signal?: AbortSignal;
|
|
45
|
+
}
|
|
46
|
+
/** Injection boundary for tests; production uses the release state and a child process. */
|
|
47
|
+
export interface CrashSymbolUploadPorts {
|
|
48
|
+
readonly readStatus: typeof readNativeReleaseStatus;
|
|
49
|
+
readonly recordUpload: typeof recordCrashSymbolUpload;
|
|
50
|
+
readonly run: (input: ReleaseProcessInput) => Promise<ReleaseProcessResult>;
|
|
51
|
+
readonly platform: NodeJS.Platform;
|
|
52
|
+
}
|
|
53
|
+
/** Upload (or, with `dryRun`, plan) the Crashlytics dSYMs of one recorded iOS build. */
|
|
54
|
+
export declare function uploadRecordedCrashSymbols(input: UploadRecordedCrashSymbolsInput, ports?: CrashSymbolUploadPorts): Promise<CrashSymbolUploadResult>;
|
|
55
|
+
/** The command that retries a failed upload for one release, for the game directory used. */
|
|
56
|
+
export declare function crashSymbolsRetryCommand(input: {
|
|
57
|
+
readonly gameRoot: string;
|
|
58
|
+
readonly gameId: string;
|
|
59
|
+
readonly releaseKey: string;
|
|
60
|
+
}): string;
|
|
61
|
+
/**
|
|
62
|
+
* The `mpgd deploy run` step: upload the recorded dSYMs of the release's iOS
|
|
63
|
+
* build once. A failure (or an error reading the files or the state) is
|
|
64
|
+
* reported with the retry command and never thrown, so it cannot fail or
|
|
65
|
+
* block the store submission.
|
|
66
|
+
*/
|
|
67
|
+
export declare function uploadCrashSymbolsForDeployRun(input: UploadRecordedCrashSymbolsInput, report?: (message: string) => void, upload?: typeof uploadRecordedCrashSymbols): Promise<CrashSymbolUploadResult | undefined>;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { chmodSync, copyFileSync, existsSync, mkdtempSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { ReleaseProcessError, runReleaseProcess, } from './deploy-process.js';
|
|
5
|
+
import { resolveRecordedArtifact } from './native-deploy-submission.js';
|
|
6
|
+
import { readNativeReleaseStatus, recordCrashSymbolUpload, sha256File, } from './release-state.js';
|
|
7
|
+
// Uploads the dSYMs recorded with an iOS build to Firebase Crashlytics with
|
|
8
|
+
// Firebase's own `upload-symbols` (the copy recorded from the build's package
|
|
9
|
+
// checkout) and the `GoogleService-Info.plist` the archived app bundles:
|
|
10
|
+
//
|
|
11
|
+
// upload-symbols -gsp GoogleService-Info.plist -p ios dSYMs.zip
|
|
12
|
+
//
|
|
13
|
+
// Firebase documents this command for uploading dSYMs outside the Xcode run
|
|
14
|
+
// script (https://firebase.google.com/docs/crashlytics/ios/get-deobfuscated-reports);
|
|
15
|
+
// the tool's usage text accepts "zip archives containing dSYMs". The outcome is
|
|
16
|
+
// recorded next to the immutable build record, so an upload is done once and a
|
|
17
|
+
// failed one can be retried with `mpgd deploy symbols`. It never touches a store
|
|
18
|
+
// submission.
|
|
19
|
+
const uploadTimeoutMs = 10 * 60_000;
|
|
20
|
+
const productionPorts = {
|
|
21
|
+
readStatus: readNativeReleaseStatus,
|
|
22
|
+
recordUpload: recordCrashSymbolUpload,
|
|
23
|
+
run: runReleaseProcess,
|
|
24
|
+
platform: process.platform,
|
|
25
|
+
};
|
|
26
|
+
/** Upload (or, with `dryRun`, plan) the Crashlytics dSYMs of one recorded iOS build. */
|
|
27
|
+
export async function uploadRecordedCrashSymbols(input, ports = productionPorts) {
|
|
28
|
+
const stateInput = {
|
|
29
|
+
gameRoot: input.gameRoot,
|
|
30
|
+
gameId: input.gameId,
|
|
31
|
+
releaseKey: input.releaseKey,
|
|
32
|
+
...(input.environment === undefined ? {} : { environment: input.environment }),
|
|
33
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
34
|
+
};
|
|
35
|
+
const status = await ports.readStatus(stateInput);
|
|
36
|
+
const build = status.builds.ios;
|
|
37
|
+
if (build === undefined) {
|
|
38
|
+
throw new Error('This release has no recorded iOS build.');
|
|
39
|
+
}
|
|
40
|
+
const symbols = build.crashlyticsSymbols;
|
|
41
|
+
if (symbols === undefined) {
|
|
42
|
+
return {
|
|
43
|
+
status: 'not-applicable',
|
|
44
|
+
reason: 'The iOS build recorded no Crashlytics dSYMs: crash reporting is off, or the build '
|
|
45
|
+
+ 'was recorded before deploy run recorded dSYMs (upload those by hand, see '
|
|
46
|
+
+ 'docs/CRASH_REPORTING.md).',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const previous = status.symbolUploads?.ios;
|
|
50
|
+
if (previous?.status === 'uploaded' && previous.buildRunId === build.buildRunId
|
|
51
|
+
&& previous.dsymArchiveSha256 === symbols.dsymArchive.sha256) {
|
|
52
|
+
return { status: 'already-uploaded', upload: previous };
|
|
53
|
+
}
|
|
54
|
+
const plan = {
|
|
55
|
+
releaseKey: input.releaseKey,
|
|
56
|
+
buildRunId: build.buildRunId,
|
|
57
|
+
dsymArchiveSha256: symbols.dsymArchive.sha256,
|
|
58
|
+
dsyms: symbols.dsyms,
|
|
59
|
+
command: [
|
|
60
|
+
symbols.uploadTool.location,
|
|
61
|
+
'-gsp',
|
|
62
|
+
symbols.googleServiceInfo.location,
|
|
63
|
+
'-p',
|
|
64
|
+
'ios',
|
|
65
|
+
symbols.dsymArchive.location,
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
// The recorded files must be the build's bytes, also for a dry run.
|
|
69
|
+
const tool = await verifiedRecordedFile(input.gameRoot, symbols.uploadTool, 'upload-symbols');
|
|
70
|
+
const plist = await verifiedRecordedFile(input.gameRoot, symbols.googleServiceInfo, 'GoogleService-Info.plist');
|
|
71
|
+
const archive = await verifiedRecordedFile(input.gameRoot, symbols.dsymArchive, 'dSYM archive');
|
|
72
|
+
if (input.dryRun === true) {
|
|
73
|
+
return { status: 'planned', plan };
|
|
74
|
+
}
|
|
75
|
+
if (ports.platform !== 'darwin') {
|
|
76
|
+
throw new Error('Firebase upload-symbols is a macOS tool; upload the dSYMs from a Mac.');
|
|
77
|
+
}
|
|
78
|
+
const record = async (outcome, failure) => ports.recordUpload({
|
|
79
|
+
...stateInput,
|
|
80
|
+
buildRunId: build.buildRunId,
|
|
81
|
+
dsymArchiveSha256: symbols.dsymArchive.sha256,
|
|
82
|
+
status: outcome,
|
|
83
|
+
...(failure === undefined ? {} : { failure }),
|
|
84
|
+
});
|
|
85
|
+
// Run a private executable copy: a restored workflow artifact loses its mode
|
|
86
|
+
// bits, and the recorded file itself stays untouched.
|
|
87
|
+
const workDirectory = mkdtempSync(path.join(tmpdir(), 'mpgd-crash-symbols-'));
|
|
88
|
+
let output = '';
|
|
89
|
+
try {
|
|
90
|
+
const executable = path.join(workDirectory, 'upload-symbols');
|
|
91
|
+
copyFileSync(tool, executable);
|
|
92
|
+
chmodSync(executable, 0o700);
|
|
93
|
+
if (await sha256File(executable) !== symbols.uploadTool.sha256) {
|
|
94
|
+
throw new Error('The copied upload-symbols differs from the recorded tool.');
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const result = await ports.run({
|
|
98
|
+
command: executable,
|
|
99
|
+
args: ['-gsp', plist, '-p', 'ios', archive],
|
|
100
|
+
cwd: workDirectory,
|
|
101
|
+
environment: uploadEnvironment(input.environment ?? process.env, workDirectory),
|
|
102
|
+
timeoutMs: uploadTimeoutMs,
|
|
103
|
+
signal: input.signal,
|
|
104
|
+
});
|
|
105
|
+
output = result.output;
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
output = error instanceof ReleaseProcessError ? error.output : '';
|
|
109
|
+
const reason = describeFailure(error);
|
|
110
|
+
let upload;
|
|
111
|
+
try {
|
|
112
|
+
upload = await record('failed', reason);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
upload = undefined;
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
status: 'failed',
|
|
119
|
+
plan,
|
|
120
|
+
error: reason,
|
|
121
|
+
output,
|
|
122
|
+
...(upload === undefined ? {} : { upload }),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
rmSync(workDirectory, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
return { status: 'uploaded', plan, upload: await record('uploaded'), output };
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
// The dSYMs reached Crashlytics; only the release state is behind.
|
|
134
|
+
return {
|
|
135
|
+
status: 'uploaded',
|
|
136
|
+
plan,
|
|
137
|
+
recordError: error instanceof Error ? error.message : String(error),
|
|
138
|
+
output,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Only what the tool needs (and network proxy settings): no store, signing or Git credentials. */
|
|
143
|
+
function uploadEnvironment(source, temporary) {
|
|
144
|
+
const environment = { TMPDIR: temporary };
|
|
145
|
+
for (const name of [
|
|
146
|
+
'PATH',
|
|
147
|
+
'HOME',
|
|
148
|
+
'USER',
|
|
149
|
+
'LANG',
|
|
150
|
+
'LC_ALL',
|
|
151
|
+
'HTTPS_PROXY',
|
|
152
|
+
'https_proxy',
|
|
153
|
+
'HTTP_PROXY',
|
|
154
|
+
'http_proxy',
|
|
155
|
+
'NO_PROXY',
|
|
156
|
+
'no_proxy',
|
|
157
|
+
'SSL_CERT_FILE',
|
|
158
|
+
]) {
|
|
159
|
+
const value = source[name];
|
|
160
|
+
if (value !== undefined) {
|
|
161
|
+
environment[name] = value;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return environment;
|
|
165
|
+
}
|
|
166
|
+
function describeFailure(error) {
|
|
167
|
+
if (error instanceof ReleaseProcessError) {
|
|
168
|
+
if (error.reason === 'exit') {
|
|
169
|
+
return `upload-symbols exited with code ${String(error.exitCode ?? 'unknown')}`;
|
|
170
|
+
}
|
|
171
|
+
return error.reason === 'timeout'
|
|
172
|
+
? 'upload-symbols timed out'
|
|
173
|
+
: `upload-symbols ${error.reason === 'spawn' ? 'could not start' : 'was aborted'}`;
|
|
174
|
+
}
|
|
175
|
+
return error instanceof Error ? error.message : String(error);
|
|
176
|
+
}
|
|
177
|
+
async function verifiedRecordedFile(gameRoot, recorded, label) {
|
|
178
|
+
const candidate = path.resolve(gameRoot, recorded.location);
|
|
179
|
+
if (!existsSync(candidate) || !statSync(candidate).isFile()) {
|
|
180
|
+
throw new Error(`The recorded ${label} is missing: ${recorded.location}. Restore the release bundle `
|
|
181
|
+
+ '(.mpgd/releases/<release>) the build wrote.');
|
|
182
|
+
}
|
|
183
|
+
let file;
|
|
184
|
+
try {
|
|
185
|
+
file = resolveRecordedArtifact(gameRoot, recorded.location);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
throw new Error(`The recorded ${label} resolves outside the game directory.`);
|
|
189
|
+
}
|
|
190
|
+
if (await sha256File(file) !== recorded.sha256) {
|
|
191
|
+
throw new Error(`The recorded ${label} differs from the immutable build record.`);
|
|
192
|
+
}
|
|
193
|
+
return file;
|
|
194
|
+
}
|
|
195
|
+
/** The command that retries a failed upload for one release, for the game directory used. */
|
|
196
|
+
export function crashSymbolsRetryCommand(input) {
|
|
197
|
+
return `mpgd deploy symbols --game ${shellWord(input.gameRoot)} --game-id ${shellWord(input.gameId)} --release ${shellWord(input.releaseKey)}`;
|
|
198
|
+
}
|
|
199
|
+
function shellWord(value) {
|
|
200
|
+
return /^[A-Za-z0-9_./:@%+=,-]+$/u.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The `mpgd deploy run` step: upload the recorded dSYMs of the release's iOS
|
|
204
|
+
* build once. A failure (or an error reading the files or the state) is
|
|
205
|
+
* reported with the retry command and never thrown, so it cannot fail or
|
|
206
|
+
* block the store submission.
|
|
207
|
+
*/
|
|
208
|
+
export async function uploadCrashSymbolsForDeployRun(input, report = (message) => console.warn(message), upload = uploadRecordedCrashSymbols) {
|
|
209
|
+
const retry = crashSymbolsRetryCommand(input);
|
|
210
|
+
try {
|
|
211
|
+
const result = await upload(input);
|
|
212
|
+
if (result.status === 'uploaded' && result.recordError !== undefined) {
|
|
213
|
+
report(`warning: Crashlytics: uploaded the iOS dSYMs, but the release state could not record `
|
|
214
|
+
+ `it (${result.recordError}); "mpgd deploy status" still shows it as not uploaded, and `
|
|
215
|
+
+ `${retry} would upload them again.`);
|
|
216
|
+
}
|
|
217
|
+
else if (result.status === 'uploaded') {
|
|
218
|
+
report(`Crashlytics: uploaded the iOS dSYMs (${result.plan.dsyms
|
|
219
|
+
.map((entry) => entry.bundle).join(', ')}).`);
|
|
220
|
+
}
|
|
221
|
+
else if (result.status === 'failed') {
|
|
222
|
+
report(`warning: Crashlytics dSYM upload failed (${result.error}); the store submission `
|
|
223
|
+
+ `is not affected. Retry with: ${retry}${result.output === '' ? '' : `\n${result.output}`}`);
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
report(`warning: Crashlytics dSYM upload did not run (${error instanceof Error ? error.message : String(error)}); the store submission is not `
|
|
229
|
+
+ `affected. Retry with: ${retry}`);
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -38,6 +38,7 @@ import { createPlayReleaseRequest, parsePlayUserFraction, readPlayReleaseNotesFi
|
|
|
38
38
|
import { promotePlayRelease } from './play-release-promote.js';
|
|
39
39
|
import { changePlayRollout } from './play-release-rollout.js';
|
|
40
40
|
import { readNativeReleaseStatus as readNativeReleaseStatusForCli } from './release-state.js';
|
|
41
|
+
import { uploadRecordedCrashSymbols } from './crash-symbols-upload.js';
|
|
41
42
|
export { renderGameAcceptanceMarkdown, resolveGameAcceptanceReleaseManifestFile, runGameAcceptance, defaultGameAcceptanceCommandTimeoutMs, maximumGameplayE2EReportBytes, } from './game-acceptance.js';
|
|
42
43
|
export { maximumPerformanceEvidenceBytes, preparePerformanceAcceptance, readPerformanceAcceptanceEvidence, validatePerformanceBudget, } from './performance-acceptance.js';
|
|
43
44
|
export { applyCapacitorShellStarter, materializeCapacitorShellStarter, planCapacitorShellStarter, } from './capacitor-shell-starter.js';
|
|
@@ -439,6 +440,42 @@ const deployCommand = defineI18n({
|
|
|
439
440
|
console.info(JSON.stringify(status, null, 2));
|
|
440
441
|
},
|
|
441
442
|
}),
|
|
443
|
+
symbols: defineI18n({
|
|
444
|
+
name: 'symbols',
|
|
445
|
+
description: "Upload the Crashlytics dSYMs recorded with a release's iOS build.",
|
|
446
|
+
resource: commandResource({
|
|
447
|
+
en: "Upload the Crashlytics dSYMs recorded with a release's iOS build.",
|
|
448
|
+
ko: '릴리스의 iOS 빌드에 기록된 Crashlytics dSYM을 업로드합니다.',
|
|
449
|
+
}),
|
|
450
|
+
args: {
|
|
451
|
+
game: { type: 'string', required: false, description: 'Game project directory holding .mpgd/releases.' },
|
|
452
|
+
'game-id': { type: 'string', required: true, description: 'Explicit release ledger game ID.' },
|
|
453
|
+
release: { type: 'string', required: true, description: 'Explicit release ID.' },
|
|
454
|
+
'dry-run': {
|
|
455
|
+
type: 'boolean', required: false,
|
|
456
|
+
description: 'Verify the recorded files and print the planned upload-symbols command; run nothing.',
|
|
457
|
+
},
|
|
458
|
+
},
|
|
459
|
+
run: async (ctx) => {
|
|
460
|
+
const result = await uploadRecordedCrashSymbols({
|
|
461
|
+
gameRoot: path.resolve(readOptionalString(ctx.values.game) ?? '.'),
|
|
462
|
+
gameId: readRequiredCliOption(ctx.values['game-id'], '--game-id'),
|
|
463
|
+
releaseKey: readRequiredCliOption(ctx.values.release, '--release'),
|
|
464
|
+
dryRun: ctx.values['dry-run'] === true,
|
|
465
|
+
});
|
|
466
|
+
const { output, ...summary } = 'output' in result ? result : { ...result, output: '' };
|
|
467
|
+
if (output !== '') {
|
|
468
|
+
console.info(output);
|
|
469
|
+
}
|
|
470
|
+
console.info(JSON.stringify(summary, null, 2));
|
|
471
|
+
if (result.status === 'failed') {
|
|
472
|
+
throw new Error(`Crashlytics dSYM upload failed: ${result.error}`);
|
|
473
|
+
}
|
|
474
|
+
if (result.status === 'uploaded' && result.recordError !== undefined) {
|
|
475
|
+
throw new Error(`The dSYMs were uploaded, but the release state could not record it: ${result.recordError}`);
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
}),
|
|
442
479
|
submit: defineI18n({
|
|
443
480
|
name: 'submit',
|
|
444
481
|
description: 'Resume store submission of one already verified native build.',
|
|
@@ -1891,7 +1928,17 @@ const targetCommand = defineI18n({
|
|
|
1891
1928
|
}),
|
|
1892
1929
|
});
|
|
1893
1930
|
if (!dryRun) {
|
|
1894
|
-
|
|
1931
|
+
try {
|
|
1932
|
+
materializeCapacitorShellStarter(plan);
|
|
1933
|
+
}
|
|
1934
|
+
catch (error) {
|
|
1935
|
+
// A failed install may be the one the warnings explain (for example
|
|
1936
|
+
// the allowBuilds entries an unsupported pnpm-workspace.yaml needs).
|
|
1937
|
+
for (const warning of plan.warnings) {
|
|
1938
|
+
console.warn(`Warning: ${warning}`);
|
|
1939
|
+
}
|
|
1940
|
+
throw error;
|
|
1941
|
+
}
|
|
1895
1942
|
}
|
|
1896
1943
|
console.info(`${dryRun ? 'Would update' : 'Updated'} game-owned Capacitor shell: ${plan.changedFiles.length} file(s).`);
|
|
1897
1944
|
for (const file of plan.changedFiles) {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Game-relative paths, under the build's `release-output/native/<target>/` root. */
|
|
2
|
+
export interface NativeBuildSymbols {
|
|
3
|
+
/** Android: R8 `mapping.txt` of this exact bundle. */
|
|
4
|
+
readonly r8Mapping?: string;
|
|
5
|
+
/** iOS with Firebase Crashlytics. */
|
|
6
|
+
readonly crashlytics?: NativeBuildCrashlyticsSymbols;
|
|
7
|
+
}
|
|
8
|
+
export interface NativeBuildCrashlyticsSymbols {
|
|
9
|
+
/** Zip of the archive's `dSYMs` folder. */
|
|
10
|
+
readonly dsymArchive: string;
|
|
11
|
+
/** Each dSYM bundle in the archive and the Mach-O UUIDs of its DWARF files. */
|
|
12
|
+
readonly dsyms: readonly NativeDsymIdentity[];
|
|
13
|
+
/** Firebase's `upload-symbols` from the shell's package checkout. */
|
|
14
|
+
readonly uploadTool: string;
|
|
15
|
+
/** The `GoogleService-Info.plist` the archived app bundles. */
|
|
16
|
+
readonly googleServiceInfo: string;
|
|
17
|
+
}
|
|
18
|
+
export interface NativeDsymIdentity {
|
|
19
|
+
readonly bundle: string;
|
|
20
|
+
readonly uuids: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Validate a symbols record read from a build status file. `isPath` checks
|
|
24
|
+
* each recorded path (game-relative, inside the build's output root).
|
|
25
|
+
*/
|
|
26
|
+
export declare function assertNativeBuildSymbols(value: unknown, isPath: (file: string) => boolean): asserts value is NativeBuildSymbols;
|
|
27
|
+
/** A non-empty, sorted list of dSYM bundles with their UUIDs. */
|
|
28
|
+
export declare function isDsymIdentityList(value: unknown): value is readonly NativeDsymIdentity[];
|
|
29
|
+
/** A dSYM bundle file name: no path separator or control character, not hidden. */
|
|
30
|
+
export declare function isDsymBundleName(name: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* A game-relative path under the target's native build output root
|
|
33
|
+
* (`release-output/native/<target>/`), without empty or `..` segments.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isNativeOutputPath(file: string, target: string): boolean;
|
|
36
|
+
/** Where AGP writes the release variant's R8 mapping file. */
|
|
37
|
+
export declare const androidReleaseMappingPath = "app/build/outputs/mapping/release/mapping.txt";
|
|
38
|
+
/** File names inside the build's `symbols/` directory. */
|
|
39
|
+
export declare const nativeSymbolFileNames: Readonly<{
|
|
40
|
+
r8Mapping: "mapping.txt";
|
|
41
|
+
dsymArchive: "dSYMs.zip";
|
|
42
|
+
uploadTool: "upload-symbols";
|
|
43
|
+
googleServiceInfo: "GoogleService-Info.plist";
|
|
44
|
+
}>;
|
|
45
|
+
/**
|
|
46
|
+
* Where a Crashlytics-enabled iOS archive puts its Swift Package checkouts
|
|
47
|
+
* (`xcodebuild -clonedSourcePackagesDirPath`), so `upload-symbols` is found
|
|
48
|
+
* without searching DerivedData.
|
|
49
|
+
*/
|
|
50
|
+
export declare const iosCrashlyticsSourcePackagesDirectory = ".mpgd-source-packages";
|
|
51
|
+
/**
|
|
52
|
+
* `upload-symbols` ships next to the Crashlytics `run` script: in the Swift
|
|
53
|
+
* Package checkout (`SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/`,
|
|
54
|
+
* the path Firebase's run script uses) or in CocoaPods
|
|
55
|
+
* (`Pods/FirebaseCrashlytics/`, the path Firebase's manual-upload command uses).
|
|
56
|
+
*/
|
|
57
|
+
export declare function crashlyticsUploadToolCandidates(input: {
|
|
58
|
+
readonly sourcePackages: string;
|
|
59
|
+
readonly iosProject: string;
|
|
60
|
+
}): readonly string[];
|
|
61
|
+
export interface CollectIosCrashlyticsSymbolsInput {
|
|
62
|
+
/** Absolute path of the `.xcarchive` this build produced. */
|
|
63
|
+
readonly archive: string;
|
|
64
|
+
/** Absolute path of the staged shell's `ios` directory. */
|
|
65
|
+
readonly iosProject: string;
|
|
66
|
+
/** Absolute `-clonedSourcePackagesDirPath` of the archive build. */
|
|
67
|
+
readonly sourcePackages: string;
|
|
68
|
+
/** Absolute output directory (`<artifact root>/symbols`). */
|
|
69
|
+
readonly outputDirectory: string;
|
|
70
|
+
readonly copyFile: (source: string, destination: string) => void;
|
|
71
|
+
/** Zips `source` (a directory) into `destination` keeping the folder name. */
|
|
72
|
+
readonly zipDirectory: (source: string, destination: string) => void;
|
|
73
|
+
}
|
|
74
|
+
export interface CollectedIosCrashlyticsSymbols {
|
|
75
|
+
readonly dsymArchive: string;
|
|
76
|
+
readonly dsyms: readonly NativeDsymIdentity[];
|
|
77
|
+
readonly uploadTool: string;
|
|
78
|
+
readonly googleServiceInfo: string;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Copy what Crashlytics needs out of a staged archive build before the stage
|
|
82
|
+
* is removed. Refused when the archive has no dSYM (Firebase: "Set Debug
|
|
83
|
+
* Information Format to DWARF with dSYM File"), when the checkout has no
|
|
84
|
+
* `upload-symbols`, or when the app does not bundle `GoogleService-Info.plist`.
|
|
85
|
+
*/
|
|
86
|
+
export declare function collectIosCrashlyticsSymbols(input: CollectIosCrashlyticsSymbolsInput): CollectedIosCrashlyticsSymbols;
|
|
87
|
+
/** Every `*.dSYM` bundle of an archive's `dSYMs` folder, sorted, with its UUIDs. */
|
|
88
|
+
export declare function readArchiveDsyms(dsymFolder: string): NativeDsymIdentity[];
|
|
89
|
+
/** dSYM UUIDs as `dwarfdump --uuid` prints them. */
|
|
90
|
+
export declare function isDsymUuid(value: unknown): value is string;
|
|
91
|
+
/**
|
|
92
|
+
* The `LC_UUID` of each architecture in a thin or universal Mach-O file,
|
|
93
|
+
* read from its headers only (DWARF files can be hundreds of megabytes).
|
|
94
|
+
*/
|
|
95
|
+
export declare function readMachOUuids(file: string): string[];
|