@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/stage-sidecar.mjs CHANGED
@@ -11,8 +11,8 @@
11
11
  // { kind: "resource", from, to, sha256?, machO?, clean?, platforms? }, // `from` may be a directory (copied recursively, every file hashed)
12
12
  // { kind: "command", name, cmd, args?, cwd?, output, externalBin: true | (false with dest), dest?, clean?, platforms? },
13
13
  // { kind: "remote", name, url (https), sha256, dest | to, size?, extract?, memberSha256?, executable?, clean?, platforms? },
14
- // { kind: "cmake", name, repo (https), commit (40 hex), target?, output?, cmakeArgs?, platformArgs?, generator?, smoke?, sha256?, dest?, clean?, platforms? },
15
- // { kind: "prebuilt", name, from?, manifest: { file, entry, sha256?, approvalSha256?, approvalAlgorithm? }, dest?, platforms? },
14
+ // { kind: "cmake", name, repo (https), commit (40 hex), target?, output?, cmakeArgs?, platformArgs?, generator?, platformGenerators?, architecture?, cmakeBin?, smoke?, sha256?, dest?, clean?, platforms? },
15
+ // { kind: "prebuilt", name, from? (a path, or { aarch64, x86_64 } for universal-apple-darwin), manifest: { file, entry, sha256?, approvalSha256?, approvalAlgorithm? }, dest?, platforms? },
16
16
  // ]
17
17
  //
18
18
  // `dest` (cargo-bin, command, remote) is a contained app-relative path (never the app root, the stage dir, the receipt, the cache, .git, src, src-tauri, node_modules or package.json; outputs may not overlap) used instead of <stageDir>/<name>-<triple>; the file is
@@ -24,7 +24,9 @@
24
24
  // profile pair; RIGHTKIT_BUILD_MODE (dev|release, default release) selects. The target `universal-apple-darwin` builds
25
25
  // both slices of a cargo-bin and lipo-creates `<name>-universal-apple-darwin`. `ffmpeg` and `onnxruntime` do the same from their
26
26
  // per-arch catalog rows (each slice staged under .right-release/slices/<triple>, then lipo-merged), a `cmake` entry builds with
27
- // CMAKE_OSX_ARCHITECTURES="arm64;x86_64".
27
+ // CMAKE_OSX_ARCHITECTURES="arm64;x86_64". A `prebuilt` entry does it from per-arch host-built inputs (`from: { aarch64, x86_64 }`, or the
28
+ // staged `<name>-<slice triple>` files), each verified against its own manifest entry and then lipo-merged. An `ffmpeg` source may be a
29
+ // local path (`sources.ffmpeg: { path: "../vendor/ffmpeg-lgpl" }`) instead of a pinned https archive.
28
30
  //
29
31
  // It never signs (release.mjs signs staged files) and never calls bare cargo. Every effect goes through `deps`.
30
32
  import { createHash } from "node:crypto";
@@ -38,6 +40,7 @@ import { APPROVAL_MACHO_STRIP_LINKEDIT_V1, APPROVAL_RAW_SHA256, approvalDigest }
38
40
  import {
39
41
  planMediaSidecar, sourceOfferText, stagedFileName, validateMediaSidecarConfig, verifyStagedSidecars, writeMediaSidecarNotices,
40
42
  } from "./media-sidecar.mjs";
43
+ import { ContentDigestError, contentIdentity, digestFor } from "./content-digest.mjs";
41
44
 
42
45
  export const STAGE_SIDECAR_CONTRACT = "rightkit-stage-sidecar-v1";
43
46
  export const STAGE_SIDECAR_RECEIPT_NAME = "stage-sidecar.json";
@@ -77,7 +80,7 @@ const KEYS = Object.freeze({
77
80
  resource: ["kind", "from", "to", "sha256", "machO", "clean", "platforms"],
78
81
  command: ["kind", "name", "cmd", "args", "cwd", "output", "externalBin", "dest", "clean", "platforms"],
79
82
  remote: ["kind", "name", "url", "sha256", "dest", "to", "size", "extract", "memberSha256", "executable", "clean", "platforms"],
80
- cmake: ["kind", "name", "repo", "commit", "target", "output", "cmakeArgs", "platformArgs", "generator", "smoke", "sha256", "dest", "clean", "platforms"],
83
+ cmake: ["kind", "name", "repo", "commit", "target", "output", "cmakeArgs", "platformArgs", "generator", "platformGenerators", "architecture", "cmakeBin", "smoke", "sha256", "dest", "clean", "platforms"],
81
84
  prebuilt: ["kind", "name", "from", "manifest", "dest", "platforms"],
82
85
  });
83
86
  const TRIPLES = Object.freeze({
@@ -190,6 +193,8 @@ function parseCatalogSpec(value, label, errors, kind) {
190
193
  return undefined;
191
194
  }
192
195
 
196
+ const CMAKE_GENERATOR = /^[A-Za-z0-9][A-Za-z0-9 .+-]{0,60}$/;
197
+ const CMAKE_ARCHITECTURE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
193
198
  const CMAKE_DEFINE = /^-D[A-Za-z0-9_]+(?::[A-Z]+)?=[^\s]*$/;
194
199
  const SOURCE_ARCHES = ["aarch64", "x86_64"];
195
200
 
@@ -205,8 +210,25 @@ function parseArchiveSource(value, label, errors) {
205
210
  }
206
211
 
207
212
  /**
208
- * `ffmpeg.sources`: where to download the bytes of a catalog row (the catalog pins digests, not URLs). A tool maps to one
209
- * archive source, or to `{ aarch64: source, x86_64: source }`; a universal target needs the per-arch form for every tool.
213
+ * A local ffmpeg source: `{ path, member? }`. `path` is relative to the app root and MAY step out of it (a vendored build such as
214
+ * ../vendor/ffmpeg-lgpl); it names the binary itself, or a directory holding `<member>` (or `bin/<member>`). Nothing is downloaded and no
215
+ * archive digest exists: the file's digest must still equal the APPROVED catalog row, exactly like a fetched member.
216
+ */
217
+ function parseLocalSource(value, label, errors) {
218
+ for (const key of Object.keys(value)) if (!["path", "member"].includes(key)) errors.push(`${label}: unknown key ${JSON.stringify(key)} for a local { path, member? } source`);
219
+ const raw = value.path;
220
+ const normalized = typeof raw === "string" ? raw.replaceAll("\\", "/") : "";
221
+ if (typeof raw !== "string" || !raw.trim() || path.isAbsolute(raw) || /^[A-Za-z]:/.test(raw) || /^[a-z][a-z0-9+.-]*:\/\//i.test(normalized)) { errors.push(`${label}.path must be a relative path (it may start with ../); a URL belongs in { url, sha256, member }`); return undefined; }
222
+ if (!normalized.split("/").some((segment) => segment !== "" && segment !== "." && segment !== "..")) { errors.push(`${label}.path must name a file or directory, not the app root or a bare parent: ${raw}`); return undefined; }
223
+ if (value.member !== undefined && (typeof value.member !== "string" || !NAME.test(value.member))) errors.push(`${label}.member must be the bare file name of the binary inside the directory`);
224
+ return { path: normalized.replace(/\/+$/, ""), ...(typeof value.member === "string" ? { member: value.member } : {}) };
225
+ }
226
+
227
+ const parseFfmpegSource = (value, label, errors) => (isObject(value) && "path" in value ? parseLocalSource(value, label, errors) : parseArchiveSource(value, label, errors));
228
+
229
+ /**
230
+ * `ffmpeg.sources`: where the bytes of a catalog row come from (the catalog pins digests, not locations). A tool maps to one
231
+ * source (a pinned https archive, or a local path), or to `{ aarch64: source, x86_64: source }`; a universal target needs the per-arch form for every tool.
210
232
  */
211
233
  function parseFfmpegSources(raw, where, errors, { universal }) {
212
234
  if (raw === undefined) return undefined;
@@ -215,12 +237,12 @@ function parseFfmpegSources(raw, where, errors, { universal }) {
215
237
  for (const [tool, value] of Object.entries(raw)) {
216
238
  const label = `${where}: sources.${tool}`;
217
239
  if (!["ffmpeg", "ffprobe"].includes(tool)) { errors.push(`${label}: not ffmpeg or ffprobe`); continue; }
218
- const perArch = isObject(value) && SOURCE_ARCHES.some((arch) => arch in value) && !("url" in value);
240
+ const perArch = isObject(value) && SOURCE_ARCHES.some((arch) => arch in value) && !("url" in value) && !("path" in value);
219
241
  if (universal && !perArch) { errors.push(`${label} needs { aarch64, x86_64 } sources for universal-apple-darwin (each slice is a thin file)`); continue; }
220
242
  if (perArch) {
221
243
  for (const key of Object.keys(value)) if (!SOURCE_ARCHES.includes(key)) errors.push(`${label}: unknown arch ${JSON.stringify(key)}`);
222
- out[tool] = Object.fromEntries(SOURCE_ARCHES.filter((arch) => arch in value).map((arch) => [arch, parseArchiveSource(value[arch], `${label}.${arch}`, errors)]));
223
- } else out[tool] = parseArchiveSource(value, label, errors);
244
+ out[tool] = Object.fromEntries(SOURCE_ARCHES.filter((arch) => arch in value).map((arch) => [arch, parseFfmpegSource(value[arch], `${label}.${arch}`, errors)]));
245
+ } else out[tool] = parseFfmpegSource(value, label, errors);
224
246
  }
225
247
  return out;
226
248
  }
@@ -397,19 +419,40 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
397
419
  else platformArgs[key] = flagList(list, `platformArgs.${key}`);
398
420
  }
399
421
  }
400
- if (item.generator !== undefined && (typeof item.generator !== "string" || !/^[A-Za-z0-9][A-Za-z0-9 .+-]{0,60}$/.test(item.generator))) errors.push(`${where}: generator must be a generator name such as "Ninja"`);
422
+ if (item.generator !== undefined && (typeof item.generator !== "string" || !CMAKE_GENERATOR.test(item.generator))) errors.push(`${where}: generator must be a generator name such as "Ninja"`);
423
+ const platformGenerators = {};
424
+ if (item.platformGenerators !== undefined) {
425
+ if (!isObject(item.platformGenerators)) errors.push(`${where}: platformGenerators must be an object keyed by mac, win or linux`);
426
+ else for (const [key, generator] of Object.entries(item.platformGenerators)) {
427
+ if (!PLATFORMS.has(key)) errors.push(`${where}: platformGenerators.${key}: platforms are ${[...PLATFORMS].join(", ")}`);
428
+ else if (typeof generator !== "string" || !CMAKE_GENERATOR.test(generator)) errors.push(`${where}: platformGenerators.${key} must be a generator name such as "Visual Studio 17 2022"`);
429
+ else platformGenerators[key] = generator;
430
+ }
431
+ }
432
+ if (item.architecture !== undefined && (typeof item.architecture !== "string" || !CMAKE_ARCHITECTURE.test(item.architecture))) errors.push(`${where}: architecture must be a cmake -A platform such as x64, Win32 or ARM64`);
433
+ if (item.cmakeBin !== undefined && (typeof item.cmakeBin !== "string" || !item.cmakeBin.trim() || /[\0\r\n]/.test(item.cmakeBin))) errors.push(`${where}: cmakeBin must be a cmake executable name or path`);
401
434
  if (item.smoke !== undefined && (!Array.isArray(item.smoke) || item.smoke.some((arg) => typeof arg !== "string"))) errors.push(`${where}: smoke must be a list of argument strings`);
402
435
  sha(item.sha256, "sha256");
403
436
  const dest = destOf(item.dest);
404
437
  const clean = cleanOf();
405
438
  if (!skipped) { if (dest) claim(dest, where); else if (typeof name === "string" && stageDir) claim(stagedClaim(name), where); }
406
- Object.assign(entry, { name, repo: item.repo, commit: item.commit, target, ...(output ? { output } : {}), cmakeArgs, platformArgs, ...(item.generator ? { generator: item.generator } : {}), ...(item.smoke ? { smoke: [...item.smoke] } : {}), ...(item.sha256 ? { sha256: item.sha256 } : {}), ...(dest ? { dest } : {}), ...(clean ? { clean } : {}) });
439
+ Object.assign(entry, { name, repo: item.repo, commit: item.commit, target, ...(output ? { output } : {}), cmakeArgs, platformArgs, ...(item.generator ? { generator: item.generator } : {}), ...(Object.keys(platformGenerators).length ? { platformGenerators } : {}), ...(item.architecture ? { architecture: item.architecture } : {}), ...(item.cmakeBin ? { cmakeBin: item.cmakeBin.trim() } : {}), ...(item.smoke ? { smoke: [...item.smoke] } : {}), ...(item.sha256 ? { sha256: item.sha256 } : {}), ...(dest ? { dest } : {}), ...(clean ? { clean } : {}) });
407
440
  } else if (item.kind === "prebuilt") {
408
441
  const name = item.name;
409
442
  if (typeof name !== "string" || !NAME.test(name) || /\.exe$/i.test(name) || name !== path.basename(name)) errors.push(`${where}: name must be a bare sidecar name (no path, no .exe, no target triple)`);
410
- const from = item.from === undefined ? undefined : relativePath(item.from, `${where}: from`, errors);
443
+ let from;
444
+ if (isObject(item.from)) {
445
+ // Per-arch inputs: each slice is verified against its own manifest entry; universal-apple-darwin lipo-merges them.
446
+ from = {};
447
+ for (const [arch, value] of Object.entries(item.from)) {
448
+ if (!SOURCE_ARCHES.includes(arch)) errors.push(`${where}: from.${arch}: arches are ${SOURCE_ARCHES.join(", ")}`);
449
+ else { const file = relativePath(value, `${where}: from.${arch}`, errors); if (file) from[arch] = file; }
450
+ }
451
+ if (!Object.keys(item.from).length) errors.push(`${where}: from must name at least one of ${SOURCE_ARCHES.join(", ")}`);
452
+ if (!skipped && t.arch === "universal" && !SOURCE_ARCHES.every((arch) => arch in item.from)) errors.push(`${where}: from needs both aarch64 and x86_64 for universal-apple-darwin (or omit from to use the staged <name>-<slice triple> files)`);
453
+ } else from = item.from === undefined ? undefined : relativePath(item.from, `${where}: from`, errors);
411
454
  const manifest = parsePrebuiltManifest(item.manifest, where, errors);
412
- if (!skipped && t.arch === "universal") errors.push(`${where}: a prebuilt binary is verified per architecture; there is no universal manifest entry`);
455
+ if (!skipped && t.arch === "universal" && typeof item.from === "string") errors.push(`${where}: from must be { aarch64, x86_64 } for universal-apple-darwin (each slice is verified against its own manifest entry, then lipo-merged)`);
413
456
  const dest = destOf(item.dest);
414
457
  if (!skipped) { if (dest) claim(dest, where); else if (typeof name === "string" && stageDir) claim(stagedClaim(name), where); }
415
458
  Object.assign(entry, { name, ...(from ? { from } : {}), ...(manifest ? { manifest } : {}), ...(dest ? { dest } : {}) });
@@ -434,6 +477,17 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
434
477
  }
435
478
 
436
479
  const sha256Of = (bytes) => createHash("sha256").update(bytes).digest("hex");
480
+ /**
481
+ * Signature-independent catalog digest of `bytes` for `arch` (content-digest.mjs, port of
482
+ * rightkit-ffmpeg content_digest.rs). Catalog `contentSha256` rows pin THIS digest, never a
483
+ * whole-file SHA-256: signed & unsigned copies of one build agree. A malformed recognized
484
+ * format throws ContentDigestError (never a plain-hash fallback). Whole-file hashes elsewhere
485
+ * (archive/download pins, verifyStagedSidecars preSignSha256 signing evidence) stay separate.
486
+ */
487
+ function catalogContentDigest(bytes, arch) {
488
+ const identity = contentIdentity(bytes);
489
+ return digestFor(identity, arch) ?? (identity.slices.length === 1 ? identity.slices[0].sha256 : null);
490
+ }
437
491
  const profileDirectory = (profile) => (profile === "dev" || profile === "test" ? "debug" : profile === "bench" ? "release" : profile);
438
492
  const tail = (text, lines = 12) => String(text ?? "").trim().split("\n").slice(-lines).join("\n");
439
493
 
@@ -673,10 +727,22 @@ function stageCargoBuild(entry, ctx, triple) {
673
727
  }
674
728
 
675
729
  /** The archive source for one tool at one arch: a single source, or the per-arch form. */
676
- const ffmpegSourceFor = (sources, tool, arch) => { const spec = sources?.[tool]; return spec?.url ? spec : spec?.[arch]; };
730
+ const ffmpegSourceFor = (sources, tool, arch) => { const spec = sources?.[tool]; return spec?.url || spec?.path ? spec : spec?.[arch]; };
731
+
732
+ /** The binary a local ffmpeg source names: the file itself, or `<dir>/<member>` / `<dir>/bin/<member>` (`.exe` added for a Windows target). */
733
+ function resolveLocalFfmpegSource(ctx, spec, tool) {
734
+ const { fs } = ctx;
735
+ const base = path.resolve(ctx.appRoot, spec.path);
736
+ if (fs.existsSync(base) && fs.statSync(base).isFile()) return base;
737
+ const member = spec.member ?? `${tool}${ctx.windows ? ".exe" : ""}`;
738
+ const candidates = [path.join(base, member), path.join(base, "bin", member)];
739
+ const found = candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile());
740
+ if (!found) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `the local ${tool} source ${spec.path} has no ${member} (looked in ${candidates.join(", ")}); vendor the build first, nothing is downloaded for a path source`, { tool, candidates });
741
+ return found;
742
+ }
677
743
 
678
744
  /**
679
- * Download the bytes of the catalog rows that `ffmpeg.sources` names: an archive pinned by digest, one member unpacked, the
745
+ * Fetch the bytes of the catalog rows that `ffmpeg.sources` names (a local `path` is copied, never downloaded): an archive pinned by digest, one member unpacked, the
680
746
  * member's digest checked against the APPROVED catalog row (so a swapped archive can never stage). A staged file that already
681
747
  * matches its row is left alone.
682
748
  */
@@ -686,12 +752,19 @@ async function fetchFfmpegSources(entry, ctx, plan, arch) {
686
752
  for (const file of plan.files) {
687
753
  const spec = ffmpegSourceFor(entry.sources, file.tool, arch);
688
754
  if (!spec) continue;
689
- if (fs.existsSync(file.stagedPath) && sha256Of(fs.readFileSync(file.stagedPath)) === file.contentSha256) { ctx.log(`stage-sidecar: ${rel(ctx, file.stagedPath)} already staged`); continue; }
755
+ if (fs.existsSync(file.stagedPath) && catalogContentDigest(fs.readFileSync(file.stagedPath), arch) === file.contentSha256) { ctx.log(`stage-sidecar: ${rel(ctx, file.stagedPath)} already staged`); continue; }
756
+ if (spec.path) {
757
+ const local = resolveLocalFfmpegSource(ctx, spec, file.tool);
758
+ const actual = catalogContentDigest(fs.readFileSync(local), arch);
759
+ if (actual !== file.contentSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${local} is ${actual}, the approved catalog row ${file.artifactId} says ${file.contentSha256}; nothing staged`, { expected: file.contentSha256, actual });
760
+ copyInto(ctx, local, file.stagedPath, { executable: true });
761
+ continue;
762
+ }
690
763
  const { cached } = await ensureCachedDownload(ctx, spec);
691
764
  const scratch = ctx.makeTempDir();
692
765
  try {
693
766
  const member = await ctx.extractMember({ archive: cached, fileName: spec.member, destDir: scratch });
694
- const actual = sha256Of(fs.readFileSync(member));
767
+ const actual = catalogContentDigest(fs.readFileSync(member), arch);
695
768
  if (actual !== file.contentSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${spec.member} from ${spec.url} is ${actual}, the approved catalog row ${file.artifactId} says ${file.contentSha256}; nothing staged`, { expected: file.contentSha256, actual });
696
769
  copyInto(ctx, member, file.stagedPath, { executable: true });
697
770
  } finally {
@@ -746,7 +819,9 @@ async function stageFfmpegSlice(entry, ctx, resolved, { config, t, writeNotices
746
819
  const verified = verifyStagedSidecars(plan, { exists: fs.existsSync, stat: (file) => fs.statSync(file), hashFile: (file) => sha256Of(fs.readFileSync(file)) });
747
820
  const notices = noticeFiles.map((file) => ({ name: path.basename(file), path: rel(ctx, file), sha256: sha256Of(fs.readFileSync(file)) }));
748
821
  const rows = verified.map((file) => {
749
- if (file.preSignSha256 !== file.contentSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${file.tool} staged as ${file.preSignSha256}, the approved catalog row ${file.artifactId} says ${file.contentSha256}`, { expected: file.contentSha256, actual: file.preSignSha256 });
822
+ // Catalog rows pin the signature-independent content digest; preSignSha256 stays whole-file signing evidence.
823
+ const contentDigest = catalogContentDigest(fs.readFileSync(file.stagedPath), t.arch);
824
+ if (contentDigest !== file.contentSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${file.tool} staged with content digest ${contentDigest}, the approved catalog row ${file.artifactId} says ${file.contentSha256}`, { expected: file.contentSha256, actual: contentDigest });
750
825
  const probe = entry.probe ? probeFfmpegBinary(ctx, { file: file.stagedPath, tool: file.tool, target: t }) : undefined;
751
826
  return { kind: "ffmpeg", name: file.tool, path: rel(ctx, file.stagedPath), sha256: file.preSignSha256, sizeBytes: file.preSignSizeBytes, changed: null, catalogRow: file.artifactId, version: file.version, catalogContentSha256: file.contentSha256, ...(file.variant ? { variant: file.variant } : {}), externalBin: true, license: "LGPL-2.1-or-later", notices, ...(probe ? { probe } : {}) };
752
827
  });
@@ -797,7 +872,7 @@ async function stageFfmpeg(entry, ctx) {
797
872
  if (target.arch === "universal") return await stageFfmpegUniversal(entry, ctx, resolved);
798
873
  return (await stageFfmpegSlice(entry, ctx, resolved, { config, t: target, writeNotices: true })).rows;
799
874
  } catch (error) {
800
- if (error instanceof StageSidecarError) throw error;
875
+ if (error instanceof StageSidecarError || error instanceof ContentDigestError) throw error;
801
876
  return fail("STAGE_SIDECAR_FFMPEG_FAILED", `ffmpeg sidecar (${target.triple}): ${error.message}`, { cause: error, causeCode: error.code });
802
877
  }
803
878
  }
@@ -1041,6 +1116,32 @@ function checkoutPinnedSource(ctx, entry, srcDir) {
1041
1116
  }
1042
1117
  }
1043
1118
 
1119
+ /** Fixed Visual Studio Installer (vswhere) location under %ProgramFiles(x86)%, and the cmake Visual Studio bundles under an installationPath. */
1120
+ const VSWHERE_PARTS = ["Microsoft Visual Studio", "Installer", "vswhere.exe"];
1121
+ const VS_CMAKE_PARTS = ["Common7", "IDE", "CommonExtensions", "Microsoft", "CMake", "CMake", "bin", "cmake.exe"];
1122
+
1123
+ /**
1124
+ * Which cmake to run. Order: the entry's `cmakeBin` (a bare name is looked up on PATH; a path with separators is resolved against the
1125
+ * app root); off a Windows host plain `cmake`; on a Windows host `cmake` when `where cmake` finds one, else the Visual Studio-bundled
1126
+ * cmake that vswhere locates (installationPath\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe), else plain `cmake`
1127
+ * (which then fails with the usual spawn error naming it).
1128
+ */
1129
+ function resolveCmakeBinary(ctx, entry) {
1130
+ if (entry.cmakeBin) return /[\\/]/.test(entry.cmakeBin) ? path.resolve(ctx.appRoot, entry.cmakeBin) : entry.cmakeBin;
1131
+ if (ctx.host.os !== "windows") return "cmake";
1132
+ const probe = ctx.run("where", ["cmake"], { cwd: ctx.appRoot, env: ctx.env });
1133
+ if (!probe?.error && probe?.status === 0 && String(probe.stdout ?? "").trim()) return "cmake";
1134
+ const programFiles = ctx.env["ProgramFiles(x86)"];
1135
+ if (typeof programFiles !== "string" || !programFiles) return "cmake";
1136
+ const vswhere = path.win32.join(programFiles, ...VSWHERE_PARTS);
1137
+ if (!ctx.fs.existsSync(vswhere)) return "cmake";
1138
+ const found = ctx.run(vswhere, ["-latest", "-products", "*", "-requires", "Microsoft.VisualStudio.Component.VC.CMake.Project", "-property", "installationPath"], { cwd: ctx.appRoot, env: ctx.env });
1139
+ const installation = found?.error || found?.status !== 0 ? "" : String(found.stdout ?? "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "";
1140
+ if (!installation) return "cmake";
1141
+ const bundled = path.win32.join(installation, ...VS_CMAKE_PARTS);
1142
+ return ctx.fs.existsSync(bundled) ? bundled : "cmake";
1143
+ }
1144
+
1044
1145
  /**
1045
1146
  * Build a pinned git commit with the build tool (no shell) and stage the result as <name>-<triple>[.exe]: fetch the commit, configure,
1046
1147
  * build `target`, locate the executable, check it carries both archs for universal-apple-darwin, optionally pin its digest and
@@ -1049,6 +1150,8 @@ function checkoutPinnedSource(ctx, entry, srcDir) {
1049
1150
  */
1050
1151
  function stageCmake(entry, ctx) {
1051
1152
  const { appRoot, fs, run, env, target } = ctx;
1153
+ const cmake = resolveCmakeBinary(ctx, entry);
1154
+ const generator = entry.platformGenerators?.[target.platform] ?? entry.generator;
1052
1155
  const root = path.resolve(appRoot, CMAKE_ROOT, entry.name);
1053
1156
  const srcDir = path.join(root, "src");
1054
1157
  const buildDir = path.join(root, "build");
@@ -1058,10 +1161,10 @@ function stageCmake(entry, ctx) {
1058
1161
  const archFlag = target.platform !== "mac" ? undefined
1059
1162
  : target.arch === "universal" ? "-DCMAKE_OSX_ARCHITECTURES=arm64;x86_64"
1060
1163
  : `-DCMAKE_OSX_ARCHITECTURES=${target.arch === "aarch64" ? "arm64" : "x86_64"}`;
1061
- const configure = ["-S", srcDir, "-B", buildDir, "-DCMAKE_BUILD_TYPE=Release", ...(entry.generator ? ["-G", entry.generator] : []), ...(archFlag ? [archFlag] : []), ...(entry.platformArgs[target.platform] ?? []), ...entry.cmakeArgs];
1164
+ const configure = ["-S", srcDir, "-B", buildDir, "-DCMAKE_BUILD_TYPE=Release", ...(generator ? ["-G", generator] : []), ...(entry.architecture ? ["-A", entry.architecture] : []), ...(archFlag ? [archFlag] : []), ...(entry.platformArgs[target.platform] ?? []), ...entry.cmakeArgs];
1062
1165
  const step = (args, label) => {
1063
- ctx.log(`stage-sidecar: cmake ${args.join(" ")}`);
1064
- const result = run("cmake", args, { cwd: appRoot, env });
1166
+ ctx.log(`stage-sidecar: ${cmake} ${args.join(" ")}`);
1167
+ const result = run(cmake, args, { cwd: appRoot, env });
1065
1168
  if (result?.error || result?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `cmake ${label} of ${entry.name} failed (${result?.error?.message ?? `exit ${result?.status}`})`, { name: entry.name, stderr: tail(result?.stderr) });
1066
1169
  };
1067
1170
  step(configure, "configure");
@@ -1081,22 +1184,21 @@ function stageCmake(entry, ctx) {
1081
1184
  const smoked = run(dest, entry.smoke, { cwd: appRoot, env });
1082
1185
  if (smoked?.error || smoked?.status !== 0) fail("STAGE_SIDECAR_BUILD_FAILED", `${entry.name} does not run (${entry.smoke.join(" ")}): ${smoked?.error?.message ?? `exit ${smoked?.status}`}`, { name: entry.name });
1083
1186
  }
1084
- return [{ kind: "cmake", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256, repo: entry.repo, commit: entry.commit, cmakeTarget: entry.target, flags: configure.filter((arg) => arg.startsWith("-D")), externalBin: !entry.dest }];
1187
+ return [{ kind: "cmake", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256, repo: entry.repo, commit: entry.commit, cmakeTarget: entry.target, ...(cmake !== "cmake" ? { cmakeBin: cmake } : {}), ...(generator ? { generator } : {}), flags: configure.filter((arg) => arg.startsWith("-D")), externalBin: !entry.dest }];
1085
1188
  }
1086
1189
 
1087
1190
  /** `{platform}` in a prebuilt manifest entry: macos-aarch64, macos-x86_64, windows-x86_64, linux-x86_64, ... */
1088
1191
  const prebuiltPlatformKey = (t) => `${t.os}-${t.arch}`;
1089
1192
 
1090
1193
  /**
1091
- * Verify a host-built binary (already staged at <stageDir>/<name>-<triple>, or copied from `from`) against the digest in an app
1092
- * manifest file: its raw sha256, and when the manifest names an approval hash, the signing-independent approval digest
1194
+ * Verify one host-built binary `source` for `slice` (a parsed target: its os and arch name the manifest entry) against the digest in
1195
+ * an app manifest file: its raw sha256, and when the manifest names an approval hash, the signing-independent approval digest
1093
1196
  * (`macho-codesign-strip-linkedit-v1` on macOS, `raw-sha256` elsewhere). Nothing is built or downloaded: a missing file or
1094
- * a mismatch fails closed.
1197
+ * a mismatch fails closed. Returns { platformKey, actual, approval? }.
1095
1198
  */
1096
- async function stagePrebuilt(entry, ctx) {
1097
- const { appRoot, fs, target } = ctx;
1098
- const dest = destFor(entry, ctx);
1099
- const platformKey = prebuiltPlatformKey(target);
1199
+ async function verifyPrebuilt(entry, ctx, source, slice) {
1200
+ const { appRoot, fs } = ctx;
1201
+ const platformKey = prebuiltPlatformKey(slice);
1100
1202
  const manifestFile = path.resolve(appRoot, entry.manifest.file);
1101
1203
  let manifest;
1102
1204
  try { manifest = JSON.parse(String(fs.readFileSync(manifestFile))); } catch (error) { fail("STAGE_SIDECAR_CATALOG_UNREADABLE", `cannot read the prebuilt manifest ${manifestFile} as JSON`, { cause: error }); }
@@ -1105,7 +1207,6 @@ async function stagePrebuilt(entry, ctx) {
1105
1207
  if (!isObject(node)) fail("STAGE_SIDECAR_NOT_APPROVED", `${entry.manifest.file} has no ${entry.manifest.entry.replaceAll("{platform}", platformKey)} entry for ${entry.name} on ${platformKey}`);
1106
1208
  const pinned = node[entry.manifest.sha256];
1107
1209
  if (typeof pinned !== "string" || !SHA256.test(pinned)) fail("STAGE_SIDECAR_NOT_APPROVED", `${entry.manifest.file} ${platformKey}.${entry.manifest.sha256} is not a lowercase 64-hex SHA-256`);
1108
- const source = entry.from ? path.resolve(appRoot, entry.from) : dest;
1109
1210
  if (!fs.existsSync(source) || !fs.statSync(source).isFile()) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `prebuilt ${entry.name} for ${platformKey} is missing: ${source}. Build it on its native host first; packaging never substitutes a stock binary.`, { name: entry.name, source });
1110
1211
  const actual = sha256Of(fs.readFileSync(source));
1111
1212
  if (actual !== pinned) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.name} integrity failure: ${source} is ${actual}, ${entry.manifest.file} pins ${pinned}`, { expected: pinned, actual });
@@ -1113,13 +1214,40 @@ async function stagePrebuilt(entry, ctx) {
1113
1214
  if (entry.manifest.approvalSha256) {
1114
1215
  const expected = node[entry.manifest.approvalSha256];
1115
1216
  if (typeof expected !== "string" || !SHA256.test(expected)) fail("STAGE_SIDECAR_NOT_APPROVED", `${entry.manifest.file} ${platformKey}.${entry.manifest.approvalSha256} is not a lowercase 64-hex SHA-256`);
1116
- const algorithm = entry.manifest.approvalAlgorithm ? node[entry.manifest.approvalAlgorithm] : (target.os === "macos" ? APPROVAL_MACHO_STRIP_LINKEDIT_V1 : APPROVAL_RAW_SHA256);
1217
+ const algorithm = entry.manifest.approvalAlgorithm ? node[entry.manifest.approvalAlgorithm] : (slice.os === "macos" ? APPROVAL_MACHO_STRIP_LINKEDIT_V1 : APPROVAL_RAW_SHA256);
1117
1218
  if (typeof algorithm !== "string") fail("STAGE_SIDECAR_NOT_APPROVED", `${entry.manifest.file} ${platformKey}.${entry.manifest.approvalAlgorithm} does not name an approval hash algorithm`);
1118
1219
  let digest;
1119
1220
  try { digest = await ctx.approvalDigest(source, algorithm); } catch (error) { fail("STAGE_SIDECAR_APPROVAL_FAILED", `${entry.name} approval hash (${algorithm}): ${error.message}`, { cause: error, causeCode: error.code }); }
1120
1221
  if (digest !== expected) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.name} signing-independent integrity failure: ${algorithm} is ${digest}, ${entry.manifest.file} pins ${expected}`, { expected, actual: digest });
1121
1222
  approval = { algorithm, sha256: digest };
1122
1223
  }
1224
+ return { platformKey, actual, ...(approval ? { approval } : {}) };
1225
+ }
1226
+
1227
+ /**
1228
+ * Verify a host-built binary (already staged at <stageDir>/<name>-<triple>, or copied from `from`) against the app manifest and stage it.
1229
+ * `from` is a path, or `{ aarch64, x86_64 }` (the entry for this arch is used). `universal-apple-darwin`: each slice (its `from.<arch>`, else the
1230
+ * staged `<stageDir>/<name>-<slice triple>`) is verified against its own manifest entry (macos-aarch64, macos-x86_64), then the two are
1231
+ * lipo-merged by the same helper the ffmpeg, onnxruntime and cmake kinds use into `<name>-universal-apple-darwin`; a thin merge result is refused.
1232
+ */
1233
+ async function stagePrebuilt(entry, ctx) {
1234
+ const { fs, target, appRoot } = ctx;
1235
+ const dest = destFor(entry, ctx);
1236
+ if (target.arch === "universal") {
1237
+ const parts = [];
1238
+ for (const triple of SLICES) {
1239
+ const slice = parseTargetTriple(triple);
1240
+ const from = entry.from?.[slice.arch];
1241
+ const source = from ? path.resolve(appRoot, from) : path.resolve(appRoot, ctx.stageDir, stagedName(entry.name, slice));
1242
+ parts.push({ triple, source, ...(await verifyPrebuilt(entry, ctx, source, slice)) });
1243
+ }
1244
+ const merged = lipoMergeInto(ctx, parts.map((part) => part.source), dest, `prebuilt ${entry.name}`, { executable: true });
1245
+ return [{ kind: "prebuilt", name: entry.name, path: rel(ctx, dest), ...merged, sourceSha256: merged.sha256, manifest: entry.manifest.file, platform: "macos-universal", externalBin: !entry.dest, slices: parts.map((part) => ({ triple: part.triple, sha256: part.actual, platform: part.platformKey, ...(part.approval ? { approvalAlgorithm: part.approval.algorithm, approvalSha256: part.approval.sha256 } : {}) })) }];
1246
+ }
1247
+ const declared = isObject(entry.from) ? entry.from[target.arch] : entry.from;
1248
+ if (isObject(entry.from) && !declared) fail("STAGE_SIDECAR_BINARY_NOT_FOUND", `prebuilt ${entry.name} declares from for ${Object.keys(entry.from).join(", ")}, not ${target.arch}`, { name: entry.name });
1249
+ const source = declared ? path.resolve(appRoot, declared) : dest;
1250
+ const { platformKey, actual, approval } = await verifyPrebuilt(entry, ctx, source, target);
1123
1251
  let copied;
1124
1252
  if (path.resolve(source) === path.resolve(dest)) {
1125
1253
  if (!ctx.windows) fs.chmodSync(dest, 0o755);
@@ -0,0 +1,5 @@
1
+ // The distribution an upload uses for one platform. An app that ships desktop and mobile has a different store per
2
+ // platform, so `targets.<platform>.distribution` wins; the top-level `distribution` is the single-store fallback.
3
+ export function targetDistribution(config, platform) {
4
+ return config?.targets?.[platform]?.distribution ?? config?.distribution;
5
+ }
@@ -36,7 +36,12 @@ if (platform === "android") {
36
36
  const { main } = await import("./android-play-upload.mjs");
37
37
  process.exit(await main({ releaseId, configName, apply, dryRun }));
38
38
  }
39
- if (apply) fail("--apply is Android only; win and mac preview with --dry-run");
39
+ // iOS: TestFlight through the App Store Connect build-upload API (in process, no key file), with its own gate and a dry run by default (ios-upload.mjs).
40
+ if (platform === "ios") {
41
+ const { main } = await import("./ios-upload.mjs");
42
+ process.exit(await main({ releaseId, configName, apply, dryRun }));
43
+ }
44
+ if (apply) fail("--apply is Android and iOS only; win and mac preview with --dry-run");
40
45
 
41
46
  // Tier, primary checkout, clean tree, sealed commit, ownership evidence, provider and repository are checked by
42
47
  // the gate that `right-release github --release` also uses; nothing below runs until it passes.
@@ -82,7 +87,7 @@ function writeJson(file, value) {
82
87
  }
83
88
 
84
89
  function usage(code) {
85
- console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update [--config right-release.config.mjs] [--repo owner/repo] [--dry-run]\n right-release upload --platform android --release <sealed-id> [--config right-release.config.mjs] [--apply] (Play internal track; dry run unless --apply)");
90
+ console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update [--config right-release.config.mjs] [--repo owner/repo] [--dry-run]\n right-release upload --platform android --release <sealed-id> [--config right-release.config.mjs] [--apply] (Play internal track; dry run unless --apply)\n right-release upload --platform ios --release <sealed-id> [--config right-release.config.mjs] [--apply] (TestFlight; dry run unless --apply)");
86
91
  process.exit(code);
87
92
  }
88
93