@rightkit/release 0.6.4 → 0.6.6

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 (48) hide show
  1. package/README.md +109 -7
  2. package/admission-files.mjs +279 -0
  3. package/admission-host.mjs +403 -0
  4. package/admission.mjs +146 -0
  5. package/android-play-upload.mjs +4 -4
  6. package/app-check.mjs +1326 -0
  7. package/apple-profile-create.mjs +4 -3
  8. package/apple-profile-renew.mjs +5 -4
  9. package/bin/darwin/rightkit-swap +0 -0
  10. package/bin/rightkit-gate.mjs +495 -69
  11. package/binary-license-notices.mjs +2 -1
  12. package/build-release.mjs +87 -26
  13. package/cache-command.mjs +4 -3
  14. package/cargo-license-notices.mjs +2 -1
  15. package/cargo-manifest-text.mjs +25 -0
  16. package/cargo-profile-contract.mjs +26 -13
  17. package/cli/right-release.mjs +45 -5
  18. package/command-spec.mjs +4 -5
  19. package/deps.mjs +5 -4
  20. package/deterministic-scans.mjs +301 -37
  21. package/development.mjs +5 -5
  22. package/github-release.mjs +5 -3
  23. package/github-upload-gate.mjs +6 -6
  24. package/init-command.mjs +134 -8
  25. package/inventory-check.mjs +12 -1
  26. package/ios-upload.mjs +4 -4
  27. package/mirror-root-artifact.mjs +3 -4
  28. package/npm-license-notices.mjs +2 -1
  29. package/outdated-command.mjs +91 -0
  30. package/ownership-dependency-allowlist.json +2 -0
  31. package/package.json +41 -3
  32. package/pipeline-admission.mjs +12 -0
  33. package/prebuild-gates.mjs +7 -3
  34. package/publish-cargo.mjs +215 -3
  35. package/publish-update.mjs +5 -3
  36. package/release-config.mjs +36 -5
  37. package/release-dependencies-command.mjs +4 -3
  38. package/release-qa-audit.mjs +12 -10
  39. package/release-qa-command.mjs +42 -7
  40. package/release-qa-hardening.mjs +10 -3
  41. package/release-qa-policy.mjs +214 -47
  42. package/release.mjs +117 -62
  43. package/rightkit-registry-resolver.mjs +5 -2
  44. package/rightkit-versions.json +18 -13
  45. package/stage-sidecar-command.mjs +5 -5
  46. package/test-command.mjs +3 -2
  47. package/upload-release.mjs +3 -2
  48. 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
@@ -20,8 +20,11 @@ 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";
24
- import { fileURLToPath, pathToFileURL } from "node:url";
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";
26
+ import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
27
+ import { fileURLToPath } from "node:url";
25
28
  import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
26
29
  import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
27
30
  import { admitSourceQa } from "./release-qa-audit.mjs";
@@ -109,7 +112,7 @@ const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
109
112
  const WORKER = path.join(TOOL_ROOT, "release.mjs");
110
113
  const RELEASE_TOOL_VERSION = JSON.parse(readFileSync(path.join(TOOL_ROOT, "package.json"), "utf8")).version;
111
114
  const args = process.argv.slice(2);
112
- let configName = "right-release.config.mjs";
115
+ let configName = defaultReleaseConfigFile();
113
116
  let platform = process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform;
114
117
  let dryRun = false;
115
118
  let skipChecks = false;
@@ -129,12 +132,15 @@ for (let i = 0; i < args.length; i += 1) {
129
132
  if (platform !== "win" && platform !== "mac" && platform !== "android" && platform !== "ios") fail("--platform must be win, mac, android or ios");
130
133
  if (resumeFrom && !["prepackage", "sign", "package", "post-sign", "harden"].includes(resumeFrom)) fail("--resume-from must be prepackage, sign, package, post-sign, or harden");
131
134
 
132
- const configPath = path.resolve(configName);
135
+ // Native real paths: Git reports the long form of a Windows short name (RUNNER~1) and the target of a symlinked temp root
136
+ // (macOS /var -> /private/var). The config path is resolved the same way the doctor resolves it (release.mjs), so every path
137
+ // derived from it (app root, build inputs, scan roots) compares equal to what Git reports; a non-canonical app root made the
138
+ // build-inputs qualification drop every input and the clean-source gate speak before the dirty-inputs row.
139
+ const nativeReal = (file) => { try { return realpathSync.native(file); } catch { return file; } };
140
+ const configPath = nativeReal(path.resolve(configName));
133
141
  const invocationRoot = path.dirname(configPath);
134
142
  const repoRoot = git(invocationRoot, ["rev-parse", "--show-toplevel"]);
135
143
  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
144
  const relativeConfig = path.relative(nativeReal(repoRoot), nativeReal(configPath)).replaceAll("\\", "/");
139
145
  if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
140
146
  const appRoot = path.dirname(configPath);
@@ -148,21 +154,19 @@ let heavySlot;
148
154
  let interrupted;
149
155
  let requestActiveRunStop = null;
150
156
 
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
157
+ // Admission (admission.mjs): every refusal the committed files, this host or the registries can decide is raised here, before the
158
+ // dry-run exit, the clean-source gate, the release lock, any state file, the install, a slot or the worker. `right-release doctor`
159
+ // runs the same rows with the same text, so the two cannot disagree (rightkit#946); a refused build leaves nothing behind.
160
+ const admitted = await admit();
161
+
162
+ // A dry-run is an inspection contract, not a partial build. It prints the admission rows and exits with their verdict, so it can
163
+ // never pass what the build refuses. It must finish before locks, cache directories, receipts, dependency installation, or any
153
164
  // child build/sign process can exist.
154
165
  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}`);
166
+ console.log(`right-release build dry-run: ${admitted.config.app} ${admitted.config.version} ${platform}`);
164
167
  console.log(`config: ${configPath}`);
165
- console.log(`commit: ${commit}`);
168
+ console.log(`commit: ${admitted.commit}`);
169
+ for (const line of formatAdmission(admitted.result)) console.log(line);
166
170
  process.exit(0);
167
171
  }
168
172
 
@@ -192,7 +196,7 @@ try {
192
196
  const shortCommit = commit.slice(0, 8);
193
197
  const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
194
198
  if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
195
- const config = (await import(`${pathToFileURL(configPath).href}?commit=${commit}`)).default;
199
+ const config = await importReleaseConfig(configPath, { query: `commit=${commit}` });
196
200
  if (!config?.app || !config?.version) fail("release config must expose app and version");
197
201
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(config.app) || !/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(config.version)) {
198
202
  fail("app and version must be safe release-id components");
@@ -396,7 +400,8 @@ try {
396
400
  let rightKitResolution = null;
397
401
  if (!legacySealed) {
398
402
  try {
399
- rightKitResolution = await prepareRightKitResolution({
403
+ // A fresh build already asked the registries in admission(); the snapshot is bound to this release's state root below.
404
+ rightKitResolution = (!rightKitReuse && admitted.registry) || await prepareRightKitResolution({
400
405
  root: appRoot,
401
406
  extraLocks: rightKitLockInventory({ requiredInputs, cargoLockPaths: pinnedCargoLocks, nativeLayout, config, root: appRoot }),
402
407
  versions: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-versions.json"), "utf8")),
@@ -414,6 +419,7 @@ try {
414
419
  if (!(error instanceof RightKitRegistryError)) throw error;
415
420
  fail(`${error.recoverable ? "RECOVERABLE " : ""}${error.message}`);
416
421
  }
422
+ if (rightKitResolution === admitted.registry) writeSnapshot(rightKitSnapshotFile, rightKitResolution.snapshot);
417
423
  for (const line of rightKitResolution.freshness.tasks) console.error(`[right-release] LANE TASK ${line}`);
418
424
  for (const line of rightKitResolution.freshness.warnings) console.error(`[right-release] WARNING ${line}`);
419
425
  console.error(`[right-release] rightkit-registry: ${rightKitResolution.summary}`);
@@ -552,6 +558,52 @@ try {
552
558
  lock?.release();
553
559
  }
554
560
 
561
+ // Read the config and run the admission rows (files, this host and, for a fresh build, the registries) with no side effect: no lock,
562
+ // state directory, cache directory, install or snapshot file. A finding is printed with its row and the process exits 1, no stack.
563
+ async function admit() {
564
+ let commit;
565
+ let config;
566
+ try {
567
+ commit = git(invocationRoot, ["rev-parse", "HEAD"]);
568
+ const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
569
+ if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
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)}`); }
571
+ } catch (error) {
572
+ if (error?.reported) process.exit(1);
573
+ throw error;
574
+ }
575
+ // A fresh build asks the registries once, here; a resume or a re-run over a sealed release reuses its recorded snapshot instead.
576
+ const platformName = platform === "win" ? "windows" : platform;
577
+ const sealed = config?.app && config?.version
578
+ && existsSync(path.join(repoRoot, ".right-release", "sealed", `${config.app}-${config.version}-${commit.slice(0, 8)}`, platformName, "release-manifest.json"));
579
+ const reuse = Boolean(resumeFrom) || Boolean(sealed);
580
+ const result = await runAdmission({
581
+ root: appRoot, repoRoot, configPath, config: config ?? {}, platform, commit, skipChecks,
582
+ tiers: reuse ? ["files", "host"] : ["files", "host", "network"],
583
+ registry: async (ctx) => {
584
+ const inputs = ctx.buildInputs();
585
+ if (!inputs.ok) return undefined; // the build-inputs row reports it
586
+ return prepareRightKitResolution({
587
+ root: appRoot,
588
+ extraLocks: rightKitLockInventory({ requiredInputs: inputs.value.requiredInputs, cargoLockPaths: inputs.value.cargoLockPaths, nativeLayout: inputs.value.nativeLayout, config, root: appRoot }),
589
+ versions: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-versions.json"), "utf8")),
590
+ ownership: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-ownership.json"), "utf8")),
591
+ releaseVersion: RELEASE_TOOL_VERSION,
592
+ // Build admission: refuse every pin behind the registry's latest. A dry run asks, but keeps no cache on disk.
593
+ admission: true,
594
+ ...(dryRun ? { cacheDir: "" } : {}),
595
+ });
596
+ },
597
+ });
598
+ for (const finding of result.findings) console.error(`right-release build: ${finding.row}: ${finding.detail}`);
599
+ for (const line of formatNotRun(result)) console.error(`right-release build: ${line}`);
600
+ if (result.findings.length > 0) {
601
+ console.error(`right-release build: admission found ${result.findings.length} blocking problem(s); nothing was started`);
602
+ process.exit(1);
603
+ }
604
+ return { config, commit, result, registry: result.out.registry ?? null };
605
+ }
606
+
555
607
  function hashInputs(files) {
556
608
  const hashes = {};
557
609
  for (const file of files) {
@@ -579,7 +631,7 @@ function sqlCipherFeatures(text) {
579
631
  }
580
632
 
581
633
  function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, nativeLayout, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity, ownershipEvidence, qaAdmission, managedRoot, stateRoot, rightkitRegistryFields }) {
582
- // Every app's right-release.config.mjs names installer/updater artifacts
634
+ // Every app's release config names installer/updater artifacts
583
635
  // relative to its resolved native target. On a broker-managed host that
584
636
  // directory is never populated (the broker owns CARGO_TARGET_DIR and
585
637
  // targetBridge.ensure() deliberately leaves a real local directory alone,
@@ -610,6 +662,14 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
610
662
  current.add(role);
611
663
  sources.set(source, current);
612
664
  };
665
+ // Named refusals for a config the admission's seal-routes row already refused, so nothing here crashes on an undefined field.
666
+ for (const artifact of [...(target.installer?.artifacts ?? []), ...(target.updater?.artifacts ?? [])]) {
667
+ if (typeof artifact?.file !== "string" || !artifact.file) fail("installer and updater artifacts must each declare a file");
668
+ }
669
+ for (const artifact of target.updater?.artifacts ?? []) {
670
+ 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`);
671
+ 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`);
672
+ }
613
673
  for (const artifact of target.installer?.artifacts ?? []) addSource(resolveArtifactPath(artifact.file), "installer");
614
674
  for (const artifact of target.updater?.artifacts ?? []) {
615
675
  addSource(resolveArtifactPath(artifact.file), "updater");
@@ -741,9 +801,9 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
741
801
  // an unsound seal, not a portable release.
742
802
  signing: signingIdentity ? { ...signingIdentity, receipts: (() => {
743
803
  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]}`);
804
+ if (present.length === 0) fail(`no signing receipts found to seal; expected at least ${signingIdentity.receiptInputs[0]}`);
745
805
  if (platform === "mac") {
746
- if (present.length !== signingIdentity.receiptInputs.length) throw new Error("missing current-build macOS signing or notarization receipt");
806
+ if (present.length !== signingIdentity.receiptInputs.length) fail("missing current-build macOS signing or notarization receipt");
747
807
  validateNotarizationReceipt({ receiptPath: signingIdentity.receiptInputs[1], archive: resolveArtifactPath(target.notarize.file) });
748
808
  }
749
809
  return present.map((file) => ({ file, sha256: hashFile(file) }));
@@ -941,7 +1001,7 @@ function commandExists(name) {
941
1001
 
942
1002
  function assertSccacheVersion(version) {
943
1003
  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");
1004
+ if (!match || Number(match[1]) === 0 && Number(match[2]) < 15) fail(sccacheRefusal(match ? match.slice(1).join(".") : null));
945
1005
  }
946
1006
 
947
1007
  function commandOutput(cmd, runArgs) {
@@ -994,13 +1054,14 @@ function writeJson(file, value) {
994
1054
  }
995
1055
 
996
1056
  function usage(code) {
997
- console.log("usage: right-release build --platform win|mac|android|ios [--config right-release.config.mjs] [--dry-run]");
1057
+ console.log("usage: right-release build --platform win|mac|android|ios [--config right-release.config.json] [--dry-run]");
998
1058
  process.exit(code);
999
1059
  }
1000
1060
 
1001
1061
  function fail(message) {
1002
1062
  console.error(`right-release build: ${message}`);
1003
- throw new Error(message);
1063
+ // `reported` marks an error whose text is already printed, so a caller that exits on it prints nothing twice.
1064
+ throw Object.assign(new Error(message), { reported: true });
1004
1065
  }
1005
1066
 
1006
1067
  function throwIfInterrupted() { if (interrupted) throw interrupted; }
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"],
@@ -54,7 +55,17 @@ const commands = new Map([
54
55
 
55
56
  const args = process.argv.slice(2);
56
57
  const first = args[0];
58
+ // The commands that compile, package, sign, ship or inspect a release run the app check (../app-check.mjs, CHECKED_COMMANDS) in this
59
+ // process before they start a child. A red check prints its findings and exits 1; there is no flag that skips it.
60
+ async function requireGreenCheck() {
61
+ const { guardCommand } = await import("../app-check.mjs");
62
+ const refused = guardCommand({ args });
63
+ if (!refused) return;
64
+ for (const line of refused.lines) console.error(line);
65
+ process.exit(refused.exitCode);
66
+ }
57
67
  if (first === "dev") {
68
+ await requireGreenCheck();
58
69
  import("../development.mjs").then(({ main }) => main(args.slice(1))).catch((error) => {
59
70
  console.error(`right-release dev: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
60
71
  process.exitCode = 1;
@@ -81,6 +92,8 @@ if (!first || first === "-h" || first === "--help") {
81
92
  process.exit(0);
82
93
  }
83
94
 
95
+ await requireGreenCheck();
96
+
84
97
  if (first === "--version" || first === "-v") {
85
98
  run("release.mjs", ["--version"]);
86
99
  } else if (first === "doctor") {
@@ -89,6 +102,18 @@ if (first === "--version" || first === "-v") {
89
102
  } else {
90
103
  run("release.mjs", ["--doctor", ...args.slice(1)]);
91
104
  }
105
+ } else if (first === "check") {
106
+ // The same offline check as `rightkit-gate check`; the repository is the git top-level of --config's folder, else the cwd.
107
+ const { appDirectory, main } = await import("../app-check.mjs");
108
+ process.exit(await main(args.slice(1), { label: "check", cwd: appDirectory(args, process.cwd()) }));
109
+ } else if (first === "hooks" && args[1] === "install") {
110
+ // One installer: `hooks install` is `rightkit-gate install-hook` (pre-push check and gate, ownership pre-commit step) run in the app.
111
+ const app = args.indexOf("--app");
112
+ if (app >= 0 && (!args[app + 1] || args[app + 1].startsWith("-"))) {
113
+ console.error("right-release hooks install: --app needs a directory");
114
+ process.exit(2);
115
+ }
116
+ run("bin/rightkit-gate.mjs", ["install-hook", ...(args.includes("--force") ? ["--force"] : [])], { cwd: app >= 0 ? path.resolve(args[app + 1]) : process.cwd() });
92
117
  } else if (first === "hooks") {
93
118
  run("ownership-hooks.mjs", args.slice(1));
94
119
  } else if (first === "suite-doctor") {
@@ -170,9 +195,9 @@ if (first === "--version" || first === "-v") {
170
195
  process.exit(2);
171
196
  }
172
197
 
173
- function run(script, scriptArgs) {
198
+ function run(script, scriptArgs, { cwd = process.cwd() } = {}) {
174
199
  const child = spawn(process.execPath, [path.join(packageRoot, script), ...scriptArgs], {
175
- cwd: process.cwd(),
200
+ cwd,
176
201
  env: process.env,
177
202
  stdio: "inherit",
178
203
  windowsHide: true,
@@ -271,8 +296,12 @@ Commands:
271
296
  ios-credentials store --key-id <KEYID> --issuer <uuid> | check
272
297
  Put the App Store Connect API key (stdin), key id and issuer into the login Keychain once, or check them; never a file (macOS)
273
298
  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
274
- 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
299
+ 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
275
300
  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
301
+ qa policy show | set --app <app> --mode warn|refuse --set-by <name> [--stage source|installed]
302
+ Show the QA admission policy, or set an app's mode (both stages, or one with --stage)
303
+ qa policy approve-runner --runner <file> --version <v> --platform mac|win --set-by adrian [--note <text>]
304
+ 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
276
305
  publish-update --release <id> --platform mac|win --tier patch|update
277
306
  Upload sealed bytes to R2 & register RightApps
278
307
  release Compatibility alias for build
@@ -308,12 +337,21 @@ Commands:
308
337
  apple profile renew [--config <file>] [--profile <file>] [--dry-run]
309
338
  Renew macOS profile for current Keychain signer
310
339
  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
340
+ check [--repo <dir>] [--config <file>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
341
+ 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).
342
+ Build, package, dev, test, web-build, swift-package, recipe-build, stage-sidecar, qa, upload, publish, publish-update, github, doctor,
343
+ 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.
344
+ Baseline: .rightkit/app-check-baseline.json (entries carry issue, added and sunset; the sunset is at most 90 days after added)
345
+ doctor [--platform mac|win] [--gates] [--inspect] [--strict]
346
+ Inspect one app's release config the way the build would refuse it; --gates also runs the build's pre-build source gates
347
+ --inspect lists admission findings (newer RightKit versions) as information instead of refusing them
348
+ --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
349
+ --admission is still accepted and does nothing (admission is the default); it cannot be combined with --inspect
312
350
  doctor --all Verify all Right Suite app release contracts
313
351
  suite-doctor Verify all local Right Suite repositories
314
352
  ownership [--app <dir>] [--json] Fail on app copies of RightKit-owned capabilities
315
353
  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)
354
+ 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
355
  ownership enroll [--root] [--name <device>] [--root-fingerprint <sha256>] [--app <dir>]
318
356
  Enroll one native key or create a signed-device request
319
357
  ownership approve-key <fingerprint|request.json> Approve one pending device key from an active host
@@ -338,6 +376,8 @@ Commands:
338
376
  disk run self-check, check, dry-run sweep, move sweep, warn (the scheduled job)
339
377
  disk schedule install|uninstall|status Daily launchd job (Mac) / hidden Task Scheduler entry (Windows), bound to the stable per-user copy
340
378
  deps --check|--audit|--update Shared dependency lane
379
+ update check|apply|gate|land|discard|run|schedule [--repo <path>]
380
+ Run the pinned @rightkit/outdated updater (right-update) that moves stale pins; reads .rightkit/update.json
341
381
  hardening <artifact...> Run the Right Suite hardening scan
342
382
  lsclean <AppName.app> Clear macOS LaunchServices duplicates
343
383
  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 {
package/deps.mjs CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import path from "node:path";
3
- import { pathToFileURL } from "node:url";
3
+ import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
4
4
  import { spawnSync } from "./background-process.mjs";
5
5
  import { existsSync } from "node:fs";
6
6
  import { cargoAuditWorkdirs, runCargoAudits } from "./deps-cargo.mjs";
7
7
 
8
8
  const args = process.argv.slice(2);
9
9
  const opts = {
10
- config: "right-release.config.mjs",
10
+ config: defaultReleaseConfigFile(),
11
11
  mode: "check",
12
12
  latest: false,
13
13
  };
@@ -25,7 +25,8 @@ for (let i = 0; i < args.length; i++) {
25
25
  }
26
26
 
27
27
  const configPath = path.resolve(opts.config);
28
- const config = (await import(pathToFileURL(configPath))).default;
28
+ let config;
29
+ try { config = await importReleaseConfig(configPath); } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
29
30
  if (config?.schema !== 1) fail(`unsupported config schema: ${config?.schema ?? "<missing>"} (expected 1)`);
30
31
  if (config.packageManager !== "pnpm") fail(`deps tool expects pnpm, got ${config.packageManager}`);
31
32
 
@@ -69,7 +70,7 @@ if (failures.length > 0) {
69
70
 
70
71
  function usage(code, message) {
71
72
  if (message) console.error(`right-deps: ${message}\n`);
72
- console.error("usage: node deps.mjs [--config right-release.config.mjs] [--check|--audit|--update] [--latest]");
73
+ console.error("usage: node deps.mjs [--config right-release.config.json] [--check|--audit|--update] [--latest]");
73
74
  process.exit(code);
74
75
  }
75
76