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