@rightkit/release 0.6.4 → 0.6.5

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.
Binary file
@@ -24,10 +24,16 @@
24
24
  // rightkit-gate [--base <ref>] [--plan] [--no-cache]
25
25
  // run the gate (default base origin/main); --plan prints the scans and runs none;
26
26
  // --no-cache (or RK_GATE_CACHE=0) reruns every scan
27
+ // rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
28
+ // the offline app check (../app-check.mjs): committed files only, no network, compile or
29
+ // broker, every finding in one run. Exit 0 pass, 1 findings, 2 could not run
27
30
  // rightkit-gate install-hook [--force] write .githooks/pre-push and set core.hooksPath. core.hooksPath hides .git/hooks, so the
28
- // pre-push hook runs the repository's own .git/hooks/pre-push first and every other hook
29
- // present in .git/hooks gets a .githooks/<name> trampoline that runs it; the hook finds
30
- // the gate binary in the root node_modules/.bin, then any workspace package's, then pnpm exec
31
+ // pre-push hook runs the repository's own .git/hooks/pre-push first, then `rightkit-gate
32
+ // check --commit HEAD`, then the gate; every other hook present in .git/hooks gets a
33
+ // .githooks/<name> trampoline that runs it; the hook finds the gate binary in the root
34
+ // node_modules/.bin, then any workspace package's, then pnpm exec. Where @rightkit/release is
35
+ // installed it also writes the advisory ownership pre-commit step (`right-release hooks install`
36
+ // is an alias of this command)
31
37
  //
32
38
  // From the pre-push hook, git's ref lines arrive on stdin: the commit being pushed must be the checked-out HEAD (broker scans
33
39
  // read the live checkout), and the remote's current commit is the diff base. To push an earlier commit of the local stack,
@@ -58,7 +64,7 @@ const text = (r) => String(r?.stdout ?? "");
58
64
  const failed = (r) => !r || Boolean(r.error) || r.status !== 0;
59
65
  const why = (r) => String(r?.error?.message ?? r?.stderr ?? "").trim().split(/\r?\n/).slice(-3).join(" | ");
60
66
 
61
- const USAGE = `usage: rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | --version`;
67
+ const USAGE = `usage: rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>] | --version`;
62
68
 
63
69
  // ---- pure helpers (exported for tests) ----------------------------------------------------------------------
64
70
 
@@ -340,13 +346,15 @@ export function lockScope({ changed, cargoDir = ".", unitNames, showBase, readLo
340
346
  /**
341
347
  * The pre-push hook. git's ref lines arrive on stdin and the gate consumes them, so stdin is buffered once and fed to both the
342
348
  * repository's own .git/hooks/pre-push (which core.hooksPath=.githooks would otherwise hide; it runs first and a failure stops the
343
- * push) and the gate. The gate binary is the root node_modules/.bin/rightkit-gate, else any workspace package's (up to three
344
- * levels down: tauri-app-next, apps/*, packages/*), else `pnpm exec rightkit-gate`, else node with a package-relative path when
345
- * pnpm is absent; the line it chose is printed. No absolute path is written: everything is resolved from git at run time.
349
+ * push) and the gate. Between them runs the app check, `rightkit-gate check --commit HEAD`: seconds, committed files only, so a
350
+ * red tree stops the push before the gate's scans start; a push that only deletes refs has no content to check and skips it. The
351
+ * gate binary is the root node_modules/.bin/rightkit-gate, else any workspace package's (up to three levels down: tauri-app-next,
352
+ * apps/*, packages/*), else `pnpm exec rightkit-gate`, else node with a package-relative path when pnpm is absent; the line it
353
+ * chose is printed once. No absolute path is written: everything is resolved from git at run time.
346
354
  */
347
355
  export function hookText(nodePath) {
348
356
  return `#!/bin/sh
349
- # Mandatory local gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
357
+ # Mandatory local check and gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
350
358
  cd "$(git rev-parse --show-toplevel)" || exit 1
351
359
  stdin=$(mktemp "\${TMPDIR:-/tmp}/rightkit-gate.XXXXXX") || exit 1
352
360
  trap 'rm -f "$stdin"' EXIT
@@ -358,18 +366,27 @@ if [ -x "$own" ]; then
358
366
  "$own" "$@" < "$stdin" || exit $?
359
367
  fi
360
368
  # The gate binary: the root install, any workspace package's install, then pnpm exec.
369
+ found=""
361
370
  for bin in node_modules/.bin/rightkit-gate */node_modules/.bin/rightkit-gate */*/node_modules/.bin/rightkit-gate */*/*/node_modules/.bin/rightkit-gate; do
362
- if [ -x "$bin" ]; then
363
- echo "rightkit-gate: using $bin" >&2
364
- ("$bin" --base origin/main) < "$stdin"; exit $?
365
- fi
371
+ if [ -x "$bin" ]; then found="$bin"; break; fi
366
372
  done
367
- if command -v pnpm >/dev/null 2>&1; then
373
+ if [ -n "$found" ]; then
374
+ echo "rightkit-gate: using $found" >&2
375
+ rightkit_gate() { "$found" "$@"; }
376
+ elif command -v pnpm >/dev/null 2>&1; then
368
377
  echo "rightkit-gate: using pnpm exec rightkit-gate" >&2
369
- (exec pnpm exec rightkit-gate --base origin/main) < "$stdin"; exit $?
378
+ rightkit_gate() { pnpm exec rightkit-gate "$@"; }
379
+ else
380
+ echo "rightkit-gate: using node ${nodePath}" >&2
381
+ rightkit_gate() { node "${nodePath}" "$@"; }
370
382
  fi
371
- echo "rightkit-gate: using node ${nodePath}" >&2
372
- (exec node "${nodePath}" --base origin/main) < "$stdin"; exit $?
383
+ # The check first, over the committed files of HEAD; a push that only deletes refs has nothing to check.
384
+ if [ -s "$stdin" ] && ! awk '$2 !~ /^0+$/ { found = 1 } END { exit !found }' "$stdin"; then
385
+ echo "rightkit-gate: only deletions are pushed; nothing to check" >&2
386
+ else
387
+ rightkit_gate check --commit HEAD < /dev/null >&2 || exit $?
388
+ fi
389
+ rightkit_gate --base origin/main < "$stdin"; exit $?
373
390
  `;
374
391
  }
375
392
 
@@ -780,7 +797,12 @@ function releaseProvider(o, root, git) {
780
797
  return null;
781
798
  }
782
799
 
783
- function installHook(o, root, force) {
800
+ // The advisory ownership pre-commit step (`right-release hooks install`, ownership-hooks.mjs) runs the pinned @rightkit/release, so
801
+ // it is written only where that is installed. Its own hook and the repository's .git/hooks/pre-commit both keep running.
802
+ const PINNED_CLI = "node_modules/@rightkit/release/cli/right-release.mjs";
803
+ const FOREIGN_PRE_COMMIT = "pre-commit.rightkit-foreign";
804
+
805
+ async function installHook(o, root, force) {
784
806
  const git = (...a) => o.spawn("git", a, { cwd: root });
785
807
  const hooksDir = path.join(root, ".githooks");
786
808
  const hookPath = path.join(hooksDir, "pre-push");
@@ -797,15 +819,29 @@ function installHook(o, root, force) {
797
819
  const gitDirResult = git("rev-parse", "--git-dir");
798
820
  let present = [];
799
821
  if (!failed(gitDirResult) && text(gitDirResult).trim()) { try { present = o.readdir(path.join(path.resolve(root, text(gitDirResult).trim()), "hooks")); } catch { present = []; } }
822
+ const gitHooksDir = !failed(gitDirResult) && text(gitDirResult).trim() ? path.join(path.resolve(root, text(gitDirResult).trim()), "hooks") : null;
800
823
  const own = present.filter(chainableHook).sort();
801
- const skipped = present.filter((name) => !name.endsWith(".sample") && !chainableHook(name)).sort();
824
+ // The ownership step takes .githooks/pre-commit. The repository's own pre-commit hook keeps running first, through a trampoline
825
+ // beside it, unless it is the ownership hook a plain `right-release hooks install` wrote (this one replaces that), whose own
826
+ // chained hook, pre-commit.rightkit-foreign, then runs instead. A name that is no plain hook name is reported, never chained.
827
+ const ownership = o.exists(path.join(root, PINNED_CLI)) ? await import("../ownership-hooks.mjs") : null;
828
+ const readHook = (name) => { try { return o.readFile(path.join(gitHooksDir, name)); } catch { return ""; } };
829
+ const chainName = !ownership ? null : own.includes("pre-commit") && !readHook("pre-commit").includes(ownership.HOOK_MARKER) ? "pre-commit" : present.includes(FOREIGN_PRE_COMMIT) ? FOREIGN_PRE_COMMIT : null;
830
+ const skipped = present.filter((name) => !name.endsWith(".sample") && !chainableHook(name) && !(ownership && name === FOREIGN_PRE_COMMIT)).sort();
802
831
  if (skipped.length) o.err(`rightkit-gate: WARNING: not chaining .git/hooks/${skipped.join(", .git/hooks/")}: not a plain hook file name (letters, digits, - and _ only); run it from a hook of its own.`);
803
- const trampolines = own.filter((name) => name !== "pre-push").map((name) => ({ name, file: path.join(hooksDir, name), wanted: trampolineText(name) }));
832
+ const trampolines = own.filter((name) => name !== "pre-push" && !(ownership && name === "pre-commit")).map((name) => ({ name, file: path.join(hooksDir, name), wanted: trampolineText(name), note: `runs .git/hooks/${name}` }));
833
+ if (ownership) {
834
+ trampolines.push({ name: "pre-commit", file: path.join(hooksDir, "pre-commit"), wanted: ownership.renderPreCommitHook(), ownership: true, replaces: [trampolineText("pre-commit")], marker: ownership.HOOK_MARKER, note: "the advisory ownership gate: right-release ownership --staged" });
835
+ if (chainName) trampolines.push({ name: FOREIGN_PRE_COMMIT, file: path.join(hooksDir, FOREIGN_PRE_COMMIT), wanted: trampolineText(chainName), note: `runs .git/hooks/${chainName}, before the ownership gate` });
836
+ }
804
837
  for (const t of trampolines) {
805
838
  t.exists = o.exists(t.file);
806
- t.current = t.exists ? o.readFile(t.file).replace(/\r\n/g, "\n") === t.wanted : false;
807
- if (t.exists && !t.current && !force) {
808
- o.err(`rightkit-gate: .githooks/${t.name} already exists and differs; nothing changed. Review it, or run \`rightkit-gate install-hook --force\` to replace it with a trampoline to .git/hooks/${t.name}.`);
839
+ const found = t.exists ? o.readFile(t.file).replace(/\r\n/g, "\n") : null;
840
+ t.current = found === t.wanted;
841
+ // Our own earlier artifacts are updated in place: a plain trampoline where the ownership hook now goes, and an older ownership hook.
842
+ const ours = found !== null && (t.current || t.replaces?.includes(found) || (t.marker && found.includes(t.marker)));
843
+ if (t.exists && !ours && !force) {
844
+ o.err(`rightkit-gate: .githooks/${t.name} already exists and differs; nothing changed. Review it, or run \`rightkit-gate install-hook --force\` to replace it with ${t.ownership ? "the ownership pre-commit hook" : `a trampoline to .git/hooks/${chainName ?? t.name}`}.`);
809
845
  return 1;
810
846
  }
811
847
  }
@@ -825,7 +861,7 @@ function installHook(o, root, force) {
825
861
  else put(hookPath, wanted, `${hasHook ? "replaced" : "wrote"} .githooks/pre-push`, ` (${nodePath} without pnpm)`);
826
862
  for (const t of trampolines) {
827
863
  if (t.current) done.push(`.githooks/${t.name} already current`);
828
- else put(t.file, t.wanted, `${t.exists ? "replaced" : "wrote"} .githooks/${t.name}`, ` (runs .git/hooks/${t.name})`);
864
+ else put(t.file, t.wanted, `${t.exists ? "replaced" : "wrote"} .githooks/${t.name}`, ` (${t.note})`);
829
865
  }
830
866
  if (existingPath === ".githooks") done.push("core.hooksPath already .githooks");
831
867
  else {
@@ -836,6 +872,7 @@ function installHook(o, root, force) {
836
872
  for (const line of done) o.out(`rightkit-gate: ${line}`);
837
873
  const chained = own.length ? own.map((name) => (name === "pre-push" ? "pre-push (run first, then the gate)" : name)) : [];
838
874
  o.out(`rightkit-gate: chained hooks from .git/hooks: ${chained.length ? chained.join(", ") : "none"}${own.includes("pre-push") ? "" : "; the pre-push hook still runs .git/hooks/pre-push first if one appears later"}`);
875
+ if (!ownership) o.out(`rightkit-gate: no ownership pre-commit step: ${PINNED_CLI} is not installed here; install @rightkit/release, then run install-hook again`);
839
876
  const files = [".githooks/pre-push", ...trampolines.map((t) => `.githooks/${t.name}`)];
840
877
  o.out(`rightkit-gate: commit the hooks with executable mode (git add --chmod=+x ${files.join(" ")}); the pre-push hook finds rightkit-gate in node_modules/.bin of the repository root, else of any workspace package, else through \`pnpm exec\`.`);
841
878
  const provider = releaseProvider(o, root, git);
@@ -865,14 +902,16 @@ export async function main(argv, io = {}) {
865
902
  }
866
903
  if (args.includes("--help") || args.includes("-h")) { o.out(USAGE); return 0; }
867
904
  const sub = args[0] && !args[0].startsWith("-") ? args[0] : null;
868
- if (sub && sub !== "install-hook") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
905
+ if (sub && sub !== "install-hook" && sub !== "check") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
906
+ // The app check has its own export and reads no gate state: it needs neither the base ref nor the pushed refs on stdin.
907
+ if (sub === "check") return (o.appCheck ?? (await import("../app-check.mjs")).main)(args.slice(1), { out: o.out, err: o.err, label: "check", cwd: o.cwd });
869
908
  if (args.includes("--base") && (!option("--base") || option("--base").startsWith("--"))) { o.err("rightkit-gate: --base needs a ref"); return 2; }
870
909
 
871
910
  const top = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: o.cwd });
872
911
  if (failed(top) || !text(top).trim()) { o.err("rightkit-gate: not inside a git repository"); return 2; }
873
912
  const root = path.resolve(text(top).trim());
874
913
  const git = (...a) => o.spawn("git", a, { cwd: root });
875
- if (sub === "install-hook") return installHook(o, root, args.includes("--force"));
914
+ if (sub === "install-hook") return await installHook(o, root, args.includes("--force"));
876
915
 
877
916
  const planOnly = args.includes("--plan");
878
917
  let base = option("--base") ?? "origin/main";
package/build-release.mjs CHANGED
@@ -20,7 +20,9 @@ import {
20
20
  import path from "node:path";
21
21
  import { preflightOwnershipScope } from "./ownership-scope.mjs";
22
22
  import { spawn, spawnSync } from "./background-process.mjs";
23
- import { RightKitRegistryError, SNAPSHOT_ENV, SNAPSHOT_FILE, assertManifestRegistryEvidence, loadAdmittedSnapshot, prepareRightKitResolution, registryManifestFields, rightKitLockInventory, writeAdmissionRecord } from "./rightkit-registry-resolver.mjs";
23
+ import { RightKitRegistryError, SNAPSHOT_ENV, SNAPSHOT_FILE, assertManifestRegistryEvidence, loadAdmittedSnapshot, prepareRightKitResolution, registryManifestFields, rightKitLockInventory, writeAdmissionRecord, writeSnapshot } from "./rightkit-registry-resolver.mjs";
24
+ import { formatAdmission, formatNotRun, runAdmission } from "./admission.mjs";
25
+ import { sccacheRefusal } from "./admission-host.mjs";
24
26
  import { fileURLToPath, pathToFileURL } from "node:url";
25
27
  import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
26
28
  import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
@@ -129,12 +131,15 @@ for (let i = 0; i < args.length; i += 1) {
129
131
  if (platform !== "win" && platform !== "mac" && platform !== "android" && platform !== "ios") fail("--platform must be win, mac, android or ios");
130
132
  if (resumeFrom && !["prepackage", "sign", "package", "post-sign", "harden"].includes(resumeFrom)) fail("--resume-from must be prepackage, sign, package, post-sign, or harden");
131
133
 
132
- const configPath = path.resolve(configName);
134
+ // Native real paths: Git reports the long form of a Windows short name (RUNNER~1) and the target of a symlinked temp root
135
+ // (macOS /var -> /private/var). The config path is resolved the same way the doctor resolves it (release.mjs), so every path
136
+ // derived from it (app root, build inputs, scan roots) compares equal to what Git reports; a non-canonical app root made the
137
+ // build-inputs qualification drop every input and the clean-source gate speak before the dirty-inputs row.
138
+ const nativeReal = (file) => { try { return realpathSync.native(file); } catch { return file; } };
139
+ const configPath = nativeReal(path.resolve(configName));
133
140
  const invocationRoot = path.dirname(configPath);
134
141
  const repoRoot = git(invocationRoot, ["rev-parse", "--show-toplevel"]);
135
142
  assertPrimaryReleaseCheckout(repoRoot);
136
- // Native real paths: Git reports the long form of a Windows short name (RUNNER~1).
137
- const nativeReal = (file) => { try { return realpathSync.native(file); } catch { return file; } };
138
143
  const relativeConfig = path.relative(nativeReal(repoRoot), nativeReal(configPath)).replaceAll("\\", "/");
139
144
  if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
140
145
  const appRoot = path.dirname(configPath);
@@ -148,21 +153,19 @@ let heavySlot;
148
153
  let interrupted;
149
154
  let requestActiveRunStop = null;
150
155
 
151
- // A dry-run is an inspection contract, not a partial build. It must finish
152
- // before locks, cache directories, receipts, dependency installation, or any
156
+ // Admission (admission.mjs): every refusal the committed files, this host or the registries can decide is raised here, before the
157
+ // dry-run exit, the clean-source gate, the release lock, any state file, the install, a slot or the worker. `right-release doctor`
158
+ // runs the same rows with the same text, so the two cannot disagree (rightkit#946); a refused build leaves nothing behind.
159
+ const admitted = await admit();
160
+
161
+ // A dry-run is an inspection contract, not a partial build. It prints the admission rows and exits with their verdict, so it can
162
+ // never pass what the build refuses. It must finish before locks, cache directories, receipts, dependency installation, or any
153
163
  // child build/sign process can exist.
154
164
  if (dryRun) {
155
- const commit = git(invocationRoot, ["rev-parse", "HEAD"]);
156
- const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
157
- if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
158
- const config = (await import(`${pathToFileURL(configPath).href}?dry-run=${commit}`)).default;
159
- if (!config?.app || !config?.version) fail("release config must expose app and version");
160
- const target = config.targets?.[platform];
161
- // Android builds with target.gradle (validated by release.mjs), not a package command; iOS with targets.ios.iosPackage.
162
- if (platform !== "android" && platform !== "ios" && !target?.package && !packageCommandOptional(target, resolveProjectType({ appRoot, config, target }))) fail(`${config.app} has no ${platform} package command`);
163
- console.log(`right-release build dry-run: ${config.app} ${config.version} ${platform}`);
165
+ console.log(`right-release build dry-run: ${admitted.config.app} ${admitted.config.version} ${platform}`);
164
166
  console.log(`config: ${configPath}`);
165
- console.log(`commit: ${commit}`);
167
+ console.log(`commit: ${admitted.commit}`);
168
+ for (const line of formatAdmission(admitted.result)) console.log(line);
166
169
  process.exit(0);
167
170
  }
168
171
 
@@ -396,7 +399,8 @@ try {
396
399
  let rightKitResolution = null;
397
400
  if (!legacySealed) {
398
401
  try {
399
- rightKitResolution = await prepareRightKitResolution({
402
+ // A fresh build already asked the registries in admission(); the snapshot is bound to this release's state root below.
403
+ rightKitResolution = (!rightKitReuse && admitted.registry) || await prepareRightKitResolution({
400
404
  root: appRoot,
401
405
  extraLocks: rightKitLockInventory({ requiredInputs, cargoLockPaths: pinnedCargoLocks, nativeLayout, config, root: appRoot }),
402
406
  versions: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-versions.json"), "utf8")),
@@ -414,6 +418,7 @@ try {
414
418
  if (!(error instanceof RightKitRegistryError)) throw error;
415
419
  fail(`${error.recoverable ? "RECOVERABLE " : ""}${error.message}`);
416
420
  }
421
+ if (rightKitResolution === admitted.registry) writeSnapshot(rightKitSnapshotFile, rightKitResolution.snapshot);
417
422
  for (const line of rightKitResolution.freshness.tasks) console.error(`[right-release] LANE TASK ${line}`);
418
423
  for (const line of rightKitResolution.freshness.warnings) console.error(`[right-release] WARNING ${line}`);
419
424
  console.error(`[right-release] rightkit-registry: ${rightKitResolution.summary}`);
@@ -552,6 +557,52 @@ try {
552
557
  lock?.release();
553
558
  }
554
559
 
560
+ // Read the config and run the admission rows (files, this host and, for a fresh build, the registries) with no side effect: no lock,
561
+ // state directory, cache directory, install or snapshot file. A finding is printed with its row and the process exits 1, no stack.
562
+ async function admit() {
563
+ let commit;
564
+ let config;
565
+ try {
566
+ commit = git(invocationRoot, ["rev-parse", "HEAD"]);
567
+ const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
568
+ if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
569
+ try { config = (await import(`${pathToFileURL(configPath).href}?commit=${commit}`)).default; } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
570
+ } catch (error) {
571
+ if (error?.reported) process.exit(1);
572
+ throw error;
573
+ }
574
+ // A fresh build asks the registries once, here; a resume or a re-run over a sealed release reuses its recorded snapshot instead.
575
+ const platformName = platform === "win" ? "windows" : platform;
576
+ const sealed = config?.app && config?.version
577
+ && existsSync(path.join(repoRoot, ".right-release", "sealed", `${config.app}-${config.version}-${commit.slice(0, 8)}`, platformName, "release-manifest.json"));
578
+ const reuse = Boolean(resumeFrom) || Boolean(sealed);
579
+ const result = await runAdmission({
580
+ root: appRoot, repoRoot, configPath, config: config ?? {}, platform, commit, skipChecks,
581
+ tiers: reuse ? ["files", "host"] : ["files", "host", "network"],
582
+ registry: async (ctx) => {
583
+ const inputs = ctx.buildInputs();
584
+ if (!inputs.ok) return undefined; // the build-inputs row reports it
585
+ return prepareRightKitResolution({
586
+ root: appRoot,
587
+ extraLocks: rightKitLockInventory({ requiredInputs: inputs.value.requiredInputs, cargoLockPaths: inputs.value.cargoLockPaths, nativeLayout: inputs.value.nativeLayout, config, root: appRoot }),
588
+ versions: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-versions.json"), "utf8")),
589
+ ownership: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-ownership.json"), "utf8")),
590
+ releaseVersion: RELEASE_TOOL_VERSION,
591
+ // Build admission: refuse every pin behind the registry's latest. A dry run asks, but keeps no cache on disk.
592
+ admission: true,
593
+ ...(dryRun ? { cacheDir: "" } : {}),
594
+ });
595
+ },
596
+ });
597
+ for (const finding of result.findings) console.error(`right-release build: ${finding.row}: ${finding.detail}`);
598
+ for (const line of formatNotRun(result)) console.error(`right-release build: ${line}`);
599
+ if (result.findings.length > 0) {
600
+ console.error(`right-release build: admission found ${result.findings.length} blocking problem(s); nothing was started`);
601
+ process.exit(1);
602
+ }
603
+ return { config, commit, result, registry: result.out.registry ?? null };
604
+ }
605
+
555
606
  function hashInputs(files) {
556
607
  const hashes = {};
557
608
  for (const file of files) {
@@ -610,6 +661,14 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
610
661
  current.add(role);
611
662
  sources.set(source, current);
612
663
  };
664
+ // Named refusals for a config the admission's seal-routes row already refused, so nothing here crashes on an undefined field.
665
+ for (const artifact of [...(target.installer?.artifacts ?? []), ...(target.updater?.artifacts ?? [])]) {
666
+ if (typeof artifact?.file !== "string" || !artifact.file) fail("installer and updater artifacts must each declare a file");
667
+ }
668
+ for (const artifact of target.updater?.artifacts ?? []) {
669
+ if (target.updater?.kind !== "appinstaller" && (typeof artifact.signature !== "string" || !artifact.signature)) fail(`updater artifact ${artifact.file} must declare the signature file sign-updater writes next to it`);
670
+ if (artifact.key === undefined && config.distribution?.provider !== "github-releases") fail(`updater artifact ${artifact.file} must declare the R2 route key (key); only github-releases names the route from the file`);
671
+ }
613
672
  for (const artifact of target.installer?.artifacts ?? []) addSource(resolveArtifactPath(artifact.file), "installer");
614
673
  for (const artifact of target.updater?.artifacts ?? []) {
615
674
  addSource(resolveArtifactPath(artifact.file), "updater");
@@ -741,9 +800,9 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
741
800
  // an unsound seal, not a portable release.
742
801
  signing: signingIdentity ? { ...signingIdentity, receipts: (() => {
743
802
  const present = signingIdentity.receiptInputs.filter((file) => existsSync(file));
744
- if (present.length === 0) throw new Error(`no signing receipts found to seal; expected at least ${signingIdentity.receiptInputs[0]}`);
803
+ if (present.length === 0) fail(`no signing receipts found to seal; expected at least ${signingIdentity.receiptInputs[0]}`);
745
804
  if (platform === "mac") {
746
- if (present.length !== signingIdentity.receiptInputs.length) throw new Error("missing current-build macOS signing or notarization receipt");
805
+ if (present.length !== signingIdentity.receiptInputs.length) fail("missing current-build macOS signing or notarization receipt");
747
806
  validateNotarizationReceipt({ receiptPath: signingIdentity.receiptInputs[1], archive: resolveArtifactPath(target.notarize.file) });
748
807
  }
749
808
  return present.map((file) => ({ file, sha256: hashFile(file) }));
@@ -941,7 +1000,7 @@ function commandExists(name) {
941
1000
 
942
1001
  function assertSccacheVersion(version) {
943
1002
  const match = String(version ?? "").match(/sccache\s+(\d+)\.(\d+)\.(\d+)/i);
944
- if (!match || Number(match[1]) === 0 && Number(match[2]) < 15) fail("shared cache requires sccache >= 0.15.0; install with: cargo install sccache --locked; then verify: sccache --version");
1003
+ if (!match || Number(match[1]) === 0 && Number(match[2]) < 15) fail(sccacheRefusal(match ? match.slice(1).join(".") : null));
945
1004
  }
946
1005
 
947
1006
  function commandOutput(cmd, runArgs) {
@@ -1000,7 +1059,8 @@ function usage(code) {
1000
1059
 
1001
1060
  function fail(message) {
1002
1061
  console.error(`right-release build: ${message}`);
1003
- throw new Error(message);
1062
+ // `reported` marks an error whose text is already printed, so a caller that exits on it prints nothing twice.
1063
+ throw Object.assign(new Error(message), { reported: true });
1004
1064
  }
1005
1065
 
1006
1066
  function throwIfInterrupted() { if (interrupted) throw interrupted; }
@@ -54,7 +54,17 @@ const commands = new Map([
54
54
 
55
55
  const args = process.argv.slice(2);
56
56
  const first = args[0];
57
+ // The commands that compile, package, sign, ship or inspect a release run the app check (../app-check.mjs, CHECKED_COMMANDS) in this
58
+ // process before they start a child. A red check prints its findings and exits 1; there is no flag that skips it.
59
+ async function requireGreenCheck() {
60
+ const { guardCommand } = await import("../app-check.mjs");
61
+ const refused = guardCommand({ args });
62
+ if (!refused) return;
63
+ for (const line of refused.lines) console.error(line);
64
+ process.exit(refused.exitCode);
65
+ }
57
66
  if (first === "dev") {
67
+ await requireGreenCheck();
58
68
  import("../development.mjs").then(({ main }) => main(args.slice(1))).catch((error) => {
59
69
  console.error(`right-release dev: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
60
70
  process.exitCode = 1;
@@ -81,6 +91,8 @@ if (!first || first === "-h" || first === "--help") {
81
91
  process.exit(0);
82
92
  }
83
93
 
94
+ await requireGreenCheck();
95
+
84
96
  if (first === "--version" || first === "-v") {
85
97
  run("release.mjs", ["--version"]);
86
98
  } else if (first === "doctor") {
@@ -89,6 +101,18 @@ if (first === "--version" || first === "-v") {
89
101
  } else {
90
102
  run("release.mjs", ["--doctor", ...args.slice(1)]);
91
103
  }
104
+ } else if (first === "check") {
105
+ // The same offline check as `rightkit-gate check`; the repository is the git top-level of --config's folder, else the cwd.
106
+ const { appDirectory, main } = await import("../app-check.mjs");
107
+ process.exit(await main(args.slice(1), { label: "check", cwd: appDirectory(args, process.cwd()) }));
108
+ } else if (first === "hooks" && args[1] === "install") {
109
+ // One installer: `hooks install` is `rightkit-gate install-hook` (pre-push check and gate, ownership pre-commit step) run in the app.
110
+ const app = args.indexOf("--app");
111
+ if (app >= 0 && (!args[app + 1] || args[app + 1].startsWith("-"))) {
112
+ console.error("right-release hooks install: --app needs a directory");
113
+ process.exit(2);
114
+ }
115
+ run("bin/rightkit-gate.mjs", ["install-hook", ...(args.includes("--force") ? ["--force"] : [])], { cwd: app >= 0 ? path.resolve(args[app + 1]) : process.cwd() });
92
116
  } else if (first === "hooks") {
93
117
  run("ownership-hooks.mjs", args.slice(1));
94
118
  } else if (first === "suite-doctor") {
@@ -170,9 +194,9 @@ if (first === "--version" || first === "-v") {
170
194
  process.exit(2);
171
195
  }
172
196
 
173
- function run(script, scriptArgs) {
197
+ function run(script, scriptArgs, { cwd = process.cwd() } = {}) {
174
198
  const child = spawn(process.execPath, [path.join(packageRoot, script), ...scriptArgs], {
175
- cwd: process.cwd(),
199
+ cwd,
176
200
  env: process.env,
177
201
  stdio: "inherit",
178
202
  windowsHide: true,
@@ -308,12 +332,21 @@ Commands:
308
332
  apple profile renew [--config <file>] [--profile <file>] [--dry-run]
309
333
  Renew macOS profile for current Keychain signer
310
334
  signing-agent install|uninstall|status Manage invisible Aqua-session signing service
311
- doctor [--platform mac|win] [--gates] Inspect one app's release config; --gates also runs the build's pre-build source gates
335
+ check [--repo <dir>] [--config <file>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
336
+ The offline app check, same as rightkit-gate check: committed files only, every finding in one run (exit 0 pass, 1 findings, 2 could not run).
337
+ Build, package, dev, test, web-build, swift-package, recipe-build, stage-sidecar, qa, upload, publish, publish-update, github, doctor,
338
+ sign-windows, sign-updater, sign-candidate, sign-dev-artifact, addon build and create-mac-updater run it first and start nothing when it is red; there is no flag that skips it.
339
+ Baseline: .rightkit/app-check-baseline.json (entries carry issue, added and sunset; the sunset is at most 90 days after added)
340
+ doctor [--platform mac|win] [--gates] [--inspect] [--strict]
341
+ Inspect one app's release config the way the build would refuse it; --gates also runs the build's pre-build source gates
342
+ --inspect lists admission findings (newer RightKit versions) as information instead of refusing them
343
+ --strict makes a row that could not run on this host (NOT RUN) a blocking problem; without it NOT RUN is printed and does not fail
344
+ --admission is still accepted and does nothing (admission is the default); it cannot be combined with --inspect
312
345
  doctor --all Verify all Right Suite app release contracts
313
346
  suite-doctor Verify all local Right Suite repositories
314
347
  ownership [--app <dir>] [--json] Fail on app copies of RightKit-owned capabilities
315
348
  ownership --staged [--app <dir>] [--json] Pre-commit gate: scan the staged index for block-tier findings
316
- hooks install [--app <dir>] Install the advisory pre-commit ownership gate (pinned @rightkit/release)
349
+ hooks install [--app <dir>] [--force] Alias of rightkit-gate install-hook: the pre-push check and gate, and the advisory pre-commit ownership gate (pinned @rightkit/release)
317
350
  ownership enroll [--root] [--name <device>] [--root-fingerprint <sha256>] [--app <dir>]
318
351
  Enroll one native key or create a signed-device request
319
352
  ownership approve-key <fingerprint|request.json> Approve one pending device key from an active host
@@ -205,10 +205,11 @@ const WORKSPACE_JS_FILE = /^(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|
205
205
  const ESLINT_FILES = ["eslint.config.js", "eslint.config.mjs", ".eslintrc.json"];
206
206
  const ROOT_LINT_FILES = [...ESLINT_FILES, "biome.json"];
207
207
  // Everything scripts/generate-index.mjs reads or writes (its header and loadInventory): crate and package manifests and
208
- // READMEs, the root Cargo.toml (workspace version), the versions and ownership data, the ownership-availability helper it imports, itself, and its outputs.
208
+ // READMEs, the root Cargo.toml (workspace version), the versions and ownership data, the ownership-availability helper it imports, itself,
209
+ // the port matrix generator it imports, and its outputs (the port matrix's MD and HTML are checked for consistency with its JSON).
209
210
  // The Right Suite API contract and every projection scripts/gen-suite-api.mjs writes from it (the test-vector copy included).
210
211
  const SUITE_API_INPUT = /^(contracts\/right-suite-api\.v1\.json|scripts\/gen-suite-api\.mjs|crates\/rightkit-license\/src\/suite_api_generated\.rs|packages\/app-update\/src\/suite-api\.generated\.ts|packages\/release\/suite-api\.generated\.mjs|crates\/rightkit-license\/test-vectors\/right-suite-api\.v1\.json)$/;
211
- const GENERATOR_INPUT = /^(crates\/[^/]+\/(Cargo\.toml|README\.md)|packages\/[^/]+\/(package\.json|README\.md)|Cargo\.toml|packages\/release\/(rightkit-versions\.json|rightkit-ownership\.json|ownership-availability\.mjs)|scripts\/generate-index\.mjs|INDEX\.md|docs\/(ADOPTION|CAPABILITY-MAP)\.md)$/;
212
+ const GENERATOR_INPUT = /^(crates\/[^/]+\/(Cargo\.toml|README\.md)|packages\/[^/]+\/(package\.json|README\.md)|Cargo\.toml|packages\/release\/(rightkit-versions\.json|rightkit-ownership\.json|ownership-availability\.mjs)|scripts\/generate-(index|port-matrix)\.mjs|INDEX\.md|docs\/(ADOPTION|CAPABILITY-MAP|PORT-MATRIX)\.md|docs\/port-matrix\.(html|json))$/;
212
213
  const PROBES = Object.freeze({
213
214
  rustfmt: ["--version"], node: ["--version"], rustright: ["cargo", "--version"], pnpm: ["--version"],
214
215
  actionlint: ["-version"], "right-git": ["--version"], swift: ["--version"],
@@ -358,10 +359,25 @@ export function planScans({
358
359
  // ---- class A: content checks on the exported commit
359
360
  // A vendor/ file keeps upstream formatting only when vendor/upstream-files.json records it and its
360
361
  // bytes still match that upstream digest; any other vendor/ .rs (new, patched) is rustfmt-checked.
361
- let upstream = {};
362
- try { upstream = JSON.parse(readFile(path.join(contentRoot, "vendor/upstream-files.json"))).files ?? {}; } catch { /* no manifest: nothing exempt */ }
362
+ // A patched vendor/ file (rightkit#915) keeps upstream formatting only while its bytes match the
363
+ // reviewed `patched` record, which names its upstream base digest and the note describing the patch;
364
+ // reformatting it would bury the patch. Any edit changes the digest, so the file is checked until re-recorded.
365
+ let upstream = {}; let patched = {};
366
+ try {
367
+ const manifest = JSON.parse(readFile(path.join(contentRoot, "vendor/upstream-files.json")));
368
+ upstream = manifest.files ?? {}; patched = manifest.patched ?? {};
369
+ } catch { /* no manifest: nothing exempt */ }
370
+ const DIGEST = /^[0-9a-f]{64}$/;
363
371
  const verbatimUpstream = (f) => f.startsWith("vendor/") && typeof upstream[f] === "string" && bytesDigest(f) === upstream[f];
364
- const rustFiles = files.filter((f) => f.endsWith(".rs") && !verbatimUpstream(f));
372
+ const recordedPatch = (f) => f.startsWith("vendor/") && DIGEST.test(patched[f]?.base ?? "") && typeof patched[f]?.note === "string"
373
+ && patched[f].note.startsWith("vendor/") && DIGEST.test(patched[f]?.sha256 ?? "") && bytesDigest(f) === patched[f].sha256;
374
+ // Generated Rust is not hand-formatted: bindgen output, or rustfmt's own `@generated` marker in the first five lines.
375
+ const generatedRust = (f) => {
376
+ let head = "";
377
+ try { head = readFile(path.join(contentRoot, f)).split("\n", 5).join("\n"); } catch { return false; }
378
+ return /@generated\b|automatically generated by rust-bindgen\b/.test(head);
379
+ };
380
+ const rustFiles = files.filter((f) => f.endsWith(".rs") && !verbatimUpstream(f) && !recordedPatch(f) && !generatedRust(f));
365
381
  const byEdition = new Map();
366
382
  for (const f of rustFiles) {
367
383
  let dir = path.dirname(path.join(contentRoot, f));
@@ -16,6 +16,7 @@ import {
16
16
  generateAdoption,
17
17
  generateLocal,
18
18
  } from "../../scripts/generate-index.mjs";
19
+ import { PORT_MATRIX_OUTPUTS, generatePortMatrix } from "../../scripts/generate-port-matrix.mjs";
19
20
 
20
21
  const HERE = dirname(fileURLToPath(import.meta.url));
21
22
  export const DEFAULT_REPO_ROOT = resolve(HERE, "..", "..");
@@ -781,6 +782,16 @@ function checkGenerated({ repoRoot, appsRoot, apps, findings }) {
781
782
  for (const rel of driftOf(repoRoot, adoption)) {
782
783
  findings.push(issue("generated", rel, "generated-doc-drift", "committed adoption report differs from the app repos; run generate-index.mjs"));
783
784
  }
785
+ // The port matrix reads the app repos like the adoption report. Its stamp, app directories and HEADs, and per-app check counts
786
+ // are carried over from the committed docs/port-matrix.json, so only a change in the data itself reads as drift.
787
+ try {
788
+ const matrix = generatePortMatrix({ root: repoRoot, workspace: appsRoot, carry: true });
789
+ for (const rel of driftOf(repoRoot, matrix.outputs)) {
790
+ findings.push(issue("generated", rel, "generated-doc-drift", "committed port matrix differs from the app repos; run generate-index.mjs"));
791
+ }
792
+ } catch (error) {
793
+ findings.push(issue("generated", PORT_MATRIX_OUTPUTS[2], "generated-doc-drift", `the port matrix could not be regenerated: ${error.message}`));
794
+ }
784
795
  return { local, adoption: Object.values(adoption)[0] };
785
796
  }
786
797
 
@@ -842,7 +853,7 @@ export async function runInventoryCheck(options = {}) {
842
853
  account: { crates: account.crates, npm: account.npm },
843
854
  thirdParty: { declarations: thirdParty.declarations, uniquePackages: thirdParty.uniquePackages, lockDuplicates: thirdParty.lockDuplicates },
844
855
  apps: { rows: appRows },
845
- generated: { checked: ["INDEX.md", "docs/CAPABILITY-MAP.md", "docs/ADOPTION.md"] },
856
+ generated: { checked: ["INDEX.md", "docs/CAPABILITY-MAP.md", "docs/ADOPTION.md", ...PORT_MATRIX_OUTPUTS] },
846
857
  hygiene: { checked: records.length },
847
858
  },
848
859
  };
@@ -399,10 +399,12 @@
399
399
  "cargo:base64",
400
400
  "cargo:block2",
401
401
  "cargo:chrono",
402
+ "cargo:clap",
402
403
  "cargo:dirs",
403
404
  "cargo:futures",
404
405
  "cargo:getrandom",
405
406
  "cargo:image",
407
+ "cargo:libc",
406
408
  "cargo:objc2",
407
409
  "cargo:objc2-foundation",
408
410
  "cargo:reqwest",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -33,7 +33,9 @@
33
33
  "LICENSE-MIT",
34
34
  "LICENSE-APACHE",
35
35
  "!*.test.mjs",
36
- "!standalone-clone-evidence.json"
36
+ "!cli/*.test.mjs",
37
+ "!standalone-clone-evidence.json",
38
+ "!refusals.json"
37
39
  ],
38
40
  "sideEffects": false,
39
41
  "publishConfig": {
@@ -49,7 +51,7 @@
49
51
  "smol-toml": "1.9.1"
50
52
  },
51
53
  "scripts": {
52
- "test": "node --import ./test-support/broker-guard.mjs --test --test-concurrency=1 --test-force-exit *.test.mjs",
54
+ "test": "node --import ./test-support/broker-guard.mjs --test --test-concurrency=1 --test-force-exit *.test.mjs cli/*.test.mjs",
53
55
  "test:registry-parity": "node registry-parity.mjs --allow-unpublished",
54
56
  "test:signer:windows": "node sign-release-manifest.integration.mjs",
55
57
  "doctor:all": "node --test right-suite-contract.test.mjs",
@@ -1,6 +1,6 @@
1
1
  // Source-level release gates that run before a build starts. The build (release.mjs) and `doctor --gates` call the
2
2
  // same function, so the doctor can never pass a config the build refuses. Gate order is part of the contract.
3
- import { existsSync, readFileSync } from "node:fs";
3
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { spawnSync } from "./background-process.mjs";
6
6
  import { assertReleaseDependencies } from "./release-dependencies.mjs";
@@ -145,11 +145,15 @@ export function evaluatePrebuildGates({ root, config, target, platform, iosSpec,
145
145
  let sourceRoots;
146
146
  let skipRoots;
147
147
  let repoTop;
148
+ let appRootReal;
149
+ const nativeReal = (file) => { try { return realpathSync.native(file); } catch { return file; } };
148
150
  if (releaseSwitchInventoryText != null) {
149
151
  gate("source-switch-roots", ["closure-commands", ...(strict ? ["feature-closure"] : [])], () => {
150
152
  if (platform === "ios") {
151
153
  repoTop = git(root, ["rev-parse", "--show-toplevel"]);
152
- const swiftSide = (iosOwnershipScope({ repoRoot: repoTop, appRoot: root, target: config.targets?.ios }) ?? []).filter((dir) => dir !== ".rightkit").map((dir) => path.join(repoTop, dir));
154
+ // Git reports the canonical path; a caller's root may be a symlink or a Windows short name (hosted temp roots).
155
+ appRootReal = nativeReal(root);
156
+ const swiftSide = (iosOwnershipScope({ repoRoot: repoTop, appRoot: appRootReal, target: config.targets?.ios }) ?? []).filter((dir) => dir !== ".rightkit").map((dir) => path.join(repoTop, dir));
153
157
  sourceRoots = [...new Set([...swiftSide, ...localCrateDirsFromTree(treeText, repoTop)])].sort();
154
158
  console.log(`right-release: source switch scan (ios): ${sourceRoots.map((dir) => path.relative(repoTop, dir) || ".").join(", ")}`);
155
159
  } else {
@@ -162,7 +166,7 @@ export function evaluatePrebuildGates({ root, config, target, platform, iosSpec,
162
166
  if (platform === "ios") {
163
167
  gate("source-switch-prepare", ["source-switch-roots"], () => {
164
168
  for (const step of config.targets?.ios?.iosPackage?.prepare ?? []) {
165
- const cwd = path.resolve(root, step?.cwd ?? ".");
169
+ const cwd = path.resolve(appRootReal, step?.cwd ?? ".");
166
170
  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`);
167
171
  }
168
172
  });