@rightkit/release 0.3.0 → 0.4.1
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 +74 -10
- package/addon-command.mjs +3 -4
- package/adoption-evidence.mjs +650 -0
- package/android-play-upload.mjs +2 -1
- package/apple-profile-create.mjs +113 -10
- package/apple-profile-renew.mjs +11 -3
- package/artifact-storage.mjs +35 -2
- package/bin/darwin/rightkit-swap +0 -0
- package/binary-license-notices.mjs +161 -0
- package/build-release.mjs +138 -47
- package/cargo-license-notices.mjs +111 -0
- package/cli/right-release.mjs +21 -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 +222 -53
- 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 +3 -1
- package/ownership-evidence.mjs +30 -18
- package/ownership-key-command.mjs +69 -2
- package/ownership-mandatory-set.json +117 -67
- package/ownership-mandatory-set.mjs +141 -21
- 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/pipeline-admission.mjs +375 -0
- package/pipeline-config.mjs +100 -0
- package/pipeline-generators.json +4 -0
- package/pipeline-generators.mjs +137 -0
- package/pipeline-scan.mjs +285 -0
- package/pipeline-tools.mjs +171 -0
- package/preflight.mjs +14 -7
- package/production-mode.mjs +42 -0
- package/project-type.mjs +3 -1
- package/protected-credentials.mjs +28 -1
- package/publication-artifacts.mjs +30 -5
- package/publication-gate.mjs +94 -32
- package/publish-cargo.mjs +117 -25
- package/publish-swift.mjs +12 -3
- package/publish-update.mjs +15 -2
- package/qa-build-features.mjs +16 -13
- package/qa-contract.mjs +30 -2
- package/qa-control-scan.mjs +8 -0
- package/release-config.mjs +330 -0
- package/release-invocation.mjs +118 -8
- package/release-switch-registry.json +126 -27
- package/release-switch-scan.mjs +88 -21
- package/release.mjs +82 -20
- package/rightkit-ownership.json +1 -1
- package/rightkit-sync.mjs +8 -0
- package/rightkit-versions.json +428 -194
- package/runtime-budget-crates.mjs +14 -0
- package/runtime-budgets.mjs +248 -47
- package/sign-updater.mjs +56 -59
- package/source-gate.mjs +8 -1
- package/stage-sidecar.mjs +207 -40
- package/swap-helper-bundle.mjs +7 -3
- package/target-distribution.mjs +5 -0
- package/upload-release.mjs +7 -2
- package/bin/darwin/rightkit-swap.sign.json +0 -6
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// In-process IPA upload through the App Store Connect API build-upload resources (ASC API 4.1). No altool, no Transporter and
|
|
2
|
+
// NO key file: the JWT is signed in memory and every API call carries only that bearer token (settled decision D4).
|
|
3
|
+
//
|
|
4
|
+
// Resources and fields, checked against Apple's reference on 2026-10-18 (the doc pages are JS-rendered, so the field tables were
|
|
5
|
+
// read from developer.apple.com/tutorials/data/documentation/appstoreconnectapi/...):
|
|
6
|
+
// POST /v1/buildUploads https://developer.apple.com/documentation/appstoreconnectapi/post-v1-builduploads
|
|
7
|
+
// data { type "buildUploads", attributes { cfBundleShortVersionString, cfBundleVersion, platform: IOS|MAC_OS|TV_OS|VISION_OS } (all required),
|
|
8
|
+
// relationships { app } (required) }
|
|
9
|
+
// GET /v1/apps/{id}/buildUploads, GET /v1/buildUploads/{id}/buildUploadFiles (the related-endpoint lists on the pages above)
|
|
10
|
+
// POST /v1/buildUploadFiles https://developer.apple.com/documentation/appstoreconnectapi/post-v1-builduploadfiles
|
|
11
|
+
// data { type "buildUploadFiles", attributes { assetType ASSET, fileName, fileSize, uti "com.apple.ipa" } (all required),
|
|
12
|
+
// relationships { buildUpload } (required) }
|
|
13
|
+
// response attributes.uploadOperations[]: { method, url, length, offset, partNumber, entityTag, expiration, requestHeaders[{name,value}] }
|
|
14
|
+
// PATCH /v1/buildUploadFiles/{id} https://developer.apple.com/documentation/appstoreconnectapi/patch-v1-builduploadfiles-_id_ (commit)
|
|
15
|
+
// data { type "buildUploadFiles", id, attributes { uploaded: true, sourceFileChecksums: { file: { hash, algorithm: MD5 } } } } (SHA_256 refused live)
|
|
16
|
+
// GET /v1/buildUploads/{id} attributes.state { state: AWAITING_UPLOAD|PROCESSING|FAILED|COMPLETE, errors[], warnings[], infos[] }
|
|
17
|
+
// https://developer.apple.com/documentation/appstoreconnectapi/builduploadstate
|
|
18
|
+
// Pre-signed part uploads (https://developer.apple.com/documentation/appstoreconnectapi/uploading-assets-to-app-store-connect, read as
|
|
19
|
+
// the .../tutorials/data/... json): send exactly the bytes offset..offset+length with the operation's requestHeaders and NO JWT; the URL
|
|
20
|
+
// carries an `Expires` unix timestamp; a part that fails may be resent any time before the commit; after an expiry delete the reservation
|
|
21
|
+
// and start over. The page names no fixed host set; its sample is store-030.blobstore.apple.com, so only *.blobstore.apple.com is
|
|
22
|
+
// accepted here (UPLOAD_HOST_SUFFIXES) and anything else fails closed, naming the host. Live run 2026-10-19 (Pulse 0.1.0 2026101803): Apple
|
|
23
|
+
// handed out northamerica-1.object-storage.apple.com operations, so *.object-storage.apple.com is accepted too.
|
|
24
|
+
// Not stated by the reference and so unverified without a live run: that `hash` is lowercase hex, the exact StateDetail error fields,
|
|
25
|
+
// and the `limit` query on the buildUploads list.
|
|
26
|
+
//
|
|
27
|
+
// Idempotent calls (GET, PATCH, each part PUT) are retried with bounded backoff, honouring Retry-After on 429 and 5xx. The create POSTs
|
|
28
|
+
// are never retried: an existing buildUploads for the same app, version and build is looked up first and resumed (AWAITING_UPLOAD),
|
|
29
|
+
// waited for (PROCESSING), finished (COMPLETE) or refused (FAILED), but ONLY if the record is in this vault's journal for the same IPA
|
|
30
|
+
// sha256 (written right after each create POST). Any other record for that version and build is another binary's: refused, never adopted.
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
|
+
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
33
|
+
import { verifyArtifactFile } from "./publication-artifacts.mjs";
|
|
34
|
+
import { RETRY_DEFAULTS, TestFlightError, parseRetryAfter, retryDelayMs, retryableStatus } from "./ios-testflight.mjs";
|
|
35
|
+
|
|
36
|
+
export const UPLOAD_HOST_SUFFIXES = Object.freeze(["blobstore.apple.com", "object-storage.apple.com"]);
|
|
37
|
+
const FORBIDDEN_HEADERS = new Set(["authorization", "cookie", "proxy-authorization"]);
|
|
38
|
+
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
39
|
+
const EXPIRY_MARGIN_MS = 60_000;
|
|
40
|
+
|
|
41
|
+
const fail = (code, message) => { throw new TestFlightError(code, message); };
|
|
42
|
+
const text = (value) => String(value ?? "");
|
|
43
|
+
|
|
44
|
+
/** The ordered plan for a dry run (pure data, no secret). */
|
|
45
|
+
export function buildUploadPlan({ appId, marketingVersion, buildNumber, ipa }) {
|
|
46
|
+
return [
|
|
47
|
+
{ id: "buildUploads.find", method: "GET", url: `https://api.appstoreconnect.apple.com/v1/apps/${appId}/buildUploads`, note: `resume, finish or refuse an existing upload of ${marketingVersion} (${buildNumber})` },
|
|
48
|
+
{ id: "buildUploads.create", method: "POST", url: "https://api.appstoreconnect.apple.com/v1/buildUploads", note: `${marketingVersion} (${buildNumber}) for app ${appId}, platform IOS; never retried` },
|
|
49
|
+
{ id: "buildUploadFiles.reserve", method: "POST", url: "https://api.appstoreconnect.apple.com/v1/buildUploadFiles", note: `${ipa.name}, ${ipa.sizeBytes} bytes, uti com.apple.ipa; never retried` },
|
|
50
|
+
{ id: "uploadOperations.put", method: "PUT", url: "<pre-signed *.blobstore.apple.com URLs returned by buildUploadFiles>", note: "one ranged PUT per reserved operation, streamed from the sealed file, retried with backoff" },
|
|
51
|
+
{ id: "buildUploadFiles.commit", method: "PATCH", url: "https://api.appstoreconnect.apple.com/v1/buildUploadFiles/<id>", note: `uploaded: true with the SHA_256 ${ipa.sha256.slice(0, 12)}...` },
|
|
52
|
+
{ id: "buildUploads.poll", method: "GET", url: "https://api.appstoreconnect.apple.com/v1/buildUploads/<id>", note: "until state COMPLETE" },
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A sealed file read in ranges: hashed in a stream here once, and never held in memory as a whole. */
|
|
57
|
+
export function fileSource(file, evidence) {
|
|
58
|
+
const verified = verifyArtifactFile(file, evidence);
|
|
59
|
+
const same = (stat) => ["dev", "ino", "size", "mtimeMs", "ctimeMs"].every((key) => stat[key] === verified.identity[key]);
|
|
60
|
+
return {
|
|
61
|
+
file,
|
|
62
|
+
size: verified.sizeBytes,
|
|
63
|
+
sha256: verified.sha256,
|
|
64
|
+
read(offset, length) {
|
|
65
|
+
const fd = openSync(file, "r");
|
|
66
|
+
try {
|
|
67
|
+
if (!same(fstatSync(fd))) fail("IOS_BUILD_UPLOAD_SOURCE_CHANGED", "the sealed IPA changed while it was being uploaded");
|
|
68
|
+
const buffer = Buffer.alloc(length);
|
|
69
|
+
let filled = 0;
|
|
70
|
+
while (filled < length) {
|
|
71
|
+
const count = readSync(fd, buffer, filled, length - filled, offset + filled);
|
|
72
|
+
if (count <= 0) fail("IOS_BUILD_UPLOAD_SOURCE_CHANGED", "the sealed IPA is shorter than its recorded size");
|
|
73
|
+
filled += count;
|
|
74
|
+
}
|
|
75
|
+
return buffer;
|
|
76
|
+
} finally { closeSync(fd); }
|
|
77
|
+
},
|
|
78
|
+
/** MD5 of the sealed bytes (what the commit sends), streamed through read() so a changed file is still refused. */
|
|
79
|
+
md5() {
|
|
80
|
+
if (this._md5) return this._md5;
|
|
81
|
+
const hash = createHash("md5");
|
|
82
|
+
for (let offset = 0; offset < this.size; offset += 8 * 1024 * 1024) hash.update(this.read(offset, Math.min(8 * 1024 * 1024, this.size - offset)));
|
|
83
|
+
return (this._md5 = hash.digest("hex"));
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** One upload operation: PUT, https on an Apple upload host, no credential headers, not expired. Throws before a byte is sent. */
|
|
89
|
+
export function assertUploadOperation(op, { now = Date.now(), hostSuffixes = UPLOAD_HOST_SUFFIXES } = {}) {
|
|
90
|
+
const bad = (message) => fail("IOS_BUILD_UPLOAD_INVALID", message);
|
|
91
|
+
if (text(op.method).toUpperCase() !== "PUT") bad(`unsupported upload operation method ${text(op.method) || "(none)"}`);
|
|
92
|
+
let url;
|
|
93
|
+
try { url = new URL(text(op.url)); } catch { bad("an upload operation URL is not a URL"); }
|
|
94
|
+
if (url.protocol !== "https:") bad("an upload operation URL is not https");
|
|
95
|
+
if (url.username || url.password) bad("an upload operation URL carries credentials");
|
|
96
|
+
const host = url.hostname.toLowerCase();
|
|
97
|
+
if (!hostSuffixes.some((suffix) => host === suffix || host.endsWith(`.${suffix}`))) bad(`the upload host ${host} is not an Apple upload host (${hostSuffixes.map((suffix) => `*.${suffix}`).join(", ")})`);
|
|
98
|
+
for (const header of op.requestHeaders ?? []) {
|
|
99
|
+
const name = text(header?.name);
|
|
100
|
+
if (!HEADER_NAME.test(name)) bad("an upload operation has an invalid header name");
|
|
101
|
+
if (FORBIDDEN_HEADERS.has(name.toLowerCase())) bad(`an upload operation asks for the ${name} header; credentials are never sent to a pre-signed URL`);
|
|
102
|
+
if (/[\r\n\0]/.test(text(header.value))) bad("an upload operation has an invalid header value");
|
|
103
|
+
}
|
|
104
|
+
const expiries = [];
|
|
105
|
+
if (op.expiration !== undefined) { const at = Date.parse(text(op.expiration)); if (Number.isNaN(at)) bad("an upload operation has an unreadable expiration"); expiries.push(at); }
|
|
106
|
+
const expires = url.searchParams.get("Expires");
|
|
107
|
+
if (expires && /^\d{9,11}$/.test(expires)) expiries.push(Number(expires) * 1000);
|
|
108
|
+
if (expiries.some((at) => at <= now + EXPIRY_MARGIN_MS)) fail("IOS_BUILD_UPLOAD_EXPIRED", "an upload operation has expired; delete the build upload reservation in App Store Connect and run the upload again (Apple: after an expiry start over with a new reservation)");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function tile(operations, size) {
|
|
112
|
+
const ordered = [...operations].sort((a, b) => a.offset - b.offset);
|
|
113
|
+
let cursor = 0;
|
|
114
|
+
for (const op of ordered) {
|
|
115
|
+
if (!Number.isInteger(op.offset) || !Number.isInteger(op.length) || op.offset !== cursor || op.length < 1 || op.offset + op.length > size) fail("IOS_BUILD_UPLOAD_INVALID", "the upload operations do not cover the IPA exactly");
|
|
116
|
+
cursor += op.length;
|
|
117
|
+
}
|
|
118
|
+
if (cursor !== size) fail("IOS_BUILD_UPLOAD_INVALID", "the upload operations do not cover the IPA exactly");
|
|
119
|
+
return ordered;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function putPart(op, bytes, { fetchImpl, sleep, retry, partTimeoutMs, now }) {
|
|
123
|
+
const headers = Object.fromEntries((op.requestHeaders ?? []).map((header) => [text(header.name), text(header.value)]));
|
|
124
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
125
|
+
assertUploadOperation(op, { now: now() });
|
|
126
|
+
let response;
|
|
127
|
+
try {
|
|
128
|
+
// The URL is pre-signed and carries its own authorisation: no bearer token goes to it, and it is never logged.
|
|
129
|
+
response = await fetchImpl(op.url, { method: "PUT", headers, body: bytes, redirect: "error", signal: AbortSignal.timeout(partTimeoutMs) });
|
|
130
|
+
} catch {
|
|
131
|
+
if (attempt < retry.attempts - 1) { await sleep(retryDelayMs(attempt, undefined, retry)); continue; }
|
|
132
|
+
fail("IOS_BUILD_UPLOAD_FAILED", `upload of part ${op.partNumber ?? "?"} failed to send`);
|
|
133
|
+
}
|
|
134
|
+
if (response.ok) return;
|
|
135
|
+
if (attempt < retry.attempts - 1 && retryableStatus(response.status)) { await sleep(retryDelayMs(attempt, parseRetryAfter(response.headers?.get?.("retry-after"), now()), retry)); continue; }
|
|
136
|
+
fail("IOS_BUILD_UPLOAD_FAILED", `upload of part ${op.partNumber ?? "?"} was refused with HTTP ${response.status}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Upload the sealed IPA (a fileSource) and wait until App Store Connect reports the build upload COMPLETE.
|
|
142
|
+
* Returns { buildUploadId, fileId, parts, resumed }.
|
|
143
|
+
*/
|
|
144
|
+
export async function uploadIpaToAppStoreConnect({
|
|
145
|
+
client, appId, marketingVersion, buildNumber, source, fileName, journal, exportWindow, fetchImpl = globalThis.fetch,
|
|
146
|
+
pollIntervalMs = 20_000, timeoutMs = 30 * 60_000, partTimeoutMs = 30 * 60_000, retry = RETRY_DEFAULTS, now = () => Date.now(),
|
|
147
|
+
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), monotonic = () => performance.now(), log = () => {},
|
|
148
|
+
}) {
|
|
149
|
+
if (!/^\d+$/.test(text(appId))) fail("IOS_BUILD_UPLOAD_CONFIG", "appId must be the numeric App Store Connect app id");
|
|
150
|
+
if (!journal || typeof journal.read !== "function" || typeof journal.record !== "function") fail("IOS_BUILD_UPLOAD_CONFIG", "a build-upload journal is required: an upload record is adopted only if this vault created it for this IPA");
|
|
151
|
+
if (!source || !Number.isInteger(source.size) || source.size < 1 || !/^[a-f0-9]{64}$/.test(text(source.sha256))) fail("IOS_BUILD_UPLOAD_CONFIG", "the IPA source is required");
|
|
152
|
+
|
|
153
|
+
const journalled = journal.read(); // a damaged journal stops the run before any request
|
|
154
|
+
// Look before creating: a create that reached Apple but lost its answer must not be repeated.
|
|
155
|
+
const existing = (await client.getAll(`/v1/apps/${encodeURIComponent(text(appId))}/buildUploads?limit=200`)).filter((item) => {
|
|
156
|
+
const attributes = item.attributes ?? {};
|
|
157
|
+
return text(attributes.cfBundleShortVersionString) === text(marketingVersion) && text(attributes.cfBundleVersion) === text(buildNumber) && (attributes.platform === undefined || attributes.platform === "IOS");
|
|
158
|
+
});
|
|
159
|
+
const stateOf = (item) => text(item.attributes?.state?.state);
|
|
160
|
+
const identity = { appId: text(appId), version: text(marketingVersion), build: text(buildNumber), ipaSha256: source.sha256 };
|
|
161
|
+
const mine = (item) => journalled.find((entry) => entry.buildUploadId === text(item.id) && entry.appId === identity.appId && entry.version === identity.version && entry.build === identity.build);
|
|
162
|
+
// Only a record this vault created, for these very bytes, may be adopted. Anything else with the same version and build is another
|
|
163
|
+
// binary's (a second Mac, a fresh vault, a manual upload): our bytes never go into it and its result is never reported as ours.
|
|
164
|
+
// Xcode's own `-exportArchive` (method app-store-connect) registers an empty build upload for the archive's version and build,
|
|
165
|
+
// even with destination export. That record is this build's: adopt it only while it is still AWAITING_UPLOAD with no file, was
|
|
166
|
+
// created inside the sealed export's window (exportWindow: exportStartedAt - 30 s to builtAt, from the sealed receipt), and is the
|
|
167
|
+
// only record of any state for this version and build.
|
|
168
|
+
const fromOwnExport = async (item) => {
|
|
169
|
+
if (!exportWindow || stateOf(item) !== "AWAITING_UPLOAD") return false;
|
|
170
|
+
if (existing.length !== 1) return false; // the sole record for this version and build, in any state
|
|
171
|
+
const created = Date.parse(text(item.attributes?.createdDate));
|
|
172
|
+
const notBefore = Date.parse(text(exportWindow.notBefore)), notAfter = Date.parse(text(exportWindow.notAfter));
|
|
173
|
+
if (![created, notBefore, notAfter].every(Number.isFinite) || created < notBefore || created > notAfter) return false;
|
|
174
|
+
return (await client.getAll(`/v1/buildUploads/${encodeURIComponent(text(item.id))}/buildUploadFiles`)).length === 0;
|
|
175
|
+
};
|
|
176
|
+
for (const item of existing) {
|
|
177
|
+
let entry = mine(item);
|
|
178
|
+
if (stateOf(item) === "FAILED" && !entry) continue;
|
|
179
|
+
if (!entry && await fromOwnExport(item)) {
|
|
180
|
+
entry = { buildUploadId: text(item.id), buildUploadFileId: null, ...identity, adoptedFromExport: true };
|
|
181
|
+
journal.record(entry);
|
|
182
|
+
journalled.push(entry);
|
|
183
|
+
log(`adopting build upload ${text(item.id)}: Xcode's export registered it for this build (${marketingVersion} ${buildNumber}) and it holds no file`);
|
|
184
|
+
}
|
|
185
|
+
if (!entry) fail("IOS_BUILD_UPLOAD_FOREIGN", `App Store Connect holds build upload ${text(item.id)} (${stateOf(item) || "unknown state"}) for ${marketingVersion} (${buildNumber}) that this machine did not create for this IPA; it is not adopted and nothing is sent into it; build again with a new build number`);
|
|
186
|
+
if (entry.ipaSha256 !== identity.ipaSha256) fail("IOS_BUILD_UPLOAD_FOREIGN", `build upload ${text(item.id)} for ${marketingVersion} (${buildNumber}) was journalled for a different IPA (${text(entry.ipaSha256).slice(0, 12)}..., this one is ${identity.ipaSha256.slice(0, 12)}...); it is not adopted; build again with a new build number`);
|
|
187
|
+
}
|
|
188
|
+
const checkChecksum = (file) => {
|
|
189
|
+
const reported = file?.attributes?.sourceFileChecksums?.file;
|
|
190
|
+
if (reported?.hash && text(reported.algorithm).toUpperCase() === "SHA_256" && text(reported.hash).toLowerCase() !== source.sha256) fail("IOS_BUILD_UPLOAD_FOREIGN", `build upload file ${text(file.id)} reports a different SHA-256 than the sealed IPA; it is not adopted`);
|
|
191
|
+
// The commit sends MD5, so a resumed, PROCESSING or COMPLETE file reports MD5: compare it with the sealed bytes too.
|
|
192
|
+
if (reported?.hash && text(reported.algorithm).toUpperCase() === "MD5" && typeof source.md5 === "function" && text(reported.hash).toLowerCase() !== source.md5()) fail("IOS_BUILD_UPLOAD_FOREIGN", `build upload file ${text(file.id)} reports a different MD5 than the sealed IPA; it is not adopted`);
|
|
193
|
+
};
|
|
194
|
+
const filesOf = async (id) => (await client.getAll(`/v1/buildUploads/${encodeURIComponent(id)}/buildUploadFiles`));
|
|
195
|
+
let buildUploadId;
|
|
196
|
+
let resumed = false;
|
|
197
|
+
let skipPut = false;
|
|
198
|
+
if (existing.length) {
|
|
199
|
+
const failed = existing.filter((item) => stateOf(item) === "FAILED");
|
|
200
|
+
const live = existing.filter((item) => stateOf(item) !== "FAILED");
|
|
201
|
+
const complete = live.find((item) => stateOf(item) === "COMPLETE");
|
|
202
|
+
const processing = live.filter((item) => stateOf(item) === "PROCESSING");
|
|
203
|
+
const awaiting = live.filter((item) => stateOf(item) === "AWAITING_UPLOAD");
|
|
204
|
+
if (complete) {
|
|
205
|
+
for (const file of await filesOf(text(complete.id))) checkChecksum(file);
|
|
206
|
+
log(`build upload ${complete.id} for ${marketingVersion} (${buildNumber}) is already COMPLETE`);
|
|
207
|
+
return { buildUploadId: text(complete.id), fileId: undefined, parts: 0, resumed: true };
|
|
208
|
+
}
|
|
209
|
+
if (live.length > 1 && processing.length + awaiting.length > 1) fail("IOS_BUILD_UPLOAD_AMBIGUOUS", `${live.length} build uploads exist for ${marketingVersion} (${buildNumber}); delete the extras in App Store Connect`);
|
|
210
|
+
if (!live.length) fail("IOS_BUILD_UPLOAD_FAILED", `App Store Connect already holds a FAILED build upload (${failed.map((item) => item.id).join(", ")}) for ${marketingVersion} (${buildNumber}); a build number cannot be reused, so build again with a new build number`);
|
|
211
|
+
buildUploadId = text(live[0].id);
|
|
212
|
+
resumed = true;
|
|
213
|
+
skipPut = stateOf(live[0]) === "PROCESSING";
|
|
214
|
+
if (skipPut) for (const file of await filesOf(buildUploadId)) checkChecksum(file);
|
|
215
|
+
log(`resuming build upload ${buildUploadId} (${stateOf(live[0])})`);
|
|
216
|
+
} else {
|
|
217
|
+
const created = await client.request("POST", "/v1/buildUploads", { data: {
|
|
218
|
+
type: "buildUploads",
|
|
219
|
+
attributes: { cfBundleShortVersionString: text(marketingVersion), cfBundleVersion: text(buildNumber), platform: "IOS" },
|
|
220
|
+
relationships: { app: { data: { type: "apps", id: text(appId) } } },
|
|
221
|
+
} });
|
|
222
|
+
buildUploadId = text(created.data?.id);
|
|
223
|
+
if (!buildUploadId) fail("IOS_BUILD_UPLOAD_INVALID", "buildUploads returned no id");
|
|
224
|
+
journal.record({ buildUploadId, buildUploadFileId: null, ...identity }); // before anything else can fail
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let fileId;
|
|
228
|
+
let parts = 0;
|
|
229
|
+
if (!skipPut) {
|
|
230
|
+
let file = resumed
|
|
231
|
+
? (await filesOf(buildUploadId)).find((item) => text(item.attributes?.fileName) === fileName)
|
|
232
|
+
: undefined;
|
|
233
|
+
if (file && file.attributes?.fileSize !== undefined && file.attributes.fileSize !== source.size) fail("IOS_BUILD_UPLOAD_MISMATCH", `the existing reservation for ${fileName} is for ${file.attributes.fileSize} bytes, the sealed IPA is ${source.size}`);
|
|
234
|
+
if (file) checkChecksum(file);
|
|
235
|
+
const known = journal.read().find((entry) => entry.buildUploadId === buildUploadId);
|
|
236
|
+
if (file && known?.buildUploadFileId && known.buildUploadFileId !== text(file.id)) fail("IOS_BUILD_UPLOAD_FOREIGN", `build upload file ${text(file.id)} is not the one journalled (${known.buildUploadFileId}); it is not adopted`);
|
|
237
|
+
if (!file) {
|
|
238
|
+
const reserved = await client.request("POST", "/v1/buildUploadFiles", { data: {
|
|
239
|
+
type: "buildUploadFiles",
|
|
240
|
+
attributes: { assetType: "ASSET", fileName, fileSize: source.size, uti: "com.apple.ipa" },
|
|
241
|
+
relationships: { buildUpload: { data: { type: "buildUploads", id: buildUploadId } } },
|
|
242
|
+
} });
|
|
243
|
+
file = reserved.data;
|
|
244
|
+
journal.record({ buildUploadId, buildUploadFileId: text(file?.id) || null, ...identity });
|
|
245
|
+
}
|
|
246
|
+
fileId = text(file?.id);
|
|
247
|
+
const deliveryState = text(file?.attributes?.assetDeliveryState?.state);
|
|
248
|
+
const operations = file?.attributes?.uploadOperations;
|
|
249
|
+
if (!fileId) fail("IOS_BUILD_UPLOAD_INVALID", "buildUploadFiles returned no id");
|
|
250
|
+
if (["UPLOAD_COMPLETE", "COMPLETE"].includes(deliveryState)) {
|
|
251
|
+
log(`build upload file ${fileId} is already committed`);
|
|
252
|
+
} else {
|
|
253
|
+
if (!Array.isArray(operations) || !operations.length) fail("IOS_BUILD_UPLOAD_INVALID", "buildUploadFiles returned no upload operations");
|
|
254
|
+
const ordered = tile(operations, source.size);
|
|
255
|
+
for (const op of ordered) assertUploadOperation(op, { now: now() }); // all structure and header checks before a single byte leaves
|
|
256
|
+
// The commit carries the file's MD5: the live API refused SHA_256 for sourceFileChecksums.file (HTTP 409, 2026-10-19). The parts tile
|
|
257
|
+
// the file in order, so the digest is taken over exactly the bytes sent; the sealed SHA-256 is still enforced by fileSource.
|
|
258
|
+
const md5 = createHash("md5");
|
|
259
|
+
for (const op of ordered) { const bytes = source.read(op.offset, op.length); md5.update(bytes); await putPart(op, bytes, { fetchImpl, sleep, retry, partTimeoutMs, now }); }
|
|
260
|
+
const fileMd5 = md5.digest("hex");
|
|
261
|
+
parts = ordered.length;
|
|
262
|
+
log(`build upload ${buildUploadId}: ${parts} part(s) sent`);
|
|
263
|
+
await client.request("PATCH", `/v1/buildUploadFiles/${encodeURIComponent(fileId)}`, { data: {
|
|
264
|
+
type: "buildUploadFiles", id: fileId,
|
|
265
|
+
attributes: { uploaded: true, sourceFileChecksums: { file: { hash: fileMd5, algorithm: "MD5" } } },
|
|
266
|
+
} });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const deadline = monotonic() + timeoutMs;
|
|
271
|
+
for (;;) {
|
|
272
|
+
const current = await client.request("GET", `/v1/buildUploads/${encodeURIComponent(buildUploadId)}`);
|
|
273
|
+
const state = current.data?.attributes?.state ?? {};
|
|
274
|
+
if (state.state === "COMPLETE") return { buildUploadId, fileId, parts, resumed };
|
|
275
|
+
if (state.state === "FAILED") {
|
|
276
|
+
const detail = (state.errors ?? []).map((item) => text(item.description || item.message || item.code)).filter(Boolean).join("; ").replace(/Bearer\s+\S+/gi, "Bearer [redacted]").slice(0, 400);
|
|
277
|
+
fail("IOS_BUILD_UPLOAD_FAILED", `App Store Connect rejected build upload ${buildUploadId}${detail ? `: ${detail}` : ""}`);
|
|
278
|
+
}
|
|
279
|
+
if (monotonic() >= deadline) fail("TESTFLIGHT_TIMEOUT", `timed out waiting for build upload ${buildUploadId} to complete (state ${text(state.state) || "unknown"})`);
|
|
280
|
+
await sleep(pollIntervalMs);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// right-release ios-credentials store | check (bogusyogi/rightkit#349, settled decision D4)
|
|
2
|
+
//
|
|
3
|
+
// cat AuthKey_ABC123DEFG.p8 | right-release ios-credentials store --key-id ABC123DEFG --issuer <issuer-uuid>
|
|
4
|
+
// [--service <key service>] [--key-id-service <name>] [--issuer-service <name>] [--account <name>]
|
|
5
|
+
// right-release ios-credentials check [same service flags]
|
|
6
|
+
//
|
|
7
|
+
// `store` reads the .p8 from stdin and writes the key, the key id and the issuer id into the macOS login Keychain once. The key
|
|
8
|
+
// is never written to disk by this command and never appears in an argument or a log line (it goes to `security -i` on stdin).
|
|
9
|
+
// Pipe it from a secure source and delete the downloaded .p8 afterwards. A key typed at a terminal is refused: it would echo.
|
|
10
|
+
// `check` reads the three items back and prints the key id, the issuer id and the service names, never the key.
|
|
11
|
+
import { realpathSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { spawnSync } from "./background-process.mjs";
|
|
15
|
+
import { checkAscCredentials, IosCredentialsError, iosCredentialsStoreHint, storeAscCredentials } from "./ios-asc-credentials.mjs";
|
|
16
|
+
|
|
17
|
+
const FLAGS = new Map([["--service", "keyService"], ["--key-id-service", "keyIdService"], ["--issuer-service", "issuerService"], ["--account", "account"]]);
|
|
18
|
+
|
|
19
|
+
export function parseIosCredentialsArgs(args) {
|
|
20
|
+
const [command, ...rest] = args;
|
|
21
|
+
if (command !== "store" && command !== "check") throw new IosCredentialsError("IOS_CREDENTIALS_USAGE", "expected: right-release ios-credentials store|check");
|
|
22
|
+
const parsed = { command, credentials: {}, keyId: undefined, issuer: undefined };
|
|
23
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
24
|
+
const arg = rest[index];
|
|
25
|
+
const value = () => {
|
|
26
|
+
const next = rest[++index];
|
|
27
|
+
if (!next || next.startsWith("--")) throw new IosCredentialsError("IOS_CREDENTIALS_USAGE", `missing value for ${arg}`);
|
|
28
|
+
return next;
|
|
29
|
+
};
|
|
30
|
+
if (FLAGS.has(arg)) parsed.credentials[FLAGS.get(arg)] = value();
|
|
31
|
+
else if (arg === "--key-id" && command === "store") parsed.keyId = value();
|
|
32
|
+
else if (arg === "--issuer" && command === "store") parsed.issuer = value();
|
|
33
|
+
else throw new IosCredentialsError("IOS_CREDENTIALS_USAGE", `unknown option: ${arg}`);
|
|
34
|
+
}
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const readStdin = async (stream) => {
|
|
39
|
+
const chunks = [];
|
|
40
|
+
for await (const chunk of stream) chunks.push(Buffer.from(chunk));
|
|
41
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export async function main(args = process.argv.slice(2), { stdin = process.stdin, env = process.env, platform = process.platform, run = spawnSync, log = console.log } = {}) {
|
|
45
|
+
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
|
|
46
|
+
log("right-release ios-credentials store --key-id <KEYID> --issuer <issuer-uuid> [--service <name>] [--key-id-service <name>] [--issuer-service <name>] [--account <name>] (the .p8 comes from stdin)\nright-release ios-credentials check [--service <name>] [--key-id-service <name>] [--issuer-service <name>] [--account <name>]\nDefault Keychain services: rightkit-asc-api-key, rightkit-asc-key-id, rightkit-asc-issuer-id. macOS only; nothing is written to disk.");
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
const parsed = parseIosCredentialsArgs(args);
|
|
50
|
+
const credentials = Object.keys(parsed.credentials).length ? parsed.credentials : undefined;
|
|
51
|
+
if (parsed.command === "check") {
|
|
52
|
+
const found = checkAscCredentials({ credentials, env, platform, run });
|
|
53
|
+
log(`ios-credentials: key id ${found.keyId}, issuer ${found.issuer}, services ${found.services.join(", ")} (account ${found.account}); the key itself is present and parses`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
if (!parsed.keyId || !parsed.issuer) throw new IosCredentialsError("IOS_CREDENTIALS_USAGE", `store needs --key-id and --issuer; ${iosCredentialsStoreHint()}`);
|
|
57
|
+
if (stdin.isTTY) throw new IosCredentialsError("IOS_CREDENTIALS_USAGE", `the key must be piped into stdin (a key typed into a terminal would echo); ${iosCredentialsStoreHint()}`);
|
|
58
|
+
const keyText = await readStdin(stdin);
|
|
59
|
+
const stored = storeAscCredentials({ keyText, keyId: parsed.keyId, issuer: parsed.issuer, credentials, env, platform, run });
|
|
60
|
+
log(`ios-credentials: stored key id ${stored.keyId} and issuer ${stored.issuer} in the login Keychain (services ${stored.services.join(", ")}, account ${stored.account}); delete the .p8 file you piped in`);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
|
|
65
|
+
if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
|
|
66
|
+
main().then((code) => { process.exitCode = code; }).catch((error) => {
|
|
67
|
+
console.error(`right-release ios-credentials: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
});
|
|
70
|
+
}
|
package/ios-dev.mjs
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// right-release dev --platform ios: a Debug build installed on a simulator or a physical device. Never a release lane: it
|
|
2
|
+
// archives nothing, uploads nothing and reads no App Store Connect credential. The simulator flow follows ScrapeRight's
|
|
3
|
+
// scripts/run-e2e.sh (simctl boot, install, launch --terminate-running-process); the device flow follows HeardRight's
|
|
4
|
+
// device-smoke.sh (devicectl install, process launch --terminate-existing --no-activate).
|
|
5
|
+
//
|
|
6
|
+
// Config (development.targets.ios):
|
|
7
|
+
// {
|
|
8
|
+
// project: "ios/Pulse.xcodeproj",
|
|
9
|
+
// projectSpec?: "ios/project.yml", // when named, `xcodegen generate` runs first and is REQUIRED (no fallback project)
|
|
10
|
+
// scheme: "Pulse", configuration?: "Debug", teamId?: "ABCDE12345", // teamId is required for a device build
|
|
11
|
+
// extraBuildSettings?: { NAME: "value" }, // non-secret
|
|
12
|
+
// install: { kind: "simulator", device: "<UDID or exact name>", bundleId, productName?, boot?: true, launch?: true, launchArgs?: [] }
|
|
13
|
+
// | { kind: "device", device: "<devicectl identifier>", bundleId, productName?, launch?: true, launchArgs?: [] },
|
|
14
|
+
// }
|
|
15
|
+
// The build commands are generated from the config (a hand-written `build` is refused). The device is chosen by id only, never
|
|
16
|
+
// "the only device": `install.device`, `--serial` or RIGHTKIT_IOS_DEVICE. DerivedData comes from `rightkit storage xcode-args`.
|
|
17
|
+
import * as nodeFs from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { spawnSync } from "./background-process.mjs";
|
|
21
|
+
import { assertGuiSigningSession, IosHostError, outputTail, resolveXcodeStorage } from "./ios-host.mjs";
|
|
22
|
+
import { SECRET_VALUE } from "./ios-package-recipe.mjs";
|
|
23
|
+
|
|
24
|
+
export class IosDevelopmentError extends Error {
|
|
25
|
+
constructor(code, message) { super(message); this.name = "IosDevelopmentError"; this.code = code; }
|
|
26
|
+
}
|
|
27
|
+
const refuse = (code, message) => { throw new IosDevelopmentError(code, message); };
|
|
28
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
29
|
+
|
|
30
|
+
const TARGET_KEYS = ["project", "projectSpec", "scheme", "configuration", "teamId", "extraBuildSettings", "prepare", "install"];
|
|
31
|
+
const INSTALL_KEYS = ["kind", "device", "bundleId", "productName", "boot", "launch", "launchArgs"];
|
|
32
|
+
const UDID = /^[0-9A-Fa-f]{8}-(?:[0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$/;
|
|
33
|
+
const DEVICE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{3,80}$/;
|
|
34
|
+
const RESERVED_SETTING = /^(?:DEVELOPMENT_TEAM|CODE_SIGN\w*|PROVISIONING\w*|OTHER_CODE_SIGN_FLAGS|ARCHS?|SDKROOT)$/;
|
|
35
|
+
const SECRET_SETTING = /SECRET|TOKEN|PASSWORD|PASSWD|BEARER|PRIVATE|CREDENTIAL|API_?KEY|AUTH/i;
|
|
36
|
+
const RELEASE_WORD = /^-{0,2}(?:archive|exportArchive|altool|upload|upload-app|upload-package|testflight)$/i;
|
|
37
|
+
|
|
38
|
+
const relativeInside = (value, label, extension) => {
|
|
39
|
+
if (typeof value !== "string" || !value.trim()) refuse("DEV_IOS_CONFIG_INVALID", `${label} must be a relative path inside the app`);
|
|
40
|
+
const normalized = value.replaceAll("\\", "/");
|
|
41
|
+
if (path.isAbsolute(value) || path.win32.isAbsolute(value) || /^[A-Za-z]:/.test(value) || normalized.split("/").includes("..")) refuse("DEV_IOS_CONFIG_INVALID", `${label} must be relative and stay inside the app`);
|
|
42
|
+
if (extension && !extension.test(normalized)) refuse("DEV_IOS_CONFIG_INVALID", `${label} must match ${extension}`);
|
|
43
|
+
return normalized.replace(/^\.\//, "");
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function iosDevelopmentDevice({ descriptor, serial, env = process.env }) {
|
|
47
|
+
const chosen = serial ?? descriptor?.device ?? env.RIGHTKIT_IOS_DEVICE;
|
|
48
|
+
if (!chosen) refuse("DEV_IOS_DEVICE_REQUIRED", "name the device by id (install.device, --serial or RIGHTKIT_IOS_DEVICE); the only attached device is never assumed");
|
|
49
|
+
const text = String(chosen);
|
|
50
|
+
const ok = descriptor?.kind === "simulator" ? (UDID.test(text) || /^[A-Za-z0-9][A-Za-z0-9 ()._-]{1,80}$/.test(text)) : DEVICE_ID.test(text);
|
|
51
|
+
if (!ok) refuse("DEV_IOS_DEVICE_INVALID", "the iOS device must be a simulator UDID or exact name, or a devicectl device identifier");
|
|
52
|
+
return text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Validates development.targets.ios in place of the generic build/install rules. Returns the normalized target. */
|
|
56
|
+
export function validateIosDevelopment(target, { root, env = process.env } = {}) {
|
|
57
|
+
if (!isObject(target)) refuse("DEV_IOS_CONFIG_INVALID", "development.targets.ios must be an object");
|
|
58
|
+
if (target.build !== undefined) refuse("DEV_IOS_CONFIG_INVALID", "development.targets.ios.build is generated from project, scheme and install; remove it");
|
|
59
|
+
const unknown = Object.keys(target).filter((key) => !TARGET_KEYS.includes(key));
|
|
60
|
+
if (unknown.length) refuse("DEV_IOS_CONFIG_INVALID", `development.targets.ios has unknown key(s): ${unknown.join(", ")} (allowed: ${TARGET_KEYS.join(", ")})`);
|
|
61
|
+
const project = relativeInside(target.project, "development.targets.ios.project", /\.xcodeproj$/i);
|
|
62
|
+
const projectSpec = target.projectSpec === undefined ? null : relativeInside(target.projectSpec, "development.targets.ios.projectSpec", /\.ya?ml$/i);
|
|
63
|
+
if (projectSpec && path.posix.dirname(projectSpec) !== path.posix.dirname(project)) refuse("DEV_IOS_CONFIG_INVALID", "projectSpec and project must be in the same directory");
|
|
64
|
+
if (typeof target.scheme !== "string" || !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,100}$/.test(target.scheme)) refuse("DEV_IOS_CONFIG_INVALID", "development.targets.ios.scheme is required");
|
|
65
|
+
const configuration = target.configuration ?? "Debug";
|
|
66
|
+
if (typeof configuration !== "string" || !/^[A-Za-z][A-Za-z0-9 _-]{0,40}$/.test(configuration)) refuse("DEV_IOS_CONFIG_INVALID", "configuration must be a build configuration name");
|
|
67
|
+
if (/release/i.test(configuration)) refuse("DEV_IOS_RELEASE_CONFIGURATION", `development.targets.ios.configuration ${configuration} is a release configuration; a dev build is a Debug build`);
|
|
68
|
+
if (target.teamId !== undefined && !/^[A-Z0-9]{10}$/.test(String(target.teamId))) refuse("DEV_IOS_CONFIG_INVALID", "teamId must be the 10 character Apple team id");
|
|
69
|
+
const extraBuildSettings = {};
|
|
70
|
+
if (target.extraBuildSettings !== undefined) {
|
|
71
|
+
if (!isObject(target.extraBuildSettings)) refuse("DEV_IOS_CONFIG_INVALID", "extraBuildSettings must be an object of NAME: value");
|
|
72
|
+
for (const [name, value] of Object.entries(target.extraBuildSettings)) {
|
|
73
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(name) || RESERVED_SETTING.test(name)) refuse("DEV_IOS_CONFIG_INVALID", `extraBuildSettings.${name} is not allowed`);
|
|
74
|
+
if (SECRET_SETTING.test(name)) refuse("DEV_IOS_CONFIG_INVALID", `extraBuildSettings.${name} looks like a secret; build settings travel on the command line`);
|
|
75
|
+
if (typeof value !== "string" || /[\0\r\n]/.test(value) || SECRET_VALUE.test(value)) refuse("DEV_IOS_CONFIG_INVALID", `extraBuildSettings.${name} must be a single-line non-secret string`);
|
|
76
|
+
extraBuildSettings[name] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const prepare = (target.prepare ?? []).map((item, index) => {
|
|
80
|
+
if (!isObject(item) || typeof item.cmd !== "string" || !item.cmd.trim() || /\s/.test(item.cmd.trim()) || (item.args !== undefined && (!Array.isArray(item.args) || item.args.some((arg) => typeof arg !== "string")))) {
|
|
81
|
+
refuse("DEV_IOS_CONFIG_INVALID", `development.targets.ios.prepare[${index}] must be { cmd, args?, cwd? }`);
|
|
82
|
+
}
|
|
83
|
+
const tokens = [item.cmd, ...(item.args ?? [])];
|
|
84
|
+
if (tokens.some((token) => RELEASE_WORD.test(token))) refuse("DEV_IOS_RELEASE_TASK", `development.targets.ios.prepare[${index}] looks like a release, archive or upload step; a dev build never archives, uploads or distributes`);
|
|
85
|
+
return { cmd: item.cmd.trim(), args: [...(item.args ?? [])], ...(item.cwd === undefined ? {} : { cwd: relativeInside(item.cwd, `prepare[${index}].cwd`) }) };
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
let install = null;
|
|
89
|
+
if (target.install !== undefined) {
|
|
90
|
+
const descriptor = target.install;
|
|
91
|
+
if (!isObject(descriptor)) refuse("DEV_IOS_CONFIG_INVALID", "development.targets.ios.install must be an object");
|
|
92
|
+
const bad = Object.keys(descriptor).filter((key) => !INSTALL_KEYS.includes(key));
|
|
93
|
+
if (bad.length) refuse("DEV_IOS_CONFIG_INVALID", `development.targets.ios.install has unknown key(s): ${bad.join(", ")} (allowed: ${INSTALL_KEYS.join(", ")})`);
|
|
94
|
+
if (descriptor.kind !== "simulator" && descriptor.kind !== "device") refuse("DEV_IOS_INSTALL_KIND", "development.targets.ios.install.kind must be simulator or device");
|
|
95
|
+
if (typeof descriptor.bundleId !== "string" || !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(descriptor.bundleId)) refuse("DEV_IOS_CONFIG_INVALID", "install.bundleId must be the app bundle id");
|
|
96
|
+
if (descriptor.productName !== undefined && (typeof descriptor.productName !== "string" || !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,80}$/.test(descriptor.productName))) refuse("DEV_IOS_CONFIG_INVALID", "install.productName must be the built .app name without the extension");
|
|
97
|
+
for (const key of ["boot", "launch"]) if (descriptor[key] !== undefined && typeof descriptor[key] !== "boolean") refuse("DEV_IOS_CONFIG_INVALID", `install.${key} must be a boolean`);
|
|
98
|
+
if (descriptor.kind === "device" && descriptor.boot !== undefined) refuse("DEV_IOS_CONFIG_INVALID", "install.boot applies to a simulator only");
|
|
99
|
+
if (descriptor.launchArgs !== undefined && (!Array.isArray(descriptor.launchArgs) || descriptor.launchArgs.some((arg) => typeof arg !== "string" || /[\0\r\n]/.test(arg)))) refuse("DEV_IOS_CONFIG_INVALID", "install.launchArgs must be a list of strings");
|
|
100
|
+
if (descriptor.device !== undefined) iosDevelopmentDevice({ descriptor, env: {} });
|
|
101
|
+
if (descriptor.kind === "device" && !target.teamId) refuse("DEV_IOS_CONFIG_INVALID", "a device build needs development.targets.ios.teamId (there is no built-in default)");
|
|
102
|
+
install = { ...descriptor, productName: descriptor.productName ?? target.scheme, launchArgs: [...(descriptor.launchArgs ?? [])] };
|
|
103
|
+
}
|
|
104
|
+
const normalized = { project, projectSpec, scheme: target.scheme, configuration, teamId: target.teamId, extraBuildSettings, prepare, install };
|
|
105
|
+
return { ...normalized, build: iosDevelopmentBuildCommands(normalized, { root, storage: { derivedData: "<rightkit storage xcode-args -derivedDataPath>" }, destinationId: "<device or simulator id>" }) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const destinationFor = (install, id) => (install?.kind === "device" ? `id=${id}` : `platform=iOS Simulator,id=${id}`);
|
|
109
|
+
export const productsDirectory = (dev) => `${dev.configuration}-${dev.install?.kind === "device" ? "iphoneos" : "iphonesimulator"}`;
|
|
110
|
+
|
|
111
|
+
/** Pure: the generated argv commands, in order. No secret is ever part of them. */
|
|
112
|
+
export function iosDevelopmentBuildCommands(dev, { root, storage, destinationId }) {
|
|
113
|
+
const commands = [];
|
|
114
|
+
if (dev.projectSpec) commands.push({ cmd: "xcodegen", args: ["generate", "--spec", path.resolve(root, dev.projectSpec), "--project", path.resolve(root, path.posix.dirname(dev.project))], name: "xcodegen generate" });
|
|
115
|
+
for (const step of dev.prepare) commands.push({ ...step, name: step.cmd });
|
|
116
|
+
const device = dev.install?.kind === "device";
|
|
117
|
+
commands.push({
|
|
118
|
+
cmd: "xcodebuild",
|
|
119
|
+
name: "xcodebuild build",
|
|
120
|
+
args: [
|
|
121
|
+
"-project", path.resolve(root, dev.project),
|
|
122
|
+
"-scheme", dev.scheme,
|
|
123
|
+
"-configuration", dev.configuration,
|
|
124
|
+
"-destination", dev.install ? destinationFor(dev.install, destinationId) : "generic/platform=iOS Simulator",
|
|
125
|
+
"-derivedDataPath", storage.derivedData,
|
|
126
|
+
...(storage.resultBundle ? ["-resultBundlePath", storage.resultBundle] : []),
|
|
127
|
+
...(dev.teamId ? [`DEVELOPMENT_TEAM=${dev.teamId}`] : []),
|
|
128
|
+
...Object.entries(dev.extraBuildSettings).map(([name, value]) => `${name}=${value}`),
|
|
129
|
+
...(device ? ["-allowProvisioningUpdates", "-allowProvisioningDeviceRegistration"] : []),
|
|
130
|
+
"build",
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
return commands;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- device and simulator lookup --------------------------------------------------------------------------------
|
|
137
|
+
const xcrun = (run, args, options = {}) => run("xcrun", args, { encoding: "utf8", windowsHide: true, shell: false, timeout: 120_000, ...options });
|
|
138
|
+
|
|
139
|
+
export function listSimulators(run) {
|
|
140
|
+
const result = xcrun(run, ["simctl", "list", "devices", "available", "--json"], { timeout: 60_000 });
|
|
141
|
+
if (result.error || result.status !== 0) refuse("DEV_IOS_SIMCTL_FAILED", `simctl list failed (${result.status ?? "unavailable"})`);
|
|
142
|
+
let parsed;
|
|
143
|
+
try { parsed = JSON.parse(String(result.stdout)); } catch { refuse("DEV_IOS_SIMCTL_FAILED", "simctl list returned invalid JSON"); }
|
|
144
|
+
return Object.entries(parsed.devices ?? {}).flatMap(([runtime, devices]) => (devices ?? []).filter((device) => device.isAvailable !== false).map((device) => ({ udid: String(device.udid), name: String(device.name), state: String(device.state), runtime })));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function selectSimulator(devices, wanted) {
|
|
148
|
+
const matches = UDID.test(wanted) ? devices.filter((device) => device.udid.toLowerCase() === wanted.toLowerCase()) : devices.filter((device) => device.name === wanted);
|
|
149
|
+
if (!matches.length) refuse("DEV_IOS_DEVICE_NOT_FOUND", `no available simulator matches ${wanted}`);
|
|
150
|
+
if (matches.length > 1) refuse("DEV_IOS_DEVICE_AMBIGUOUS", `${matches.length} simulators are named ${wanted}; use the UDID`);
|
|
151
|
+
return matches[0];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Port of HeardRight's check-ios-device-ready.py readiness rule. */
|
|
155
|
+
export function deviceReadiness(device) {
|
|
156
|
+
const props = device.deviceProperties ?? {};
|
|
157
|
+
const hardware = device.hardwareProperties ?? {};
|
|
158
|
+
const tunnel = device.connectionProperties?.tunnelState ?? "unknown";
|
|
159
|
+
const ddi = props.ddiServicesAvailable;
|
|
160
|
+
const ready = !["unavailable", "disconnected"].includes(tunnel) && ddi !== false;
|
|
161
|
+
return { ready, developerMode: props.developerModeStatus ?? "unknown", detail: `${props.name ?? device.name ?? "device"} (${hardware.productType ?? hardware.marketingName ?? "unknown model"}) tunnel=${tunnel} ddi=${ddi} developerMode=${props.developerModeStatus ?? "unknown"}` };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function selectDevice(devices, wanted) {
|
|
165
|
+
const physical = devices.filter((device) => (device.deviceProperties?.name ?? device.name) !== "My Mac" && device.hardwareProperties?.deviceType !== "Mac");
|
|
166
|
+
const found = physical.find((device) => [device.identifier, device.deviceProperties?.identifier, device.hardwareProperties?.udid, device.deviceProperties?.name, device.name].includes(wanted));
|
|
167
|
+
if (!found) refuse("DEV_IOS_DEVICE_NOT_FOUND", `devicectl does not report a device ${wanted}`);
|
|
168
|
+
const state = deviceReadiness(found);
|
|
169
|
+
if (!state.ready) refuse("DEV_IOS_DEVICE_NOT_READY", `${state.detail}: connect it, unlock it, keep it awake, trust this Mac and enable Developer Mode`);
|
|
170
|
+
return { identifier: String(found.identifier ?? found.deviceProperties?.identifier ?? wanted), detail: state.detail };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function listDevices({ run, fs, tmpRoot }) {
|
|
174
|
+
const dir = fs.mkdtempSync(path.join(tmpRoot, "rightkit-ios-dev-"));
|
|
175
|
+
try {
|
|
176
|
+
const file = path.join(dir, "devices.json");
|
|
177
|
+
const result = xcrun(run, ["devicectl", "list", "devices", "--json-output", file, "--timeout", "10"], { timeout: 60_000 });
|
|
178
|
+
if (result.error || result.status !== 0) refuse("DEV_IOS_DEVICECTL_FAILED", `devicectl list devices failed (${result.status ?? "unavailable"})`);
|
|
179
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")).result?.devices ?? []; } catch { return refuse("DEV_IOS_DEVICECTL_FAILED", "devicectl returned unreadable device JSON"); }
|
|
180
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Resolve the storage, the device or simulator and the final build commands BEFORE the (long) build, so a missing or
|
|
185
|
+
* unready device is refused up front. Returns { build, install } with install carrying the resolved id and the .app path.
|
|
186
|
+
*/
|
|
187
|
+
export function prepareIosDevelopment(dev, { root, env = process.env, run = spawnSync, fs = nodeFs, serial, tmpRoot = os.tmpdir() }) {
|
|
188
|
+
const storage = resolveXcodeStorage({ env, run, cwd: root, fallbackRoot: path.join(root, ".right-release", "ios-dev") });
|
|
189
|
+
let resolved = null;
|
|
190
|
+
if (dev.install) {
|
|
191
|
+
const wanted = iosDevelopmentDevice({ descriptor: dev.install, serial, env });
|
|
192
|
+
if (dev.install.kind === "simulator") {
|
|
193
|
+
const simulator = selectSimulator(listSimulators(run), wanted);
|
|
194
|
+
resolved = { id: simulator.udid, state: simulator.state, name: simulator.name };
|
|
195
|
+
} else {
|
|
196
|
+
const device = selectDevice(listDevices({ run, fs, tmpRoot }), wanted);
|
|
197
|
+
resolved = { id: device.identifier, detail: device.detail };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const build = iosDevelopmentBuildCommands(dev, { root, storage, destinationId: resolved?.id });
|
|
201
|
+
const app = dev.install ? path.join(storage.derivedData, "Build", "Products", productsDirectory(dev), `${dev.install.productName}.app`) : null;
|
|
202
|
+
return { build, storage, install: dev.install ? { ...dev.install, resolved, app } : null };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// App arguments follow a "--" terminator so an argument such as --device or --json-output is passed to the app and can never be
|
|
206
|
+
// read as an option of simctl or devicectl (the standard ArgumentParser terminator; unverified on a live Mac, see the docs).
|
|
207
|
+
const launchTail = (args) => (args.length ? ["--", ...args] : []);
|
|
208
|
+
|
|
209
|
+
// ---- install ------------------------------------------------------------------------------------------------------
|
|
210
|
+
/** `run` is spawnSync-shaped (tests inject a fake). Every step is bounded; the first failure stops and throws. */
|
|
211
|
+
export function installIosDevelopment(install, { root, env = process.env, run = spawnSync, fs = nodeFs, tmpRoot = os.tmpdir(), log = console.log } = {}) {
|
|
212
|
+
if (!install?.app || !install.resolved) refuse("DEV_IOS_APP_MISSING", "the install was not prepared (no built .app)");
|
|
213
|
+
if (!fs.existsSync(install.app)) refuse("DEV_IOS_APP_MISSING", `the Debug build did not produce ${path.basename(install.app)}`);
|
|
214
|
+
const id = install.resolved.id;
|
|
215
|
+
const step = (name, args, options) => {
|
|
216
|
+
const result = xcrun(run, args, { cwd: root, env, ...options });
|
|
217
|
+
if (result.error || result.status !== 0) refuse(`DEV_IOS_${name.toUpperCase()}_FAILED`, `${args.slice(0, 3).join(" ")} failed (${result.status ?? "unavailable"})\n${outputTail(result, 20)}`);
|
|
218
|
+
return result;
|
|
219
|
+
};
|
|
220
|
+
if (install.kind === "simulator") {
|
|
221
|
+
if (install.resolved.state !== "Booted" && install.boot !== false) step("boot", ["simctl", "boot", id], { timeout: 120_000 });
|
|
222
|
+
step("install", ["simctl", "install", id, install.app], { timeout: 5 * 60_000 });
|
|
223
|
+
if (install.launch !== false) step("launch", ["simctl", "launch", "--terminate-running-process", id, install.bundleId, ...launchTail(install.launchArgs)], { timeout: 60_000 });
|
|
224
|
+
log(`right-release dev: installed ${install.bundleId} on simulator ${install.resolved.name} (${id}) (debug build; not a release artifact)`);
|
|
225
|
+
return { kind: "simulator", device: id, bundleId: install.bundleId, app: install.app };
|
|
226
|
+
}
|
|
227
|
+
step("install", ["devicectl", "device", "install", "app", "--device", id, install.app], { timeout: 10 * 60_000 });
|
|
228
|
+
if (install.launch !== false) {
|
|
229
|
+
const dir = fs.mkdtempSync(path.join(tmpRoot, "rightkit-ios-dev-"));
|
|
230
|
+
try {
|
|
231
|
+
const out = path.join(dir, "launch.json");
|
|
232
|
+
step("launch", ["devicectl", "device", "process", "launch", "--device", id, "--terminate-existing", "--no-activate", "--json-output", out, install.bundleId, ...launchTail(install.launchArgs)], { timeout: 120_000 });
|
|
233
|
+
let payload = {};
|
|
234
|
+
try { payload = JSON.parse(fs.readFileSync(out, "utf8")); } catch { /* reported below */ }
|
|
235
|
+
const result = payload.result ?? {};
|
|
236
|
+
if (!(result.process || result.processIdentifier || result.launchResult)) refuse("DEV_IOS_LAUNCH_FAILED", "devicectl launch did not report a process result");
|
|
237
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
238
|
+
}
|
|
239
|
+
log(`right-release dev: installed ${install.bundleId} on device ${id} (debug build; not a release artifact)`);
|
|
240
|
+
return { kind: "device", device: id, bundleId: install.bundleId, app: install.app };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The GUI signing session check for a device build (the keychain must be unlocked); a simulator build signs nothing. */
|
|
244
|
+
export function assertIosDevelopmentSession(dev, { env = process.env, run = spawnSync, detect, route } = {}) {
|
|
245
|
+
if (dev.install?.kind !== "device") return { routed: false, skipped: true };
|
|
246
|
+
try { return assertGuiSigningSession({ operation: "right-release dev --platform ios (device build)", env, run, detect, route }); }
|
|
247
|
+
catch (error) { if (error instanceof IosHostError) refuse(error.code, error.message); throw error; }
|
|
248
|
+
}
|