@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.
Files changed (83) hide show
  1. package/README.md +74 -10
  2. package/addon-command.mjs +3 -4
  3. package/adoption-evidence.mjs +650 -0
  4. package/android-play-upload.mjs +2 -1
  5. package/apple-profile-create.mjs +113 -10
  6. package/apple-profile-renew.mjs +11 -3
  7. package/artifact-storage.mjs +35 -2
  8. package/bin/darwin/rightkit-swap +0 -0
  9. package/binary-license-notices.mjs +161 -0
  10. package/build-release.mjs +138 -47
  11. package/cargo-license-notices.mjs +111 -0
  12. package/cli/right-release.mjs +21 -2
  13. package/codesign-environment.mjs +2 -2
  14. package/content-digest.mjs +209 -0
  15. package/development.mjs +17 -6
  16. package/direct-bootstrap.mjs +5 -6
  17. package/disk-command.mjs +1 -0
  18. package/disk-policy.json +6 -2
  19. package/disk-stable-root.mjs +13 -9
  20. package/github-updater-release.mjs +70 -7
  21. package/in-place-install.mjs +17 -14
  22. package/install-app-windows.mjs +24 -7
  23. package/install-app.mjs +21 -40
  24. package/ios-asc-credentials.mjs +163 -0
  25. package/ios-build-context.mjs +129 -0
  26. package/ios-build-upload.mjs +282 -0
  27. package/ios-credentials-command.mjs +70 -0
  28. package/ios-dev.mjs +248 -0
  29. package/ios-host.mjs +76 -0
  30. package/ios-lock.mjs +75 -0
  31. package/ios-package-recipe.mjs +707 -0
  32. package/ios-testflight.mjs +266 -0
  33. package/ios-upload.mjs +313 -0
  34. package/local-publish.mjs +222 -53
  35. package/managed-cargo.mjs +8 -0
  36. package/mirror-root-artifact.mjs +5 -1
  37. package/native-cargo-layout.mjs +47 -4
  38. package/ownership-command.mjs +2 -1
  39. package/ownership-dependency-allowlist.json +3 -1
  40. package/ownership-evidence.mjs +30 -18
  41. package/ownership-key-command.mjs +69 -2
  42. package/ownership-mandatory-set.json +117 -67
  43. package/ownership-mandatory-set.mjs +141 -21
  44. package/ownership-repositories.json +1 -1
  45. package/ownership-scope.mjs +94 -0
  46. package/ownership.mjs +49 -7
  47. package/package-recipe-targets.mjs +41 -1
  48. package/package.json +1 -1
  49. package/pipeline-admission.mjs +375 -0
  50. package/pipeline-config.mjs +100 -0
  51. package/pipeline-generators.json +4 -0
  52. package/pipeline-generators.mjs +137 -0
  53. package/pipeline-scan.mjs +285 -0
  54. package/pipeline-tools.mjs +171 -0
  55. package/preflight.mjs +14 -7
  56. package/production-mode.mjs +42 -0
  57. package/project-type.mjs +3 -1
  58. package/protected-credentials.mjs +28 -1
  59. package/publication-artifacts.mjs +30 -5
  60. package/publication-gate.mjs +94 -32
  61. package/publish-cargo.mjs +117 -25
  62. package/publish-swift.mjs +12 -3
  63. package/publish-update.mjs +15 -2
  64. package/qa-build-features.mjs +16 -13
  65. package/qa-contract.mjs +30 -2
  66. package/qa-control-scan.mjs +8 -0
  67. package/release-config.mjs +330 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +88 -21
  71. package/release.mjs +82 -20
  72. package/rightkit-ownership.json +1 -1
  73. package/rightkit-sync.mjs +8 -0
  74. package/rightkit-versions.json +428 -194
  75. package/runtime-budget-crates.mjs +14 -0
  76. package/runtime-budgets.mjs +248 -47
  77. package/sign-updater.mjs +56 -59
  78. package/source-gate.mjs +8 -1
  79. package/stage-sidecar.mjs +207 -40
  80. package/swap-helper-bundle.mjs +7 -3
  81. package/target-distribution.mjs +5 -0
  82. package/upload-release.mjs +7 -2
  83. package/bin/darwin/rightkit-swap.sign.json +0 -6
@@ -0,0 +1,266 @@
1
+ // TestFlight distribution through the App Store Connect API: wait for one EXACT build to finish processing, assign it to beta
2
+ // groups, and (HeardRight parity) put it into external beta review when an external group is selected. A port of HeardRight's
3
+ // scripts/testflight-distribute.py to Node, with the JWT signed in-process from the Keychain key (signAppleApiJwtWithKey)
4
+ // instead of shelling out to openssl on a .p8 file. No App Store submission happens here: the only review object created is
5
+ // the TestFlight beta app review submission, and only when `submitExternalBetaReview` is true.
6
+ //
7
+ // Everything external is injected (`fetchImpl`, `sleep`, `monotonic`, `now`) so tests run no network and no real wait.
8
+ import { signAppleApiJwtWithKey } from "./apple-profile-renew.mjs";
9
+
10
+ export const ASC_API = "https://api.appstoreconnect.apple.com";
11
+ const FAILED_PROCESSING_STATES = new Set(["FAILED", "INVALID"]);
12
+ const EXTERNAL_REVIEW_PENDING_OR_READY = new Set(["WAITING_FOR_BETA_REVIEW", "IN_BETA_REVIEW", "READY_FOR_BETA_TESTING", "IN_BETA_TESTING", "BETA_APPROVED"]);
13
+ const ACCEPTED_REVIEW_STATES = new Set(["WAITING_FOR_REVIEW", "IN_REVIEW", "APPROVED"]);
14
+
15
+ export class TestFlightError extends Error {
16
+ constructor(code, message) { super(message); this.name = "TestFlightError"; this.code = code; }
17
+ }
18
+ const fail = (code, message) => { throw new TestFlightError(code, message); };
19
+ const text = (value) => String(value ?? "");
20
+
21
+ // Apple's limit is 20 minutes; stay well under it (1080 s at most) and renew one minute before expiry, so a 30 minute wait never
22
+ // uses a stale token.
23
+ export const MAX_JWT_LIFETIME_SECONDS = 1080;
24
+ export function createTokenProvider({ privateKey, keyId, issuer, now = () => new Date(), lifetimeSeconds = MAX_JWT_LIFETIME_SECONDS, sign = signAppleApiJwtWithKey }) {
25
+ if (!Number.isInteger(lifetimeSeconds) || lifetimeSeconds < 120 || lifetimeSeconds > MAX_JWT_LIFETIME_SECONDS) fail("TESTFLIGHT_CONFIG_INVALID", `the JWT lifetime must be 120..${MAX_JWT_LIFETIME_SECONDS} seconds`);
26
+ let token = "";
27
+ let expiresAt = 0;
28
+ return {
29
+ get() {
30
+ const current = now();
31
+ const seconds = Math.floor(current.getTime() / 1000);
32
+ if (token && seconds < expiresAt - 60) return token;
33
+ token = sign(privateKey, { keyId, issuer }, current, { lifetimeSeconds });
34
+ expiresAt = seconds + lifetimeSeconds;
35
+ return token;
36
+ },
37
+ };
38
+ }
39
+
40
+ // ---- bounded retry of idempotent calls ----------------------------------------------------------------------------
41
+ export const RETRY_DEFAULTS = Object.freeze({ attempts: 5, baseMs: 1000, maxBackoffMs: 30_000, maxRetryAfterMs: 120_000 });
42
+ const IDEMPOTENT = new Set(["GET", "HEAD", "PUT", "PATCH", "DELETE"]);
43
+
44
+ /** Seconds or an HTTP date in a Retry-After header, as milliseconds (undefined when absent or unreadable). */
45
+ export function parseRetryAfter(value, nowMs = Date.now()) {
46
+ const raw = String(value ?? "").trim();
47
+ if (!raw) return undefined;
48
+ if (/^\d+$/.test(raw)) return Number(raw) * 1000;
49
+ const date = Date.parse(raw);
50
+ return Number.isNaN(date) ? undefined : Math.max(0, date - nowMs);
51
+ }
52
+ export const retryableStatus = (status) => status === 429 || (status >= 500 && status <= 599);
53
+
54
+ /** Delay before retry number `attempt` (0-based): Retry-After when given (capped), else exponential backoff (capped). */
55
+ export function retryDelayMs(attempt, retryAfterMs, { baseMs, maxBackoffMs, maxRetryAfterMs } = RETRY_DEFAULTS) {
56
+ if (retryAfterMs !== undefined) return Math.min(retryAfterMs, maxRetryAfterMs);
57
+ return Math.min(baseMs * 2 ** attempt, maxBackoffMs);
58
+ }
59
+
60
+ export function createAscClient({ tokenProvider, fetchImpl = globalThis.fetch, apiBase = ASC_API, timeoutMs = 30_000, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), retry = RETRY_DEFAULTS, now = () => Date.now() }) {
61
+ const base = new URL(apiBase);
62
+ // Only idempotent calls (GET, PATCH, PUT, DELETE) are retried, on a network error, 429 or 5xx, honouring Retry-After. A POST is never
63
+ // retried: a create that reached Apple but lost its answer must be looked up, not repeated.
64
+ const request = async (method, pathOrUrl, body, { retries = IDEMPOTENT.has(method) ? retry.attempts - 1 : 0 } = {}) => {
65
+ const url = new URL(pathOrUrl, base);
66
+ if (url.origin !== base.origin || !url.pathname.startsWith("/v1/")) fail("TESTFLIGHT_API_INVALID", "an App Store Connect URL is outside the API origin");
67
+ for (let attempt = 0; ; attempt += 1) {
68
+ let response;
69
+ try {
70
+ response = await fetchImpl(url.href, {
71
+ method,
72
+ headers: { Authorization: `Bearer ${tokenProvider.get()}`, Accept: "application/json", ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
73
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
74
+ redirect: "error",
75
+ signal: AbortSignal.timeout(timeoutMs),
76
+ });
77
+ } catch {
78
+ if (attempt < retries) { await sleep(retryDelayMs(attempt, undefined, retry)); continue; }
79
+ fail("TESTFLIGHT_API_FAILED", `App Store Connect ${method} ${url.pathname} request failed`);
80
+ }
81
+ if (!response.ok) {
82
+ if (attempt < retries && retryableStatus(response.status)) {
83
+ await sleep(retryDelayMs(attempt, parseRetryAfter(response.headers?.get?.("retry-after"), now()), retry));
84
+ continue;
85
+ }
86
+ let detail = "";
87
+ try {
88
+ const payload = await response.json();
89
+ detail = (payload?.errors ?? []).map((item) => text(item.detail || item.title)).join("; ");
90
+ } catch { /* no body */ }
91
+ detail = detail.replace(/Bearer\s+\S+/gi, "Bearer [redacted]").replace(/\s+/g, " ").slice(0, 400);
92
+ fail("TESTFLIGHT_API_FAILED", `App Store Connect ${method} ${url.pathname} failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
93
+ }
94
+ if (response.status === 204) return {};
95
+ let payload;
96
+ try { payload = await response.json(); } catch { return {}; }
97
+ return payload ?? {};
98
+ }
99
+ };
100
+ const getAll = async (pathOrUrl) => {
101
+ const items = [];
102
+ const visited = new Set();
103
+ let next = pathOrUrl;
104
+ while (next) {
105
+ if (visited.has(next) || visited.size >= 100) fail("TESTFLIGHT_API_INVALID", "invalid or excessive App Store Connect pagination");
106
+ visited.add(next);
107
+ const payload = await request("GET", next);
108
+ if (!Array.isArray(payload.data)) fail("TESTFLIGHT_API_INVALID", "an App Store Connect collection response was not a list");
109
+ items.push(...payload.data);
110
+ next = payload.links?.next;
111
+ }
112
+ return items;
113
+ };
114
+ return { request, getAll };
115
+ }
116
+
117
+ const groupName = (group) => text(group?.attributes?.name || group?.id);
118
+ const isInternal = (group) => Boolean(group?.attributes?.isInternalGroup);
119
+
120
+ export function normalizeGroupSelection(groups) {
121
+ // No implicit policy: the app says "all" or names its groups.
122
+ if (groups === "all") return "all";
123
+ if (Array.isArray(groups) && groups.length && groups.every((name) => typeof name === "string" && name.trim())) return [...new Set(groups.map((name) => name.trim()))];
124
+ return fail("TESTFLIGHT_CONFIG_INVALID", 'groups is required: "all" or a non-empty list of beta group names');
125
+ }
126
+
127
+ /**
128
+ * Wait for the exact build, assign it to the selected beta groups (re-read until the assignment converges), and handle
129
+ * external beta review. Returns the evidence object (no secret in it).
130
+ */
131
+ export async function distributeToTestFlight({
132
+ client, appId, marketingVersion, buildNumber, groups, submitExternalBetaReview = false,
133
+ pollIntervalMs = 20_000, timeoutMs = 30 * 60_000, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
134
+ monotonic = () => performance.now(), log = () => {},
135
+ }) {
136
+ if (!/^\d+$/.test(text(appId))) fail("TESTFLIGHT_CONFIG_INVALID", "appId must be the numeric App Store Connect app id");
137
+ if (!text(marketingVersion) || !text(buildNumber)) fail("TESTFLIGHT_CONFIG_INVALID", "the marketing version and the build number are required");
138
+ const selection = normalizeGroupSelection(groups);
139
+ const deadline = monotonic() + timeoutMs;
140
+ const wait = async (reason) => {
141
+ if (monotonic() >= deadline) fail("TESTFLIGHT_TIMEOUT", `timed out waiting for ${reason}`);
142
+ await sleep(pollIntervalMs);
143
+ };
144
+ const identity = `${marketingVersion} (${buildNumber})`;
145
+
146
+ const candidates = async () => {
147
+ const query = new URLSearchParams({ "filter[app]": text(appId), "filter[version]": text(buildNumber), include: "preReleaseVersion", limit: "200" });
148
+ const payload = await client.request("GET", `/v1/builds?${query}`);
149
+ const releases = new Map((payload.included ?? []).filter((item) => item.type === "preReleaseVersions").map((item) => [item.id, text(item.attributes?.version)]));
150
+ return (payload.data ?? []).filter((build) => text(build.attributes?.version) === text(buildNumber)
151
+ && releases.get(build.relationships?.preReleaseVersion?.data?.id) === text(marketingVersion));
152
+ };
153
+ const waitForBuild = async () => {
154
+ for (;;) {
155
+ const matches = await candidates();
156
+ if (matches.length > 1) fail("TESTFLIGHT_AMBIGUOUS_BUILD", `the exact build identity is ambiguous in App Store Connect: ${matches.map((item) => item.id).join(", ")}`);
157
+ if (!matches.length) { await wait(`build ${identity} to appear`); continue; }
158
+ const state = text(matches[0].attributes?.processingState);
159
+ if (state === "VALID") return matches[0];
160
+ if (FAILED_PROCESSING_STATES.has(state)) fail("TESTFLIGHT_PROCESSING_FAILED", `build ${identity} processing failed: ${state}`);
161
+ await wait(`build ${identity} processing`);
162
+ }
163
+ };
164
+
165
+ const listGroups = async () => {
166
+ const all = await client.getAll(`/v1/apps/${appId}/betaGroups?limit=200`);
167
+ if (!all.length) fail("TESTFLIGHT_NO_GROUPS", `app ${appId} has no TestFlight beta groups`);
168
+ if (selection === "all") return all;
169
+ const missing = selection.filter((name) => !all.some((group) => groupName(group) === name));
170
+ if (missing.length) fail("TESTFLIGHT_GROUP_MISSING", `beta group(s) not found for app ${appId}: ${missing.join(", ")}`);
171
+ return all.filter((group) => selection.includes(groupName(group)));
172
+ };
173
+ const hasBuild = async (groupId, buildId) => (await client.getAll(`/v1/betaGroups/${groupId}/relationships/builds?limit=200`)).some((item) => text(item.id) === buildId);
174
+ const assignedIds = async (buildId, list) => {
175
+ const ids = new Set();
176
+ for (const group of list) if (await hasBuild(text(group.id), buildId)) ids.add(text(group.id));
177
+ return ids;
178
+ };
179
+
180
+ const build = await waitForBuild();
181
+ const buildId = text(build.id);
182
+ log(`TestFlight: build ${identity} is VALID (${buildId})`);
183
+
184
+ // An internal group with access to all builds gets every build by itself, and App Store Connect refuses to assign one
185
+ // (HTTP 422 "Cannot add internal group to a build", live run 2026-10-19), so it is not assigned and counts as holding the build.
186
+ const automatic = (group) => isInternal(group) && group.attributes?.hasAccessToAllBuilds === true;
187
+ for (const group of await listGroups()) {
188
+ if (automatic(group)) log(`TestFlight: internal group ${groupName(group)} receives every build (access to all builds)`);
189
+ }
190
+ let converged = false;
191
+ for (let attempt = 0; attempt < 3 && !converged; attempt += 1) {
192
+ const list = (await listGroups()).filter((group) => !automatic(group));
193
+ const assigned = await assignedIds(buildId, list);
194
+ const missing = list.filter((group) => !assigned.has(text(group.id)));
195
+ if (!missing.length) { converged = true; break; }
196
+ for (const group of missing) {
197
+ try {
198
+ await client.request("POST", `/v1/betaGroups/${group.id}/relationships/builds`, { data: [{ type: "builds", id: buildId }] });
199
+ } catch (error) {
200
+ if (!(await hasBuild(text(group.id), buildId))) fail("TESTFLIGHT_ASSIGN_FAILED", `could not assign build ${identity} to beta group ${groupName(group)}: ${error.message}`);
201
+ }
202
+ }
203
+ }
204
+ const finalGroups = (await listGroups()).filter((group) => !automatic(group));
205
+ const finalAssigned = await assignedIds(buildId, finalGroups);
206
+ const unassigned = finalGroups.filter((group) => !finalAssigned.has(text(group.id)));
207
+ if (unassigned.length) fail("TESTFLIGHT_ASSIGN_FAILED", `build assignment did not converge for beta groups: ${unassigned.map(groupName).join(", ")}`);
208
+
209
+ const reviewSubmissions = async () => client.getAll(`/v1/betaAppReviewSubmissions?${new URLSearchParams({ "filter[build]": buildId, limit: "200" })}`);
210
+ const betaDetail = async () => {
211
+ const payload = await client.request("GET", `/v1/builds/${buildId}/buildBetaDetail`);
212
+ if (!payload.data || typeof payload.data !== "object") fail("TESTFLIGHT_API_INVALID", "App Store Connect returned no build beta detail");
213
+ return payload.data;
214
+ };
215
+ const stateSet = (submissions) => new Set(submissions.map((item) => text(item.attributes?.betaReviewState)));
216
+ const ensureExternalReview = async () => {
217
+ for (;;) {
218
+ let detail = await betaDetail();
219
+ const state = text(detail.attributes?.externalBuildState);
220
+ let submissions = await reviewSubmissions();
221
+ let states = stateSet(submissions);
222
+ if ([...states].some((item) => ACCEPTED_REVIEW_STATES.has(item))) return { detail, submissions };
223
+ if (submissions.length) fail("TESTFLIGHT_REVIEW_STATE", `external beta review is not pending or approved: ${[...states].sort().join(", ") || "UNKNOWN"}`);
224
+ if (EXTERNAL_REVIEW_PENDING_OR_READY.has(state)) return { detail, submissions };
225
+ if (state === "READY_FOR_BETA_SUBMISSION") {
226
+ await client.request("POST", "/v1/betaAppReviewSubmissions", { data: { type: "betaAppReviewSubmissions", relationships: { build: { data: { type: "builds", id: buildId } } } } });
227
+ submissions = await reviewSubmissions();
228
+ if (!submissions.length) fail("TESTFLIGHT_REVIEW_STATE", "the external beta review submission was not visible after creation");
229
+ states = stateSet(submissions);
230
+ if (![...states].some((item) => ACCEPTED_REVIEW_STATES.has(item))) fail("TESTFLIGHT_REVIEW_STATE", `the external beta review submission has an invalid state: ${[...states].sort().join(", ") || "UNKNOWN"}`);
231
+ detail = await betaDetail();
232
+ return { detail, submissions };
233
+ }
234
+ if (state === "PROCESSING" || state === "") { await wait("the external TestFlight state"); continue; }
235
+ return fail("TESTFLIGHT_REVIEW_STATE", `build cannot enter external TestFlight review: ${state}`);
236
+ }
237
+ };
238
+
239
+ let detail = null;
240
+ let submissions = [];
241
+ if (submitExternalBetaReview && finalGroups.some((group) => !isInternal(group))) ({ detail, submissions } = await ensureExternalReview());
242
+
243
+ return {
244
+ appId: text(appId),
245
+ marketingVersion: text(marketingVersion),
246
+ buildNumber: text(buildNumber),
247
+ buildId,
248
+ processingState: "VALID",
249
+ groupSelection: selection,
250
+ groups: finalGroups.map((group) => ({ id: text(group.id), name: groupName(group), internal: isInternal(group), assigned: finalAssigned.has(text(group.id)) })),
251
+ externalBuildState: detail ? text(detail.attributes?.externalBuildState) : null,
252
+ betaReviewSubmissionIds: submissions.map((item) => text(item.id)),
253
+ betaReviewStates: submissions.map((item) => text(item.attributes?.betaReviewState)),
254
+ };
255
+ }
256
+
257
+ /** The API plan printed by a dry run (pure data, no secret). */
258
+ export function testflightPlan({ appId, marketingVersion, buildNumber, groups, submitExternalBetaReview }) {
259
+ const selection = normalizeGroupSelection(groups);
260
+ return [
261
+ { id: "builds.wait", method: "GET", url: `${ASC_API}/v1/builds?filter[app]=${appId}&filter[version]=${buildNumber}&include=preReleaseVersion`, note: `poll until ${marketingVersion} (${buildNumber}) is VALID` },
262
+ { id: "betaGroups.list", method: "GET", url: `${ASC_API}/v1/apps/${appId}/betaGroups`, note: selection === "all" ? "every beta group" : `groups: ${selection.join(", ")}` },
263
+ { id: "betaGroups.assign", method: "POST", url: `${ASC_API}/v1/betaGroups/<groupId>/relationships/builds`, note: "only for groups that do not hold the build yet; re-read until converged" },
264
+ ...(submitExternalBetaReview ? [{ id: "betaAppReviewSubmissions.create", method: "POST", url: `${ASC_API}/v1/betaAppReviewSubmissions`, note: "only when an external group is selected and the build is READY_FOR_BETA_SUBMISSION" }] : []),
265
+ ];
266
+ }
package/ios-upload.mjs ADDED
@@ -0,0 +1,313 @@
1
+ // right-release iOS upload (bogusyogi/rightkit#349): a sealed, signed IPA to TestFlight, then the exact build into beta groups.
2
+ // Build-free: it reads only the sealed release that `right-release build --platform ios` wrote. Dry run is the default (as
3
+ // for Android): everything local is verified and the exact plan is printed; no Keychain item is read and nothing leaves the
4
+ // machine. `--apply` uploads.
5
+ //
6
+ // Config (release config, top level):
7
+ // distribution: {
8
+ // provider: "app-store-connect",
9
+ // appId: "6806854037", // optional: the App Store Connect app id; when absent it is looked up from bundleId at --apply
10
+ // bundleId: "app.heardright.ios.native",
11
+ // track: "testflight", // the only track; there is no App Store submission
12
+ // groups: "all" | ["Internal Dogfood"], // REQUIRED: no implicit policy
13
+ // submitExternalBetaReview: false, // default false; true creates the TestFlight beta app review submission for external groups
14
+ // waitTimeoutSeconds: 1800, pollIntervalSeconds: 20,
15
+ // credentials: { keyService?, keyIdService?, issuerService?, account? }, // Keychain service NAMES only
16
+ // }
17
+ //
18
+ // Credentials (settled decision D4: no credential sits in a file): the .p8, key id and issuer id come from the macOS login
19
+ // Keychain (ios-asc-credentials.mjs) and live only in memory. A key path, key id, issuer or key in the config or the environment is
20
+ // refused. The IPA is uploaded in process through the App Store Connect build-upload resources (ios-build-upload.mjs), the
21
+ // TestFlight steps use the same in-memory ES256 JWT. No file, temp or not, ever holds the key; no altool, no Transporter.
22
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
23
+ import path from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+ import { spawnSync } from "./background-process.mjs";
26
+ import { assertGuiSigningSession, assertIosHost, IosHostError } from "./ios-host.mjs";
27
+ import { acquireFileLock } from "./ios-lock.mjs";
28
+ import {
29
+ IosCredentialsError, readAscCredentials, refuseIosAscEnv, resolveAscCredentialSource,
30
+ } from "./ios-asc-credentials.mjs";
31
+ import { buildUploadPlan, fileSource, uploadIpaToAppStoreConnect } from "./ios-build-upload.mjs";
32
+ import { IOS_SIGNING_CONTRACT } from "./ios-package-recipe.mjs";
33
+ import { ASC_API, createAscClient, createTokenProvider, distributeToTestFlight, normalizeGroupSelection, TestFlightError, testflightPlan } from "./ios-testflight.mjs";
34
+ import { iosOwnershipScope } from "./ownership-scope.mjs";
35
+ import { assertArtifactUnchanged, digestBytes, verifyPublicationRelease } from "./publication-artifacts.mjs";
36
+ import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
37
+ import { assertCleanSource } from "./source-gate.mjs";
38
+ import { targetDistribution } from "./target-distribution.mjs";
39
+
40
+ export const IOS_PROVIDER = "app-store-connect";
41
+ export const IOS_TRACK = "testflight";
42
+ export const IOS_UPLOADED_MARKER = "verified-ios-upload.json";
43
+ export const IOS_DISTRIBUTED_MARKER = "verified-ios-testflight.json";
44
+ export const IOS_EVIDENCE_NAME = "ios-testflight.json";
45
+
46
+ const RELEASE_ID = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/;
47
+ const TEST_SEGMENT = /^(?:dev|qa|test|debug)$/i;
48
+ const DISTRIBUTION_KEYS = ["provider", "appId", "bundleId", "track", "groups", "submitExternalBetaReview", "waitTimeoutSeconds", "pollIntervalSeconds", "credentials"];
49
+ const CREDENTIAL_LIKE = /key|issuer|p8|pem|secret|token|password|file|path|env/i;
50
+
51
+ export class IosUploadError extends Error {
52
+ constructor(code, message) { super(`[${code}] ${message}`); this.name = "IosUploadError"; this.code = code; }
53
+ }
54
+ const refuse = (code, message) => { throw new IosUploadError(code, message); };
55
+
56
+ // ---- config ------------------------------------------------------------------------------------------------------
57
+ export function validateIosDistribution(distribution) {
58
+ if (!distribution || typeof distribution !== "object" || Array.isArray(distribution)) refuse("IOS_UPLOAD_CONFIG_INVALID", "release config distribution is required");
59
+ if (distribution.provider !== IOS_PROVIDER) refuse("IOS_UPLOAD_CONFIG_INVALID", `release config distribution.provider must be ${IOS_PROVIDER} for an iOS upload`);
60
+ for (const key of Object.keys(distribution)) {
61
+ if (DISTRIBUTION_KEYS.includes(key)) continue;
62
+ if (CREDENTIAL_LIKE.test(key)) refuse("IOS_ASC_CREDENTIAL_SOURCE_REFUSED", `distribution.${key} is refused: App Store Connect credentials are read only from the macOS login Keychain (distribution.credentials names the Keychain services), never from the config, a file or the environment`);
63
+ refuse("IOS_UPLOAD_CONFIG_INVALID", `distribution has unsupported key ${key} (allowed: ${DISTRIBUTION_KEYS.join(", ")})`);
64
+ }
65
+ // appId is optional: absent, --apply resolves it from bundleId through the App Store Connect API (resolveAscAppId).
66
+ const appId = distribution.appId === undefined ? null : String(distribution.appId);
67
+ if (appId !== null && !/^\d{5,20}$/.test(appId)) refuse("IOS_UPLOAD_CONFIG_INVALID", "distribution.appId must be the numeric App Store Connect app id (or omit it to resolve it from bundleId)");
68
+ const bundleId = String(distribution.bundleId ?? "");
69
+ if (!/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(bundleId)) refuse("IOS_UPLOAD_CONFIG_INVALID", "distribution.bundleId must be the reverse-DNS app bundle id");
70
+ if (TEST_SEGMENT.test(bundleId.split(".").at(-1))) refuse("IOS_UPLOAD_TEST_BUNDLE", `distribution.bundleId ${bundleId} is a dev/qa/test/debug id; those never go to TestFlight`);
71
+ const track = distribution.track ?? IOS_TRACK;
72
+ if (track !== IOS_TRACK) refuse("IOS_UPLOAD_TRACK_REFUSED", `distribution.track must be ${IOS_TRACK}; this command never submits to the App Store`);
73
+ let groups;
74
+ if (distribution.groups === undefined) refuse("IOS_UPLOAD_CONFIG_INVALID", 'distribution.groups is required: "all" or a non-empty list of beta group names (there is no implicit default)');
75
+ try { groups = normalizeGroupSelection(distribution.groups); } catch (error) { refuse("IOS_UPLOAD_CONFIG_INVALID", `distribution.${error.message}`); }
76
+ if (distribution.submitExternalBetaReview !== undefined && typeof distribution.submitExternalBetaReview !== "boolean") refuse("IOS_UPLOAD_CONFIG_INVALID", "distribution.submitExternalBetaReview must be a boolean");
77
+ const int = (value, label, min, max, fallback) => {
78
+ if (value === undefined) return fallback;
79
+ if (!Number.isInteger(value) || value < min || value > max) refuse("IOS_UPLOAD_CONFIG_INVALID", `distribution.${label} must be an integer ${min}..${max}`);
80
+ return value;
81
+ };
82
+ let credentials;
83
+ try { credentials = resolveAscCredentialSource(distribution.credentials, { label: "distribution.credentials" }); } catch (error) {
84
+ if (error instanceof IosCredentialsError) refuse(error.code, error.message);
85
+ throw error;
86
+ }
87
+ return {
88
+ appId, bundleId, track, groups,
89
+ submitExternalBetaReview: distribution.submitExternalBetaReview ?? false,
90
+ waitTimeoutSeconds: int(distribution.waitTimeoutSeconds, "waitTimeoutSeconds", 60, 7200, 1800),
91
+ pollIntervalSeconds: int(distribution.pollIntervalSeconds, "pollIntervalSeconds", 5, 300, 20),
92
+ credentials: distribution.credentials,
93
+ credentialServices: credentials,
94
+ };
95
+ }
96
+
97
+ const APP_ID_PENDING = "{appId from bundleId}";
98
+
99
+ /**
100
+ * The App Store Connect app id for a bundle id: exactly one app whose bundleId equals it (the filter is not trusted to be exact).
101
+ * Read with the same in-memory JWT as the upload, so the config needs only the bundle id.
102
+ */
103
+ export async function resolveAscAppId({ client, bundleId }) {
104
+ const apps = await client.getAll(`/v1/apps?${new URLSearchParams({ "filter[bundleId]": bundleId, limit: "200" })}`);
105
+ const exact = apps.filter((app) => String(app?.attributes?.bundleId ?? "") === bundleId);
106
+ if (exact.length !== 1) refuse("IOS_ASC_APP_NOT_FOUND", exact.length ? `${exact.length} App Store Connect apps have bundle id ${bundleId}; set distribution.appId` : `no App Store Connect app has bundle id ${bundleId}; create the app record in App Store Connect first`);
107
+ const id = String(exact[0].id ?? "");
108
+ if (!/^\d{5,20}$/.test(id)) refuse("IOS_ASC_APP_NOT_FOUND", `App Store Connect returned a non-numeric app id for ${bundleId}`);
109
+ return id;
110
+ }
111
+
112
+ /** The plan printed by a dry run: pure data, no secret, no Keychain read. */
113
+ export function iosUploadPlan({ distribution, ipa, receipt }) {
114
+ const appId = distribution.appId ?? APP_ID_PENDING;
115
+ return {
116
+ appId,
117
+ ...(distribution.appId ? {} : { appIdSource: `resolved at --apply: GET ${ASC_API}/v1/apps?filter[bundleId]=${distribution.bundleId} (exactly one exact match)` }),
118
+ bundleId: distribution.bundleId,
119
+ track: distribution.track,
120
+ ipa: { name: ipa.name, sha256: ipa.sha256, sizeBytes: ipa.sizeBytes },
121
+ marketingVersion: receipt.marketingVersion,
122
+ buildNumber: receipt.buildNumber,
123
+ credentials: { source: "macOS login Keychain", services: [distribution.credentialServices.keyService, distribution.credentialServices.keyIdService, distribution.credentialServices.issuerService], account: distribution.credentialServices.account },
124
+ steps: [
125
+ ...buildUploadPlan({ appId, marketingVersion: receipt.marketingVersion, buildNumber: receipt.buildNumber, ipa }),
126
+ ...testflightPlan({ appId, marketingVersion: receipt.marketingVersion, buildNumber: receipt.buildNumber, groups: distribution.groups, submitExternalBetaReview: distribution.submitExternalBetaReview }),
127
+ ],
128
+ };
129
+ }
130
+
131
+ // ---- authorisation -----------------------------------------------------------------------------------------------
132
+ const git = (cwd, args) => {
133
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
134
+ if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr}`);
135
+ return result.stdout.trim();
136
+ };
137
+
138
+ /** Local checks only; no network, no secret. The sealed release is the only input: build-free by construction. */
139
+ export async function authorizeIosUpload({ releaseId, configName = "right-release.config.mjs", cwd = process.cwd() }) {
140
+ if (!RELEASE_ID.test(releaseId ?? "")) refuse("IOS_UPLOAD_RELEASE_REQUIRED", "--release <sealed-id> is required");
141
+ const repoRoot = git(cwd, ["rev-parse", "--show-toplevel"]);
142
+ assertPrimaryReleaseCheckout(repoRoot);
143
+ const commit = git(repoRoot, ["rev-parse", "HEAD"]);
144
+ assertCleanSource({ status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]), commandId: "right-release upload" });
145
+ const configPath = path.resolve(cwd, configName);
146
+ if (!existsSync(configPath)) refuse("IOS_UPLOAD_CONFIG_INVALID", `release config is required: ${configName}`);
147
+ // The config is a module that runs on import: loaded only after the tree is proven clean.
148
+ const config = (await import(`${pathToFileURL(configPath).href}?upload=${Date.now()}`)).default;
149
+ const distribution = validateIosDistribution(targetDistribution(config, "ios"));
150
+ const sealedDir = path.join(repoRoot, ".right-release", "sealed", releaseId, "ios");
151
+ const appRoot = path.dirname(configPath);
152
+ // The IPA is verified by a streamed hash and never held in memory (streamRoles); the parts are read as byte ranges at upload time.
153
+ const sealed = verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit, app: config.app, version: config.version, platform: "ios", configPath, releaseId, streamRoles: ["ipa"], ownershipScope: iosOwnershipScope({ repoRoot, appRoot, target: config.targets?.ios, nativeAssembly: config.nativeAssembly }) });
154
+ const entries = sealed.manifest.files.filter((entry) => entry.role === "ipa");
155
+ if (entries.length !== 1) refuse("IOS_IPA_MISSING", "the sealed release must hold exactly one ipa entry");
156
+ const ipa = sealed.artifacts.get(entries[0].name);
157
+ // The build receipt rides in the seal; it names the version, build number, team and bundle id of exactly this IPA.
158
+ const receiptEntry = (sealed.manifest.signing?.receipts ?? []).map((item) => ({ item, value: tryParse(path.resolve(appRoot, item.file), item.sha256) })).find((candidate) => candidate.value?.contract === IOS_SIGNING_CONTRACT);
159
+ if (!receiptEntry) refuse("IOS_RECEIPT_MISSING", "the sealed release has no iOS build receipt");
160
+ const receipt = receiptEntry.value;
161
+ if (receipt.file?.sha256 !== entries[0].sha256) refuse("IOS_RECEIPT_MISMATCH", "the iOS build receipt describes a different IPA than the sealed ipa entry");
162
+ if (receipt.bundleId !== distribution.bundleId) refuse("IOS_RECEIPT_MISMATCH", `distribution.bundleId ${distribution.bundleId} is not the IPA's bundle id ${receipt.bundleId}`);
163
+ if (receipt.teamId !== sealed.manifest.signing?.teamId) refuse("IOS_RECEIPT_MISMATCH", "the iOS build receipt team id is not the team pinned in the sealed signing policy");
164
+ return { repoRoot, commit, sealed, sealedDir, configPath, config, distribution, ipa, entry: entries[0], receipt };
165
+ }
166
+
167
+ function tryParse(file, expectedSha256) {
168
+ try {
169
+ const bytes = readFileSync(file);
170
+ if (digestBytes(bytes) !== expectedSha256) return undefined;
171
+ return JSON.parse(bytes.toString("utf8"));
172
+ } catch { return undefined; }
173
+ }
174
+
175
+ // ---- the command -------------------------------------------------------------------------------------------------
176
+ const writeJsonAtomic = (file, value) => {
177
+ mkdirSync(path.dirname(file), { recursive: true });
178
+ const temporary = `${file}.tmp-${process.pid}`;
179
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
180
+ renameSync(temporary, file);
181
+ };
182
+
183
+ /**
184
+ * The journal of build uploads this vault created: {buildUploadId, buildUploadFileId, appId, version, build, ipaSha256}. It is written
185
+ * (atomically) right after each create POST, and an App Store Connect record is adopted only if it is listed here for the same IPA.
186
+ * A missing file is an empty journal; an unreadable or malformed one is an error, never an empty journal.
187
+ */
188
+ export function buildUploadJournal(dir, appId) {
189
+ const file = path.join(dir, `ios-build-uploads.${appId}.json`);
190
+ const read = () => {
191
+ let raw;
192
+ try { raw = readFileSync(file, "utf8"); } catch (error) {
193
+ if (error?.code === "ENOENT") return [];
194
+ refuse("IOS_UPLOAD_JOURNAL_INVALID", `the build-upload journal ${file} is unreadable (${error?.code ?? "error"}); fix or remove it only after checking App Store Connect`);
195
+ }
196
+ let parsed;
197
+ try { parsed = JSON.parse(raw); } catch { parsed = undefined; }
198
+ if (!parsed || !Array.isArray(parsed.entries) || parsed.entries.some((entry) => !entry || typeof entry.buildUploadId !== "string" || !/^[a-f0-9]{64}$/.test(String(entry.ipaSha256)))) refuse("IOS_UPLOAD_JOURNAL_INVALID", `the build-upload journal ${file} is malformed; fix or remove it only after checking App Store Connect`);
199
+ return parsed.entries;
200
+ };
201
+ const record = (entry) => {
202
+ const entries = read().filter((item) => item.buildUploadId !== entry.buildUploadId);
203
+ writeJsonAtomic(file, { schema: 1, entries: [...entries, entry] });
204
+ };
205
+ return { file, read, record };
206
+ }
207
+
208
+ /** A marker may only be trusted for the very IPA, app and build number it was written for. */
209
+ function readMarker(file, expected, label) {
210
+ let marker;
211
+ try { marker = JSON.parse(readFileSync(file, "utf8")); } catch { refuse("IOS_UPLOAD_MARKER_INVALID", `${label} marker ${file} is unreadable; inspect App Store Connect and remove it only if the build was not uploaded`); }
212
+ for (const [key, value] of Object.entries(expected)) {
213
+ if (marker?.[key] !== value) refuse("IOS_UPLOAD_MARKER_MISMATCH", `${label} marker ${file} was written for a different ${key} (${String(marker?.[key])}, now ${String(value)}); refusing to skip the upload on it`);
214
+ }
215
+ return marker;
216
+ }
217
+
218
+ export async function runIosUpload({
219
+ releaseId, configName, cwd = process.cwd(), apply = false, env = process.env, platform = process.platform, log = console.log,
220
+ fetchImpl = globalThis.fetch, authorize = authorizeIosUpload, readCredentials = readAscCredentials, run = spawnSync, detectSession,
221
+ acquireLock = acquireFileLock, journalDir, sleep, monotonic, now = () => new Date(),
222
+ }) {
223
+ refuseIosAscEnv(env);
224
+ const auth = await authorize({ releaseId, configName, cwd });
225
+ const ipaEvidence = { name: auth.entry.name, sha256: auth.entry.sha256, sizeBytes: auth.entry.sizeBytes };
226
+ const plan = iosUploadPlan({ distribution: auth.distribution, ipa: ipaEvidence, receipt: auth.receipt });
227
+ const stateRoot = path.join(auth.repoRoot, ".right-release", "state", releaseId, "ios");
228
+ const uploadedMarker = path.join(stateRoot, IOS_UPLOADED_MARKER);
229
+ const distributedMarker = path.join(stateRoot, IOS_DISTRIBUTED_MARKER);
230
+ if (!apply) {
231
+ log(`right-release upload: dry-run (default; pass --apply to upload) ${releaseId} -> TestFlight of app ${plan.appId} (${plan.bundleId})`);
232
+ log(JSON.stringify(plan, null, 2));
233
+ return { applied: false, plan };
234
+ }
235
+ try { assertIosHost({ operation: "right-release upload --apply", platform }); } catch (error) {
236
+ if (error instanceof IosHostError) refuse(error.code, error.message);
237
+ throw error;
238
+ }
239
+ // One apply per release at a time (the same pid lock the build lane uses).
240
+ const lock = acquireLock(path.join(auth.repoRoot, ".right-release", "locks", `ios-upload-${releaseId}.lock.json`), `right-release upload --platform ios ${releaseId}`);
241
+ try {
242
+ // The Keychain is read only in the GUI (Aqua) session; an SSH session is refused before any read. The key is read once, lazily.
243
+ let ascClient;
244
+ const client = () => {
245
+ if (ascClient) return ascClient;
246
+ try { assertGuiSigningSession({ operation: "right-release upload --apply (login Keychain)", env, run, detect: detectSession }); } catch (error) {
247
+ if (error instanceof IosHostError) refuse(error.code, error.message);
248
+ throw error;
249
+ }
250
+ const { keyId, issuer, privateKey } = readCredentials({ credentials: auth.distribution.credentials, env, platform });
251
+ ascClient = createAscClient({ tokenProvider: createTokenProvider({ privateKey, keyId, issuer }), fetchImpl, ...(sleep ? { sleep } : {}) });
252
+ return ascClient;
253
+ };
254
+ if (!auth.distribution.appId) {
255
+ plan.appId = await resolveAscAppId({ client: client(), bundleId: plan.bundleId });
256
+ log(`right-release upload: App Store Connect app ${plan.appId} resolved from bundle id ${plan.bundleId}`);
257
+ }
258
+ const identity = { appId: plan.appId, bundleId: plan.bundleId, marketingVersion: plan.marketingVersion, buildNumber: plan.buildNumber, ipaSha256: ipaEvidence.sha256 };
259
+ if (existsSync(distributedMarker)) {
260
+ readMarker(distributedMarker, identity, "distribution");
261
+ log(`right-release upload: already distributed ${releaseId} to TestFlight`);
262
+ return { applied: false, alreadyDistributed: true, plan };
263
+ }
264
+
265
+ // 1. The sealed bytes are uploaded once, in process (or an existing upload is resumed); a rerun after a later failure skips straight
266
+ // to the TestFlight steps, but only on a marker written for this very IPA, app and build number.
267
+ const waits = { ...(sleep ? { sleep } : {}), ...(monotonic ? { monotonic } : {}) };
268
+ if (!existsSync(uploadedMarker)) {
269
+ const source = fileSource(auth.ipa.file, auth.entry); // streamed hash; refuses bytes that changed since the seal
270
+ log(`right-release upload: uploading ${ipaEvidence.name} through the App Store Connect API (in process, no key file)`);
271
+ const uploaded = await uploadIpaToAppStoreConnect({
272
+ client: client(), appId: plan.appId, marketingVersion: plan.marketingVersion, buildNumber: plan.buildNumber, source, fileName: ipaEvidence.name, fetchImpl,
273
+ // Xcode's export registers an empty build upload during the build; only one created between the sealed export start (less 30 s of
274
+ // clock skew) and the receipt may be adopted. A receipt without exportStartedAt (sealed before it existed) adopts nothing.
275
+ exportWindow: auth.receipt?.exportStartedAt && auth.receipt?.builtAt ? { notBefore: new Date(Date.parse(auth.receipt.exportStartedAt) - 30_000).toISOString(), notAfter: auth.receipt.builtAt } : undefined,
276
+ journal: buildUploadJournal(journalDir ?? (env.RIGHT_RELEASE_VAULT_STATE_ROOT ? path.join(path.resolve(env.RIGHT_RELEASE_VAULT_STATE_ROOT), "ios-build-uploads") : path.join(auth.repoRoot, ".right-release", "state", "ios-build-uploads")), plan.appId),
277
+ pollIntervalMs: auth.distribution.pollIntervalSeconds * 1000, timeoutMs: auth.distribution.waitTimeoutSeconds * 1000, ...waits,
278
+ log: (line) => log(`right-release upload: ${line}`),
279
+ });
280
+ writeJsonAtomic(uploadedMarker, { schema: 1, releaseId, platform: "ios", provider: IOS_PROVIDER, ...identity, buildUploadId: uploaded.buildUploadId, uploadedAt: now().toISOString() });
281
+ log(`right-release upload: uploaded ${releaseId} ${plan.marketingVersion} (${plan.buildNumber})`);
282
+ } else {
283
+ readMarker(uploadedMarker, identity, "upload");
284
+ log(`right-release upload: ${releaseId} was already uploaded; continuing with TestFlight`);
285
+ }
286
+
287
+ // 2. TestFlight: the exact build into the beta groups.
288
+ const distributed = await distributeToTestFlight({
289
+ client: client(), appId: plan.appId, marketingVersion: plan.marketingVersion, buildNumber: plan.buildNumber, groups: auth.distribution.groups,
290
+ submitExternalBetaReview: auth.distribution.submitExternalBetaReview,
291
+ pollIntervalMs: auth.distribution.pollIntervalSeconds * 1000, timeoutMs: auth.distribution.waitTimeoutSeconds * 1000,
292
+ ...waits,
293
+ log: (line) => log(`right-release upload: ${line}`),
294
+ });
295
+ const evidence = { schema: 1, releaseId, platform: "ios", provider: IOS_PROVIDER, api: ASC_API, ipa: ipaEvidence, completedAt: now().toISOString(), testflight: distributed };
296
+ writeJsonAtomic(path.join(auth.repoRoot, ".right-release", "receipts", IOS_EVIDENCE_NAME), evidence);
297
+ writeJsonAtomic(distributedMarker, { schema: 1, releaseId, platform: "ios", ...identity, buildId: distributed.buildId, completedAt: evidence.completedAt });
298
+ log(`right-release upload: ${releaseId} is in TestFlight for ${distributed.groups.length} beta group(s)`);
299
+ return { applied: true, plan, evidence };
300
+ } finally { lock.release(); }
301
+ }
302
+
303
+ /** Entry used by upload-release.mjs for --platform ios. Exit code 0 on success, 1 on any refusal. */
304
+ export async function main({ releaseId, configName, apply, dryRun }) {
305
+ if (apply && dryRun) { console.error("right-release upload: --apply and --dry-run are mutually exclusive"); return 2; }
306
+ try {
307
+ await runIosUpload({ releaseId, configName, apply: apply === true });
308
+ return 0;
309
+ } catch (error) {
310
+ console.error(`right-release upload: ${error instanceof TestFlightError ? `[${error.code}] ` : ""}${error.message}`);
311
+ return 1;
312
+ }
313
+ }