@rightkit/release 0.2.174 → 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 (82) hide show
  1. package/README.md +200 -2
  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 +40 -5
  10. package/codesign-environment.mjs +2 -2
  11. package/command-spec.mjs +80 -0
  12. package/content-digest.mjs +209 -0
  13. package/deps-cargo.mjs +55 -0
  14. package/deps.mjs +7 -0
  15. package/development.mjs +17 -6
  16. package/direct-bootstrap.mjs +5 -6
  17. package/disk-command.mjs +1 -0
  18. package/disk-policy.json +6 -2
  19. package/disk-stable-root.mjs +13 -9
  20. package/dist-scan.mjs +102 -0
  21. package/dmg-build.mjs +53 -4
  22. package/github-updater-release.mjs +70 -7
  23. package/icon-pack.mjs +117 -0
  24. package/in-place-install.mjs +17 -14
  25. package/init-command.mjs +116 -0
  26. package/install-app-windows.mjs +24 -7
  27. package/install-app.mjs +56 -42
  28. package/ios-asc-credentials.mjs +163 -0
  29. package/ios-build-context.mjs +129 -0
  30. package/ios-build-upload.mjs +282 -0
  31. package/ios-credentials-command.mjs +70 -0
  32. package/ios-dev.mjs +248 -0
  33. package/ios-host.mjs +76 -0
  34. package/ios-lock.mjs +75 -0
  35. package/ios-package-recipe.mjs +707 -0
  36. package/ios-testflight.mjs +266 -0
  37. package/ios-upload.mjs +313 -0
  38. package/local-publish.mjs +390 -47
  39. package/macho-approval-hash.mjs +181 -0
  40. package/managed-cargo.mjs +57 -1
  41. package/media-sidecar.mjs +20 -4
  42. package/mirror-root-artifact.mjs +74 -22
  43. package/native-cargo-layout.mjs +47 -4
  44. package/npm-license-notices.mjs +159 -0
  45. package/ownership-command.mjs +2 -1
  46. package/ownership-dependency-allowlist.json +2 -1
  47. package/ownership-evidence.mjs +30 -18
  48. package/ownership-key-command.mjs +69 -2
  49. package/ownership-mandatory-set.mjs +66 -16
  50. package/ownership-repositories.json +1 -1
  51. package/ownership-scope.mjs +94 -0
  52. package/ownership.mjs +86 -11
  53. package/package-recipe-targets.mjs +56 -2
  54. package/package.json +1 -1
  55. package/pe-content.mjs +133 -0
  56. package/preflight.mjs +14 -7
  57. package/project-type.mjs +3 -1
  58. package/protected-credentials.mjs +28 -1
  59. package/publication-artifacts.mjs +30 -5
  60. package/publication-gate.mjs +83 -5
  61. package/publish-cargo.mjs +124 -18
  62. package/publish-update.mjs +15 -2
  63. package/qa-build-features.mjs +16 -13
  64. package/qa-contract.mjs +30 -2
  65. package/qa-control-scan.mjs +4 -0
  66. package/release-dependencies-command.mjs +45 -0
  67. package/release-dependencies.mjs +60 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +62 -24
  71. package/release.mjs +144 -21
  72. package/rightkit-ownership.json +46 -2
  73. package/rightkit-versions.json +380 -189
  74. package/runtime-budgets.mjs +134 -23
  75. package/sign-updater.mjs +56 -59
  76. package/source-gate.mjs +8 -1
  77. package/stage-sidecar.mjs +634 -69
  78. package/target-distribution.mjs +5 -0
  79. package/test-command.mjs +190 -0
  80. package/toolchain-swift.mjs +239 -0
  81. package/upload-release.mjs +7 -2
  82. package/web-build.mjs +67 -0
@@ -0,0 +1,181 @@
1
+ // Signing-independent approval hashes for staged prebuilt binaries (Orthic-Labs/rightkit#490).
2
+ //
3
+ // `raw-sha256` the sha256 of the file bytes (Windows and Linux binaries).
4
+ // `macho-codesign-strip-linkedit-v1` the sha256 of a thin little-endian 64-bit Mach-O after its code signature is removed:
5
+ // LC_CODE_SIGNATURE and the trailing signature blob are gone, the __LINKEDIT filesize is
6
+ // whatever `codesign --remove-signature` leaves, and the __LINKEDIT vmsize is normalised to
7
+ // the page-rounded filesize, so a signed and an unsigned copy of the same build hash alike.
8
+ //
9
+ // This is the algorithm GenRight's `local-image-approval-hash.mjs` recorded as `approval_hash_algorithm` in its runtime
10
+ // manifest, moved here so apps stop carrying it. Every layout surprise (a fat or big-endian file, a duplicate or non-terminal
11
+ // signature, a load command that changed, padding that is not zero) is a typed refusal, never a guess. The signature is removed
12
+ // from a private copy with `/usr/bin/codesign --remove-signature`; the input file is never modified. macOS only for Mach-O.
13
+ import { createHash, randomBytes } from "node:crypto";
14
+ import { chmodSync, lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { spawnSync } from "./background-process.mjs";
18
+
19
+ export const APPROVAL_RAW_SHA256 = "raw-sha256";
20
+ export const APPROVAL_MACHO_STRIP_LINKEDIT_V1 = "macho-codesign-strip-linkedit-v1";
21
+ export const APPROVAL_ALGORITHMS = Object.freeze([APPROVAL_RAW_SHA256, APPROVAL_MACHO_STRIP_LINKEDIT_V1]);
22
+
23
+ const LC_CODE_SIGNATURE = 0x1d;
24
+ const LC_SEGMENT_64 = 0x19;
25
+ const MH_MAGIC_64 = 0xfeedfacf;
26
+ const CPU_TYPE_X86_64 = 0x01000007;
27
+ const CPU_TYPE_ARM64 = 0x0100000c;
28
+
29
+ export class ApprovalHashError extends Error {
30
+ constructor(code, message) { super(message); this.name = "ApprovalHashError"; this.code = code; }
31
+ }
32
+ const fail = (message, code = "APPROVAL_HASH_INVALID_MACHO") => { throw new ApprovalHashError(code, `backend integrity: ${message}`); };
33
+
34
+ /** Parse a thin 64-bit little-endian Mach-O. Exported for tests. */
35
+ export function parseMachO(bytes) {
36
+ if (bytes.length < 32 || bytes.readUInt32LE(0) !== MH_MAGIC_64) fail("only little-endian 64-bit thin Mach-O is supported");
37
+ const cpuType = bytes.readUInt32LE(4);
38
+ if (![CPU_TYPE_X86_64, CPU_TYPE_ARM64].includes(cpuType)) fail("unsupported Mach-O CPU type");
39
+ const ncmds = bytes.readUInt32LE(16);
40
+ const sizeofcmds = bytes.readUInt32LE(20);
41
+ const commandEnd = 32 + sizeofcmds;
42
+ if (ncmds > 65_536 || commandEnd > bytes.length || sizeofcmds > bytes.length / 2) fail("invalid Mach-O load command table");
43
+ let cursor = 32;
44
+ let signature = null;
45
+ let linkedit = null;
46
+ const normalizedCommands = [];
47
+ for (let index = 0; index < ncmds; index += 1) {
48
+ if (cursor + 8 > commandEnd) fail("truncated Mach-O load command");
49
+ const command = bytes.readUInt32LE(cursor);
50
+ const commandSize = bytes.readUInt32LE(cursor + 4);
51
+ if (commandSize < 8 || commandSize % 4 !== 0 || cursor + commandSize > commandEnd) fail("invalid Mach-O load command size");
52
+ const commandBytes = Buffer.from(bytes.subarray(cursor, cursor + commandSize));
53
+ if (command === LC_CODE_SIGNATURE) {
54
+ if (commandSize !== 16 || signature) fail("invalid duplicate code signature command");
55
+ const offset = bytes.readUInt32LE(cursor + 8);
56
+ const size = bytes.readUInt32LE(cursor + 12);
57
+ if (!size || offset + size > bytes.length) fail("invalid code signature extent");
58
+ signature = { offset, size };
59
+ } else if (command === LC_SEGMENT_64) {
60
+ if (commandSize < 72) fail("truncated Mach-O segment command");
61
+ const sectionCount = commandBytes.readUInt32LE(64);
62
+ if (sectionCount > 65_536 || 72 + sectionCount * 80 > commandSize) fail("invalid Mach-O segment sections");
63
+ const segmentName = commandBytes.subarray(8, 24).toString("utf8").replace(/\0.*$/s, "");
64
+ if (segmentName === "__LINKEDIT") {
65
+ if (linkedit) fail("duplicate __LINKEDIT segment");
66
+ const vmsize = commandBytes.readBigUInt64LE(32);
67
+ const fileoff = commandBytes.readBigUInt64LE(40);
68
+ const filesize = commandBytes.readBigUInt64LE(48);
69
+ if (fileoff + filesize > BigInt(bytes.length) || vmsize < filesize) fail("invalid __LINKEDIT extent");
70
+ // vmsize and filesize legitimately differ after the signature is removed: they are normalised, never compared raw.
71
+ commandBytes.fill(0, 32, 40);
72
+ commandBytes.fill(0, 48, 56);
73
+ linkedit = { fileoff, filesize, vmsize, vmsizeOffset: cursor + 32 };
74
+ }
75
+ }
76
+ if (command !== LC_CODE_SIGNATURE) normalizedCommands.push(commandBytes);
77
+ cursor += commandSize;
78
+ }
79
+ if (cursor !== commandEnd) fail("load commands do not fill declared table");
80
+ if (!linkedit) fail("missing __LINKEDIT segment");
81
+ if (signature && (BigInt(signature.offset) < linkedit.fileoff
82
+ || signature.offset + signature.size !== bytes.length
83
+ || linkedit.fileoff + linkedit.filesize !== BigInt(bytes.length))) {
84
+ fail("signature is not terminal __LINKEDIT data");
85
+ }
86
+ return { bytes, cpuType, ncmds, sizeofcmds, signature, linkedit, normalizedCommands };
87
+ }
88
+
89
+ /**
90
+ * Every byte outside the explicitly permitted ranges must be identical between the captured original and the stripped copy.
91
+ * Permitted: header ncmds/sizeofcmds (checked by the caller), the load-command table (compared command by command, with only
92
+ * the signature command removed and __LINKEDIT vmsize/filesize normalised), the zeroed tail the shorter table leaves, and the
93
+ * signature blob plus its alignment padding at the end of __LINKEDIT. Code, data and all __LINKEDIT content before the
94
+ * signature are compared byte for byte.
95
+ */
96
+ function assertOnlySignatureDiffers(before, after) {
97
+ const a = before.bytes;
98
+ const b = after.bytes;
99
+ if (!before.signature) {
100
+ if (a.length !== b.length || !a.equals(b)) fail("unsigned Mach-O changed during normalization");
101
+ return;
102
+ }
103
+ const same = (from, to, label) => {
104
+ if (from === to) return;
105
+ if (!a.subarray(from, to).equals(b.subarray(from, to))) fail(`signature removal changed ${label}`);
106
+ };
107
+ const beforeTable = 32 + before.sizeofcmds;
108
+ const afterTable = 32 + after.sizeofcmds;
109
+ same(0, 16, "Mach-O header");
110
+ same(24, 32, "Mach-O header");
111
+ if (b.subarray(afterTable, beforeTable).some((byte) => byte !== 0)) fail("signature removal left nonzero bytes after the load commands");
112
+ const keptEnd = Number(after.linkedit.fileoff + after.linkedit.filesize);
113
+ if (b.length !== keptEnd) fail("stripped Mach-O does not end at __LINKEDIT");
114
+ same(beforeTable, keptEnd, "executable or data bytes");
115
+ }
116
+
117
+ /**
118
+ * The approval digest of an already-unsigned copy and its signed original: verifies that signature removal changed
119
+ * nothing but the signature, then hashes the unsigned bytes with the normalised __LINKEDIT vmsize. Exported for tests.
120
+ */
121
+ export function machoApprovalDigest(beforeBytes, afterBytes) {
122
+ const before = parseMachO(beforeBytes);
123
+ const after = parseMachO(Buffer.from(afterBytes));
124
+ if (after.signature) fail("signature remains after normalization");
125
+ if (before.signature && (before.ncmds !== after.ncmds + 1 || before.sizeofcmds !== after.sizeofcmds + 16)) fail("signature removal changed Mach-O command layout");
126
+ if (before.signature) {
127
+ const beforeLinkeditEnd = before.linkedit.fileoff + before.linkedit.filesize;
128
+ const afterLinkeditEnd = after.linkedit.fileoff + after.linkedit.filesize;
129
+ const removedPadding = BigInt(before.signature.offset) - afterLinkeditEnd;
130
+ if (beforeLinkeditEnd !== BigInt(before.signature.offset + before.signature.size)
131
+ || after.linkedit.fileoff !== before.linkedit.fileoff
132
+ || afterLinkeditEnd > BigInt(before.signature.offset)
133
+ || removedPadding < 0n || removedPadding > 15n
134
+ || BigInt(after.bytes.length) !== afterLinkeditEnd) {
135
+ fail("code signature is outside trailing __LINKEDIT data");
136
+ }
137
+ const start = Number(afterLinkeditEnd);
138
+ const end = before.signature.offset;
139
+ if (before.bytes.subarray(start, end).some((byte) => byte !== 0)) fail("nonzero alignment padding before code signature");
140
+ }
141
+ assertOnlySignatureDiffers(before, after);
142
+ if (before.normalizedCommands.length !== after.normalizedCommands.length
143
+ || before.normalizedCommands.some((command, index) => !command.equals(after.normalizedCommands[index]))) {
144
+ fail("signature removal changed non-signature load commands");
145
+ }
146
+ const pageSize = before.cpuType === CPU_TYPE_ARM64 ? 16n * 1024n : 4096n;
147
+ const roundedSize = ((after.linkedit.filesize + pageSize - 1n) / pageSize) * pageSize;
148
+ if (after.linkedit.vmsize < roundedSize) fail("invalid __LINKEDIT virtual size");
149
+ after.bytes.writeBigUInt64LE(roundedSize, after.linkedit.vmsizeOffset);
150
+ return createHash("sha256").update(after.bytes).digest("hex");
151
+ }
152
+
153
+ /** Default signature removal: `/usr/bin/codesign --remove-signature <copy>` (macOS only). */
154
+ function removeSignatureWithCodesign(copy) {
155
+ const result = spawnSync("/usr/bin/codesign", ["--remove-signature", copy], { stdio: "ignore", timeout: 15_000, windowsHide: true });
156
+ if (result.error || result.status !== 0) throw new ApprovalHashError("APPROVAL_HASH_CODESIGN_FAILED", `backend integrity: codesign normalization failed (${result.error?.message ?? result.status})`);
157
+ }
158
+
159
+ /**
160
+ * The approval digest of `file` under `algorithm`. `removeSignature(copyPath)` is injectable for tests and for hosts that
161
+ * strip signatures another way; the default needs macOS. The input file is never modified.
162
+ */
163
+ export function approvalDigest(file, algorithm, { platform = process.platform, removeSignature = removeSignatureWithCodesign, tmpdir = os.tmpdir() } = {}) {
164
+ if (algorithm === APPROVAL_RAW_SHA256) return createHash("sha256").update(readFileSync(file)).digest("hex");
165
+ if (algorithm !== APPROVAL_MACHO_STRIP_LINKEDIT_V1) throw new ApprovalHashError("APPROVAL_HASH_ALGORITHM_UNKNOWN", `unknown approval hash algorithm ${JSON.stringify(algorithm)} (supported: ${APPROVAL_ALGORITHMS.join(", ")})`);
166
+ if (platform !== "darwin") throw new ApprovalHashError("APPROVAL_HASH_HOST_UNSUPPORTED", `${APPROVAL_MACHO_STRIP_LINKEDIT_V1} needs macOS (codesign); this host is ${platform}`);
167
+ if (!lstatSync(file).isFile()) fail("expected regular Mach-O file");
168
+ const original = readFileSync(file);
169
+ const before = parseMachO(original);
170
+ const directory = mkdtempSync(path.join(tmpdir, `rightkit-macho-${process.pid}-${randomBytes(8).toString("hex")}-`), { mode: 0o700 });
171
+ const copy = path.join(directory, "binary");
172
+ try {
173
+ // The copy is written from the captured bytes, never re-read from the path, so the validated snapshot is the one stripped.
174
+ writeFileSync(copy, original, { mode: 0o700 });
175
+ chmodSync(copy, 0o700);
176
+ if (before.signature) removeSignature(copy);
177
+ return machoApprovalDigest(original, readFileSync(copy));
178
+ } finally {
179
+ rmSync(directory, { recursive: true, force: true });
180
+ }
181
+ }
package/managed-cargo.mjs CHANGED
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import path from "node:path";
6
- import { spawnSync } from "./background-process.mjs";
6
+ import { spawn as spawnProcess, spawnSync } from "./background-process.mjs";
7
7
 
8
8
  export function managedRuntimeClient(env = process.env, platform = process.platform, exists = existsSync) {
9
9
  const roots = managedControlCandidates({ env, platform });
@@ -18,6 +18,14 @@ export function managedRuntimeClient(env = process.env, platform = process.platf
18
18
  const client = path.posix.join(env.HOME || homedir(), 'Library', 'Application Support', 'RustRight', 'runtime', 'rustright');
19
19
  if (exists(client)) return client;
20
20
  }
21
+ // RustRight's own install on Windows (2026-10-18 cutover): the runtime moved out of the control root to
22
+ // <drive>\.rustright\runtime, beside the control root's drive, and runtime-current was retired.
23
+ if (platform === 'win32') {
24
+ for (const root of roots) {
25
+ const client = path.win32.join(path.win32.parse(root).root, '.rustright', 'runtime', 'rustright.exe');
26
+ if (exists(client)) return client;
27
+ }
28
+ }
21
29
  return undefined;
22
30
  }
23
31
 
@@ -72,3 +80,51 @@ export function runCargoSync(args, options = {}, { platform = process.platform,
72
80
  function unavailable(message) {
73
81
  return { status: null, stdout: "", stderr: message, error: Object.assign(new Error(message), { code: 'RK_BROKER_UNAVAILABLE' }) };
74
82
  }
83
+
84
+ /**
85
+ * Async `spawnSync` look-alike for the batch publisher: runs the command through the release spawn boundary
86
+ * (background-process.mjs), collects stdout and stderr, and resolves `{ status, signal, stdout, stderr, error }`.
87
+ * It never rejects: a spawn failure, a timeout or an exceeded `maxBuffer` resolves with `error` set, which is
88
+ * exactly how callers already read a failed `spawnSync`.
89
+ */
90
+ export function spawnCollect(command, args, options = {}, { spawnAsync = spawnProcess } = {}) {
91
+ const { timeout, maxBuffer = 512 * 1024 * 1024, encoding: _encoding, stdio: _stdio, ...rest } = options;
92
+ return new Promise((resolve) => {
93
+ let child;
94
+ try { child = spawnAsync(command, args, { ...rest, stdio: ["ignore", "pipe", "pipe"] }); } catch (error) { resolve({ status: null, signal: null, stdout: "", stderr: "", error }); return; }
95
+ const out = []; const err = [];
96
+ let size = 0; let settled = false; let timer; let failure;
97
+ const finish = (result) => { if (settled) return; settled = true; clearTimeout(timer); resolve(result); };
98
+ const take = (chunks) => (chunk) => {
99
+ size += chunk.length;
100
+ if (size > maxBuffer) { failure ??= new Error(`output exceeded maxBuffer (${maxBuffer} bytes)`); child.kill("SIGKILL"); return; }
101
+ chunks.push(chunk);
102
+ };
103
+ child.stdout.on("data", take(out));
104
+ child.stderr.on("data", take(err));
105
+ child.on("error", (error) => finish({ status: null, signal: null, stdout: "", stderr: "", error }));
106
+ child.on("close", (status, signal) => finish({
107
+ status, signal, stdout: Buffer.concat(out).toString("utf8"), stderr: Buffer.concat(err).toString("utf8"), error: failure,
108
+ }));
109
+ if (timeout) timer = setTimeout(() => { failure ??= new Error(`timed out after ${timeout}ms`); child.kill("SIGKILL"); }, timeout);
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Async twin of `runCargoSync`: the same client, broker-socket and darwin-never-direct selection (it asks
115
+ * `runCargoSync` to choose, with a spawn that only records the invocation), then runs the chosen invocation
116
+ * without blocking the event loop so independent crates can publish concurrently. The broker still
117
+ * serializes the builds themselves.
118
+ */
119
+ export async function runCargoAsync(args, options = {}, deps = {}) {
120
+ const { spawnAsync = spawnCollect, ...syncDeps } = deps;
121
+ let chosen;
122
+ const probe = runCargoSync(args, options, { ...syncDeps, spawn: (command, commandArgs, commandOptions) => { chosen = { command, args: commandArgs, options: commandOptions }; return { status: 0, stdout: "", stderr: "" }; } });
123
+ if (!chosen) return probe;
124
+ const result = await spawnAsync(chosen.command, chosen.args, chosen.options);
125
+ const platform = syncDeps.platform ?? process.platform;
126
+ if (platform === 'darwin' && result.status !== 0 && /RK_BROKER_UNAVAILABLE|RightKit broker (?:unavailable|endpoint is not configured)/i.test(String(result.stderr ?? ''))) {
127
+ return { ...result, error: unavailable(String(result.stderr).trim()).error };
128
+ }
129
+ return result;
130
+ }
package/media-sidecar.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // targets.mac.mediaSidecar = {
4
4
  // arch: "aarch64", // catalog arch (default: build host); one arch per build
5
5
  // variant: "decode", // catalog variant (default "decode")
6
+ // variants: { ffmpeg: "export-ass" }, // optional per-tool variant override (a tool not named uses `variant`)
6
7
  // tools: ["ffmpeg", "ffprobe"], // default both
7
8
  // stageDir: "src-tauri/binaries", // Tauri externalBin directory (default)
8
9
  // bundleDir: "Contents/MacOS", // mac only: where Tauri puts externalBin (or "Contents/Resources")
@@ -26,7 +27,7 @@ export const MEDIA_SIDECAR_TOOLS = Object.freeze(["ffmpeg", "ffprobe"]);
26
27
  const PLATFORM_OS = Object.freeze({ mac: "macos", win: "windows", linux: "linux" });
27
28
  const ARCHES = new Set(["aarch64", "x86_64"]);
28
29
  const SHA256 = /^[0-9a-f]{64}$/;
29
- const CONFIG_KEYS = new Set(["arch", "variant", "tools", "stageDir", "bundleDir", "notices", "catalog", "stage"]);
30
+ const CONFIG_KEYS = new Set(["arch", "variant", "variants", "tools", "stageDir", "bundleDir", "notices", "catalog", "stage"]);
30
31
  const BUNDLE_DIRS = new Set(["Contents/MacOS", "Contents/Resources"]);
31
32
 
32
33
  const invalid = (code, message) => Object.assign(new Error(message), { code });
@@ -87,6 +88,18 @@ export function validateMediaSidecarConfig(raw, { platform, hostArch = hostCatal
87
88
  if (!Array.isArray(tools) || !tools.length || new Set(tools).size !== tools.length || tools.some((tool) => !MEDIA_SIDECAR_TOOLS.includes(tool))) {
88
89
  errors.push(`tools must be a non-empty list without duplicates drawn from ${MEDIA_SIDECAR_TOOLS.join(", ")}`);
89
90
  }
91
+ // One entry may mix variants (for example the export-ass ffmpeg with the decode ffprobe): `variants` maps a tool to its variant.
92
+ let variants;
93
+ if (raw.variants !== undefined) {
94
+ if (!isObject(raw.variants) || !Object.keys(raw.variants).length) errors.push("variants must be an object mapping a tool to a catalog variant slug");
95
+ else {
96
+ for (const [tool, slug] of Object.entries(raw.variants)) {
97
+ if (!MEDIA_SIDECAR_TOOLS.includes(tool) || (Array.isArray(tools) && !tools.includes(tool))) errors.push(`variants.${tool}: not one of the configured tools`);
98
+ if (typeof slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) errors.push(`variants.${tool} must be a catalog variant slug: ${JSON.stringify(slug)}`);
99
+ }
100
+ variants = { ...raw.variants };
101
+ }
102
+ }
90
103
  const stageDir = relativePath(raw.stageDir ?? "src-tauri/binaries", "stageDir");
91
104
  let bundleDir;
92
105
  if (os === "macos") {
@@ -109,7 +122,7 @@ export function validateMediaSidecarConfig(raw, { platform, hostArch = hostCatal
109
122
  else stage = raw.stage;
110
123
  }
111
124
  if (errors.length) throw invalid("MEDIA_SIDECAR_CONFIG_INVALID", `invalid mediaSidecar configuration:\n${errors.map((line) => `- ${line}`).join("\n")}`);
112
- return { os, arch, variant, tools: [...tools], stageDir, ...(bundleDir ? { bundleDir } : {}), notices, ...(catalog ? { catalog } : {}), ...(stage ? { stage } : {}) };
125
+ return { os, arch, variant, ...(variants ? { variants } : {}), tools: [...tools], stageDir, ...(bundleDir ? { bundleDir } : {}), notices, ...(catalog ? { catalog } : {}), ...(stage ? { stage } : {}) };
113
126
  }
114
127
 
115
128
  /** `sign.nestedCode` entries for the macOS bundle: the sidecar executables, hardened runtime, no entitlements. */
@@ -126,9 +139,10 @@ export function mediaSidecarNestedCode(config) {
126
139
  export function selectSidecarRows(catalog, config) {
127
140
  if (!isObject(catalog) || !Array.isArray(catalog.artifacts)) throw invalid("MEDIA_SIDECAR_CATALOG_INVALID", "catalog has no artifacts array");
128
141
  return config.tools.map((tool) => {
129
- const matches = catalog.artifacts.filter((row) => row.tool === tool && (row.variant ?? "decode") === config.variant
142
+ const variant = config.variants?.[tool] ?? config.variant;
143
+ const matches = catalog.artifacts.filter((row) => row.tool === tool && (row.variant ?? "decode") === variant
130
144
  && String(row.os).toLowerCase() === config.os && String(row.arch).toLowerCase() === config.arch);
131
- const where = `${tool} ${config.variant} ${config.os}/${config.arch}`;
145
+ const where = `${tool} ${variant} ${config.os}/${config.arch}`;
132
146
  if (matches.length === 0) throw invalid("MEDIA_SIDECAR_NO_ROW", `the rightkit-ffmpeg catalog has no ${where} row`);
133
147
  if (matches.length > 1) throw invalid("MEDIA_SIDECAR_AMBIGUOUS_ROW", `the rightkit-ffmpeg catalog has ${matches.length} ${where} rows`);
134
148
  const [row] = matches;
@@ -152,6 +166,7 @@ export function planMediaSidecar({ config, catalog, crateDir, crateVersion, cata
152
166
  artifactId: row.id,
153
167
  version: row.version,
154
168
  contentSha256: row.contentSha256,
169
+ ...(config.variants ? { variant: row.variant ?? "decode" } : {}),
155
170
  stagedPath: path.resolve(root, config.stageDir, stagedFileName(row.tool, config.os, config.arch)),
156
171
  bundledName: bundledFileName(row.tool, config.os),
157
172
  ...(config.bundleDir ? { bundlePath: `${config.bundleDir}/${bundledFileName(row.tool, config.os)}` } : {}),
@@ -242,6 +257,7 @@ export function buildMediaSidecarReceipt(plan, preSign, signed, { notices = [],
242
257
  tool: file.tool,
243
258
  artifactId: file.artifactId,
244
259
  version: file.version,
260
+ ...(file.variant ? { variant: file.variant } : {}),
245
261
  catalogContentSha256: file.contentSha256,
246
262
  preSignSha256: file.preSignSha256,
247
263
  signedSha256: after.sha256,
@@ -1,11 +1,25 @@
1
1
  #!/usr/bin/env node
2
- import { copyFileSync, lstatSync, mkdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
2
+ // Two forms:
3
+ // right-release mirror-root-artifact --file <artifact> --package-root <dir> [--name <filename>]
4
+ // right-release mirror-root-artifact --config <right-release.config.mjs> --platform mac|win [--package-root <dir>]
5
+ // The config form mirrors every entry of `targets.<platform>.mirror`, a list of `"<app-relative file>"` or
6
+ // `{ file, name?, pruneSuffix? }`. `pruneSuffix` (for example "-setup.exe") then removes the other files with that suffix
7
+ // from the managed directory, so it holds exactly the current artifact. Run it as `targets.<platform>.postSign`.
8
+ // Output goes only to RIGHT_RELEASE_ARTIFACT_DIR (outside the repository); it never writes into the repository root.
9
+ import { configuredManagedRootSpellings } from "./artifact-storage.mjs";
10
+ import { copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
3
11
  import { execFileSync } from "./background-process.mjs";
4
12
  import path from "node:path";
13
+ import { pathToFileURL } from "node:url";
5
14
 
6
15
  const args = parseArgs(process.argv.slice(2));
7
- if (!args.file || !args.packageRoot) {
8
- console.error("Usage: right-release mirror-root-artifact --file <artifact> --package-root <dir> [--name <filename>]");
16
+ const usage = "Usage: right-release mirror-root-artifact (--file <artifact> --package-root <dir> [--name <filename>] | --config <file> --platform mac|win [--package-root <dir>])";
17
+ if (!args.config && (!args.file || !args.packageRoot)) {
18
+ console.error(usage);
19
+ process.exit(2);
20
+ }
21
+ if (args.config && (!args.platform || args.file)) {
22
+ console.error(usage);
9
23
  process.exit(2);
10
24
  }
11
25
  if (!process.env.RIGHT_RELEASE_ARTIFACT_DIR) {
@@ -13,33 +27,68 @@ if (!process.env.RIGHT_RELEASE_ARTIFACT_DIR) {
13
27
  process.exit(2);
14
28
  }
15
29
 
16
- const packageRoot = realpathSync(path.resolve(args.packageRoot));
17
- const source = path.resolve(packageRoot, args.file);
18
- const name = args.name || path.basename(source);
30
+ let entries;
31
+ let rootArg = args.packageRoot;
32
+ if (args.config) {
33
+ const configPath = path.resolve(args.config);
34
+ const loaded = await import(pathToFileURL(configPath).href);
35
+ const config = loaded.default ?? loaded.config ?? loaded;
36
+ const declared = config?.targets?.[args.platform]?.mirror;
37
+ if (!Array.isArray(declared) || declared.length === 0) {
38
+ console.error(`right-release mirror-root-artifact: targets.${args.platform}.mirror declares no files in ${args.config}`);
39
+ process.exit(2);
40
+ }
41
+ entries = declared.map((item) => (typeof item === "string" ? { file: item } : item));
42
+ for (const entry of entries) {
43
+ const unknown = entry && typeof entry === "object" ? Object.keys(entry).filter((key) => !["file", "name", "pruneSuffix"].includes(key)) : ["(not an object)"];
44
+ if (typeof entry?.file !== "string" || !entry.file.trim() || unknown.length || path.isAbsolute(entry.file) || entry.file.replaceAll("\\", "/").split("/").includes("..")) {
45
+ console.error(`right-release mirror-root-artifact: each mirror entry is an app-relative file or { file, name?, pruneSuffix? }: ${JSON.stringify(entry)}`);
46
+ process.exit(2);
47
+ }
48
+ }
49
+ rootArg ??= path.dirname(configPath);
50
+ } else {
51
+ entries = [{ file: args.file, name: args.name }];
52
+ }
53
+
54
+ const packageRoot = realpathSync(path.resolve(rootArg));
19
55
  const managedOutput = path.resolve(process.env.RIGHT_RELEASE_ARTIFACT_DIR);
20
56
  const repoRoot = path.resolve(git(packageRoot, "rev-parse", "--show-toplevel"));
21
57
  if (inside(managedOutput, repoRoot)) {
22
58
  console.error(`right-release mirror-root-artifact: managed output must be outside repository: ${managedOutput}`);
23
59
  process.exit(2);
24
60
  }
25
- if (name !== path.basename(name) || !/^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/.test(name)) {
26
- console.error(`right-release mirror-root-artifact: unsafe artifact name: ${name}`);
27
- process.exit(2);
28
- }
29
- assertSafePath(source);
30
- if (!statSync(source).isFile()) {
31
- console.error(`right-release mirror-root-artifact: source is not a regular file: ${source}`);
32
- process.exit(2);
33
- }
34
- const destinations = new Set([path.join(managedOutput, name)]);
61
+ const safeName = (name) => name === path.basename(name) && /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/.test(name);
35
62
 
36
- for (const destination of destinations) {
37
- if (path.resolve(destination) === source) continue;
38
- atomicCopy(source, destination);
63
+ for (const entry of entries) {
64
+ const source = path.resolve(packageRoot, entry.file);
65
+ const name = entry.name || path.basename(source);
66
+ if (!safeName(name)) {
67
+ console.error(`right-release mirror-root-artifact: unsafe artifact name: ${name}`);
68
+ process.exit(2);
69
+ }
70
+ if (entry.pruneSuffix !== undefined && (typeof entry.pruneSuffix !== "string" || !/^[A-Za-z0-9._+-]{1,40}$/.test(entry.pruneSuffix) || !name.endsWith(entry.pruneSuffix))) {
71
+ console.error(`right-release mirror-root-artifact: pruneSuffix must be a plain suffix of the artifact name: ${entry.pruneSuffix}`);
72
+ process.exit(2);
73
+ }
74
+ assertSafePath(source);
75
+ if (!statSync(source).isFile()) {
76
+ console.error(`right-release mirror-root-artifact: source is not a regular file: ${source}`);
77
+ process.exit(2);
78
+ }
79
+ const destination = path.join(managedOutput, name);
80
+ if (path.resolve(destination) !== source) atomicCopy(source, destination);
81
+ if (entry.pruneSuffix) {
82
+ for (const stale of readdirSync(managedOutput)) {
83
+ if (stale !== name && stale.endsWith(entry.pruneSuffix) && safeName(stale)) {
84
+ assertSafePath(path.join(managedOutput, stale));
85
+ rmSync(path.join(managedOutput, stale), { force: true });
86
+ }
87
+ }
88
+ }
89
+ console.log(`right-release: managed artifact -> ${destination}`);
39
90
  }
40
91
 
41
- console.log(`right-release: managed artifact -> ${[...destinations][0]}`);
42
-
43
92
  function git(cwd, ...commandArgs) {
44
93
  return execFileSync("git", commandArgs, { cwd, encoding: "utf8" }).trim();
45
94
  }
@@ -65,11 +114,14 @@ function atomicCopy(from, to) {
65
114
  function assertSafePath(target) {
66
115
  const absolute = path.resolve(target);
67
116
  const root = path.parse(absolute).root;
117
+ // Only the configured managed root itself may be a symlink (same rule as artifact-storage assertSafeMutation).
118
+ const managedRoots = configuredManagedRootSpellings();
119
+ const configured = (candidate) => managedRoots.has(process.platform === "win32" ? path.resolve(candidate).toLowerCase() : path.resolve(candidate));
68
120
  let cursor = root;
69
121
  for (const segment of path.relative(root, absolute).split(path.sep).filter(Boolean)) {
70
122
  cursor = path.join(cursor, segment);
71
123
  try {
72
- if (lstatSync(cursor).isSymbolicLink()) throw new Error(`refusing filesystem mutation through symlink: ${cursor}`);
124
+ if (lstatSync(cursor).isSymbolicLink() && !configured(cursor)) throw new Error(`refusing filesystem mutation through symlink: ${cursor}`);
73
125
  } catch (error) {
74
126
  if (error.code !== "ENOENT") throw error;
75
127
  }
@@ -18,6 +18,8 @@ export function resolveNativeCargoLayout({
18
18
  read = (file) => readFileSync(file, "utf8"),
19
19
  requireManifest = false,
20
20
  requireLock = false,
21
+ // The repository the app lives in. A manifest outside the app searches for its workspace only up to this directory.
22
+ repoRoot,
21
23
  } = {}) {
22
24
  if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
23
25
  const resolvedAppRoot = path.resolve(appRoot);
@@ -31,7 +33,7 @@ export function resolveNativeCargoLayout({
31
33
  const explicitWorkspace = nativeAssembly?.workspaceRoot ?? nativeAssembly?.cargoWorkspaceRoot;
32
34
  const workspaceRoot = explicitWorkspace
33
35
  ? normalizeWorkspaceRoot(resolveDeclaredPath(resolvedAppRoot, explicitWorkspace), exists)
34
- : discoverWorkspaceRoot({ appRoot: resolvedAppRoot, manifestPath, manifestDir, manifestText, exists, read });
36
+ : discoverWorkspaceRoot({ appRoot: resolvedAppRoot, manifestPath, manifestDir, manifestText, exists, read, repoRoot: repoRoot ? path.resolve(repoRoot) : undefined });
35
37
 
36
38
  const lock = resolveCargoLock({
37
39
  appRoot: resolvedAppRoot,
@@ -84,6 +86,28 @@ export function resolveNativeCargoLayout({
84
86
  return layout;
85
87
  }
86
88
 
89
+ /**
90
+ * True when the app has a native Cargo manifest the release must hash and build against: the config declares one
91
+ * (nativeAssembly.cargoManifest / manifest) or the historical src-tauri/Cargo.toml exists. A native iOS app (xcodegen
92
+ * project, Rust static library built by iosPackage.prepare) has neither, and then owns no Cargo layout at all.
93
+ */
94
+ export function nativeCargoDeclared({ appRoot, config, nativeAssembly = config?.nativeAssembly, exists = existsSync } = {}) {
95
+ if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
96
+ if (nativeAssembly?.cargoManifest ?? nativeAssembly?.manifest) return true;
97
+ return exists(path.resolve(appRoot, DEFAULT_MANIFEST));
98
+ }
99
+
100
+ /** The manifest nativeAssembly names, if any (a path relative to the app root, or absolute). */
101
+ export function declaredCargoManifest(config, nativeAssembly = config?.nativeAssembly) {
102
+ return nativeAssembly?.cargoManifest ?? nativeAssembly?.manifest;
103
+ }
104
+
105
+ /** True when the historical Tauri host manifest exists. */
106
+ export function hasDefaultTauriManifest({ appRoot, exists = existsSync } = {}) {
107
+ if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
108
+ return exists(path.resolve(appRoot, DEFAULT_MANIFEST));
109
+ }
110
+
87
111
  export const resolveCargoLayout = resolveNativeCargoLayout;
88
112
  export const resolveNativeCargoManifest = resolveNativeCargoLayout;
89
113
  export const resolveCargoManifest = resolveNativeCargoLayout;
@@ -160,20 +184,29 @@ function resolveCargoLock({ appRoot, manifestPath, manifestDir, workspaceRoot, n
160
184
  return { path: found, source: found ? orderedSources[candidates.indexOf(found)] : undefined, candidates };
161
185
  }
162
186
 
163
- function discoverWorkspaceRoot({ appRoot, manifestPath, manifestDir, manifestText, exists, read }) {
187
+ function discoverWorkspaceRoot({ appRoot, manifestPath, manifestDir, manifestText, exists, read, repoRoot }) {
164
188
  const parsed = parseCargoManifest(manifestText);
165
189
  const declaredWorkspace = parsed.workspacePath ?? parsed.packageWorkspace;
166
190
  if (declaredWorkspace) {
167
191
  return normalizeWorkspaceRoot(resolveDeclaredPath(manifestDir, declaredWorkspace), exists);
168
192
  }
169
193
  if (parsed.hasWorkspace) return manifestDir;
194
+ // A manifest inside the app searches up to the app root (unchanged); one outside it (a shared crates/ tree) searches up to the
195
+ // nearest directory that contains both, which is where its workspace and Cargo.lock live.
196
+ // Only a caller that names the repository opts in to the climb above the app (native iOS); every other caller keeps the
197
+ // exact historical search, which never leaves the app root.
198
+ let boundary = appRoot;
199
+ if (repoRoot && !isWithin(appRoot, manifestDir)) {
200
+ boundary = commonAncestor(appRoot, manifestDir);
201
+ if (!isWithin(repoRoot, boundary)) boundary = repoRoot;
202
+ }
170
203
  let current = manifestDir;
171
204
  for (;;) {
172
205
  const candidate = path.join(current, "Cargo.toml");
173
206
  if (candidate !== manifestPath && exists(candidate) && parseCargoManifest(readIfPresent(candidate, exists, read)).hasWorkspace) return current;
174
- if (current === appRoot) break;
207
+ if (current === boundary) break;
175
208
  const parent = path.dirname(current);
176
- if (parent === current || !isWithin(appRoot, parent)) break;
209
+ if (parent === current || !isWithin(boundary, parent)) break;
177
210
  current = parent;
178
211
  }
179
212
  return manifestDir;
@@ -231,6 +264,16 @@ function readIfPresent(file, exists, read) {
231
264
  try { return exists(file) ? read(file) : ""; } catch { return ""; }
232
265
  }
233
266
 
267
+ function commonAncestor(left, right) {
268
+ let current = path.resolve(left);
269
+ while (!isWithin(current, path.resolve(right))) {
270
+ const parent = path.dirname(current);
271
+ if (parent === current) return current;
272
+ current = parent;
273
+ }
274
+ return current;
275
+ }
276
+
234
277
  function isWithin(root, candidate) {
235
278
  const relative = path.relative(root, candidate);
236
279
  return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));