@mpgd/cli 0.34.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 +483 -13
- 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 +41 -0
- package/dist/kit-upgrade.js +489 -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 +20 -13
- package/templates/phaser-game/README.md +23 -8
- package/templates/phaser-game/src/main.ts +34 -33
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type NativeDeploymentPlan } from './deploy-planning.js';
|
|
2
|
+
export interface PinnedReleaseInput {
|
|
3
|
+
readonly gameRoot: string;
|
|
4
|
+
readonly deploymentProfile: string;
|
|
5
|
+
readonly buildProfile: NativeDeploymentPlan['buildProfile'];
|
|
6
|
+
readonly targets: readonly ('android' | 'ios')[];
|
|
7
|
+
readonly gameGitSha: string;
|
|
8
|
+
readonly lockfileSha256: string;
|
|
9
|
+
readonly targetConfigSha256: string;
|
|
10
|
+
readonly deployConfigSha256: string;
|
|
11
|
+
readonly kitPackageVersion: string;
|
|
12
|
+
readonly kitGitSha: string;
|
|
13
|
+
}
|
|
14
|
+
export interface PinnedReleaseWorkspace {
|
|
15
|
+
readonly workspaceRoot: string;
|
|
16
|
+
readonly gameRoot: string;
|
|
17
|
+
readonly input: PinnedReleaseInput;
|
|
18
|
+
dispose(): void;
|
|
19
|
+
}
|
|
20
|
+
export interface PinnedNativeBuildResult {
|
|
21
|
+
readonly artifact: string;
|
|
22
|
+
readonly releaseManifest: string;
|
|
23
|
+
readonly runId: string;
|
|
24
|
+
}
|
|
25
|
+
/** Freeze a read-only deployment plan into the exact inputs used by a release run. */
|
|
26
|
+
export declare function pinNativeDeploymentPlan(plan: NativeDeploymentPlan, kit: {
|
|
27
|
+
readonly packageVersion: string;
|
|
28
|
+
readonly gitSha: string;
|
|
29
|
+
}, options?: {
|
|
30
|
+
readonly environment?: NodeJS.ProcessEnv;
|
|
31
|
+
readonly signal?: AbortSignal;
|
|
32
|
+
}): Promise<PinnedReleaseInput>;
|
|
33
|
+
/** Clone the selected game commit, including its monorepo workspace packages. */
|
|
34
|
+
export declare function preparePinnedReleaseWorkspace(input: PinnedReleaseInput, options?: {
|
|
35
|
+
readonly temporaryParent?: string;
|
|
36
|
+
readonly environment?: NodeJS.ProcessEnv;
|
|
37
|
+
readonly signal?: AbortSignal;
|
|
38
|
+
}): Promise<PinnedReleaseWorkspace>;
|
|
39
|
+
/** Ensure failed, cancelled, and successful release runs all remove their checkout. */
|
|
40
|
+
export declare function withPinnedReleaseWorkspace<T>(input: PinnedReleaseInput, action: (workspace: PinnedReleaseWorkspace) => Promise<T>, options?: {
|
|
41
|
+
readonly temporaryParent?: string;
|
|
42
|
+
readonly environment?: NodeJS.ProcessEnv;
|
|
43
|
+
readonly signal?: AbortSignal;
|
|
44
|
+
}): Promise<T>;
|
|
45
|
+
/** Install only inside the pinned checkout; the package store may be shared. */
|
|
46
|
+
export declare function installPinnedReleaseDependencies(workspace: PinnedReleaseWorkspace, options?: {
|
|
47
|
+
readonly environment?: NodeJS.ProcessEnv;
|
|
48
|
+
readonly signal?: AbortSignal;
|
|
49
|
+
readonly timeoutMs?: number;
|
|
50
|
+
readonly secretValues?: readonly string[];
|
|
51
|
+
}): Promise<void>;
|
|
52
|
+
/** Run the existing packed CLI native builder against the pinned checkout. */
|
|
53
|
+
export declare function runPinnedNativeBuild(workspace: PinnedReleaseWorkspace, input: {
|
|
54
|
+
readonly target: 'android' | 'ios';
|
|
55
|
+
readonly profile: string;
|
|
56
|
+
readonly mode: string;
|
|
57
|
+
readonly environment?: NodeJS.ProcessEnv;
|
|
58
|
+
readonly signal?: AbortSignal;
|
|
59
|
+
readonly timeoutMs?: number;
|
|
60
|
+
readonly secretValues?: readonly string[];
|
|
61
|
+
}): Promise<PinnedNativeBuildResult>;
|
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { planNativeDeployment } from './deploy-planning.js';
|
|
7
|
+
import { runReleaseProcess, } from './deploy-process.js';
|
|
8
|
+
const gitShaPattern = /^[0-9a-f]{40}$/u;
|
|
9
|
+
const sha256Pattern = /^[0-9a-f]{64}$/u;
|
|
10
|
+
const defaultCloneTimeoutMs = 5 * 60_000;
|
|
11
|
+
const defaultInstallTimeoutMs = 15 * 60_000;
|
|
12
|
+
const defaultBuildTimeoutMs = 45 * 60_000;
|
|
13
|
+
const pinnedNativeConfigOverrides = [
|
|
14
|
+
'MPGD_PRODUCT_CATALOG_FILE',
|
|
15
|
+
'MPGD_AD_PLACEMENTS_FILE',
|
|
16
|
+
'MPGD_TARGET_CONFIG_EXTENSIONS_FILE',
|
|
17
|
+
'MPGD_RELEASE_MANIFEST_FILE',
|
|
18
|
+
'MPGD_EFFECTIVE_TARGET_CONFIG_OUTPUT_DIR',
|
|
19
|
+
'MPGD_ICON_MANIFEST_PATH',
|
|
20
|
+
'MPGD_ICON_MANIFEST_ARTIFACT_PATH',
|
|
21
|
+
];
|
|
22
|
+
/** Freeze a read-only deployment plan into the exact inputs used by a release run. */
|
|
23
|
+
export async function pinNativeDeploymentPlan(plan, kit, options = {}) {
|
|
24
|
+
const current = planNativeDeployment({
|
|
25
|
+
game: plan.gameRoot,
|
|
26
|
+
profile: plan.profile,
|
|
27
|
+
targets: plan.targets.map((target) => target.target),
|
|
28
|
+
});
|
|
29
|
+
if (!sameDeploymentPlan(current, plan)) {
|
|
30
|
+
throw new Error('Deployment plan differs from the current game configuration.');
|
|
31
|
+
}
|
|
32
|
+
const gameRoot = realpathSync(plan.gameRoot);
|
|
33
|
+
const revision = await runReleaseProcess({
|
|
34
|
+
command: 'git',
|
|
35
|
+
args: ['-C', gameRoot, 'rev-parse', 'HEAD'],
|
|
36
|
+
cwd: gameRoot,
|
|
37
|
+
environment: options.environment,
|
|
38
|
+
timeoutMs: 10_000,
|
|
39
|
+
signal: options.signal,
|
|
40
|
+
captureMachineStdout: true,
|
|
41
|
+
});
|
|
42
|
+
const sourceRepository = await runReleaseProcess({
|
|
43
|
+
command: 'git',
|
|
44
|
+
args: ['-C', gameRoot, 'rev-parse', '--show-toplevel'],
|
|
45
|
+
cwd: gameRoot,
|
|
46
|
+
environment: options.environment,
|
|
47
|
+
timeoutMs: 10_000,
|
|
48
|
+
signal: options.signal,
|
|
49
|
+
captureMachineStdout: true,
|
|
50
|
+
});
|
|
51
|
+
const repositoryRoot = realpathSync(machineOutput(sourceRepository).trim());
|
|
52
|
+
const relativeGame = path.relative(repositoryRoot, gameRoot);
|
|
53
|
+
if (relativeGame === '..' || relativeGame.startsWith(`..${path.sep}`)
|
|
54
|
+
|| path.isAbsolute(relativeGame)) {
|
|
55
|
+
throw new Error('Game path is outside its Git repository.');
|
|
56
|
+
}
|
|
57
|
+
assertNoGitSubmodules(await listTrackedIndex(repositoryRoot, options.environment, options.signal));
|
|
58
|
+
const dirtyInputs = await runReleaseProcess({
|
|
59
|
+
command: 'git',
|
|
60
|
+
args: ['-C', repositoryRoot, 'status', '--porcelain=v1', '--untracked-files=all'],
|
|
61
|
+
cwd: repositoryRoot,
|
|
62
|
+
environment: options.environment,
|
|
63
|
+
timeoutMs: 10_000,
|
|
64
|
+
signal: options.signal,
|
|
65
|
+
captureMachineStdout: true,
|
|
66
|
+
});
|
|
67
|
+
if (machineOutput(dirtyInputs) !== '') {
|
|
68
|
+
throw new Error('Pinned release inputs have uncommitted changes. Commit or remove all repository changes before deploying HEAD.');
|
|
69
|
+
}
|
|
70
|
+
for (const file of [
|
|
71
|
+
path.join(repositoryRoot, 'pnpm-lock.yaml'),
|
|
72
|
+
path.join(gameRoot, 'mpgd.targets.json'),
|
|
73
|
+
path.join(gameRoot, 'mpgd.deploy.json'),
|
|
74
|
+
]) {
|
|
75
|
+
assertPinnedInputPath(repositoryRoot, file);
|
|
76
|
+
}
|
|
77
|
+
const input = {
|
|
78
|
+
gameRoot,
|
|
79
|
+
deploymentProfile: plan.profile,
|
|
80
|
+
buildProfile: plan.buildProfile,
|
|
81
|
+
targets: plan.targets.map((target) => target.target),
|
|
82
|
+
gameGitSha: machineOutput(revision).trim(),
|
|
83
|
+
lockfileSha256: sha256(path.join(repositoryRoot, 'pnpm-lock.yaml')),
|
|
84
|
+
targetConfigSha256: plan.targetConfigSha256,
|
|
85
|
+
deployConfigSha256: plan.deployConfigSha256,
|
|
86
|
+
kitPackageVersion: kit.packageVersion,
|
|
87
|
+
kitGitSha: kit.gitSha,
|
|
88
|
+
};
|
|
89
|
+
assertPinnedInput(input);
|
|
90
|
+
return input;
|
|
91
|
+
}
|
|
92
|
+
/** Clone the selected game commit, including its monorepo workspace packages. */
|
|
93
|
+
export async function preparePinnedReleaseWorkspace(input, options = {}) {
|
|
94
|
+
assertPinnedInput(input);
|
|
95
|
+
const sourceGame = realpathSync(input.gameRoot);
|
|
96
|
+
const environment = options.environment ?? process.env;
|
|
97
|
+
const gitTopLevel = await runReleaseProcess({
|
|
98
|
+
command: 'git',
|
|
99
|
+
args: ['-C', sourceGame, 'rev-parse', '--show-toplevel'],
|
|
100
|
+
cwd: sourceGame,
|
|
101
|
+
environment,
|
|
102
|
+
timeoutMs: 10_000,
|
|
103
|
+
signal: options.signal,
|
|
104
|
+
captureMachineStdout: true,
|
|
105
|
+
});
|
|
106
|
+
const sourceRepository = realpathSync(machineOutput(gitTopLevel).trim());
|
|
107
|
+
const relativeGame = path.relative(sourceRepository, sourceGame);
|
|
108
|
+
if (relativeGame === '..' || relativeGame.startsWith(`..${path.sep}`)
|
|
109
|
+
|| path.isAbsolute(relativeGame)) {
|
|
110
|
+
throw new Error('Game path is outside its Git repository.');
|
|
111
|
+
}
|
|
112
|
+
const parent = options.temporaryParent ?? tmpdir();
|
|
113
|
+
mkdirSync(parent, { recursive: true });
|
|
114
|
+
const ownedRoot = mkdtempSync(path.join(parent, 'mpgd-release-workspace-'));
|
|
115
|
+
const workspaceRoot = path.join(ownedRoot, 'checkout');
|
|
116
|
+
try {
|
|
117
|
+
await runReleaseProcess({
|
|
118
|
+
command: 'git',
|
|
119
|
+
args: [
|
|
120
|
+
'clone',
|
|
121
|
+
'--local',
|
|
122
|
+
'--no-hardlinks',
|
|
123
|
+
'--no-checkout',
|
|
124
|
+
'--',
|
|
125
|
+
sourceRepository,
|
|
126
|
+
workspaceRoot,
|
|
127
|
+
],
|
|
128
|
+
cwd: ownedRoot,
|
|
129
|
+
environment,
|
|
130
|
+
timeoutMs: defaultCloneTimeoutMs,
|
|
131
|
+
signal: options.signal,
|
|
132
|
+
});
|
|
133
|
+
await runReleaseProcess({
|
|
134
|
+
command: 'git',
|
|
135
|
+
args: ['-C', workspaceRoot, 'checkout', '--detach', input.gameGitSha],
|
|
136
|
+
cwd: workspaceRoot,
|
|
137
|
+
environment,
|
|
138
|
+
timeoutMs: defaultCloneTimeoutMs,
|
|
139
|
+
signal: options.signal,
|
|
140
|
+
});
|
|
141
|
+
const actualRevision = await runReleaseProcess({
|
|
142
|
+
command: 'git',
|
|
143
|
+
args: ['-C', workspaceRoot, 'rev-parse', 'HEAD'],
|
|
144
|
+
cwd: workspaceRoot,
|
|
145
|
+
environment,
|
|
146
|
+
timeoutMs: 10_000,
|
|
147
|
+
signal: options.signal,
|
|
148
|
+
captureMachineStdout: true,
|
|
149
|
+
});
|
|
150
|
+
if (machineOutput(actualRevision).trim() !== input.gameGitSha) {
|
|
151
|
+
throw new Error('Pinned game Git revision changed during workspace preparation.');
|
|
152
|
+
}
|
|
153
|
+
const gameRoot = path.join(workspaceRoot, relativeGame);
|
|
154
|
+
assertWorkspaceOutputRoots(workspaceRoot, gameRoot);
|
|
155
|
+
assertPinnedFiles(workspaceRoot, gameRoot, input);
|
|
156
|
+
assertPinnedTargetPaths(gameRoot, input);
|
|
157
|
+
await assertNoEscapingTrackedSymlinks(workspaceRoot, environment, options.signal);
|
|
158
|
+
return {
|
|
159
|
+
workspaceRoot,
|
|
160
|
+
gameRoot,
|
|
161
|
+
input,
|
|
162
|
+
dispose() {
|
|
163
|
+
rmSync(ownedRoot, { recursive: true, force: true });
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
rmSync(ownedRoot, { recursive: true, force: true });
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Ensure failed, cancelled, and successful release runs all remove their checkout. */
|
|
173
|
+
export async function withPinnedReleaseWorkspace(input, action, options = {}) {
|
|
174
|
+
const workspace = await preparePinnedReleaseWorkspace(input, options);
|
|
175
|
+
try {
|
|
176
|
+
return await action(workspace);
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
workspace.dispose();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/** Install only inside the pinned checkout; the package store may be shared. */
|
|
183
|
+
export async function installPinnedReleaseDependencies(workspace, options = {}) {
|
|
184
|
+
assertWorkspaceOutputRoots(workspace.workspaceRoot, workspace.gameRoot);
|
|
185
|
+
assertPinnedFiles(workspace.workspaceRoot, workspace.gameRoot, workspace.input);
|
|
186
|
+
assertPinnedTargetPaths(workspace.gameRoot, workspace.input);
|
|
187
|
+
await runReleaseProcess({
|
|
188
|
+
command: 'pnpm',
|
|
189
|
+
args: ['install', '--frozen-lockfile', '--prod=false'],
|
|
190
|
+
cwd: workspace.workspaceRoot,
|
|
191
|
+
environment: options.environment ?? process.env,
|
|
192
|
+
timeoutMs: options.timeoutMs ?? defaultInstallTimeoutMs,
|
|
193
|
+
signal: options.signal,
|
|
194
|
+
secretValues: options.secretValues,
|
|
195
|
+
});
|
|
196
|
+
assertWorkspaceOutputRoots(workspace.workspaceRoot, workspace.gameRoot);
|
|
197
|
+
assertPinnedFiles(workspace.workspaceRoot, workspace.gameRoot, workspace.input);
|
|
198
|
+
assertPinnedTargetPaths(workspace.gameRoot, workspace.input);
|
|
199
|
+
}
|
|
200
|
+
/** Run the existing packed CLI native builder against the pinned checkout. */
|
|
201
|
+
export async function runPinnedNativeBuild(workspace, input) {
|
|
202
|
+
assertWorkspaceOutputRoots(workspace.workspaceRoot, workspace.gameRoot);
|
|
203
|
+
assertPinnedFiles(workspace.workspaceRoot, workspace.gameRoot, workspace.input);
|
|
204
|
+
assertPinnedTargetPaths(workspace.gameRoot, workspace.input);
|
|
205
|
+
if (!workspace.input.targets.includes(input.target)
|
|
206
|
+
|| input.profile !== workspace.input.buildProfile) {
|
|
207
|
+
throw new Error('Native build target and profile must match the pinned deployment plan.');
|
|
208
|
+
}
|
|
209
|
+
const cliExecutable = assertInstalledKitIdentity(workspace);
|
|
210
|
+
const targetsFile = path.join(workspace.gameRoot, 'mpgd.targets.json');
|
|
211
|
+
const environment = {
|
|
212
|
+
...(input.environment ?? process.env),
|
|
213
|
+
MPGD_NATIVE_BUILD_MODE: input.mode,
|
|
214
|
+
MPGD_SOURCE_GIT_SHA: workspace.input.gameGitSha,
|
|
215
|
+
};
|
|
216
|
+
for (const name of pinnedNativeConfigOverrides) {
|
|
217
|
+
if (environment[name] !== undefined && environment[name] !== '') {
|
|
218
|
+
throw new Error(`Pinned native builds cannot use ${name}; use files committed inside the game checkout.`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
delete environment.MPGD_KIT_PATH;
|
|
222
|
+
delete environment.MPGD_CLI_ARGV;
|
|
223
|
+
delete environment.MPGD_RUN_IOS_ARCHIVE;
|
|
224
|
+
delete environment.MPGD_RUN_IOS_SIMULATOR_BUILD;
|
|
225
|
+
const statusFile = path.join(workspace.gameRoot, 'artifacts/native-build-status', `${input.target}.json`);
|
|
226
|
+
const previousRunId = existsSync(statusFile) ? readJsonObject(statusFile).runId : undefined;
|
|
227
|
+
const buildTemporaryDirectory = mkdtempSync(path.join(workspace.workspaceRoot, 'mpgd-native-tmp-'));
|
|
228
|
+
environment.TMPDIR = buildTemporaryDirectory;
|
|
229
|
+
environment.TMP = buildTemporaryDirectory;
|
|
230
|
+
environment.TEMP = buildTemporaryDirectory;
|
|
231
|
+
try {
|
|
232
|
+
const processInput = {
|
|
233
|
+
command: process.execPath,
|
|
234
|
+
args: [
|
|
235
|
+
cliExecutable,
|
|
236
|
+
'target',
|
|
237
|
+
'build',
|
|
238
|
+
input.target,
|
|
239
|
+
input.profile,
|
|
240
|
+
'--targets-file',
|
|
241
|
+
targetsFile,
|
|
242
|
+
],
|
|
243
|
+
cwd: workspace.gameRoot,
|
|
244
|
+
environment,
|
|
245
|
+
timeoutMs: input.timeoutMs ?? defaultBuildTimeoutMs,
|
|
246
|
+
signal: input.signal,
|
|
247
|
+
secretValues: input.secretValues,
|
|
248
|
+
};
|
|
249
|
+
await runReleaseProcess(processInput);
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
rmSync(buildTemporaryDirectory, { recursive: true, force: true });
|
|
253
|
+
}
|
|
254
|
+
assertPinnedFiles(workspace.workspaceRoot, workspace.gameRoot, workspace.input);
|
|
255
|
+
const status = readJsonObject(statusFile);
|
|
256
|
+
if (status.target !== input.target || status.status !== 'success'
|
|
257
|
+
|| typeof status.runId !== 'string' || status.runId === previousRunId
|
|
258
|
+
|| typeof status.artifact !== 'string') {
|
|
259
|
+
throw new Error('Pinned native build did not record a successful current attempt.');
|
|
260
|
+
}
|
|
261
|
+
const artifact = assertInsideWorkspace(workspace.gameRoot, status.artifact);
|
|
262
|
+
if (!existsSync(artifact)) {
|
|
263
|
+
throw new Error('Pinned native build artifact is missing.');
|
|
264
|
+
}
|
|
265
|
+
const canonicalGameRoot = realpathSync(workspace.gameRoot);
|
|
266
|
+
const canonicalArtifact = realpathSync(artifact);
|
|
267
|
+
const relativeArtifact = path.relative(canonicalGameRoot, canonicalArtifact);
|
|
268
|
+
if (relativeArtifact === '..' || relativeArtifact.startsWith(`..${path.sep}`)
|
|
269
|
+
|| path.isAbsolute(relativeArtifact)) {
|
|
270
|
+
throw new Error('Pinned native build artifact resolves outside its workspace.');
|
|
271
|
+
}
|
|
272
|
+
const releaseManifest = path.join(workspace.gameRoot, 'artifacts/release-manifest.json');
|
|
273
|
+
const manifest = readJsonObject(releaseManifest);
|
|
274
|
+
const manifestTargets = manifest.targets;
|
|
275
|
+
if (typeof manifestTargets !== 'object' || manifestTargets === null
|
|
276
|
+
|| Array.isArray(manifestTargets)) {
|
|
277
|
+
throw new Error('Pinned native build manifest has no target records.');
|
|
278
|
+
}
|
|
279
|
+
const targetManifest = manifestTargets[input.target];
|
|
280
|
+
if (typeof targetManifest !== 'object' || targetManifest === null
|
|
281
|
+
|| Array.isArray(targetManifest)
|
|
282
|
+
|| targetManifest.artifact !== status.artifact
|
|
283
|
+
|| targetManifest.profile !== input.profile) {
|
|
284
|
+
throw new Error('Pinned native build manifest target does not match the current artifact.');
|
|
285
|
+
}
|
|
286
|
+
if (manifest.gitSha !== workspace.input.gameGitSha
|
|
287
|
+
|| manifest.kitGitSha !== workspace.input.kitGitSha) {
|
|
288
|
+
throw new Error('Pinned native build manifest provenance does not match its inputs.');
|
|
289
|
+
}
|
|
290
|
+
return { artifact, releaseManifest, runId: status.runId };
|
|
291
|
+
}
|
|
292
|
+
function assertPinnedInput(input) {
|
|
293
|
+
if (!gitShaPattern.test(input.gameGitSha) || !gitShaPattern.test(input.kitGitSha)
|
|
294
|
+
|| input.deploymentProfile.trim() === ''
|
|
295
|
+
|| input.buildProfile !== 'production'
|
|
296
|
+
|| input.targets.length === 0
|
|
297
|
+
|| new Set(input.targets).size !== input.targets.length
|
|
298
|
+
|| input.targets.some((target) => target !== 'android' && target !== 'ios')
|
|
299
|
+
|| !sha256Pattern.test(input.lockfileSha256)
|
|
300
|
+
|| !sha256Pattern.test(input.targetConfigSha256)
|
|
301
|
+
|| !sha256Pattern.test(input.deployConfigSha256)
|
|
302
|
+
|| input.kitPackageVersion.trim() === '') {
|
|
303
|
+
throw new Error('Pinned release input contains an invalid revision, digest, or Kit version.');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function machineOutput(result) {
|
|
307
|
+
if (result.truncated || result.machineStdout === undefined) {
|
|
308
|
+
throw new Error('Release process machine output was truncated or unavailable.');
|
|
309
|
+
}
|
|
310
|
+
return result.machineStdout;
|
|
311
|
+
}
|
|
312
|
+
function assertPinnedTargetPaths(gameRoot, input) {
|
|
313
|
+
const plan = planNativeDeployment({
|
|
314
|
+
game: gameRoot,
|
|
315
|
+
profile: input.deploymentProfile,
|
|
316
|
+
targets: input.targets,
|
|
317
|
+
});
|
|
318
|
+
if (plan.buildProfile !== input.buildProfile
|
|
319
|
+
|| plan.targets.length !== input.targets.length
|
|
320
|
+
|| plan.targets.some((target, index) => target.target !== input.targets[index])) {
|
|
321
|
+
throw new Error('Pinned native target paths no longer match the deployment plan.');
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function sameDeploymentPlan(left, right) {
|
|
325
|
+
return left.schemaVersion === right.schemaVersion
|
|
326
|
+
&& left.gameRoot === right.gameRoot
|
|
327
|
+
&& left.profile === right.profile
|
|
328
|
+
&& left.buildProfile === right.buildProfile
|
|
329
|
+
&& left.approval === right.approval
|
|
330
|
+
&& left.targetConfigSha256 === right.targetConfigSha256
|
|
331
|
+
&& left.deployConfigSha256 === right.deployConfigSha256
|
|
332
|
+
&& left.targets.length === right.targets.length
|
|
333
|
+
&& left.targets.every((target, index) => {
|
|
334
|
+
const other = right.targets[index];
|
|
335
|
+
return other !== undefined && target.target === other.target
|
|
336
|
+
&& target.destination === other.destination
|
|
337
|
+
&& target.appId === other.appId
|
|
338
|
+
&& target.testGroup === other.testGroup;
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
function assertPinnedFiles(workspaceRoot, gameRoot, input) {
|
|
342
|
+
for (const [file, expected] of [
|
|
343
|
+
[path.join(workspaceRoot, 'pnpm-lock.yaml'), input.lockfileSha256],
|
|
344
|
+
[path.join(gameRoot, 'mpgd.targets.json'), input.targetConfigSha256],
|
|
345
|
+
[path.join(gameRoot, 'mpgd.deploy.json'), input.deployConfigSha256],
|
|
346
|
+
]) {
|
|
347
|
+
assertPinnedInputPath(workspaceRoot, file);
|
|
348
|
+
const actual = sha256(file);
|
|
349
|
+
if (actual !== expected) {
|
|
350
|
+
throw new Error(`Pinned release input differs from its plan: ${path.basename(file)}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
for (const name of ['mpgd.catalog.json', 'mpgd.ad-placements.json', 'mpgd.target-config.json']) {
|
|
354
|
+
const file = path.join(gameRoot, name);
|
|
355
|
+
if (existsSync(file)) {
|
|
356
|
+
assertPinnedInputPath(workspaceRoot, file);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async function assertNoEscapingTrackedSymlinks(workspaceRoot, environment, signal) {
|
|
361
|
+
const entries = await listTrackedIndex(workspaceRoot, environment, signal);
|
|
362
|
+
assertNoGitSubmodules(entries);
|
|
363
|
+
for (const record of entries) {
|
|
364
|
+
const separator = record.indexOf('\t');
|
|
365
|
+
if (separator === -1 || !record.startsWith('120000 ')) {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const file = path.join(workspaceRoot, record.slice(separator + 1));
|
|
369
|
+
const entry = lstatSync(file);
|
|
370
|
+
const linkTarget = entry.isSymbolicLink()
|
|
371
|
+
? readlinkSync(file)
|
|
372
|
+
: entry.isFile()
|
|
373
|
+
? readFileSync(file, 'utf8')
|
|
374
|
+
: undefined;
|
|
375
|
+
if (linkTarget === undefined) {
|
|
376
|
+
throw new Error(`Pinned release Git symlink has an unsupported checkout type: ${file}`);
|
|
377
|
+
}
|
|
378
|
+
const target = path.resolve(path.dirname(file), linkTarget);
|
|
379
|
+
const relative = path.relative(workspaceRoot, target);
|
|
380
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`)
|
|
381
|
+
|| path.isAbsolute(relative)) {
|
|
382
|
+
throw new Error(`Pinned release source symlink escapes its checkout: ${file}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async function listTrackedIndex(repositoryRoot, environment, signal) {
|
|
387
|
+
const tracked = await runReleaseProcess({
|
|
388
|
+
command: 'git',
|
|
389
|
+
args: ['-C', repositoryRoot, 'ls-files', '--stage', '-z'],
|
|
390
|
+
cwd: repositoryRoot,
|
|
391
|
+
environment,
|
|
392
|
+
timeoutMs: 30_000,
|
|
393
|
+
maxOutputBytes: 16 * 1024 * 1024,
|
|
394
|
+
signal,
|
|
395
|
+
captureMachineStdout: true,
|
|
396
|
+
});
|
|
397
|
+
return machineOutput(tracked).split('\0').filter((record) => record !== '');
|
|
398
|
+
}
|
|
399
|
+
function assertNoGitSubmodules(entries) {
|
|
400
|
+
for (const record of entries) {
|
|
401
|
+
if (record.startsWith('160000 ')) {
|
|
402
|
+
throw new Error('Pinned native releases do not support Git submodules; include required game inputs directly in the repository.');
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function assertPinnedInputPath(repositoryRoot, file) {
|
|
407
|
+
const relativeFile = path.relative(repositoryRoot, file);
|
|
408
|
+
if (relativeFile === '..' || relativeFile.startsWith(`..${path.sep}`)
|
|
409
|
+
|| path.isAbsolute(relativeFile)) {
|
|
410
|
+
throw new Error(`Pinned release input resolves outside its checkout: ${file}`);
|
|
411
|
+
}
|
|
412
|
+
let current = repositoryRoot;
|
|
413
|
+
for (const segment of relativeFile.split(path.sep)) {
|
|
414
|
+
current = path.join(current, segment);
|
|
415
|
+
if (lstatSync(current).isSymbolicLink()) {
|
|
416
|
+
throw new Error(`Pinned release input path is symlinked: ${file}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function assertWorkspaceOutputRoots(workspaceRoot, gameRoot) {
|
|
421
|
+
const canonicalWorkspace = realpathSync(workspaceRoot);
|
|
422
|
+
const canonicalGame = realpathSync(gameRoot);
|
|
423
|
+
const relativeGame = path.relative(canonicalWorkspace, canonicalGame);
|
|
424
|
+
if (relativeGame === '..' || relativeGame.startsWith(`..${path.sep}`)
|
|
425
|
+
|| path.isAbsolute(relativeGame)) {
|
|
426
|
+
throw new Error('Pinned game resolves outside its release checkout.');
|
|
427
|
+
}
|
|
428
|
+
for (const name of ['artifacts', 'release-output', 'dist']) {
|
|
429
|
+
assertNoOutputSymlinks(path.join(gameRoot, name));
|
|
430
|
+
}
|
|
431
|
+
assertNoOutputSymlinks(path.join(gameRoot, '.mpgd.targets.generated.json'));
|
|
432
|
+
}
|
|
433
|
+
function assertNoOutputSymlinks(candidate) {
|
|
434
|
+
let entry;
|
|
435
|
+
try {
|
|
436
|
+
entry = lstatSync(candidate);
|
|
437
|
+
}
|
|
438
|
+
catch (error) {
|
|
439
|
+
if (error.code === 'ENOENT') {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
throw error;
|
|
443
|
+
}
|
|
444
|
+
if (entry.isSymbolicLink()) {
|
|
445
|
+
throw new Error(`Pinned native build output path is symlinked: ${candidate}`);
|
|
446
|
+
}
|
|
447
|
+
if (entry.isDirectory()) {
|
|
448
|
+
for (const child of readdirSync(candidate)) {
|
|
449
|
+
assertNoOutputSymlinks(path.join(candidate, child));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
function sha256(file) {
|
|
454
|
+
return createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
455
|
+
}
|
|
456
|
+
function assertInstalledKitIdentity(workspace) {
|
|
457
|
+
const requireGame = createRequire(path.join(workspace.gameRoot, 'package.json'));
|
|
458
|
+
const cliEntry = requireGame.resolve('@mpgd/cli');
|
|
459
|
+
const packageRoot = path.dirname(path.dirname(cliEntry));
|
|
460
|
+
assertInsideCheckout(workspace.workspaceRoot, packageRoot, 'CLI package');
|
|
461
|
+
assertInsideCheckout(workspace.workspaceRoot, cliEntry, 'CLI entrypoint');
|
|
462
|
+
const packageJson = readJsonObject(path.join(packageRoot, 'package.json'));
|
|
463
|
+
const buildInfo = readJsonObject(path.join(packageRoot, 'dist/native-build-info.json'));
|
|
464
|
+
const bin = packageJson.bin;
|
|
465
|
+
if (typeof bin !== 'object' || bin === null || Array.isArray(bin)
|
|
466
|
+
|| bin.mpgd !== './dist/bin.js') {
|
|
467
|
+
throw new Error('Installed Kit package has no expected mpgd executable.');
|
|
468
|
+
}
|
|
469
|
+
const cliExecutable = path.join(packageRoot, 'dist/bin.js');
|
|
470
|
+
assertInsideCheckout(workspace.workspaceRoot, cliExecutable, 'CLI executable');
|
|
471
|
+
const requireCli = createRequire(cliEntry);
|
|
472
|
+
const targetMatrix = requireCli.resolve('@mpgd/target-config/targets.json');
|
|
473
|
+
assertInsideCheckout(workspace.workspaceRoot, targetMatrix, 'target-config matrix');
|
|
474
|
+
if (packageJson.version !== workspace.input.kitPackageVersion
|
|
475
|
+
|| buildInfo.packageVersion !== workspace.input.kitPackageVersion
|
|
476
|
+
|| buildInfo.kitGitSha !== workspace.input.kitGitSha
|
|
477
|
+
|| buildInfo.kitDirty !== false) {
|
|
478
|
+
throw new Error('Installed Kit package does not match pinned release identity.');
|
|
479
|
+
}
|
|
480
|
+
return cliExecutable;
|
|
481
|
+
}
|
|
482
|
+
function assertInsideCheckout(workspaceRoot, candidate, label) {
|
|
483
|
+
const relative = path.relative(realpathSync(workspaceRoot), realpathSync(candidate));
|
|
484
|
+
if (relative === '..' || relative.startsWith(`..${path.sep}`)
|
|
485
|
+
|| path.isAbsolute(relative)) {
|
|
486
|
+
throw new Error(`Installed ${label} resolves outside the pinned checkout.`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function readJsonObject(file) {
|
|
490
|
+
const value = JSON.parse(readFileSync(file, 'utf8'));
|
|
491
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
492
|
+
throw new Error(`Expected a JSON object: ${file}`);
|
|
493
|
+
}
|
|
494
|
+
return value;
|
|
495
|
+
}
|
|
496
|
+
function assertInsideWorkspace(gameRoot, relativeArtifact) {
|
|
497
|
+
const absolute = path.resolve(gameRoot, relativeArtifact);
|
|
498
|
+
const fromGame = path.relative(gameRoot, absolute);
|
|
499
|
+
if (fromGame === '..' || fromGame.startsWith(`..${path.sep}`)
|
|
500
|
+
|| path.isAbsolute(fromGame)) {
|
|
501
|
+
throw new Error('Native build artifact path escapes its pinned workspace.');
|
|
502
|
+
}
|
|
503
|
+
return absolute;
|
|
504
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ImmutableNativeBuildRecord } from './release-state.js';
|
|
2
|
+
export declare const pinnedAscVersion = "5.5.0";
|
|
3
|
+
export type IosTestFlightStatus = 'uploaded' | 'processing' | 'testflight-ready' | 'action-required' | 'failed' | 'unknown';
|
|
4
|
+
export interface IosTestFlightSubmissionInput {
|
|
5
|
+
readonly record: ImmutableNativeBuildRecord;
|
|
6
|
+
readonly ipaFile: string;
|
|
7
|
+
readonly bundleId: string;
|
|
8
|
+
/** Numeric App Store Connect app ID, not an ambiguous app name. */
|
|
9
|
+
readonly appStoreAppId: string;
|
|
10
|
+
/** Existing internal beta group ID, not a group name. */
|
|
11
|
+
readonly internalGroupId: string;
|
|
12
|
+
/** Absolute path to the pinned asc 5.5.0 binary. */
|
|
13
|
+
readonly ascBinary: string;
|
|
14
|
+
readonly apiKeyId: string;
|
|
15
|
+
readonly apiIssuerId: string;
|
|
16
|
+
readonly apiPrivateKeyBase64: string;
|
|
17
|
+
/** Persist this ID before continuing; supplying it on retry skips uploading. */
|
|
18
|
+
readonly resumeUploadId?: string;
|
|
19
|
+
/** Reconcile an already-discovered build without starting another upload. */
|
|
20
|
+
readonly resumeBuildId?: string;
|
|
21
|
+
/** SHA-256 persisted with resumeUploadId by the same release checkpoint. */
|
|
22
|
+
readonly resumeArtifactSha256?: string;
|
|
23
|
+
readonly onUploadCommitted?: (uploadId: string) => Promise<void>;
|
|
24
|
+
readonly signal?: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
export interface IosTestFlightSubmissionResult {
|
|
27
|
+
readonly status: IosTestFlightStatus;
|
|
28
|
+
readonly appStoreAppId: string;
|
|
29
|
+
readonly bundleId: string;
|
|
30
|
+
readonly marketingVersion: string;
|
|
31
|
+
readonly buildNumber: string;
|
|
32
|
+
readonly internalGroupId: string;
|
|
33
|
+
readonly uploadId?: string;
|
|
34
|
+
readonly buildId?: string;
|
|
35
|
+
readonly detail?: string;
|
|
36
|
+
}
|
|
37
|
+
export declare class IosSubmissionUncertainError extends Error {
|
|
38
|
+
readonly uploadId: string;
|
|
39
|
+
constructor(uploadId: string);
|
|
40
|
+
}
|
|
41
|
+
/** A narrow, testable command boundary; no arbitrary public submitter choice. */
|
|
42
|
+
export type AscJsonRunner = (args: readonly string[], timeoutMs: number) => Promise<unknown>;
|
|
43
|
+
/** Verify and use exactly the pinned asc release, with isolated environment auth. */
|
|
44
|
+
export declare function submitVerifiedIosBuild(input: IosTestFlightSubmissionInput): Promise<IosTestFlightSubmissionResult>;
|
|
45
|
+
/** Internal mock boundary used by credential-free submission tests. */
|
|
46
|
+
export declare function submitVerifiedIosBuildWithRunner(input: IosTestFlightSubmissionInput, run: AscJsonRunner): Promise<IosTestFlightSubmissionResult>;
|
|
47
|
+
export declare function isolatedAscEnvironment(input: IosTestFlightSubmissionInput, directory: string, source?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
48
|
+
/** The package consumes one reviewed asc pin; it does not trust PATH or version text. */
|
|
49
|
+
export declare function verifyPinnedAscBinary(binary: string, pinFile?: string): Promise<void>;
|