@rightkit/release 0.4.0 → 0.5.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 (50) hide show
  1. package/README.md +59 -7
  2. package/adhoc-entitlements.mjs +8 -0
  3. package/adoption-evidence.mjs +650 -0
  4. package/advisory-exceptions.mjs +371 -0
  5. package/apple-profile-create.mjs +113 -10
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/rightkit-gate.mjs +1079 -0
  8. package/binary-license-notices.mjs +161 -0
  9. package/cargo-license-notices.mjs +111 -0
  10. package/cli/right-release.mjs +39 -0
  11. package/deterministic-scans.mjs +966 -0
  12. package/development-signing.mjs +2 -1
  13. package/host-upload-refusal.mjs +90 -0
  14. package/local-publish.mjs +142 -19
  15. package/local-qualify.mjs +728 -0
  16. package/local-runtime.mjs +319 -0
  17. package/lock-graph.mjs +51 -0
  18. package/managed-cargo.mjs +4 -1
  19. package/ownership-approval.mjs +23 -6
  20. package/ownership-dependency-allowlist.json +2 -0
  21. package/ownership-mandatory-set.json +158 -68
  22. package/ownership-mandatory-set.mjs +82 -6
  23. package/package.json +6 -2
  24. package/pipeline-admission.mjs +375 -0
  25. package/pipeline-config.mjs +100 -0
  26. package/pipeline-generators.json +4 -0
  27. package/pipeline-generators.mjs +137 -0
  28. package/pipeline-receipts.mjs +302 -0
  29. package/pipeline-scan.mjs +285 -0
  30. package/pipeline-tools.mjs +171 -0
  31. package/production-mode.mjs +42 -0
  32. package/publication-gate.mjs +94 -32
  33. package/publish-cargo.mjs +24 -7
  34. package/publish-swift.mjs +12 -3
  35. package/qa-control-scan.mjs +4 -0
  36. package/release-config.mjs +330 -0
  37. package/release-switch-scan.mjs +44 -5
  38. package/release.mjs +14 -4
  39. package/rightkit-ownership.json +57 -0
  40. package/rightkit-sync.mjs +8 -0
  41. package/rightkit-versions.json +191 -131
  42. package/runtime-budget-classes.json +8 -0
  43. package/runtime-budget-crates.mjs +14 -0
  44. package/runtime-budgets.mjs +128 -31
  45. package/scan-digest.mjs +107 -0
  46. package/sign-qa-bundle.mjs +210 -0
  47. package/stage-sidecar.mjs +99 -15
  48. package/swap-helper-bundle.mjs +7 -3
  49. package/workflow-yaml.mjs +177 -0
  50. package/bin/darwin/rightkit-swap.sign.json +0 -6
@@ -21,6 +21,10 @@
21
21
  "ceilings": null,
22
22
  "requiredScale": null
23
23
  },
24
+ "rightkit-crash": {
25
+ "ceilings": null,
26
+ "requiredScale": null
27
+ },
24
28
  "rightkit-embed": {
25
29
  "ceilings": null,
26
30
  "requiredScale": null
@@ -41,6 +45,10 @@
41
45
  "ceilings": null,
42
46
  "requiredScale": null
43
47
  },
48
+ "rightkit-overlay-win": {
49
+ "ceilings": null,
50
+ "requiredScale": null
51
+ },
44
52
  "rightkit-record": {
45
53
  "ceilings": null,
46
54
  "requiredScale": null
@@ -0,0 +1,14 @@
1
+ // The continuous owners that must carry a runtime budget (docs/RUNTIME-BUDGETS.md). Kept free of
2
+ // dependencies so the secretless publish plan job can check the runtime proof table against it.
3
+ export const RUNTIME_BUDGET_CRATES = Object.freeze([
4
+ "rightkit-fsindex",
5
+ "rightkit-file-watch",
6
+ "rightkit-job-queue",
7
+ "rightkit-daemon",
8
+ "rightkit-cache",
9
+ "rightkit-sync",
10
+ "rightkit-logs",
11
+ "rightkit-tauri-shell",
12
+ "rightkit-qa",
13
+ "rightkit-send",
14
+ ]);
@@ -59,6 +59,7 @@ import path from "node:path";
59
59
  import { fileURLToPath } from "node:url";
60
60
  import { spawnSync } from "./background-process.mjs";
61
61
  import { parse } from "smol-toml";
62
+ import { RUNTIME_BUDGET_CRATES } from "./runtime-budget-crates.mjs";
62
63
  import { singleHostOfPolicy } from "./publication-gate.mjs";
63
64
  import { findWorkspaceRoot, lockClosure, pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
64
65
 
@@ -68,18 +69,7 @@ export const RUNTIME_BUDGET_FILE = "runtime-budget.json";
68
69
  export const RUNTIME_BUDGET_EVIDENCE_DIR = "runtime-budget.evidence";
69
70
  export const RUNTIME_BUDGET_SCHEMA = 1;
70
71
 
71
- export const RUNTIME_BUDGET_CRATES = Object.freeze([
72
- "rightkit-fsindex",
73
- "rightkit-file-watch",
74
- "rightkit-job-queue",
75
- "rightkit-daemon",
76
- "rightkit-cache",
77
- "rightkit-sync",
78
- "rightkit-logs",
79
- "rightkit-tauri-shell",
80
- "rightkit-qa",
81
- "rightkit-send",
82
- ]);
72
+ export { RUNTIME_BUDGET_CRATES };
83
73
 
84
74
  export const BUDGET_FIGURES = Object.freeze(["residentMemoryBytes", "idleCpuPercent", "wakeupsPerSecond", "diskOpsPerSecond"]);
85
75
  const FIGURE_LABEL = Object.freeze({
@@ -260,17 +250,86 @@ const posix = (p) => p.split(path.sep).join("/");
260
250
  /**
261
251
  * The git pathspecs (relative to the workspace root) whose change makes a measurement stale: the
262
252
  * crate's src, benches, Cargo.toml (features) and build.rs, plus src, Cargo.toml and build.rs of every
263
- * workspace crate it reaches through `path` dependencies (transitively). Returns { root, specs }.
253
+ * workspace crate it reaches through `path` dependencies (transitively). Returns { root, specs, problems }.
254
+ * Callers must refuse incomplete metadata rather than using a partial dependency scope.
264
255
  */
265
256
  export function measuredPathspecs(crateDir, deps = {}) {
266
- const root = findWorkspaceRoot(crateDir, deps) ?? path.resolve(crateDir);
257
+ const { exists = existsSync, readFile = readFileSync } = deps;
258
+ const problems = [];
259
+ // Workspace discovery deliberately tolerates bad ancestor manifests elsewhere. Freshness cannot:
260
+ // a swallowed read/parse failure could hide inherited dependencies and shrink the measured graph.
261
+ const checkedRead = (file, encoding) => {
262
+ try {
263
+ const contents = readFile(file, encoding);
264
+ if (path.basename(file) === "Cargo.toml") parse(contents);
265
+ return contents;
266
+ } catch (error) {
267
+ problems.push(`cannot read dependency metadata ${file}: ${error.message}`);
268
+ throw error;
269
+ }
270
+ };
271
+ const checked = { exists, readFile: checkedRead };
272
+ let root = path.resolve(crateDir);
267
273
  const specs = [];
268
274
  const add = (dir, names) => { for (const name of names) specs.push(posix(path.relative(root, path.join(dir, name)))); };
269
- add(path.resolve(crateDir), CRATE_PATHS);
270
- let dependencies = [];
271
- try { dependencies = pathDependencyDirs(crateDir, deps); } catch { /* unreadable manifest: the crate paths alone */ }
272
- for (const dir of dependencies) add(dir, DEPENDENCY_PATHS);
273
- return { root, specs: [...new Set(specs)] };
275
+ try {
276
+ root = findWorkspaceRoot(crateDir, checked) ?? root;
277
+ add(path.resolve(crateDir), CRATE_PATHS);
278
+ const dependencies = pathDependencyDirs(crateDir, checked);
279
+ const workspace = exists(path.join(root, "Cargo.toml")) ? parse(checkedRead(path.join(root, "Cargo.toml"), "utf8")).workspace : undefined;
280
+ for (const dir of [path.resolve(crateDir), ...dependencies]) {
281
+ const manifestPath = path.join(dir, "Cargo.toml");
282
+ if (!exists(manifestPath)) {
283
+ // A manifest-free standalone path has no Cargo graph. A declared path dependency must exist.
284
+ if (dir !== path.resolve(crateDir)) problems.push(`dependency manifest is missing: ${manifestPath}`);
285
+ continue;
286
+ }
287
+ const manifest = parse(checkedRead(manifestPath, "utf8"));
288
+ if (!nonEmpty(manifest.package?.name)) problems.push(`dependency manifest has no package.name: ${manifestPath}`);
289
+ const holders = [manifest, ...Object.values(manifest.target ?? {})];
290
+ for (const holder of holders) {
291
+ for (const key of ["dependencies", "build-dependencies", ...(dir === path.resolve(crateDir) ? ["dev-dependencies"] : [])]) {
292
+ for (const [name, spec] of Object.entries(holder?.[key] ?? {})) {
293
+ if (spec && typeof spec === "object" && spec.workspace === true && workspace?.dependencies?.[name] === undefined) {
294
+ problems.push(`workspace dependency ${name} cannot be resolved for ${manifestPath}`);
295
+ }
296
+ }
297
+ }
298
+ }
299
+ }
300
+ for (const dir of dependencies) add(dir, DEPENDENCY_PATHS);
301
+ } catch (error) {
302
+ if (!problems.length) problems.push(`cannot resolve measured dependency paths for ${crateDir}: ${error.message}`);
303
+ }
304
+ return { root, specs: [...new Set(specs)], problems: [...new Set(problems)] };
305
+ }
306
+
307
+ // lockClosure compares reachable entries, but its permissive parser can return an empty/partial graph.
308
+ // Require the root and every reachable edge before allowing equality to establish freshness.
309
+ function completeLockClosure(contents, name) {
310
+ const packages = parse(contents).package;
311
+ if (!Array.isArray(packages)) throw new Error("missing [[package]] entries");
312
+ const roots = packages.filter((entry) => entry.name === name);
313
+ if (!roots.length) throw new Error(`missing package ${name}`);
314
+ const seen = new Set();
315
+ const pending = [...roots];
316
+ while (pending.length) {
317
+ const entry = pending.pop();
318
+ if (seen.has(entry)) continue;
319
+ seen.add(entry);
320
+ if (!nonEmpty(entry.name) || !nonEmpty(entry.version)) throw new Error(`incomplete package identity for ${entry.name ?? name}`);
321
+ if (entry.dependencies !== undefined && !Array.isArray(entry.dependencies)) throw new Error(`invalid dependencies for ${entry.name}`);
322
+ for (const edge of entry.dependencies ?? []) {
323
+ if (!nonEmpty(edge)) throw new Error(`invalid dependency edge for ${entry.name}`);
324
+ const match = /^(\S+)(?: (\S+))?(?: \(([^)]+)\))?$/.exec(edge);
325
+ if (!match) throw new Error(`invalid dependency ${edge} of ${entry.name}`);
326
+ const candidates = packages.filter((candidate) => candidate.name === match[1]
327
+ && (!match[2] || candidate.version === match[2]) && (!match[3] || candidate.source === match[3]));
328
+ if (!candidates.length) throw new Error(`unresolved dependency ${edge} of ${entry.name}`);
329
+ pending.push(...candidates);
330
+ }
331
+ }
332
+ return lockClosure(contents, name);
274
333
  }
275
334
 
276
335
  /**
@@ -289,7 +348,8 @@ export function measurementStaleness(commit, crateDir, git = defaultGit, deps =
289
348
  const known = git(["cat-file", "-t", commit], crateDir);
290
349
  if (known.status !== 0 || known.stdout !== "commit") return `commit ${commit.slice(0, 12)} is not a commit in this repository (shallow clone?)`;
291
350
  if (git(["merge-base", "--is-ancestor", commit, "HEAD"], crateDir).status !== 0) return `commit ${commit.slice(0, 12)} is not an ancestor of HEAD`;
292
- const { root, specs } = measuredPathspecs(crateDir, { exists, readFile });
351
+ const { root, specs, problems } = measuredPathspecs(crateDir, { exists, readFile });
352
+ if (problems.length) return `cannot resolve measured dependency graph: ${problems.join("; ")}`;
293
353
  // `git diff <commit>` (no second revision) compares the working tree to the commit.
294
354
  const changed = git(["diff", "--name-only", commit, "--", ...specs], root);
295
355
  if (changed.status !== 0) return "cannot diff the crate against the measured commit";
@@ -298,17 +358,29 @@ export function measurementStaleness(commit, crateDir, git = defaultGit, deps =
298
358
  if (untracked.status !== 0) return "cannot list untracked crate files";
299
359
  if (untracked.stdout !== "") return `untracked files since the measurement: ${untracked.stdout.split("\n").slice(0, 5).join(", ")}`;
300
360
  let name = null;
301
- try { name = readCargoPackage(crateDir, { exists, readFile }).name; } catch { /* no manifest: nothing to resolve in the lock */ }
361
+ try {
362
+ if (exists(path.join(crateDir, "Cargo.toml"))) {
363
+ name = readCargoPackage(crateDir, { exists, readFile }).name;
364
+ if (!nonEmpty(name)) return `cannot resolve Cargo.lock: ${path.join(crateDir, "Cargo.toml")} has no package.name`;
365
+ }
366
+ } catch (error) { return `cannot resolve Cargo.lock package identity: ${error.message}`; }
302
367
  if (name) {
303
368
  const lockPath = path.join(root, "Cargo.lock");
304
- const now = exists(lockPath) ? readFile(lockPath, "utf8") : null;
369
+ let now;
370
+ try { now = exists(lockPath) ? readFile(lockPath, "utf8") : null; }
371
+ catch (error) { return `cannot read Cargo.lock ${lockPath}: ${error.message}`; }
305
372
  const then = git(["show", `${commit}:./Cargo.lock`], root);
306
373
  const had = then.status === 0;
307
374
  if (now === null && had) return "Cargo.lock was removed since the measurement";
308
375
  if (now !== null && !had) return "Cargo.lock did not exist at the measured commit";
376
+ if (now === null && !had) {
377
+ // A failed show alone cannot distinguish an absent lock from an unreadable Git object.
378
+ const listed = git(["ls-tree", commit, "--", "Cargo.lock"], root);
379
+ if (listed.status !== 0 || listed.stdout !== "") return "cannot establish that Cargo.lock was absent at the measured commit";
380
+ }
309
381
  if (now !== null) {
310
382
  try {
311
- if (JSON.stringify(lockClosure(now, name)) !== JSON.stringify(lockClosure(then.stdout, name))) {
383
+ if (JSON.stringify(completeLockClosure(now, name)) !== JSON.stringify(completeLockClosure(then.stdout, name))) {
312
384
  return `Cargo.lock changed in the dependency graph of ${name} since commit ${commit.slice(0, 12)}`;
313
385
  }
314
386
  } catch (error) { return `cannot compare Cargo.lock: ${error.message}`; }
@@ -500,7 +572,14 @@ export function statusApiDeclared(api, source) {
500
572
  * `evidence` is the release gate evidence for it (the host-policy authority). `requireInstalled`
501
573
  * (adoption) additionally needs a fresh in-ceiling installed-app measurement, optionally for `app`.
502
574
  */
503
- export function evaluateRuntimeBudget({ crate, crateDir, version, evidence, requireInstalled = false, app, heldResource = false }, { readFile = readFileSync, exists = existsSync, git = defaultGit } = {}) {
575
+ export function evaluateRuntimeBudget(options, deps = {}) {
576
+ return evaluateRuntimeBudgetOwned(options, deps);
577
+ }
578
+
579
+ // Private owner mode: no caller-supplied boolean can weaken publication or adoption.
580
+ const STRUCTURE_ONLY = Symbol("runtime-budget-structure-only");
581
+ function evaluateRuntimeBudgetOwned({ crate, crateDir, version, evidence, requireInstalled = false, app, heldResource = false }, { readFile = readFileSync, exists = existsSync, git = defaultGit } = {}, mode) {
582
+ const structural = mode === STRUCTURE_ONLY;
504
583
  const file = path.join(crateDir, RUNTIME_BUDGET_FILE);
505
584
  if (!exists(file)) return { ok: false, problems: [`${RUNTIME_BUDGET_FILE} is missing (docs/RUNTIME-BUDGETS.md)`] };
506
585
  let budget;
@@ -574,7 +653,7 @@ export function evaluateRuntimeBudget({ crate, crateDir, version, evidence, requ
574
653
 
575
654
  // Measurements.
576
655
  const records = Array.isArray(budget.measurements) ? budget.measurements : [];
577
- if (records.length === 0) problems.push("no measurements recorded (a declared ceiling is not evidence)");
656
+ if (!structural && records.length === 0) problems.push("no measurements recorded (a declared ceiling is not evidence)");
578
657
  const valid = [];
579
658
  records.forEach((m, index) => {
580
659
  const bad = validateMeasurement(m, index);
@@ -583,7 +662,7 @@ export function evaluateRuntimeBudget({ crate, crateDir, version, evidence, requ
583
662
  if (evidenceProblems.length) problems.push(...evidenceProblems); else valid.push({ m, index });
584
663
  });
585
664
  const counted = valid.filter(({ m }) => m.crateVersion === version);
586
- if (valid.length && nonEmpty(version) && counted.length === 0) {
665
+ if (!structural && valid.length && nonEmpty(version) && counted.length === 0) {
587
666
  problems.push(`no measurement for version ${version} (recorded: ${[...new Set(valid.map(({ m }) => m.crateVersion))].join(", ")})`);
588
667
  }
589
668
  // Why a measurement fails one figure, or null. A delta-basis figure of a synthetic measurement is gated on
@@ -608,7 +687,7 @@ export function evaluateRuntimeBudget({ crate, crateDir, version, evidence, requ
608
687
  }
609
688
  return null;
610
689
  };
611
- for (const { m, index } of counted) {
690
+ for (const { m, index } of (structural ? valid : counted)) {
612
691
  for (const key of BUDGET_FIGURES) {
613
692
  const failure = figureFailure(m, key);
614
693
  if (failure) problems.push(`measurement[${index}] on ${m.host.os}/${m.host.arch} ${failure}`);
@@ -616,6 +695,14 @@ export function evaluateRuntimeBudget({ crate, crateDir, version, evidence, requ
616
695
  }
617
696
  const withinCeilings = (m) => BUDGET_FIGURES.every((key) => ceilings[key] !== undefined && (usesDelta(m, key) ? deltaCeilings[key] !== undefined && figureFailure(m, key) === null : m.observed[key] === null ? figureUnavailableOn(m.host?.os, key) : m.observed[key] <= ceilings[key]));
618
697
  const atScale = (m) => requiredScale !== null && m.dataScale.unit === requiredScale.unit && m.dataScale.count >= requiredScale.minCount;
698
+ if (structural) {
699
+ if (!Array.isArray(budget.measurements)) problems.push("measurements must be an array");
700
+ const scope = measuredPathspecs(crateDir, { exists, readFile });
701
+ problems.push(...scope.problems);
702
+ for (const key of deltaFigures) if (!nonEmpty(budget.deltaSources?.[key])) problems.push(`deltaSources.${key} must name its ceiling source`);
703
+ if (budget.deltaSources !== undefined && (!budget.deltaSources || typeof budget.deltaSources !== "object" || Array.isArray(budget.deltaSources) || Object.keys(budget.deltaSources).some((key) => !deltaFigures.includes(key)))) problems.push("deltaSources must name only declared delta figures");
704
+ for (const { m, index } of valid) if (!atScale(m)) problems.push(`measurement[${index}] is below required scale`);
705
+ }
619
706
  const staleness = new Map();
620
707
  const stale = (m) => {
621
708
  if (!staleness.has(m.commit)) staleness.set(m.commit, measurementStaleness(m.commit, crateDir, git, { exists, readFile }));
@@ -624,7 +711,7 @@ export function evaluateRuntimeBudget({ crate, crateDir, version, evidence, requ
624
711
 
625
712
  // Required hosts: one fresh, in-ceiling, at-scale measurement of this version on each.
626
713
  // A held-resource crate defers measurement to adoption: no per-host publish measurement is required.
627
- const required = heldResource ? [] : requiredBudgetHosts(budget, evidence);
714
+ const required = heldResource || structural ? [] : requiredBudgetHosts(budget, evidence);
628
715
  for (const host of required) {
629
716
  const onHost = counted.filter(({ m }) => m.host.os === host);
630
717
  if (onHost.length === 0) { problems.push(`no ${HOSTS[host]} measurement for version ${version} (required hosts: ${required.map((h) => HOSTS[h]).join(" and ")}${required.length > 1 ? "; apps ship on both and the release gate requires both unless a darwin-only policy with a reason is recorded" : `; ${required[0]}-only policy: ${String((evidence ?? budget).hostPolicyReason).trim()}`})`); continue; }
@@ -728,7 +815,17 @@ export function heldResourceProblems({ crate, crateDir, meta }, { readFile = rea
728
815
  * naming every matched pattern), "held-resource" (resources table, adoption-time proof) or always-on
729
816
  * (runtime-budget.json, fully checked). `readFile`, `exists` and `git` are injectable.
730
817
  */
731
- export function runtimeBudgetCheck({ crate, crateDir, version, evidence, requireInstalled = false, app }, deps = {}) {
818
+ export function runtimeBudgetCheck(options, deps = {}) {
819
+ return runtimeBudgetCheckOwned(options, deps);
820
+ }
821
+
822
+ /** Source structure only; grants no runtime qualification or semantic admission. */
823
+ export function runtimeBudgetStructureCheck(options, deps = {}) {
824
+ const result = runtimeBudgetCheckOwned({ ...options, requireInstalled: false }, deps, STRUCTURE_ONLY);
825
+ return { ...result, name: "runtime-budget-structure" };
826
+ }
827
+
828
+ function runtimeBudgetCheckOwned({ crate, crateDir, version, evidence, requireInstalled = false, app }, deps = {}, mode) {
732
829
  const { readFile = readFileSync, exists = existsSync } = deps;
733
830
  let pkg;
734
831
  try { pkg = readCargoPackage(crateDir, { exists, readFile }); } catch (error) {
@@ -775,9 +872,9 @@ export function runtimeBudgetCheck({ crate, crateDir, version, evidence, require
775
872
  return fail(crate, crateDir, [`undeclared: none of ${RUNTIME_BUDGET_FILE}, continuous = false with a continuous_reason, or continuous = "held-resource" with resources`],
776
873
  `declare the crate (docs/RUNTIME-BUDGETS.md)`);
777
874
  }
778
- const result = evaluateRuntimeBudget({ crate, crateDir, version: version ?? pkg.version, evidence, requireInstalled, app }, deps);
875
+ const result = evaluateRuntimeBudgetOwned({ crate, crateDir, version: version ?? pkg.version, evidence, requireInstalled, app }, deps, mode);
779
876
  if (result.ok) {
780
- return { name: RUNTIME_BUDGET_CHECK, status: "ok", detail: `${crate} ${version ?? pkg.version} has measured runtime budgets on every required host` };
877
+ return { name: RUNTIME_BUDGET_CHECK, status: "ok", detail: mode === STRUCTURE_ONLY ? `${crate} ${version ?? pkg.version} runtime budget structure is valid; qualification is separate` : `${crate} ${version ?? pkg.version} has measured runtime budgets on every required host` };
781
878
  }
782
879
  return fail(crate, crateDir, result.problems);
783
880
  }
@@ -0,0 +1,107 @@
1
+ // Canonical JSON and the scan-set digest, shared by the scanner (deterministic-scans.mjs) and the credentialed
2
+ // receipt verifiers (scripts/ci/scan-receipt.mjs). No imports beyond node:crypto, by design.
3
+ import crypto from "node:crypto";
4
+
5
+ const sha256 = (value) => crypto.createHash("sha256").update(value).digest("hex");
6
+
7
+ /** Deterministic JSON: object keys sorted, no undefined, no non-finite numbers. */
8
+ export function canonicalJson(value) {
9
+ if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
10
+ if (typeof value === "number") {
11
+ if (!Number.isFinite(value)) throw new TypeError("canonicalJson: non-finite number");
12
+ return JSON.stringify(value);
13
+ }
14
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
15
+ if (typeof value === "object") {
16
+ const keys = Object.keys(value).sort();
17
+ for (const key of keys) if (value[key] === undefined) throw new TypeError(`canonicalJson: undefined at ${key}`);
18
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
19
+ }
20
+ throw new TypeError(`canonicalJson: unsupported ${typeof value}`);
21
+ }
22
+
23
+ /** Digest of a scan set: the sorted (id, class, command) of every entry. Plan items and result rows agree. */
24
+ export function digestScanSet(entries) {
25
+ const rows = entries.map((e) => [String(e.id), String(e.class), String(e.command)]).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
26
+ return sha256(canonicalJson(rows));
27
+ }
28
+
29
+ // ---- required scan ids ---------------------------------------------------------------------------------
30
+ // The one place the scan ids that belong to a crate are spelled, shared by planScans (deterministic-scans.mjs) and by the
31
+ // credentialed verifiers (scripts/ci/scan-receipt.mjs), so a verifier recomputes what a receipt must contain from the
32
+ // authenticated source and never trusts the plan the producer recorded. Dependency-free by design (see the header).
33
+
34
+ export const rustScanId = Object.freeze({
35
+ check: (name) => `check:${name}`,
36
+ checkFeatures: (name, index) => `check-features:${name}:${index}`,
37
+ clippy: (name, count, index) => (count === 1 ? `clippy:${name}` : `clippy:${name}:${index}`),
38
+ doc: (name) => `doc:${name}`,
39
+ testRust: (name, count, index) => `test:rust:${name}${count === 1 ? "" : `:${index}`}`,
40
+ testRustSerial: (name, count, index) => `test:rust-serial:${name}${count === 1 ? "" : `:${index}`}`,
41
+ packageList: (name) => `cargo-package-list:${name}`,
42
+ changelog: (name) => `changelog-version:${name}`,
43
+ });
44
+
45
+ /**
46
+ * A crate's compile feature plan exactly as CI's gate runs it (ci/gate.config.json rust.<crate>): `lintSets` (clippy; default
47
+ * features when none), whether a default `check` is needed (no lint set covers default features), and the `checkSets` that
48
+ * remain (none when a lint set already uses --all-features).
49
+ */
50
+ export function rustCratePlan(own = {}) {
51
+ const lintSets = Array.isArray(own?.featureSets) && own.featureSets.length ? own.featureSets : [[]];
52
+ const defaultCheck = !lintSets.some((set) => set.length === 0);
53
+ const checkSets = (lintSets.some((set) => set.includes("--all-features")) ? [] : (Array.isArray(own?.checkFeatureSets) ? own.checkFeatureSets : [["--all-features"]])).filter((set) => set.length);
54
+ return { lintSets, defaultCheck, checkSets };
55
+ }
56
+
57
+ /** Scan ids every scan of the whole repository always has when it holds Rust units (cargo.toml, workflows, YAML, JSON and licences). */
58
+ export const WORKSPACE_SCAN_IDS = Object.freeze(["cargo-metadata", "cargo-deny", "cargo-audit", "actionlint", "yaml-parse", "toml-parse", "json-parse", "license", "secret-scan"]);
59
+ /** Prefixes at least one scan id must carry (rustfmt groups by edition, so its exact ids depend on the files). */
60
+ export const REQUIRED_SCAN_PREFIXES = Object.freeze(["rustfmt:"]);
61
+
62
+ /**
63
+ * The scan ids a LOCAL scan record must contain, each with status pass, for these publishable Rust `units` ([names], crates/<name>).
64
+ * `exists(repoPath)` answers from the AUTHENTICATED source (never from the record): src/lib.rs decides `doc`, CHANGELOG.md decides
65
+ * `changelog-version`, and the repository's own ci/advisory-exceptions.json, ownership catalog and docs generator decide theirs.
66
+ * `crateFeatures` is ci/gate.config.json `rust`. `includeTests` adds the per-feature-set test scans. Extra scans in a record are
67
+ * allowed; a missing one is not. planScans builds its ids from the same helpers, and a test holds the two in agreement.
68
+ */
69
+ export function requiredScanIds({ units, crateFeatures = {}, exists, includeTests = false }) {
70
+ if (!Array.isArray(units) || units.length === 0 || !units.every((n) => typeof n === "string" && n)) throw new TypeError("requiredScanIds: units must be a non-empty array of names");
71
+ if (typeof exists !== "function") throw new TypeError("requiredScanIds: exists(repoPath) is required");
72
+ const ids = new Set(WORKSPACE_SCAN_IDS);
73
+ if (exists("ci/advisory-exceptions.json")) ids.add("advisory-exceptions");
74
+ if (exists("packages/release/rightkit-ownership.json") && exists("crates/rightkit-ownership/catalog/ownership-catalog.json")) ids.add("ownership-catalog");
75
+ if (exists("scripts/generate-index.mjs")) ids.add("generated-docs");
76
+ for (const name of units) {
77
+ const own = crateFeatures?.[name] ?? {};
78
+ const { lintSets, defaultCheck, checkSets } = rustCratePlan(own);
79
+ ids.add(rustScanId.packageList(name));
80
+ if (exists(`crates/${name}/CHANGELOG.md`)) ids.add(rustScanId.changelog(name));
81
+ if (defaultCheck) ids.add(rustScanId.check(name));
82
+ checkSets.forEach((_set, i) => ids.add(rustScanId.checkFeatures(name, i)));
83
+ lintSets.forEach((_set, i) => ids.add(rustScanId.clippy(name, lintSets.length, i)));
84
+ if (exists(`crates/${name}/src/lib.rs`)) ids.add(rustScanId.doc(name));
85
+ if (includeTests) {
86
+ lintSets.forEach((_set, i) => {
87
+ ids.add(rustScanId.testRust(name, lintSets.length, i));
88
+ if (own.alsoSerial) ids.add(rustScanId.testRustSerial(name, lintSets.length, i));
89
+ });
90
+ }
91
+ }
92
+ return [...ids].sort();
93
+ }
94
+
95
+ /** Why `rows` (a record's scans) do not contain every required id with status pass; empty when they do. */
96
+ export function requiredScanProblems(rows, required, prefixes = REQUIRED_SCAN_PREFIXES) {
97
+ const byId = new Map();
98
+ for (const row of Array.isArray(rows) ? rows : []) if (row && typeof row.id === "string") byId.set(row.id, row);
99
+ const problems = [];
100
+ for (const id of required) {
101
+ const row = byId.get(id);
102
+ if (!row) problems.push(`required scan ${id} is missing`);
103
+ else if (row.status !== "pass") problems.push(`required scan ${id} is ${row.status}`);
104
+ }
105
+ for (const prefix of prefixes) if (![...byId.values()].some((row) => row.id.startsWith(prefix) && row.status === "pass")) problems.push(`no passing ${prefix}* scan`);
106
+ return problems;
107
+ }
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env node
2
+ // right-release sign-qa-bundle: QA-ONLY ad hoc signing of an already-assembled .app that lives inside a QA scratch root.
3
+ // Why it exists: an assembled QA bundle (for example an Accessibility QA copy) needs a coherent ad hoc signature, but
4
+ // `codesign --force --deep --sign -` routed through the signing agent (SSH or non-Aqua session) is refused by design
5
+ // (macos-signing-session.mjs codesignAgentItems; rightkit-signing-agent.mjs). Those refusals stay. This module never calls the
6
+ // agent: it spawns /usr/bin/codesign itself, signs nested code one item at a time, deepest first, the outer bundle last, with
7
+ // identity "-" and never `--deep`. Ad hoc output is not a release artifact: it is never notarized, sealed, published or installed
8
+ // under /Applications, and the receipt says so (signature "adhoc", releaseArtifact false, qaOnly true).
9
+ import { existsSync, lstatSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+ import { spawnSync } from "./background-process.mjs";
14
+ import { adhocSafeEntitlements } from "./adhoc-entitlements.mjs";
15
+
16
+ export const QA_BUNDLE_SIGNING_SCHEMA = 1;
17
+ export const CODESIGN = "/usr/bin/codesign";
18
+ const PLUTIL = "/usr/bin/plutil";
19
+ // macOS paths only: posix semantics keep the checks identical on every host that unit-tests this module.
20
+ const px = path.posix;
21
+
22
+ export class QaBundleSigningError extends Error {
23
+ constructor(code, message, details = {}) {
24
+ super(message);
25
+ this.name = "QaBundleSigningError";
26
+ this.code = code;
27
+ Object.assign(this, details);
28
+ }
29
+ }
30
+ const fail = (code, message, details) => { throw new QaBundleSigningError(code, message, details); };
31
+
32
+ const defaultIo = { lstatSync, readdirSync, realpathSync, existsSync, mkdtempSync, writeFileSync, rmSync };
33
+ const captureOptions = { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false };
34
+ const outputOf = (result) => `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
35
+ const inside = (parent, child) => child !== parent && child.startsWith(parent === "/" ? "/" : `${parent}/`);
36
+
37
+ function checked(run, args, code, command = CODESIGN) {
38
+ const result = run(command, args, captureOptions);
39
+ if (!result || result.error || result.status !== 0) {
40
+ fail(code, `${command} ${args[0]} failed (${result?.status ?? "unavailable"}): ${result?.error?.message || outputOf(result ?? {})}`, { status: result?.status });
41
+ }
42
+ return result;
43
+ }
44
+
45
+ function lstatOrNull(io, file) { try { return io.lstatSync(file); } catch { return null; } }
46
+
47
+ function requireAbsolute(label, value) {
48
+ if (typeof value !== "string" || !value.startsWith("/")) fail("QA_SIGN_PATH_NOT_ABSOLUTE", `${label} must be an absolute macOS path: ${JSON.stringify(value)}`);
49
+ if (value.split("/").includes("..") || value.includes("\0")) fail("QA_SIGN_PATH_NOT_NORMAL", `${label} must not contain ".." segments: ${JSON.stringify(value)}`);
50
+ return value.length > 1 ? value.replace(/\/+$/, "") : value;
51
+ }
52
+
53
+ /** Validate the target and return its real paths. Refusals are typed and happen before any codesign call. */
54
+ export function resolveQaBundleTarget({ app, qaScratchRoot, platform = process.platform, home = os.homedir(), io = defaultIo }) {
55
+ if (platform !== "darwin") fail("QA_SIGN_DARWIN_ONLY", `sign-qa-bundle runs on macOS only (platform ${platform})`);
56
+ const lexicalApp = requireAbsolute("--app", app);
57
+ const lexicalRoot = requireAbsolute("--qa-root", qaScratchRoot);
58
+ const lexicalHome = requireAbsolute("home", home);
59
+ const applications = ["/Applications", px.join(lexicalHome, "Applications")];
60
+ const real = (label, file) => { try { return io.realpathSync(file); } catch { return fail("QA_SIGN_PATH_MISSING", `${label} does not exist: ${file}`); } };
61
+ const realApplications = applications.flatMap((dir) => { const out = [dir]; try { out.push(io.realpathSync(dir)); } catch { /* absent */ } return out; });
62
+ const inApplications = (file) => realApplications.some((dir) => file === dir || inside(dir, file));
63
+ if (inApplications(lexicalApp)) fail("QA_SIGN_APPLICATIONS_REFUSED", `QA signing refuses installed apps (/Applications, ~/Applications): ${lexicalApp}`);
64
+ if (px.extname(lexicalApp) !== ".app") fail("QA_SIGN_NOT_APP", `target is not a .app bundle: ${lexicalApp}`);
65
+ const link = lstatOrNull(io, lexicalApp);
66
+ if (!link) fail("QA_SIGN_PATH_MISSING", `--app does not exist: ${lexicalApp}`);
67
+ if (link.isSymbolicLink()) fail("QA_SIGN_LINK_REFUSED", `--app is a symbolic link; pass the real bundle: ${lexicalApp}`);
68
+ if (!link.isDirectory()) fail("QA_SIGN_NOT_APP", `target is not a directory bundle: ${lexicalApp}`);
69
+ const realRoot = real("--qa-root", lexicalRoot);
70
+ const realApp = real("--app", lexicalApp);
71
+ if (realRoot === "/" || realRoot === lexicalHome || inApplications(realRoot) || realApplications.some((dir) => inside(realRoot, dir))) {
72
+ fail("QA_SIGN_ROOT_REFUSED", `QA scratch root is too broad or an installed-app location: ${realRoot}`);
73
+ }
74
+ if (inApplications(realApp)) fail("QA_SIGN_APPLICATIONS_REFUSED", `QA signing refuses installed apps (resolved to ${realApp})`);
75
+ if (!inside(realRoot, realApp)) fail("QA_SIGN_OUTSIDE_QA_ROOT", `bundle ${realApp} is not inside the QA scratch root ${realRoot}`);
76
+ if (px.extname(realApp) !== ".app") fail("QA_SIGN_NOT_APP", `target resolves to a non-.app path: ${realApp}`);
77
+ return { app: lexicalApp, realApp, realRoot };
78
+ }
79
+
80
+ /** Bundle kinds that are signed as a unit, and the directory whose files are loose Mach-O candidates. */
81
+ const BUNDLE_EXTENSIONS = new Set([".app", ".xpc", ".framework"]);
82
+
83
+ function readInfo(run, bundle, cache, io) {
84
+ if (cache.has(bundle)) return cache.get(bundle);
85
+ let info = {};
86
+ for (const location of ["Contents/Info.plist", "Resources/Info.plist", "Info.plist"]) {
87
+ const file = px.join(bundle, location);
88
+ if (!io.existsSync(file)) continue;
89
+ const result = checked(run, ["-convert", "json", "-o", "-", file], "QA_SIGN_PLIST_INVALID", PLUTIL);
90
+ try { info = JSON.parse(result.stdout); } catch { fail("QA_SIGN_PLIST_INVALID", `Invalid Info.plist JSON: ${file}`); }
91
+ break;
92
+ }
93
+ cache.set(bundle, info);
94
+ return info;
95
+ }
96
+
97
+ /**
98
+ * Nested code inside the bundle, ordered inside-out: deepest path first (stable by path), the outer bundle excluded.
99
+ * Symbolic links are never entered or signed; a link that resolves outside the bundle is refused.
100
+ */
101
+ export function enumerateNestedCode({ realApp, run, io = defaultIo }) {
102
+ const infoCache = new Map();
103
+ const found = new Map();
104
+ const add = (file, kind) => { if (!found.has(file)) found.set(file, kind); };
105
+ const bundleOf = (dir) => px.dirname(px.dirname(dir)); // .../X.app/Contents/MacOS -> .../X.app
106
+ const walk = (dir) => {
107
+ for (const entry of io.readdirSync(dir, { withFileTypes: true })) {
108
+ const file = px.join(dir, entry.name);
109
+ if (entry.isSymbolicLink()) {
110
+ let target;
111
+ try { target = io.realpathSync(file); } catch { fail("QA_SIGN_LINK_REFUSED", `dangling symbolic link inside the bundle: ${file}`); }
112
+ if (target !== realApp && !inside(realApp, target)) fail("QA_SIGN_LINK_REFUSED", `symbolic link escapes the bundle: ${file} -> ${target}`);
113
+ continue;
114
+ }
115
+ const ext = px.extname(entry.name);
116
+ if (entry.isDirectory()) {
117
+ if (BUNDLE_EXTENSIONS.has(ext)) add(file, ext === ".framework" ? "framework" : ext === ".xpc" ? "xpc" : "app");
118
+ else if (px.basename(dir) === "LoginItems" && px.basename(px.dirname(dir)) === "Library") add(file, "login-item");
119
+ walk(file);
120
+ } else if (entry.isFile()) {
121
+ if (ext === ".dylib") add(file, "dylib");
122
+ else if (px.basename(dir) === "MacOS" && px.basename(px.dirname(dir)) === "Contents") {
123
+ const main = readInfo(run, bundleOf(dir), infoCache, io).CFBundleExecutable;
124
+ if (typeof main !== "string" || !main) fail("QA_SIGN_PLIST_INVALID", `no CFBundleExecutable for ${bundleOf(dir)}`);
125
+ if (entry.name !== main) add(file, "executable");
126
+ } else if (px.basename(dir) === "Frameworks" && px.basename(px.dirname(dir)) === "Contents") add(file, "executable");
127
+ else if (px.basename(dir) === "LoginItems" && px.basename(px.dirname(dir)) === "Library") add(file, "login-item");
128
+ }
129
+ }
130
+ };
131
+ walk(realApp);
132
+ const depth = (file) => file.split("/").length;
133
+ return [...found].map(([file, kind]) => ({ path: file, kind }))
134
+ .sort((a, b) => depth(b.path) - depth(a.path) || (a.path < b.path ? -1 : 1));
135
+ }
136
+
137
+ /** The only codesign argv this module emits. `--deep` and any identity other than "-" are structurally impossible. */
138
+ export function adhocCodesignArgs(item, entitlementsFile) {
139
+ const args = ["--force", "--sign", "-"];
140
+ if (entitlementsFile) args.push("--entitlements", entitlementsFile);
141
+ args.push(item);
142
+ if (args.includes("--deep") || args[args.indexOf("--sign") + 1] !== "-") fail("QA_SIGN_ARGV_REFUSED", "internal: ad hoc argv must never carry --deep or a real identity");
143
+ return args;
144
+ }
145
+
146
+ export function signQaBundleAdhoc({ app, qaScratchRoot, run = spawnSync, platform = process.platform, home = os.homedir(), entitlements, io = defaultIo, out } = {}) {
147
+ const target = resolveQaBundleTarget({ app, qaScratchRoot, platform, home, io });
148
+ const { realApp, realRoot } = target;
149
+ let scratch;
150
+ try {
151
+ let outerEntitlements;
152
+ let entitlementKeys = [];
153
+ if (entitlements !== undefined) {
154
+ const source = requireAbsolute("entitlements", entitlements);
155
+ const parsed = checked(run, ["-convert", "json", "-o", "-", source], "QA_SIGN_ENTITLEMENTS_INVALID", PLUTIL);
156
+ let reduced;
157
+ try { reduced = adhocSafeEntitlements(JSON.parse(parsed.stdout)); } catch { fail("QA_SIGN_ENTITLEMENTS_INVALID", `Invalid entitlements plist: ${source}`); }
158
+ entitlementKeys = Object.keys(reduced).sort();
159
+ if (entitlementKeys.length) {
160
+ scratch = io.mkdtempSync(px.join(realRoot, ".sign-qa-"));
161
+ outerEntitlements = px.join(scratch, "adhoc-entitlements.plist");
162
+ io.writeFileSync(outerEntitlements, JSON.stringify(reduced));
163
+ checked(run, ["-convert", "xml1", outerEntitlements], "QA_SIGN_ENTITLEMENTS_INVALID", PLUTIL);
164
+ }
165
+ }
166
+ const nested = enumerateNestedCode({ realApp, run, io });
167
+ const plan = [...nested, { path: realApp, kind: "bundle" }];
168
+ const signed = [];
169
+ for (const item of plan) {
170
+ const withEntitlements = item.kind === "bundle" ? outerEntitlements : undefined;
171
+ checked(run, adhocCodesignArgs(item.path, withEntitlements), "QA_SIGN_CODESIGN_FAILED");
172
+ signed.push({ path: item.path, kind: item.kind });
173
+ }
174
+ checked(run, ["--verify", "--strict", realApp], "QA_SIGN_VERIFY_FAILED");
175
+ const receipt = {
176
+ schema: QA_BUNDLE_SIGNING_SCHEMA, signature: "adhoc", releaseArtifact: false, qaOnly: true,
177
+ notarized: false, published: false, usedSigningAgent: false,
178
+ app: realApp, qaScratchRoot: realRoot, entitlementKeys, verified: "codesign --verify --strict", signed,
179
+ };
180
+ if (out) {
181
+ const file = requireAbsolute("--out", out);
182
+ io.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
183
+ }
184
+ return receipt;
185
+ } finally {
186
+ if (scratch) { try { io.rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ } }
187
+ }
188
+ }
189
+
190
+ export function main(args = process.argv.slice(2), options = {}) {
191
+ const take = (flag) => {
192
+ const index = args.indexOf(flag);
193
+ if (index < 0) return undefined;
194
+ const value = args[index + 1];
195
+ if (!value || value.startsWith("--")) fail("QA_SIGN_USAGE", `${flag} requires a value`);
196
+ return value;
197
+ };
198
+ try {
199
+ for (const arg of args.filter((a) => a.startsWith("--"))) {
200
+ if (!["--app", "--qa-root", "--out", "--entitlements"].includes(arg)) fail("QA_SIGN_USAGE", `unknown option ${arg}`);
201
+ }
202
+ const receipt = signQaBundleAdhoc({ app: take("--app"), qaScratchRoot: take("--qa-root"), entitlements: take("--entitlements"), out: take("--out"), ...options });
203
+ console.log(`ad hoc signed (QA only, not a release artifact) ${receipt.signed.length} item(s), outer bundle last: ${receipt.app}`);
204
+ } catch (error) {
205
+ console.error(`right-release sign-qa-bundle: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
206
+ process.exitCode = 1;
207
+ }
208
+ }
209
+
210
+ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) main();