@rightkit/release 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +25 -4
  2. package/addon-command.mjs +3 -4
  3. package/android-play-upload.mjs +2 -1
  4. package/apple-profile-renew.mjs +11 -3
  5. package/artifact-storage.mjs +35 -2
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +1 -1
  8. package/build-release.mjs +138 -47
  9. package/cli/right-release.mjs +11 -2
  10. package/codesign-environment.mjs +2 -2
  11. package/content-digest.mjs +209 -0
  12. package/development.mjs +17 -6
  13. package/direct-bootstrap.mjs +5 -6
  14. package/disk-command.mjs +1 -0
  15. package/disk-policy.json +6 -2
  16. package/disk-stable-root.mjs +13 -9
  17. package/github-updater-release.mjs +70 -7
  18. package/in-place-install.mjs +17 -14
  19. package/install-app-windows.mjs +24 -7
  20. package/install-app.mjs +21 -40
  21. package/ios-asc-credentials.mjs +163 -0
  22. package/ios-build-context.mjs +129 -0
  23. package/ios-build-upload.mjs +282 -0
  24. package/ios-credentials-command.mjs +70 -0
  25. package/ios-dev.mjs +248 -0
  26. package/ios-host.mjs +76 -0
  27. package/ios-lock.mjs +75 -0
  28. package/ios-package-recipe.mjs +707 -0
  29. package/ios-testflight.mjs +266 -0
  30. package/ios-upload.mjs +313 -0
  31. package/local-publish.mjs +92 -37
  32. package/managed-cargo.mjs +8 -0
  33. package/mirror-root-artifact.mjs +5 -1
  34. package/native-cargo-layout.mjs +47 -4
  35. package/ownership-command.mjs +2 -1
  36. package/ownership-dependency-allowlist.json +1 -1
  37. package/ownership-evidence.mjs +30 -18
  38. package/ownership-key-command.mjs +69 -2
  39. package/ownership-mandatory-set.mjs +66 -16
  40. package/ownership-repositories.json +1 -1
  41. package/ownership-scope.mjs +94 -0
  42. package/ownership.mjs +49 -7
  43. package/package-recipe-targets.mjs +41 -1
  44. package/package.json +1 -1
  45. package/preflight.mjs +14 -7
  46. package/project-type.mjs +3 -1
  47. package/protected-credentials.mjs +28 -1
  48. package/publication-artifacts.mjs +30 -5
  49. package/publication-gate.mjs +15 -4
  50. package/publish-cargo.mjs +109 -22
  51. package/publish-update.mjs +15 -2
  52. package/qa-build-features.mjs +16 -13
  53. package/qa-contract.mjs +30 -2
  54. package/qa-control-scan.mjs +4 -0
  55. package/release-invocation.mjs +118 -8
  56. package/release-switch-registry.json +126 -27
  57. package/release-switch-scan.mjs +45 -17
  58. package/release.mjs +69 -17
  59. package/rightkit-ownership.json +1 -1
  60. package/rightkit-versions.json +379 -189
  61. package/runtime-budgets.mjs +120 -16
  62. package/sign-updater.mjs +56 -59
  63. package/source-gate.mjs +8 -1
  64. package/stage-sidecar.mjs +160 -32
  65. package/target-distribution.mjs +5 -0
  66. package/upload-release.mjs +7 -2
package/build-release.mjs CHANGED
@@ -5,7 +5,6 @@ import {
5
5
  closeSync,
6
6
  copyFileSync,
7
7
  existsSync,
8
- mkdtempSync,
9
8
  mkdirSync,
10
9
  openSync,
11
10
  readFileSync,
@@ -18,8 +17,8 @@ import {
18
17
  unlinkSync,
19
18
  writeFileSync,
20
19
  } from "node:fs";
21
- import { tmpdir } from "node:os";
22
20
  import path from "node:path";
21
+ import { iosOwnershipScope } from "./ownership-scope.mjs";
23
22
  import { spawn, spawnSync } from "./background-process.mjs";
24
23
  import { fileURLToPath, pathToFileURL } from "node:url";
25
24
  import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
@@ -37,6 +36,7 @@ import { ensureLegacyTargetLink, resolveReleaseCacheMode } from "./release-cache
37
36
  import { resolveTargetRoot } from "./cargo-target.mjs";
38
37
  import { assertNsisInPlaceUpgradeContract } from "./nsis-upgrade-contract.mjs";
39
38
  import { collectSwiftToolVersions, planSwiftBuild } from "./swift-build-context.mjs";
39
+ import { planIosNativeBuild, resolveIosBuildContext } from "./ios-build-context.mjs";
40
40
  import { packageCommandOptional, requiredBuildExecutables, resolveProjectType, targetUsesMsix } from "./project-type.mjs";
41
41
  import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
42
42
  import { assertCleanSource } from "./source-gate.mjs";
@@ -46,6 +46,15 @@ import { cancelAndReapManagedRequest, firstManagedRequestId, releaseProgressLimi
46
46
  import { beginArtifactLease, completeArtifactLease, materializeArtifacts, retainArtifacts, resolveArtifactDir, resolveManagedRoot } from "./artifact-storage.mjs";
47
47
  import { macosCodesignEnvironment } from "./codesign-environment.mjs";
48
48
  import { ANDROID_RECEIPT_NAME, androidSealEntry, validateAndroidTarget, verifyAndroidReceipt } from "./android-release.mjs";
49
+ import { IOS_RECEIPT_NAME, iosSealEntry, verifyIosReceipt } from "./ios-package-recipe.mjs";
50
+ import { expandIosPackageTarget } from "./package-recipe-targets.mjs";
51
+ import { assertUpdaterSignature } from "./github-updater-release.mjs";
52
+
53
+ // The iOS lane's own packaging code, kept out of the win/mac fingerprint for the same reason as the Android one.
54
+ const IOS_PIPELINE_FINGERPRINT = createHash("sha256")
55
+ .update(["ios-package-recipe.mjs", "ios-host.mjs"].map((file) => `${file}:${createHash("sha256").update(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file))).digest("hex")}`).join("\n"))
56
+ .digest("hex")
57
+ .slice(0, 16);
49
58
 
50
59
  // Fingerprint of the pipeline code that can change the bytes we ship or the way
51
60
  // they are signed — deliberately NOT the package version, which moves for docs
@@ -66,15 +75,18 @@ const ANDROID_PIPELINE_FINGERPRINT = createHash("sha256")
66
75
  .slice(0, 16);
67
76
 
68
77
  /** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
69
- function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativeLayout, target }) {
78
+ function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativeLayout, target, pinnedCargoLocks = [] }) {
70
79
  let version;
71
80
  try {
72
81
  version = JSON.parse(readFileSync(path.join(appRoot, "package.json"), "utf8")).version;
73
82
  } catch {
74
83
  version = undefined;
75
84
  }
85
+ // An iOS target is built from its Xcode project and local packages only; the ownership scan covers just those roots.
86
+ const ownershipScope = platform === "ios" ? iosOwnershipScope({ repoRoot, appRoot, target, nativeAssembly: config?.nativeAssembly }) : null;
76
87
  return collectPreflight({
77
88
  platform,
89
+ ownershipScope,
78
90
  appRoot,
79
91
  repoRoot,
80
92
  app: config.app,
@@ -83,8 +95,11 @@ function buildPreflight({ config, configPath, appRoot, repoRoot, platform, nativ
83
95
  // collision is already enforced downstream, and duplicating it here would
84
96
  // reject a legitimate resume of an in-flight build.
85
97
  version: undefined,
86
- cargoLockPaths: nativeLayout?.lockCandidates ?? [],
98
+ cargoLockPaths: [...(nativeLayout?.lockCandidates ?? []), ...pinnedCargoLocks],
87
99
  projectType: resolveProjectType({ appRoot, config, target }),
100
+ // A native iOS app has no broker target or bridge; it still gets the Rust budget and lock checks when it pins a Cargo.lock.
101
+ rust: Boolean(nativeLayout) || pinnedCargoLocks.length > 0,
102
+ bridge: Boolean(nativeLayout),
88
103
  msix: targetUsesMsix(target),
89
104
  });
90
105
  }
@@ -109,7 +124,7 @@ for (let i = 0; i < args.length; i += 1) {
109
124
  else if (arg === "-h" || arg === "--help") usage(0);
110
125
  else fail(`unknown argument: ${arg}`);
111
126
  }
112
- if (platform !== "win" && platform !== "mac" && platform !== "android") fail("--platform must be win, mac or android");
127
+ if (platform !== "win" && platform !== "mac" && platform !== "android" && platform !== "ios") fail("--platform must be win, mac, android or ios");
113
128
  if (resumeFrom && !["prepackage", "sign", "package", "post-sign", "harden"].includes(resumeFrom)) fail("--resume-from must be prepackage, sign, package, post-sign, or harden");
114
129
 
115
130
  const configPath = path.resolve(configName);
@@ -141,8 +156,8 @@ if (dryRun) {
141
156
  const config = (await import(`${pathToFileURL(configPath).href}?dry-run=${commit}`)).default;
142
157
  if (!config?.app || !config?.version) fail("release config must expose app and version");
143
158
  const target = config.targets?.[platform];
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`);
159
+ // Android builds with target.gradle (validated by release.mjs), not a package command; iOS with targets.ios.iosPackage.
160
+ if (platform !== "android" && platform !== "ios" && !target?.package && !packageCommandOptional(target, resolveProjectType({ appRoot, config, target }))) fail(`${config.app} has no ${platform} package command`);
146
161
  console.log(`right-release build dry-run: ${config.app} ${config.version} ${platform}`);
147
162
  console.log(`config: ${configPath}`);
148
163
  console.log(`commit: ${commit}`);
@@ -166,7 +181,7 @@ try {
166
181
  // turn an already-built, signed installer into a needless full rebuild.
167
182
  if (!resumeFrom) {
168
183
  assertCleanSource({
169
- status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]),
184
+ status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { trim: false }), // untrimmed: a leading space is the first record's X column (#782)
170
185
  commandId: "right-release build",
171
186
  });
172
187
  }
@@ -181,24 +196,41 @@ try {
181
196
  fail("app and version must be safe release-id components");
182
197
  }
183
198
  const target = config.targets?.[platform];
184
- if (platform !== "android" && !target?.package && !packageCommandOptional(target, resolveProjectType({ appRoot, config, target }))) fail(`${config.app} has no ${platform} package command`);
199
+ if (platform !== "android" && platform !== "ios" && !target?.package && !packageCommandOptional(target, resolveProjectType({ appRoot, config, target }))) fail(`${config.app} has no ${platform} package command`);
200
+ // iOS: the same strict validation release.mjs applies; yields the IPA path and the team and bundle id the seal pins.
201
+ let iosSpec = null;
202
+ if (platform === "ios") {
203
+ try { iosSpec = expandIosPackageTarget(target, { appRoot }).recipe?.spec ?? null; } catch (error) { fail(`${config.app} ios ${error.message}`); }
204
+ if (!iosSpec) fail(`${config.app} ios must declare targets.ios.iosPackage`);
205
+ }
185
206
  // Same validation release.mjs applies; yields the normalised upload-certificate pin and the AAB path.
186
207
  let androidTarget = null;
187
208
  if (platform === "android") {
188
209
  try { androidTarget = validateAndroidTarget(target, { root: appRoot }); } catch (error) { fail(`${config.app} android ${error.message}`); }
189
210
  }
190
211
  const isSwiftBuild = resolveProjectType({ appRoot, config, target }) === "swiftpm";
191
- const nativeLayout = resolveNativeCargoLayout({ appRoot, config });
212
+ // A native iOS app (xcodegen project; no Rust library unless nativeAssembly.cargoManifest declares one) has no src-tauri and no
213
+ // declared nativeAssembly manifest: it owns no Cargo layout (null), and every Cargo-derived value below is skipped for it.
214
+ // The Xcode tools are asserted present, then probed, inside resolveIosBuildContext (a missing tool fails with the normal message).
215
+ const iosContext = iosSpec !== null
216
+ ? resolveIosBuildContext({ appRoot, config, target, iosSpec, exec: (cmd, runArgs) => commandOutputAt(cmd, runArgs, appRoot), assertExecutables })
217
+ : null;
218
+ const nativeIos = iosContext?.nativeIos ?? false;
219
+ const nativeLayout = iosContext ? iosContext.nativeLayout : resolveNativeCargoLayout({ appRoot, config });
192
220
  if (platform === "win" && target.signingContract === "windows-raw-exe-authenticode-before-nsis-v1") {
193
221
  assertNsisInPlaceUpgradeContract(appRoot, target.nsisUpgradeContract);
194
222
  }
195
- const { requiredInputs, buildInputs } = resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, config, target });
223
+ const { requiredInputs, buildInputs, cargoLockPaths: pinnedCargoLocks } = resolveReleaseBuildInputs({ repoRoot, appRoot, configPath, config, target });
196
224
  const dirtyInputs = dirtyBuildInputs(repoRoot, buildInputs, commit);
197
225
  if (dirtyInputs.length > 0) fail(`dirty files can change the packaged app; resolve them before release:\n${dirtyInputs.map((file) => `- ${file}`).join("\n")}`);
198
- const toolVersions = isSwiftBuild ? collectSwiftToolVersions({ packageManager: config.packageManager, exec: commandOutput }) : collectToolVersions(config.packageManager ?? "pnpm");
226
+ const hasPackageJson = existsSync(path.join(appRoot, "package.json"));
227
+ // iOS records the Xcode toolchain (and xcodegen when a project.yml is named) beside the Node and Rust tools.
228
+ const toolVersions = isSwiftBuild ? collectSwiftToolVersions({ packageManager: config.packageManager, exec: commandOutput })
229
+ : iosContext ? iosContext.toolVersions
230
+ : collectToolVersions(config.packageManager ?? "pnpm", appRoot);
199
231
  const inputHashes = hashInputs(requiredInputs);
200
232
  const releaseId = `${config.app}-${config.version}-${shortCommit}`;
201
- const platformDir = platform === "win" ? "windows" : platform === "android" ? "android" : "mac";
233
+ const platformDir = platform === "win" ? "windows" : platform === "android" ? "android" : platform === "ios" ? "ios" : "mac";
202
234
  const stateRoot = path.join(vaultRoot, "state", releaseId, platformDir);
203
235
  const signingIdentity = platform === "win" ? {
204
236
  contract: target.signingContract ?? "<missing>",
@@ -230,17 +262,27 @@ try {
230
262
  uploadCertSha256: androidTarget.signing.expectedCertSha256,
231
263
  // release.mjs writes the AAB receipt into RIGHT_RELEASE_STATE_ROOT (= stateRoot) as android-aab.json.
232
264
  receiptInputs: [path.resolve(stateRoot, ANDROID_RECEIPT_NAME)],
265
+ } : platform === "ios" ? {
266
+ contract: "ios-xcodebuild-archive-automatic-v1",
267
+ pipeline: IOS_PIPELINE_FINGERPRINT,
268
+ configSha256: hashFile(configPath),
269
+ // The team and bundle id the IPA receipt must carry; the upload re-checks both from the sealed manifest.
270
+ teamId: iosSpec.teamId,
271
+ bundleId: iosSpec.bundleId,
272
+ // release.mjs writes the IPA receipt into RIGHT_RELEASE_STATE_ROOT (= stateRoot) as ios-archive.json.
273
+ receiptInputs: [path.resolve(stateRoot, IOS_RECEIPT_NAME)],
233
274
  } : null;
234
275
  if (signingIdentity) inputHashes[".right-release/signing-identity.json"] = hashFileText(JSON.stringify(signingIdentity));
235
- const cargoLock = nativeLayout.lockPath;
236
- const cargoToml = nativeLayout.manifestPath;
276
+ const cargoLock = nativeLayout?.lockPath;
277
+ const cargoToml = nativeLayout?.manifestPath;
237
278
  // A broker-managed host is not identified by RIGHTKIT_BUILD_BROKER_SOCKET alone:
238
279
  // that variable is exported by login shells only, while the managed cargo shim is
239
280
  // on PATH for every shell. Detecting on the variable alone made agent (non-login)
240
281
  // shells take the Cache V2 branch, bridge resolved native target into release cache,
241
282
  // and then lose CARGO_TARGET_DIR to the broker's unconditional override — so the
242
283
  // app bundle landed in the broker workspace and packaging failed on an empty cache.
243
- const swiftPlan = isSwiftBuild ? planSwiftBuild({ appRoot, vaultRoot, platform, target, toolVersions }) : null;
284
+ // swiftPlan is the "no Rust host" plan for SwiftPM and for a Cargo-less native iOS app alike: same shape, same skips.
285
+ const swiftPlan = isSwiftBuild ? planSwiftBuild({ appRoot, vaultRoot, platform, target, toolVersions }) : nativeIos ? planIosNativeBuild({ appRoot, vaultRoot, platform, toolVersions }) : null;
244
286
  // SwiftPM targets have no Cargo manifest: no broker cargo target, no Rust cache identity.
245
287
  const managedCargoTarget = swiftPlan ? swiftPlan.managedCargoTarget : brokerManagedHost(process.env) ? resolveTargetRoot(cargoToml) : null;
246
288
  const cacheIdentity = swiftPlan ? swiftPlan.identity : resolveSharedCacheIdentity({ cargoLockPath: cargoLock, cargoTomlPath: cargoToml, rustcVerbose: toolVersions.rustc, targetTriple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? toolVersions.rustHost, profile: target.profile ?? "release" });
@@ -267,7 +309,7 @@ try {
267
309
  // Preflight BEFORE any compile. These preconditions used to be discovered one
268
310
  // per build cycle, minutes deep, with errors that named a symptom instead of a
269
311
  // cause. Blocking problems stop here, in seconds, naming the fix.
270
- const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform, nativeLayout, target });
312
+ const preflight = buildPreflight({ config, configPath, appRoot, repoRoot: layout.repoRoot, platform, nativeLayout, target, pinnedCargoLocks });
271
313
  const preflightBlockers = preflightFailures(preflight);
272
314
  if (preflightBlockers.length > 0) {
273
315
  fail(`preflight found ${preflightBlockers.length} blocking problem(s):\n${formatPreflight(preflightBlockers)}`);
@@ -323,6 +365,7 @@ try {
323
365
  env.RIGHT_RELEASE_ARTIFACT_DIR = managedArtifactDir;
324
366
  const buildRoot = managedCargoTarget ? null : path.join(vaultRoot, "build", config.app, config.version, shortCommit, platformDir);
325
367
  env.RIGHT_RELEASE_STATE_ROOT = stateRoot;
368
+ env.RIGHT_RELEASE_VAULT_STATE_ROOT = path.join(vaultRoot, "state");
326
369
  const lockPayload = {
327
370
  schema: 1,
328
371
  releaseId,
@@ -336,7 +379,7 @@ try {
336
379
  tools: toolVersions,
337
380
  createdAt: new Date().toISOString(),
338
381
  };
339
- const targetLink = nativeLayout.targetLink;
382
+ const targetLink = nativeLayout?.targetLink;
340
383
  // ownedRoot is the per-app cache parent holding every fingerprint dir, so a
341
384
  // link left by an earlier fingerprint is recognised as ours and repointed
342
385
  // instead of hard-stopping the build.
@@ -367,7 +410,7 @@ try {
367
410
  const volumes = inspectWriteVolumes({ repoRoot, vaultRoot, cacheRoot: sharedCacheRoot });
368
411
  assertWriteVolumeFloors({ volumes, policy, configuredRepoMinimumBytes: Math.max(Number(target.preflight?.minFreeGb ?? 25), 25) * 1024 ** 3 });
369
412
  } else assertDiskSpace(repoRoot, target.preflight?.minFreeGb ?? 20);
370
- assertExecutables([...requiredBuildExecutables({ projectType: resolveProjectType({ appRoot, config, target }), packageManager: config.packageManager ?? "pnpm", usesPackageManager: Boolean(config.packageManager) || existsSync(path.join(appRoot, "package.json")) }), ...(target.preflight?.executables ?? [])]);
413
+ assertExecutables([...requiredBuildExecutables({ projectType: resolveProjectType({ appRoot, config, target }), packageManager: config.packageManager ?? "pnpm", usesPackageManager: nativeIos ? hasPackageJson : Boolean(config.packageManager) || hasPackageJson, usesCargo: !nativeIos }), ...(iosContext?.prepareExecutables ?? []), ...(target.preflight?.executables ?? [])]);
371
414
  for (const name of target.preflight?.env ?? []) if (!process.env[name]) fail(`missing required environment variable: ${name}`);
372
415
  for (const command of target.preflight?.commands ?? []) runChecked(command.cmd, command.args ?? [], path.resolve(appRoot, command.cwd ?? "."), env);
373
416
  if (buildRoot) mkdirSync(buildRoot, { recursive: true });
@@ -394,7 +437,8 @@ try {
394
437
  if (linked.action === "refused") fail(linked.reason);
395
438
  if (linked.action === "moved-aside") console.error(`[right-release] hosted CI: moved existing ${targetLink} aside to ${linked.aside}`);
396
439
  }
397
- if (resolveProjectType({ appRoot, config, target }) !== "swiftpm" || config.packageManager) await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget, path.join(stateRoot, "stall-install.log"));
440
+ // A Cargo-less native iOS app installs Node dependencies only when it has a package.json.
441
+ if (nativeIos ? hasPackageJson : resolveProjectType({ appRoot, config, target }) !== "swiftpm" || config.packageManager) await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget, path.join(stateRoot, "stall-install.log"));
398
442
  },
399
443
  build: async () => {
400
444
  throwIfInterrupted();
@@ -409,7 +453,10 @@ try {
409
453
  );
410
454
  checkpoint(stateRoot, "build_complete");
411
455
  checkpoint(stateRoot, "signed");
412
- checkpoint(stateRoot, "hardened");
456
+ // Truthful checkpoint: the worker (release.mjs) runs the hardening scan only when it has scan
457
+ // targets and fails the whole build when the scan fails, so reaching here with targets means
458
+ // the scan passed. With no targets the worker ran no scan — never claim "hardened".
459
+ if (hardeningScanTargets(target).length > 0) checkpoint(stateRoot, "hardened");
413
460
  },
414
461
  // Product-specific checks that must run after the signed/hardened bytes
415
462
  // exist but before they become immutable. This is intentionally a small
@@ -427,7 +474,7 @@ try {
427
474
  });
428
475
  },
429
476
  seal: async ({ sealedDir }) => {
430
- sealRelease({ configRoot: appRoot, managedCargoTarget, androidTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot });
477
+ sealRelease({ configRoot: appRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot });
431
478
  verifySealedRelease(sealedDir);
432
479
  if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
433
480
  checkpoint(stateRoot, "sealed");
@@ -449,7 +496,14 @@ try {
449
496
  }
450
497
 
451
498
  function hashInputs(files) {
452
- return Object.fromEntries(files.map((file) => [path.relative(path.dirname(path.dirname(file)), file), hashFile(file)]));
499
+ const hashes = {};
500
+ for (const file of files) {
501
+ const key = path.relative(path.dirname(path.dirname(file)), file);
502
+ // Two inputs (an app file and a shared crate's file, say) must never share a key: one would silently replace the other.
503
+ if (Object.hasOwn(hashes, key)) fail(`release inputs share the hash key ${key}; rename one so every input is sealed`);
504
+ hashes[key] = hashFile(file);
505
+ }
506
+ return hashes;
453
507
  }
454
508
 
455
509
  function hashFile(file) {
@@ -467,7 +521,7 @@ function sqlCipherFeatures(text) {
467
521
  .filter((value) => /sqlcipher|openssl/i.test(value));
468
522
  }
469
523
 
470
- function sealRelease({ configRoot, managedCargoTarget, androidTarget, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot }) {
524
+ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot }) {
471
525
  // Every app's right-release.config.mjs names installer/updater artifacts
472
526
  // relative to its resolved native target. On a broker-managed host that
473
527
  // directory is never populated (the broker owns CARGO_TARGET_DIR and
@@ -476,6 +530,7 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, nativeLayo
476
530
  // own target root instead, or every managed build fails at seal after a
477
531
  // full compile, sign, and notarize.
478
532
  const targetPrefix = "src-tauri/target/";
533
+ // A Cargo-less iOS app has no native layout and no managed cargo target: only the default prefix is ever compared.
479
534
  const effectiveTargetPrefix = nativeLayout?.targetPrefix ?? targetPrefix;
480
535
  const resolveArtifactPath = (file) => {
481
536
  const normalized = file.replaceAll(path.win32.sep, "/");
@@ -507,14 +562,28 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, nativeLayo
507
562
  // updater block: `target.artifacts` entries (paths or { file }) seal with the
508
563
  // "portable" role. On android the declared AAB seals with the "aab" role instead.
509
564
  const aabSource = platform === "android" ? resolveArtifactPath(androidTarget.aab.file) : null;
565
+ const ipaSource = platform === "ios" ? resolveArtifactPath(iosSpec.ipa) : null;
510
566
  for (const artifact of target.artifacts ?? []) {
511
567
  const file = typeof artifact === "string" ? artifact : artifact?.file;
512
568
  if (typeof file !== "string" || !file) fail("target.artifacts entries must be paths or { file }");
513
569
  const source = resolveArtifactPath(file);
514
- addSource(source, source === aabSource ? "aab" : "portable");
570
+ addSource(source, source === aabSource ? "aab" : source === ipaSource ? "ipa" : "portable");
515
571
  }
516
572
  if (sources.size === 0) fail(`nothing to seal: declare installer.artifacts, updater.artifacts or artifacts for the ${platform} target`);
517
573
  for (const file of sources.keys()) if (!existsSync(file)) fail(`cannot seal missing artifact: ${file}`);
574
+ // Every minisign updater requires an explicit trust root & full-byte signature
575
+ // verification before any seal directory or artifact copy is created.
576
+ const updaterPublicKey = config.distribution?.updaterPublicKey ?? config.distribution?.updaterPubkey ?? target?.updater?.pubkey ?? target?.updater?.publicKey;
577
+ if ((target.updater?.artifacts?.length ?? 0) > 0 && target.updater?.kind !== "appinstaller") {
578
+ if (typeof updaterPublicKey !== "string" || !updaterPublicKey.trim()) fail("updater public key is required to verify updater artifacts before sealing");
579
+ for (const artifact of target.updater?.artifacts ?? []) {
580
+ const source = resolveArtifactPath(artifact.file);
581
+ const signature = readFileSync(resolveArtifactPath(artifact.signature), "utf8").trim();
582
+ if (!signature) fail(`updater signature is empty, refusing to seal: ${artifact.signature}`);
583
+ try { assertUpdaterSignature(readFileSync(source), signature, updaterPublicKey, `signature of ${path.basename(artifact.file)}`); }
584
+ catch (error) { fail(`updater signature rejected at seal: ${error.message}`); }
585
+ }
586
+ }
518
587
  // Android: the build receipt must describe exactly the AAB being sealed (bytes re-hashed) and its signer must be the pinned
519
588
  // upload certificate. Checked before anything is copied, so a mismatch leaves no partial seal.
520
589
  let androidReceipt = null;
@@ -525,6 +594,16 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, nativeLayo
525
594
  try { androidReceipt = verifyAndroidReceipt(receiptPath, aabSource); } catch (error) { fail(`android AAB receipt rejected: ${error.message}`); }
526
595
  if (androidReceipt.signer?.certSha256 !== signingIdentity.uploadCertSha256) fail("android AAB receipt signer certificate is not the pinned upload certificate");
527
596
  }
597
+ // iOS: the receipt must describe exactly the IPA being sealed (bytes re-hashed), for the pinned team and bundle id.
598
+ let iosReceipt = null;
599
+ if (platform === "ios") {
600
+ if (!sources.has(ipaSource)) fail("ios seal requires targets.ios.artifacts to list iosPackage.ipa");
601
+ const [receiptPath] = signingIdentity.receiptInputs;
602
+ if (!existsSync(receiptPath)) fail(`no signing receipts found to seal; expected at least ${receiptPath}`);
603
+ try { iosReceipt = verifyIosReceipt(receiptPath, ipaSource); } catch (error) { fail(`ios IPA receipt rejected: ${error.message}`); }
604
+ if (iosReceipt.teamId !== signingIdentity.teamId || iosReceipt.bundleId !== signingIdentity.bundleId) fail("ios IPA receipt team or bundle id is not the one pinned by the release config");
605
+ if (iosReceipt.sourceCommit !== commit) fail(`ios IPA receipt was built from ${String(iosReceipt.sourceCommit)}, not the release commit ${commit}`);
606
+ }
528
607
  // rightkit-ffmpeg decode sidecar: release.mjs signed the binaries with the app and wrote media-sidecar.json. The seal
529
608
  // refuses a release without it, re-checks its shape against the configuration, and (Windows) that every sidecar's
530
609
  // signed digest is one the sealed raw-EXE signing receipt recorded. The hashes then live in the sealed manifest.
@@ -557,6 +636,8 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, nativeLayo
557
636
  copyFileSync(source, path.join(temp, name));
558
637
  files.push(androidReceipt && source === aabSource && name === androidReceipt.file.name
559
638
  ? androidSealEntry(androidReceipt)
639
+ : iosReceipt && source === ipaSource && name === iosReceipt.file.name
640
+ ? iosSealEntry(iosReceipt)
560
641
  : { role: [...roles].sort().join("+"), name, sha256: hashFile(source), sizeBytes: statSync(source).size });
561
642
  artifactInputs.push({ source, name });
562
643
  }
@@ -575,7 +656,12 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, nativeLayo
575
656
  routes.patch.push({ role: "updater", name: sourceNames.get(source), signature: signatureSource ? sourceNames.get(signatureSource) : undefined, bucket: "public", key: patchKey, platform: artifact.platform });
576
657
  routes.update.push({ role: "updater", name: sourceNames.get(source), signature: signatureSource ? sourceNames.get(signatureSource) : undefined, bucket: "private", key: routeKey, platform: artifact.platform });
577
658
  }
578
- writeJson(path.join(temp, "hardening.json"), { schema: 1, passed: true, checkedAt: new Date().toISOString(), files: files.map((file) => file.sha256) });
659
+ // Derived from the worker's actual scan, never asserted unconditionally: release.mjs scans exactly
660
+ // `target.hardening ?? target.artifacts` and fails the build on scan failure, so a completed build with
661
+ // targets passed; with no targets no scan ran and the status is "skipped", not passed.
662
+ const hardeningTargets = hardeningScanTargets(target);
663
+ const hardeningScanned = hardeningTargets.length > 0;
664
+ writeJson(path.join(temp, "hardening.json"), { schema: 1, status: hardeningScanned ? "passed" : "skipped", passed: hardeningScanned, checkedAt: new Date().toISOString(), targets: hardeningTargets.length, files: hardeningScanned ? files.map((file) => file.sha256) : [] });
579
665
  const manifest = {
580
666
  schema: 1,
581
667
  releaseId,
@@ -606,7 +692,7 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, nativeLayo
606
692
  routes,
607
693
  inputs: inputHashes,
608
694
  tools: toolVersions,
609
- checkpoints: ["preflight_complete", "build_complete", "signed", "hardened", "sealed"],
695
+ checkpoints: ["preflight_complete", "build_complete", "signed", ...(hardeningScanned ? ["hardened"] : []), "sealed"],
610
696
  sealedAt: new Date().toISOString(),
611
697
  };
612
698
  writeJson(path.join(temp, "release-manifest.json"), manifest);
@@ -627,21 +713,26 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, nativeLayo
627
713
  else if (retention.removed.length) console.log(`[right-release] artifact retention removed ${retention.removed.join(", ")}`);
628
714
  }
629
715
 
630
- function collectToolVersions(packageManager) {
631
- const probeDir = mkdtempSync(path.join(tmpdir(), "right-release-tool-probe-"));
632
- try {
633
- const rustcVerbose = commandOutputAt("rustc", ["-vV"], probeDir);
634
- return {
635
- node: process.version,
636
- packageManager: `${packageManager} ${commandOutput(packageManager, ["--version"])}`,
637
- cargo: commandOutputAt("cargo", ["--version"], probeDir),
638
- rustc: rustcVerbose,
639
- rustHost: rustcVerbose.match(/^host:\s*(.+)$/m)?.[1] ?? "unknown",
640
- sccache: commandExists("sccache") ? commandOutput("sccache", ["--version"]) : null,
641
- };
642
- } finally {
643
- rmSync(probeDir, { recursive: true, force: true });
644
- }
716
+ /** The exact scan-target expression the worker uses (release.mjs: `target.hardening ?? target.artifacts ?? []`).
717
+ * Recipe expansion in the worker preserves both fields (`...rest`), so the raw config target gives the same list. */
718
+ function hardeningScanTargets(target) {
719
+ return target?.hardening ?? target?.artifacts ?? [];
720
+ }
721
+
722
+ // Probes run with cwd = the app root (bogusyogi/rightkit#783): on an agent host `cargo` is the
723
+ // RustRight shim, which refuses any cwd outside a primary checkout, so a $TMPDIR probe dir failed
724
+ // with RK_NOT_PRIMARY_CHECKOUT before the first build step. The app root also makes rustc report
725
+ // the toolchain the app's rust-toolchain.toml selects, which is the one the build uses.
726
+ function collectToolVersions(packageManager, probeDir = process.cwd()) {
727
+ const rustcVerbose = commandOutputAt("rustc", ["-vV"], probeDir);
728
+ return {
729
+ node: process.version,
730
+ packageManager: `${packageManager} ${commandOutput(packageManager, ["--version"])}`,
731
+ cargo: commandOutputAt("cargo", ["--version"], probeDir),
732
+ rustc: rustcVerbose,
733
+ rustHost: rustcVerbose.match(/^host:\s*(.+)$/m)?.[1] ?? "unknown",
734
+ sccache: commandExists("sccache") ? commandOutput("sccache", ["--version"]) : null,
735
+ };
645
736
  }
646
737
 
647
738
  async function runProgress(cmd, runArgs, cwd, env, watchDir, diagnosticPath = null, { livenessOwnerTimeoutMs = null } = {}) {
@@ -801,14 +892,14 @@ function commandOutput(cmd, runArgs) {
801
892
  }
802
893
  }
803
894
 
804
- function git(cwd, runArgs) {
805
- return commandOutputAt("git", runArgs, cwd);
895
+ function git(cwd, runArgs, { trim = true } = {}) {
896
+ return commandOutputAt("git", runArgs, cwd, { trim });
806
897
  }
807
898
 
808
- function commandOutputAt(cmd, runArgs, cwd) {
899
+ function commandOutputAt(cmd, runArgs, cwd, { trim = true } = {}) {
809
900
  const result = spawnSync(cmd, runArgs, { cwd, encoding: "utf8", windowsHide: true });
810
901
  if (result.status !== 0) fail(`${cmd} ${runArgs.join(" ")} failed: ${(result.stderr ?? "").trim()}`);
811
- return result.stdout.trim();
902
+ return trim ? result.stdout.trim() : result.stdout;
812
903
  }
813
904
 
814
905
  function runChecked(cmd, runArgs, cwd, env = process.env) {
@@ -843,7 +934,7 @@ function writeJson(file, value) {
843
934
  }
844
935
 
845
936
  function usage(code) {
846
- console.log("usage: right-release build --platform win|mac|android [--config right-release.config.mjs] [--dry-run]");
937
+ console.log("usage: right-release build --platform win|mac|android|ios [--config right-release.config.mjs] [--dry-run]");
847
938
  process.exit(code);
848
939
  }
849
940
 
@@ -46,6 +46,7 @@ const commands = new Map([
46
46
  ["dist-scan", "dist-scan.mjs"],
47
47
  ["identity-scan", "packaged-app-identity.mjs"],
48
48
  ["apple-profile-create", "apple-profile-create.mjs"],
49
+ ["ios-credentials", "ios-credentials-command.mjs"],
49
50
  ]);
50
51
 
51
52
  const args = process.argv.slice(2);
@@ -236,6 +237,9 @@ Commands:
236
237
  Assert codesign Identifier/Team/cdhash/lipo archs; reject ad-hoc
237
238
  upload --release <id> --platform mac|win --tier patch|update
238
239
  Upload one sealed release; never builds
240
+ upload --platform ios --release <id> [--apply] Sealed IPA to TestFlight, then the exact build into the beta groups; dry run unless --apply (macOS)
241
+ ios-credentials store --key-id <KEYID> --issuer <uuid> | check
242
+ Put the App Store Connect API key (stdin), key id and issuer into the login Keychain once, or check them; never a file (macOS)
239
243
  qa --stage source|installed --platform mac|win [--release <id>] Run rightkit-qa and write the sealed QA receipt (a missing receipt warns and is recorded; it never blocks)
240
244
  qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>] List source/installed QA status per release; exit 1 if an uploaded build lacks passing QA
241
245
  publish-update --release <id> --platform mac|win --tier patch|update
@@ -249,8 +253,13 @@ Commands:
249
253
  (npm-rightkit-publish) and verify registry bytes
250
254
  publish cargo --crate <name> [--dry-run] Test, inspect, scan, and publish one crates.io package
251
255
  publish npm --package @rightkit/<name> --local Mac-only local publish under the committed local-build grant
252
- publish cargo --crate <name> --local (.rightkit-local-build.json): runs the unit's CI tests, packs, writes darwin-only
253
- evidence, then the same publication gate and publisher; refuses without the grant
256
+ publish cargo --crate <name> --local (.rightkit-local-build.json), on the Mac or Windows: runs the unit's CI tests, packs,
257
+ writes darwin-only (Mac) or win32-only (Windows) evidence, then the same
258
+ publication gate and publisher; refuses without the grant
259
+ publish cargo --crate <name> --local --qualify-only --bundle <new-dir> [--jobs <1-64>]
260
+ Verify on source host; export crate bytes & gate evidence without uploading
261
+ publish cargo --crate <name> --from-evidence <gate.json>
262
+ Reuse exact HEAD/artifact proof on upload host; recheck archive hash & registry bytes
254
263
  publish swift [--dry-run] [--evidence <file>] [--workspace <dir>]
255
264
  Check main, a clean tree, and the publication evidence, then tag RightKitSwift at its VERSION on origin
256
265
  inventory check [--json] [--warn-only] [--md <file>] [--no-cache]
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { detectMacSigningSession } from "./macos-signing-session.mjs";
6
6
  import { ensurePrivateDirectory, privatePath } from "./rightkit-signing-agent.mjs";
7
- import { resolveManagedRoot } from "./artifact-storage.mjs";
7
+ import { canonicalManagedRoot, resolveManagedRoot } from "./artifact-storage.mjs";
8
8
 
9
9
  const quote = (text) => `'${String(text).replaceAll("'", "'\\''")}'`;
10
10
  export function codesignShimScript({ route, node = process.execPath,
@@ -35,7 +35,7 @@ fi
35
35
  exec /bin/bash "$@"\n`;
36
36
  // Immutable wrappers avoid races between GUI & SSH builds or package versions.
37
37
  const identity = createHash("sha256").update(script).update(automationScript).update(bashScript).digest("hex").slice(0, 24);
38
- const bin = path.join(resolveManagedRoot({ env }), "signing", "codesign-bin", identity);
38
+ const bin = path.join(canonicalManagedRoot(resolveManagedRoot({ env })), "signing", "codesign-bin", identity);
39
39
  ensurePrivateDirectory(bin, { io });
40
40
  const file = path.join(bin, "codesign");
41
41
  if (io.existsSync(file)) {