@rightkit/release 0.2.165 → 0.2.172

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 (77) hide show
  1. package/README.md +121 -4
  2. package/addon-command.mjs +6 -13
  3. package/android-aab-scan.mjs +265 -0
  4. package/android-dev.mjs +111 -0
  5. package/android-ndk.mjs +77 -0
  6. package/android-play-upload.mjs +262 -0
  7. package/android-release.mjs +344 -0
  8. package/android-test-fixtures.mjs +79 -0
  9. package/apple-profile-create.mjs +363 -0
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/darwin/rightkit-swap.sign.json +6 -0
  12. package/build-release.mjs +73 -13
  13. package/cargo-guard.mjs +2 -0
  14. package/cargo-profile-contract.mjs +183 -0
  15. package/cargo-target.mjs +8 -0
  16. package/cli/right-release.mjs +14 -4
  17. package/committed-source.mjs +1 -1
  18. package/dev-artifact-refresh.mjs +176 -0
  19. package/dev-artifact-support.mjs +61 -0
  20. package/development-signing.mjs +71 -4
  21. package/development.mjs +78 -46
  22. package/direct-bootstrap.mjs +26 -41
  23. package/disk-command.mjs +298 -5
  24. package/disk-policy.json +7 -5
  25. package/disk-policy.mjs +105 -16
  26. package/disk-stable-root.mjs +19 -16
  27. package/engine-locator.mjs +116 -0
  28. package/external-bin.mjs +133 -0
  29. package/github-release.mjs +2 -2
  30. package/github-updater-release.mjs +154 -0
  31. package/hardeningscan.mjs +1 -1
  32. package/in-place-install.mjs +56 -0
  33. package/install-app-windows.mjs +63 -0
  34. package/install-app.mjs +53 -23
  35. package/local-publish.mjs +11 -1
  36. package/mac-signing-matrix.mjs +23 -11
  37. package/managed-cargo.mjs +6 -0
  38. package/media-sidecar-catalog.mjs +45 -0
  39. package/media-sidecar.mjs +306 -0
  40. package/ownership-approval-command.mjs +10 -44
  41. package/ownership-approval.mjs +3 -42
  42. package/ownership-catalog-config.json +2 -1
  43. package/ownership-catalog.mjs +4 -2
  44. package/ownership-command.mjs +7 -9
  45. package/ownership-dependency-allowlist.json +25 -1
  46. package/ownership-evidence.mjs +8 -5
  47. package/ownership-git-inventory.mjs +41 -7
  48. package/ownership-lockfiles.mjs +91 -8
  49. package/ownership-mandatory-set.json +36 -16
  50. package/ownership-report.mjs +5 -6
  51. package/ownership-scan-worker.mjs +2 -2
  52. package/ownership-verdict.mjs +10 -7
  53. package/ownership.mjs +47 -29
  54. package/package.json +7 -4
  55. package/portable-development.mjs +32 -115
  56. package/preflight.mjs +4 -6
  57. package/publication-artifacts.mjs +34 -0
  58. package/publish-update.mjs +72 -11
  59. package/qa-adoption-exemptions.json +5 -0
  60. package/qa-build-features.mjs +7 -0
  61. package/qa-contract.mjs +182 -0
  62. package/qa-control-scan.mjs +3 -3
  63. package/release-qa-command.mjs +38 -12
  64. package/release-qa-installed.mjs +136 -0
  65. package/release-qa-receipt.mjs +23 -2
  66. package/release.mjs +281 -13
  67. package/rightkit-ownership.json +537 -5
  68. package/rightkit-sync.mjs +26 -0
  69. package/rightkit-versions.json +109 -62
  70. package/runtime-budget-classes.json +28 -0
  71. package/runtime-budgets.mjs +4 -1
  72. package/sign-candidate.mjs +161 -0
  73. package/sign-dev-artifact.mjs +198 -0
  74. package/stage-binary.mjs +60 -0
  75. package/swap-helper-bundle.mjs +105 -0
  76. package/templates/cargo-profiles.toml +28 -0
  77. package/upload-release.mjs +11 -1
@@ -0,0 +1,363 @@
1
+ import { createHash, generateKeyPairSync, randomUUID, sign, X509Certificate } from "node:crypto";
2
+ import { chmodSync, copyFileSync, constants, existsSync, mkdirSync, mkdtempSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { spawnSync } from "./background-process.mjs";
7
+ import { appleProfileAuth, decodeAppleProfile, signAppleApiJwt } from "./apple-profile-renew.mjs";
8
+
9
+ // right-release apple-profile-create: issue a Mac Development provisioning profile
10
+ // (MAC_APP_DEVELOPMENT) for one explicit bundle id with the ICLOUD/CloudKit capability,
11
+ // an Apple Development signing identity and every enabled Mac device of the team.
12
+ // Credentials follow apple-profile-renew: key id/issuer from the release config or
13
+ // APPLE_API_KEY/APPLE_API_ISSUER, the .p8 from a file path, never inline PEM.
14
+
15
+ const ASC = "https://api.appstoreconnect.apple.com";
16
+ const PROFILE_TYPE = "MAC_APP_DEVELOPMENT";
17
+ export class AppleProfileCreateError extends Error {
18
+ constructor(code, message) { super(message); this.name = "AppleProfileCreateError"; this.code = code; }
19
+ }
20
+ const fail = (code, message) => { throw new AppleProfileCreateError(code, message); };
21
+ const sha1 = (bytes) => createHash("sha1").update(bytes).digest("hex").toUpperCase();
22
+ const serial = (value) => String(value ?? "").replace(/[^a-f0-9]/gi, "").replace(/^0+/, "").toUpperCase();
23
+ const query = (endpoint, params) => `${endpoint}?${new URLSearchParams(params)}`;
24
+ const enc = encodeURIComponent;
25
+ const PEM_MARKER = "-----BEGIN";
26
+
27
+ export function containerManualSteps(bundleId, containers) {
28
+ return [
29
+ "The App Store Connect API has no endpoint to create or assign iCloud containers, so do this once at https://developer.apple.com/account/resources:",
30
+ ...containers.map((container) => ` 1. Identifiers > filter "iCloud Containers" > register ${container} if it is not listed.`),
31
+ ` 2. Identifiers > filter "App IDs" > ${bundleId} > Capabilities > iCloud > tick "Include CloudKit support" > Edit/Configure > select ${containers.join(", ")} > Save.`,
32
+ " 3. Re-run the same right-release apple-profile-create command with --apply.",
33
+ ].join("\n");
34
+ }
35
+
36
+ // ---- PKCS#10 CSR built with node:crypto only (no openssl binary) -------------------
37
+
38
+ const derLength = (n) => n < 0x80 ? Buffer.from([n]) : n < 0x100 ? Buffer.from([0x81, n]) : Buffer.from([0x82, n >> 8, n & 0xff]);
39
+ const tlv = (tag, ...parts) => { const body = Buffer.concat(parts); return Buffer.concat([Buffer.from([tag]), derLength(body.length), body]); };
40
+
41
+ export function buildCsr(commonName, { privateKey, publicKey }) {
42
+ const subject = tlv(0x30, tlv(0x31, tlv(0x30, tlv(0x06, Buffer.from("550403", "hex")), tlv(0x0c, Buffer.from(commonName, "utf8")))));
43
+ const info = tlv(0x30, tlv(0x02, Buffer.from([0])), subject, publicKey.export({ type: "spki", format: "der" }), Buffer.from([0xa0, 0x00]));
44
+ const algorithm = tlv(0x30, tlv(0x06, Buffer.from("2a864886f70d01010b", "hex")), Buffer.from([0x05, 0x00]));
45
+ const signature = sign("sha256", info, privateKey);
46
+ const der = tlv(0x30, info, algorithm, tlv(0x03, Buffer.from([0]), signature));
47
+ const pem = `-----BEGIN CERTIFICATE REQUEST-----\n${der.toString("base64").replace(/(.{64})/g, "$1\n").replace(/\n$/, "")}\n-----END CERTIFICATE REQUEST-----\n`;
48
+ return { der, info, signature, pem };
49
+ }
50
+
51
+ // ---- arguments ---------------------------------------------------------------------
52
+
53
+ const CONTAINER_PATTERN = /^iCloud\.[A-Za-z0-9][A-Za-z0-9.-]*$/;
54
+ const BUNDLE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9.-]*$/;
55
+ const UDID_PATTERN = /^[A-Fa-f0-9-]{8,64}$/;
56
+
57
+ export function parseCreateArgs(args) {
58
+ const out = { configPath: undefined, bundleId: "", name: "", containers: [], type: PROFILE_TYPE, certificate: "create", out: "", dryRun: true, apply: false, registerDevices: [], keyOut: "" };
59
+ const values = { "--config": "configPath", "--bundle-id": "bundleId", "--name": "name", "--type": "type", "--certificate": "certificate", "--out": "out", "--key-out": "keyOut" };
60
+ for (let i = 0; i < args.length; i++) {
61
+ const arg = args[i];
62
+ if (arg === "--dry-run") out.dryRun = true;
63
+ else if (arg === "--apply") out.apply = true;
64
+ else if (Object.hasOwn(values, arg) || arg ==="--icloud-container" || arg === "--register-device") {
65
+ const value = args[++i];
66
+ if (!value || value.startsWith("--")) fail("APPLE_PROFILE_USAGE", `Missing value for ${arg}`);
67
+ if (arg === "--icloud-container") out.containers.push(value);
68
+ else if (arg === "--register-device") out.registerDevices.push(value);
69
+ else out[values[arg]] = value;
70
+ } else fail("APPLE_PROFILE_USAGE", `Unknown option: ${arg}`);
71
+ }
72
+ if (out.apply && args.includes("--dry-run")) fail("APPLE_PROFILE_USAGE", "--dry-run and --apply are mutually exclusive.");
73
+ out.dryRun = !out.apply;
74
+ if (out.type !== PROFILE_TYPE) fail("APPLE_PROFILE_USAGE", `--type must be ${PROFILE_TYPE} (the only supported value).`);
75
+ if (!BUNDLE_PATTERN.test(out.bundleId)) fail("APPLE_PROFILE_USAGE", "--bundle-id must be an explicit bundle identifier (no wildcard).");
76
+ if (!out.name.trim()) fail("APPLE_PROFILE_USAGE", "--name is required.");
77
+ if (!out.containers.length) fail("APPLE_PROFILE_USAGE", "At least one --icloud-container is required.");
78
+ for (const container of out.containers) if (!CONTAINER_PATTERN.test(container)) fail("APPLE_PROFILE_USAGE", `Invalid iCloud container identifier: ${container}`);
79
+ for (const udid of out.registerDevices) if (!UDID_PATTERN.test(udid)) fail("APPLE_PROFILE_USAGE", `Invalid device UDID: ${udid}`);
80
+ if (!out.out) fail("APPLE_PROFILE_USAGE", "--out <profile path> is required.");
81
+ if (out.certificate !== "create" && !/^[A-Za-z0-9]{4,40}$/.test(out.certificate)) fail("APPLE_PROFILE_USAGE", '--certificate must be an App Store Connect certificate id or "create".');
82
+ return out;
83
+ }
84
+
85
+ // Key material must never ride in config, the environment or arguments. Unlike the
86
+ // renew command this refusal applies on every host.
87
+ export function assertNoInlinePem(env, config, extra = []) {
88
+ const asc = config.appStoreConnect ?? {};
89
+ const named = [["APPLE_API_KEY_PATH", env.APPLE_API_KEY_PATH], ["APPLE_API_KEY", env.APPLE_API_KEY], ["APPLE_API_ISSUER", env.APPLE_API_ISSUER],
90
+ ["appStoreConnect.keyPath", asc.keyPath], ["appStoreConnect.keyId", asc.keyId], ["appStoreConnect.issuer", asc.issuer], ...extra];
91
+ for (const [label, value] of named) {
92
+ if (String(value ?? "").includes(PEM_MARKER)) fail("APPLE_PROFILE_INLINE_PEM_REFUSED", `${label} carries inline key material; supply the .p8 as a file path (appStoreConnect.keyPath or APPLE_API_KEY_PATH).`);
93
+ }
94
+ }
95
+
96
+ // ---- local identities --------------------------------------------------------------
97
+
98
+ function localDevelopmentIdentities(run, now, { tolerant }) {
99
+ const call = (command, args) => {
100
+ const result = run(command, args, { encoding: "utf8", stdio: "pipe", shell: false, windowsHide: true });
101
+ if (result.error || result.status !== 0) { if (tolerant) return null; fail("APPLE_PROFILE_LOCAL_INSPECTION_FAILED", `${command} failed while inspecting local signing identities.`); }
102
+ return result.stdout;
103
+ };
104
+ const identities = call("security", ["find-identity", "-v", "-p", "codesigning"]);
105
+ const pem = identities === null ? null : call("security", ["find-certificate", "-a", "-p"]);
106
+ if (identities === null || pem === null) return [];
107
+ const valid = new Set([...identities.matchAll(/([a-f0-9]{40})\s+"[^"\n]+"/gi)].map((match) => match[1].toUpperCase()));
108
+ return [...pem.matchAll(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g)].map((match) => {
109
+ const cert = new X509Certificate(match[0]);
110
+ return { sha1: sha1(cert.raw), serialNumber: cert.serialNumber, name: /^CN=(.+)$/m.exec(cert.subject)?.[1], expires: Date.parse(cert.validTo), issued: Date.parse(cert.validFrom) };
111
+ }).filter((cert) => valid.has(cert.sha1) && cert.name?.startsWith("Apple Development:") && cert.issued <= now.getTime() && cert.expires > now.getTime());
112
+ }
113
+
114
+ // ---- main flow ---------------------------------------------------------------------
115
+
116
+ export async function createAppleProfile({ root, config = {}, bundleId, name, containers, type = PROFILE_TYPE, certificate = "create", out, dryRun = true, registerDevices = [], keyOut = "", env = process.env, run = spawnSync, fetch: fetchApi = globalThis.fetch, jwtSigner = signAppleApiJwt, now = new Date(), log = console.log, platform = process.platform, home = os.homedir() }) {
117
+ if (type !== PROFILE_TYPE) fail("APPLE_PROFILE_USAGE", `--type must be ${PROFILE_TYPE}.`);
118
+ if (!BUNDLE_PATTERN.test(bundleId ?? "")) fail("APPLE_PROFILE_USAGE", "bundleId must be an explicit bundle identifier.");
119
+ if (!containers?.length) fail("APPLE_PROFILE_USAGE", "At least one iCloud container is required.");
120
+ assertNoInlinePem(env, config, [["--certificate", certificate], ["--name", name]]);
121
+ if (!dryRun && platform !== "darwin") fail("APPLE_PROFILE_PLATFORM", "A real run decodes the issued profile and uses the Keychain; run it on the macOS host.");
122
+ const outFile = path.resolve(root, out);
123
+ const auth = appleProfileAuth(env, { config, root });
124
+ const token = await jwtSigner(auth, now);
125
+ const calls = [], planned = [];
126
+
127
+ const request = async (method, endpoint, body) => {
128
+ const url = new URL(endpoint, ASC);
129
+ if (url.origin !== ASC || !url.pathname.startsWith("/v1/")) fail("APPLE_PROFILE_API_INVALID", "ASC URL is outside the API origin.");
130
+ calls.push({ method, path: `${url.pathname}${url.search}` });
131
+ log(`${method} ${decodeURIComponent(url.pathname + url.search)}`);
132
+ if (dryRun && method !== "GET") fail("APPLE_PROFILE_DRY_RUN_MUTATION", "Dry-run cannot mutate ASC.");
133
+ let response;
134
+ try { response = await fetchApi(url.href, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, ...(body ? { body: JSON.stringify(body) } : {}), redirect: "error", signal: AbortSignal.timeout(30_000) }); }
135
+ catch { fail("APPLE_PROFILE_API_FAILED", `ASC ${method} ${url.pathname} request failed.`); }
136
+ if (response.status === 403) { const error = new AppleProfileCreateError("APPLE_PROFILE_INSUFFICIENT_ROLE", "ASC key lacks permission for Certificates, Identifiers & Profiles (403); it needs the Admin or App Manager role."); error.status = 403; throw error; }
137
+ if (!response.ok) {
138
+ let detail = "";
139
+ try { const parsed = await response.json(); detail = String(parsed?.errors?.[0]?.detail ?? parsed?.errors?.[0]?.title ?? "").slice(0, 240); } catch { /* no body */ }
140
+ const error = new AppleProfileCreateError("APPLE_PROFILE_API_FAILED", `ASC ${method} ${url.pathname} returned HTTP ${response.status}${detail ? `: ${detail}` : ""}.`);
141
+ error.status = response.status; throw error;
142
+ }
143
+ if (response.status === 204) return {};
144
+ try { return await response.json(); }
145
+ catch { fail("APPLE_PROFILE_API_INVALID", "ASC returned invalid JSON."); }
146
+ };
147
+ const list = async (endpoint) => {
148
+ const items = [], visited = new Set();
149
+ while (endpoint) {
150
+ if (visited.has(endpoint) || visited.size >= 100) fail("APPLE_PROFILE_API_INVALID", "Invalid or excessive ASC pagination.");
151
+ visited.add(endpoint);
152
+ const result = await request("GET", endpoint);
153
+ if (!Array.isArray(result.data)) fail("APPLE_PROFILE_API_INVALID", "ASC list response has no resource array.");
154
+ items.push(...result.data); endpoint = result.links?.next;
155
+ }
156
+ return items;
157
+ };
158
+ const plan = { dryRun, command: "apple-profile-create", profileType: PROFILE_TYPE, out: outFile, name, calls, planned };
159
+ const mutate = async (method, endpoint, body) => {
160
+ if (dryRun) { planned.push({ method, path: endpoint, ...(body ? { body } : {}) }); log(`PLANNED ${method} ${endpoint}${body ? ` ${JSON.stringify(body)}` : ""}`); return null; }
161
+ return request(method, endpoint, body);
162
+ };
163
+
164
+ // (1) bundle id
165
+ const bundles = await list(query("/v1/bundleIds", { "filter[identifier]": bundleId, limit: "200" }));
166
+ let bundle = bundles.find((item) => item.attributes?.identifier === bundleId && ["MAC_OS", "UNIVERSAL"].includes(item.attributes?.platform));
167
+ plan.bundleId = { identifier: bundleId, id: bundle?.id ?? null, action: bundle ? "reuse" : "create" };
168
+ if (!bundle) {
169
+ const result = await mutate("POST", "/v1/bundleIds", { data: { type: "bundleIds", attributes: { identifier: bundleId, name: name.replace(/[^A-Za-z0-9 ]/g, " ").trim() || bundleId, platform: "MAC_OS" } } });
170
+ if (result) { bundle = result.data; plan.bundleId.id = bundle?.id ?? null; if (!bundle?.id) fail("APPLE_PROFILE_API_INVALID", "ASC did not return a created bundleId."); }
171
+ }
172
+
173
+ // (2) ICLOUD capability with CloudKit, and the container
174
+ const icloudSettings = [{ key: "ICLOUD_VERSION", options: [{ key: "XCODE_6" }] }];
175
+ const capabilities = bundle ? await list(`/v1/bundleIds/${enc(bundle.id)}/bundleIdCapabilities`) : [];
176
+ const icloud = capabilities.find((item) => item.attributes?.capabilityType === "ICLOUD");
177
+ const cloudKit = icloud?.attributes?.settings?.some((setting) => setting.key === "ICLOUD_VERSION" && setting.options?.some((option) => option.key === "XCODE_6" && option.enabled !== false));
178
+ plan.capability = { type: "ICLOUD", cloudKit: true, id: icloud?.id ?? null, action: !icloud ? "create" : cloudKit ? "reuse" : "update" };
179
+ if (plan.capability.action !== "reuse") {
180
+ try {
181
+ const result = icloud
182
+ ? await mutate("PATCH", `/v1/bundleIdCapabilities/${enc(icloud.id)}`, { data: { type: "bundleIdCapabilities", id: icloud.id, attributes: { capabilityType: "ICLOUD", settings: icloudSettings } } })
183
+ : await mutate("POST", "/v1/bundleIdCapabilities", { data: { type: "bundleIdCapabilities", attributes: { capabilityType: "ICLOUD", settings: icloudSettings }, relationships: { bundleId: { data: { type: "bundleIds", id: bundle?.id ?? "<new bundleId>" } } } } });
184
+ if (result?.data?.id) plan.capability.id = result.data.id;
185
+ } catch (error) {
186
+ if (error.code === "APPLE_PROFILE_API_FAILED") fail("APPLE_PROFILE_CAPABILITY_MISSING", `Could not enable the iCloud/CloudKit capability on ${bundleId} (${error.message})\n${containerManualSteps(bundleId, containers)}`);
187
+ throw error;
188
+ }
189
+ }
190
+ plan.containers = containers.map((identifier) => ({ identifier, assignment: "not-verifiable-via-api", verification: "checked on the issued profile entitlements before it is written" }));
191
+
192
+ // (4 first, so a missing device list is found before any certificate is created)
193
+ const devices = await list(query("/v1/devices", { "filter[platform]": "MAC_OS", limit: "200" }));
194
+ const registered = [];
195
+ for (const raw of registerDevices) {
196
+ const udid = raw.toUpperCase();
197
+ const existing = devices.find((item) => String(item.attributes?.udid ?? "").toUpperCase() === udid);
198
+ if (existing) { if (existing.attributes?.status !== "ENABLED") fail("APPLE_PROFILE_DEVICE_DISABLED", `Device ${udid} is registered but disabled in ASC; enable it at developer.apple.com.`); continue; }
199
+ const result = await mutate("POST", "/v1/devices", { data: { type: "devices", attributes: { name: `${name} Mac ${udid.slice(0, 8)}`, platform: "MAC_OS", udid } } });
200
+ registered.push(udid);
201
+ if (result?.data?.id) devices.push(result.data);
202
+ }
203
+ const enabledDevices = devices.filter((item) => item.attributes?.status === "ENABLED" && item.id);
204
+ plan.devices = { enabled: enabledDevices.map((item) => ({ id: item.id, udid: item.attributes.udid })), registeredNow: registered };
205
+ if (!enabledDevices.length && !dryRun) fail("APPLE_PROFILE_NO_DEVICES", "No enabled Mac devices in ASC; pass --register-device <UDID> for this Mac.");
206
+ const deviceIds = [...enabledDevices.map((item) => item.id), ...(dryRun ? registered.map(() => "<new device id>") : [])];
207
+
208
+ // (3) Apple Development certificate
209
+ const local = localDevelopmentIdentities(run, now, { tolerant: dryRun });
210
+ let ascCert, mode, generate = false;
211
+ if (certificate !== "create") {
212
+ mode = "explicit";
213
+ ascCert = (await request("GET", `/v1/certificates/${enc(certificate)}`)).data;
214
+ if (!ascCert?.id) fail("APPLE_PROFILE_CERT_NOT_IN_ASC", `Certificate ${certificate} not found in ASC.`);
215
+ if (!["DEVELOPMENT", "MAC_APP_DEVELOPMENT"].includes(ascCert.attributes?.certificateType)) fail("APPLE_PROFILE_CERT_TYPE", `Certificate ${certificate} is ${ascCert.attributes?.certificateType}, not an Apple Development certificate.`);
216
+ if (!(Date.parse(ascCert.attributes?.expirationDate) > now.getTime())) fail("APPLE_PROFILE_CERT_EXPIRED", `Certificate ${certificate} is expired.`);
217
+ } else {
218
+ const ascCerts = (await list(query("/v1/certificates", { "filter[certificateType]": "DEVELOPMENT", limit: "200" })))
219
+ .filter((item) => item.id && Date.parse(item.attributes?.expirationDate) > now.getTime());
220
+ const usable = ascCerts.map((item) => ({ item, identity: local.find((cert) => serial(cert.serialNumber) === serial(item.attributes?.serialNumber)) })).filter((entry) => entry.identity)
221
+ .sort((a, b) => b.identity.expires - a.identity.expires || a.item.id.localeCompare(b.item.id));
222
+ if (usable.length) { mode = "reuse"; ascCert = usable[0].item; }
223
+ else { mode = "create"; generate = true; }
224
+ }
225
+ const certBytes = ascCert?.attributes?.certificateContent ? Buffer.from(ascCert.attributes.certificateContent, "base64") : null;
226
+ const certSha1 = certBytes ? sha1(certBytes) : null;
227
+ const localIdentity = certSha1 ? local.find((cert) => cert.sha1 === certSha1) : undefined;
228
+ plan.certificate = { mode, id: ascCert?.id ?? null, serialNumber: ascCert?.attributes?.serialNumber ?? null, sha1: certSha1, name: localIdentity?.name ?? ascCert?.attributes?.name ?? null, privateKeyInKeychain: !!localIdentity, keyStore: generate ? (keyOut ? "file" : "login-keychain") : null };
229
+ if (mode === "explicit" && !localIdentity) log(`WARNING: certificate ${certificate} has no valid private-key identity in the local Keychain; the profile will authorize it but this Mac cannot sign with it.`);
230
+ if (generate) {
231
+ const commonName = `${name} Apple Development`;
232
+ if (dryRun) {
233
+ planned.push({ method: "LOCAL", path: `generate RSA-2048 key + CSR (CN=${commonName}); store key in ${keyOut ? "file --key-out" : "login Keychain"}` });
234
+ planned.push({ method: "POST", path: "/v1/certificates", body: { data: { type: "certificates", attributes: { certificateType: "DEVELOPMENT", csrContent: "<PEM CSR>" } } } });
235
+ log("PLANNED local RSA-2048 key + CSR, then POST /v1/certificates certificateType=DEVELOPMENT");
236
+ } else {
237
+ const created = await createCertificate({ commonName, request, run, keyOut: keyOut ? path.resolve(root, keyOut) : "", home, log });
238
+ ascCert = created.cert; plan.certificate = { ...plan.certificate, id: ascCert.id, serialNumber: ascCert.attributes?.serialNumber, sha1: created.sha1, name: created.name, privateKeyInKeychain: !keyOut };
239
+ }
240
+ }
241
+ const certificateSha1 = plan.certificate.sha1;
242
+
243
+ // (5) profile
244
+ const profiles = bundle && ascCert ? await list(query("/v1/profiles", { "filter[name]": name, "filter[profileType]": PROFILE_TYPE, limit: "200" })) : [];
245
+ let profile = profiles.find((item) => item.attributes?.name === name);
246
+ let content = null, createdNow = false;
247
+ if (profile) {
248
+ const [bundleRel, certRel, deviceRel] = await Promise.all([
249
+ request("GET", `/v1/profiles/${enc(profile.id)}/bundleId`), list(`/v1/profiles/${enc(profile.id)}/certificates`), list(`/v1/profiles/${enc(profile.id)}/devices`)]).then(([a, b, c]) => [a.data, b, c]);
250
+ const satisfied = profile.attributes?.profileState === "ACTIVE" && Date.parse(profile.attributes?.expirationDate) > now.getTime() && bundleRel?.id === bundle.id
251
+ && certRel.some((item) => item.id === ascCert.id) && deviceRel.length >= enabledDevices.length && enabledDevices.every((device) => deviceRel.some((item) => item.id === device.id));
252
+ if (!satisfied) fail("APPLE_PROFILE_NAME_CONFLICT", `A profile named ${name} already exists but is not an active ${PROFILE_TYPE} profile for ${bundleId} covering this certificate and every Mac device. Delete it at developer.apple.com or pass a new --name.`);
253
+ plan.profile = { id: profile.id, action: "reuse" };
254
+ content = profile.attributes?.profileContent ?? (await request("GET", `/v1/profiles/${enc(profile.id)}`)).data?.attributes?.profileContent;
255
+ } else {
256
+ plan.profile = { id: null, action: "create" };
257
+ const body = { data: { type: "profiles", attributes: { name, profileType: PROFILE_TYPE }, relationships: {
258
+ bundleId: { data: { type: "bundleIds", id: bundle?.id ?? "<new bundleId>" } },
259
+ certificates: { data: [{ type: "certificates", id: ascCert?.id ?? "<new certificate id>" }] },
260
+ devices: { data: deviceIds.map((id) => ({ type: "devices", id })) } } } };
261
+ const result = await mutate("POST", "/v1/profiles", body);
262
+ if (result) {
263
+ profile = result.data; createdNow = true;
264
+ if (!profile?.id) fail("APPLE_PROFILE_API_INVALID", "ASC did not return a created profile id.");
265
+ plan.profile.id = profile.id;
266
+ content = profile.attributes?.profileContent ?? (await request("GET", `/v1/profiles/${enc(profile.id)}`)).data?.attributes?.profileContent;
267
+ }
268
+ }
269
+ plan.verification = "On apply the issued profile is decoded and must carry the bundle id, get-task-allow, CloudKit, every requested container, the certificate and every enabled Mac device.";
270
+ log(JSON.stringify({ ...plan, calls: undefined }, null, 2));
271
+ if (dryRun) { plan.writes = `would write ${outFile} (0600)`; log(`PLANNED write ${outFile} with mode 0600`); return plan; }
272
+
273
+ // verify, then write 0600
274
+ if (typeof content !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(content)) fail("APPLE_PROFILE_API_INVALID", "ASC profileContent is missing or invalid.");
275
+ mkdirSync(path.dirname(outFile), { recursive: true });
276
+ const temporary = `${outFile}.create-${randomUUID()}.tmp`;
277
+ try {
278
+ writeFileSync(temporary, Buffer.from(content, "base64"), { flag: "wx", mode: 0o600 });
279
+ const problems = verifyIssuedProfile(decodeAppleProfile(temporary, { run }), { bundleId, containers, certificateSha1, deviceUdids: enabledDevices.map((item) => item.attributes.udid), now });
280
+ if (problems.containers.length) {
281
+ if (createdNow) { try { await request("DELETE", `/v1/profiles/${enc(profile.id)}`); } catch { /* best effort; reported below */ } }
282
+ fail("APPLE_PROFILE_ICLOUD_CONTAINER_UNASSIGNED", `The issued profile does not carry iCloud container(s) ${problems.containers.join(", ")}${createdNow ? " (the profile just created was deleted again)" : ""}.\n${containerManualSteps(bundleId, containers)}`);
283
+ }
284
+ if (problems.other.length) fail("APPLE_PROFILE_VERIFY_FAILED", `Issued profile ${profile.id} failed verification: ${problems.other.join("; ")}. Nothing was written to ${outFile}.`);
285
+ if (existsSync(outFile)) copyFileSync(outFile, `${outFile}.backup-${now.toISOString().replace(/[:.]/g, "-")}-${randomUUID()}`, constants.COPYFILE_EXCL);
286
+ renameSync(temporary, outFile);
287
+ chmodSync(outFile, 0o600);
288
+ } finally { rmSync(temporary, { force: true }); }
289
+ log(`Wrote ${outFile}; use HEARDRIGHT_MAC_DEV_SIGN_IDENTITY=${certificateSha1}`);
290
+ return { ...plan, profileId: profile.id, identitySha1: certificateSha1 };
291
+ }
292
+
293
+ export function verifyIssuedProfile(decoded, { bundleId, containers, certificateSha1, deviceUdids, now }) {
294
+ const other = [], missing = [];
295
+ const entitlements = decoded.Entitlements ?? {};
296
+ const appId = entitlements["com.apple.application-identifier"] ?? entitlements["application-identifier"];
297
+ if (!Array.isArray(decoded.Platform) || !decoded.Platform.includes("OSX")) other.push("not a macOS profile");
298
+ if (typeof appId !== "string" || !appId.endsWith(`.${bundleId}`)) other.push("application identifier does not match the bundle id");
299
+ if ((entitlements["com.apple.security.get-task-allow"] ?? entitlements["get-task-allow"]) !== true) other.push("not a development profile (get-task-allow)");
300
+ if (!(Date.parse(decoded.ExpirationDate) > now.getTime())) other.push("expired or no expiration date");
301
+ if (certificateSha1 && !(decoded.DeveloperCertificates ?? []).some((data) => sha1(Buffer.from(data, "base64")) === certificateSha1)) other.push("certificate not authorized");
302
+ const provisioned = new Set((decoded.ProvisionedDevices ?? []).map((udid) => String(udid).toUpperCase()));
303
+ for (const udid of deviceUdids) if (!provisioned.has(String(udid).toUpperCase())) other.push(`device ${udid} not provisioned`);
304
+ if (!(entitlements["com.apple.developer.icloud-services"] ?? []).includes("CloudKit")) missing.push("CloudKit service");
305
+ const carried = new Set([...(entitlements["com.apple.developer.icloud-container-identifiers"] ?? []), ...(entitlements["com.apple.developer.icloud-container-development-container-identifiers"] ?? [])]);
306
+ for (const container of containers) if (!carried.has(container)) missing.push(container);
307
+ return { other, containers: missing };
308
+ }
309
+
310
+ async function createCertificate({ commonName, request, run, keyOut, home, log }) {
311
+ const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
312
+ const csr = buildCsr(commonName, { privateKey, publicKey });
313
+ const keychain = path.join(home, "Library", "Keychains", "login.keychain-db");
314
+ const scratch = mkdtempSync(path.join(os.tmpdir(), "rk-apple-dev-"));
315
+ const local = (args) => {
316
+ const result = run("security", args, { encoding: "utf8", stdio: "pipe", shell: false, windowsHide: true });
317
+ if (result.error || result.status !== 0) fail("APPLE_PROFILE_KEYCHAIN_FAILED", `security ${args[0]} failed; stopped before any further change.`);
318
+ return result.stdout;
319
+ };
320
+ try {
321
+ // The key is stored BEFORE the certificate is requested, so a failure can never
322
+ // leave a certificate in ASC whose private key is lost. Key bytes are never logged.
323
+ const keyFile = keyOut || path.join(scratch, "key.pem");
324
+ writeFileSync(keyFile, privateKey.export({ type: "pkcs1", format: "pem" }), { mode: 0o600, flag: keyOut ? "wx" : "w" });
325
+ if (!keyOut) {
326
+ if (!existsSync(keychain)) fail("APPLE_PROFILE_KEYCHAIN_FAILED", `Login Keychain not found at ${keychain}.`);
327
+ local(["import", keyFile, "-k", keychain, "-t", "priv", "-f", "openssl", "-T", "/usr/bin/codesign"]);
328
+ rmSync(keyFile, { force: true });
329
+ } else log(`Private key written to ${keyFile} (0600); import it into a Keychain yourself.`);
330
+ const created = (await request("POST", "/v1/certificates", { data: { type: "certificates", attributes: { certificateType: "DEVELOPMENT", csrContent: csr.pem } } })).data;
331
+ const content = created?.attributes?.certificateContent;
332
+ if (!created?.id || typeof content !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(content)) fail("APPLE_PROFILE_API_INVALID", "ASC did not return a usable certificate.");
333
+ const der = Buffer.from(content, "base64"), cert = new X509Certificate(der);
334
+ const certFile = keyOut ? `${keyOut}.cer` : path.join(scratch, "cert.cer");
335
+ writeFileSync(certFile, der, { mode: 0o600, flag: keyOut ? "wx" : "w" });
336
+ const sha = sha1(der), commonSubject = /^CN=(.+)$/m.exec(cert.subject)?.[1];
337
+ if (!keyOut) {
338
+ local(["import", certFile, "-k", keychain, "-t", "cert", "-f", "x509"]);
339
+ if (!new RegExp(sha, "i").test(local(["find-identity", "-v", "-p", "codesigning"]))) fail("APPLE_PROFILE_KEYCHAIN_FAILED", `Certificate ${sha} was imported but is not a valid code-signing identity (is the Apple WWDR intermediate installed?). Certificate id ${created.id} exists in ASC.`);
340
+ }
341
+ log(`Created Apple Development certificate ${created.id} (${commonSubject ?? "unnamed"}) sha1 ${sha}`);
342
+ return { cert: created, sha1: sha, name: commonSubject };
343
+ } finally { rmSync(scratch, { recursive: true, force: true }); }
344
+ }
345
+
346
+ export async function main(args = process.argv.slice(2), options = {}) {
347
+ if (args.includes("--help") || args.includes("-h")) {
348
+ (options.log ?? console.log)("right-release apple-profile-create --bundle-id <id> --name <profile name> --icloud-container <iCloud.x> [--icloud-container ...] --out <profile path>\n [--config <file>] [--type MAC_APP_DEVELOPMENT] [--certificate <asc cert id>|create] [--register-device <UDID>]... [--key-out <file>] [--dry-run | --apply]\nDefault is --dry-run (GET discovery only). Uses APPLE_API_ISSUER, APPLE_API_KEY & APPLE_API_KEY_PATH or appStoreConnect.* in the config; inline PEM is refused.\nThe ASC API cannot create or assign iCloud containers; the issued profile is verified and the command prints manual steps if the container is missing.");
349
+ return;
350
+ }
351
+ const parsed = parseCreateArgs(args);
352
+ const configPath = parsed.configPath ?? "right-release.config.mjs";
353
+ const absolute = path.resolve(configPath);
354
+ let config = {};
355
+ if (existsSync(absolute)) { const loaded = await import(pathToFileURL(absolute)); config = loaded.default ?? loaded.config ?? loaded; }
356
+ else if (parsed.configPath) fail("APPLE_PROFILE_USAGE", `Config file not found: ${parsed.configPath}`);
357
+ return createAppleProfile({ ...options, root: path.dirname(absolute), config, bundleId: parsed.bundleId, name: parsed.name, containers: parsed.containers, type: parsed.type, certificate: parsed.certificate, out: parsed.out, dryRun: parsed.dryRun, registerDevices: parsed.registerDevices, keyOut: parsed.keyOut });
358
+ }
359
+
360
+ const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
361
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
362
+ main().catch((error) => { console.error(`right-release apple-profile-create: [${error.code ?? "APPLE_PROFILE_FAILED"}] ${error instanceof AppleProfileCreateError ? error.message : "Profile creation failed; check app configuration & local file access."}`); process.exitCode = 1; });
363
+ }
Binary file
@@ -0,0 +1,6 @@
1
+ {
2
+ "schema": 1,
3
+ "sha256": "68f8aba2f5ecbaa120e948619f10a90d27fc7b701271e9382a412c7c07aca781",
4
+ "identity": "Developer ID Application: Adrian D'souza (6KLGD3LLKF)",
5
+ "codesign": "strict-valid"
6
+ }
package/build-release.mjs CHANGED
@@ -30,6 +30,7 @@ import { commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBui
30
30
  import { acquireCacheLease, acquireSuiteBuildSlot, applyCachePrune, assertWriteVolumeFloors, inspectCache, inspectWriteVolumes, markCacheEntrySuccessful, planCachePrune, readCachePolicy, resolveCacheLayout, resolveSharedCacheIdentity, resolveSharedCacheRoot } from "./cache-policy.mjs";
31
31
  import { createTargetBridge } from "./target-bridge.mjs";
32
32
  import { signingReceiptPath } from "./signing-receipts.mjs";
33
+ import { MEDIA_SIDECAR_RECEIPT_NAME, validateMediaSidecarConfig, validateMediaSidecarReceipt, windowsSidecarSigningProblems } from "./media-sidecar.mjs";
33
34
  import { validateNotarizationReceipt } from "./macos-notarization.mjs";
34
35
  import { brokerManagedHost } from "./cargo-contract.mjs";
35
36
  import { ensureLegacyTargetLink, resolveReleaseCacheMode } from "./release-cache-mode.mjs";
@@ -44,17 +45,25 @@ import { processGroupCpuSeconds } from "./process-liveness.mjs";
44
45
  import { cancelAndReapManagedRequest, firstManagedRequestId, releaseProgressLimits } from "./progress-control.mjs";
45
46
  import { beginArtifactLease, completeArtifactLease, materializeArtifacts, retainArtifacts, resolveArtifactDir, resolveManagedRoot } from "./artifact-storage.mjs";
46
47
  import { macosCodesignEnvironment } from "./codesign-environment.mjs";
48
+ import { ANDROID_RECEIPT_NAME, androidSealEntry, validateAndroidTarget, verifyAndroidReceipt } from "./android-release.mjs";
47
49
 
48
50
  // Fingerprint of the pipeline code that can change the bytes we ship or the way
49
51
  // they are signed — deliberately NOT the package version, which moves for docs
50
52
  // and test-only edits and would force a cold Rust rebuild of every app each time.
51
53
  // Cached target directories must not outlive changes to these signing helpers.
52
54
 
53
- const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "sign-macos.mjs", "macos-signing-session.mjs", "codesign-environment.mjs", "codesign-shim.mjs", "codesign-argv.mjs", "osascript-guard.mjs", "dmg-build.mjs", "dmg-layout.mjs", "dmg-spec.mjs", "swift-mac-dmg.mjs", "mac-bundle-signing.mjs", "mac-signing-matrix.mjs", "mac-sign-core.mjs", "macos-file-evidence.mjs", "swift-mac-app.mjs", "rightkit-signing-agent.mjs", "notarize-macos.mjs", "macos-notarization.mjs", "signing-receipts.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs", "msix-build.mjs", "msix-plan.mjs"];
55
+ const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "sign-macos.mjs", "macos-signing-session.mjs", "codesign-environment.mjs", "codesign-shim.mjs", "codesign-argv.mjs", "osascript-guard.mjs", "dmg-build.mjs", "dmg-layout.mjs", "dmg-spec.mjs", "swift-mac-dmg.mjs", "mac-bundle-signing.mjs", "media-sidecar.mjs", "media-sidecar-catalog.mjs", "mac-signing-matrix.mjs", "mac-sign-core.mjs", "macos-file-evidence.mjs", "swift-mac-app.mjs", "rightkit-signing-agent.mjs", "notarize-macos.mjs", "macos-notarization.mjs", "signing-receipts.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs", "msix-build.mjs", "msix-plan.mjs"];
54
56
  const PIPELINE_FINGERPRINT = createHash("sha256")
55
57
  .update(PIPELINE_FINGERPRINT_SOURCES.map((file) => `${file}:${createHash("sha256").update(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file))).digest("hex")}`).join("\n"))
56
58
  .digest("hex")
57
59
  .slice(0, 16);
60
+ // The Android lane's own signing/scan code. Kept out of PIPELINE_FINGERPRINT_SOURCES so a change here never cold-rebuilds win/mac,
61
+ // but still part of the android signing identity so a changed scanner or receipt shape retires cached android output.
62
+ const ANDROID_PIPELINE_FINGERPRINT = createHash("sha256")
63
+ .update(["android-release.mjs", "android-aab-scan.mjs", "android-ndk.mjs"].map((file) => `${file}:${createHash("sha256").update(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file))).digest("hex")}`).join("\n"))
64
+ .update(PIPELINE_FINGERPRINT)
65
+ .digest("hex")
66
+ .slice(0, 16);
58
67
 
59
68
  /** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
60
69
  function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativeLayout, target }) {
@@ -100,7 +109,7 @@ for (let i = 0; i < args.length; i += 1) {
100
109
  else if (arg === "-h" || arg === "--help") usage(0);
101
110
  else fail(`unknown argument: ${arg}`);
102
111
  }
103
- if (platform !== "win" && platform !== "mac") fail("--platform must be win or mac");
112
+ if (platform !== "win" && platform !== "mac" && platform !== "android") fail("--platform must be win, mac or android");
104
113
  if (resumeFrom && !["prepackage", "sign", "package", "post-sign", "harden"].includes(resumeFrom)) fail("--resume-from must be prepackage, sign, package, post-sign, or harden");
105
114
 
106
115
  const configPath = path.resolve(configName);
@@ -132,7 +141,8 @@ if (dryRun) {
132
141
  const config = (await import(`${pathToFileURL(configPath).href}?dry-run=${commit}`)).default;
133
142
  if (!config?.app || !config?.version) fail("release config must expose app and version");
134
143
  const target = config.targets?.[platform];
135
- if (!target?.package && !packageCommandOptional(target, resolveProjectType({ appRoot, config, target }))) fail(`${config.app} has no ${platform} package command`);
144
+ // Android builds with target.gradle (validated by release.mjs), not a package command.
145
+ if (platform !== "android" && !target?.package && !packageCommandOptional(target, resolveProjectType({ appRoot, config, target }))) fail(`${config.app} has no ${platform} package command`);
136
146
  console.log(`right-release build dry-run: ${config.app} ${config.version} ${platform}`);
137
147
  console.log(`config: ${configPath}`);
138
148
  console.log(`commit: ${commit}`);
@@ -171,7 +181,12 @@ try {
171
181
  fail("app and version must be safe release-id components");
172
182
  }
173
183
  const target = config.targets?.[platform];
174
- if (!target?.package && !packageCommandOptional(target, resolveProjectType({ appRoot, config, target }))) fail(`${config.app} has no ${platform} package command`);
184
+ if (platform !== "android" && !target?.package && !packageCommandOptional(target, resolveProjectType({ appRoot, config, target }))) fail(`${config.app} has no ${platform} package command`);
185
+ // Same validation release.mjs applies; yields the normalised upload-certificate pin and the AAB path.
186
+ let androidTarget = null;
187
+ if (platform === "android") {
188
+ try { androidTarget = validateAndroidTarget(target, { root: appRoot }); } catch (error) { fail(`${config.app} android ${error.message}`); }
189
+ }
175
190
  const isSwiftBuild = resolveProjectType({ appRoot, config, target }) === "swiftpm";
176
191
  const nativeLayout = resolveNativeCargoLayout({ appRoot, config });
177
192
  if (platform === "win" && target.signingContract === "windows-raw-exe-authenticode-before-nsis-v1") {
@@ -183,7 +198,7 @@ try {
183
198
  const toolVersions = isSwiftBuild ? collectSwiftToolVersions({ packageManager: config.packageManager, exec: commandOutput }) : collectToolVersions(config.packageManager ?? "pnpm");
184
199
  const inputHashes = hashInputs(requiredInputs);
185
200
  const releaseId = `${config.app}-${config.version}-${shortCommit}`;
186
- const platformDir = platform === "win" ? "windows" : "mac";
201
+ const platformDir = platform === "win" ? "windows" : platform === "android" ? "android" : "mac";
187
202
  const stateRoot = path.join(vaultRoot, "state", releaseId, platformDir);
188
203
  const signingIdentity = platform === "win" ? {
189
204
  contract: target.signingContract ?? "<missing>",
@@ -207,6 +222,14 @@ try {
207
222
  // and one Accepted notarization receipt after. Both must be part of cache
208
223
  // identity and seal evidence, including configured receipt overrides.
209
224
  receiptInputs: ["signing", "notarization"].map((phase) => signingReceiptPath({ appRoot, target, platform, phase, stateRoot })),
225
+ } : platform === "android" ? {
226
+ contract: target.signingContract ?? "<missing>",
227
+ pipeline: ANDROID_PIPELINE_FINGERPRINT,
228
+ configSha256: hashFile(configPath),
229
+ // The pinned upload-certificate SHA-256 (64 lowercase hex). The seal refuses a receipt whose signer differs from it.
230
+ uploadCertSha256: androidTarget.signing.expectedCertSha256,
231
+ // release.mjs writes the AAB receipt into RIGHT_RELEASE_STATE_ROOT (= stateRoot) as android-aab.json.
232
+ receiptInputs: [path.resolve(stateRoot, ANDROID_RECEIPT_NAME)],
210
233
  } : null;
211
234
  if (signingIdentity) inputHashes[".right-release/signing-identity.json"] = hashFileText(JSON.stringify(signingIdentity));
212
235
  const cargoLock = nativeLayout.lockPath;
@@ -404,7 +427,7 @@ try {
404
427
  });
405
428
  },
406
429
  seal: async ({ sealedDir }) => {
407
- sealRelease({ configRoot: appRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot });
430
+ sealRelease({ configRoot: appRoot, managedCargoTarget, androidTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot });
408
431
  verifySealedRelease(sealedDir);
409
432
  if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
410
433
  checkpoint(stateRoot, "sealed");
@@ -444,7 +467,7 @@ function sqlCipherFeatures(text) {
444
467
  .filter((value) => /sqlcipher|openssl/i.test(value));
445
468
  }
446
469
 
447
- function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot }) {
470
+ function sealRelease({ configRoot, managedCargoTarget, androidTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot }) {
448
471
  // Every app's right-release.config.mjs names installer/updater artifacts
449
472
  // relative to its resolved native target. On a broker-managed host that
450
473
  // directory is never populated (the broker owns CARGO_TARGET_DIR and
@@ -482,14 +505,45 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
482
505
  }
483
506
  // Portable contracts ship bare archives (zip / tar.gz) with no installer or
484
507
  // updater block: `target.artifacts` entries (paths or { file }) seal with the
485
- // "portable" role.
508
+ // "portable" role. On android the declared AAB seals with the "aab" role instead.
509
+ const aabSource = platform === "android" ? resolveArtifactPath(androidTarget.aab.file) : null;
486
510
  for (const artifact of target.artifacts ?? []) {
487
511
  const file = typeof artifact === "string" ? artifact : artifact?.file;
488
512
  if (typeof file !== "string" || !file) fail("target.artifacts entries must be paths or { file }");
489
- addSource(resolveArtifactPath(file), "portable");
513
+ const source = resolveArtifactPath(file);
514
+ addSource(source, source === aabSource ? "aab" : "portable");
490
515
  }
491
516
  if (sources.size === 0) fail(`nothing to seal: declare installer.artifacts, updater.artifacts or artifacts for the ${platform} target`);
492
517
  for (const file of sources.keys()) if (!existsSync(file)) fail(`cannot seal missing artifact: ${file}`);
518
+ // Android: the build receipt must describe exactly the AAB being sealed (bytes re-hashed) and its signer must be the pinned
519
+ // upload certificate. Checked before anything is copied, so a mismatch leaves no partial seal.
520
+ let androidReceipt = null;
521
+ if (platform === "android") {
522
+ if (!sources.has(aabSource)) fail("android seal requires targets.android.artifacts to list aab.file");
523
+ const [receiptPath] = signingIdentity.receiptInputs;
524
+ if (!existsSync(receiptPath)) fail(`no signing receipts found to seal; expected at least ${receiptPath}`);
525
+ try { androidReceipt = verifyAndroidReceipt(receiptPath, aabSource); } catch (error) { fail(`android AAB receipt rejected: ${error.message}`); }
526
+ if (androidReceipt.signer?.certSha256 !== signingIdentity.uploadCertSha256) fail("android AAB receipt signer certificate is not the pinned upload certificate");
527
+ }
528
+ // rightkit-ffmpeg decode sidecar: release.mjs signed the binaries with the app and wrote media-sidecar.json. The seal
529
+ // refuses a release without it, re-checks its shape against the configuration, and (Windows) that every sidecar's
530
+ // signed digest is one the sealed raw-EXE signing receipt recorded. The hashes then live in the sealed manifest.
531
+ let mediaSidecars = null;
532
+ if (target.mediaSidecar !== undefined) {
533
+ const receiptPath = path.resolve(stateRoot, MEDIA_SIDECAR_RECEIPT_NAME);
534
+ if (!existsSync(receiptPath)) fail(`mediaSidecar is configured but its receipt is missing: ${receiptPath}`);
535
+ try {
536
+ const sidecarConfig = validateMediaSidecarConfig(target.mediaSidecar, { platform });
537
+ const receipt = validateMediaSidecarReceipt(JSON.parse(readFileSync(receiptPath, "utf8")), { os: sidecarConfig.os, variant: sidecarConfig.variant, tools: sidecarConfig.tools });
538
+ if (receipt.arch !== sidecarConfig.arch) throw new Error(`receipt arch ${receipt.arch} differs from the configured ${sidecarConfig.arch}`);
539
+ if (platform === "win") {
540
+ const rawReceipt = JSON.parse(readFileSync(signingIdentity.receiptInputs[0], "utf8"));
541
+ const problems = windowsSidecarSigningProblems(rawReceipt, receipt);
542
+ if (problems.length) throw new Error(problems.join("; "));
543
+ }
544
+ mediaSidecars = { ...receipt, receiptSha256: hashFile(receiptPath) };
545
+ } catch (error) { fail(`media sidecar receipt rejected: ${error.message}`); }
546
+ }
493
547
  const temp = `${sealedDir}.tmp-${process.pid}`;
494
548
  if (existsSync(temp)) rmSync(temp, { recursive: true, force: true });
495
549
  mkdirSync(temp, { recursive: true });
@@ -501,7 +555,9 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
501
555
  if ([...sourceNames.values()].includes(name) && sourceNames.get(source) !== name) name = `${[...roles].join("-")}-${name}`;
502
556
  sourceNames.set(source, name);
503
557
  copyFileSync(source, path.join(temp, name));
504
- files.push({ role: [...roles].sort().join("+"), name, sha256: hashFile(source), sizeBytes: statSync(source).size });
558
+ files.push(androidReceipt && source === aabSource && name === androidReceipt.file.name
559
+ ? androidSealEntry(androidReceipt)
560
+ : { role: [...roles].sort().join("+"), name, sha256: hashFile(source), sizeBytes: statSync(source).size });
505
561
  artifactInputs.push({ source, name });
506
562
  }
507
563
  const routes = { patch: [], update: [] };
@@ -512,9 +568,12 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
512
568
  for (const artifact of target.updater?.artifacts ?? []) {
513
569
  const source = resolveArtifactPath(artifact.file);
514
570
  const signatureSource = target.updater?.kind === "appinstaller" ? null : resolveArtifactPath(artifact.signature);
515
- const patchKey = artifact.patchKey ?? artifact.key.replace("/updates/", "/installers/");
571
+ // github-releases updater artifacts declare no R2 key: the asset name is the route key and nothing is replaced in place.
572
+ const githubUpdater = config.distribution?.provider === "github-releases";
573
+ const routeKey = artifact.key ?? (githubUpdater ? path.basename(artifact.file) : undefined);
574
+ const patchKey = artifact.patchKey ?? routeKey.replace("/updates/", "/installers/");
516
575
  routes.patch.push({ role: "updater", name: sourceNames.get(source), signature: signatureSource ? sourceNames.get(signatureSource) : undefined, bucket: "public", key: patchKey, platform: artifact.platform });
517
- routes.update.push({ role: "updater", name: sourceNames.get(source), signature: signatureSource ? sourceNames.get(signatureSource) : undefined, bucket: "private", key: artifact.key, platform: artifact.platform });
576
+ routes.update.push({ role: "updater", name: sourceNames.get(source), signature: signatureSource ? sourceNames.get(signatureSource) : undefined, bucket: "private", key: routeKey, platform: artifact.platform });
518
577
  }
519
578
  writeJson(path.join(temp, "hardening.json"), { schema: 1, passed: true, checkedAt: new Date().toISOString(), files: files.map((file) => file.sha256) });
520
579
  const manifest = {
@@ -528,6 +587,7 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
528
587
  // DevTools allowance (owner's fixed design): { mode: "denied" } for every app; content-only only for an allow-listed app.
529
588
  devtools: devtoolsEvidence(resolveDevtoolsAllowance({ config, root: configRoot })),
530
589
  platform,
590
+ ...(mediaSidecars ? { mediaSidecars } : {}),
531
591
  cacheKey,
532
592
  // Portable contracts sign only raw executables — installer/embedding
533
593
  // receipts legitimately never exist there. Seal every receipt that was
@@ -783,7 +843,7 @@ function writeJson(file, value) {
783
843
  }
784
844
 
785
845
  function usage(code) {
786
- console.log("usage: right-release build --platform win|mac [--config right-release.config.mjs] [--dry-run]");
846
+ console.log("usage: right-release build --platform win|mac|android [--config right-release.config.mjs] [--dry-run]");
787
847
  process.exit(code);
788
848
  }
789
849
 
package/cargo-guard.mjs CHANGED
@@ -185,6 +185,8 @@ export function cargoCacheEnvironment(args, {
185
185
  const projectRoot = cargoProjectRoot(args, { cwd, platform, exists });
186
186
  const projectName = paths.basename(projectRoot).replace(/[^A-Za-z0-9._-]+/g, "-") || "cargo";
187
187
  const projectHash = createHash("sha256").update(platform === "win32" || platform === "win" ? projectRoot.toLowerCase() : projectRoot).digest("hex").slice(0, 12);
188
+ // Build output only (hashed per project path): nothing runs from here. Dev and QA binaries run from the fixed
189
+ // staged path (`stage-binary.mjs`), not from this folder or the broker's `ws/<hash>/g/<gen>` target.
188
190
  const defaultTarget = paths.join(cacheRoot, "dev-targets", `${projectName}-${projectHash}`);
189
191
  const defaultSccache = paths.join(cacheRoot, "sccache");
190
192
  const targetOption = args.find((value) => value.startsWith("--target-dir="));