@rightkit/release 0.3.0 → 0.4.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/README.md +25 -4
- package/addon-command.mjs +3 -4
- package/android-play-upload.mjs +2 -1
- package/apple-profile-renew.mjs +11 -3
- package/artifact-storage.mjs +35 -2
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/build-release.mjs +138 -47
- package/cli/right-release.mjs +11 -2
- package/codesign-environment.mjs +2 -2
- package/content-digest.mjs +209 -0
- package/development.mjs +17 -6
- package/direct-bootstrap.mjs +5 -6
- package/disk-command.mjs +1 -0
- package/disk-policy.json +6 -2
- package/disk-stable-root.mjs +13 -9
- package/github-updater-release.mjs +70 -7
- package/in-place-install.mjs +17 -14
- package/install-app-windows.mjs +24 -7
- package/install-app.mjs +21 -40
- package/ios-asc-credentials.mjs +163 -0
- package/ios-build-context.mjs +129 -0
- package/ios-build-upload.mjs +282 -0
- package/ios-credentials-command.mjs +70 -0
- package/ios-dev.mjs +248 -0
- package/ios-host.mjs +76 -0
- package/ios-lock.mjs +75 -0
- package/ios-package-recipe.mjs +707 -0
- package/ios-testflight.mjs +266 -0
- package/ios-upload.mjs +313 -0
- package/local-publish.mjs +92 -37
- package/managed-cargo.mjs +8 -0
- package/mirror-root-artifact.mjs +5 -1
- package/native-cargo-layout.mjs +47 -4
- package/ownership-command.mjs +2 -1
- package/ownership-dependency-allowlist.json +1 -1
- package/ownership-evidence.mjs +30 -18
- package/ownership-key-command.mjs +69 -2
- package/ownership-mandatory-set.mjs +66 -16
- package/ownership-repositories.json +1 -1
- package/ownership-scope.mjs +94 -0
- package/ownership.mjs +49 -7
- package/package-recipe-targets.mjs +41 -1
- package/package.json +1 -1
- package/preflight.mjs +14 -7
- package/project-type.mjs +3 -1
- package/protected-credentials.mjs +28 -1
- package/publication-artifacts.mjs +30 -5
- package/publication-gate.mjs +15 -4
- package/publish-cargo.mjs +109 -22
- package/publish-update.mjs +15 -2
- package/qa-build-features.mjs +16 -13
- package/qa-contract.mjs +30 -2
- package/qa-control-scan.mjs +4 -0
- package/release-invocation.mjs +118 -8
- package/release-switch-registry.json +126 -27
- package/release-switch-scan.mjs +45 -17
- package/release.mjs +69 -17
- package/rightkit-ownership.json +1 -1
- package/rightkit-versions.json +379 -189
- package/runtime-budgets.mjs +120 -16
- package/sign-updater.mjs +56 -59
- package/source-gate.mjs +8 -1
- package/stage-sidecar.mjs +160 -32
- package/target-distribution.mjs +5 -0
- package/upload-release.mjs +7 -2
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
// Declarative iOS packaging recipe (bogusyogi/rightkit#349). A `targets.ios` entry with `iosPackage` replaces the per-app
|
|
2
|
+
// archive / export scripts (HeardRight appstore-archive.sh, and the missing ones in ScrapeRight, CodeRight and Pulse):
|
|
3
|
+
//
|
|
4
|
+
// 1. preflight host is macOS, GUI signing session, xcodebuild (and xcodegen when a project.yml is named) exist
|
|
5
|
+
// 2. generate `xcodegen generate` when the config names a project.yml; REQUIRED, never a fallback to a committed xcodeproj
|
|
6
|
+
// 3. prepare the app's own argv commands (no shell)
|
|
7
|
+
// 4. archive xcodebuild archive, automatic signing, -allowProvisioningUpdates, date-based build number
|
|
8
|
+
// 5. verify-archive Info.plist identity (app, appex, Watch: one version), bundle id, build number, QA scan, forbidden strings
|
|
9
|
+
// 6. export ExportOptions.plist (app-store-connect, destination export) and xcodebuild -exportArchive
|
|
10
|
+
// 7. verify-ipa extract the IPA: version identity again, entitlements (no get-task-allow, production APNs),
|
|
11
|
+
// embedded.mobileprovision (team, application identifier, no device list), code signature
|
|
12
|
+
// 8. receipt .right-release/receipts/ios-archive.json with the sha256 of the FINAL .ipa bytes
|
|
13
|
+
//
|
|
14
|
+
// Upload is a separate command (ios-upload.mjs): `build` never uploads, `upload` never builds. Nothing here reads an App Store
|
|
15
|
+
// Connect credential: archive and export sign through the Xcode account of the GUI session. Fails closed: every problem
|
|
16
|
+
// throws IosPackageError, and once an archive or an IPA exists a failure deletes them (and any stale receipt) so a later
|
|
17
|
+
// seal cannot pick up a half-verified artifact. Every external effect is injectable through `deps`.
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import * as nodeFs from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { spawnSync } from "./background-process.mjs";
|
|
23
|
+
import { assertGuiSigningSession, IosHostError, outputTail, resolveXcodeStorage } from "./ios-host.mjs";
|
|
24
|
+
|
|
25
|
+
export const IOS_SIGNING_CONTRACT = "ios-xcodebuild-archive-automatic-v1";
|
|
26
|
+
export const IOS_RECEIPT_NAME = "ios-archive.json";
|
|
27
|
+
export const IOS_PACKAGE_STEPS = Object.freeze(["preflight", "generate", "prepare", "archive", "verify-archive", "export", "verify-ipa", "receipt"]);
|
|
28
|
+
export const IOS_DEFAULT_TIMEOUT_MS = 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Codes: IOS_PACKAGE_CONFIG_INVALID, IOS_PACKAGE_HOST_UNSUPPORTED, IOS_PACKAGE_TOOL_MISSING, IOS_PACKAGE_GENERATE_FAILED,
|
|
32
|
+
* IOS_PACKAGE_PREPARE_FAILED, IOS_PACKAGE_ARCHIVE_FAILED, IOS_PACKAGE_SOURCE_CHANGED, IOS_PACKAGE_VERIFY_FAILED,
|
|
33
|
+
* IOS_PACKAGE_EXPORT_FAILED, IOS_PACKAGE_BUILD_NUMBER, IOS_PACKAGE_RECEIPT_FAILED.
|
|
34
|
+
*/
|
|
35
|
+
export class IosPackageError extends Error {
|
|
36
|
+
constructor(code, message, options) { super(message, options); this.name = "IosPackageError"; this.code = code; }
|
|
37
|
+
}
|
|
38
|
+
const invalid = (message) => new IosPackageError("IOS_PACKAGE_CONFIG_INVALID", `iosPackage: ${message}`);
|
|
39
|
+
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
40
|
+
|
|
41
|
+
// ---- config ------------------------------------------------------------------------------------------------------
|
|
42
|
+
const IOS_KEYS = ["project", "projectSpec", "scheme", "configuration", "teamId", "bundleId", "exportMethod", "signing", "buildNumber", "extraBuildSettings", "packageResolved", "trustedSwiftMacros", "trustedSwiftPlugins", "prepare", "cleanup", "checks", "archive", "ipa", "timeoutMs"];
|
|
43
|
+
const CLEANUP_KEYS = ["derivedData"];
|
|
44
|
+
const CHECK_KEYS = ["versionIdentity", "forbidStrings", "plist"];
|
|
45
|
+
const COMMAND_KEYS = ["cmd", "args", "cwd"];
|
|
46
|
+
const TEST_SEGMENT = /^(?:dev|qa|test|debug)$/i;
|
|
47
|
+
const RESERVED_SETTING = /^(?:DEVELOPMENT_TEAM|CURRENT_PROJECT_VERSION|CODE_SIGN\w*|PROVISIONING\w*|OTHER_CODE_SIGN_FLAGS|ARCHS?|SDKROOT|SKIP_INSTALL|PRODUCT_BUNDLE_IDENTIFIER)$/;
|
|
48
|
+
const SECRET_SETTING = /SECRET|TOKEN|PASSWORD|PASSWD|BEARER|PRIVATE|CREDENTIAL|API_?KEY|AUTH|SIGNING_KEY/i;
|
|
49
|
+
export const SECRET_VALUE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|Bearer\s+\S{8,}|[\w-]{16,}\.[\w-]{16,}\.[\w-]{8,}/;
|
|
50
|
+
|
|
51
|
+
function relativeInside(value, label, extension) {
|
|
52
|
+
if (typeof value !== "string" || !value.trim()) throw invalid(`${label} must be a non-empty relative path`);
|
|
53
|
+
const normalized = value.replaceAll("\\", "/");
|
|
54
|
+
if (path.isAbsolute(value) || path.win32.isAbsolute(value) || /^[A-Za-z]:/.test(value) || normalized.split("/").includes("..")) throw invalid(`${label} must be relative and stay inside the app: ${value}`);
|
|
55
|
+
if (extension && !extension.test(normalized)) throw invalid(`${label} must match ${extension}: ${value}`);
|
|
56
|
+
return normalized.replace(/^\.\//, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function commandList(value, label) {
|
|
60
|
+
if (!Array.isArray(value)) throw invalid(`${label} must be an array of { cmd, args?, cwd? }`);
|
|
61
|
+
return value.map((item, index) => {
|
|
62
|
+
const where = `${label}[${index}]`;
|
|
63
|
+
if (!isPlainObject(item)) throw invalid(`${where} must be an object`);
|
|
64
|
+
const unknown = Object.keys(item).filter((key) => !COMMAND_KEYS.includes(key));
|
|
65
|
+
if (unknown.length) throw invalid(`${where} has unknown key(s): ${unknown.join(", ")} (allowed: ${COMMAND_KEYS.join(", ")})`);
|
|
66
|
+
if (typeof item.cmd !== "string" || !item.cmd.trim() || /\s/.test(item.cmd.trim())) throw invalid(`${where}.cmd must be one executable; put its arguments in args`);
|
|
67
|
+
if (item.args !== undefined && (!Array.isArray(item.args) || item.args.some((arg) => typeof arg !== "string"))) throw invalid(`${where}.args must be a list of strings`);
|
|
68
|
+
return { cmd: item.cmd.trim(), args: [...(item.args ?? [])], ...(item.cwd === undefined ? {} : { cwd: relativeInside(item.cwd, `${where}.cwd`) }) };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const TRUST_ENTRY_KEYS = ["package", "revision"];
|
|
73
|
+
const SWIFT_IDENTITY = /^[a-z0-9][a-z0-9._-]{0,100}$/;
|
|
74
|
+
const GIT_REVISION = /^[0-9a-f]{40}$/;
|
|
75
|
+
/** Where Xcode reads the SwiftPM pins of a project (`-project` builds). */
|
|
76
|
+
export const defaultPackageResolvedPath = (project) => path.posix.join(project, "project.xcworkspace", "xcshareddata", "swiftpm", "Package.resolved");
|
|
77
|
+
|
|
78
|
+
function trustList(value, label) {
|
|
79
|
+
if (!Array.isArray(value)) throw invalid(`${label} must be an array of { package, revision }`);
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
return value.map((item, index) => {
|
|
82
|
+
const where = `${label}[${index}]`;
|
|
83
|
+
if (!isPlainObject(item)) throw invalid(`${where} must be an object { package, revision }`);
|
|
84
|
+
const unknown = Object.keys(item).filter((key) => !TRUST_ENTRY_KEYS.includes(key));
|
|
85
|
+
if (unknown.length) throw invalid(`${where} has unknown key(s): ${unknown.join(", ")} (allowed: ${TRUST_ENTRY_KEYS.join(", ")})`);
|
|
86
|
+
if (typeof item.package !== "string" || !SWIFT_IDENTITY.test(item.package)) throw invalid(`${where}.package must be a lowercase SwiftPM package identity such as swift-composable-architecture`);
|
|
87
|
+
if (typeof item.revision !== "string" || !GIT_REVISION.test(item.revision)) throw invalid(`${where}.revision must be the exact 40 character lowercase hex git commit the package is pinned to (no tag, branch or abbreviation)`);
|
|
88
|
+
if (seen.has(item.package)) throw invalid(`${label} lists ${item.package} twice`);
|
|
89
|
+
seen.add(item.package);
|
|
90
|
+
return { package: item.package, revision: item.revision };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Strictly validate and normalize an `iosPackage` value. Unknown keys, wrong types and missing fields throw. */
|
|
95
|
+
export function validateIosPackageConfig(spec) {
|
|
96
|
+
if (!isPlainObject(spec)) throw invalid("must be an object");
|
|
97
|
+
const unknown = Object.keys(spec).filter((key) => !IOS_KEYS.includes(key));
|
|
98
|
+
if (unknown.length) throw invalid(`unknown key(s): ${unknown.join(", ")} (allowed: ${IOS_KEYS.join(", ")})`);
|
|
99
|
+
|
|
100
|
+
const project = relativeInside(spec.project, "project", /\.xcodeproj$/i);
|
|
101
|
+
const projectSpec = spec.projectSpec === undefined ? null : relativeInside(spec.projectSpec, "projectSpec", /\.ya?ml$/i);
|
|
102
|
+
if (projectSpec && path.posix.dirname(projectSpec) !== path.posix.dirname(project)) throw invalid("projectSpec and project must be in the same directory (xcodegen writes the project beside its project.yml)");
|
|
103
|
+
|
|
104
|
+
if (typeof spec.scheme !== "string" || !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,100}$/.test(spec.scheme)) throw invalid("scheme is required and must be an Xcode scheme name");
|
|
105
|
+
const configuration = spec.configuration ?? "Release";
|
|
106
|
+
if (typeof configuration !== "string" || !/^[A-Za-z][A-Za-z0-9 _-]{0,40}$/.test(configuration)) throw invalid("configuration must be a build configuration name");
|
|
107
|
+
if (/debug/i.test(configuration)) throw invalid(`configuration ${configuration} is a debug configuration; a release archives Release`);
|
|
108
|
+
if (typeof spec.teamId !== "string" || !/^[A-Z0-9]{10}$/.test(spec.teamId)) throw invalid("teamId is required: the 10 character Apple team id (there is no built-in default)");
|
|
109
|
+
if (typeof spec.bundleId !== "string" || !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(spec.bundleId)) throw invalid("bundleId is required and must be the reverse-DNS app bundle id");
|
|
110
|
+
if (TEST_SEGMENT.test(spec.bundleId.split(".").at(-1))) throw invalid(`bundleId ${spec.bundleId} is a dev/qa/test/debug id; those never go through the release lane`);
|
|
111
|
+
if (spec.exportMethod !== undefined && spec.exportMethod !== "app-store-connect") throw invalid("exportMethod must be app-store-connect (the only method v1 supports)");
|
|
112
|
+
if (spec.signing !== undefined && spec.signing !== "automatic") throw invalid("signing must be automatic (v1 signs with Xcode automatic signing and -allowProvisioningUpdates)");
|
|
113
|
+
const buildNumber = spec.buildNumber === undefined ? "date" : isPlainObject(spec.buildNumber) ? spec.buildNumber.kind : spec.buildNumber;
|
|
114
|
+
if (buildNumber !== "date" || (isPlainObject(spec.buildNumber) && Object.keys(spec.buildNumber).some((key) => key !== "kind"))) throw invalid('buildNumber must be "date" (YYYYMMDDNN); the marketing version stays as the app sets it');
|
|
115
|
+
|
|
116
|
+
const extraBuildSettings = {};
|
|
117
|
+
if (spec.extraBuildSettings !== undefined) {
|
|
118
|
+
if (!isPlainObject(spec.extraBuildSettings)) throw invalid("extraBuildSettings must be an object of NAME: value");
|
|
119
|
+
for (const [name, value] of Object.entries(spec.extraBuildSettings)) {
|
|
120
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(name)) throw invalid(`extraBuildSettings.${name} is not a build setting name`);
|
|
121
|
+
if (RESERVED_SETTING.test(name)) throw invalid(`extraBuildSettings.${name} is set by the recipe or by signing; it cannot be overridden`);
|
|
122
|
+
if (SECRET_SETTING.test(name)) throw invalid(`extraBuildSettings.${name} looks like a secret; build settings travel on the command line and must be non-secret`);
|
|
123
|
+
if (typeof value !== "string" || /[\0\r\n]/.test(value) || SECRET_VALUE.test(value)) throw invalid(`extraBuildSettings.${name} must be a single-line non-secret string`);
|
|
124
|
+
extraBuildSettings[name] = value;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const trustedSwiftMacros = spec.trustedSwiftMacros === undefined ? [] : trustList(spec.trustedSwiftMacros, "trustedSwiftMacros");
|
|
129
|
+
const trustedSwiftPlugins = spec.trustedSwiftPlugins === undefined ? [] : trustList(spec.trustedSwiftPlugins, "trustedSwiftPlugins");
|
|
130
|
+
const trusting = trustedSwiftMacros.length > 0 || trustedSwiftPlugins.length > 0;
|
|
131
|
+
const packageResolved = spec.packageResolved === undefined ? null : relativeInside(spec.packageResolved, "packageResolved", /(?:^|\/)Package\.resolved$/);
|
|
132
|
+
if (packageResolved && !trusting) throw invalid("packageResolved is only used with trustedSwiftMacros or trustedSwiftPlugins; remove it or list the reviewed packages");
|
|
133
|
+
if (trusting && projectSpec && !packageResolved) throw invalid("trustedSwiftMacros/trustedSwiftPlugins with a projectSpec need packageResolved: the xcodegen project is regenerated, so its own Package.resolved is untracked; name a committed Package.resolved outside the xcodeproj");
|
|
134
|
+
|
|
135
|
+
const cleanup = { derivedData: false };
|
|
136
|
+
if (spec.cleanup !== undefined) {
|
|
137
|
+
if (!isPlainObject(spec.cleanup)) throw invalid("cleanup must be an object");
|
|
138
|
+
const bad = Object.keys(spec.cleanup).filter((key) => !CLEANUP_KEYS.includes(key));
|
|
139
|
+
if (bad.length) throw invalid(`cleanup has unknown key(s): ${bad.join(", ")} (allowed: ${CLEANUP_KEYS.join(", ")})`);
|
|
140
|
+
if (spec.cleanup.derivedData !== undefined && typeof spec.cleanup.derivedData !== "boolean") throw invalid("cleanup.derivedData must be a boolean");
|
|
141
|
+
cleanup.derivedData = spec.cleanup.derivedData === true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const checks = { versionIdentity: true, forbidStrings: [], plist: {} };
|
|
145
|
+
if (spec.checks !== undefined) {
|
|
146
|
+
if (!isPlainObject(spec.checks)) throw invalid("checks must be an object");
|
|
147
|
+
const bad = Object.keys(spec.checks).filter((key) => !CHECK_KEYS.includes(key));
|
|
148
|
+
if (bad.length) throw invalid(`checks has unknown key(s): ${bad.join(", ")} (allowed: ${CHECK_KEYS.join(", ")})`);
|
|
149
|
+
if (spec.checks.versionIdentity !== undefined && spec.checks.versionIdentity !== true) throw invalid("checks.versionIdentity cannot be turned off: the app, appex and Watch plists must carry one version");
|
|
150
|
+
if (spec.checks.forbidStrings !== undefined) {
|
|
151
|
+
if (!Array.isArray(spec.checks.forbidStrings) || spec.checks.forbidStrings.some((item) => typeof item !== "string" || !item || item.length > 200 || item.includes("\0"))) throw invalid("checks.forbidStrings must be a list of non-empty strings");
|
|
152
|
+
checks.forbidStrings = [...spec.checks.forbidStrings];
|
|
153
|
+
}
|
|
154
|
+
if (spec.checks.plist !== undefined) {
|
|
155
|
+
if (!isPlainObject(spec.checks.plist)) throw invalid("checks.plist must be an object of Info.plist key: expected value");
|
|
156
|
+
for (const [key, value] of Object.entries(spec.checks.plist)) {
|
|
157
|
+
if (!key || !["string", "boolean", "number"].includes(typeof value)) throw invalid(`checks.plist.${key} must be a string, boolean or number`);
|
|
158
|
+
}
|
|
159
|
+
checks.plist = { ...spec.checks.plist };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let timeoutMs = IOS_DEFAULT_TIMEOUT_MS;
|
|
164
|
+
if (spec.timeoutMs !== undefined) {
|
|
165
|
+
if (!Number.isInteger(spec.timeoutMs) || spec.timeoutMs < 1) throw invalid("timeoutMs must be a positive integer (milliseconds)");
|
|
166
|
+
timeoutMs = spec.timeoutMs;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
project,
|
|
171
|
+
projectSpec,
|
|
172
|
+
scheme: spec.scheme,
|
|
173
|
+
configuration,
|
|
174
|
+
teamId: spec.teamId,
|
|
175
|
+
bundleId: spec.bundleId,
|
|
176
|
+
exportMethod: "app-store-connect",
|
|
177
|
+
signing: "automatic",
|
|
178
|
+
buildNumber: "date",
|
|
179
|
+
extraBuildSettings,
|
|
180
|
+
packageResolved,
|
|
181
|
+
trustedSwiftMacros,
|
|
182
|
+
trustedSwiftPlugins,
|
|
183
|
+
prepare: spec.prepare === undefined ? [] : commandList(spec.prepare, "prepare"),
|
|
184
|
+
cleanup,
|
|
185
|
+
checks,
|
|
186
|
+
archive: relativeInside(spec.archive, "archive", /\.xcarchive$/i),
|
|
187
|
+
ipa: relativeInside(spec.ipa, "ipa", /\.ipa$/i),
|
|
188
|
+
timeoutMs,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- pure builders -----------------------------------------------------------------------------------------------
|
|
193
|
+
const xmlEscape = (value) => String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
194
|
+
|
|
195
|
+
/** ExportOptions.plist: app-store-connect, export (the upload is a separate command), automatic signing, our build number kept. */
|
|
196
|
+
export function exportOptionsPlist(spec) {
|
|
197
|
+
return [
|
|
198
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
199
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
200
|
+
'<plist version="1.0">',
|
|
201
|
+
"<dict>",
|
|
202
|
+
" <key>destination</key><string>export</string>",
|
|
203
|
+
" <key>manageAppVersionAndBuildNumber</key><false/>",
|
|
204
|
+
` <key>method</key><string>${xmlEscape(spec.exportMethod)}</string>`,
|
|
205
|
+
" <key>signingStyle</key><string>automatic</string>",
|
|
206
|
+
" <key>stripSwiftSymbols</key><true/>",
|
|
207
|
+
` <key>teamID</key><string>${xmlEscape(spec.teamId)}</string>`,
|
|
208
|
+
" <key>uploadSymbols</key><true/>",
|
|
209
|
+
"</dict>",
|
|
210
|
+
"</plist>",
|
|
211
|
+
"",
|
|
212
|
+
].join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* YYYYMMDDNN: the UTC date plus a two-digit counter that rises for every build on the same day. Fails closed: only a missing file
|
|
217
|
+
* (ENOENT) means "first build"; an unreadable, corrupt or malformed counter, or one from a later day than the clock, is an error
|
|
218
|
+
* (a silently restarted counter could repeat a build number Apple already holds). The write is atomic (temp file, then rename).
|
|
219
|
+
*/
|
|
220
|
+
export function nextDateBuildNumber({ now, stateFile, fs = nodeFs }) {
|
|
221
|
+
const day = now.toISOString().slice(0, 10).replaceAll("-", "");
|
|
222
|
+
const broken = (why) => new IosPackageError("IOS_PACKAGE_BUILD_NUMBER", `the build number counter ${stateFile} is ${why}; fix or remove it only after checking the highest build number already uploaded`);
|
|
223
|
+
let seq = 1;
|
|
224
|
+
let raw;
|
|
225
|
+
try { raw = fs.readFileSync(stateFile, "utf8"); } catch (error) {
|
|
226
|
+
if (error?.code !== "ENOENT") throw broken(`unreadable (${error?.code ?? "error"})`);
|
|
227
|
+
}
|
|
228
|
+
if (raw !== undefined) {
|
|
229
|
+
let previous;
|
|
230
|
+
try { previous = JSON.parse(raw); } catch { throw broken("not valid JSON"); }
|
|
231
|
+
if (!previous || typeof previous !== "object" || !/^\d{8}$/.test(String(previous.day)) || !Number.isInteger(previous.seq) || previous.seq < 1 || previous.seq > 99) throw broken("malformed");
|
|
232
|
+
if (previous.day > day) throw broken(`from ${previous.day}, later than today (${day}); the clock moved back`);
|
|
233
|
+
if (previous.day === day) seq = previous.seq + 1;
|
|
234
|
+
}
|
|
235
|
+
if (seq > 99) throw new IosPackageError("IOS_PACKAGE_BUILD_NUMBER", `more than 99 iOS builds on ${day}; the YYYYMMDDNN build number is exhausted for today`);
|
|
236
|
+
fs.mkdirSync(path.dirname(stateFile), { recursive: true });
|
|
237
|
+
const temporary = `${stateFile}.tmp-${process.pid}`;
|
|
238
|
+
fs.writeFileSync(temporary, `${JSON.stringify({ day, seq }, null, 2)}\n`);
|
|
239
|
+
fs.renameSync(temporary, stateFile);
|
|
240
|
+
return `${day}${String(seq).padStart(2, "0")}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function xcodegenArgv(spec, root) {
|
|
244
|
+
return ["generate", "--spec", path.resolve(root, spec.projectSpec), "--project", path.resolve(root, path.posix.dirname(spec.project))];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- reviewed Swift macro / plugin trust ---------------------------------------------------------------------------
|
|
248
|
+
// Xcode refuses a macro or build-tool plugin the user has not enabled ("Macro ... must be enabled before it can be used").
|
|
249
|
+
// -skipMacroValidation / -skipPackagePluginValidation are BUILD-WIDE switches (they trust every macro/plugin of every
|
|
250
|
+
// package), so they are added only when the app lists the exact reviewed package revisions AND every one is pinned to that
|
|
251
|
+
// revision in a tracked, hashed Package.resolved. -disableAutomaticPackageResolution and -onlyUsePackageVersionsFromResolvedFile
|
|
252
|
+
// then stop Xcode from moving a pin after the check. Without a list nothing changes.
|
|
253
|
+
const VERIFIED_TRUST = new WeakSet();
|
|
254
|
+
const trustError = (code, message) => new IosPackageError(code, message);
|
|
255
|
+
|
|
256
|
+
/** Pure: parse a Package.resolved (format versions 2 and 3) into pins keyed by lowercase identity. Anything else throws. */
|
|
257
|
+
export function parsePackageResolved(text) {
|
|
258
|
+
let json;
|
|
259
|
+
try { json = JSON.parse(String(text)); } catch { throw trustError("IOS_PACKAGE_TRUST_FAILED", "Package.resolved is not valid JSON"); }
|
|
260
|
+
if (!isPlainObject(json) || (json.version !== 2 && json.version !== 3)) throw trustError("IOS_PACKAGE_TRUST_FAILED", `Package.resolved version ${JSON.stringify(json?.version)} is not supported (only versions 2 and 3, which key pins by identity)`);
|
|
261
|
+
if (!Array.isArray(json.pins)) throw trustError("IOS_PACKAGE_TRUST_FAILED", "Package.resolved has no pins array");
|
|
262
|
+
const pins = new Map();
|
|
263
|
+
for (const [index, pin] of json.pins.entries()) {
|
|
264
|
+
if (!isPlainObject(pin) || typeof pin.identity !== "string" || !pin.identity) throw trustError("IOS_PACKAGE_TRUST_FAILED", `Package.resolved pins[${index}] has no identity`);
|
|
265
|
+
const identity = pin.identity.toLowerCase();
|
|
266
|
+
if (pins.has(identity)) throw trustError("IOS_PACKAGE_TRUST_FAILED", `Package.resolved pins ${identity} twice`);
|
|
267
|
+
const state = isPlainObject(pin.state) ? pin.state : {};
|
|
268
|
+
pins.set(identity, { identity, kind: String(pin.kind ?? ""), location: String(pin.location ?? ""), revision: typeof state.revision === "string" ? state.revision.toLowerCase() : null, version: typeof state.version === "string" ? state.version : null });
|
|
269
|
+
}
|
|
270
|
+
return pins;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Throws IOS_PACKAGE_TRUST_MISMATCH naming every package that is not pinned, or is pinned to another revision than its
|
|
275
|
+
* allowlist entry. Returns the evidence object that archiveArgv requires before it adds a skip flag.
|
|
276
|
+
*/
|
|
277
|
+
export function assertTrustedPins(pins, { trustedSwiftMacros = [], trustedSwiftPlugins = [] }) {
|
|
278
|
+
const problems = [];
|
|
279
|
+
const evidence = { macros: [], plugins: [] };
|
|
280
|
+
for (const [listName, key, list] of [["trustedSwiftMacros", "macros", trustedSwiftMacros], ["trustedSwiftPlugins", "plugins", trustedSwiftPlugins]]) {
|
|
281
|
+
for (const entry of list) {
|
|
282
|
+
const pin = pins.get(entry.package);
|
|
283
|
+
if (!pin) problems.push(`${listName}: ${entry.package} is not pinned in Package.resolved (reviewed revision ${entry.revision})`);
|
|
284
|
+
else if (!pin.revision) problems.push(`${listName}: ${entry.package} is pinned without a git revision (${pin.kind || "unknown kind"}); reviewed revision ${entry.revision}`);
|
|
285
|
+
else if (pin.revision !== entry.revision) problems.push(`${listName}: ${entry.package} is pinned to ${pin.revision} in Package.resolved but the reviewed revision is ${entry.revision}`);
|
|
286
|
+
else evidence[key].push({ package: entry.package, revision: entry.revision, location: pin.location });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (problems.length) throw trustError("IOS_PACKAGE_TRUST_MISMATCH", `Swift macro/plugin trust refused; review the update, then change the allowlist in the same commit as Package.resolved:\n- ${problems.join("\n- ")}`);
|
|
290
|
+
VERIFIED_TRUST.add(evidence);
|
|
291
|
+
return evidence;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The xcodebuild flags for the trust lists. Fails closed: a non-empty list without verified evidence throws. */
|
|
295
|
+
export function swiftTrustFlags(spec, trusted) {
|
|
296
|
+
const macros = spec.trustedSwiftMacros?.length > 0;
|
|
297
|
+
const plugins = spec.trustedSwiftPlugins?.length > 0;
|
|
298
|
+
if (!macros && !plugins) return [];
|
|
299
|
+
if (!trusted || !VERIFIED_TRUST.has(trusted)) throw trustError("IOS_PACKAGE_TRUST_FAILED", "trustedSwiftMacros/trustedSwiftPlugins are set but the Package.resolved pins were not verified; refusing to skip macro or plugin validation");
|
|
300
|
+
return [...(macros ? ["-skipMacroValidation"] : []), ...(plugins ? ["-skipPackagePluginValidation"] : []), "-disableAutomaticPackageResolution", "-onlyUsePackageVersionsFromResolvedFile"];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Before any effect: the pinned file must exist, be tracked by git and equal HEAD, parse, and hold every listed package at
|
|
305
|
+
* exactly the reviewed revision. Returns { sourceRel, effectiveRel, bytes, sha256, evidence }, or null when no list is configured.
|
|
306
|
+
*/
|
|
307
|
+
export function verifySwiftTrustSource({ spec, root, sh, fs = nodeFs }) {
|
|
308
|
+
if (!spec.trustedSwiftMacros?.length && !spec.trustedSwiftPlugins?.length) return null;
|
|
309
|
+
const effectiveRel = defaultPackageResolvedPath(spec.project);
|
|
310
|
+
const sourceRel = spec.packageResolved ?? effectiveRel;
|
|
311
|
+
const sourceAbs = path.resolve(root, sourceRel);
|
|
312
|
+
if (!fs.existsSync(sourceAbs)) throw trustError("IOS_PACKAGE_TRUST_FAILED", `${sourceRel} does not exist; trustedSwiftMacros/trustedSwiftPlugins need a committed Package.resolved${spec.packageResolved ? "" : " inside the xcodeproj (or name one with iosPackage.packageResolved)"}`);
|
|
313
|
+
const tracked = sh("git", ["ls-files", "--error-unmatch", "--", sourceRel], { timeout: 60_000 });
|
|
314
|
+
if (tracked.error || tracked.status !== 0) throw trustError("IOS_PACKAGE_TRUST_FAILED", `${sourceRel} is not tracked by git; the pins that unlock macro trust must be committed`);
|
|
315
|
+
const clean = sh("git", ["diff", "--quiet", "HEAD", "--", sourceRel], { timeout: 60_000 });
|
|
316
|
+
if (clean.error || clean.status !== 0) throw trustError("IOS_PACKAGE_TRUST_FAILED", `${sourceRel} differs from HEAD; commit the reviewed pins first`);
|
|
317
|
+
const bytes = Buffer.from(fs.readFileSync(sourceAbs));
|
|
318
|
+
const evidence = assertTrustedPins(parsePackageResolved(bytes.toString("utf8")), spec);
|
|
319
|
+
return { sourceRel, effectiveRel, bytes, sha256: sha256Bytes(bytes), evidence };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The file Xcode reads must be exactly the verified bytes (checked before and after the archive). */
|
|
323
|
+
export function assertSwiftTrustUnchanged({ trust, root, fs = nodeFs }) {
|
|
324
|
+
let current;
|
|
325
|
+
try { current = fs.readFileSync(path.resolve(root, trust.effectiveRel)); } catch { throw trustError("IOS_PACKAGE_TRUST_FAILED", `${trust.effectiveRel} is missing; Xcode would read other pins than the verified ones`); }
|
|
326
|
+
if (sha256Bytes(current) !== trust.sha256) throw trustError("IOS_PACKAGE_TRUST_FAILED", `${trust.effectiveRel} differs from the verified ${trust.sourceRel} (sha256 changed)`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* After generate and prepare: make the committed file the one Xcode reads. When it lives elsewhere it is copied into the
|
|
331
|
+
* project's swiftpm location; a different file already there is refused, never overwritten.
|
|
332
|
+
*/
|
|
333
|
+
export function installSwiftTrustSource({ trust, root, fs = nodeFs }) {
|
|
334
|
+
if (trust.sourceRel !== trust.effectiveRel) {
|
|
335
|
+
const effectiveAbs = path.resolve(root, trust.effectiveRel);
|
|
336
|
+
if (fs.existsSync(effectiveAbs)) {
|
|
337
|
+
if (!Buffer.from(fs.readFileSync(effectiveAbs)).equals(trust.bytes)) throw trustError("IOS_PACKAGE_TRUST_FAILED", `${trust.effectiveRel} already exists and differs from ${trust.sourceRel}; refusing to overwrite it`);
|
|
338
|
+
} else {
|
|
339
|
+
fs.mkdirSync(path.dirname(effectiveAbs), { recursive: true });
|
|
340
|
+
fs.writeFileSync(effectiveAbs, trust.bytes);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
assertSwiftTrustUnchanged({ trust, root, fs });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function archiveArgv(spec, { root, buildNumber, storage, trusted }) {
|
|
347
|
+
const trustFlags = swiftTrustFlags(spec, trusted);
|
|
348
|
+
return [
|
|
349
|
+
"-project", path.resolve(root, spec.project),
|
|
350
|
+
"-scheme", spec.scheme,
|
|
351
|
+
"-configuration", spec.configuration,
|
|
352
|
+
"-destination", "generic/platform=iOS",
|
|
353
|
+
"-archivePath", path.resolve(root, spec.archive),
|
|
354
|
+
"-derivedDataPath", storage.derivedData,
|
|
355
|
+
...(storage.resultBundle ? ["-resultBundlePath", storage.resultBundle] : []),
|
|
356
|
+
`DEVELOPMENT_TEAM=${spec.teamId}`,
|
|
357
|
+
`CURRENT_PROJECT_VERSION=${buildNumber}`,
|
|
358
|
+
...Object.entries(spec.extraBuildSettings).map(([name, value]) => `${name}=${value}`),
|
|
359
|
+
"-allowProvisioningUpdates",
|
|
360
|
+
...trustFlags,
|
|
361
|
+
"archive",
|
|
362
|
+
];
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** A dedicated directory beside the IPA (never the IPA's own directory, which may be the app root): xcodebuild fills it with logs and plists. */
|
|
366
|
+
export const exportDirectory = (spec, root) => path.join(path.dirname(path.resolve(root, spec.ipa)), `${path.basename(spec.ipa, ".ipa")}.export`);
|
|
367
|
+
|
|
368
|
+
export function exportArgv(spec, { root, optionsFile }) {
|
|
369
|
+
return ["-exportArchive", "-archivePath", path.resolve(root, spec.archive), "-exportPath", exportDirectory(spec, root), "-exportOptionsPlist", optionsFile, "-allowProvisioningUpdates"];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Dry-run display only: the flags the run would add once its own check passes. The real run never uses this. */
|
|
373
|
+
const plannedTrust = (spec) => {
|
|
374
|
+
if (!spec.trustedSwiftMacros.length && !spec.trustedSwiftPlugins.length) return undefined;
|
|
375
|
+
const planned = { macros: [], plugins: [] };
|
|
376
|
+
VERIFIED_TRUST.add(planned);
|
|
377
|
+
return planned;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/** The ordered plan (pure; no secret is part of any argument). Used by --dry-run and by the run itself. */
|
|
381
|
+
export function iosPackagePlan(spec, { root, buildNumber = "<YYYYMMDDNN>", storage = { derivedData: "<rightkit storage xcode-args -derivedDataPath>" }, receiptDir }) {
|
|
382
|
+
const optionsFile = path.join(receiptDir, "ios-export-options.plist");
|
|
383
|
+
return {
|
|
384
|
+
steps: [
|
|
385
|
+
{ id: "preflight", description: "macOS host, GUI signing session, xcodebuild" + (spec.projectSpec ? ", xcodegen (required)" : "") },
|
|
386
|
+
...(spec.projectSpec ? [{ id: "generate", cmd: "xcodegen", args: xcodegenArgv(spec, root) }] : []),
|
|
387
|
+
...spec.prepare.map((step, index) => ({ id: "prepare", index, cmd: step.cmd, args: step.args, cwd: step.cwd ?? "." })),
|
|
388
|
+
...(spec.trustedSwiftMacros.length || spec.trustedSwiftPlugins.length
|
|
389
|
+
? [{ id: "trust-check", description: `Package.resolved (${spec.packageResolved ?? defaultPackageResolvedPath(spec.project)}) tracked and equal to HEAD; ${[...spec.trustedSwiftMacros, ...spec.trustedSwiftPlugins].map((entry) => entry.package).join(", ")} pinned to the reviewed revisions; the skip flags below are added only after this passes` }]
|
|
390
|
+
: []),
|
|
391
|
+
{ id: "archive", cmd: "xcodebuild", args: archiveArgv(spec, { root, buildNumber, storage, trusted: plannedTrust(spec) }) },
|
|
392
|
+
{ id: "verify-archive", description: "version identity across app, appex and Watch plists; bundle id; build number; QA scan; forbidden strings" },
|
|
393
|
+
{ id: "export", cmd: "xcodebuild", args: exportArgv(spec, { root, optionsFile }), optionsFile },
|
|
394
|
+
{ id: "verify-ipa", description: "extracted IPA: version identity, entitlements, embedded.mobileprovision, code signature" },
|
|
395
|
+
{ id: "receipt", file: path.join(receiptDir, IOS_RECEIPT_NAME) },
|
|
396
|
+
],
|
|
397
|
+
teamId: spec.teamId,
|
|
398
|
+
bundleId: spec.bundleId,
|
|
399
|
+
ipa: path.resolve(root, spec.ipa),
|
|
400
|
+
archive: path.resolve(root, spec.archive),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---- verification helpers ----------------------------------------------------------------------------------------
|
|
405
|
+
const sha256Bytes = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
406
|
+
|
|
407
|
+
export function iosSealEntry(receipt) {
|
|
408
|
+
return { role: "ipa", name: receipt.file.name, sha256: receipt.file.sha256, sizeBytes: receipt.file.sizeBytes };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Walk a bundle for the plists HeardRight checks: the app, PlugIns/*.appex, Watch/*.app and Watch/*.app/PlugIns/*.appex. */
|
|
412
|
+
export function bundlePlistPaths(appDir, fs = nodeFs) {
|
|
413
|
+
const list = (dir, suffix) => {
|
|
414
|
+
try { return fs.readdirSync(dir).filter((name) => name.endsWith(suffix)).sort().map((name) => path.join(dir, name)); } catch { return []; }
|
|
415
|
+
};
|
|
416
|
+
const bundles = [{ role: "app", dir: appDir }];
|
|
417
|
+
for (const appex of list(path.join(appDir, "PlugIns"), ".appex")) bundles.push({ role: "appex", dir: appex });
|
|
418
|
+
for (const watch of list(path.join(appDir, "Watch"), ".app")) {
|
|
419
|
+
bundles.push({ role: "watch", dir: watch });
|
|
420
|
+
for (const appex of list(path.join(watch, "PlugIns"), ".appex")) bundles.push({ role: "watch-appex", dir: appex });
|
|
421
|
+
}
|
|
422
|
+
return bundles.map((bundle) => ({ ...bundle, plist: path.join(bundle.dir, "Info.plist") }));
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Pure: throws IosPackageError unless every bundle shares the main app's marketing version and build, with no `$(...)`. */
|
|
426
|
+
export function assertVersionIdentity(infos, { expectedBuild } = {}) {
|
|
427
|
+
const [main] = infos;
|
|
428
|
+
const marketing = String(main?.info?.CFBundleShortVersionString ?? "");
|
|
429
|
+
const build = String(main?.info?.CFBundleVersion ?? "");
|
|
430
|
+
const fail = (message) => new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", message);
|
|
431
|
+
if (!marketing || !build || marketing.includes("$") || build.includes("$")) throw fail(`the archive has an unresolved version identity: ${marketing || "(none)"} (${build || "(none)"})`);
|
|
432
|
+
for (const { role, label, info } of infos.slice(1)) {
|
|
433
|
+
const m = String(info?.CFBundleShortVersionString ?? ""); const b = String(info?.CFBundleVersion ?? "");
|
|
434
|
+
if (m !== marketing || b !== build) throw fail(`version drift: ${role} ${label} has ${m || "(none)"} (${b || "(none)"}), the app has ${marketing} (${build})`);
|
|
435
|
+
}
|
|
436
|
+
if (expectedBuild !== undefined && build !== expectedBuild) throw fail(`the app build number is ${build}, not the recipe's ${expectedBuild}: an Info.plist literal (not $(CURRENT_PROJECT_VERSION)) overrides it`);
|
|
437
|
+
return { marketingVersion: marketing, buildNumber: build };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Pure: entitlements of one signed bundle in the distribution IPA. */
|
|
441
|
+
export function assertDistributionEntitlements(entitlements, label) {
|
|
442
|
+
const fail = (message) => new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", message);
|
|
443
|
+
if (!isPlainObject(entitlements)) throw fail(`${label} has no readable entitlements`);
|
|
444
|
+
if (entitlements["get-task-allow"] === true) throw fail(`${label} carries get-task-allow: a debuggable build cannot be a release`);
|
|
445
|
+
const aps = entitlements["aps-environment"];
|
|
446
|
+
if (aps !== undefined && aps !== "production") throw fail(`${label} has aps-environment ${JSON.stringify(aps)}; a release needs production`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Pure: XML plist text that `plutil -convert json` accepts. JSON has no date or data type, so a provisioning profile (its
|
|
451
|
+
* ExpirationDate/CreationDate and DeveloperCertificates) is refused as-is: dates become their ISO strings, data blobs empty
|
|
452
|
+
* strings. Only used to read values the checks compare; never written back.
|
|
453
|
+
*/
|
|
454
|
+
export function jsonSafePlistText(text) {
|
|
455
|
+
return String(text ?? "")
|
|
456
|
+
.replace(/<date>([^<]*)<\/date>/g, "<string>$1</string>")
|
|
457
|
+
.replace(/<data>[^<]*<\/data>/g, "<string></string>")
|
|
458
|
+
.replace(/<data\/>/g, "<string></string>");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Pure: the distribution provisioning profile embedded in the main app. */
|
|
462
|
+
export function assertDistributionProfile(profile, { teamId, bundleId }) {
|
|
463
|
+
const fail = (message) => new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", message);
|
|
464
|
+
if (!isPlainObject(profile)) throw fail("embedded.mobileprovision is missing or unreadable");
|
|
465
|
+
if (!Array.isArray(profile.TeamIdentifier) || !profile.TeamIdentifier.includes(teamId)) throw fail(`the embedded profile is not for team ${teamId}`);
|
|
466
|
+
const appId = profile.Entitlements?.["application-identifier"];
|
|
467
|
+
if (appId !== `${teamId}.${bundleId}`) throw fail(`the embedded profile authorizes ${String(appId)}, not ${teamId}.${bundleId}`);
|
|
468
|
+
if (profile.ProvisionedDevices !== undefined || profile.ProvisionsAllDevices === true) throw fail("the embedded profile is a development, ad hoc or enterprise profile; an App Store build carries a distribution profile");
|
|
469
|
+
if (profile.Entitlements?.["get-task-allow"] === true) throw fail("the embedded profile allows get-task-allow");
|
|
470
|
+
return { teamIdentifier: teamId, applicationIdentifier: appId, name: String(profile.Name ?? ""), uuid: String(profile.UUID ?? ""), expiration: profile.ExpirationDate ? String(profile.ExpirationDate) : null };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** For the sealing step: the receipt must describe exactly the IPA that is about to be sealed. */
|
|
474
|
+
export function verifyIosReceipt(receiptPath, ipaFile, { fs = nodeFs } = {}) {
|
|
475
|
+
const receipt = JSON.parse(fs.readFileSync(receiptPath, "utf8"));
|
|
476
|
+
if (receipt.schema !== 1 || receipt.contract !== IOS_SIGNING_CONTRACT) throw new Error("not an iOS IPA receipt");
|
|
477
|
+
if (path.basename(ipaFile) !== receipt.file?.name) throw new Error("IPA receipt names a different file");
|
|
478
|
+
const bytes = fs.readFileSync(ipaFile);
|
|
479
|
+
if (bytes.length !== receipt.file.sizeBytes || sha256Bytes(bytes) !== receipt.file.sha256) throw new Error("IPA bytes differ from the build receipt");
|
|
480
|
+
return receipt;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function scanForbiddenStrings(dir, strings, fs = nodeFs) {
|
|
484
|
+
const findings = [];
|
|
485
|
+
const walk = (current) => {
|
|
486
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
487
|
+
const file = path.join(current, entry.name);
|
|
488
|
+
if (entry.isDirectory()) walk(file);
|
|
489
|
+
else if (entry.isFile()) {
|
|
490
|
+
const bytes = fs.readFileSync(file);
|
|
491
|
+
for (const text of strings) if (bytes.includes(text)) findings.push({ file, string: text });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
walk(dir);
|
|
496
|
+
return findings;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ---- the recipe --------------------------------------------------------------------------------------------------
|
|
500
|
+
/**
|
|
501
|
+
* Runs the steps. `root` is the app root. `deps` (all optional, defaults are the real host):
|
|
502
|
+
* run(cmd, args, options) spawnSync-shaped; fakes in tests
|
|
503
|
+
* fs node:fs-shaped
|
|
504
|
+
* scanFiles(label, files, o) release.mjs's assertNoQaControlBinaries (QA rules plus the release-switch binary layer)
|
|
505
|
+
* detectSession / route GUI signing session seams (ios-host.mjs)
|
|
506
|
+
* gitHead() the source commit; it must not change during the build
|
|
507
|
+
* storage() DerivedData and result bundle (rightkit storage xcode-args)
|
|
508
|
+
* scanForbidden(dir, strings) forbidden-string scan
|
|
509
|
+
* counterDir where the per-app build number counter lives (the vault state root under right-release build)
|
|
510
|
+
*/
|
|
511
|
+
export async function runIosPackageRecipe({
|
|
512
|
+
root, app = "app", spec, dryRun = false, env = process.env, platform = process.platform,
|
|
513
|
+
receiptDir = path.join(root, ".right-release", "receipts"), stateDir = path.join(root, ".right-release"), counterDir = stateDir,
|
|
514
|
+
run = spawnSync, fs = nodeFs, scanFiles, detectSession, route, gitHead, storage, scanForbidden = scanForbiddenStrings,
|
|
515
|
+
now = () => new Date(), tmpRoot = os.tmpdir(), log = console.log,
|
|
516
|
+
}) {
|
|
517
|
+
if (!path.isAbsolute(root)) throw invalid("root must be an absolute path");
|
|
518
|
+
const ipa = path.resolve(root, spec.ipa);
|
|
519
|
+
const archive = path.resolve(root, spec.archive);
|
|
520
|
+
const receiptPath = path.join(receiptDir, IOS_RECEIPT_NAME);
|
|
521
|
+
|
|
522
|
+
if (dryRun) {
|
|
523
|
+
const plan = iosPackagePlan(spec, { root, receiptDir });
|
|
524
|
+
log(`right-release: dry-run: ios ${spec.scheme} ${spec.configuration} -> ${ipa}; ${plan.steps.length} steps (nothing is run, no credential is read)`);
|
|
525
|
+
for (const step of plan.steps) log(` ${step.id}${step.cmd ? `: ${step.cmd} ${step.args.join(" ")}` : step.description ? `: ${step.description}` : ""}`);
|
|
526
|
+
return { dryRun: true, plan, ipa };
|
|
527
|
+
}
|
|
528
|
+
if (platform !== "darwin") throw new IosPackageError("IOS_PACKAGE_HOST_UNSUPPORTED", `the iOS package recipe needs macOS with Xcode; this host is ${platform} (only config validation and --dry-run run elsewhere)`);
|
|
529
|
+
|
|
530
|
+
// ---- preflight: before any effect ----
|
|
531
|
+
let session;
|
|
532
|
+
try { session = assertGuiSigningSession({ operation: "ios package", env, run, detect: detectSession, route }); }
|
|
533
|
+
catch (error) { if (error instanceof IosHostError) throw new IosPackageError("IOS_PACKAGE_HOST_UNSUPPORTED", error.message, { cause: error }); throw error; }
|
|
534
|
+
const xcRun = session.routed ? session.route : run;
|
|
535
|
+
const sh = (cmd, args, options = {}) => xcRun(cmd, args, { cwd: root, env, encoding: "utf8", windowsHide: true, shell: false, maxBuffer: 512 * 1024 * 1024, ...options });
|
|
536
|
+
const xcode = sh("xcodebuild", ["-version"], { timeout: 60_000 });
|
|
537
|
+
if (xcode.error || xcode.status !== 0) throw new IosPackageError("IOS_PACKAGE_TOOL_MISSING", "xcodebuild is not available (install Xcode and select it with xcode-select)");
|
|
538
|
+
const xcodebuildVersion = String(xcode.stdout ?? "").trim().split(/\r?\n/).join(" ");
|
|
539
|
+
if (spec.projectSpec) {
|
|
540
|
+
const gen = sh("xcodegen", ["--version"], { timeout: 60_000 });
|
|
541
|
+
if (gen.error || gen.status !== 0) throw new IosPackageError("IOS_PACKAGE_TOOL_MISSING", `xcodegen is required because iosPackage.projectSpec names ${spec.projectSpec}; install it (brew install xcodegen). A committed xcodeproj is never used instead`);
|
|
542
|
+
}
|
|
543
|
+
if (!fs.existsSync(path.resolve(root, spec.projectSpec ?? spec.project))) throw new IosPackageError("IOS_PACKAGE_CONFIG_INVALID", `${spec.projectSpec ?? spec.project} does not exist`);
|
|
544
|
+
const resolvedStorage = (storage ?? (() => resolveXcodeStorage({ env, run, cwd: root, fallbackRoot: path.join(stateDir, "ios") })))();
|
|
545
|
+
const head = gitHead ?? (() => { const r = run("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", windowsHide: true }); if (r.status !== 0) throw new IosPackageError("IOS_PACKAGE_SOURCE_CHANGED", "git rev-parse HEAD failed"); return String(r.stdout).trim(); });
|
|
546
|
+
const sourceCommit = head();
|
|
547
|
+
|
|
548
|
+
// ---- stale outputs go first, so an old IPA is never mistaken for this build's ----
|
|
549
|
+
const exportDir = exportDirectory(spec, root);
|
|
550
|
+
const stale = [archive, ipa, exportDir, receiptPath, path.join(receiptDir, "ios-export-options.plist"), ...(resolvedStorage.resultBundle ? [resolvedStorage.resultBundle] : []), ...(spec.cleanup.derivedData ? [resolvedStorage.derivedData] : [])];
|
|
551
|
+
for (const file of stale) fs.rmSync(file, { recursive: true, force: true });
|
|
552
|
+
fs.mkdirSync(receiptDir, { recursive: true });
|
|
553
|
+
fs.mkdirSync(path.dirname(ipa), { recursive: true });
|
|
554
|
+
|
|
555
|
+
const cleanupOutputs = () => { for (const file of [ipa, archive, exportDir, receiptPath]) { try { fs.rmSync(file, { recursive: true, force: true }); } catch { /* best effort */ } } };
|
|
556
|
+
const step = (id, code, cmd, args, options = {}) => {
|
|
557
|
+
log(`right-release: ios ${id}: ${cmd}`);
|
|
558
|
+
const { logFile, ...runOptions } = options;
|
|
559
|
+
const result = sh(cmd, args, { timeout: spec.timeoutMs, ...runOptions });
|
|
560
|
+
if (logFile) { try { fs.writeFileSync(logFile, `${outputTail(result, 4000)}\n`); } catch { /* diagnostics only */ } }
|
|
561
|
+
if (result.error || result.status !== 0) throw new IosPackageError(code, `${cmd} ${id} failed (${result.status ?? "unavailable"})${result.error ? `: ${result.error.message}` : ""}\n${outputTail(result)}`);
|
|
562
|
+
return result;
|
|
563
|
+
};
|
|
564
|
+
const plist = (file) => {
|
|
565
|
+
const result = sh("plutil", ["-convert", "json", "-o", "-", "--", file], { timeout: 60_000 });
|
|
566
|
+
try { if (result.status === 0) return JSON.parse(String(result.stdout)); } catch { /* reported below */ }
|
|
567
|
+
throw new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", `cannot read ${path.basename(path.dirname(file))}/${path.basename(file)} with plutil`);
|
|
568
|
+
};
|
|
569
|
+
const plistFromText = (id, command, args, { emptyIsNone = false } = {}) => {
|
|
570
|
+
const first = sh(command, args, { timeout: 60_000 });
|
|
571
|
+
// A signed bundle with no entitlements prints nothing: that is an empty set, not a failure.
|
|
572
|
+
if (emptyIsNone && first.status === 0 && !String(first.stdout ?? "").trim()) return {};
|
|
573
|
+
if (first.status !== 0 || !String(first.stdout ?? "").includes("<")) throw new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", `${id} could not be read`);
|
|
574
|
+
const second = sh("plutil", ["-convert", "json", "-o", "-", "-"], { input: jsonSafePlistText(first.stdout), timeout: 60_000 });
|
|
575
|
+
try { if (second.status === 0) return JSON.parse(String(second.stdout)); } catch { /* reported below */ }
|
|
576
|
+
throw new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", `${id} is not a readable property list`);
|
|
577
|
+
};
|
|
578
|
+
const identityOf = (appDir) => bundlePlistPaths(appDir, fs).map((bundle) => {
|
|
579
|
+
if (!fs.existsSync(bundle.plist)) throw new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", `${bundle.role} ${path.basename(bundle.dir)} has no Info.plist`);
|
|
580
|
+
return { ...bundle, label: path.basename(bundle.dir), info: plist(bundle.plist) };
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
let tempDir;
|
|
584
|
+
try {
|
|
585
|
+
// ---- trust-check: reviewed Swift macro/plugin pins, before anything is generated or built ----
|
|
586
|
+
const trust = verifySwiftTrustSource({ spec, root, sh, fs });
|
|
587
|
+
// ---- generate ----
|
|
588
|
+
if (spec.projectSpec) {
|
|
589
|
+
step("generate", "IOS_PACKAGE_GENERATE_FAILED", "xcodegen", xcodegenArgv(spec, root));
|
|
590
|
+
if (!fs.existsSync(path.resolve(root, spec.project))) throw new IosPackageError("IOS_PACKAGE_GENERATE_FAILED", `xcodegen did not produce ${spec.project}`);
|
|
591
|
+
}
|
|
592
|
+
// ---- prepare ----
|
|
593
|
+
for (const [index, command] of spec.prepare.entries()) {
|
|
594
|
+
const cwd = path.resolve(root, command.cwd ?? ".");
|
|
595
|
+
step(`prepare ${index + 1}`, "IOS_PACKAGE_PREPARE_FAILED", command.cmd, command.args, { cwd });
|
|
596
|
+
}
|
|
597
|
+
if (trust) installSwiftTrustSource({ trust, root, fs });
|
|
598
|
+
// ---- archive ----
|
|
599
|
+
const buildNumber = nextDateBuildNumber({ now: now(), stateFile: path.join(counterDir, `ios-build-number.${spec.bundleId}.json`), fs });
|
|
600
|
+
step("archive", "IOS_PACKAGE_ARCHIVE_FAILED", "xcodebuild", archiveArgv(spec, { root, buildNumber, storage: resolvedStorage, trusted: trust?.evidence }), { logFile: path.join(receiptDir, "ios-archive.log") });
|
|
601
|
+
if (trust) assertSwiftTrustUnchanged({ trust, root, fs });
|
|
602
|
+
const appsDir = path.join(archive, "Products", "Applications");
|
|
603
|
+
const apps = fs.existsSync(appsDir) ? fs.readdirSync(appsDir).filter((name) => name.endsWith(".app")) : [];
|
|
604
|
+
if (apps.length !== 1) throw new IosPackageError("IOS_PACKAGE_ARCHIVE_FAILED", `the archive must hold exactly one app under Products/Applications, found ${apps.length}`);
|
|
605
|
+
const archivedApp = path.join(appsDir, apps[0]);
|
|
606
|
+
|
|
607
|
+
// ---- verify the archive ----
|
|
608
|
+
const archiveInfos = identityOf(archivedApp);
|
|
609
|
+
const identity = assertVersionIdentity(archiveInfos, { expectedBuild: buildNumber });
|
|
610
|
+
const mainInfo = archiveInfos[0].info;
|
|
611
|
+
if (mainInfo.CFBundleIdentifier !== spec.bundleId) throw new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", `the archived app bundle id is ${mainInfo.CFBundleIdentifier}, not iosPackage.bundleId ${spec.bundleId}`);
|
|
612
|
+
for (const [key, expected] of Object.entries(spec.checks.plist)) {
|
|
613
|
+
if (mainInfo[key] !== expected) throw new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", `Info.plist ${key} is ${JSON.stringify(mainInfo[key])}, expected ${JSON.stringify(expected)}`);
|
|
614
|
+
}
|
|
615
|
+
scanFiles?.("ios archive app", [archivedApp], { requireBinary: true, prePack: true });
|
|
616
|
+
if (spec.checks.forbidStrings.length) {
|
|
617
|
+
const found = scanForbidden(archivedApp, spec.checks.forbidStrings);
|
|
618
|
+
if (found.length) throw new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", `the archive contains forbidden string(s): ${[...new Set(found.map((item) => item.string))].join(", ")} (first file: ${path.relative(archive, found[0].file)})`);
|
|
619
|
+
}
|
|
620
|
+
if (head() !== sourceCommit) throw new IosPackageError("IOS_PACKAGE_SOURCE_CHANGED", "the repository HEAD changed during the archive; refusing to export a mixed-source build");
|
|
621
|
+
|
|
622
|
+
// ---- export ----
|
|
623
|
+
const optionsFile = path.join(receiptDir, "ios-export-options.plist");
|
|
624
|
+
const optionsText = exportOptionsPlist(spec);
|
|
625
|
+
fs.writeFileSync(optionsFile, optionsText);
|
|
626
|
+
// Taken just before the export: xcodebuild registers a build upload for this version and build during it (see ios-build-upload.mjs).
|
|
627
|
+
const exportStartedAt = now().toISOString();
|
|
628
|
+
step("export", "IOS_PACKAGE_EXPORT_FAILED", "xcodebuild", exportArgv(spec, { root, optionsFile }), { logFile: path.join(receiptDir, "ios-export.log") });
|
|
629
|
+
{
|
|
630
|
+
const produced = fs.existsSync(exportDir) ? fs.readdirSync(exportDir).filter((name) => name.endsWith(".ipa")) : [];
|
|
631
|
+
if (produced.length !== 1) throw new IosPackageError("IOS_PACKAGE_EXPORT_FAILED", `the export must produce exactly one .ipa in ${path.basename(exportDir)}, found ${produced.length}`);
|
|
632
|
+
fs.mkdirSync(path.dirname(ipa), { recursive: true });
|
|
633
|
+
fs.renameSync(path.join(exportDir, produced[0]), ipa);
|
|
634
|
+
}
|
|
635
|
+
if (!fs.statSync(ipa).size) throw new IosPackageError("IOS_PACKAGE_EXPORT_FAILED", "the exported IPA is empty");
|
|
636
|
+
|
|
637
|
+
// ---- verify the IPA: the bytes that will ship ----
|
|
638
|
+
tempDir = fs.mkdtempSync(path.join(tmpRoot, "rightkit-ios-ipa-"));
|
|
639
|
+
step("unzip ipa", "IOS_PACKAGE_VERIFY_FAILED", "ditto", ["-x", "-k", ipa, tempDir], { timeout: 10 * 60_000 });
|
|
640
|
+
const payload = path.join(tempDir, "Payload");
|
|
641
|
+
const shipped = fs.existsSync(payload) ? fs.readdirSync(payload).filter((name) => name.endsWith(".app")) : [];
|
|
642
|
+
if (shipped.length !== 1) throw new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", `the IPA must hold exactly one app under Payload, found ${shipped.length}`);
|
|
643
|
+
const shippedApp = path.join(payload, shipped[0]);
|
|
644
|
+
const ipaInfos = identityOf(shippedApp);
|
|
645
|
+
const ipaIdentity = assertVersionIdentity(ipaInfos, { expectedBuild: buildNumber });
|
|
646
|
+
if (ipaInfos[0].info.CFBundleIdentifier !== spec.bundleId) throw new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", `the IPA app bundle id is ${ipaInfos[0].info.CFBundleIdentifier}, not ${spec.bundleId}`);
|
|
647
|
+
let mainEntitlements;
|
|
648
|
+
for (const bundle of ipaInfos) {
|
|
649
|
+
const verify = sh("codesign", ["--verify", "--strict", bundle.dir], { timeout: 5 * 60_000 });
|
|
650
|
+
if (verify.error || verify.status !== 0) throw new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", `${bundle.label} fails codesign --verify --strict\n${outputTail(verify, 10)}`);
|
|
651
|
+
const entitlements = plistFromText(`${bundle.label} entitlements`, "codesign", ["-d", "--entitlements", ":-", bundle.dir], { emptyIsNone: true });
|
|
652
|
+
assertDistributionEntitlements(entitlements, `${bundle.role} ${bundle.label}`);
|
|
653
|
+
if (bundle.role === "app") mainEntitlements = entitlements;
|
|
654
|
+
}
|
|
655
|
+
// Every bundle that carries its own profile (the app, each appex, the Watch app and its appex) must hold a distribution profile for
|
|
656
|
+
// the configured team and its own bundle id; a missing profile fails closed.
|
|
657
|
+
let profile;
|
|
658
|
+
for (const bundle of ipaInfos) {
|
|
659
|
+
const profileFile = path.join(bundle.dir, "embedded.mobileprovision");
|
|
660
|
+
if (!fs.existsSync(profileFile)) throw new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", `${bundle.role} ${bundle.label} has no embedded.mobileprovision`);
|
|
661
|
+
const summary = assertDistributionProfile(plistFromText(`${bundle.label} embedded.mobileprovision`, "security", ["cms", "-D", "-i", profileFile]), { teamId: spec.teamId, bundleId: String(bundle.info.CFBundleIdentifier ?? "") });
|
|
662
|
+
if (bundle.role === "app") profile = summary;
|
|
663
|
+
}
|
|
664
|
+
scanFiles?.("ios IPA app", [shippedApp], { requireBinary: true, prePack: true });
|
|
665
|
+
if (spec.checks.forbidStrings.length) {
|
|
666
|
+
const found = scanForbidden(shippedApp, spec.checks.forbidStrings);
|
|
667
|
+
if (found.length) throw new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", `the IPA contains forbidden string(s): ${[...new Set(found.map((item) => item.string))].join(", ")}`);
|
|
668
|
+
}
|
|
669
|
+
if (head() !== sourceCommit) throw new IosPackageError("IOS_PACKAGE_SOURCE_CHANGED", "the repository HEAD changed during the export");
|
|
670
|
+
|
|
671
|
+
// ---- receipt over the FINAL bytes ----
|
|
672
|
+
const bytes = fs.readFileSync(ipa);
|
|
673
|
+
const receipt = {
|
|
674
|
+
schema: 1,
|
|
675
|
+
contract: IOS_SIGNING_CONTRACT,
|
|
676
|
+
app,
|
|
677
|
+
builtAt: now().toISOString(),
|
|
678
|
+
exportStartedAt,
|
|
679
|
+
sourceCommit,
|
|
680
|
+
xcodebuild: xcodebuildVersion,
|
|
681
|
+
scheme: spec.scheme,
|
|
682
|
+
configuration: spec.configuration,
|
|
683
|
+
teamId: spec.teamId,
|
|
684
|
+
bundleId: spec.bundleId,
|
|
685
|
+
marketingVersion: ipaIdentity.marketingVersion,
|
|
686
|
+
buildNumber: ipaIdentity.buildNumber,
|
|
687
|
+
file: { name: path.basename(ipa), sha256: sha256Bytes(bytes), sizeBytes: bytes.length },
|
|
688
|
+
exportOptionsSha256: sha256Bytes(optionsText),
|
|
689
|
+
entitlementsSha256: sha256Bytes(JSON.stringify(Object.fromEntries(Object.entries(mainEntitlements).sort(([a], [b]) => (a < b ? -1 : 1))))),
|
|
690
|
+
profile,
|
|
691
|
+
bundles: ipaInfos.map((bundle) => ({ role: bundle.role, name: bundle.label, bundleId: String(bundle.info.CFBundleIdentifier ?? "") })),
|
|
692
|
+
archiveVersion: identity,
|
|
693
|
+
// Present only when the app trusts reviewed Swift macros/plugins: what was trusted and the exact pins file it was checked against.
|
|
694
|
+
...(trust ? { swiftTrust: { packageResolved: { path: trust.sourceRel, sha256: trust.sha256 }, macros: trust.evidence.macros, plugins: trust.evidence.plugins } } : {}),
|
|
695
|
+
};
|
|
696
|
+
try { fs.writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`); }
|
|
697
|
+
catch (error) { throw new IosPackageError("IOS_PACKAGE_RECEIPT_FAILED", `cannot write the iOS receipt: ${error.message}`); }
|
|
698
|
+
log(`right-release: ios IPA ${receipt.file.name} ${receipt.marketingVersion} (${receipt.buildNumber}) sha256 ${receipt.file.sha256}; receipt ${receiptPath}`);
|
|
699
|
+
return { dryRun: false, ipa, receipt, receiptPath, sealEntry: iosSealEntry(receipt) };
|
|
700
|
+
} catch (error) {
|
|
701
|
+
cleanupOutputs();
|
|
702
|
+
throw error instanceof IosPackageError ? error : new IosPackageError("IOS_PACKAGE_VERIFY_FAILED", `iOS package failed: ${error.message}`, { cause: error });
|
|
703
|
+
} finally {
|
|
704
|
+
try { fs.rmSync(exportDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
705
|
+
if (tempDir) { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
706
|
+
}
|
|
707
|
+
}
|