@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
@@ -36,7 +36,7 @@
36
36
  // "channel": "env", // env | argv | settings | preference | frontend
37
37
  // "anchor": "src/test_mode.rs:97", // where it is read (informational)
38
38
  // "compiledOut": "feature:test-mode", // required for S1-S4: feature:<name> | cfg:test |
39
- // // cfg:debug_assertions (read exists only there)
39
+ // // cfg:debug_assertions | swift:DEBUG (read exists only there)
40
40
  // "valueType": "bool", // required for S6: bool | int | enum
41
41
  // "userGesture": true, // required for S5: chosen by a user gesture
42
42
  // "note": "free text" } ],
@@ -54,7 +54,8 @@ const CHUNK = 4 * 1024 * 1024;
54
54
  const FORBIDDEN_CLASSES = new Set(["S1", "S2", "S3", "S4"]);
55
55
  const CLASSES = new Set(["S1", "S2", "S3", "S4", "S5", "S6"]);
56
56
  const CHANNELS = new Set(["env", "argv", "settings", "preference", "frontend"]);
57
- const COMPILED_OUT = /^(feature:[A-Za-z0-9_-]+|cfg:test|cfg:debug_assertions)$/;
57
+ // `swift:DEBUG`: the read sits inside `#if DEBUG`, which an Xcode Release configuration compiles out.
58
+ const COMPILED_OUT = /^(feature:[A-Za-z0-9_-]+|cfg:test|cfg:debug_assertions|swift:DEBUG)$/;
58
59
 
59
60
  export const REGISTRY = JSON.parse(readFileSync(new URL("./release-switch-registry.json", import.meta.url), "utf8"));
60
61
 
@@ -64,12 +65,13 @@ export const REGISTRY = JSON.parse(readFileSync(new URL("./release-switch-regist
64
65
  // `cargo tree -e features` lines look like: `├── rightkit-daemon feature "test-mode"` (possibly `(*)` suffixed).
65
66
  const FEATURE_LINE = /^[\s│├└─|`+\\-]*([A-Za-z0-9_][A-Za-z0-9_-]*)(?: v[^\s]+)? feature "([^"]+)"/;
66
67
 
67
- export function featureClosureFindings(treeText, { forbidden = REGISTRY.forbiddenFeatures } = {}) {
68
+ export function featureClosureFindings(treeText, { forbidden = [...REGISTRY.forbiddenFeatures, ...REGISTRY.conditionalForbiddenFeatures["content-only"]], allowDevtools = false } = {}) {
68
69
  const deny = new Set(forbidden);
69
70
  const findings = []; const seen = new Set();
70
71
  for (const line of String(treeText).split(/\r?\n/)) {
71
72
  const m = FEATURE_LINE.exec(line);
72
- if (!m || !deny.has(m[2])) continue;
73
+ if (!m || (!deny.has(m[2]) && !deny.has(`${m[1]}/${m[2]}`))) continue;
74
+ if (allowDevtools && REGISTRY.conditionalForbiddenFeatures["content-only"].includes(m[2])) continue;
73
75
  const id = `forbidden-feature ${m[2]}`;
74
76
  const key = `${m[1]}/${m[2]}`;
75
77
  if (seen.has(key)) continue;
@@ -120,7 +122,13 @@ function readPackageScripts(root) {
120
122
  // skip is `rust === false` (releaseSwitches.rust: false, an explicit "this app has no Rust") and only when no candidate
121
123
  // manifest exists. Returns { treeText, manifest } or { skipped: <reason> }. Throws when cargo fails: the caller fails closed.
122
124
  // `run` is injectable for tests: (command, args, options) => { status, stdout, stderr, error }.
123
- export function resolveFeatureClosure({ root, manifest, cargoManifest, rust, features = [], scripts, triple, commandTexts = [], run = spawnSync } = {}) {
125
+ export function resolveFeatureClosure(options = {}) {
126
+ return resolveCargoTree(options);
127
+ }
128
+
129
+ /** Shared Cargo tree resolver; keeps each build command's feature selection separate. */
130
+ export function resolveCargoTree({ root, manifest, cargoManifest, rust, features = [], scripts, triple, commandTexts = [],
131
+ edges = "features,no-dev", prefix, noDefaultFeatures = false, label = "feature closure", run = spawnSync } = {}) {
124
132
  const { rel, declared, candidates } = resolveCargoManifestPath({ root, cargoManifest, manifest });
125
133
  if (!rel) {
126
134
  if (rust === false) return { skipped: "releaseSwitches.rust is false: the app declares no Rust" };
@@ -129,7 +137,8 @@ export function resolveFeatureClosure({ root, manifest, cargoManifest, rust, fea
129
137
  if (declared.length && !declared.some((c) => existsSync(path.join(root, c)))) {
130
138
  throw new Error(`release-switch-manifest-missing: declared manifest ${declared.join(", ")} does not exist under ${root}`);
131
139
  }
132
- const baseArgs = ["tree", "-e", "features,no-dev", "--locked", "--manifest-path", path.join(root, rel), "--target", triple || "all"];
140
+ const baseArgs = ["tree", "--color", "never", "-e", edges, "--locked", "--manifest-path", path.join(root, rel), "--target", triple || "all"];
141
+ if (prefix) baseArgs.push("--prefix", prefix);
133
142
  const pkgScripts = scripts ?? readPackageScripts(root);
134
143
  // One cargo tree per distinct (features, no-default-features, manifest) combination, one combination per build command
135
144
  // (flags gathered over the command and the package.json scripts it runs). A `--no-default-features` in one command never
@@ -143,30 +152,33 @@ export function resolveFeatureClosure({ root, manifest, cargoManifest, rust, fea
143
152
  if (script && depth < 3 && Object.hasOwn(pkgScripts, script) && !seen.has(script)) expand(String(pkgScripts[script]), depth + 1, new Set([...seen, script]));
144
153
  };
145
154
  expand(text, 0, new Set());
146
- let noDefault = false;
155
+ let noDefault = noDefaultFeatures;
156
+ let allFeatures = false;
147
157
  const enabled = [...features];
148
158
  for (const t of expanded) {
149
159
  const tokens = commandTokens(t);
150
160
  if (tokens.includes("--no-default-features")) noDefault = true;
161
+ if (tokens.includes("--all-features")) allFeatures = true;
151
162
  enabled.push(...featuresInTokens(tokens).enabled);
152
163
  }
153
164
  const list = [...new Set(enabled)].sort();
154
- combos.set(`${rel}|${noDefault}|${list.join(",")}`, { noDefault, list });
165
+ combos.set(`${rel}|${noDefault}|${allFeatures}|${list.join(",")}`, { noDefault, allFeatures, list });
155
166
  };
156
167
  const texts = commandTexts.filter(Boolean);
157
168
  if (texts.length === 0) comboFor("");
158
169
  for (const text of texts) comboFor(text);
159
170
  const trees = [];
160
- for (const { noDefault, list } of combos.values()) {
171
+ for (const { noDefault, allFeatures, list } of combos.values()) {
161
172
  const args = [...baseArgs];
162
173
  if (noDefault) args.push("--no-default-features");
174
+ if (allFeatures) args.push("--all-features");
163
175
  if (list.length) args.push("--features", list.join(","));
164
176
  const result = run("cargo", args, { cwd: root, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
165
177
  if (result.error || result.status !== 0) {
166
- throw new Error(`feature closure: \`cargo ${args.join(" ")}\` failed (${result.error?.message ?? `exit ${result.status}`}): ${String(result.stderr ?? "").trim().slice(0, 600)}. The resolved feature graph is the authoritative test-mode check, so a release cannot continue without it.`);
178
+ throw new Error(`${label}: \`cargo ${args.join(" ")}\` failed (${result.error?.message ?? `exit ${result.status}`}): ${String(result.stderr ?? "").trim().slice(0, 600)}. The resolved feature graph is the authoritative test-mode check, so a release cannot continue without it.`);
167
179
  }
168
180
  const out = String(result.stdout ?? "");
169
- if (!out.trim()) throw new Error("feature closure: `cargo tree` printed nothing; cannot verify");
181
+ if (!out.trim()) throw new Error(`${label}: \`cargo tree\` printed nothing; cannot verify`);
170
182
  trees.push(out);
171
183
  }
172
184
  const treeText = trees.join("\n");
@@ -181,7 +193,7 @@ export function inventoryDenyNames(inventoryText) {
181
193
  }
182
194
 
183
195
  // Findings for a build command (`--features test-mode`, `-F`, `--all-features`, ...). Uses the existing tokenizer.
184
- export function commandSwitchFindings(argv, { forbidden = REGISTRY.forbiddenFeatures } = {}) {
196
+ export function commandSwitchFindings(argv, { forbidden = [...REGISTRY.forbiddenFeatures, ...REGISTRY.conditionalForbiddenFeatures["content-only"]], allowDevtools = false } = {}) {
185
197
  const deny = new Set(forbidden);
186
198
  const tokens = argv;
187
199
  const enabled = []; let all = false;
@@ -196,7 +208,8 @@ export function commandSwitchFindings(argv, { forbidden = REGISTRY.forbiddenFeat
196
208
  const findings = [];
197
209
  for (const item of enabled) {
198
210
  const name = item.includes("/") ? item.slice(item.lastIndexOf("/") + 1) : item;
199
- if (deny.has(name)) findings.push({ layer: "feature-closure", rule: `forbidden-feature ${name}`, file: "<command>", reason: `forbidden-feature ${name}: build command enables \`${item}\`` });
211
+ if (allowDevtools && REGISTRY.conditionalForbiddenFeatures["content-only"].includes(name)) continue;
212
+ if (deny.has(name) || deny.has(item.replace("?/", "/"))) findings.push({ layer: "feature-closure", rule: `forbidden-feature ${name}`, file: "<command>", reason: `forbidden-feature ${name}: build command enables \`${item}\`` });
200
213
  }
201
214
  if (all) findings.push({ layer: "feature-closure", rule: "forbidden-feature all-features", file: "<command>", reason: "forbidden-feature all-features: build command enables every feature, including test-mode" });
202
215
  return findings;
@@ -208,7 +221,7 @@ export function commandSwitchFindings(argv, { forbidden = REGISTRY.forbiddenFeat
208
221
  export function parseInventory(text, label = "switches.json") {
209
222
  const issues = [];
210
223
  let doc;
211
- try { doc = JSON.parse(text); } catch (error) { return { doc: null, issues: [{ rule: "inventory-invalid", file: label, reason: `inventory-invalid: ${label} is not valid JSON (${error.message})` }] }; }
224
+ try { doc = JSON.parse(text); } catch (error) { return { doc: null, issues: [{ layer: "inventory", rule: "inventory-invalid", file: label, reason: `inventory-invalid: ${label} is not valid JSON (${error.message})` }] }; }
212
225
  const bad = (reason) => issues.push({ layer: "inventory", rule: "inventory-invalid", file: label, reason: `inventory-invalid: ${reason}` });
213
226
  if (doc?.schema !== 1) bad("`schema` must be 1");
214
227
  if (typeof doc?.app !== "string" || !doc.app) bad("`app` must be a non-empty string");
@@ -223,7 +236,7 @@ export function parseInventory(text, label = "switches.json") {
223
236
  if (!CHANNELS.has(entry.channel)) bad(`${where}: \`channel\` must be one of ${[...CHANNELS].join(", ")}`);
224
237
  if (FORBIDDEN_CLASSES.has(entry.class)) {
225
238
  if (typeof entry.compiledOut !== "string" || !COMPILED_OUT.test(entry.compiledOut)) {
226
- issues.push({ layer: "inventory", rule: "not-compiled-out", file: label, reason: `not-compiled-out: ${where} is class ${entry.class} and must declare \`compiledOut\` as feature:<name>, cfg:test or cfg:debug_assertions (release builds may not contain it)` });
239
+ issues.push({ layer: "inventory", rule: "not-compiled-out", file: label, reason: `not-compiled-out: ${where} is class ${entry.class} and must declare \`compiledOut\` as feature:<name>, cfg:test, cfg:debug_assertions or swift:DEBUG (release builds may not contain it)` });
227
240
  }
228
241
  } else if (entry.class === "S6") {
229
242
  if (!["bool", "int", "enum"].includes(entry.valueType)) bad(`${where}: class S6 needs a bounded \`valueType\` (bool, int or enum)`);
@@ -235,13 +248,20 @@ export function parseInventory(text, label = "switches.json") {
235
248
  }
236
249
 
237
250
  const SOURCE_EXT = new Set([".rs", ".ts", ".tsx", ".js", ".mjs", ".cjs", ".jsx", ".vue", ".svelte", ".swift", ".c", ".cc", ".cpp", ".h", ".m", ".mm"]);
238
- const SKIP_DIRS = new Set(["node_modules", "target", "dist", "build", ".git", "tests", "__tests__", "benches", "examples", "fixtures", "test", "coverage", ".vite"]);
251
+ // Build output and fetched dependencies, never app source: the release recipe's own .right-release/ (Xcode DerivedData, whose
252
+ // SourcePackages/checkouts holds whole dependency repositories), SwiftPM's .build and .swiftpm, and Xcode DerivedData anywhere.
253
+ const SKIP_DIRS = new Set(["node_modules", "target", "dist", "build", ".git", "tests", "__tests__", "benches", "examples", "fixtures", "test", "coverage", ".vite", ".right-release", "deriveddata", "sourcepackages", ".build", ".swiftpm"]);
254
+ // Swift/Xcode test bundles are capitalised (`Tests`, `UITests`, `<App>Tests`, `<App>UITests`): they are test code too.
255
+ const skipDir = (name) => SKIP_DIRS.has(name.toLowerCase()) || /(?:^|[a-z0-9])(?:UI)?Tests$/.test(name);
239
256
  const TEST_FILE = /(\.test\.|\.spec\.|_tests?\.rs$|^tests?\.rs$)/;
240
257
 
258
+ const CARGO_COMPILE_TIME = /\b(?:option_)?env!\(\s*"([^"]+)"/g;
259
+ // Variables Cargo sets for every build script and crate compilation (the Cargo book, "Environment variables Cargo sets for crates").
260
+ const CARGO_SET = /^(CARGO|CARGO_(PKG_[A-Z_]+|MANIFEST_DIR|MANIFEST_PATH|CRATE_NAME|BIN_NAME|BIN_EXE_[A-Za-z0-9_-]+|PRIMARY_PACKAGE|TARGET_TMPDIR|RUSTC_CURRENT_DIR)|OUT_DIR)$/;
241
261
  // Reads of a literal name; group 1 is the name.
242
262
  const LITERAL_READS = [
243
263
  /\b(?:std::)?env::var(?:_os)?\(\s*"([^"]+)"\s*\)/g,
244
- /\b(?:option_)?env!\(\s*"([^"]+)"/g,
264
+ CARGO_COMPILE_TIME,
245
265
  /\bgetenv\(\s*"([^"]+)"\s*\)/g,
246
266
  /process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
247
267
  /process\.env\[\s*["']([^"']+)["']\s*\]/g,
@@ -277,7 +297,7 @@ function* walkSource(root) {
277
297
  for (const entry of entries) {
278
298
  const full = path.join(dir, entry.name);
279
299
  if (entry.isSymbolicLink()) continue;
280
- if (entry.isDirectory()) { if (!SKIP_DIRS.has(entry.name)) stack.push(full); continue; }
300
+ if (entry.isDirectory()) { if (!skipDir(entry.name)) stack.push(full); continue; }
281
301
  if (entry.isFile() && SOURCE_EXT.has(path.extname(entry.name)) && !TEST_FILE.test(entry.name)) yield full;
282
302
  }
283
303
  }
@@ -286,7 +306,7 @@ function* walkSource(root) {
286
306
  function lineOf(text, index) { let n = 1; for (let i = 0; i < index; i += 1) if (text.charCodeAt(i) === 10) n += 1; return n; }
287
307
 
288
308
  // inventoryText: contents of switches.json. Returns findings only.
289
- export function sourceSwitchFindings({ sourceRoot, inventoryText, inventoryLabel = "switches.json", extraDeny = [] }) {
309
+ export function sourceSwitchFindings({ sourceRoot, sourceRoots, inventoryText, inventoryLabel = "switches.json", extraDeny = [] }) {
290
310
  const { doc, issues } = parseInventory(inventoryText, inventoryLabel);
291
311
  const findings = [...issues];
292
312
  if (!doc || !Array.isArray(doc.switches)) return findings;
@@ -294,7 +314,9 @@ export function sourceSwitchFindings({ sourceRoot, inventoryText, inventoryLabel
294
314
  const dynamicAllowed = new Set((doc.dynamicReads ?? []).map((d) => path.normalize(d.file)));
295
315
  const reads = new Set();
296
316
  const denyFlags = [...REGISTRY.denyNames, ...extraDeny.map((n) => ({ name: typeof n === "string" ? n : n.name }))].map((d) => d.name).filter((n) => n.startsWith("-"));
297
- for (const file of walkSource(sourceRoot)) {
317
+ // `sourceRoots` (absolute directories) narrows the walk to what a target compiles; paths stay relative to `sourceRoot`.
318
+ const walkRoots = Array.isArray(sourceRoots) && sourceRoots.length ? sourceRoots : [sourceRoot];
319
+ for (const file of walkRoots.flatMap((walkRoot) => [...walkSource(walkRoot)])) {
298
320
  let text;
299
321
  try { text = readFileSync(file, "utf8"); } catch { continue; }
300
322
  const rel = path.relative(sourceRoot, file);
@@ -304,6 +326,9 @@ export function sourceSwitchFindings({ sourceRoot, inventoryText, inventoryLabel
304
326
  for (let m = pattern.exec(text); m; m = pattern.exec(text)) {
305
327
  const name = m[1];
306
328
  if (JS_BUILTINS.has(name) && /\.(ts|tsx|js|mjs|cjs|jsx|vue|svelte)$/.test(file)) continue;
329
+ // `env!`/`option_env!` of a name Cargo itself sets at compile time (CARGO_PKG_VERSION, CARGO_MANIFEST_DIR, ...) is
330
+ // not a switch: no caller can choose it. Any other compile-time name is still a read the builder could set.
331
+ if (pattern === CARGO_COMPILE_TIME && CARGO_SET.test(name)) continue;
307
332
  reads.add(name);
308
333
  if (!registered.has(name)) findings.push({ layer: "inventory", rule: "unregistered-read", file: `${rel}:${lineOf(text, m.index)}`, reason: `unregistered-read: \`${name}\` is read here but is not listed in ${inventoryLabel}` });
309
334
  }
@@ -420,18 +445,18 @@ export function scanSwitchPaths(paths, { denyMap = denyNamesFor() } = {}) {
420
445
  // Every layer is optional input; a layer given no input is skipped, except that `requireBinary` fails a scan that
421
446
  // found no native binary. `bypass: ["feature-closure"]` exists only for the negative fixtures that prove the binary
422
447
  // layer independently; release.mjs never passes it.
423
- export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, inventoryText, binaryPaths = [], appForbid = [], requireBinary = true, bypass = [], remoteHost } = {}) {
448
+ export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, sourceRoots, inventoryText, binaryPaths = [], appForbid = [], requireBinary = true, bypass = [], remoteHost, devtools } = {}) {
424
449
  const findings = [];
425
450
  const bypassed = new Set(bypass);
426
451
  const denyMap = denyNamesFor({ appForbid: [...appForbid, ...(inventoryText != null ? inventoryDenyNames(inventoryText) : [])] });
427
452
  if (!bypassed.has("feature-closure")) {
428
453
  if (treeText != null) {
429
- findings.push(...featureClosureFindings(treeText));
454
+ findings.push(...featureClosureFindings(treeText, { allowDevtools: devtools?.mode === "content-only" }));
430
455
  findings.push(...remoteServiceFindings(treeText, { remoteHost }));
431
456
  }
432
- for (const argv of commands) findings.push(...commandSwitchFindings(argv));
457
+ for (const argv of commands) findings.push(...commandSwitchFindings(argv, { allowDevtools: devtools?.mode === "content-only" }));
433
458
  }
434
- if (sourceRoot && inventoryText != null) findings.push(...sourceSwitchFindings({ sourceRoot, inventoryText, extraDeny: appForbid }));
459
+ if (sourceRoot && inventoryText != null) findings.push(...sourceSwitchFindings({ sourceRoot, sourceRoots, inventoryText, extraDeny: appForbid }));
435
460
  let receipt = { files: [], binaries: 0 };
436
461
  if (binaryPaths.length) {
437
462
  const scanned = scanSwitchPaths(binaryPaths, { denyMap });
@@ -450,3 +475,16 @@ export function assertNoReleaseSwitches(input, label = "release") {
450
475
  + "Build release and update tiers without the `test-mode` feature, and register every environment read in the app's switches.json.",
451
476
  );
452
477
  }
478
+
479
+ /** Local (path) packages in a `cargo tree` text under `root`: the directories of crates a target actually compiles. */
480
+ export function localCrateDirsFromTree(treeText, root) {
481
+ const dirs = new Set();
482
+ const base = path.resolve(root);
483
+ // `name v1 (/abs/path)` and `name v1 (proc-macro) (/abs/path)`; a bare `(*)` or `(proc-macro)` is a marker, not a path.
484
+ for (const m of String(treeText ?? "").matchAll(/ v\S+(?: \(proc-macro\))? \(([^)]+)\)/g)) {
485
+ if (!path.isAbsolute(m[1])) continue;
486
+ const dir = path.resolve(m[1]);
487
+ if (dir === base || dir.startsWith(base + path.sep)) dirs.add(dir);
488
+ }
489
+ return [...dirs].sort();
490
+ }
package/release.mjs CHANGED
@@ -11,8 +11,8 @@ import { validateRightKitCargoContract } from "./cargo-contract.mjs";
11
11
  import { assertCargoProfileContract } from "./cargo-profile-contract.mjs";
12
12
  import { assertQaAdoption, assertQaBackdoorContract } from "./qa-contract.mjs";
13
13
  import { assertLegalReleaseContract } from "./legal-contract.mjs";
14
- import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
15
- import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
14
+ import { assertNativeIosPrepareHasManifest, assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
15
+ import { declaredCargoManifest, hasDefaultTauriManifest, resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
16
16
  import {
17
17
  canonicalPackageIdentity,
18
18
  createNativeFinalizationReceipt,
@@ -45,11 +45,15 @@ import { resolveMacosDeveloperIdIdentity } from "./macos-signing-identity.mjs";
45
45
  import { DEFAULT_STORAGE_THRESHOLD_BYTES, findInstallerCopyScripts, findLargeRepositorySubdirectories } from "./installer-storage-doctor.mjs";
46
46
  import { macosCodesignEnvironment } from "./codesign-environment.mjs";
47
47
  import { assertNoQaControl, assertNoWebBundleMocks } from "./qa-control-scan.mjs";
48
+ import { assertReleaseDependencies } from "./release-dependencies.mjs";
48
49
  import { assertNoForbiddenBuildFeatures } from "./qa-build-features.mjs";
49
50
  import { expandRecipeBuildCommands } from "./recipe-build.mjs";
50
- import { assertNoReleaseSwitches, commandTokens, resolveFeatureClosure } from "./release-switch-scan.mjs";
51
+ import { assertNoReleaseSwitches, commandTokens, localCrateDirsFromTree, resolveFeatureClosure } from "./release-switch-scan.mjs";
52
+ import { iosOwnershipScope } from "./ownership-scope.mjs";
51
53
  import { devtoolsEvidence, resolveDevtoolsAllowance } from "./devtools-policy.mjs";
52
54
  import { runAndroidRelease, validateAndroidTarget } from "./android-release.mjs";
55
+ import { runIosPackageRecipe } from "./ios-package-recipe.mjs";
56
+ import { releaseSwitchInputs } from "./ios-build-context.mjs";
53
57
 
54
58
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
55
59
  const HARDENING_SCAN = path.resolve(TOOL_ROOT, "hardeningscan.mjs");
@@ -179,11 +183,14 @@ const SUPERSEDE_JOB_HEAD = /^\n supersede:\n needs: \[private-repo-guard\]\n
179
183
 
180
184
  const root = path.dirname(configPath);
181
185
  const workdir = path.resolve(root, config.workdir ?? ".");
182
- const nativeLayout = resolveNativeCargoLayout({ appRoot: root, config });
186
+ // A native iOS app (xcodegen project; Rust built by iosPackage.prepare) with no src-tauri and no declared nativeAssembly
187
+ // manifest owns no Cargo layout: null, so the Cargo-derived preflight, sweep and package-contract steps are skipped for it.
188
+ const nativeIos = opts.platform === "ios" && !hasDefaultTauriManifest({ appRoot: root });
189
+ const nativeLayout = nativeIos ? null : resolveNativeCargoLayout({ appRoot: root, config });
183
190
  const nativeAssembly = config.nativeAssembly;
184
191
  const nativeFinalizer = resolveNativeFinalizerConfig(nativeAssembly);
185
- // A SwiftPM app without a package.json has no Node package contract to validate.
186
- if (!(resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.platform] }) === "swiftpm" && !existsSync(path.join(root, "package.json")))) {
192
+ // A SwiftPM app or a native iOS app without a package.json has no Node package contract to validate.
193
+ if (!((nativeIos || resolveProjectType({ appRoot: root, config, target: config.targets?.[opts.platform] }) === "swiftpm") && !existsSync(path.join(root, "package.json")))) {
187
194
  await validateRightKitPackageContract(root, config.app, config.hostedWorkflows);
188
195
  }
189
196
  validateRightKitCargoContract(root, RIGHTKIT_CARGO_ALLOWED, config.app ?? path.basename(root));
@@ -256,12 +263,18 @@ let androidTarget; // validated Android AAB target (android only; see android-re
256
263
  if (opts.platform === "android") {
257
264
  try { androidTarget = validateAndroidTarget(target, { root }); } catch (error) { fail(`${config.app ?? "app"} android ${error.message}`); }
258
265
  }
266
+ let iosSpec; // validated iOS iosPackage spec (ios only; package-recipe-targets.mjs expands targets.ios, ios-package-recipe.mjs runs it)
267
+ if (opts.platform === "ios") {
268
+ if (target.recipe?.kind !== "iosPackage") fail(`${config.app ?? "app"} ios must declare targets.ios.iosPackage; there is no hand-written iOS package script`);
269
+ iosSpec = target.recipe.spec;
270
+ if (nativeIos) { try { assertNativeIosPrepareHasManifest(iosSpec, config, root); } catch (error) { fail(`${config.app ?? "app"} ios ${error.message}`); } }
271
+ }
259
272
  if (opts.upload && target.publishBlocked) fail(`${config.app ?? "app"} ${opts.platform} publish blocked: ${target.publishBlocked}`);
260
273
  /**
261
274
  * Assemble preflight inputs from the app's own files. Kept here (not in
262
275
  * preflight.mjs) so the check module stays free of release.mjs's config shape.
263
276
  */
264
- function releasePreflight({ config, configPath, root, repoRoot, platform, nativeLayout: suppliedNativeLayout = nativeLayout, projectTarget = config.targets?.[platform] }) {
277
+ function releasePreflight({ config, configPath, root, repoRoot, platform, nativeLayout: suppliedNativeLayout = nativeLayout, projectTarget = config.targets?.[platform], pinnedCargoLocks = [] }) {
265
278
  let version;
266
279
  try {
267
280
  version = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version;
@@ -272,6 +285,7 @@ function releasePreflight({ config, configPath, root, repoRoot, platform, native
272
285
  // them means the build needs a real Windows Perl.
273
286
  const cargoLockPaths = [
274
287
  ...(suppliedNativeLayout?.lockCandidates ?? []),
288
+ ...pinnedCargoLocks,
275
289
  ...(config.cargoLocks ?? []).map((entry) => path.resolve(root, entry)),
276
290
  ].filter((entry, index, entries) => entries.indexOf(entry) === index);
277
291
  return collectPreflight({
@@ -285,12 +299,14 @@ function releasePreflight({ config, configPath, root, repoRoot, platform, native
285
299
  nativeLayout: suppliedNativeLayout,
286
300
  targetLink: suppliedNativeLayout?.targetLink,
287
301
  projectType: resolveProjectType({ appRoot: root, config, target: projectTarget }),
302
+ rust: Boolean(suppliedNativeLayout) || pinnedCargoLocks.length > 0,
303
+ bridge: Boolean(suppliedNativeLayout),
288
304
  msix: targetUsesMsix(projectTarget),
289
305
  });
290
306
  }
291
307
 
292
308
  if (opts.doctor) {
293
- const { buildInputs, nativeLayout: resolvedNativeLayout } = resolveReleaseBuildInputs({
309
+ const { buildInputs, nativeLayout: resolvedNativeLayout, cargoLockPaths: pinnedCargoLocks } = resolveReleaseBuildInputs({
294
310
  repoRoot: doctorInvocation.repoRoot,
295
311
  appRoot: root,
296
312
  configPath,
@@ -334,7 +350,7 @@ if (opts.doctor) {
334
350
 
335
351
  // Config above, MACHINE below. Printing config never told anyone whether this
336
352
  // box could finish a build; these checks do.
337
- const checks = releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform, nativeLayout: resolvedNativeLayout });
353
+ const checks = releasePreflight({ config, configPath, root, repoRoot: doctorInvocation.repoRoot, platform: opts.platform, nativeLayout: resolvedNativeLayout, pinnedCargoLocks });
338
354
  console.log("preflight:");
339
355
  console.log(formatPreflight(checks));
340
356
  const failures = preflightFailures(checks);
@@ -349,10 +365,15 @@ let prePackScanned = false; // set once a pre-pack payload scan has seen a nativ
349
365
  // or app Cargo manifest enables devtools, qa-native, qa-control or dev-native (also in --dry-run).
350
366
  // DevTools: refused for every app except an allow-listed one that declares `devtools: "content-only"` (devtools-policy.mjs).
351
367
  let devtoolsAllowance = { mode: "denied" };
368
+ // The Rust a build compiles is checked from the manifest it compiles: targets.<os>.cargoManifest, else the declared
369
+ // nativeAssembly.cargoManifest. iosPackage.prepare commands run cargo for a native iOS app, so they are scanned like any build command.
370
+ const switchInputs = releaseSwitchInputs({ appRoot: root, config, target, prepare: iosSpec?.prepare ?? [], nativeIos });
371
+ if (switchInputs.problems.length) fail(`${config.app ?? "app"} ios release-switch scan cannot see what is built:\n- ${switchInputs.problems.join("\n- ")}`);
352
372
  try {
353
373
  devtoolsAllowance = resolveDevtoolsAllowance({ config, root });
354
374
  if (devtoolsAllowance.mode === "content-only") console.log(`right-release: devtools allowance recorded: ${JSON.stringify(devtoolsEvidence(devtoolsAllowance))}`);
355
- assertNoForbiddenBuildFeatures({ root, tier: "release/update", devtools: devtoolsAllowance, cargoManifest: target.cargoManifest, manifest: config.releaseSwitches?.manifest, commands: [
375
+ assertNoForbiddenBuildFeatures({ root, tier: "release/update", devtools: devtoolsAllowance, cargoManifest: switchInputs.cargoManifest, manifest: config.releaseSwitches?.manifest, commands: [
376
+ ...switchInputs.commands,
356
377
  { label: "target.prePackage", command: target.prePackage },
357
378
  { label: "target.package", command: target.package },
358
379
  { label: "nativeAssembly.packageHook", command: nativeAssembly?.packageHook },
@@ -373,16 +394,18 @@ const releaseSwitchInventoryText = existsSync(releaseSwitchInventoryPath) ? read
373
394
  try {
374
395
  // A `node recipe-build.mjs --plan <JSON>` prePackage hides each step's `--features` in one argument: the closure and the
375
396
  // command token scan see every prepare / build / postBuild step (a plan that cannot be read or validated fails closed).
376
- const closureCommands = [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, ...commandList(target.postSign), target.gradle].filter(Boolean).flatMap((c) => expandRecipeBuildCommands(c, { root }));
397
+ const closureCommands = [target.prePackage, target.package, nativeAssembly?.packageHook, nativeFinalizer, ...commandList(target.postSign), target.gradle, ...switchInputs.cargoCommands].filter(Boolean).flatMap((c) => expandRecipeBuildCommands(c, { root }));
398
+ assertReleaseDependencies({ root, config, target, commands: closureCommands });
377
399
  let treeText;
378
400
  try {
379
401
  const closure = resolveFeatureClosure({
380
402
  root,
381
403
  manifest: config.releaseSwitches?.manifest,
382
- cargoManifest: target.cargoManifest,
404
+ cargoManifest: switchInputs.cargoManifest,
383
405
  rust: config.releaseSwitches?.rust,
384
406
  features: config.releaseSwitches?.features ?? [],
385
- triple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget,
407
+ // iOS ships one device triple; "all" is refused by the build broker and would pull in desktop-only targets.
408
+ triple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? (opts.platform === "ios" ? "aarch64-apple-ios" : undefined),
386
409
  commandTexts: closureCommands,
387
410
  });
388
411
  if (closure.skipped) console.log(`right-release: feature closure skipped (${closure.skipped})`);
@@ -392,9 +415,26 @@ try {
392
415
  console.log(`right-release: dry run: ${error.message}`);
393
416
  }
394
417
  // remoteHost: rightkit-daemon `remote` is refused in the closure unless the app declares releaseSwitches.remoteHost: true.
395
- const closureInput = { treeText, remoteHost: config.releaseSwitches?.remoteHost, commands: closureCommands.map((c) => commandTokens(c)) };
418
+ const closureInput = { treeText, devtools: devtoolsAllowance, remoteHost: config.releaseSwitches?.remoteHost, commands: closureCommands.map((c) => commandTokens(c)) };
396
419
  if (releaseSwitchInventoryText != null) {
397
- assertNoReleaseSwitches({ ...closureInput, sourceRoot: root, inventoryText: releaseSwitchInventoryText, appForbid: config.releaseSwitches?.forbid ?? [] }, "source switch inventory");
420
+ // iOS: only what the iPhone app compiles — the Xcode project and its local packages, plus the local crates in the
421
+ // resolved aarch64-apple-ios cargo graph (feature-aware, unlike the ownership closure). Other targets walk the app root.
422
+ let sourceRoots;
423
+ if (opts.platform === "ios") {
424
+ const repoTop = git(root, ["rev-parse", "--show-toplevel"]);
425
+ // Swift side only: the Rust side is the resolved cargo graph below. The ownership scope also folds in the whole
426
+ // nativeAssembly workspace, which would scan crates (desktop tools, the TUI) the iPhone never links.
427
+ const swiftSide = (iosOwnershipScope({ repoRoot: repoTop, appRoot: root, target: config.targets?.ios }) ?? []).filter((dir) => dir !== ".rightkit").map((dir) => path.join(repoTop, dir));
428
+ sourceRoots = [...new Set([...swiftSide, ...localCrateDirsFromTree(treeText, repoTop)])].sort();
429
+ // A prepare step can stage sources from anywhere (a web bundle, a generated resource) into the app; its working directory must be
430
+ // inside the scanned roots, or the release fails instead of shipping what the scan never saw. Add the directory to ownershipScope.
431
+ for (const step of config.targets?.ios?.iosPackage?.prepare ?? []) {
432
+ const cwd = path.resolve(root, step?.cwd ?? ".");
433
+ if (!sourceRoots.some((dir) => cwd === dir || cwd.startsWith(dir + path.sep))) throw new Error(`source switch inventory: targets.ios.iosPackage.prepare runs in ${path.relative(repoTop, cwd) || "."}, outside the iOS scan roots (${sourceRoots.map((dir) => path.relative(repoTop, dir) || ".").join(", ")}); list that directory in targets.ios.ownershipScope`);
434
+ }
435
+ console.log(`right-release: source switch scan (ios): ${sourceRoots.map((dir) => path.relative(repoTop, dir) || ".").join(", ")}`);
436
+ }
437
+ assertNoReleaseSwitches({ ...closureInput, sourceRoot: root, sourceRoots, inventoryText: releaseSwitchInventoryText, appForbid: config.releaseSwitches?.forbid ?? [] }, "source switch inventory");
398
438
  } else if (treeText != null || closureInput.commands.length) {
399
439
  assertNoReleaseSwitches({ ...closureInput, appForbid: config.releaseSwitches?.forbid ?? [] }, "feature closure");
400
440
  }
@@ -406,7 +446,7 @@ try {
406
446
  const releaseLock = acquireReleaseLock(root, config.app ?? path.basename(root), opts.platform, opts.tier);
407
447
  const mode = "package";
408
448
  const command = target.package;
409
- if (!command && opts.platform !== "android" && !packageCommandOptional(target, resolveProjectType({ appRoot: root, config, target }))) fail(`${config.app ?? "app"} has no ${opts.platform} ${mode} command`);
449
+ if (!command && opts.platform !== "android" && opts.platform !== "ios" && !packageCommandOptional(target, resolveProjectType({ appRoot: root, config, target }))) fail(`${config.app ?? "app"} has no ${opts.platform} ${mode} command`);
410
450
  if (opts.upload && !target.publish && !target.upload) {
411
451
  fail(`${config.app ?? "app"} has no ${opts.platform} upload config`);
412
452
  }
@@ -522,6 +562,16 @@ if (opts.platform === "win" && target.signingContract === MSIX_SIGNING_CONTRACT)
522
562
  receiptDir: path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts")),
523
563
  allowContentOnlyDevtools: devtoolsAllowance.mode === "content-only",
524
564
  });
565
+ } else if (opts.platform === "ios") {
566
+ // Archive and export with Xcode automatic signing in the GUI session, then the IPA verification and the receipt (macOS only;
567
+ // --dry-run prints the plan anywhere). Upload is a separate command: `right-release upload --platform ios`.
568
+ await runIosPackageRecipe({
569
+ root, app: config.app ?? path.basename(root), spec: iosSpec, dryRun: opts.dryRun,
570
+ receiptDir: path.resolve(process.env.RIGHT_RELEASE_STATE_ROOT ?? path.join(root, ".right-release", "receipts")),
571
+ scanFiles: (label, files, scanOptions) => assertNoQaControlBinaries(label, files, scanOptions),
572
+ // The build number counter outlives every release id: it lives in the vault state root that right-release build exports.
573
+ ...(process.env.RIGHT_RELEASE_VAULT_STATE_ROOT ? { counterDir: path.join(path.resolve(process.env.RIGHT_RELEASE_VAULT_STATE_ROOT), "ios-build-numbers") } : {}),
574
+ });
525
575
  } else if (nativeAssembly) {
526
576
  if (nativeAssembly.packageHook) await runCommand(nativeAssembly.packageHook, root);
527
577
  if (nativeFinalizer) {
@@ -645,7 +695,7 @@ function assertNoQaControlBinaries(label, files, { requireBinary = false, prePac
645
695
  try {
646
696
  const stats = assertNoQaControl(files, label, { requireBinary, allowContentOnlyDevtools: devtoolsAllowance.mode === "content-only" });
647
697
  // D4 binary layer, same inputs: no test-mode marker and no S1-S4 selector name in any native binary.
648
- assertNoReleaseSwitches({ binaryPaths: files, inventoryText: releaseSwitchInventoryText ?? undefined, appForbid: config.releaseSwitches?.forbid ?? [], requireBinary: false }, `${label} (release switches)`);
698
+ assertNoReleaseSwitches({ binaryPaths: files, devtools: devtoolsAllowance, inventoryText: releaseSwitchInventoryText ?? undefined, appForbid: config.releaseSwitches?.forbid ?? [], requireBinary: false }, `${label} (release switches)`);
649
699
  if (prePack && stats.binaries > 0) prePackScanned = true;
650
700
  for (const opaque of stats.opaque) {
651
701
  console.log(`right-release: ${label}: ${opaque.kind.toUpperCase()} payload is compressed and cannot be scanned (${opaque.file}); the raw payload was scanned before packing`);
@@ -906,6 +956,8 @@ function hasNativeTargetSpecificPrePackage(target, packageHook) {
906
956
  // publish so it cannot race an artifact, and is best-effort: a missing
907
957
  // cargo-sweep or a sweep failure must never fail a release.
908
958
  function sweepStaleRustArtifacts() {
959
+ // No Cargo host, nothing of ours to sweep.
960
+ if (!nativeLayout) return;
909
961
  const candidates = [...new Set([root, workdir, nativeLayout.manifestDir, nativeLayout.workspaceRoot])];
910
962
  const projects = candidates.filter((dir) => existsSync(path.join(dir, "target")));
911
963
  for (const dir of projects) {
@@ -1132,9 +1184,13 @@ function signExistingRefusal(source) {
1132
1184
  // The renderer comes from this monorepo's own packages/ci when present, else from the repository's installed @rightkit/ci; with
1133
1185
  // neither the file is refused, because an unverifiable sign-existing.yml must not be accepted.
1134
1186
  async function loadRightKitCiRenderer(root) {
1135
- const candidates = [pathToFileURL(fileURLToPath(new URL("../ci/sync.mjs", import.meta.url))).href];
1187
+ return (await loadRightKitCiModule(root, "sync.mjs", "renderLanes"))?.renderLanes ?? null;
1188
+ }
1189
+
1190
+ async function loadRightKitCiModule(root, file, exportName) {
1191
+ const candidates = [pathToFileURL(fileURLToPath(new URL(`../ci/${file}`, import.meta.url))).href];
1136
1192
  try {
1137
- candidates.push(pathToFileURL(createRequire(path.join(root, "package.json")).resolve("@rightkit/ci/sync.mjs")).href);
1193
+ candidates.push(pathToFileURL(createRequire(path.join(root, "package.json")).resolve(`@rightkit/ci/${file}`)).href);
1138
1194
  } catch {
1139
1195
  // not installed in the repository; the sibling candidate may still apply
1140
1196
  }
@@ -1142,7 +1198,7 @@ async function loadRightKitCiRenderer(root) {
1142
1198
  try {
1143
1199
  if (href.startsWith("file:") && !existsSync(fileURLToPath(href))) continue;
1144
1200
  const loaded = await import(href);
1145
- if (typeof loaded.renderLanes === "function") return loaded.renderLanes;
1201
+ if (typeof loaded[exportName] === "function") return loaded;
1146
1202
  } catch {
1147
1203
  // try the next candidate
1148
1204
  }
@@ -1165,6 +1221,57 @@ async function signExistingRenderRefusal(root, manifest, source) {
1165
1221
  }
1166
1222
 
1167
1223
 
1224
+ // `workflows` block entries render from code, so they are admitted only when the block passes @rightkit/ci's own
1225
+ // validateWorkflows and each file is byte-for-byte the @rightkit/ci render. With no resolvable @rightkit/ci they are refused.
1226
+ async function validateDeclaredWorkflowBlock(root, appName, manifest, declaredFiles) {
1227
+ const validator = (await loadRightKitCiModule(root, "workflows.mjs", "validateWorkflows"))?.validateWorkflows;
1228
+ const renderLanes = await loadRightKitCiRenderer(root);
1229
+ if (!validator || !renderLanes) fail(`${appName} right-git-ci-only policy cannot verify .rightgit.json workflows: @rightkit/ci is not available (install @rightkit/ci as a devDependency)`);
1230
+ const errors = [];
1231
+ validator(manifest, errors);
1232
+ if (errors.length > 0) fail(`${appName} right-git-ci-only policy refuses the .rightgit.json workflows block: ${errors.join("; ")}`);
1233
+ let rendered;
1234
+ try {
1235
+ rendered = renderLanes(manifest, root);
1236
+ } catch (error) {
1237
+ fail(`${appName} right-git-ci-only policy cannot render the .rightgit.json workflows block: ${error.message}`);
1238
+ }
1239
+ for (const file of declaredFiles) {
1240
+ const expected = rendered.find((entry) => entry.filename === file)?.content;
1241
+ const actual = await readFile(path.join(root, ".github", "workflows", file), "utf8");
1242
+ if (typeof expected !== "string" || actual.replace(/\r\n?/g, "\n") !== expected.replace(/\r\n?/g, "\n")) {
1243
+ fail(`${appName} ${file} is not byte-for-byte the @rightkit/ci render of its .rightgit.json workflows entry (run right-git sync)`);
1244
+ }
1245
+ }
1246
+ }
1247
+
1248
+ // Structural backstop for declared workflows (after render equality): the one top-level permissions block is exactly
1249
+ // `contents: read`, no other permissions key exists, no secrets reference, every `uses:` is commit-pinned, and every
1250
+ // checkout step sets persist-credentials: false. Steps are split on step-list indentation, not matched as free text.
1251
+ function declaredWorkflowPolicyRefusal(appName, workflow, source) {
1252
+ const refuse = (why) => fail(`${appName} right-git managed ${workflow} violates the declared-workflow policy: ${why}`);
1253
+ const lines = source.replace(/\r\n?/g, "\n").split("\n");
1254
+ const permissionsAt = lines.flatMap((line, i) => (/^\s*permissions\s*:/.test(line) ? [i] : []));
1255
+ if (permissionsAt.length !== 1 || lines[permissionsAt[0]] !== "permissions:" || lines[permissionsAt[0] + 1] !== " contents: read" || /^ {2,}\S/.test(lines[permissionsAt[0] + 2] ?? "")) refuse("permissions must be exactly one top-level `contents: read`");
1256
+ if (/\bsecrets\b/i.test(source)) refuse("it references the secrets context");
1257
+ let step = null;
1258
+ const steps = [];
1259
+ for (const line of lines) {
1260
+ if (/^ {6}- /.test(line)) { step = [line]; steps.push(step); }
1261
+ else if (step && (/^ {7,}\S/.test(line) || line === "")) step.push(line);
1262
+ else step = null;
1263
+ }
1264
+ for (const body of steps) {
1265
+ const uses = /^ {6}- uses: (\S+)|^ {8}uses: (\S+)/m.exec(body.join("\n"));
1266
+ if (!uses) continue;
1267
+ const ref = uses[1] ?? uses[2];
1268
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_./-]+@[0-9a-f]{40}$/.test(ref)) refuse(`action ${ref} is not commit-pinned`);
1269
+ if (/^actions\/checkout@/.test(ref) && !body.some((line) => /^ {10}persist-credentials: false\s*$/.test(line))) refuse("a checkout step does not set persist-credentials: false");
1270
+ if (body.some((line) => /persist-credentials:\s*true/.test(line))) refuse("persist-credentials: true");
1271
+ }
1272
+ if (/^\s*(?:- )?uses:/m.test(lines.filter((l) => !steps.some((b) => b.includes(l))).join("\n"))) refuse("a uses: sits outside a step list");
1273
+ }
1274
+
1168
1275
  async function validateHostedWorkflows(root, appName, workflows, policy) {
1169
1276
  if (policy !== "right-git-ci-only") {
1170
1277
  fail(`${appName} hosted workflow files are forbidden: ${workflows.join(", ")}`);
@@ -1188,15 +1295,29 @@ async function validateHostedWorkflows(root, appName, workflows, policy) {
1188
1295
  || lanes.some((lane) => !safeLanes.has(lane))) {
1189
1296
  fail(`${appName} right-git-ci-only policy requires compile=github-actions-only plus unique .rightgit.json lanes drawn from [${[...safeLanes].map((lane) => `"${lane}"`).join(", ")}] and including "ci" or "swift-check"`);
1190
1297
  }
1191
- const expectedWorkflows = lanes.map((lane) => `${lane}.yml`).sort();
1298
+ // Workflows declared under the manifest's `workflows` block (@rightkit/ci workflows.mjs: explicit `file`,
1299
+ // else `<name>.yml`) are managed files too, in addition to the lane files.
1300
+ const declaredWorkflows = manifest?.workflows;
1301
+ if (declaredWorkflows !== undefined && (declaredWorkflows === null || typeof declaredWorkflows !== "object" || Array.isArray(declaredWorkflows))) {
1302
+ fail(`${appName} right-git-ci-only policy requires .rightgit.json workflows to be an object`);
1303
+ }
1304
+ const declaredFiles = Object.entries(declaredWorkflows ?? {}).map(([name, config]) => {
1305
+ if (config === null || typeof config !== "object" || Array.isArray(config)) fail(`${appName} right-git-ci-only policy requires .rightgit.json workflows.${name} to be an object`);
1306
+ const file = typeof config?.file === "string" ? config.file : `${name}.yml`;
1307
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,60}\.yml$/.test(file)) fail(`${appName} right-git-ci-only policy refuses workflow file name ${JSON.stringify(file)} declared by .rightgit.json`);
1308
+ return file;
1309
+ });
1310
+ const expectedWorkflows = [...new Set([...lanes.map((lane) => `${lane}.yml`), ...declaredFiles])].sort();
1192
1311
  if (JSON.stringify(workflows) !== JSON.stringify(expectedWorkflows)) {
1193
1312
  fail(`${appName} right-git-ci-only policy requires workflows declared by .rightgit.json; expected ${expectedWorkflows.join(", ")}, got: ${workflows.join(", ")}`);
1194
1313
  }
1314
+ if (declaredFiles.length > 0) await validateDeclaredWorkflowBlock(root, appName, manifest, declaredFiles);
1195
1315
  for (const workflow of expectedWorkflows) {
1196
1316
  const source = await readFile(path.join(root, ".github", "workflows", workflow), "utf8");
1197
1317
  if (!source.startsWith("# Managed by right-git")) {
1198
1318
  fail(`${appName} ${workflow} is not marked as right-git managed`);
1199
1319
  }
1320
+ if (declaredFiles.includes(workflow)) declaredWorkflowPolicyRefusal(appName, workflow, source);
1200
1321
  const jobSpan = (jobName) => {
1201
1322
  const marker = `\n ${jobName}:\n`;
1202
1323
  const start = source.indexOf(marker);
@@ -1361,6 +1482,8 @@ function expandEnv(value) {
1361
1482
  // notices and the source offer, optionally run the app's stage command, and (before signing) check the staged files.
1362
1483
  async function prepareMediaSidecar(root, { stage = true } = {}) {
1363
1484
  if (!mediaSidecarConfig) return null;
1485
+ // Unreachable for iOS (mediaSidecar is mac and win only, refused above); a Cargo-less layout must still never be dereferenced.
1486
+ if (!nativeLayout) fail(`${config.app ?? "app"} ${opts.platform} mediaSidecar needs a native Cargo manifest to read the sidecar catalog, and this app has none`);
1364
1487
  let plan;
1365
1488
  try {
1366
1489
  const loaded = loadSidecarCatalog({ config: mediaSidecarConfig, root, manifestPath: nativeLayout.manifestPath });