@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,244 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { checkpointNativeSubmission, freshSubmissionLeaseExpiresAt, isNativeSubmissionSettled, readNativeReleaseStatus, reclaimNativeSubmission, releaseNativeSubmissionLease, } from './release-state.js';
|
|
5
|
+
import { PlaySubmissionUncertainError, submitVerifiedAndroidBundle, } from './play-internal-submission.js';
|
|
6
|
+
import { IosSubmissionUncertainError, submitVerifiedIosBuild, } from './testflight-submission.js';
|
|
7
|
+
const productionPorts = {
|
|
8
|
+
readStatus: readNativeReleaseStatus,
|
|
9
|
+
checkpoint: checkpointNativeSubmission,
|
|
10
|
+
reclaim: reclaimNativeSubmission,
|
|
11
|
+
releaseLease: releaseNativeSubmissionLease,
|
|
12
|
+
submitAndroid: submitVerifiedAndroidBundle,
|
|
13
|
+
submitIos: submitVerifiedIosBuild,
|
|
14
|
+
};
|
|
15
|
+
/** Resume exactly one verified build; never rebuild or allocate another version here. */
|
|
16
|
+
export async function submitRecordedNativeTarget(input) {
|
|
17
|
+
return submitRecordedNativeTargetWithPorts(input, productionPorts);
|
|
18
|
+
}
|
|
19
|
+
export async function submitRecordedNativeTargetWithPorts(input, ports) {
|
|
20
|
+
const target = input.credential.target;
|
|
21
|
+
const planned = input.plan.targets.find((entry) => entry.target === target);
|
|
22
|
+
if (planned === undefined || !input.approved) {
|
|
23
|
+
throw new Error('Native store submission requires a planned target and explicit approval.');
|
|
24
|
+
}
|
|
25
|
+
const stateInput = {
|
|
26
|
+
gameRoot: input.plan.gameRoot,
|
|
27
|
+
gameId: input.gameId,
|
|
28
|
+
...(input.environment === undefined ? {} : { environment: input.environment }),
|
|
29
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
30
|
+
};
|
|
31
|
+
const status = await ports.readStatus({ ...stateInput, releaseKey: input.releaseKey });
|
|
32
|
+
if (status.plan.targetConfigDigest !== input.plan.targetConfigSha256
|
|
33
|
+
|| status.plan.gameId !== input.gameId
|
|
34
|
+
|| status.plan.targets[target] === undefined) {
|
|
35
|
+
throw new Error('Store submission plan differs from the reserved release.');
|
|
36
|
+
}
|
|
37
|
+
const record = status.builds[target];
|
|
38
|
+
if (record === undefined || record.releaseKey !== input.releaseKey
|
|
39
|
+
|| record.target !== target || record.inspectedAppId !== planned.appId
|
|
40
|
+
|| record.deployConfigSha256 !== input.plan.deployConfigSha256
|
|
41
|
+
|| record.deploymentProfile !== input.plan.profile
|
|
42
|
+
|| record.deploymentDestination !== planned.destination
|
|
43
|
+
|| record.internalTestGroupId !== planned.testGroup) {
|
|
44
|
+
throw new Error('Store submission requires a matching immutable native build record.');
|
|
45
|
+
}
|
|
46
|
+
const artifactFile = resolveRecordedArtifact(input.plan.gameRoot, record.artifactLocation);
|
|
47
|
+
let checkpoint = status.submissions[target];
|
|
48
|
+
if (checkpoint !== undefined && isNativeSubmissionSettled(checkpoint.status)) {
|
|
49
|
+
return checkpoint;
|
|
50
|
+
}
|
|
51
|
+
if (input.environment?.MPGD_VERIFY_RELEASE_MANIFEST === '1') {
|
|
52
|
+
verifyRecordedReleaseManifest(input.plan.gameRoot, record);
|
|
53
|
+
}
|
|
54
|
+
if (checkpoint?.status === 'failed'
|
|
55
|
+
|| (checkpoint?.status === 'action-required'
|
|
56
|
+
&& checkpoint.remoteUploadId === undefined && checkpoint.remoteBuildId === undefined)
|
|
57
|
+
|| (checkpoint?.status === 'unknown'
|
|
58
|
+
&& checkpoint.remoteEditId === undefined && checkpoint.remoteUploadId === undefined
|
|
59
|
+
&& checkpoint.remoteBuildId === undefined)) {
|
|
60
|
+
throw new Error('Submission requires operator reconciliation before another store call.');
|
|
61
|
+
}
|
|
62
|
+
if (checkpoint === undefined) {
|
|
63
|
+
checkpoint = (await ports.checkpoint({
|
|
64
|
+
...stateInput,
|
|
65
|
+
checkpoint: {
|
|
66
|
+
releaseKey: input.releaseKey,
|
|
67
|
+
target,
|
|
68
|
+
buildRunId: record.buildRunId,
|
|
69
|
+
artifactSha256: record.artifactSha256,
|
|
70
|
+
attemptId: randomBytes(16).toString('hex'),
|
|
71
|
+
leaseExpiresAt: freshSubmissionLeaseExpiresAt(),
|
|
72
|
+
status: 'started',
|
|
73
|
+
},
|
|
74
|
+
})).checkpoint;
|
|
75
|
+
}
|
|
76
|
+
else if (Date.parse(checkpoint.leaseExpiresAt) <= Date.now()) {
|
|
77
|
+
checkpoint = (await ports.reclaim({
|
|
78
|
+
...stateInput,
|
|
79
|
+
releaseKey: input.releaseKey,
|
|
80
|
+
target,
|
|
81
|
+
expectedStateCommit: status.stateCommit,
|
|
82
|
+
attemptId: randomBytes(16).toString('hex'),
|
|
83
|
+
})).checkpoint;
|
|
84
|
+
}
|
|
85
|
+
else if (input.attemptId !== checkpoint.attemptId) {
|
|
86
|
+
throw new Error('Another submission attempt owns the active release lease.');
|
|
87
|
+
}
|
|
88
|
+
if (checkpoint.buildRunId !== record.buildRunId
|
|
89
|
+
|| checkpoint.artifactSha256 !== record.artifactSha256) {
|
|
90
|
+
throw new Error('Submission checkpoint no longer matches its immutable build.');
|
|
91
|
+
}
|
|
92
|
+
let activeCheckpoint = checkpoint;
|
|
93
|
+
const save = async (patch) => {
|
|
94
|
+
activeCheckpoint = (await ports.checkpoint({
|
|
95
|
+
...stateInput,
|
|
96
|
+
checkpoint: {
|
|
97
|
+
...activeCheckpoint,
|
|
98
|
+
...patch,
|
|
99
|
+
leaseExpiresAt: freshSubmissionLeaseExpiresAt(),
|
|
100
|
+
},
|
|
101
|
+
})).checkpoint;
|
|
102
|
+
};
|
|
103
|
+
const releaseLease = async () => {
|
|
104
|
+
activeCheckpoint = await ports.releaseLease({
|
|
105
|
+
...stateInput,
|
|
106
|
+
releaseKey: input.releaseKey,
|
|
107
|
+
target,
|
|
108
|
+
attemptId: activeCheckpoint.attemptId,
|
|
109
|
+
});
|
|
110
|
+
};
|
|
111
|
+
if (input.credential.target === 'android') {
|
|
112
|
+
let result;
|
|
113
|
+
try {
|
|
114
|
+
result = await ports.submitAndroid({
|
|
115
|
+
record,
|
|
116
|
+
aabFile: artifactFile,
|
|
117
|
+
packageName: planned.appId,
|
|
118
|
+
serviceAccountFile: input.credential.serviceAccountFile,
|
|
119
|
+
...(activeCheckpoint.remoteEditId === undefined ? {} : {
|
|
120
|
+
resumeEditId: activeCheckpoint.remoteEditId,
|
|
121
|
+
}),
|
|
122
|
+
onEditCreated: async (editId) => {
|
|
123
|
+
await save({ status: 'edit-open', remoteEditId: editId });
|
|
124
|
+
},
|
|
125
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
try {
|
|
130
|
+
if (error instanceof PlaySubmissionUncertainError) {
|
|
131
|
+
if (error.stage === 'checkpoint'
|
|
132
|
+
&& activeCheckpoint.remoteEditId !== undefined
|
|
133
|
+
&& activeCheckpoint.remoteEditId !== error.editId) {
|
|
134
|
+
await save({ status: 'edit-open', remoteEditId: error.editId });
|
|
135
|
+
}
|
|
136
|
+
await save({ status: 'unknown', remoteEditId: error.editId });
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
await save({
|
|
140
|
+
status: activeCheckpoint.remoteEditId === undefined ? 'started' : 'unknown',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
await releaseLease();
|
|
144
|
+
}
|
|
145
|
+
catch (persistenceError) {
|
|
146
|
+
throw new AggregateError([error, persistenceError], `Google Play submission failed and its checkpoint could not be persisted${error instanceof PlaySubmissionUncertainError ? ` for edit ${error.editId}` : ''}.`);
|
|
147
|
+
}
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
await save({ status: 'committed', remoteEditId: result.editId });
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
if (planned.testGroup === undefined) {
|
|
154
|
+
throw new Error('TestFlight submission requires an internal group ID.');
|
|
155
|
+
}
|
|
156
|
+
let result;
|
|
157
|
+
try {
|
|
158
|
+
result = await ports.submitIos({
|
|
159
|
+
record,
|
|
160
|
+
ipaFile: artifactFile,
|
|
161
|
+
bundleId: planned.appId,
|
|
162
|
+
appStoreAppId: input.credential.appStoreAppId,
|
|
163
|
+
internalGroupId: planned.testGroup,
|
|
164
|
+
ascBinary: input.credential.ascBinary,
|
|
165
|
+
apiKeyId: input.credential.apiKeyId,
|
|
166
|
+
apiIssuerId: input.credential.apiIssuerId,
|
|
167
|
+
apiPrivateKeyBase64: input.credential.apiPrivateKeyBase64,
|
|
168
|
+
...(activeCheckpoint.remoteUploadId === undefined ? {} : {
|
|
169
|
+
resumeUploadId: activeCheckpoint.remoteUploadId,
|
|
170
|
+
resumeArtifactSha256: activeCheckpoint.artifactSha256,
|
|
171
|
+
}),
|
|
172
|
+
...(activeCheckpoint.remoteBuildId === undefined ? {} : {
|
|
173
|
+
resumeBuildId: activeCheckpoint.remoteBuildId,
|
|
174
|
+
}),
|
|
175
|
+
onUploadCommitted: async (uploadId) => {
|
|
176
|
+
await save({ status: 'upload-committed', remoteUploadId: uploadId });
|
|
177
|
+
},
|
|
178
|
+
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
try {
|
|
183
|
+
if (error instanceof IosSubmissionUncertainError) {
|
|
184
|
+
await save({ status: 'unknown', remoteUploadId: error.uploadId });
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
await save({
|
|
188
|
+
status: activeCheckpoint.remoteUploadId === undefined
|
|
189
|
+
&& activeCheckpoint.remoteBuildId === undefined ? 'started' : 'unknown',
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
await releaseLease();
|
|
193
|
+
}
|
|
194
|
+
catch (persistenceError) {
|
|
195
|
+
throw new AggregateError([error, persistenceError], `TestFlight submission failed and its checkpoint could not be persisted${error instanceof IosSubmissionUncertainError ? ` for upload ${error.uploadId}` : ''}.`);
|
|
196
|
+
}
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
await save({
|
|
200
|
+
status: result.status,
|
|
201
|
+
...(result.uploadId === undefined ? {} : { remoteUploadId: result.uploadId }),
|
|
202
|
+
...(result.buildId === undefined ? {} : { remoteBuildId: result.buildId }),
|
|
203
|
+
});
|
|
204
|
+
if (activeCheckpoint.status !== 'testflight-ready') {
|
|
205
|
+
await releaseLease();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return activeCheckpoint;
|
|
209
|
+
}
|
|
210
|
+
/** Validate the workflow-restored manifest against the immutable release-state hash. */
|
|
211
|
+
export function verifyRecordedReleaseManifest(gameRoot, record) {
|
|
212
|
+
if (!/^[0-9a-f]{64}$/u.test(record.releaseManifestSha256)) {
|
|
213
|
+
throw new Error('Recorded release manifest hash is invalid.');
|
|
214
|
+
}
|
|
215
|
+
const location = path.posix.join(path.posix.dirname(record.artifactLocation), `${record.target}-manifest-${record.releaseManifestSha256}.json`);
|
|
216
|
+
let manifestFile;
|
|
217
|
+
try {
|
|
218
|
+
manifestFile = resolveRecordedArtifact(gameRoot, location);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
throw new Error('Recorded release manifest is missing or outside the game directory.');
|
|
222
|
+
}
|
|
223
|
+
const metadata = statSync(manifestFile);
|
|
224
|
+
if (!metadata.isFile() || metadata.size > 2 * 1024 * 1024) {
|
|
225
|
+
throw new Error('Recorded release manifest is not a bounded file.');
|
|
226
|
+
}
|
|
227
|
+
const digest = createHash('sha256').update(readFileSync(manifestFile)).digest('hex');
|
|
228
|
+
if (digest !== record.releaseManifestSha256) {
|
|
229
|
+
throw new Error('Restored release manifest differs from the immutable build record.');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function resolveRecordedArtifact(gameRoot, recordedLocation) {
|
|
233
|
+
if (path.isAbsolute(recordedLocation)) {
|
|
234
|
+
throw new Error('Native artifact location must be game-relative.');
|
|
235
|
+
}
|
|
236
|
+
const root = realpathSync(gameRoot);
|
|
237
|
+
const artifact = realpathSync(path.resolve(root, recordedLocation));
|
|
238
|
+
const relative = path.relative(root, artifact);
|
|
239
|
+
if (relative === '' || relative === '..' || relative.startsWith(`..${path.sep}`)
|
|
240
|
+
|| path.isAbsolute(relative)) {
|
|
241
|
+
throw new Error('Native artifact location escapes the game directory.');
|
|
242
|
+
}
|
|
243
|
+
return artifact;
|
|
244
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type NativeDeployTarget } from './deploy-planning.js';
|
|
2
|
+
export interface InitializeNativeDeployWorkflowInput {
|
|
3
|
+
readonly game: string;
|
|
4
|
+
readonly profile: string;
|
|
5
|
+
readonly releaseBranch: string;
|
|
6
|
+
readonly targets?: readonly NativeDeployTarget[];
|
|
7
|
+
readonly gameId?: string;
|
|
8
|
+
readonly approvalEnvironment?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Write a game-owned, non-overwriting workflow that calls the installed CLI. */
|
|
11
|
+
export declare function initializeNativeDeployWorkflow(input: InitializeNativeDeployWorkflowInput): string;
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
5
|
+
import { planNativeDeployment, readNativeDeployCredentialNames, readNativeDeployTargetProfile, } from './deploy-planning.js';
|
|
6
|
+
import { pinnedAscVersion } from './testflight-submission.js';
|
|
7
|
+
const reservedWorkflowEnvironmentNames = new Set([
|
|
8
|
+
'PATH',
|
|
9
|
+
'HOME',
|
|
10
|
+
'JAVA_HOME',
|
|
11
|
+
'ANDROID_HOME',
|
|
12
|
+
'ANDROID_SDK_ROOT',
|
|
13
|
+
'NODE_OPTIONS',
|
|
14
|
+
'PNPM_HOME',
|
|
15
|
+
'COREPACK_HOME',
|
|
16
|
+
'CI',
|
|
17
|
+
'NPM_TOKEN',
|
|
18
|
+
'GITHUB_TOKEN',
|
|
19
|
+
'GH_TOKEN',
|
|
20
|
+
'RUNNER_TEMP',
|
|
21
|
+
'GITHUB_ENV',
|
|
22
|
+
'GITHUB_SERVER_URL',
|
|
23
|
+
'GITHUB_WORKSPACE',
|
|
24
|
+
'SIGNING_B64',
|
|
25
|
+
'SUBMISSION_B64',
|
|
26
|
+
'PROFILE_B64',
|
|
27
|
+
'GAME_PATH',
|
|
28
|
+
'GAME_ID',
|
|
29
|
+
'PROFILE',
|
|
30
|
+
'TARGET',
|
|
31
|
+
'RELEASE_ID',
|
|
32
|
+
'RELEASE_KEY',
|
|
33
|
+
'GAME_VERSION',
|
|
34
|
+
'INITIAL_LEDGER',
|
|
35
|
+
'ARTIFACT_RUN_ID',
|
|
36
|
+
'MPGD_ASC_BINARY',
|
|
37
|
+
'MPGD_IOS_PROVISIONING_PROFILE',
|
|
38
|
+
'MPGD_ANDROID_UPLOAD_STORE_PASSWORD',
|
|
39
|
+
'MPGD_ANDROID_UPLOAD_KEY_ALIAS',
|
|
40
|
+
'MPGD_ANDROID_UPLOAD_KEY_PASSWORD',
|
|
41
|
+
'MPGD_ANDROID_UPLOAD_CERT_SHA256',
|
|
42
|
+
'MPGD_IOS_SIGNING_P12_PASSWORD',
|
|
43
|
+
'MPGD_IOS_TEAM_ID',
|
|
44
|
+
'MPGD_ASC_APP_ID',
|
|
45
|
+
'MPGD_ASC_KEY_ID',
|
|
46
|
+
'MPGD_ASC_ISSUER_ID',
|
|
47
|
+
'MPGD_DEPENDENCY_INSTALL_ENV_NAMES',
|
|
48
|
+
'MPGD_VERIFY_RELEASE_MANIFEST',
|
|
49
|
+
'MPGD_RECORDED_BUILD_MARKER',
|
|
50
|
+
'MPGD_NEW_BUILD',
|
|
51
|
+
'MPGD_CLI_ARGV',
|
|
52
|
+
'ASC_TELEMETRY_DISABLED',
|
|
53
|
+
]);
|
|
54
|
+
/** Write a game-owned, non-overwriting workflow that calls the installed CLI. */
|
|
55
|
+
export function initializeNativeDeployWorkflow(input) {
|
|
56
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/u.test(input.releaseBranch)
|
|
57
|
+
|| input.releaseBranch.includes('..') || input.releaseBranch.includes('//')
|
|
58
|
+
|| input.releaseBranch.endsWith('/')) {
|
|
59
|
+
throw new Error('The native deployment release branch is invalid.');
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
execFileSync('git', ['check-ref-format', '--branch', input.releaseBranch], {
|
|
63
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw new Error('The native deployment release branch is invalid.');
|
|
68
|
+
}
|
|
69
|
+
const plan = planNativeDeployment({
|
|
70
|
+
game: input.game,
|
|
71
|
+
profile: input.profile,
|
|
72
|
+
...(input.targets === undefined ? {} : { targets: input.targets }),
|
|
73
|
+
});
|
|
74
|
+
const gameRoot = realpathSync(plan.gameRoot);
|
|
75
|
+
let repositoryRoot;
|
|
76
|
+
try {
|
|
77
|
+
repositoryRoot = realpathSync(execFileSync('git', [
|
|
78
|
+
'-C', gameRoot, 'rev-parse', '--show-toplevel',
|
|
79
|
+
], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim());
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
throw new Error(`The game must live inside a Git repository: ${gameRoot}`);
|
|
83
|
+
}
|
|
84
|
+
const relativeGame = relative(repositoryRoot, gameRoot).split(sep).join('/');
|
|
85
|
+
if (relativeGame === '..' || relativeGame.startsWith('../')
|
|
86
|
+
|| isAbsolute(relativeGame) || !/^[A-Za-z0-9._/-]*$/u.test(relativeGame)) {
|
|
87
|
+
throw new Error('The game must use a simple path inside its Git repository.');
|
|
88
|
+
}
|
|
89
|
+
const readableName = relativeGame === '' ? 'root' : relativeGame.replaceAll('/', '-');
|
|
90
|
+
const digest = createHash('sha256').update(relativeGame).digest('hex').slice(0, 8);
|
|
91
|
+
const slug = `${readableName.slice(0, 40)}-${digest}`.toLowerCase();
|
|
92
|
+
const gameId = input.gameId ?? slug;
|
|
93
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,127}$/u.test(gameId)) {
|
|
94
|
+
const reason = input.gameId === undefined
|
|
95
|
+
? 'The game directory must start with a letter or digit, or pass --game-id.'
|
|
96
|
+
: 'The native deployment game ID is invalid.';
|
|
97
|
+
throw new Error(reason);
|
|
98
|
+
}
|
|
99
|
+
const approvalEnvironment = input.approvalEnvironment ?? `native-${input.profile}`;
|
|
100
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/u.test(approvalEnvironment)) {
|
|
101
|
+
const reason = input.approvalEnvironment === undefined
|
|
102
|
+
? 'The profile name cannot form a CI environment; pass --approval-environment.'
|
|
103
|
+
: 'The native deployment approval environment is invalid.';
|
|
104
|
+
throw new Error(reason);
|
|
105
|
+
}
|
|
106
|
+
const workflowName = `mpgd-native-${slug}.yml`;
|
|
107
|
+
const githubDirectory = join(repositoryRoot, '.github');
|
|
108
|
+
const workflowDirectory = join(githubDirectory, 'workflows');
|
|
109
|
+
for (const directory of [githubDirectory, workflowDirectory]) {
|
|
110
|
+
if (existsSync(directory) && lstatSync(directory).isSymbolicLink()) {
|
|
111
|
+
throw new Error('Native deployment workflow directories must not be symlinks.');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
mkdirSync(workflowDirectory, { recursive: true });
|
|
115
|
+
const file = join(workflowDirectory, workflowName);
|
|
116
|
+
const secretPrefix = `MPGD_${slug.toUpperCase().replaceAll(/[^A-Z0-9]/gu, '_')}`;
|
|
117
|
+
const signingNames = {};
|
|
118
|
+
const submissionNames = {};
|
|
119
|
+
for (const name of readNativeDeployCredentialNames(plan)) {
|
|
120
|
+
if (!name.startsWith('MPGD_') || reservedWorkflowEnvironmentNames.has(name)) {
|
|
121
|
+
throw new Error(`Credential ${name} must use an unreserved MPGD_ prefix in CI.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const entry of plan.targets) {
|
|
125
|
+
const profile = readNativeDeployTargetProfile(plan, entry.target);
|
|
126
|
+
if (profile.signingCredential.env === profile.submissionCredential.env) {
|
|
127
|
+
throw new Error(`${entry.target} credentials need distinct environment names.`);
|
|
128
|
+
}
|
|
129
|
+
signingNames[entry.target] = profile.signingCredential.env;
|
|
130
|
+
submissionNames[entry.target] = profile.submissionCredential.env;
|
|
131
|
+
}
|
|
132
|
+
const renderInput = {
|
|
133
|
+
gamePath: relativeGame === '' ? '.' : relativeGame,
|
|
134
|
+
gameId,
|
|
135
|
+
profile: plan.profile,
|
|
136
|
+
releaseBranch: input.releaseBranch,
|
|
137
|
+
approvalEnvironment,
|
|
138
|
+
targets: plan.targets.map((entry) => entry.target),
|
|
139
|
+
appIds: Object.fromEntries(plan.targets.map((entry) => [entry.target, entry.appId])),
|
|
140
|
+
secretPrefix,
|
|
141
|
+
signingNames,
|
|
142
|
+
submissionNames,
|
|
143
|
+
};
|
|
144
|
+
const content = renderNativeDeployWorkflow(renderInput);
|
|
145
|
+
writeFileSync(file, content, { flag: 'wx', mode: 0o644 });
|
|
146
|
+
return file;
|
|
147
|
+
}
|
|
148
|
+
function expression(value) {
|
|
149
|
+
return '$' + `{{ ${value} }}`;
|
|
150
|
+
}
|
|
151
|
+
function quoted(value) {
|
|
152
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
153
|
+
}
|
|
154
|
+
function renderNativeDeployWorkflow(input) {
|
|
155
|
+
const options = input.targets.map((target) => ` - ${target}`);
|
|
156
|
+
const lines = [
|
|
157
|
+
'# Generated by mpgd deploy workflow init. Configure the protected environment first.',
|
|
158
|
+
`name: mpgd-native-${input.gameId}`,
|
|
159
|
+
'',
|
|
160
|
+
'on:',
|
|
161
|
+
' pull_request:',
|
|
162
|
+
' workflow_dispatch:',
|
|
163
|
+
' inputs:',
|
|
164
|
+
' target:',
|
|
165
|
+
' description: Native target to deploy',
|
|
166
|
+
' type: choice',
|
|
167
|
+
' required: true',
|
|
168
|
+
' options:',
|
|
169
|
+
...options,
|
|
170
|
+
' release:',
|
|
171
|
+
' description: Unique release label; the target suffix is added automatically',
|
|
172
|
+
' type: string',
|
|
173
|
+
' required: true',
|
|
174
|
+
' game_version:',
|
|
175
|
+
' description: Game SemVer version',
|
|
176
|
+
' type: string',
|
|
177
|
+
' required: true',
|
|
178
|
+
' initial_ledger:',
|
|
179
|
+
' description: Committed initial ledger path for a first release only',
|
|
180
|
+
' type: string',
|
|
181
|
+
' required: false',
|
|
182
|
+
' artifact_run_id:',
|
|
183
|
+
' description: Earlier workflow run ID to restore an identical recorded binary',
|
|
184
|
+
' type: string',
|
|
185
|
+
' required: false',
|
|
186
|
+
'',
|
|
187
|
+
'permissions:',
|
|
188
|
+
' contents: read',
|
|
189
|
+
'',
|
|
190
|
+
'jobs:',
|
|
191
|
+
' validate:',
|
|
192
|
+
" if: github.event_name == 'pull_request'",
|
|
193
|
+
' runs-on: ubuntu-latest',
|
|
194
|
+
' steps:',
|
|
195
|
+
' - uses: actions/checkout@v5',
|
|
196
|
+
' with:',
|
|
197
|
+
' persist-credentials: false',
|
|
198
|
+
' - uses: pnpm/action-setup@v6',
|
|
199
|
+
' with:',
|
|
200
|
+
' version: 11.7.0',
|
|
201
|
+
' - uses: actions/setup-node@v5',
|
|
202
|
+
' with:',
|
|
203
|
+
' node-version: 24',
|
|
204
|
+
' cache: pnpm',
|
|
205
|
+
' - run: pnpm install --frozen-lockfile',
|
|
206
|
+
' - name: Validate deployment plan without secrets',
|
|
207
|
+
' env:',
|
|
208
|
+
` GAME_PATH: ${quoted(input.gamePath)}`,
|
|
209
|
+
` PROFILE: ${quoted(input.profile)}`,
|
|
210
|
+
` TARGETS: ${quoted(input.targets.join(','))}`,
|
|
211
|
+
' run: |',
|
|
212
|
+
' pnpm --dir "$GAME_PATH" exec mpgd deploy plan --game . --profile "$PROFILE" \\',
|
|
213
|
+
' --targets "$TARGETS" --out "$RUNNER_TEMP/mpgd-pr-plan.json"',
|
|
214
|
+
' - run: pnpm --dir ' + quoted(input.gamePath)
|
|
215
|
+
+ ' run --if-present test:deploy-mock',
|
|
216
|
+
];
|
|
217
|
+
for (const target of input.targets) {
|
|
218
|
+
lines.push(...renderDeployJob(input, target));
|
|
219
|
+
}
|
|
220
|
+
return `${lines.join('\n')}\n`;
|
|
221
|
+
}
|
|
222
|
+
function renderDeployJob(input, target) {
|
|
223
|
+
const appId = input.appIds[target];
|
|
224
|
+
if (appId === undefined) {
|
|
225
|
+
throw new Error(`Missing ${target} app ID in the deployment plan.`);
|
|
226
|
+
}
|
|
227
|
+
const secret = (suffix) => expression(`secrets.${input.secretPrefix}_${suffix}`);
|
|
228
|
+
const lines = [
|
|
229
|
+
` deploy_${target}:`,
|
|
230
|
+
` if: github.event_name == 'workflow_dispatch' && github.ref == `
|
|
231
|
+
+ `'refs/heads/${input.releaseBranch}' && inputs.target == '${target}'`,
|
|
232
|
+
` runs-on: ${target === 'android' ? 'ubuntu-latest' : 'macos-26'}`,
|
|
233
|
+
` environment: ${quoted(input.approvalEnvironment)}`,
|
|
234
|
+
' permissions:',
|
|
235
|
+
' contents: write',
|
|
236
|
+
' actions: read',
|
|
237
|
+
' concurrency:',
|
|
238
|
+
` group: mpgd-${appId}-${target}-${expression('github.repository')}`,
|
|
239
|
+
' cancel-in-progress: false',
|
|
240
|
+
' env:',
|
|
241
|
+
` GAME_PATH: ${quoted(input.gamePath)}`,
|
|
242
|
+
` GAME_ID: ${quoted(input.gameId)}`,
|
|
243
|
+
` PROFILE: ${quoted(input.profile)}`,
|
|
244
|
+
` TARGET: ${target}`,
|
|
245
|
+
` RELEASE_ID: ${expression('inputs.release')}`,
|
|
246
|
+
` GAME_VERSION: ${expression('inputs.game_version')}`,
|
|
247
|
+
` INITIAL_LEDGER: ${expression('inputs.initial_ledger')}`,
|
|
248
|
+
` ARTIFACT_RUN_ID: ${expression('inputs.artifact_run_id')}`,
|
|
249
|
+
' steps:',
|
|
250
|
+
' - uses: actions/checkout@v5',
|
|
251
|
+
' with:',
|
|
252
|
+
' fetch-depth: 0',
|
|
253
|
+
' persist-credentials: false',
|
|
254
|
+
' - uses: pnpm/action-setup@v6',
|
|
255
|
+
' with:',
|
|
256
|
+
' version: 11.7.0',
|
|
257
|
+
' - uses: actions/setup-node@v5',
|
|
258
|
+
' with:',
|
|
259
|
+
' node-version: 24',
|
|
260
|
+
' cache: pnpm',
|
|
261
|
+
];
|
|
262
|
+
if (target === 'android') {
|
|
263
|
+
lines.push(' - uses: actions/setup-java@v5', ' with:', ' distribution: temurin', ' java-version: 21', ' - uses: android-actions/setup-android@v4', ' with:', " packages: ''", ' - run: sdkmanager "platforms;android-36" "build-tools;36.0.0"');
|
|
264
|
+
}
|
|
265
|
+
lines.push(' - name: Install committed game dependencies', ' env:', ` NPM_TOKEN: ${secret('NPM_READ_TOKEN')}`, ' run: pnpm install --frozen-lockfile', ' - name: Prepare explicit release and read-only plan', ' shell: bash', ' run: |', ' set -euo pipefail', ' [[ "$RELEASE_ID" =~ ^[a-z0-9][a-z0-9._-]{0,100}$ ]]', ' if [[ -n "$ARTIFACT_RUN_ID" ]]; then', ' [[ "$ARTIFACT_RUN_ID" =~ ^[0-9]+$ ]]', ' fi', ' echo "RELEASE_KEY=$RELEASE_ID-$TARGET" >> "$GITHUB_ENV"', ' pnpm --dir "$GAME_PATH" exec mpgd deploy plan --game . --profile "$PROFILE" \\', ' --targets "$TARGET" --out "$RUNNER_TEMP/mpgd-$TARGET-plan.json"', ' - name: Keep release artifacts out of the Git index', ' shell: bash', ' run: |', ' set -euo pipefail', ' if [[ -n "$(git ls-files -- "$GAME_PATH/.mpgd")" ]]; then', ' echo "Tracked .mpgd files would be overwritten by artifact restore" >&2', ' exit 1', ' fi', ' game_prefix="$(git -C "$GAME_PATH" rev-parse --show-prefix)"', ' printf "/%s.mpgd/\\n" "$game_prefix" >> "$(git rev-parse --git-path info/exclude)"', ' git check-ignore -q -- "$GAME_PATH/.mpgd/releases/ignore-probe"', ' - name: Restore the exact recorded release bundle', " if: inputs.artifact_run_id != ''", ' uses: actions/download-artifact@v7', ' with:', ` name: ${input.gameId}-${target}-${expression('env.RELEASE_KEY')}-release`, ` run-id: ${expression('inputs.artifact_run_id')}`, ` github-token: ${expression('github.token')}`, ` path: ${input.gamePath}/.mpgd/releases/${expression('env.RELEASE_KEY')}`, ' - name: Deploy verified internal-test binary', ' shell: bash', ' env:', ` NPM_TOKEN: ${secret('NPM_READ_TOKEN')}`, ` GITHUB_TOKEN: ${expression('github.token')}`, ' MPGD_VERIFY_RELEASE_MANIFEST: "1"');
|
|
266
|
+
lines.push(...renderCredentialEnvironment(target, secret));
|
|
267
|
+
lines.push(' run: |', ' set -euo pipefail', ' umask 077', ' secret_dir="$(mktemp -d)"', ' trap \'rm -rf -- "$secret_dir"\' EXIT', ' git_auth="$(node -e \'process.stdout.write(', ' Buffer.from("x-access-token:"+process.env.GITHUB_TOKEN).toString("base64"))\')"', ' [[ "$GITHUB_SERVER_URL" == https://* ]]', ' export GIT_CONFIG_COUNT=1', ' export GIT_CONFIG_KEY_0="http.${GITHUB_SERVER_URL%/}/.extraheader"', ' export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $git_auth"', ' write_secret() {', ' node -e \'const fs=require("node:fs"); const name=process.argv[1];', ' const value=process.env[name]; if (!value) throw Error(`Missing ${name}`);', ' const decoded=Buffer.from(value,"base64");', ' if (!decoded.length || decoded.toString("base64") !== value.trim())', ' throw Error(`Invalid ${name}`);', ' fs.writeFileSync(process.argv[2], decoded, {mode:0o600});\' "$1" "$2"', ' }');
|
|
268
|
+
lines.push(...renderCredentialSetup(input, target));
|
|
269
|
+
lines.push(' if [[ -n "$NPM_TOKEN" ]]; then', ' export MPGD_DEPENDENCY_INSTALL_ENV_NAMES=NPM_TOKEN', ' fi', ' export MPGD_RECORDED_BUILD_MARKER="$RUNNER_TEMP/mpgd-$TARGET-recorded-build"', ' args=(deploy run --plan "$RUNNER_TEMP/mpgd-$TARGET-plan.json"', ' --game . --game-id "$GAME_ID" --game-version "$GAME_VERSION"', ' --release "$RELEASE_KEY" --approve)', ' if [[ -n "$INITIAL_LEDGER" ]]; then', ' [[ "$INITIAL_LEDGER" =~ ^[A-Za-z0-9._/-]+$ ]]', ' [[ "$INITIAL_LEDGER" != /* && "$INITIAL_LEDGER" != *..* ]]', ' git ls-files --error-unmatch -- "$INITIAL_LEDGER" >/dev/null', ' args+=(--initial-ledger "$GITHUB_WORKSPACE/$INITIAL_LEDGER")', ' fi', ' pnpm --dir "$GAME_PATH" exec mpgd "${args[@]}"', ' - name: Detect a newly recorded binary', " if: always() && env.RELEASE_KEY != ''", ' shell: bash', ' run: |', ' set -euo pipefail', ' if [[ -f "$RUNNER_TEMP/mpgd-$TARGET-recorded-build" ]] &&', ' grep -Fxq -- "$TARGET" "$RUNNER_TEMP/mpgd-$TARGET-recorded-build"; then', ' echo "MPGD_NEW_BUILD=1" >> "$GITHUB_ENV"', ' fi', ' - name: Retain verified release bundle', " if: always() && env.RELEASE_KEY != '' && env.MPGD_NEW_BUILD == '1'", ' uses: actions/upload-artifact@v6', ' with:', ` name: ${input.gameId}-${target}-${expression('env.RELEASE_KEY')}-release`, ` path: ${input.gamePath}/.mpgd/releases/${expression('env.RELEASE_KEY')}`, ' if-no-files-found: error', ' include-hidden-files: true', ' retention-days: 30');
|
|
270
|
+
return lines;
|
|
271
|
+
}
|
|
272
|
+
function renderCredentialEnvironment(target, secret) {
|
|
273
|
+
if (target === 'android') {
|
|
274
|
+
return [
|
|
275
|
+
` SIGNING_B64: ${secret('ANDROID_KEYSTORE_B64')}`,
|
|
276
|
+
` SUBMISSION_B64: ${secret('PLAY_SERVICE_ACCOUNT_B64')}`,
|
|
277
|
+
` MPGD_ANDROID_UPLOAD_STORE_PASSWORD: ${secret('ANDROID_STORE_PASSWORD')}`,
|
|
278
|
+
` MPGD_ANDROID_UPLOAD_KEY_ALIAS: ${secret('ANDROID_KEY_ALIAS')}`,
|
|
279
|
+
` MPGD_ANDROID_UPLOAD_KEY_PASSWORD: ${secret('ANDROID_KEY_PASSWORD')}`,
|
|
280
|
+
` MPGD_ANDROID_UPLOAD_CERT_SHA256: ${secret('ANDROID_CERT_SHA256')}`,
|
|
281
|
+
];
|
|
282
|
+
}
|
|
283
|
+
return [
|
|
284
|
+
` SIGNING_B64: ${secret('IOS_P12_B64')}`,
|
|
285
|
+
` PROFILE_B64: ${secret('IOS_PROFILE_B64')}`,
|
|
286
|
+
` SUBMISSION_B64: ${secret('ASC_API_KEY_B64')}`,
|
|
287
|
+
` MPGD_IOS_SIGNING_P12_PASSWORD: ${secret('IOS_P12_PASSWORD')}`,
|
|
288
|
+
` MPGD_IOS_TEAM_ID: ${secret('IOS_TEAM_ID')}`,
|
|
289
|
+
` MPGD_ASC_APP_ID: ${secret('ASC_APP_ID')}`,
|
|
290
|
+
` MPGD_ASC_KEY_ID: ${secret('ASC_KEY_ID')}`,
|
|
291
|
+
` MPGD_ASC_ISSUER_ID: ${secret('ASC_ISSUER_ID')}`,
|
|
292
|
+
' ASC_TELEMETRY_DISABLED: 1',
|
|
293
|
+
];
|
|
294
|
+
}
|
|
295
|
+
function renderCredentialSetup(input, target) {
|
|
296
|
+
const signingName = input.signingNames[target];
|
|
297
|
+
const submissionName = input.submissionNames[target];
|
|
298
|
+
if (signingName === undefined || submissionName === undefined) {
|
|
299
|
+
throw new Error(`Missing ${target} deployment credential references.`);
|
|
300
|
+
}
|
|
301
|
+
if (target === 'android') {
|
|
302
|
+
return [
|
|
303
|
+
' write_secret SIGNING_B64 "$secret_dir/upload.keystore"',
|
|
304
|
+
' write_secret SUBMISSION_B64 "$secret_dir/play-service-account.json"',
|
|
305
|
+
` export ${signingName}="$secret_dir/upload.keystore"`,
|
|
306
|
+
` export ${submissionName}="$secret_dir/play-service-account.json"`,
|
|
307
|
+
' unset SIGNING_B64 SUBMISSION_B64',
|
|
308
|
+
];
|
|
309
|
+
}
|
|
310
|
+
return [
|
|
311
|
+
' write_secret SIGNING_B64 "$secret_dir/signing.p12"',
|
|
312
|
+
' write_secret PROFILE_B64 "$secret_dir/profile.mobileprovision"',
|
|
313
|
+
` export ${signingName}="$secret_dir/signing.p12"`,
|
|
314
|
+
` export ${submissionName}="$SUBMISSION_B64"`,
|
|
315
|
+
' export MPGD_IOS_PROVISIONING_PROFILE="$secret_dir/profile.mobileprovision"',
|
|
316
|
+
' unset SIGNING_B64 PROFILE_B64 SUBMISSION_B64',
|
|
317
|
+
' case "$(uname -m)" in',
|
|
318
|
+
` arm64) asc_asset=asc_${pinnedAscVersion}_macOS_arm64 ;;`,
|
|
319
|
+
` x86_64) asc_asset=asc_${pinnedAscVersion}_macOS_amd64 ;;`,
|
|
320
|
+
' *) echo "Unsupported macOS architecture" >&2; exit 1 ;;',
|
|
321
|
+
' esac',
|
|
322
|
+
' curl -fL --retry 3 \\',
|
|
323
|
+
` "https://github.com/rorkai/App-Store-Connect-CLI/releases/download/${pinnedAscVersion}/$asc_asset" \\`,
|
|
324
|
+
' -o "$secret_dir/asc"',
|
|
325
|
+
' chmod 700 "$secret_dir/asc"',
|
|
326
|
+
' export MPGD_ASC_BINARY="$secret_dir/asc"',
|
|
327
|
+
];
|
|
328
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PlayPublisher } from './play-publisher-port.js';
|
|
2
|
+
import type { ImmutableNativeBuildRecord } from './release-state.js';
|
|
3
|
+
export interface PlayInternalSubmissionInput {
|
|
4
|
+
readonly record: ImmutableNativeBuildRecord;
|
|
5
|
+
readonly aabFile: string;
|
|
6
|
+
readonly packageName: string;
|
|
7
|
+
/** Absolute path to a game-owned Google service account JSON file. */
|
|
8
|
+
readonly serviceAccountFile: string;
|
|
9
|
+
/** Reuse the checkpointed edit; never create a replacement after an uncertain response. */
|
|
10
|
+
readonly resumeEditId?: string;
|
|
11
|
+
/** Persist this edit ID before any upload begins, for later reconciliation. */
|
|
12
|
+
readonly onEditCreated?: (editId: string) => Promise<void>;
|
|
13
|
+
readonly signal?: AbortSignal;
|
|
14
|
+
}
|
|
15
|
+
export interface PlayInternalSubmissionResult {
|
|
16
|
+
readonly status: 'committed';
|
|
17
|
+
readonly packageName: string;
|
|
18
|
+
readonly editId: string;
|
|
19
|
+
readonly track: 'internal';
|
|
20
|
+
readonly versionCode: number;
|
|
21
|
+
readonly bundleSha256: string;
|
|
22
|
+
readonly alreadyCommitted: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare class PlaySubmissionUncertainError extends Error {
|
|
25
|
+
readonly stage: 'checkpoint' | 'upload' | 'track-update' | 'validate' | 'commit';
|
|
26
|
+
readonly editId: string;
|
|
27
|
+
constructor(stage: 'checkpoint' | 'upload' | 'track-update' | 'validate' | 'commit', editId: string);
|
|
28
|
+
}
|
|
29
|
+
/** Submit only a previously inspected, immutable Android build record. */
|
|
30
|
+
export declare function submitVerifiedAndroidBundle(input: PlayInternalSubmissionInput): Promise<PlayInternalSubmissionResult>;
|
|
31
|
+
/** Internal injection point for credential-free SDK contract tests. */
|
|
32
|
+
export declare function submitVerifiedAndroidBundleWithPublisher(input: PlayInternalSubmissionInput, publisher: PlayPublisher, requestRootUrl?: string): Promise<PlayInternalSubmissionResult>;
|