@rightkit/release 0.2.174 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +200 -2
  2. package/addon-command.mjs +3 -4
  3. package/android-play-upload.mjs +2 -1
  4. package/apple-profile-renew.mjs +11 -3
  5. package/artifact-storage.mjs +35 -2
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +1 -1
  8. package/build-release.mjs +138 -47
  9. package/cli/right-release.mjs +40 -5
  10. package/codesign-environment.mjs +2 -2
  11. package/command-spec.mjs +80 -0
  12. package/content-digest.mjs +209 -0
  13. package/deps-cargo.mjs +55 -0
  14. package/deps.mjs +7 -0
  15. package/development.mjs +17 -6
  16. package/direct-bootstrap.mjs +5 -6
  17. package/disk-command.mjs +1 -0
  18. package/disk-policy.json +6 -2
  19. package/disk-stable-root.mjs +13 -9
  20. package/dist-scan.mjs +102 -0
  21. package/dmg-build.mjs +53 -4
  22. package/github-updater-release.mjs +70 -7
  23. package/icon-pack.mjs +117 -0
  24. package/in-place-install.mjs +17 -14
  25. package/init-command.mjs +116 -0
  26. package/install-app-windows.mjs +24 -7
  27. package/install-app.mjs +56 -42
  28. package/ios-asc-credentials.mjs +163 -0
  29. package/ios-build-context.mjs +129 -0
  30. package/ios-build-upload.mjs +282 -0
  31. package/ios-credentials-command.mjs +70 -0
  32. package/ios-dev.mjs +248 -0
  33. package/ios-host.mjs +76 -0
  34. package/ios-lock.mjs +75 -0
  35. package/ios-package-recipe.mjs +707 -0
  36. package/ios-testflight.mjs +266 -0
  37. package/ios-upload.mjs +313 -0
  38. package/local-publish.mjs +390 -47
  39. package/macho-approval-hash.mjs +181 -0
  40. package/managed-cargo.mjs +57 -1
  41. package/media-sidecar.mjs +20 -4
  42. package/mirror-root-artifact.mjs +74 -22
  43. package/native-cargo-layout.mjs +47 -4
  44. package/npm-license-notices.mjs +159 -0
  45. package/ownership-command.mjs +2 -1
  46. package/ownership-dependency-allowlist.json +2 -1
  47. package/ownership-evidence.mjs +30 -18
  48. package/ownership-key-command.mjs +69 -2
  49. package/ownership-mandatory-set.mjs +66 -16
  50. package/ownership-repositories.json +1 -1
  51. package/ownership-scope.mjs +94 -0
  52. package/ownership.mjs +86 -11
  53. package/package-recipe-targets.mjs +56 -2
  54. package/package.json +1 -1
  55. package/pe-content.mjs +133 -0
  56. package/preflight.mjs +14 -7
  57. package/project-type.mjs +3 -1
  58. package/protected-credentials.mjs +28 -1
  59. package/publication-artifacts.mjs +30 -5
  60. package/publication-gate.mjs +83 -5
  61. package/publish-cargo.mjs +124 -18
  62. package/publish-update.mjs +15 -2
  63. package/qa-build-features.mjs +16 -13
  64. package/qa-contract.mjs +30 -2
  65. package/qa-control-scan.mjs +4 -0
  66. package/release-dependencies-command.mjs +45 -0
  67. package/release-dependencies.mjs +60 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +62 -24
  71. package/release.mjs +144 -21
  72. package/rightkit-ownership.json +46 -2
  73. package/rightkit-versions.json +380 -189
  74. package/runtime-budgets.mjs +134 -23
  75. package/sign-updater.mjs +56 -59
  76. package/source-gate.mjs +8 -1
  77. package/stage-sidecar.mjs +634 -69
  78. package/target-distribution.mjs +5 -0
  79. package/test-command.mjs +190 -0
  80. package/toolchain-swift.mjs +239 -0
  81. package/upload-release.mjs +7 -2
  82. package/web-build.mjs +67 -0
@@ -7,7 +7,8 @@ import { isManagedHost, refuseManagedEnvSecrets } from "./managed-host-secrets.m
7
7
  // Protected credential loaders for release sites that previously read only
8
8
  // ambient environment variables. The storage pattern matches release-token.mjs:
9
9
  // macOS Keychain generic passwords (account $USER) and, on Windows, a protected
10
- // token file under %APPDATA%\RightKit. Values are returned to the caller only;
10
+ // token file under %APPDATA%\RightKit for legacy non-updater sites. Updater
11
+ // signing uses M11's native OS-store child-exec boundary. Other values return to the caller only;
11
12
  // they are never logged, and diagnostics name the secret, never its value.
12
13
  export const PROTECTED_SECRET_MISSING = "PROTECTED_SECRET_MISSING";
13
14
 
@@ -16,6 +17,7 @@ export const PROTECTED_SECRET_MISSING = "PROTECTED_SECRET_MISSING";
16
17
  export const PROTECTED_SECRETS = Object.freeze({
17
18
  "cloudflare-api-token": { label: "Cloudflare API token", keychain: "rightkit-cloudflare-api-token", file: "cloudflare-api-token.token" },
18
19
  "qa-receipt-key": { label: "RightKit QA receipt key", keychain: null, file: "qa-receipt-key.token", darwinFile: true },
20
+ "updater-signing-key": { label: "RightKit updater signing key", keychain: "rightsuite-updater-key", providerExec: true },
19
21
  });
20
22
 
21
23
  export class ProtectedSecretMissingError extends Error {
@@ -39,6 +41,7 @@ export function protectedSecretStoreCommand(name, platform = process.platform) {
39
41
  if (platform === "darwin" && spec.darwinFile) return null;
40
42
  if (platform === "darwin") return `security add-generic-password -a "$USER" -s ${spec.keychain} -U -w`;
41
43
  if (platform === "win32") {
44
+ if (spec.providerExec) return `$rightkitSecure = Read-Host -AsSecureString '${spec.label}'; $rightkitPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($rightkitSecure); try { [Runtime.InteropServices.Marshal]::PtrToStringBSTR($rightkitPointer) | & rightkit-credentials.exe set --namespace '${spec.keychain}' --account ([Environment]::UserName) --replace } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($rightkitPointer) }`;
42
45
  return `powershell -NoProfile -Command "$s = Read-Host -AsSecureString '${spec.label}'; $d = Join-Path $env:APPDATA 'RightKit'; New-Item -ItemType Directory -Force -Path $d | Out-Null; [IO.File]::WriteAllText((Join-Path $d '${spec.file}'), [Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s)))"`;
43
46
  }
44
47
  return null;
@@ -78,6 +81,9 @@ export function createCredentialStore({
78
81
  read(name) {
79
82
  if (!PROTECTED_SECRETS[name]) throw new Error(`unknown protected secret: ${name}`);
80
83
  const spec = PROTECTED_SECRETS[name];
84
+ // Signing keys stay inside the native provider's child-exec boundary.
85
+ // No JavaScript/file/environment fallback can export this credential.
86
+ if (spec.providerExec) throw new ProtectedSecretMissingError(name, protectedSecretStoreCommand(name, platform));
81
87
  let value;
82
88
  if (platform === "darwin") {
83
89
  // Receipt MAC keys are provisioned by right-release itself as a 0600 file, never from the Keychain.
@@ -98,6 +104,7 @@ export function createCredentialStore({
98
104
  // when the store has no value. A missing store entry on a managed host never
99
105
  // permits the environment fallback.
100
106
  export function resolveEnvBackedSecret({ name, envName, env = process.env, credentialStore, required = true }) {
107
+ if (PROTECTED_SECRETS[name]?.providerExec) throw new Error("updater signing requires the OS credential provider execution boundary");
101
108
  refuseManagedEnvSecrets(env, [envName]);
102
109
  if (!required) return undefined;
103
110
  const store = credentialStore ?? createCredentialStore({ env });
@@ -111,6 +118,26 @@ export function resolveEnvBackedSecret({ name, envName, env = process.env, crede
111
118
  }
112
119
  }
113
120
 
121
+ // M11 owns OS acquisition & secret lifetime. The release adapter supplies only
122
+ // non-secret identity plus an absolute native child executable and literal argv.
123
+ export function execWithUpdaterCredential(command, args, {
124
+ env = process.env, cwd = process.cwd(), run = spawnSync,
125
+ provider = process.platform === "win32" ? "rightkit-credentials.exe" : "rightkit-credentials",
126
+ account = os.userInfo().username,
127
+ } = {}) {
128
+ for (const key of Object.keys(env)) {
129
+ if (["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"].includes(key.toUpperCase()) && env[key]) {
130
+ throw new Error("updater signing key/password must come from the OS credential provider");
131
+ }
132
+ }
133
+ if (!path.isAbsolute(command)) throw new Error("credential provider child must be an absolute native executable");
134
+ const result = run(provider, ["exec", "--namespace", PROTECTED_SECRETS["updater-signing-key"].keychain,
135
+ "--account", account, "--env", "TAURI_SIGNING_PRIVATE_KEY", "--", command, ...args],
136
+ { cwd, env: { ...env, TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" }, stdio: "inherit", shell: false, windowsHide: true });
137
+ if (result.error?.code === "ENOENT") throw new Error("OS credential provider unavailable: install rightkit-credentials >=0.2.2 on PATH before updater signing");
138
+ return result;
139
+ }
140
+
114
141
  export function resolveCloudflareApiToken({ env = process.env, credentialStore, required = true } = {}) {
115
142
  return resolveEnvBackedSecret({ name: "cloudflare-api-token", envName: "CLOUDFLARE_API_TOKEN", env, credentialStore, required });
116
143
  }
@@ -41,7 +41,9 @@ export function assertArtifactUnchanged(artifact) {
41
41
  return artifact.bytes;
42
42
  }
43
43
 
44
- export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit, app, version, platform, configPath, releaseId }, deps = {}) {
44
+ // `streamRoles`: sealed entries of these roles are hash-verified by a streamed read and returned WITHOUT their bytes (the caller reads
45
+ // byte ranges of `file`), so a large IPA is never held in memory.
46
+ export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit, app, version, platform, configPath, releaseId, streamRoles = [], ownershipScope = null }, deps = {}) {
45
47
  let sealed;
46
48
  try { sealed = verifySealedRelease(sealedDir); }
47
49
  catch (error) {
@@ -52,7 +54,7 @@ export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit,
52
54
  if (!/^[a-f0-9]{40}$/.test(commit ?? "") || manifest.commit !== commit
53
55
  || (app && manifest.app !== app) || (version && manifest.version !== version)
54
56
  || manifest.platform !== platform || (releaseId && manifest.releaseId !== releaseId)) throw new PublicationError("PUBLICATION_SOURCE_MISMATCH", "sealed release source or product identity mismatch");
55
- const ownership = (deps.verifyOwnership ?? verifyOwnershipEvidence)(manifest.ownershipEvidence, repoRoot, appRoot);
57
+ const ownership = (deps.verifyOwnership ?? verifyOwnershipEvidence)(manifest.ownershipEvidence, repoRoot, appRoot, ownershipScope);
56
58
  if (!ownership.ok || manifest.ownershipEvidence?.commit !== commit) throw new PublicationError("PUBLICATION_OWNERSHIP_INVALID", `sealed ownership evidence rejected: ${ownership.reason ?? "commit mismatch"}`);
57
59
  if (!manifest.checkpoints?.includes("signed") || !manifest.checkpoints?.includes("hardened")) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "signed & hardened seal checkpoints are required");
58
60
  if (configPath && digestBytes(readFileSync(configPath)) !== manifest.signing?.configSha256) throw new PublicationError("PUBLICATION_POLICY_MISMATCH", "release config differs from sealed signing policy");
@@ -65,14 +67,16 @@ export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit,
65
67
  let notarized = false;
66
68
  let signed = false;
67
69
  let androidVerified = false;
70
+ let iosVerified = false;
68
71
  for (const receipt of receipts) {
69
72
  const artifact = readVerifiedArtifact(path.resolve(appRoot, receipt.file), receipt);
70
73
  let value;
71
74
  try { value = JSON.parse(artifact.bytes); } catch { throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", "invalid sealed signing receipt"); }
72
75
  if (value.schema !== 1) throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", "malformed sealed signing receipt");
73
76
  if (value.contract !== undefined) {
74
- verifyAndroidPublicationReceipt(value, manifest, platform);
75
- androidVerified = true; signed = true;
77
+ if (value.contract === IOS_RECEIPT_CONTRACT) { verifyIosPublicationReceipt(value, manifest, platform); iosVerified = true; }
78
+ else { verifyAndroidPublicationReceipt(value, manifest, platform); androidVerified = true; }
79
+ signed = true;
76
80
  continue;
77
81
  }
78
82
  if (Array.isArray(value.files)) {
@@ -93,10 +97,11 @@ export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit,
93
97
  if (!signed) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed native signing evidence is required");
94
98
  if (platform === "mac" && !notarized) throw new PublicationError("PUBLICATION_NOTARIZATION_MISSING", "sealed notarization evidence is required");
95
99
  if (platform === "android" && !androidVerified) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed android AAB upload-key receipt is required");
100
+ if (platform === "ios" && !iosVerified) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed iOS IPA build receipt is required");
96
101
  const artifacts = new Map();
97
102
  for (const entry of manifest.files ?? []) {
98
103
  if (typeof entry.name !== "string" || path.basename(entry.name) !== entry.name || artifacts.has(entry.name)) throw new PublicationError("PUBLICATION_SEAL_INVALID", "unsafe or duplicate sealed artifact name");
99
- artifacts.set(entry.name, readVerifiedArtifact(path.join(sealedDir, entry.name), entry));
104
+ artifacts.set(entry.name, streamRoles.includes(entry.role) ? verifyArtifactFile(path.join(sealedDir, entry.name), entry) : readVerifiedArtifact(path.join(sealedDir, entry.name), entry));
100
105
  }
101
106
  if (!artifacts.size) throw new PublicationError("PUBLICATION_SEAL_INVALID", "sealed release has no artifacts");
102
107
  return { ...sealed, artifacts };
@@ -124,6 +129,26 @@ function verifyAndroidPublicationReceipt(value, manifest, platform) {
124
129
  if (value.file.name !== aab.name) invalid("android AAB receipt names a different file than the sealed aab entry");
125
130
  }
126
131
 
132
+ // iOS IPA receipt (ios-package-recipe.mjs: contract "ios-xcodebuild-archive-automatic-v1"). The receipt was already read from the
133
+ // manifest's signing.receipts and hash-verified by the caller; here it must describe exactly the sealed "ipa" file, for the team
134
+ // and bundle id pinned in the sealed signing policy. Fails closed with a specific message on any mismatch.
135
+ const IOS_RECEIPT_CONTRACT = "ios-xcodebuild-archive-automatic-v1";
136
+ function verifyIosPublicationReceipt(value, manifest, platform) {
137
+ const invalid = (message) => { throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", message); };
138
+ if (platform !== "ios" || manifest.platform !== "ios") invalid("iOS IPA receipt in a non-iOS release");
139
+ const pinnedTeam = manifest.signing?.teamId;
140
+ if (!/^[A-Z0-9]{10}$/.test(pinnedTeam ?? "")) invalid("sealed signing policy has no pinned iOS team id");
141
+ if (value.teamId !== pinnedTeam) invalid("iOS IPA receipt team id is not the pinned team");
142
+ if (!manifest.signing?.bundleId || value.bundleId !== manifest.signing.bundleId) invalid("iOS IPA receipt bundle id is not the pinned bundle id");
143
+ if (!value.marketingVersion || !value.buildNumber) invalid("iOS IPA receipt has no version identity");
144
+ const ipas = (manifest.files ?? []).filter((entry) => entry.role === "ipa");
145
+ if (ipas.length !== 1) invalid(ipas.length ? "sealed release has more than one ipa entry" : "sealed release has no ipa entry");
146
+ const [ipa] = ipas;
147
+ if (!/^[a-f0-9]{64}$/.test(value.file?.sha256 ?? "") || value.file.sha256 !== ipa.sha256) invalid("iOS IPA receipt sha256 differs from the sealed ipa entry");
148
+ if (!Number.isInteger(value.file.sizeBytes) || value.file.sizeBytes !== ipa.sizeBytes) invalid("iOS IPA receipt size differs from the sealed ipa entry");
149
+ if (value.file.name !== ipa.name) invalid("iOS IPA receipt names a different file than the sealed ipa entry");
150
+ }
151
+
127
152
  // Bounded memory for large artifacts. Transport must read back against this digest.
128
153
  export function verifyArtifactFile(file, evidence) {
129
154
  if (!/^[a-f0-9]{64}$/.test(evidence?.sha256 ?? "")) throw new PublicationError("PUBLICATION_SEAL_INVALID", "publication hash evidence is required");
@@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url";
13
13
  import { digestBytes, readVerifiedArtifact } from "./publication-artifacts.mjs";
14
14
 
15
15
  export const GATE_SCHEMA = 1;
16
+ export const MAX_CARGO_JOBS = 64;
16
17
  export const REQUIRED_HOSTS = Object.freeze(["darwin", "win32"]);
17
18
  // Swift packages (RightKitSwift) are Apple-only; Windows cannot run them.
18
19
  export const requiredHosts = (kind) => (kind === "swift" ? Object.freeze(["darwin"]) : REQUIRED_HOSTS);
@@ -20,6 +21,11 @@ const SHA = /^[0-9a-f]{40}$/;
20
21
  // The one definition of a recorded darwin-only host policy (release evidence and the runtime budget gate share it).
21
22
  export const isDarwinOnlyPolicy = (source) => source?.hostPolicy === "darwin-only"
22
23
  && typeof source.hostPolicyReason === "string" && source.hostPolicyReason.trim() !== "";
24
+ // The symmetric recorded win32-only host policy: a crate published from the Windows host under the local-build grant.
25
+ export const isWin32OnlyPolicy = (source) => source?.hostPolicy === "win32-only"
26
+ && typeof source.hostPolicyReason === "string" && source.hostPolicyReason.trim() !== "";
27
+ /** The one host a recorded single-host policy names ("darwin" or "win32"), or null when none is recorded. */
28
+ export const singleHostOfPolicy = (source) => (isDarwinOnlyPolicy(source) ? "darwin" : isWin32OnlyPolicy(source) ? "win32" : null);
23
29
 
24
30
  export function defaultEvidencePath(workspaceRoot, kind, name) {
25
31
  return path.join(workspaceRoot, "release-evidence", "gate", `${kind}-${name.replace(/^@/, "").replace("/", "-")}.json`);
@@ -48,11 +54,75 @@ function describeCommitMismatch(evidenceCommit, head, packagePath, git) {
48
54
  return parts.join("; ");
49
55
  }
50
56
 
57
+ const text = (value) => typeof value === "string" && value.trim() !== "";
58
+ const escapeRegExp = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
59
+
60
+ // A batch publication (local-publish.mjs --crates) runs fmt, the secret scan and clippy ONCE for the whole
61
+ // batch at one commit and tree, then copies that receipt into every crate's evidence run as `run.batch`.
62
+ // The receipt proves, for THIS crate, the same three checks a per-crate run proves; it is bound to the evidence
63
+ // commit and to the HEAD tree (the git tree of that commit), so it cannot be carried to another commit.
64
+ export const BATCH_CHECKS = Object.freeze(["fmt", "secret-scan", "clippy"]);
65
+ const BATCH_COMMAND = Object.freeze({
66
+ fmt: /\bfmt\b.*--check\b/,
67
+ "secret-scan": /\S/,
68
+ clippy: /\bclippy\b.*--all-targets\b.*-D warnings\s*$/,
69
+ });
70
+
71
+ /** Problems with a batch receipt for the crate `evidence.name` (empty when it proves fmt, secret scan and clippy).
72
+ * `tree` is the current HEAD tree when known; `undefined` skips only that comparison. */
73
+ export function batchReceiptProblems(batch, evidence, tree) {
74
+ if (!batch || typeof batch !== "object" || Array.isArray(batch)) return ["batch receipt must be an object"];
75
+ const problems = [];
76
+ if (!text(batch.id)) problems.push("batch.id");
77
+ if (batch.commit !== evidence.commit) problems.push(`batch.commit (must equal ${evidence.commit})`);
78
+ if (!SHA.test(String(batch.tree ?? ""))) problems.push("batch.tree (must be a 40-hex git tree)");
79
+ else if (tree !== undefined && batch.tree !== tree) problems.push(`batch.tree (must equal the HEAD tree ${tree})`);
80
+ if (!(Array.isArray(batch.crates) && batch.crates.includes(evidence.name))) problems.push("batch.crates (must name this crate)");
81
+ const checks = Array.isArray(batch.checks) ? batch.checks : [];
82
+ for (const name of BATCH_CHECKS) {
83
+ const rows = checks.filter((row) => row?.name === name);
84
+ if (!rows.length) { problems.push(`batch check ${name} missing`); continue; }
85
+ for (const row of rows) {
86
+ if (row.ok !== true) problems.push(`batch check ${name} not ok`);
87
+ if (row.commit !== evidence.commit || row.tree !== batch.tree) problems.push(`batch check ${name} not bound to the batch commit and tree`);
88
+ if (!(text(row.command) && BATCH_COMMAND[name].test(row.command))) problems.push(`batch check ${name} command`);
89
+ }
90
+ // fmt is workspace-wide; clippy and the secret scan name the crates they covered.
91
+ if (name !== "fmt" && !rows.some((row) => Array.isArray(row.crates) && row.crates.includes(evidence.name))) problems.push(`batch check ${name} does not cover this crate`);
92
+ }
93
+ return problems;
94
+ }
95
+
96
+ /**
97
+ * Which per-crate publication steps the evidence ALREADY proves for this exact commit, so the publisher may
98
+ * skip repeating them: `tests` (a passing `cargo test -p <name>` step that executed tests), `clippy` (a passing
99
+ * `cargo clippy -p <name> ... -D warnings` step, or a valid batch receipt covering the crate), and `fmt` and
100
+ * `secretScan` (a valid batch receipt covering the crate). Every run on the proving host (darwin, or win32 under a win32-only policy) at the evidence commit must prove a
101
+ * step for it to count. Call after the gate accepted the evidence (the gate binds commit, tree and artifact).
102
+ */
103
+ export function provenGateSteps(evidence) {
104
+ const none = { tests: false, clippy: false, fmt: false, secretScan: false };
105
+ if (!evidence || !SHA.test(String(evidence.commit ?? "")) || !text(evidence.name)) return none;
106
+ // The proving host is the one a recorded win32-only policy names; every other evidence is proved on darwin.
107
+ const proofHost = isWin32OnlyPolicy(evidence) ? "win32" : "darwin";
108
+ const runs = (Array.isArray(evidence.runs) ? evidence.runs : []).filter((run) => run?.host?.os === proofHost);
109
+ if (!runs.length || runs.some((run) => run.commit !== evidence.commit)) return none;
110
+ const name = escapeRegExp(evidence.name);
111
+ const stepProves = (run, pattern, needTests) => (Array.isArray(run.steps) ? run.steps : []).some((step) => step?.ok === true
112
+ && pattern.test(String(step.command ?? "")) && (!needTests || (step.executed > 0 && step.failed === 0)));
113
+ const batchOk = (run) => run.batch !== undefined && batchReceiptProblems(run.batch, evidence, undefined).length === 0;
114
+ return {
115
+ tests: runs.every((run) => stepProves(run, new RegExp(`^cargo test -p ${name}( |$)`), true)),
116
+ clippy: runs.every((run) => batchOk(run) || stepProves(run, new RegExp(`^cargo clippy -p ${name} .*-- -D warnings$`), false)),
117
+ fmt: runs.every(batchOk),
118
+ secretScan: runs.every(batchOk),
119
+ };
120
+ }
121
+
51
122
  // Names of the fields of one host run that do not qualify (empty when the run qualifies). Every run binds to
52
123
  // the same unit, commit and artifact: commit, host arch, feature set and artifactSha256 equal to the evidence.
53
- function invalidRunFields(run, evidence) {
124
+ function invalidRunFields(run, evidence, tree) {
54
125
  const bad = [];
55
- const text = (value) => typeof value === "string" && value.trim() !== "";
56
126
  if (run.commit !== evidence.commit) bad.push(`commit (must equal ${evidence.commit})`);
57
127
  if (!text(run.host?.arch)) bad.push("host.arch");
58
128
  if (!(Array.isArray(run.features) && run.features.length > 0 && run.features.every(text))) bad.push("features");
@@ -62,6 +132,8 @@ function invalidRunFields(run, evidence) {
62
132
  if (!text(run.timestamp)) bad.push("timestamp");
63
133
  if (!(Number.isInteger(run.passed) && run.passed > 0)) bad.push("passed (must be > 0)");
64
134
  if (run.failed !== 0) bad.push("failed (must be 0)");
135
+ if (run.jobs !== undefined && !(Number.isInteger(run.jobs) && run.jobs >= 1 && run.jobs <= MAX_CARGO_JOBS)) bad.push(`jobs (must be an integer from 1 to ${MAX_CARGO_JOBS})`);
136
+ if (run.batch !== undefined) bad.push(...batchReceiptProblems(run.batch, evidence, tree ?? null).map((problem) => `batch: ${problem}`));
65
137
  return bad;
66
138
  }
67
139
 
@@ -88,17 +160,23 @@ export function checkPublicationGate({ kind, name, version, packagePath, evidenc
88
160
  missing.push(`evidence commit ${evidence.commit} != current HEAD ${head} (${describeCommitMismatch(evidence.commit, head, packagePath, git)})`);
89
161
  }
90
162
  const runs = Array.isArray(evidence.runs) ? evidence.runs : [];
163
+ // A batch receipt is bound to the HEAD tree as well as the commit; read it only when a run carries one.
164
+ // `tree ?? null` below makes an unreadable tree fail the comparison (null never equals a 40-hex tree).
165
+ const tree = runs.some((run) => run?.batch !== undefined) ? git(["rev-parse", "HEAD^{tree}"], packagePath) : undefined;
166
+ if (tree === null) missing.push(`cannot read the HEAD tree for ${packagePath}`);
91
167
  // npm packages and crates may ship on macOS evidence first when Adrian decides so; the decision must be
92
168
  // recorded in the evidence (hostPolicy "darwin-only" + a non-empty hostPolicyReason).
93
169
  // Crates may ship Mac-first too (Adrian 2026-10-07): the Windows run follows; the reason is recorded.
94
- const darwinOnly = (kind === "npm" || kind === "cargo") && isDarwinOnlyPolicy(evidence);
95
- for (const host of darwinOnly ? ["darwin"] : requiredHosts(kind)) {
170
+ // A crate published from the Windows host under the local-build grant records the symmetric
171
+ // hostPolicy "win32-only" + reason, and then the one required receipt is the Windows run.
172
+ const singleHost = (kind === "npm" || kind === "cargo") ? singleHostOfPolicy(evidence) : null;
173
+ for (const host of singleHost ? [singleHost] : requiredHosts(kind)) {
96
174
  const label = host === "darwin" ? "macOS" : "Windows";
97
175
  const matching = runs.filter((run) => run?.host?.os === host);
98
176
  if (!matching.length) { missing.push(`no ${label} host run receipt`); continue; }
99
177
  // Every run on the host must qualify: a failing run is not masked by a passing run beside it.
100
178
  matching.forEach((run, index) => {
101
- const fields = invalidRunFields(run, evidence);
179
+ const fields = invalidRunFields(run, evidence, tree);
102
180
  if (fields.length) missing.push(`${label} run receipt ${index} invalid: ${fields.join(", ")}`);
103
181
  });
104
182
  }
package/publish-cargo.mjs CHANGED
@@ -2,8 +2,9 @@
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { enforcePublicationGate, defaultEvidencePath } from "./publication-gate.mjs";
5
+ import { enforcePublicationGate, defaultEvidencePath, provenGateSteps, singleHostOfPolicy } from "./publication-gate.mjs";
6
6
  import { runCargoSync } from "./managed-cargo.mjs";
7
+ import { spawnSync } from "./background-process.mjs";
7
8
  import { readVerifiedArtifact, assertArtifactUnchanged } from "./publication-artifacts.mjs";
8
9
  import { verifyCargoRegistry } from "./cargo-registry-readback.mjs";
9
10
  import { recordPublished } from "./rightkit-sync.mjs";
@@ -23,7 +24,7 @@ export function parseCargoPublishArgs(argv, cwd = process.cwd()) {
23
24
  const crate = valueAfter(argv, "--crate");
24
25
  if (!crate) throw new Error("publish cargo requires --crate <name>");
25
26
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(crate)) throw new Error(`invalid crate name: ${crate}`);
26
- const allowed = new Set(["--crate", "--workspace", "--dry-run", "--allow-dirty", "--evidence"]);
27
+ const allowed = new Set(["--crate", "--workspace", "--dry-run", "--allow-dirty", "--evidence", "--from-evidence"]);
27
28
  for (let index = 0; index < argv.length; index += 1) {
28
29
  const arg = argv[index];
29
30
  if (!arg.startsWith("--")) continue;
@@ -33,12 +34,45 @@ export function parseCargoPublishArgs(argv, cwd = process.cwd()) {
33
34
  const dryRun = argv.includes("--dry-run");
34
35
  const allowDirty = argv.includes("--allow-dirty");
35
36
  if (allowDirty && !dryRun) throw new Error("--allow-dirty is only valid with --dry-run");
37
+ const fromEvidence = valueAfter(argv, "--from-evidence");
38
+ if (argv.includes("--from-evidence") && (!fromEvidence || fromEvidence.startsWith("--"))) throw new Error("--from-evidence requires a gate evidence file");
39
+ if (fromEvidence && (dryRun || allowDirty || argv.includes("--evidence"))) throw new Error("--from-evidence cannot be combined with --dry-run, --allow-dirty or --evidence");
36
40
  return {
37
41
  crate,
38
42
  dryRun,
39
43
  allowDirty,
40
44
  workspaceRoot: path.resolve(valueAfter(argv, "--workspace") ?? cwd),
41
- evidence: valueAfter(argv, "--evidence"),
45
+ evidence: fromEvidence ?? valueAfter(argv, "--evidence"),
46
+ ...(fromEvidence ? { fromEvidence: true } : {}),
47
+ };
48
+ }
49
+
50
+ const OFFLINE_CACHE_MISS = /(?:attempting to make an HTTP request, but --offline was specified|as a reminder, you're using offline mode(?: \(--offline\))?)/i;
51
+ const OFFLINE_READ_ONLY_COMMANDS = new Set(["metadata", "package"]);
52
+
53
+ /** Retry only a Cargo offline cache miss for metadata/package inspection, once with normal network access. */
54
+ export async function runCargoReadOnly(args, settings, execute, onOfflineFailure = () => {}) {
55
+ const subcommand = args[0];
56
+ if (!OFFLINE_READ_ONLY_COMMANDS.has(subcommand) || !args.includes("--offline")) {
57
+ return execute("cargo", args, settings);
58
+ }
59
+ const first = await execute("cargo", args, settings);
60
+ if (!first.error && first.status === 0) return first;
61
+ const diagnostic = `${first.stdout ?? ""}\n${first.stderr ?? ""}`;
62
+ if (!OFFLINE_CACHE_MISS.test(diagnostic)) return first;
63
+ onOfflineFailure(first);
64
+ const retryArgs = args.filter((arg) => arg !== "--offline");
65
+ const retry = await execute("cargo", retryArgs, settings);
66
+ if (!retry.error && retry.status === 0) return { ...retry, usedOfflineFallback: true };
67
+ const labeled = (result) => String(result ?? "");
68
+ const stdout = `[offline attempt]\n${labeled(first.stdout)}\n[online retry]\n${labeled(retry.stdout)}`;
69
+ const stderr = `[offline attempt]\n${labeled(first.stderr)}\n[online retry]\n${labeled(retry.stderr)}`;
70
+ return {
71
+ ...retry,
72
+ stdout,
73
+ stderr,
74
+ ...(retry.error ? { error: new Error(`${retry.error.message}\n${stdout}\n${stderr}`) } : {}),
75
+ offlineFallbackFailed: true,
42
76
  };
43
77
  }
44
78
 
@@ -53,11 +87,24 @@ export function runtimeBudgetDeferral(crateDir, crate) {
53
87
  return `${data.reason} (${data.approvedBy}, ${data.approved}; ci/runtime-budget-deferred.json)`;
54
88
  }
55
89
 
56
- export function buildCargoPublishPlan({ crate, crateDir, dryRun, allowDirty = false }) {
90
+ // Plan step label -> the `provenGateSteps` key whose proof lets that step be skipped.
91
+ const PROVEN_STEP = Object.freeze({ "secret scan": "secretScan", format: "fmt", tests: "tests", clippy: "clippy" });
92
+
93
+ /**
94
+ * `proven` (from `provenGateSteps(evidence)`) removes a secret scan, format, tests or clippy step the evidence
95
+ * already proves for this exact commit; the runtime budget, package contents, archive hash and registry steps
96
+ * are never skippable. `verifyOnPublish` (real publish only) drops the separate registry dry-run and lets the
97
+ * real `cargo publish` run Cargo's own verify build (no `--no-verify`), so the package compiles once, before
98
+ * the upload, instead of in a dry-run and not at all in the publish. `packageVerified` is set only after
99
+ * explicit evidence reuse proves a successful standalone package verification; hash checking stays mandatory.
100
+ */
101
+ export function buildCargoPublishPlan({ crate, crateDir, dryRun, allowDirty = false, proven = {}, verifyOnPublish = false, packageVerified = false, offlineReadOnly = false }) {
102
+ const offline = offlineReadOnly ? ["--offline"] : [];
57
103
  const dryRunArgs = ["publish", "--dry-run", "-p", crate];
58
- const packageArgs = ["package", "--list", "-p", crate];
104
+ const packageArgs = ["package", "--list", ...offline, "-p", crate];
59
105
  if (allowDirty) dryRunArgs.push("--allow-dirty");
60
106
  if (allowDirty) packageArgs.push("--allow-dirty");
107
+ const verifyHere = verifyOnPublish && !dryRun;
61
108
  const plan = [
62
109
  { label: "secret scan", internal: "secret-scan", crateDir },
63
110
  // Runtime-budget crates (runtime-budgets.mjs) must carry a measured runtime-budget.json for this
@@ -67,15 +114,18 @@ export function buildCargoPublishPlan({ crate, crateDir, dryRun, allowDirty = fa
67
114
  { label: "tests", command: "cargo", args: ["test", "-p", crate, "--", "--test-threads=1"] },
68
115
  { label: "clippy", command: "cargo", args: ["clippy", "-p", crate, "--all-targets", "--", "-D", "warnings"] },
69
116
  { label: "package contents", command: "cargo", args: packageArgs, capture: true },
70
- { label: "registry dry-run", command: "cargo", args: dryRunArgs },
71
- ];
117
+ ...((verifyHere || packageVerified) ? [] : [{ label: "registry dry-run", command: "cargo", args: dryRunArgs }]),
118
+ ].filter((step) => !(PROVEN_STEP[step.label] && proven[PROVEN_STEP[step.label]] === true));
72
119
  if (!dryRun) plan.push(
73
120
  // `publish --dry-run` leaves its archive under target/package/tmp-crate on current Cargo; package
74
- // explicitly to bind target/package/<name>-<version>.crate to evidence before Cargo uploads.
75
- { label: "package archive", command: "cargo", args: ["package", "--no-verify", "-p", crate, ...(allowDirty ? ["--allow-dirty"] : [])] },
76
- { label: "package location", command: "cargo", args: ["metadata", "--format-version", "1", "--no-deps"], capture: true },
121
+ // explicitly to bind target/package/<name>-<version>.crate to evidence before Cargo uploads. Always
122
+ // online and --locked, like qualification: offline packaging resolves the archive's Cargo.lock from
123
+ // only the .crate files already downloaded, so a sibling published since then silently resolves to an
124
+ // older version and the archive no longer matches its evidence hash.
125
+ { label: "package archive", command: "cargo", args: ["package", "--locked", "--no-verify", "-p", crate, ...(allowDirty ? ["--allow-dirty"] : [])] },
126
+ { label: "package location", command: "cargo", args: ["metadata", ...offline, "--format-version", "1", "--no-deps"], capture: true },
77
127
  { label: "archive hash check", internal: "verify-archive" },
78
- { label: "registry publish", command: "cargo", args: ["publish", "--no-verify", "-p", crate] },
128
+ { label: "registry publish", command: "cargo", args: verifyHere ? ["publish", "--locked", "-p", crate] : ["publish", ...(packageVerified ? ["--locked"] : []), "--no-verify", "-p", crate] },
79
129
  { label: "registry readback", internal: "registry-readback" },
80
130
  );
81
131
  return plan;
@@ -119,7 +169,45 @@ export async function runCargoPublish(options, execute = (_command, args, settin
119
169
  };
120
170
  const gate = (deps.enforceGate ?? enforcePublicationGate)(gateOptions, { dryRun: options.dryRun });
121
171
  const expectedSha256 = gate.evidence?.artifactSha256;
122
- const plan = buildCargoPublishPlan({ ...options, crateDir });
172
+ // Explicit evidence reuse enters through the same gate: current commit, version, clean source, host runs
173
+ // and artifact digest must match. Windows evidence keeps its actual win32 host policy on the Mac.
174
+ const reuseEvidence = options.fromEvidence === true;
175
+ if (reuseEvidence && (options.dryRun || options.allowDirty || !options.evidence)) throw new Error("--from-evidence requires a real publish, clean source and explicit evidence");
176
+ const proven = (reuseEvidence || options.trustEvidenceSteps === true) && !options.dryRun ? provenGateSteps(gate.evidence) : {};
177
+ if (reuseEvidence && (proven.tests !== true || proven.clippy !== true)) throw new Error("--from-evidence requires gate-proven tests and clippy for this crate and commit");
178
+ if (reuseEvidence) {
179
+ const status = (deps.workspaceStatus ?? ((workspaceRoot) => spawnSync("git", ["status", "--porcelain", "--", "Cargo.toml", "Cargo.lock"], { cwd: workspaceRoot, encoding: "utf8", windowsHide: true })))(options.workspaceRoot);
180
+ if (status.error || status.status !== 0 || String(status.stdout ?? "").trim()) throw new Error("--from-evidence requires clean workspace Cargo.toml and Cargo.lock");
181
+ const proofHost = singleHostOfPolicy(gate.evidence) ?? "darwin";
182
+ const runs = gate.evidence.runs.filter((run) => run?.host?.os === proofHost);
183
+ const command = `cargo package --locked -p ${options.crate}`;
184
+ if (!runs.length || !runs.every((run) => run.steps?.some((step) => step.ok === true && step.command === command))) {
185
+ throw new Error("--from-evidence requires a verified cargo package --locked receipt for this artifact");
186
+ }
187
+ // Load the shared planner after module initialization: local-publish itself imports this publisher.
188
+ // Validate the complete current qualification matrix, not merely one passing test and lint command.
189
+ const { localTestPlan } = await import("./local-publish.mjs");
190
+ let config;
191
+ try { config = JSON.parse(fs.readFileSync(path.join(options.workspaceRoot, "ci", "gate.config.json"), "utf8")); }
192
+ catch { throw new Error("--from-evidence cannot read current ci/gate.config.json"); }
193
+ for (const run of runs) {
194
+ if (!Array.isArray(run.steps) || run.steps.some((step) => step?.ok !== true || (step.failed ?? 0) !== 0)) {
195
+ throw new Error("--from-evidence refuses an unsuccessful qualification step");
196
+ }
197
+ const expected = localTestPlan({ kind: "cargo", unit: options.crate, dir: `crates/${options.crate}`, config, batch: Boolean(run.batch), platform: proofHost });
198
+ for (const step of expected.steps) {
199
+ const text = `${step.command} ${step.args.join(" ")}`;
200
+ if (!run.steps.some((row) => row.command === text && row.ok === true && (!step.test || (row.executed > 0 && row.failed === 0)))) {
201
+ throw new Error(`--from-evidence missing configured qualification step: ${text}`);
202
+ }
203
+ }
204
+ }
205
+ }
206
+ const plan = buildCargoPublishPlan({ ...options, crateDir, proven,
207
+ verifyOnPublish: !reuseEvidence && options.verifyOnPublish === true, packageVerified: reuseEvidence, offlineReadOnly: options.offlineReadOnly === true });
208
+ for (const [label, key] of Object.entries(PROVEN_STEP)) {
209
+ if (proven[key] === true) process.stdout.write(`[right-release] cargo ${label}: proven by the gate evidence for this commit, not repeated\n`);
210
+ }
123
211
 
124
212
  let targetDir;
125
213
  let artifact;
@@ -159,21 +247,39 @@ export async function runCargoPublish(options, execute = (_command, args, settin
159
247
  continue;
160
248
  }
161
249
  if (step.label === "registry publish") assertArtifactUnchanged(artifact);
162
- const result = execute(step.command, step.args, {
250
+ // `execute` may be synchronous (the default) or async (the batch publisher overlaps uploads).
251
+ const isReadOnlyInspection = ["package contents", "package location"].includes(step.label);
252
+ const settings = {
163
253
  cwd: options.workspaceRoot,
164
254
  encoding: "utf8",
165
255
  windowsHide: true,
166
- stdio: step.capture ? "pipe" : "inherit",
167
- });
168
- if (result.error) throw result.error;
256
+ stdio: step.capture || (options.offlineReadOnly === true && isReadOnlyInspection) ? "pipe" : "inherit",
257
+ };
258
+ const result = options.offlineReadOnly === true && isReadOnlyInspection
259
+ ? await runCargoReadOnly(step.args, settings, execute, (failed) => {
260
+ process.stderr.write(`[right-release] offline Cargo inspection missed cache; retrying online once\n${failed.stdout ?? ""}${failed.stderr ? `\n${failed.stderr}` : ""}`);
261
+ })
262
+ : await execute(step.command, step.args, settings);
263
+ if (result.error) {
264
+ if (result.offlineFallbackFailed) throw new Error(`${step.label} failed: ${result.error.message}`);
265
+ throw result.error;
266
+ }
169
267
  if (result.status !== 0) {
170
- if (step.capture && result.stderr) process.stderr.write(result.stderr);
171
- throw new Error(`${step.label} failed with exit code ${result.status}`);
268
+ if (result.offlineFallbackFailed || step.label === "package archive") {
269
+ if (result.stdout) process.stdout.write(result.stdout);
270
+ if (result.stderr) process.stderr.write(result.stderr);
271
+ } else if (step.capture && result.stderr) process.stderr.write(result.stderr);
272
+ const fallback = result.offlineFallbackFailed ? `\n${result.stdout ?? ""}\n${result.stderr ?? ""}` : "";
273
+ throw new Error(`${step.label} failed with exit code ${result.status}${fallback}`);
172
274
  }
173
275
  if (step.label === "package location") {
276
+ if (result.stderr) process.stderr.write(result.stderr);
174
277
  const metadata = JSON.parse(result.stdout);
175
278
  if (typeof metadata.target_directory !== "string" || !path.isAbsolute(metadata.target_directory)) throw new Error("invalid Cargo package target directory");
176
279
  targetDir = metadata.target_directory;
280
+ } else if (step.label === "package archive") {
281
+ if (result.stdout) process.stdout.write(result.stdout);
282
+ if (result.stderr) process.stderr.write(result.stderr);
177
283
  } else if (step.capture) {
178
284
  if (result.stderr) process.stderr.write(result.stderr);
179
285
  const files = String(result.stdout ?? "").split(/\r?\n/).filter(Boolean);
@@ -14,7 +14,7 @@ import { verifyPublicationRelease, PublicationError, assertArtifactUnchanged, di
14
14
  import { uploadVerifiedR2Artifact } from "./r2-verified-upload.mjs";
15
15
  import { verifyPlatformTrust } from "./github-release.mjs";
16
16
  import { admitInstalledQa } from "./release-qa-audit.mjs";
17
- import { assertAssetName, assertGitHubRepository, assertUpdaterKeyId, assertUpdaterPlatform, buildLatestJson, githubAssetUrl, githubTag, publishGitHubUpdaterRelease } from "./github-updater-release.mjs";
17
+ import { assertAssetName, assertGitHubRepository, assertUpdaterKeyId, assertUpdaterPlatform, assertUpdaterSignature, buildLatestJson, githubAssetUrl, githubTag, publishGitHubUpdaterRelease } from "./github-updater-release.mjs";
18
18
 
19
19
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
20
20
  const API_BASE = process.env.RIGHTAPPS_API_URL || "https://api.spoares.com";
@@ -116,7 +116,20 @@ function git(args) {
116
116
  if (!githubLane && tier === "patch" && !installers.length) fail(`${config?.app ?? "app"} ${platform} patch has no installer.artifacts`);
117
117
  if ((githubLane || tier === "update") && !artifacts.length) fail(`${config?.app ?? "app"} ${platform} update has no updater.artifacts`);
118
118
 
119
- if (sealed) for (const artifact of artifacts) signatureFor(artifact);
119
+ // Cryptographic gate before any upload: every sealed minisign signature must verify over the
120
+ // exact final artifact bytes against the declared public key. No declared key means the
121
+ // signature cannot be verified, so a sealed (real) publish fails closed; key-id pinning is
122
+ // supplementary only. Integrator owns declaring distribution.updaterPublicKey in app configs
123
+ // (suite key already pinned per-app as tauri plugins.updater.pubkey).
124
+ const updaterPublicKey = config.distribution?.updaterPublicKey ?? config.distribution?.updaterPubkey ?? target?.updater?.pubkey ?? target?.updater?.publicKey;
125
+ if (sealed) for (const artifact of artifacts) {
126
+ const signature = signatureFor(artifact);
127
+ if (!appInstallerKind) {
128
+ if (!updaterPublicKey) throw new PublicationError("PUBLICATION_SIGNATURE_UNVERIFIABLE", `no updater public key declared (distribution.updaterPublicKey); refusing to upload ${path.basename(artifact.file)} with an unverified signature`);
129
+ assertUpdaterSignature(assertArtifactUnchanged(verifiedArtifact(artifact.file)), signature, updaterPublicKey, `signature of ${path.basename(artifact.file)}`);
130
+ assertUpdaterKeyId(signature, config.distribution.updaterKeyId, `signature of ${path.basename(artifact.file)}`);
131
+ }
132
+ }
120
133
  let releaseToken = null;
121
134
  if (!githubLane && !dryRun && (tier === "update" || artifacts.length > 0)) {
122
135
  try {
@@ -12,17 +12,10 @@ import { existsSync, readFileSync } from "node:fs";
12
12
  import path from "node:path";
13
13
  import { expandRecipeBuildCommands } from "./recipe-build.mjs";
14
14
 
15
- export const FORBIDDEN_FEATURES = Object.freeze(["devtools", "content-devtools", "qa-native", "qa-control", "dev-native", "dev-inspect",
16
- // Per-app test-automation features (M20 owner gap): HeardRight `qa-shot`/`qa-tools`, ViewRight `dev-control`/`bench-native`.
17
- "qa-shot", "qa-tools", "dev-control", "bench-native",
18
- // D4 (M21/M23): rightkit-daemon `test-mode` compiles the override selectors; never in a release build.
19
- "test-mode"]);
20
- // rightkit-qa 0.6.0 moved the embedded control server (the former rightkit-qa-driver `qa-control`) behind its `control`
21
- // feature. It emits the same QA_CONTROL_MARKER, so the binary layer refuses it unchanged; this is the source layer.
22
- export const QUALIFIED_FORBIDDEN_FEATURES = Object.freeze(["rightkit-qa/control", "rightkit-qa?/control"]);
23
- // The only forbidden features an allow-listed app may enable: rightkit-tauri-shell's `content-devtools` (and the Tauri
24
- // `devtools` it switches on). See devtools-policy.mjs; every other app is refused them.
25
- const DEVTOOLS_FEATURES = Object.freeze(["devtools", "content-devtools"]);
15
+ const featureRegistry = JSON.parse(readFileSync(new URL("./release-switch-registry.json", import.meta.url), "utf8"));
16
+ const DEVTOOLS_FEATURES = Object.freeze(featureRegistry.conditionalForbiddenFeatures["content-only"]);
17
+ export const FORBIDDEN_FEATURES = Object.freeze([...featureRegistry.forbiddenFeatures.filter((name) => !name.includes("/")), ...DEVTOOLS_FEATURES]);
18
+ export const QUALIFIED_FORBIDDEN_FEATURES = Object.freeze(featureRegistry.forbiddenFeatures.filter((name) => name.includes("/")));
26
19
  const FORBIDDEN_DEPENDENCY = "tauri-plugin-wdio-webdriver";
27
20
  const MANAGERS = new Set(["pnpm", "npm", "yarn", "bun", "npx"]);
28
21
 
@@ -36,7 +29,7 @@ export function isForbiddenFeature(item, { allowDevtools = false } = {}) {
36
29
  const name = featureName(text);
37
30
  if (allowDevtools && DEVTOOLS_FEATURES.includes(name)) return false;
38
31
  // `control` is too generic to forbid by bare name; rightkit-qa's dev-only server feature is refused when qualified.
39
- if (QUALIFIED_FORBIDDEN_FEATURES.includes(text.replace(/\s+/g, ""))) return true;
32
+ if (QUALIFIED_FORBIDDEN_FEATURES.includes(text.replace(/\s+/g, "").replace("?/", "/"))) return true;
40
33
  return text === `dep:${FORBIDDEN_DEPENDENCY}` || FORBIDDEN_FEATURES.includes(name);
41
34
  }
42
35
 
@@ -154,6 +147,7 @@ export function parseCargoFeaturesAndDependencies(manifestText) {
154
147
  function dependencyInfo(spec) {
155
148
  const list = spec.match(/features\s*=\s*(\[)/);
156
149
  return {
150
+ package: spec.match(/package\s*=\s*["']([^"']+)["']/)?.[1],
157
151
  optional: /optional\s*=\s*true/.test(spec),
158
152
  features: list ? strings(balanced(spec, list.index + list[0].length - 1, "[", "]")) : [],
159
153
  };
@@ -162,6 +156,15 @@ function dependencyInfo(spec) {
162
156
  export function cargoManifestIssues(manifestText, label = "Cargo.toml", { allowDevtools = false } = {}) {
163
157
  const { features, dependencies } = parseCargoFeaturesAndDependencies(manifestText);
164
158
  const issues = [];
159
+ const canonicalFeature = (item) => {
160
+ const match = /^([^/]+?)(\?)?\/(.+)$/.exec(item);
161
+ return match ? `${dependencies.get(match[1])?.package ?? match[1]}/${match[3]}` : item;
162
+ };
163
+ for (const [alias, dep] of dependencies) {
164
+ if (dep.package && dep.package !== alias && !dep.optional) for (const feature of dep.features) {
165
+ if (isForbiddenFeature(`${dep.package ?? alias}/${feature}`, { allowDevtools })) issues.push(`${label}: dependency \`${alias}\` enables forbidden feature \`${dep.package ?? alias}/${feature}\``);
166
+ }
167
+ }
165
168
  const tauri = dependencies.get("tauri");
166
169
  if (!allowDevtools && tauri?.features.includes("devtools")) issues.push(`${label}: dependency \`tauri\` lists feature \`devtools\` unconditionally (inspector ships in release builds)`);
167
170
  // The crate was renamed from rightkit-control; apps still on the old published name must stay covered.
@@ -185,7 +188,7 @@ export function cargoManifestIssues(manifestText, label = "Cargo.toml", { allowD
185
188
  const item = stack.pop();
186
189
  if (reached.has(item)) continue;
187
190
  reached.add(item);
188
- if (isForbiddenFeature(item, { allowDevtools })) issues.push(`${label}: default feature set reaches \`${item}\``);
191
+ if (isForbiddenFeature(canonicalFeature(item), { allowDevtools })) issues.push(`${label}: default feature set reaches \`${item}\``);
189
192
  if (features.has(item)) stack.push(...features.get(item));
190
193
  }
191
194
  return issues;