@mpgd/cli 0.35.0 → 0.36.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/android-bundle-signer.d.ts +2 -0
- package/dist/android-bundle-signer.js +38 -0
- package/dist/android-signing-session.d.ts +22 -0
- package/dist/android-signing-session.js +198 -0
- package/dist/artifact-inspection-environment.d.ts +2 -0
- package/dist/artifact-inspection-environment.js +31 -0
- package/dist/asc-pin.json +26 -0
- package/dist/capacitor-shell-starter.d.ts +1 -0
- package/dist/capacitor-shell-starter.js +9 -6
- package/dist/deploy-planning.d.ts +64 -0
- package/dist/deploy-planning.js +471 -0
- package/dist/deploy-process.d.ts +28 -0
- package/dist/deploy-process.js +197 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +409 -10
- package/dist/ios-ipa-inspection.js +151 -0
- package/dist/ios-keychain-import.swift +92 -0
- package/dist/ios-plist.d.ts +2 -0
- package/dist/ios-plist.js +75 -0
- package/dist/ios-signing-profile.d.ts +12 -0
- package/dist/ios-signing-profile.js +60 -0
- package/dist/ios-signing-session.d.ts +22 -0
- package/dist/ios-signing-session.js +256 -0
- package/dist/kit-upgrade-target-readiness.d.ts +8 -0
- package/dist/kit-upgrade-target-readiness.js +119 -0
- package/dist/kit-upgrade.d.ts +2 -0
- package/dist/kit-upgrade.js +3 -0
- package/dist/native-build-info.json +5 -0
- package/dist/native-build-target.js +12032 -0
- package/dist/native-deploy-run.d.ts +35 -0
- package/dist/native-deploy-run.js +462 -0
- package/dist/native-deploy-submission.d.ts +40 -0
- package/dist/native-deploy-submission.js +244 -0
- package/dist/native-deploy-workflow.d.ts +11 -0
- package/dist/native-deploy-workflow.js +328 -0
- package/dist/play-internal-submission.d.ts +32 -0
- package/dist/play-internal-submission.js +247 -0
- package/dist/play-publisher-port.d.ts +30 -0
- package/dist/play-publisher-port.js +1 -0
- package/dist/play-sdk-adapter.js +78 -0
- package/dist/release-state.d.ts +140 -0
- package/dist/release-state.js +1046 -0
- package/dist/release-workspace.d.ts +61 -0
- package/dist/release-workspace.js +504 -0
- package/dist/testflight-submission.d.ts +49 -0
- package/dist/testflight-submission.js +462 -0
- package/package.json +18 -13
- package/templates/phaser-game/README.md +23 -8
- package/templates/phaser-game/src/main.ts +34 -33
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { createReadStream, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
+
import { allocatePlatformVersions, assertPlatformVersionLedger, createMpgdReleaseIdentity, formatMpgdReleaseId, } from '@mpgd/target-config';
|
|
8
|
+
import { runReleaseProcess } from './deploy-process.js';
|
|
9
|
+
import { inspectAndroidBundleSigner } from './android-bundle-signer.js';
|
|
10
|
+
export function isNativeSubmissionSettled(status) {
|
|
11
|
+
return status === 'committed' || status === 'testflight-ready';
|
|
12
|
+
}
|
|
13
|
+
const stateBranch = 'release-state';
|
|
14
|
+
const stateFileName = 'mpgd-release-state.json';
|
|
15
|
+
const releaseKeyPattern = /^[a-z0-9][a-z0-9._-]{0,127}$/u;
|
|
16
|
+
const gitShaPattern = /^[0-9a-f]{40}$/u;
|
|
17
|
+
const sha256Pattern = /^[0-9a-f]{64}$/u;
|
|
18
|
+
const androidFingerprintPattern = /^(?:[0-9a-fA-F]{2}:){31}[0-9a-fA-F]{2}$|^[0-9a-fA-F]{64}$/u;
|
|
19
|
+
const teamIdPattern = /^[A-Z0-9]{10}$/u;
|
|
20
|
+
const remoteIdPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
21
|
+
const attemptIdPattern = /^[0-9a-f]{32}$/u;
|
|
22
|
+
const submissionLeaseMs = 25 * 60_000;
|
|
23
|
+
/** Leave one minute of clock-skew headroom below the checkpoint lease cap. */
|
|
24
|
+
export function freshSubmissionLeaseExpiresAt() {
|
|
25
|
+
return new Date(Date.now() + submissionLeaseMs - 60_000).toISOString();
|
|
26
|
+
}
|
|
27
|
+
function normalizeAndroidFingerprint(value) {
|
|
28
|
+
return value.replaceAll(':', '').toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
/** Match the private manifest package's complete schema without a runtime workspace dependency. */
|
|
31
|
+
function assertCompleteReleaseManifest(value) {
|
|
32
|
+
if (!isRecord(value) || !isRecord(value.targets)) {
|
|
33
|
+
throw new Error('Native release manifest must include target records.');
|
|
34
|
+
}
|
|
35
|
+
assertStringFields(value, [
|
|
36
|
+
'releaseId',
|
|
37
|
+
'gitSha',
|
|
38
|
+
'kitGitSha',
|
|
39
|
+
'gameVersion',
|
|
40
|
+
'buildId',
|
|
41
|
+
'targetConfigVersion',
|
|
42
|
+
'catalogVersion',
|
|
43
|
+
'adPlacementVersion',
|
|
44
|
+
], 'release manifest');
|
|
45
|
+
if (value.releaseIdentity !== undefined) {
|
|
46
|
+
if (!isRecord(value.releaseIdentity)) {
|
|
47
|
+
throw new Error('Native release manifest identity is malformed.');
|
|
48
|
+
}
|
|
49
|
+
assertStringFields(value.releaseIdentity, ['gameVersion', 'label'], 'release identity');
|
|
50
|
+
if (value.releaseIdentity.releaseRevision !== undefined
|
|
51
|
+
&& typeof value.releaseIdentity.releaseRevision !== 'number') {
|
|
52
|
+
throw new Error('Native release manifest revision is malformed.');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const [name, rawEntry] of Object.entries(value.targets)) {
|
|
56
|
+
if (!isRecord(rawEntry) || !isRecord(rawEntry.effectiveConfig)
|
|
57
|
+
|| !isRecord(rawEntry.iconManifest)) {
|
|
58
|
+
throw new Error(`Native release manifest target ${name} is malformed.`);
|
|
59
|
+
}
|
|
60
|
+
assertStringFields(rawEntry, ['artifact'], `target ${name}`);
|
|
61
|
+
assertStringFields(rawEntry.effectiveConfig, ['path', 'version', 'digest'], `effective config ${name}`);
|
|
62
|
+
assertStringFields(rawEntry.iconManifest, [
|
|
63
|
+
'path',
|
|
64
|
+
'digest',
|
|
65
|
+
'sourceSha256',
|
|
66
|
+
'sharedConfigSha256',
|
|
67
|
+
'renderConfigSha256',
|
|
68
|
+
'generatorVersion',
|
|
69
|
+
'targetProfile',
|
|
70
|
+
'targetProfileVersion',
|
|
71
|
+
], `icon manifest ${name}`);
|
|
72
|
+
for (const field of ['profile', 'versionName', 'marketingVersion', 'buildNumber', 'appName']) {
|
|
73
|
+
if (rawEntry[field] !== undefined && typeof rawEntry[field] !== 'string') {
|
|
74
|
+
throw new Error(`Native release manifest target ${name}.${field} is malformed.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const field of ['versionCode', 'sdkMajor']) {
|
|
78
|
+
if (rawEntry[field] !== undefined && typeof rawEntry[field] !== 'number') {
|
|
79
|
+
throw new Error(`Native release manifest target ${name}.${field} is malformed.`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (rawEntry.nativeDelivery !== undefined) {
|
|
83
|
+
if (!isRecord(rawEntry.nativeDelivery)
|
|
84
|
+
|| !['android', 'ios'].includes(String(rawEntry.nativeDelivery.platform))
|
|
85
|
+
|| !['sync', 'debug', 'simulator', 'unsigned-archive', 'signed-archive', 'store-export']
|
|
86
|
+
.includes(String(rawEntry.nativeDelivery.mode))
|
|
87
|
+
|| typeof rawEntry.nativeDelivery.signed !== 'boolean'
|
|
88
|
+
|| typeof rawEntry.nativeDelivery.submissionCandidate !== 'boolean') {
|
|
89
|
+
throw new Error(`Native release manifest target ${name} delivery is malformed.`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const manifest = value;
|
|
94
|
+
if (!gitShaPattern.test(manifest.kitGitSha)) {
|
|
95
|
+
throw new Error('Native release manifest Kit revision is invalid.');
|
|
96
|
+
}
|
|
97
|
+
for (const [target, entry] of Object.entries(manifest.targets)) {
|
|
98
|
+
const delivery = entry.nativeDelivery;
|
|
99
|
+
if (delivery === undefined) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const expectedSigned = delivery.mode === 'signed-archive' || delivery.mode === 'store-export';
|
|
103
|
+
const validMode = delivery.platform === 'android'
|
|
104
|
+
? ['debug', 'unsigned-archive', 'signed-archive'].includes(delivery.mode)
|
|
105
|
+
: ['sync', 'simulator', 'unsigned-archive', 'signed-archive', 'store-export']
|
|
106
|
+
.includes(delivery.mode);
|
|
107
|
+
const expectedCandidate = entry.profile === 'production' && (delivery.platform === 'android'
|
|
108
|
+
? delivery.mode === 'signed-archive' : delivery.mode === 'store-export');
|
|
109
|
+
if (!validMode || delivery.signed !== expectedSigned
|
|
110
|
+
|| delivery.submissionCandidate !== expectedCandidate
|
|
111
|
+
|| (entry.profile === 'production' && !delivery.signed)) {
|
|
112
|
+
throw new Error(`Native release manifest delivery is invalid: ${target}.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (manifest.releaseIdentity !== undefined) {
|
|
116
|
+
const identity = createMpgdReleaseIdentity({
|
|
117
|
+
gameVersion: manifest.releaseIdentity.gameVersion,
|
|
118
|
+
...(manifest.releaseIdentity.releaseRevision === undefined
|
|
119
|
+
? {} : { releaseRevision: manifest.releaseIdentity.releaseRevision }),
|
|
120
|
+
expectedLabel: manifest.releaseIdentity.label,
|
|
121
|
+
});
|
|
122
|
+
if (manifest.releaseIdentity.label !== identity.label
|
|
123
|
+
|| manifest.releaseIdentity.gameVersion !== identity.gameVersion
|
|
124
|
+
|| manifest.gameVersion !== identity.gameVersion
|
|
125
|
+
|| manifest.releaseId !== formatMpgdReleaseId(identity.label, manifest.buildId)) {
|
|
126
|
+
throw new Error('Native release manifest identity is invalid.');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return manifest;
|
|
130
|
+
}
|
|
131
|
+
function assertStringFields(value, fields, label) {
|
|
132
|
+
for (const field of fields) {
|
|
133
|
+
if (typeof value[field] !== 'string') {
|
|
134
|
+
throw new Error(`Native ${label} is missing ${field}.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Reserve version numbers by committing the ledger and plan atomically. */
|
|
139
|
+
export async function reserveNativeRelease(input) {
|
|
140
|
+
assertReleaseKey(input.releaseKey);
|
|
141
|
+
if (input.targets.length === 0
|
|
142
|
+
|| input.targets.some((request) => request.target !== 'android' && request.target !== 'ios')) {
|
|
143
|
+
throw new Error('Native release reservations require Android or iOS targets.');
|
|
144
|
+
}
|
|
145
|
+
if (!gitShaPattern.test(input.sourceGitSha)) {
|
|
146
|
+
throw new Error('Native release source revision must be a full Git SHA.');
|
|
147
|
+
}
|
|
148
|
+
const source = await runReleaseProcess({
|
|
149
|
+
command: 'git',
|
|
150
|
+
args: ['-C', input.gameRoot, 'cat-file', '-t', input.sourceGitSha],
|
|
151
|
+
cwd: input.gameRoot,
|
|
152
|
+
environment: isolatedGitEnvironment(input.environment ?? process.env),
|
|
153
|
+
timeoutMs: 10_000,
|
|
154
|
+
signal: input.signal,
|
|
155
|
+
captureMachineStdout: true,
|
|
156
|
+
});
|
|
157
|
+
if (source.truncated || source.machineStdout?.trim() !== 'commit') {
|
|
158
|
+
throw new Error('Native release source revision is not a game Git commit.');
|
|
159
|
+
}
|
|
160
|
+
return withStateSession(input, async (session) => {
|
|
161
|
+
const game = ownValue(session.state.games, input.gameId);
|
|
162
|
+
if (game === undefined && input.initialLedger === undefined) {
|
|
163
|
+
throw new Error('First release requires an explicit initial platform version ledger.');
|
|
164
|
+
}
|
|
165
|
+
const ledger = game?.ledger ?? assertPlatformVersionLedger(input.initialLedger);
|
|
166
|
+
const existingPlan = game === undefined
|
|
167
|
+
? undefined
|
|
168
|
+
: ownValue(game.reservations, input.releaseKey);
|
|
169
|
+
if (existingPlan !== undefined) {
|
|
170
|
+
const requested = input.targets.map((item) => item.target).sort();
|
|
171
|
+
const reserved = Object.keys(existingPlan.targets).sort();
|
|
172
|
+
if (JSON.stringify(requested) !== JSON.stringify(reserved)) {
|
|
173
|
+
throw new Error('An existing release reservation cannot change its target set.');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const allocation = allocatePlatformVersions({
|
|
177
|
+
gameId: input.gameId,
|
|
178
|
+
gameVersion: input.gameVersion,
|
|
179
|
+
sourceGitSha: input.sourceGitSha,
|
|
180
|
+
kitGitSha: input.kitGitSha,
|
|
181
|
+
targetConfigDigest: input.targetConfigDigest,
|
|
182
|
+
targets: input.targets,
|
|
183
|
+
ledger,
|
|
184
|
+
...(existingPlan === undefined ? {} : { existingPlan }),
|
|
185
|
+
});
|
|
186
|
+
if (existingPlan !== undefined) {
|
|
187
|
+
if (session.previousCommit === undefined) {
|
|
188
|
+
throw new Error('Existing reservation has no state commit.');
|
|
189
|
+
}
|
|
190
|
+
return { plan: existingPlan, stateCommit: session.previousCommit, reused: true };
|
|
191
|
+
}
|
|
192
|
+
const nextGame = {
|
|
193
|
+
initialLedger: game?.initialLedger ?? ledger,
|
|
194
|
+
ledger: allocation.ledger,
|
|
195
|
+
reservations: { ...(game?.reservations ?? {}), [input.releaseKey]: allocation.plan },
|
|
196
|
+
builds: game?.builds ?? {},
|
|
197
|
+
...(game?.submissions === undefined ? {} : { submissions: game.submissions }),
|
|
198
|
+
};
|
|
199
|
+
const next = {
|
|
200
|
+
schemaVersion: 1,
|
|
201
|
+
games: { ...session.state.games, [input.gameId]: nextGame },
|
|
202
|
+
};
|
|
203
|
+
const stateCommit = await commitState(session, next, `Reserve ${input.gameId}/${input.releaseKey}`);
|
|
204
|
+
return { plan: allocation.plan, stateCommit, reused: false };
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** Attach one immutable, content-hashed native artifact to a reserved release. */
|
|
208
|
+
export async function recordNativeReleaseBuild(input) {
|
|
209
|
+
assertReleaseKey(input.releaseKey);
|
|
210
|
+
if ((input.deployConfigSha256 === undefined) !== (input.deploymentProfile === undefined)
|
|
211
|
+
|| (input.deployConfigSha256 === undefined) !== (input.deploymentDestination === undefined)
|
|
212
|
+
|| (input.deployConfigSha256 !== undefined
|
|
213
|
+
&& !sha256Pattern.test(input.deployConfigSha256))
|
|
214
|
+
|| (input.deploymentProfile !== undefined
|
|
215
|
+
&& !/^[a-z][a-z0-9-]*$/u.test(input.deploymentProfile))
|
|
216
|
+
|| (input.deploymentDestination !== undefined
|
|
217
|
+
&& input.deploymentDestination !== (input.target === 'android'
|
|
218
|
+
? 'play-internal' : 'testflight'))
|
|
219
|
+
|| (input.internalTestGroupId !== undefined && input.deployConfigSha256 === undefined)
|
|
220
|
+
|| (input.target === 'android' && input.internalTestGroupId !== undefined)
|
|
221
|
+
|| (input.target === 'ios' && input.deployConfigSha256 !== undefined
|
|
222
|
+
&& (input.internalTestGroupId === undefined
|
|
223
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9-]*$/u.test(input.internalTestGroupId)))) {
|
|
224
|
+
throw new Error('Native build deployment profile identity is malformed.');
|
|
225
|
+
}
|
|
226
|
+
if (input.buildRunId.trim() === '' || input.artifactLocation.trim() === ''
|
|
227
|
+
|| input.kitPackageVersion.trim() === ''
|
|
228
|
+
|| input.inspectedAppId.trim() === ''
|
|
229
|
+
|| (input.target === 'android' && (input.inspectedTeamId !== undefined
|
|
230
|
+
|| !androidFingerprintPattern.test(input.inspectedSignerSha256 ?? '')))
|
|
231
|
+
|| (input.target === 'ios' && (input.inspectedSignerSha256 !== undefined
|
|
232
|
+
|| !teamIdPattern.test(input.inspectedTeamId ?? '')))
|
|
233
|
+
|| !sha256Pattern.test(input.buildConfigDigest)) {
|
|
234
|
+
throw new Error('Native build record is missing a run ID, artifact location, or inspection.');
|
|
235
|
+
}
|
|
236
|
+
if (!existsSync(input.artifactFile) || !existsSync(input.releaseManifestFile)
|
|
237
|
+
|| !statSync(input.artifactFile).isFile()
|
|
238
|
+
|| !statSync(input.releaseManifestFile).isFile()) {
|
|
239
|
+
throw new Error('Native build record requires file artifacts.');
|
|
240
|
+
}
|
|
241
|
+
const artifactSha256 = await sha256(input.artifactFile);
|
|
242
|
+
const manifestBytes = readFileSync(input.releaseManifestFile);
|
|
243
|
+
const releaseManifestSha256 = createHash('sha256').update(manifestBytes).digest('hex');
|
|
244
|
+
if (artifactSha256 !== input.expectedArtifactSha256
|
|
245
|
+
|| releaseManifestSha256 !== input.expectedReleaseManifestSha256) {
|
|
246
|
+
throw new Error('Copied native artifact or release manifest differs from the verified build.');
|
|
247
|
+
}
|
|
248
|
+
const signer = input.target === 'android'
|
|
249
|
+
? await inspectAndroidBundleSigner(input.artifactFile)
|
|
250
|
+
: undefined;
|
|
251
|
+
if (signer !== undefined
|
|
252
|
+
&& signer !== normalizeAndroidFingerprint(input.inspectedSignerSha256 ?? '')) {
|
|
253
|
+
throw new Error('Recorded Android signer does not match the signed AAB bytes.');
|
|
254
|
+
}
|
|
255
|
+
return withStateSession(input, async (session) => {
|
|
256
|
+
const game = ownValue(session.state.games, input.gameId);
|
|
257
|
+
const plan = game === undefined ? undefined : ownValue(game.reservations, input.releaseKey);
|
|
258
|
+
if (game === undefined || plan === undefined || plan.targets[input.target] === undefined) {
|
|
259
|
+
throw new Error('Native build record has no matching reserved target.');
|
|
260
|
+
}
|
|
261
|
+
let manifest;
|
|
262
|
+
try {
|
|
263
|
+
manifest = JSON.parse(manifestBytes.toString('utf8'));
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
throw new Error('Native release manifest is not valid JSON.');
|
|
267
|
+
}
|
|
268
|
+
const validatedManifest = assertCompleteReleaseManifest(manifest);
|
|
269
|
+
if (validatedManifest.gitSha !== plan.sourceGitSha
|
|
270
|
+
|| validatedManifest.kitGitSha !== plan.kitGitSha
|
|
271
|
+
|| validatedManifest.buildId !== plan.buildId
|
|
272
|
+
|| validatedManifest.gameVersion !== plan.gameVersion) {
|
|
273
|
+
throw new Error('Native build manifest does not match its reserved provenance.');
|
|
274
|
+
}
|
|
275
|
+
const manifestRecord = validatedManifest;
|
|
276
|
+
const releaseIdentity = manifestRecord.releaseIdentity;
|
|
277
|
+
if (typeof releaseIdentity !== 'object' || releaseIdentity === null
|
|
278
|
+
|| Array.isArray(releaseIdentity)
|
|
279
|
+
|| releaseIdentity.releaseRevision !== plan.releaseRevision
|
|
280
|
+
|| releaseIdentity.label !== plan.releaseLabel) {
|
|
281
|
+
throw new Error('Native build manifest release revision does not match its reservation.');
|
|
282
|
+
}
|
|
283
|
+
const manifestTargets = manifestRecord.targets;
|
|
284
|
+
if (typeof manifestTargets !== 'object' || manifestTargets === null
|
|
285
|
+
|| Array.isArray(manifestTargets)) {
|
|
286
|
+
throw new Error('Native build manifest is missing target evidence.');
|
|
287
|
+
}
|
|
288
|
+
const targetManifest = manifestTargets[input.target];
|
|
289
|
+
const plannedTarget = plan.targets[input.target];
|
|
290
|
+
if (typeof targetManifest !== 'object' || targetManifest === null
|
|
291
|
+
|| Array.isArray(targetManifest) || plannedTarget === undefined) {
|
|
292
|
+
throw new Error('Native build manifest is missing its reserved target.');
|
|
293
|
+
}
|
|
294
|
+
const target = targetManifest;
|
|
295
|
+
const delivery = target.nativeDelivery;
|
|
296
|
+
if (typeof delivery !== 'object' || delivery === null || Array.isArray(delivery)
|
|
297
|
+
|| delivery.platform !== input.target
|
|
298
|
+
|| delivery.signed !== true
|
|
299
|
+
|| delivery.submissionCandidate !== true
|
|
300
|
+
|| delivery.mode !== (input.target === 'android'
|
|
301
|
+
? 'signed-archive' : 'store-export')
|
|
302
|
+
|| target.profile !== 'production'
|
|
303
|
+
|| (input.target === 'android' && target.versionCode !== plannedTarget.versionCode)
|
|
304
|
+
|| (input.target === 'android' && target.versionName !== plannedTarget.versionName)
|
|
305
|
+
|| (input.target === 'ios' && target.buildNumber !== String(plannedTarget.buildNumber))
|
|
306
|
+
|| (input.target === 'ios' && target.marketingVersion !== plannedTarget.marketingVersion)) {
|
|
307
|
+
throw new Error('Native build manifest does not match reserved signed delivery.');
|
|
308
|
+
}
|
|
309
|
+
const record = {
|
|
310
|
+
releaseKey: input.releaseKey,
|
|
311
|
+
target: input.target,
|
|
312
|
+
buildRunId: input.buildRunId,
|
|
313
|
+
gameVersion: plan.gameVersion,
|
|
314
|
+
sourceGitSha: plan.sourceGitSha,
|
|
315
|
+
kitGitSha: plan.kitGitSha,
|
|
316
|
+
kitPackageVersion: input.kitPackageVersion,
|
|
317
|
+
buildConfigDigest: input.buildConfigDigest,
|
|
318
|
+
targetConfigDigest: plan.targetConfigDigest,
|
|
319
|
+
...(input.deployConfigSha256 === undefined ? {} : {
|
|
320
|
+
deployConfigSha256: input.deployConfigSha256,
|
|
321
|
+
deploymentProfile: input.deploymentProfile,
|
|
322
|
+
deploymentDestination: input.deploymentDestination,
|
|
323
|
+
...(input.internalTestGroupId === undefined
|
|
324
|
+
? {} : { internalTestGroupId: input.internalTestGroupId }),
|
|
325
|
+
}),
|
|
326
|
+
platformVersion: plannedTarget,
|
|
327
|
+
artifactLocation: input.artifactLocation,
|
|
328
|
+
artifactSha256,
|
|
329
|
+
releaseManifestSha256,
|
|
330
|
+
inspectedAppId: input.inspectedAppId,
|
|
331
|
+
...(input.target === 'android'
|
|
332
|
+
? { inspectedSignerSha256: signer }
|
|
333
|
+
: { inspectedTeamId: input.inspectedTeamId ?? '' }),
|
|
334
|
+
};
|
|
335
|
+
const key = `${input.releaseKey}/${input.target}`;
|
|
336
|
+
const previous = ownValue(game.builds, key);
|
|
337
|
+
if (previous !== undefined) {
|
|
338
|
+
if (!isDeepStrictEqual(previous, record)) {
|
|
339
|
+
throw new Error('An immutable native build record cannot be replaced.');
|
|
340
|
+
}
|
|
341
|
+
if (session.previousCommit === undefined) {
|
|
342
|
+
throw new Error('Existing build record has no state commit.');
|
|
343
|
+
}
|
|
344
|
+
return { record: previous, stateCommit: session.previousCommit };
|
|
345
|
+
}
|
|
346
|
+
const next = {
|
|
347
|
+
schemaVersion: 1,
|
|
348
|
+
games: {
|
|
349
|
+
...session.state.games,
|
|
350
|
+
[input.gameId]: { ...game, builds: { ...game.builds, [key]: record } },
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
const stateCommit = await commitState(session, next, `Record ${input.gameId}/${key}`);
|
|
354
|
+
return { record, stateCommit };
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
/** Read one explicitly named release from the authoritative game-owned state branch. */
|
|
358
|
+
export async function readNativeReleaseStatus(input) {
|
|
359
|
+
assertReleaseKey(input.releaseKey);
|
|
360
|
+
return withStateSession(input, async (session) => {
|
|
361
|
+
const game = ownValue(session.state.games, input.gameId);
|
|
362
|
+
const plan = game === undefined ? undefined : ownValue(game.reservations, input.releaseKey);
|
|
363
|
+
if (game === undefined || plan === undefined || session.previousCommit === undefined) {
|
|
364
|
+
throw new Error('The requested native release has not been reserved.');
|
|
365
|
+
}
|
|
366
|
+
const androidKey = `${input.releaseKey}/android`;
|
|
367
|
+
const iosKey = `${input.releaseKey}/ios`;
|
|
368
|
+
return {
|
|
369
|
+
plan,
|
|
370
|
+
builds: {
|
|
371
|
+
...(game.builds[androidKey] === undefined ? {} : { android: game.builds[androidKey] }),
|
|
372
|
+
...(game.builds[iosKey] === undefined ? {} : { ios: game.builds[iosKey] }),
|
|
373
|
+
},
|
|
374
|
+
submissions: {
|
|
375
|
+
...(game.submissions?.[androidKey] === undefined
|
|
376
|
+
? {} : { android: game.submissions[androidKey] }),
|
|
377
|
+
...(game.submissions?.[iosKey] === undefined
|
|
378
|
+
? {} : { ios: game.submissions[iosKey] }),
|
|
379
|
+
},
|
|
380
|
+
stateCommit: session.previousCommit,
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
/** Persist a resumable store checkpoint with the build record in one Git state. */
|
|
385
|
+
export async function checkpointNativeSubmission(input) {
|
|
386
|
+
assertNativeSubmissionCheckpoint(input.checkpoint);
|
|
387
|
+
return withStateSession(input, async (session) => {
|
|
388
|
+
const game = ownValue(session.state.games, input.gameId);
|
|
389
|
+
const key = `${input.checkpoint.releaseKey}/${input.checkpoint.target}`;
|
|
390
|
+
const build = game === undefined ? undefined : ownValue(game.builds, key);
|
|
391
|
+
if (game === undefined || build === undefined
|
|
392
|
+
|| build.buildRunId !== input.checkpoint.buildRunId
|
|
393
|
+
|| build.artifactSha256 !== input.checkpoint.artifactSha256) {
|
|
394
|
+
throw new Error('Store checkpoint must reference the immutable native build record.');
|
|
395
|
+
}
|
|
396
|
+
const previous = ownValue(game.submissions ?? {}, key);
|
|
397
|
+
if (previous !== undefined) {
|
|
398
|
+
if (previous.attemptId !== input.checkpoint.attemptId) {
|
|
399
|
+
throw new Error('Store submission has another active or unreconciled attempt owner.');
|
|
400
|
+
}
|
|
401
|
+
if (Date.parse(previous.leaseExpiresAt) <= Date.now()
|
|
402
|
+
&& !isNativeSubmissionSettled(previous.status)) {
|
|
403
|
+
throw new Error('Store submission lease expired; reconcile remote state before takeover.');
|
|
404
|
+
}
|
|
405
|
+
if (isDeepStrictEqual(previous, input.checkpoint)) {
|
|
406
|
+
if (session.previousCommit === undefined) {
|
|
407
|
+
throw new Error('Existing store checkpoint has no state commit.');
|
|
408
|
+
}
|
|
409
|
+
return { checkpoint: previous, stateCommit: session.previousCommit };
|
|
410
|
+
}
|
|
411
|
+
if (isNativeSubmissionSettled(previous.status)
|
|
412
|
+
|| previous.status === 'failed'
|
|
413
|
+
|| previous.remoteEditId !== undefined
|
|
414
|
+
&& previous.remoteEditId !== input.checkpoint.remoteEditId
|
|
415
|
+
&& !(previous.target === 'android'
|
|
416
|
+
&& (previous.status === 'unknown' || previous.status === 'edit-open')
|
|
417
|
+
&& input.checkpoint.status === 'edit-open'
|
|
418
|
+
&& input.checkpoint.remoteEditId !== undefined)
|
|
419
|
+
|| previous.remoteUploadId !== undefined
|
|
420
|
+
&& previous.remoteUploadId !== input.checkpoint.remoteUploadId
|
|
421
|
+
|| previous.remoteBuildId !== undefined
|
|
422
|
+
&& previous.remoteBuildId !== input.checkpoint.remoteBuildId) {
|
|
423
|
+
throw new Error('A completed submission or remote checkpoint ID cannot be replaced.');
|
|
424
|
+
}
|
|
425
|
+
if (!allowedSubmissionTransition(previous.status, input.checkpoint.status)) {
|
|
426
|
+
throw new Error('Store submission status cannot move backward.');
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
else if (input.checkpoint.status !== 'started') {
|
|
430
|
+
throw new Error('A new store submission must checkpoint started before remote mutation.');
|
|
431
|
+
}
|
|
432
|
+
if (Date.parse(input.checkpoint.leaseExpiresAt) <= Date.now()
|
|
433
|
+
|| Date.parse(input.checkpoint.leaseExpiresAt) > Date.now() + submissionLeaseMs) {
|
|
434
|
+
throw new Error('Store submission lease must be active and no longer than 25 minutes.');
|
|
435
|
+
}
|
|
436
|
+
const next = {
|
|
437
|
+
schemaVersion: 1,
|
|
438
|
+
games: {
|
|
439
|
+
...session.state.games,
|
|
440
|
+
[input.gameId]: {
|
|
441
|
+
...game,
|
|
442
|
+
submissions: { ...(game.submissions ?? {}), [key]: input.checkpoint },
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
const stateCommit = await commitState(session, next, `Submit ${input.gameId}/${key}`);
|
|
447
|
+
return { checkpoint: input.checkpoint, stateCommit };
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
/** Claim an expired attempt without discarding its remote IDs or changing its status. */
|
|
451
|
+
export async function reclaimNativeSubmission(input) {
|
|
452
|
+
assertReleaseKey(input.releaseKey);
|
|
453
|
+
if (!gitShaPattern.test(input.expectedStateCommit)
|
|
454
|
+
|| !attemptIdPattern.test(input.attemptId)) {
|
|
455
|
+
throw new Error('Submission reclaim needs an explicit state commit and new attempt ID.');
|
|
456
|
+
}
|
|
457
|
+
return withStateSession(input, async (session) => {
|
|
458
|
+
if (session.previousCommit !== input.expectedStateCommit) {
|
|
459
|
+
throw new Error('Submission state changed; read its latest remote checkpoint first.');
|
|
460
|
+
}
|
|
461
|
+
const game = ownValue(session.state.games, input.gameId);
|
|
462
|
+
const key = `${input.releaseKey}/${input.target}`;
|
|
463
|
+
const previous = game === undefined ? undefined : ownValue(game.submissions ?? {}, key);
|
|
464
|
+
if (game === undefined || previous === undefined) {
|
|
465
|
+
throw new Error('No store submission exists to reclaim.');
|
|
466
|
+
}
|
|
467
|
+
if (Date.parse(previous.leaseExpiresAt) > Date.now()) {
|
|
468
|
+
throw new Error('Store submission lease is still active.');
|
|
469
|
+
}
|
|
470
|
+
if (isNativeSubmissionSettled(previous.status)
|
|
471
|
+
|| previous.status === 'failed'
|
|
472
|
+
|| (previous.status === 'action-required'
|
|
473
|
+
&& previous.remoteUploadId === undefined && previous.remoteBuildId === undefined)
|
|
474
|
+
|| (previous.status === 'unknown'
|
|
475
|
+
&& previous.remoteEditId === undefined && previous.remoteUploadId === undefined
|
|
476
|
+
&& previous.remoteBuildId === undefined)) {
|
|
477
|
+
throw new Error('Store submission needs operator reconciliation before reclaim.');
|
|
478
|
+
}
|
|
479
|
+
const checkpoint = {
|
|
480
|
+
...previous,
|
|
481
|
+
attemptId: input.attemptId,
|
|
482
|
+
leaseExpiresAt: new Date(Date.now() + submissionLeaseMs).toISOString(),
|
|
483
|
+
};
|
|
484
|
+
const next = {
|
|
485
|
+
schemaVersion: 1,
|
|
486
|
+
games: {
|
|
487
|
+
...session.state.games,
|
|
488
|
+
[input.gameId]: {
|
|
489
|
+
...game,
|
|
490
|
+
submissions: { ...(game.submissions ?? {}), [key]: checkpoint },
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
const stateCommit = await commitState(session, next, `Reclaim ${input.gameId}/${key}`);
|
|
495
|
+
return { checkpoint, stateCommit };
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
/** Relinquish a completed command's lease so an immediate status poll can resume. */
|
|
499
|
+
export async function releaseNativeSubmissionLease(input) {
|
|
500
|
+
assertReleaseKey(input.releaseKey);
|
|
501
|
+
return withStateSession(input, async (session) => {
|
|
502
|
+
const game = ownValue(session.state.games, input.gameId);
|
|
503
|
+
const key = `${input.releaseKey}/${input.target}`;
|
|
504
|
+
const previous = game === undefined ? undefined : ownValue(game.submissions ?? {}, key);
|
|
505
|
+
if (game === undefined || previous === undefined
|
|
506
|
+
|| previous.attemptId !== input.attemptId) {
|
|
507
|
+
throw new Error('Cannot release another submission attempt lease.');
|
|
508
|
+
}
|
|
509
|
+
if (Date.parse(previous.leaseExpiresAt) <= Date.now()) {
|
|
510
|
+
return previous;
|
|
511
|
+
}
|
|
512
|
+
const checkpoint = { ...previous, leaseExpiresAt: new Date(0).toISOString() };
|
|
513
|
+
const next = {
|
|
514
|
+
schemaVersion: 1,
|
|
515
|
+
games: {
|
|
516
|
+
...session.state.games,
|
|
517
|
+
[input.gameId]: {
|
|
518
|
+
...game,
|
|
519
|
+
submissions: { ...(game.submissions ?? {}), [key]: checkpoint },
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
await commitState(session, next, `Release ${input.gameId}/${key} submission lease`);
|
|
524
|
+
return checkpoint;
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
function allowedSubmissionTransition(from, to) {
|
|
528
|
+
if (from === to) {
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
const allowed = {
|
|
532
|
+
started: ['edit-open', 'upload-committed', 'unknown', 'failed', 'action-required'],
|
|
533
|
+
'edit-open': ['committed', 'unknown', 'failed', 'action-required'],
|
|
534
|
+
'upload-committed': [
|
|
535
|
+
'uploaded',
|
|
536
|
+
'processing',
|
|
537
|
+
'testflight-ready',
|
|
538
|
+
'unknown',
|
|
539
|
+
'failed',
|
|
540
|
+
'action-required',
|
|
541
|
+
],
|
|
542
|
+
uploaded: ['processing', 'testflight-ready', 'unknown', 'failed', 'action-required'],
|
|
543
|
+
processing: ['testflight-ready', 'unknown', 'failed', 'action-required'],
|
|
544
|
+
unknown: [
|
|
545
|
+
'edit-open',
|
|
546
|
+
'uploaded',
|
|
547
|
+
'processing',
|
|
548
|
+
'testflight-ready',
|
|
549
|
+
'committed',
|
|
550
|
+
'failed',
|
|
551
|
+
'action-required',
|
|
552
|
+
],
|
|
553
|
+
'action-required': ['processing', 'testflight-ready', 'unknown'],
|
|
554
|
+
};
|
|
555
|
+
return allowed[from]?.includes(to) ?? false;
|
|
556
|
+
}
|
|
557
|
+
async function withStateSession(input, action) {
|
|
558
|
+
const environment = isolatedGitEnvironment(input.environment ?? process.env);
|
|
559
|
+
// Read the authoritative write destination, not a possibly stale fetch mirror.
|
|
560
|
+
// Keep the URL in memory: the redacted runner must never modify embedded auth.
|
|
561
|
+
let remoteUrl;
|
|
562
|
+
let repositoryRoot;
|
|
563
|
+
try {
|
|
564
|
+
repositoryRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
565
|
+
cwd: input.gameRoot,
|
|
566
|
+
env: environment,
|
|
567
|
+
encoding: 'utf8',
|
|
568
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
569
|
+
timeout: 10_000,
|
|
570
|
+
maxBuffer: 64 * 1024,
|
|
571
|
+
}).trim();
|
|
572
|
+
remoteUrl = execFileSync('git', ['remote', 'get-url', '--push', 'origin'], {
|
|
573
|
+
cwd: input.gameRoot,
|
|
574
|
+
env: environment,
|
|
575
|
+
encoding: 'utf8',
|
|
576
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
577
|
+
timeout: 10_000,
|
|
578
|
+
maxBuffer: 64 * 1024,
|
|
579
|
+
}).trim();
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
throw new Error('Could not read the game repository origin remote.');
|
|
583
|
+
}
|
|
584
|
+
if (remoteUrl === '' || repositoryRoot === '') {
|
|
585
|
+
throw new Error('Game repository origin remote is missing.');
|
|
586
|
+
}
|
|
587
|
+
remoteUrl = resolveRemoteUrl(repositoryRoot, remoteUrl);
|
|
588
|
+
const directory = mkdtempSync(path.join(tmpdir(), 'mpgd-release-state-'));
|
|
589
|
+
try {
|
|
590
|
+
const session = {
|
|
591
|
+
directory,
|
|
592
|
+
previousCommit: undefined,
|
|
593
|
+
remoteUrl,
|
|
594
|
+
environment,
|
|
595
|
+
signal: input.signal,
|
|
596
|
+
state: { schemaVersion: 1, games: {} },
|
|
597
|
+
};
|
|
598
|
+
await git(session, ['init', '--object-format=sha1', '-q', directory]);
|
|
599
|
+
await git(session, ['remote', 'add', 'origin', remoteUrl]);
|
|
600
|
+
const remoteHead = await git(session, [
|
|
601
|
+
'ls-remote',
|
|
602
|
+
'--heads',
|
|
603
|
+
'origin',
|
|
604
|
+
`refs/heads/${stateBranch}`,
|
|
605
|
+
]);
|
|
606
|
+
const previousCommit = remoteHead.output.trim().split(/\s+/u)[0];
|
|
607
|
+
if (previousCommit !== undefined && gitShaPattern.test(previousCommit)) {
|
|
608
|
+
await git(session, ['fetch', 'origin', `refs/heads/${stateBranch}`]);
|
|
609
|
+
await git(session, ['checkout', '-q', '-B', stateBranch, 'FETCH_HEAD']);
|
|
610
|
+
const fetchedCommit = (await git(session, ['rev-parse', 'HEAD'])).output.trim();
|
|
611
|
+
if (!gitShaPattern.test(fetchedCommit)) {
|
|
612
|
+
throw new Error('Fetched release-state revision is invalid.');
|
|
613
|
+
}
|
|
614
|
+
const statePath = path.join(directory, stateFileName);
|
|
615
|
+
if (!existsSync(statePath)) {
|
|
616
|
+
throw new Error('Existing release-state branch has no state file.');
|
|
617
|
+
}
|
|
618
|
+
return await action({
|
|
619
|
+
...session,
|
|
620
|
+
previousCommit: fetchedCommit,
|
|
621
|
+
state: parseReleaseState(readFileSync(statePath, 'utf8')),
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
if (remoteHead.output.trim() !== '') {
|
|
625
|
+
throw new Error('Release-state remote returned an invalid branch reference.');
|
|
626
|
+
}
|
|
627
|
+
await git(session, ['checkout', '-q', '--orphan', stateBranch]);
|
|
628
|
+
return await action(session);
|
|
629
|
+
}
|
|
630
|
+
finally {
|
|
631
|
+
rmSync(directory, { recursive: true, force: true });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
function resolveRemoteUrl(repositoryRoot, remoteUrl) {
|
|
635
|
+
if (!path.isAbsolute(remoteUrl)
|
|
636
|
+
&& !/^(?:[a-z][a-z0-9+.-]*:\/\/|(?:[^/@:\s]+@)?[^/@:\s]+:)/iu.test(remoteUrl)) {
|
|
637
|
+
return path.resolve(repositoryRoot, remoteUrl);
|
|
638
|
+
}
|
|
639
|
+
return remoteUrl;
|
|
640
|
+
}
|
|
641
|
+
async function commitState(session, state, subject) {
|
|
642
|
+
const statePath = path.join(session.directory, stateFileName);
|
|
643
|
+
writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
|
|
644
|
+
await git(session, ['add', '--', stateFileName]);
|
|
645
|
+
await git(session, [
|
|
646
|
+
'-c',
|
|
647
|
+
'user.name=mpgd-release',
|
|
648
|
+
'-c',
|
|
649
|
+
'user.email=mpgd-release@example.invalid',
|
|
650
|
+
'commit',
|
|
651
|
+
'--no-gpg-sign',
|
|
652
|
+
'-qm',
|
|
653
|
+
subject,
|
|
654
|
+
]);
|
|
655
|
+
const commit = (await git(session, ['rev-parse', 'HEAD'])).output.trim();
|
|
656
|
+
if (!gitShaPattern.test(commit)) {
|
|
657
|
+
throw new Error('Release-state commit is not a full Git SHA.');
|
|
658
|
+
}
|
|
659
|
+
const expected = session.previousCommit ?? '';
|
|
660
|
+
try {
|
|
661
|
+
await git(session, [
|
|
662
|
+
'push',
|
|
663
|
+
`--force-with-lease=refs/heads/${stateBranch}:${expected}`,
|
|
664
|
+
'origin',
|
|
665
|
+
`HEAD:refs/heads/${stateBranch}`,
|
|
666
|
+
]);
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
let remoteCommit;
|
|
670
|
+
let verificationFailure;
|
|
671
|
+
try {
|
|
672
|
+
const remoteHead = await git(session, [
|
|
673
|
+
'ls-remote',
|
|
674
|
+
'--heads',
|
|
675
|
+
'origin',
|
|
676
|
+
`refs/heads/${stateBranch}`,
|
|
677
|
+
]);
|
|
678
|
+
remoteCommit = remoteHead.output.trim().split(/\s+/u)[0];
|
|
679
|
+
}
|
|
680
|
+
catch (verificationError) {
|
|
681
|
+
verificationFailure = verificationError;
|
|
682
|
+
}
|
|
683
|
+
if (remoteCommit !== commit) {
|
|
684
|
+
const cause = verificationFailure === undefined
|
|
685
|
+
? error
|
|
686
|
+
: new AggregateError([error, verificationFailure], 'Push and verification both failed.');
|
|
687
|
+
throw new Error('Release-state update conflicted or has an uncertain result; retry with the same release key.', { cause });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return commit;
|
|
691
|
+
}
|
|
692
|
+
function parseReleaseState(json) {
|
|
693
|
+
let value;
|
|
694
|
+
try {
|
|
695
|
+
value = JSON.parse(json);
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
throw new Error('Release-state file on the remote branch is not valid JSON.');
|
|
699
|
+
}
|
|
700
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
701
|
+
throw new Error('Release state must be a JSON object.');
|
|
702
|
+
}
|
|
703
|
+
const record = value;
|
|
704
|
+
if (record.schemaVersion !== 1 || typeof record.games !== 'object'
|
|
705
|
+
|| record.games === null || Array.isArray(record.games)
|
|
706
|
+
|| Object.keys(record).some((key) => key !== 'schemaVersion' && key !== 'games')) {
|
|
707
|
+
throw new Error('Release state has an unsupported schema.');
|
|
708
|
+
}
|
|
709
|
+
for (const [gameId, rawGame] of Object.entries(record.games)) {
|
|
710
|
+
if (typeof rawGame !== 'object' || rawGame === null || Array.isArray(rawGame)) {
|
|
711
|
+
throw new Error(`Release state game ${gameId} is malformed.`);
|
|
712
|
+
}
|
|
713
|
+
const game = rawGame;
|
|
714
|
+
if (Object.keys(game).some((key) => ![
|
|
715
|
+
'initialLedger', 'ledger', 'reservations', 'builds', 'submissions',
|
|
716
|
+
].includes(key))
|
|
717
|
+
|| typeof game.reservations !== 'object' || game.reservations === null
|
|
718
|
+
|| Array.isArray(game.reservations)
|
|
719
|
+
|| typeof game.builds !== 'object' || game.builds === null
|
|
720
|
+
|| Array.isArray(game.builds)
|
|
721
|
+
|| (game.submissions !== undefined && !isRecord(game.submissions))) {
|
|
722
|
+
throw new Error(`Release state game ${gameId} has invalid entries.`);
|
|
723
|
+
}
|
|
724
|
+
const initialLedger = assertPlatformVersionLedger(game.initialLedger);
|
|
725
|
+
const ledger = assertPlatformVersionLedger(game.ledger);
|
|
726
|
+
const plans = [];
|
|
727
|
+
for (const [releaseKey, rawPlan] of Object.entries(game.reservations)) {
|
|
728
|
+
let targetNames = [];
|
|
729
|
+
if (isRecord(rawPlan) && isRecord(rawPlan.targets)) {
|
|
730
|
+
targetNames = Object.keys(rawPlan.targets);
|
|
731
|
+
}
|
|
732
|
+
if (!releaseKeyPattern.test(releaseKey) || !isRecord(rawPlan)
|
|
733
|
+
|| rawPlan.schemaVersion !== 2 || !isRecord(rawPlan.targets)
|
|
734
|
+
|| typeof rawPlan.gameId !== 'string' || rawPlan.gameId !== gameId
|
|
735
|
+
|| typeof rawPlan.gameVersion !== 'string'
|
|
736
|
+
|| !Number.isSafeInteger(rawPlan.releaseRevision)
|
|
737
|
+
|| Number(rawPlan.releaseRevision) < 1
|
|
738
|
+
|| typeof rawPlan.releaseLabel !== 'string'
|
|
739
|
+
|| typeof rawPlan.buildId !== 'string'
|
|
740
|
+
|| !gitShaPattern.test(String(rawPlan.sourceGitSha))
|
|
741
|
+
|| !gitShaPattern.test(String(rawPlan.kitGitSha))
|
|
742
|
+
|| !sha256Pattern.test(String(rawPlan.targetConfigDigest))
|
|
743
|
+
|| targetNames.length === 0
|
|
744
|
+
|| targetNames.some((target) => target !== 'android' && target !== 'ios')
|
|
745
|
+
|| Object.values(rawPlan.targets).some((target) => !isRecord(target))) {
|
|
746
|
+
throw new Error(`Release state game ${gameId} has an invalid reservation ${releaseKey}.`);
|
|
747
|
+
}
|
|
748
|
+
const plan = rawPlan;
|
|
749
|
+
try {
|
|
750
|
+
const checked = allocatePlatformVersions({
|
|
751
|
+
gameId,
|
|
752
|
+
gameVersion: plan.gameVersion,
|
|
753
|
+
sourceGitSha: plan.sourceGitSha,
|
|
754
|
+
kitGitSha: plan.kitGitSha,
|
|
755
|
+
targetConfigDigest: plan.targetConfigDigest,
|
|
756
|
+
targets: targetNames.map((target) => ({
|
|
757
|
+
target: target,
|
|
758
|
+
})),
|
|
759
|
+
ledger,
|
|
760
|
+
existingPlan: plan,
|
|
761
|
+
});
|
|
762
|
+
if (!isDeepStrictEqual(checked.plan, plan)) {
|
|
763
|
+
throw new Error('Stored release plan differs from its validated allocation.');
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
catch {
|
|
767
|
+
throw new Error(`Release state game ${gameId} has an invalid reservation ${releaseKey}.`);
|
|
768
|
+
}
|
|
769
|
+
plans.push(plan);
|
|
770
|
+
}
|
|
771
|
+
assertReservationHistory(gameId, initialLedger, ledger, plans);
|
|
772
|
+
for (const [buildKey, rawBuild] of Object.entries(game.builds)) {
|
|
773
|
+
assertStoredBuildRecord(gameId, buildKey, rawBuild, game.reservations);
|
|
774
|
+
}
|
|
775
|
+
for (const [key, rawCheckpoint] of Object.entries(game.submissions ?? {})) {
|
|
776
|
+
assertNativeSubmissionCheckpoint(rawCheckpoint);
|
|
777
|
+
const checkpoint = rawCheckpoint;
|
|
778
|
+
const build = game.builds[key];
|
|
779
|
+
if (key !== `${checkpoint.releaseKey}/${checkpoint.target}` || build === undefined
|
|
780
|
+
|| checkpoint.buildRunId !== build.buildRunId
|
|
781
|
+
|| checkpoint.artifactSha256 !== build.artifactSha256) {
|
|
782
|
+
throw new Error(`Release state game ${gameId} has an invalid submission ${key}.`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return value;
|
|
787
|
+
}
|
|
788
|
+
const buildRecordFields = [
|
|
789
|
+
'releaseKey',
|
|
790
|
+
'target',
|
|
791
|
+
'buildRunId',
|
|
792
|
+
'gameVersion',
|
|
793
|
+
'sourceGitSha',
|
|
794
|
+
'kitGitSha',
|
|
795
|
+
'kitPackageVersion',
|
|
796
|
+
'buildConfigDigest',
|
|
797
|
+
'targetConfigDigest',
|
|
798
|
+
'deployConfigSha256',
|
|
799
|
+
'deploymentProfile',
|
|
800
|
+
'deploymentDestination',
|
|
801
|
+
'internalTestGroupId',
|
|
802
|
+
'platformVersion',
|
|
803
|
+
'artifactLocation',
|
|
804
|
+
'artifactSha256',
|
|
805
|
+
'releaseManifestSha256',
|
|
806
|
+
'inspectedAppId',
|
|
807
|
+
'inspectedSignerSha256',
|
|
808
|
+
'inspectedTeamId',
|
|
809
|
+
];
|
|
810
|
+
function assertStoredBuildRecord(gameId, buildKey, value, reservations) {
|
|
811
|
+
const malformed = () => {
|
|
812
|
+
throw new Error(`Release state game ${gameId} has an invalid build record ${buildKey}.`);
|
|
813
|
+
};
|
|
814
|
+
if (!isRecord(value)) {
|
|
815
|
+
malformed();
|
|
816
|
+
}
|
|
817
|
+
const record = value;
|
|
818
|
+
if (typeof record.releaseKey !== 'string'
|
|
819
|
+
|| !releaseKeyPattern.test(record.releaseKey)
|
|
820
|
+
|| (record.target !== 'android' && record.target !== 'ios')
|
|
821
|
+
|| buildKey !== `${record.releaseKey}/${record.target}`
|
|
822
|
+
|| Object.keys(record).some((key) => !buildRecordFields.includes(key))
|
|
823
|
+
|| typeof record.buildRunId !== 'string' || record.buildRunId.trim() === ''
|
|
824
|
+
|| typeof record.kitPackageVersion !== 'string' || record.kitPackageVersion.trim() === ''
|
|
825
|
+
|| typeof record.artifactLocation !== 'string' || record.artifactLocation.trim() === ''
|
|
826
|
+
|| typeof record.inspectedAppId !== 'string' || record.inspectedAppId.trim() === ''
|
|
827
|
+
|| !sha256Pattern.test(String(record.buildConfigDigest))
|
|
828
|
+
|| (record.deployConfigSha256 !== undefined
|
|
829
|
+
&& !sha256Pattern.test(String(record.deployConfigSha256)))
|
|
830
|
+
|| (record.deploymentProfile !== undefined
|
|
831
|
+
&& (typeof record.deploymentProfile !== 'string'
|
|
832
|
+
|| !/^[a-z][a-z0-9-]*$/u.test(record.deploymentProfile)))
|
|
833
|
+
|| (record.deployConfigSha256 === undefined) !== (record.deploymentProfile === undefined)
|
|
834
|
+
|| (record.deployConfigSha256 === undefined) !== (record.deploymentDestination === undefined)
|
|
835
|
+
|| (record.deploymentDestination !== undefined
|
|
836
|
+
&& record.deploymentDestination !== (record.target === 'android'
|
|
837
|
+
? 'play-internal' : 'testflight'))
|
|
838
|
+
|| (record.internalTestGroupId !== undefined && record.deployConfigSha256 === undefined)
|
|
839
|
+
|| (record.target === 'android' && record.internalTestGroupId !== undefined)
|
|
840
|
+
|| (record.target === 'ios' && record.deployConfigSha256 !== undefined
|
|
841
|
+
&& (typeof record.internalTestGroupId !== 'string'
|
|
842
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9-]*$/u.test(record.internalTestGroupId)))
|
|
843
|
+
|| !sha256Pattern.test(String(record.artifactSha256))
|
|
844
|
+
|| !sha256Pattern.test(String(record.releaseManifestSha256))
|
|
845
|
+
|| (record.target === 'android' && (record.inspectedTeamId !== undefined
|
|
846
|
+
|| !sha256Pattern.test(String(record.inspectedSignerSha256))))
|
|
847
|
+
|| (record.target === 'ios' && (record.inspectedSignerSha256 !== undefined
|
|
848
|
+
|| !teamIdPattern.test(String(record.inspectedTeamId))))
|
|
849
|
+
|| !isRecord(record.platformVersion)) {
|
|
850
|
+
malformed();
|
|
851
|
+
}
|
|
852
|
+
const plan = ownValue(reservations, record.releaseKey);
|
|
853
|
+
if (!isRecord(plan) || !isRecord(plan.targets)
|
|
854
|
+
|| !isDeepStrictEqual(record.platformVersion, plan.targets[record.target])
|
|
855
|
+
|| record.gameVersion !== plan.gameVersion
|
|
856
|
+
|| record.sourceGitSha !== plan.sourceGitSha
|
|
857
|
+
|| record.kitGitSha !== plan.kitGitSha
|
|
858
|
+
|| record.targetConfigDigest !== plan.targetConfigDigest) {
|
|
859
|
+
malformed();
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const checkpointFields = [
|
|
863
|
+
'releaseKey',
|
|
864
|
+
'target',
|
|
865
|
+
'buildRunId',
|
|
866
|
+
'artifactSha256',
|
|
867
|
+
'attemptId',
|
|
868
|
+
'leaseExpiresAt',
|
|
869
|
+
'status',
|
|
870
|
+
'remoteEditId',
|
|
871
|
+
'remoteUploadId',
|
|
872
|
+
'remoteBuildId',
|
|
873
|
+
];
|
|
874
|
+
/** Validate persisted checkpoint shape before any remote mutation or recovery. */
|
|
875
|
+
export function assertNativeSubmissionCheckpoint(value) {
|
|
876
|
+
if (!isRecord(value)) {
|
|
877
|
+
throw new Error('Native submission checkpoint is not an object.');
|
|
878
|
+
}
|
|
879
|
+
const id = (name) => value[name] === undefined || typeof value[name] === 'string'
|
|
880
|
+
&& remoteIdPattern.test(value[name]);
|
|
881
|
+
const androidStatuses = [
|
|
882
|
+
'started',
|
|
883
|
+
'edit-open',
|
|
884
|
+
'committed',
|
|
885
|
+
'failed',
|
|
886
|
+
'action-required',
|
|
887
|
+
'unknown',
|
|
888
|
+
];
|
|
889
|
+
const iosStatuses = [
|
|
890
|
+
'started',
|
|
891
|
+
'upload-committed',
|
|
892
|
+
'uploaded',
|
|
893
|
+
'processing',
|
|
894
|
+
'testflight-ready',
|
|
895
|
+
'failed',
|
|
896
|
+
'action-required',
|
|
897
|
+
'unknown',
|
|
898
|
+
];
|
|
899
|
+
if (typeof value.releaseKey !== 'string' || !releaseKeyPattern.test(value.releaseKey)
|
|
900
|
+
|| (value.target !== 'android' && value.target !== 'ios')
|
|
901
|
+
|| typeof value.buildRunId !== 'string' || value.buildRunId.trim() === ''
|
|
902
|
+
|| !sha256Pattern.test(String(value.artifactSha256))
|
|
903
|
+
|| typeof value.attemptId !== 'string' || !attemptIdPattern.test(value.attemptId)
|
|
904
|
+
|| typeof value.leaseExpiresAt !== 'string'
|
|
905
|
+
|| !Number.isFinite(Date.parse(value.leaseExpiresAt))
|
|
906
|
+
|| Object.keys(value).some((key) => !checkpointFields.includes(key))
|
|
907
|
+
|| !id('remoteEditId') || !id('remoteUploadId') || !id('remoteBuildId')
|
|
908
|
+
|| (value.target === 'android' && (!androidStatuses.includes(value.status)
|
|
909
|
+
|| value.remoteUploadId !== undefined || value.remoteBuildId !== undefined
|
|
910
|
+
|| (['edit-open', 'committed'].includes(String(value.status))
|
|
911
|
+
&& value.remoteEditId === undefined)))
|
|
912
|
+
|| (value.target === 'ios' && (!iosStatuses.includes(value.status)
|
|
913
|
+
|| value.remoteEditId !== undefined
|
|
914
|
+
|| (['upload-committed', 'uploaded', 'processing', 'testflight-ready']
|
|
915
|
+
.includes(String(value.status)) && value.remoteUploadId === undefined
|
|
916
|
+
&& (value.status === 'upload-committed' || value.remoteBuildId === undefined))))
|
|
917
|
+
|| (value.status === 'started'
|
|
918
|
+
&& (value.remoteEditId !== undefined || value.remoteUploadId !== undefined
|
|
919
|
+
|| value.remoteBuildId !== undefined))) {
|
|
920
|
+
throw new Error('Native submission checkpoint is malformed.');
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
function assertReservationHistory(gameId, initialLedger, ledger, plans) {
|
|
924
|
+
if (plans.length === 0) {
|
|
925
|
+
throw new Error(`Release state game ${gameId} has no reservation history.`);
|
|
926
|
+
}
|
|
927
|
+
const ordered = [...plans].sort((left, right) => left.releaseRevision - right.releaseRevision);
|
|
928
|
+
if (ordered[0]?.releaseRevision !== initialLedger.releaseRevision.lastAllocated + 1) {
|
|
929
|
+
throw new Error(`Release state game ${gameId} has a truncated reservation history.`);
|
|
930
|
+
}
|
|
931
|
+
for (let index = 1; index < ordered.length; index += 1) {
|
|
932
|
+
if (ordered[index]?.releaseRevision !== Number(ordered[index - 1]?.releaseRevision) + 1) {
|
|
933
|
+
throw new Error(`Release state game ${gameId} has nonconsecutive release revisions.`);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
if (ordered.at(-1)?.releaseRevision !== ledger.releaseRevision.lastAllocated) {
|
|
937
|
+
throw new Error(`Release state game ${gameId} ledger revision differs from reservations.`);
|
|
938
|
+
}
|
|
939
|
+
for (const target of ['android', 'ios']) {
|
|
940
|
+
const key = target === 'android' ? 'versionCode' : 'buildNumber';
|
|
941
|
+
const versions = ordered.flatMap((plan) => {
|
|
942
|
+
const entry = plan.targets[target];
|
|
943
|
+
return entry === undefined ? [] : [Number(entry[key])];
|
|
944
|
+
});
|
|
945
|
+
const initialVersion = target === 'android'
|
|
946
|
+
? initialLedger.platforms.android?.versionCode
|
|
947
|
+
: initialLedger.platforms.ios?.buildNumber;
|
|
948
|
+
if (versions.length > 0 && (initialVersion === undefined
|
|
949
|
+
|| versions[0] !== initialVersion + 1)) {
|
|
950
|
+
throw new Error(`Release state game ${gameId} has a truncated ${target} history.`);
|
|
951
|
+
}
|
|
952
|
+
for (let index = 1; index < versions.length; index += 1) {
|
|
953
|
+
if (versions[index] !== Number(versions[index - 1]) + 1) {
|
|
954
|
+
throw new Error(`Release state game ${gameId} has nonconsecutive ${target} numbers.`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
let ledgerVersion;
|
|
958
|
+
if (target === 'android') {
|
|
959
|
+
ledgerVersion = ledger.platforms.android?.versionCode;
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
ledgerVersion = ledger.platforms.ios?.buildNumber;
|
|
963
|
+
}
|
|
964
|
+
if (versions.length > 0 && versions.at(-1) !== ledgerVersion) {
|
|
965
|
+
throw new Error(`Release state game ${gameId} ${target} ledger differs from reservations.`);
|
|
966
|
+
}
|
|
967
|
+
if (versions.length === 0 && ledgerVersion !== initialVersion) {
|
|
968
|
+
throw new Error(`Release state game ${gameId} ${target} ledger differs from its baseline.`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
if (!isDeepStrictEqual(ledger.platforms['microsoft-store'], initialLedger.platforms['microsoft-store'])) {
|
|
972
|
+
throw new Error(`Release state game ${gameId} Microsoft Store ledger differs from its baseline.`);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
function isRecord(value) {
|
|
976
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
977
|
+
}
|
|
978
|
+
function ownValue(items, key) {
|
|
979
|
+
return Object.hasOwn(items, key) ? items[key] : undefined;
|
|
980
|
+
}
|
|
981
|
+
function assertReleaseKey(key) {
|
|
982
|
+
if (!releaseKeyPattern.test(key)) {
|
|
983
|
+
throw new Error('Release key must be a lowercase, path-safe identifier.');
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
async function sha256(file) {
|
|
987
|
+
const hash = createHash('sha256');
|
|
988
|
+
for await (const chunk of createReadStream(file)) {
|
|
989
|
+
hash.update(chunk);
|
|
990
|
+
}
|
|
991
|
+
return hash.digest('hex');
|
|
992
|
+
}
|
|
993
|
+
function isolatedGitEnvironment(source) {
|
|
994
|
+
const environment = { ...source };
|
|
995
|
+
for (const name of [
|
|
996
|
+
'GIT_DIR',
|
|
997
|
+
'GIT_WORK_TREE',
|
|
998
|
+
'GIT_INDEX_FILE',
|
|
999
|
+
'GIT_COMMON_DIR',
|
|
1000
|
+
'GIT_OBJECT_DIRECTORY',
|
|
1001
|
+
'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
1002
|
+
'GIT_NAMESPACE',
|
|
1003
|
+
]) {
|
|
1004
|
+
delete environment[name];
|
|
1005
|
+
}
|
|
1006
|
+
return environment;
|
|
1007
|
+
}
|
|
1008
|
+
async function git(session, args) {
|
|
1009
|
+
const secrets = [session.remoteUrl];
|
|
1010
|
+
for (const remote of [session.remoteUrl]) {
|
|
1011
|
+
try {
|
|
1012
|
+
const url = new URL(remote);
|
|
1013
|
+
for (const component of [url.username, url.password]) {
|
|
1014
|
+
if (component !== '') {
|
|
1015
|
+
secrets.push(component);
|
|
1016
|
+
try {
|
|
1017
|
+
secrets.push(decodeURIComponent(component));
|
|
1018
|
+
}
|
|
1019
|
+
catch {
|
|
1020
|
+
// Retain the raw component if it is not valid percent encoding.
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
catch {
|
|
1026
|
+
// Local paths and scp-style Git remotes are not URL instances.
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
const input = {
|
|
1030
|
+
command: 'git',
|
|
1031
|
+
// Apply before checkout too: a relative inherited hooksPath could execute
|
|
1032
|
+
// post-checkout from the fetched, untrusted release-state branch.
|
|
1033
|
+
args: ['-c', `core.hooksPath=${path.join(session.directory, '.git', '.mpgd-no-hooks')}`, ...args],
|
|
1034
|
+
cwd: session.directory,
|
|
1035
|
+
environment: session.environment,
|
|
1036
|
+
timeoutMs: 60_000,
|
|
1037
|
+
signal: session.signal,
|
|
1038
|
+
secretValues: secrets,
|
|
1039
|
+
captureMachineStdout: true,
|
|
1040
|
+
};
|
|
1041
|
+
const result = await runReleaseProcess(input);
|
|
1042
|
+
if (result.truncated || result.machineStdout === undefined) {
|
|
1043
|
+
throw new Error('Release-state Git machine output was truncated or unavailable.');
|
|
1044
|
+
}
|
|
1045
|
+
return { output: result.machineStdout };
|
|
1046
|
+
}
|