@rightkit/release 0.6.5 → 0.6.7

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 (73) hide show
  1. package/README.md +196 -11
  2. package/admission-files.mjs +3 -2
  3. package/advisory-db.json +4 -0
  4. package/advisory-db.mjs +124 -0
  5. package/advisory-exceptions.mjs +13 -10
  6. package/android-play-upload.mjs +4 -4
  7. package/app-check.mjs +702 -26
  8. package/apple-profile-create.mjs +4 -3
  9. package/apple-profile-renew.mjs +5 -4
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/rightkit-agent-hook.mjs +9 -0
  12. package/bin/rightkit-gate.mjs +671 -133
  13. package/binary-license-notices.mjs +2 -1
  14. package/build-release.mjs +20 -12
  15. package/cache-command.mjs +4 -3
  16. package/cargo-license-notices.mjs +2 -1
  17. package/cargo-manifest-text.mjs +25 -0
  18. package/cargo-profile-contract.mjs +26 -13
  19. package/cli/right-release.mjs +37 -7
  20. package/command-spec.mjs +4 -5
  21. package/compile-inputs.mjs +66 -0
  22. package/consumer-contracts.json +1044 -0
  23. package/consumer-contracts.mjs +627 -0
  24. package/consumer-input-contracts.mjs +422 -0
  25. package/consumer-policy.mjs +588 -0
  26. package/consumer-source-contracts.mjs +469 -0
  27. package/deny-default.toml +42 -0
  28. package/deps.mjs +5 -4
  29. package/deterministic-scans.mjs +513 -54
  30. package/development.mjs +5 -5
  31. package/disk-command.mjs +270 -30
  32. package/disk-policy.json +50 -10
  33. package/disk-policy.mjs +482 -35
  34. package/disk-report.mjs +129 -0
  35. package/door-policy.mjs +228 -0
  36. package/gate-executor.mjs +30 -0
  37. package/gate-stages.mjs +110 -0
  38. package/github-release.mjs +5 -3
  39. package/github-updater-release.mjs +48 -8
  40. package/github-upload-gate.mjs +6 -6
  41. package/init-command.mjs +175 -8
  42. package/ios-upload.mjs +4 -4
  43. package/legion-semantic-contracts.mjs +428 -0
  44. package/mirror-root-artifact.mjs +3 -4
  45. package/npm-license-notices.mjs +2 -1
  46. package/outdated-command.mjs +91 -0
  47. package/ownership-class-rules.mjs +277 -0
  48. package/ownership-key-authority.mjs +3 -1
  49. package/ownership-mandatory-set.mjs +3 -2
  50. package/package.json +46 -3
  51. package/pipeline-admission.mjs +12 -0
  52. package/publish-cargo.mjs +219 -4
  53. package/publish-update.mjs +19 -14
  54. package/release-config.mjs +87 -11
  55. package/release-dependencies-command.mjs +4 -3
  56. package/release-qa-audit.mjs +12 -10
  57. package/release-qa-command.mjs +42 -7
  58. package/release-qa-hardening.mjs +10 -3
  59. package/release-qa-policy.mjs +214 -47
  60. package/release.mjs +4 -4
  61. package/rightkit-registry-resolver.mjs +5 -2
  62. package/rightkit-versions.json +178 -170
  63. package/shell-contract-bridge.json +251 -0
  64. package/shell-contract.mjs +1069 -0
  65. package/sign-updater.mjs +24 -4
  66. package/source-closure-rules.mjs +204 -0
  67. package/stage-sidecar-command.mjs +7 -6
  68. package/stage-sidecar.mjs +437 -114
  69. package/suite-api.generated.mjs +96 -6
  70. package/suite-trust-roots.mjs +198 -0
  71. package/test-command.mjs +3 -2
  72. package/upload-release.mjs +3 -2
  73. package/web-build.mjs +2 -1
@@ -18,6 +18,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
18
  import path from "node:path";
19
19
  import { pathToFileURL } from "node:url";
20
20
  import { CommandSpecError, loadAppConfig, relativeInside } from "./command-spec.mjs";
21
+ import { defaultReleaseConfigFile } from "./release-config.mjs";
21
22
  import { escapeHtml } from "./npm-license-notices.mjs";
22
23
 
23
24
  const fail = (message) => { throw new CommandSpecError(message); };
@@ -123,7 +124,7 @@ export function generateBinaryNotices({ appRoot, config, flags = {}, check = fal
123
124
  }
124
125
 
125
126
  export function parseBinaryNoticeArgs(argv) {
126
- const opts = { config: "right-release.config.mjs", output: undefined, productName: undefined, receipt: undefined, check: false };
127
+ const opts = { config: defaultReleaseConfigFile(), output: undefined, productName: undefined, receipt: undefined, check: false };
127
128
  for (let i = 0; i < argv.length; i += 1) {
128
129
  const arg = argv[i];
129
130
  if (arg === "--config" || arg === "--output" || arg === "--product-name" || arg === "--receipt") {
package/build-release.mjs CHANGED
@@ -23,7 +23,8 @@ import { spawn, spawnSync } from "./background-process.mjs";
23
23
  import { RightKitRegistryError, SNAPSHOT_ENV, SNAPSHOT_FILE, assertManifestRegistryEvidence, loadAdmittedSnapshot, prepareRightKitResolution, registryManifestFields, rightKitLockInventory, writeAdmissionRecord, writeSnapshot } from "./rightkit-registry-resolver.mjs";
24
24
  import { formatAdmission, formatNotRun, runAdmission } from "./admission.mjs";
25
25
  import { sccacheRefusal } from "./admission-host.mjs";
26
- import { fileURLToPath, pathToFileURL } from "node:url";
26
+ import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
27
+ import { fileURLToPath } from "node:url";
27
28
  import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
28
29
  import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
29
30
  import { admitSourceQa } from "./release-qa-audit.mjs";
@@ -51,7 +52,7 @@ import { macosCodesignEnvironment } from "./codesign-environment.mjs";
51
52
  import { ANDROID_RECEIPT_NAME, androidSealEntry, validateAndroidTarget, verifyAndroidReceipt } from "./android-release.mjs";
52
53
  import { IOS_RECEIPT_NAME, iosSealEntry, verifyIosReceipt } from "./ios-package-recipe.mjs";
53
54
  import { expandIosPackageTarget } from "./package-recipe-targets.mjs";
54
- import { assertUpdaterSignature } from "./github-updater-release.mjs";
55
+ import { assertUpdaterKeyId, assertUpdaterSignature, isSuiteUpdaterApp, resolveUpdaterTrust } from "./github-updater-release.mjs";
55
56
 
56
57
  // The iOS lane's own packaging code, kept out of the win/mac fingerprint for the same reason as the Android one.
57
58
  const IOS_PIPELINE_FINGERPRINT = createHash("sha256")
@@ -111,7 +112,7 @@ const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
111
112
  const WORKER = path.join(TOOL_ROOT, "release.mjs");
112
113
  const RELEASE_TOOL_VERSION = JSON.parse(readFileSync(path.join(TOOL_ROOT, "package.json"), "utf8")).version;
113
114
  const args = process.argv.slice(2);
114
- let configName = "right-release.config.mjs";
115
+ let configName = defaultReleaseConfigFile();
115
116
  let platform = process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform;
116
117
  let dryRun = false;
117
118
  let skipChecks = false;
@@ -195,7 +196,7 @@ try {
195
196
  const shortCommit = commit.slice(0, 8);
196
197
  const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
197
198
  if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
198
- const config = (await import(`${pathToFileURL(configPath).href}?commit=${commit}`)).default;
199
+ const config = await importReleaseConfig(configPath, { query: `commit=${commit}` });
199
200
  if (!config?.app || !config?.version) fail("release config must expose app and version");
200
201
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(config.app) || !/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(config.version)) {
201
202
  fail("app and version must be safe release-id components");
@@ -566,7 +567,7 @@ async function admit() {
566
567
  commit = git(invocationRoot, ["rev-parse", "HEAD"]);
567
568
  const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
568
569
  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
+ try { config = await importReleaseConfig(configPath, { query: `commit=${commit}` }); } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
570
571
  } catch (error) {
571
572
  if (error?.reported) process.exit(1);
572
573
  throw error;
@@ -630,7 +631,7 @@ function sqlCipherFeatures(text) {
630
631
  }
631
632
 
632
633
  function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields }) {
633
- // Every app's right-release.config.mjs names installer/updater artifacts
634
+ // Every app's release config names installer/updater artifacts
634
635
  // relative to its resolved native target. On a broker-managed host that
635
636
  // directory is never populated (the broker owns CARGO_TARGET_DIR and
636
637
  // targetBridge.ensure() deliberately leaves a real local directory alone,
@@ -687,17 +688,24 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
687
688
  }
688
689
  if (sources.size === 0) fail(`nothing to seal: declare installer.artifacts, updater.artifacts or artifacts for the ${platform} target`);
689
690
  for (const file of sources.keys()) if (!existsSync(file)) fail(`cannot seal missing artifact: ${file}`);
690
- // Every minisign updater requires an explicit trust root & full-byte signature
691
- // verification before any seal directory or artifact copy is created.
691
+ // Every minisign updater is checked against a trust root, by key id and over the full bytes, before any seal directory or
692
+ // artifact copy is created. A Right Suite app's root is the Right Suite contract's; any other app must declare its own key.
692
693
  const updaterPublicKey = config.distribution?.updaterPublicKey ?? config.distribution?.updaterPubkey ?? target?.updater?.pubkey ?? target?.updater?.publicKey;
693
694
  if ((target.updater?.artifacts?.length ?? 0) > 0 && target.updater?.kind !== "appinstaller") {
694
- if (typeof updaterPublicKey !== "string" || !updaterPublicKey.trim()) fail("updater public key is required to verify updater artifacts before sealing");
695
+ if (!isSuiteUpdaterApp(config.app) && (typeof updaterPublicKey !== "string" || !updaterPublicKey.trim())) fail("updater public key is required to verify updater artifacts before sealing");
696
+ let trust;
697
+ try { trust = resolveUpdaterTrust({ app: config.app, configuredPublicKey: updaterPublicKey, configuredKeyId: config.distribution?.updaterKeyId }); }
698
+ catch (error) { fail(`updater trust rejected at seal: ${error.message}`); }
695
699
  for (const artifact of target.updater?.artifacts ?? []) {
696
700
  const source = resolveArtifactPath(artifact.file);
697
701
  const signature = readFileSync(resolveArtifactPath(artifact.signature), "utf8").trim();
698
702
  if (!signature) fail(`updater signature is empty, refusing to seal: ${artifact.signature}`);
699
- try { assertUpdaterSignature(readFileSync(source), signature, updaterPublicKey, `signature of ${path.basename(artifact.file)}`); }
700
- catch (error) { fail(`updater signature rejected at seal: ${error.message}`); }
703
+ const label = `signature of ${path.basename(artifact.file)}`;
704
+ // The id comes first: a signature by another key would otherwise fail verification without saying whose it is.
705
+ try {
706
+ assertUpdaterKeyId(signature, trust.keyId, label, { app: config.app });
707
+ assertUpdaterSignature(readFileSync(source), signature, trust.publicKey, label);
708
+ } catch (error) { fail(`updater signature rejected at seal: ${error.message}`); }
701
709
  }
702
710
  }
703
711
  // Android: the build receipt must describe exactly the AAB being sealed (bytes re-hashed) and its signer must be the pinned
@@ -1053,7 +1061,7 @@ function writeJson(file, value) {
1053
1061
  }
1054
1062
 
1055
1063
  function usage(code) {
1056
- console.log("usage: right-release build --platform win|mac|android|ios [--config right-release.config.mjs] [--dry-run]");
1064
+ console.log("usage: right-release build --platform win|mac|android|ios [--config right-release.config.json] [--dry-run]");
1057
1065
  process.exit(code);
1058
1066
  }
1059
1067
 
package/cache-command.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { fileURLToPath, pathToFileURL } from "node:url";
4
+ import { fileURLToPath } from "node:url";
5
5
  import {
6
6
  applyCachePrune,
7
7
  inspectCache,
@@ -12,6 +12,7 @@ import {
12
12
  resolveSharedCacheIdentity,
13
13
  resolveSharedCacheRoot,
14
14
  } from "./cache-policy.mjs";
15
+ import { importReleaseConfig } from "./release-config.mjs";
15
16
  import { resolveReleaseLayout } from "./release-state.mjs";
16
17
  import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
17
18
  import { brokerManagedHost } from "./cargo-contract.mjs";
@@ -52,11 +53,11 @@ export async function runCacheCommand(args = process.argv.slice(2), { env = proc
52
53
  }
53
54
  if (command === "migrate") {
54
55
  const configPath = option(rest, "--config");
55
- if (!configPath) throw new Error("cache migrate requires --config <right-release.config.mjs>");
56
+ if (!configPath) throw new Error("cache migrate requires --config <right-release.config.json>");
56
57
  rejectUnknown(rest.filter((value) => value !== "--config" && value !== configPath), new Set(["--json", "--dry-run", "--apply"]));
57
58
  if (rest.includes("--dry-run") && rest.includes("--apply")) throw new Error("cache migrate accepts either --dry-run or --apply");
58
59
  const configFile = path.resolve(configPath);
59
- const config = (await import(pathToFileURL(configFile).href)).default;
60
+ const config = await importReleaseConfig(configFile);
60
61
  if (!config?.app) throw new Error("cache migrate config must expose app");
61
62
  const appRoot = path.dirname(configFile);
62
63
  const configuredTarget = config.targets?.[platform] ?? {};
@@ -15,6 +15,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import path from "node:path";
16
16
  import { pathToFileURL } from "node:url";
17
17
  import { CommandSpecError, loadAppConfig, relativeInside } from "./command-spec.mjs";
18
+ import { defaultReleaseConfigFile } from "./release-config.mjs";
18
19
  import { runCargoSync } from "./managed-cargo.mjs";
19
20
 
20
21
  const fail = (message) => { throw new CommandSpecError(message); };
@@ -73,7 +74,7 @@ export function generateCargoNotices({ appRoot, config, flags = {}, check = fals
73
74
  }
74
75
 
75
76
  export function parseCargoNoticeArgs(argv) {
76
- const opts = { config: "right-release.config.mjs", output: undefined, check: false };
77
+ const opts = { config: defaultReleaseConfigFile(), output: undefined, check: false };
77
78
  for (let i = 0; i < argv.length; i += 1) {
78
79
  const arg = argv[i];
79
80
  if (arg === "--config" || arg === "--output") {
@@ -0,0 +1,25 @@
1
+ // A line reading of the [package] table of a Cargo.toml, for the scans that must run without a TOML parser (the license scan and
2
+ // the secret scan of the repository gate). This module has no imports on purpose: publish-cargo.mjs is in the trusted runtime
3
+ // helper closure (scripts/ci/runtime-supplement.mjs), which may only follow static relative imports of plain modules.
4
+
5
+ /**
6
+ * `{ hasPackage, unpublished }` for the text of a Cargo.toml: whether it has a `[package]` table, and whether that table says
7
+ * `publish = false` or `publish = []`. A crate that can never be published ships its licence to no one, like a package.json with
8
+ * `"private": true`. An array-of-tables header (`[[bin]]`, `[[package.metadata.x]]`) starts a new table like any other, so a
9
+ * `publish` key under it is not the package's.
10
+ */
11
+ export function cargoManifestState(text) {
12
+ let table = "";
13
+ let hasPackage = false;
14
+ let unpublished = false;
15
+ for (const line of String(text).split(/\r?\n/)) {
16
+ const header = /^\s*\[\[?([^\]]+)\]\]?\s*(?:#.*)?$/.exec(line);
17
+ if (header) {
18
+ table = header[1].trim();
19
+ if (table === "package") hasPackage = true;
20
+ continue;
21
+ }
22
+ if (table === "package" && /^\s*publish\s*=\s*(?:false\b|\[\s*\])/.test(line)) unpublished = true;
23
+ }
24
+ return { hasPackage, unpublished };
25
+ }
@@ -19,6 +19,7 @@ import path from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { parse } from "smol-toml";
21
21
  import { findCargoManifests, isExcludedScanDirectory } from "./cargo-contract.mjs";
22
+ import { defaultReleaseConfigFile, isJsonReleaseConfig } from "./release-config.mjs";
22
23
 
23
24
  export const CARGO_PROFILE_TEMPLATE = fileURLToPath(new URL("./templates/cargo-profiles.toml", import.meta.url));
24
25
  export const RETIRED_PROFILE = "release-iterate";
@@ -74,28 +75,40 @@ export function checkManifestProfiles(manifest, template, file) {
74
75
  // Non-shipping workspaces: fixed prefixes plus the app's declared `cargoProfileExempt` directories.
75
76
  const NON_SHIPPING_PREFIXES = ["qa", "benchmarks"];
76
77
  const CARGO_PROFILE_EXEMPT_KEY = "cargoProfileExempt";
77
- const RELEASE_CONFIG = "right-release.config.mjs";
78
78
  const STRING_LITERAL = /"([^"\\\n]*)"|'([^'\\\n]*)'/g;
79
79
 
80
- // release.mjs imports the app's release config as an ES module. This scanner is synchronous and has only the
81
- // app root, so it reads the literal `cargoProfileExempt` array from the same file: every entry must be a plain
82
- // string literal naming a directory inside the app. Anything else is a finding, never silently ignored.
80
+ // This scanner is synchronous and has only the app root, so it reads `cargoProfileExempt` from the release config file itself and
81
+ // never imports it. A JSON config (the one `defaultReleaseConfigFile` picks when it exists) is parsed as data: the array must hold
82
+ // plain strings. A legacy `.mjs` config, which release.mjs still imports as an ES module, is read as text: the literal array must
83
+ // hold plain string literals. Either way every entry names a directory inside the app, and anything else is a finding, never
84
+ // silently ignored. M24 command admission is not run here: this is a pure file reader.
83
85
  export function readCargoProfileExempt(root) {
84
- const file = path.join(root, RELEASE_CONFIG);
86
+ const configFile = defaultReleaseConfigFile(root);
87
+ const file = path.join(root, configFile);
85
88
  const findings = [];
86
89
  if (!existsSync(file)) return { paths: [], findings };
87
- const match = new RegExp(`\\b${CARGO_PROFILE_EXEMPT_KEY}\\s*:\\s*\\[([^\\]]*)\\]`).exec(readFileSync(file, "utf8"));
88
- if (!match) return { paths: [], findings };
89
- const body = match[1];
90
- if (body.replace(STRING_LITERAL, "").replace(/[,\s]/g, "") !== "") {
91
- findings.push({ rule: "invalid-exempt", file: RELEASE_CONFIG, message: `${CARGO_PROFILE_EXEMPT_KEY} must be an array of string literals` });
90
+ let entries;
91
+ if (isJsonReleaseConfig(configFile)) {
92
+ let declared;
93
+ try { declared = JSON.parse(readFileSync(file, "utf8").replace(/^\uFEFF/, ""))?.[CARGO_PROFILE_EXEMPT_KEY]; }
94
+ catch (error) { return { paths: [], findings: [{ rule: "invalid-exempt", file: configFile, message: `is not valid JSON, so ${CARGO_PROFILE_EXEMPT_KEY} cannot be read: ${error.message}` }] }; }
95
+ if (declared === undefined) return { paths: [], findings };
96
+ if (!Array.isArray(declared) || declared.some((entry) => typeof entry !== "string")) findings.push({ rule: "invalid-exempt", file: configFile, message: `${CARGO_PROFILE_EXEMPT_KEY} must be an array of strings` });
97
+ entries = Array.isArray(declared) ? declared.filter((entry) => typeof entry === "string") : [];
98
+ } else {
99
+ const match = new RegExp(`\\b${CARGO_PROFILE_EXEMPT_KEY}\\s*:\\s*\\[([^\\]]*)\\]`).exec(readFileSync(file, "utf8"));
100
+ if (!match) return { paths: [], findings };
101
+ const body = match[1];
102
+ if (body.replace(STRING_LITERAL, "").replace(/[,\s]/g, "") !== "") {
103
+ findings.push({ rule: "invalid-exempt", file: configFile, message: `${CARGO_PROFILE_EXEMPT_KEY} must be an array of string literals` });
104
+ }
105
+ entries = [...body.matchAll(STRING_LITERAL)].map(([, double, single]) => double ?? single);
92
106
  }
93
107
  const paths = [];
94
- for (const [, double, single] of body.matchAll(STRING_LITERAL)) {
95
- const entry = double ?? single;
108
+ for (const entry of entries) {
96
109
  const normalized = path.posix.normalize(entry.replace(/\\/g, "/")).replace(/\/+$/, "");
97
110
  if (normalized === "." || normalized === "" || path.posix.isAbsolute(normalized) || normalized.split("/").includes("..")) {
98
- findings.push({ rule: "invalid-exempt", file: RELEASE_CONFIG, message: `${CARGO_PROFILE_EXEMPT_KEY} entry ${JSON.stringify(entry)} must be a relative directory inside the app` });
111
+ findings.push({ rule: "invalid-exempt", file: configFile, message: `${CARGO_PROFILE_EXEMPT_KEY} entry ${JSON.stringify(entry)} must be a relative directory inside the app` });
99
112
  } else paths.push(normalized);
100
113
  }
101
114
  return { paths, findings };
@@ -13,6 +13,7 @@ const commands = new Map([
13
13
  ["publish", "upload-release.mjs"],
14
14
  ["upload", "upload-release.mjs"],
15
15
  ["deps", "deps.mjs"],
16
+ ["update", "outdated-command.mjs"],
16
17
  ["publish-update", "publish-update.mjs"],
17
18
  ["hardening", "hardeningscan.mjs"],
18
19
  ["harden", "hardeningscan.mjs"],
@@ -56,8 +57,27 @@ const args = process.argv.slice(2);
56
57
  const first = args[0];
57
58
  // The commands that compile, package, sign, ship or inspect a release run the app check (../app-check.mjs, CHECKED_COMMANDS) in this
58
59
  // process before they start a child. A red check prints its findings and exits 1; there is no flag that skips it.
60
+ const WORKTREE_COMPILES = new Set(["dev", "test", "stage-sidecar"]);
59
61
  async function requireGreenCheck() {
60
- const { guardCommand } = await import("../app-check.mjs");
62
+ const { guardCommand, checkedCommandOf, checkModeFor, appDirectory } = await import("../app-check.mjs");
63
+ // One door (Adrian, 2026-10-25): a commit-bound command (build, package, release, qa, upload, publish, signing) runs only when
64
+ // rightkit-gate --to launched it for HEAD, after class A and the compile stage passed.
65
+ const checked = checkedCommandOf(args);
66
+ // dev, test and stage-sidecar work on the uncommitted tree but compile, so they too run only under a door run
67
+ // (rightkit-gate --to dev|test, class A on the working tree first).
68
+ // Gate on the mode, not the key: a flag-first call (`right-release --platform mac`) is a build with no command word.
69
+ if (checkModeFor(args) === "commit" || (checked && WORKTREE_COMPILES.has(checked))) {
70
+ const { requireDoorRun } = await import("../gate-stages.mjs");
71
+ const { spawnSync } = await import("../background-process.mjs");
72
+ const cwd = appDirectory(args, process.cwd());
73
+ const gitOut = (a) => { const r = spawnSync("git", a, { cwd, encoding: "utf8" }); return r.status === 0 ? r.stdout.trim() : ""; };
74
+ const common = gitOut(["rev-parse", "--git-common-dir"]);
75
+ // Outside a git repository the app check below refuses (nothing can be bound to a commit); inside one, only a door run admits.
76
+ if (common) {
77
+ const refusal = requireDoorRun({ commonDir: path.resolve(cwd, common), commit: gitOut(["rev-parse", "HEAD"]), command: `right-release ${checked ?? args.join(" ")}`.trim() });
78
+ if (refusal) { console.error(`right-release: ${refusal}`); process.exit(2); }
79
+ }
80
+ }
61
81
  const refused = guardCommand({ args });
62
82
  if (!refused) return;
63
83
  for (const line of refused.lines) console.error(line);
@@ -102,9 +122,11 @@ if (first === "--version" || first === "-v") {
102
122
  run("release.mjs", ["--doctor", ...args.slice(1)]);
103
123
  }
104
124
  } 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()) }));
125
+ // Exactly `rightkit-gate check` (one door): the app check, then every class A scan over the whole commit. The repository is the git
126
+ // top-level of --config's folder, else the cwd.
127
+ const { appDirectory } = await import("../app-check.mjs");
128
+ const { main } = await import("../bin/rightkit-gate.mjs");
129
+ process.exit(await main(["check", ...args.slice(1)], { cwd: appDirectory(args, process.cwd()) }));
108
130
  } else if (first === "hooks" && args[1] === "install") {
109
131
  // One installer: `hooks install` is `rightkit-gate install-hook` (pre-push check and gate, ownership pre-commit step) run in the app.
110
132
  const app = args.indexOf("--app");
@@ -295,8 +317,12 @@ Commands:
295
317
  ios-credentials store --key-id <KEYID> --issuer <uuid> | check
296
318
  Put the App Store Connect API key (stdin), key id and issuer into the login Keychain once, or check them; never a file (macOS)
297
319
  qa --stage source|installed --platform mac|win [--release <id>] Run rightkit-qa (and the journeys lane when the config sets qa.journeys) and write the sealed QA receipt. Refused inside a QA run. The receipt must match the platform and release id, come from an approved runner digest, and show every release-critical journey passed
298
- Admission is per app in qa-admission-policy.json: warn (default) records a refusal and continues; refuse blocks build, upload and publish. Only adrian moves an app from refuse back to warn
320
+ Admission is per app, and per stage (source = build, installed = upload and publish), in qa-admission-policy.json: warn (default) records a refusal and continues; refuse blocks that stage. Only adrian moves an app or stage from refuse back to warn, or approves a runner
299
321
  qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>] List source/installed QA status per release; exit 1 if an uploaded build lacks passing QA
322
+ qa policy show | set --app <app> --mode warn|refuse --set-by <name> [--stage source|installed]
323
+ Show the QA admission policy, or set an app's mode (both stages, or one with --stage)
324
+ qa policy approve-runner --runner <file> --version <v> --platform mac|win --set-by adrian [--note <text>]
325
+ Approve a rightkit-qa runner build for one platform: the file must report "rightkit-qa <v>" from --version and <v> must be the published version
300
326
  publish-update --release <id> --platform mac|win --tier patch|update
301
327
  Upload sealed bytes to R2 & register RightApps
302
328
  release Compatibility alias for build
@@ -368,9 +394,13 @@ Commands:
368
394
  disk check [--json] Per-writer sizes vs caps and free-space floor (exit 3 on warning)
369
395
  disk sweep [--dry-run] [--days N] [--delete] [--json]
370
396
  Move stale idle heavy data to the heavy volume (delete only with --delete)
371
- disk run self-check, check, dry-run sweep, move sweep, warn (the scheduled job)
372
- disk schedule install|uninstall|status Daily launchd job (Mac) / hidden Task Scheduler entry (Windows), bound to the stable per-user copy
397
+ disk run [--auto|--light] self-check, then the full pass (check, sweep, purge, daily report); --light is the stat-only hourly pass (floors, temp-like writers, scratch; purges only below the agent volume's soft floor); --auto picks full once a day after 03:30, else light (the scheduled job)
398
+ disk run-root <name> [--days N] [--json] Lease a run directory under the scratch root (default 3 days): the sweep skips it until the lease ends
399
+ disk run-root --release <name> End a run-root lease
400
+ disk schedule install|uninstall|status Hourly launchd job (Mac) / hidden Task Scheduler entry (Windows) running "disk run --auto", bound to the stable per-user copy; status fails when no full pass finished in 26 h
373
401
  deps --check|--audit|--update Shared dependency lane
402
+ update check|apply|gate|land|discard|run|schedule [--repo <path>]
403
+ Run the pinned @rightkit/outdated updater (right-update) that moves stale pins; reads .rightkit/update.json
374
404
  hardening <artifact...> Run the Right Suite hardening scan
375
405
  lsclean <AppName.app> Clear macOS LaunchServices duplicates
376
406
  generate-dmg-background <options> Generate the branded multi-resolution DMG background
package/command-spec.mjs CHANGED
@@ -5,13 +5,12 @@
5
5
  // `cwd` and `node` are app-relative and may not leave the app. `nodeFlags` go before the script, `nodeOptions` are merged
6
6
  // (de-duplicated) into NODE_OPTIONS for the child. Unknown keys are refused so a typo cannot silently change a run.
7
7
  import path from "node:path";
8
- import { pathToFileURL } from "node:url";
8
+ import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
9
9
 
10
- /** Load an app's `right-release.config.mjs`; the app root is the config file's directory. */
11
- export async function loadAppConfig(configFile = "right-release.config.mjs") {
10
+ /** Load an app's release config (`right-release.config.json`, or the legacy `.mjs` while it is the only one); the app root is the config file's directory. */
11
+ export async function loadAppConfig(configFile = defaultReleaseConfigFile()) {
12
12
  const configPath = path.resolve(configFile);
13
- const loaded = await import(pathToFileURL(configPath).href);
14
- return { config: loaded.default ?? loaded.config ?? loaded, appRoot: path.dirname(configPath), configPath };
13
+ return { config: await importReleaseConfig(configPath, { lenient: true }), appRoot: path.dirname(configPath), configPath };
15
14
  }
16
15
 
17
16
  export class CommandSpecError extends Error {
@@ -0,0 +1,66 @@
1
+ // The declared compile inputs a RustRight broker captured for a compile (RIGHTKIT_COMPILE_INPUTS): the gate judges exactly
2
+ // those bytes, where the broker copied them, so what is checked is what the compile reads. The tree's tracked files are
3
+ // judged from the commit as always; the declared inputs are the ignored paths (a built `dist`, generated assets) the
4
+ // commit does not hold, listed in the captured tree's `.rightkit/compile-inputs.json`.
5
+ import crypto from "node:crypto";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { spawnSync } from "./background-process.mjs";
9
+ import { SECRET_PATTERNS } from "./publish-cargo.mjs";
10
+
11
+ export const COMPILE_INPUTS_FILE = ".rightkit/compile-inputs.json";
12
+
13
+ /** Files of one declared input in the broker's order (children by name), refusing links; [{ name, abs }]. */
14
+ function inputFiles(root, input) {
15
+ const out = [];
16
+ const walk = (abs, name) => {
17
+ const st = fs.lstatSync(abs);
18
+ if (st.isSymbolicLink()) throw new Error(`declared compile input holds a symbolic link at ${name}`);
19
+ if (st.isDirectory()) {
20
+ for (const child of fs.readdirSync(abs).sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)))) walk(path.join(abs, child), `${name}/${child}`);
21
+ } else if (st.isFile()) out.push({ name, abs });
22
+ };
23
+ walk(path.join(root, input), input);
24
+ return out;
25
+ }
26
+
27
+ /**
28
+ * Judges the captured declared inputs under `root`: each declared path must exist without links; every `.js/.mjs/.cjs`
29
+ * file parses (`node --check`), every `.json` file parses, and no file carries a secret. Returns { inputs: [{ path, sha256,
30
+ * files }], findings: [string] }; `sha256` is computed as the broker records it (name, NUL, bytes, NUL per file, in order),
31
+ * so the receipt and the gate name the same bytes.
32
+ */
33
+ export function judgeCompileInputs(root, { spawn = spawnSync, execPath = process.execPath } = {}) {
34
+ const findings = [];
35
+ let declared = [];
36
+ const file = path.join(root, COMPILE_INPUTS_FILE);
37
+ if (!fs.existsSync(file)) return { inputs: [], findings };
38
+ try {
39
+ declared = JSON.parse(fs.readFileSync(file, "utf8")).inputs;
40
+ if (!Array.isArray(declared) || declared.some((p) => typeof p !== "string")) throw new Error("`inputs` must be a list of paths");
41
+ } catch (e) {
42
+ return { inputs: [], findings: [`${COMPILE_INPUTS_FILE}: ${e.message}`] };
43
+ }
44
+ const inputs = [];
45
+ for (const input of declared) {
46
+ if (path.isAbsolute(input) || input.split(/[\\/]/).some((part) => part === ".." || part === "." || part === "")) { findings.push(`${input}: not a plain relative path`); continue; }
47
+ let files;
48
+ try { files = inputFiles(root, input); } catch (e) { findings.push(`${input}: ${e.code === "ENOENT" ? "missing from the captured tree" : e.message}`); continue; }
49
+ const hash = crypto.createHash("sha256");
50
+ for (const f of files) {
51
+ const bytes = fs.readFileSync(f.abs);
52
+ hash.update(f.name); hash.update(Buffer.from([0])); hash.update(bytes); hash.update(Buffer.from([0]));
53
+ const text = bytes.toString("utf8");
54
+ if (SECRET_PATTERNS.some((pattern) => pattern.test(text))) findings.push(`${f.name}: holds a secret (token or private key pattern)`);
55
+ if (/\.(m|c)?js$/.test(f.name)) {
56
+ const r = spawn(execPath, ["--check", f.abs], { encoding: "utf8" });
57
+ if (r.status !== 0) findings.push(`${f.name}: does not parse (${String(r.stderr ?? "").split("\n").find((l) => /Error/.test(l))?.trim() ?? `node --check exit ${r.status}`})`);
58
+ }
59
+ if (/\.json$/.test(f.name)) {
60
+ try { JSON.parse(text); } catch (e) { findings.push(`${f.name}: invalid JSON (${e.message})`); }
61
+ }
62
+ }
63
+ inputs.push({ path: input, sha256: hash.digest("hex"), files: files.length });
64
+ }
65
+ return { inputs, findings };
66
+ }