@rightkit/release 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +59 -7
  2. package/adhoc-entitlements.mjs +8 -0
  3. package/adoption-evidence.mjs +650 -0
  4. package/advisory-exceptions.mjs +371 -0
  5. package/apple-profile-create.mjs +113 -10
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/rightkit-gate.mjs +1079 -0
  8. package/binary-license-notices.mjs +161 -0
  9. package/cargo-license-notices.mjs +111 -0
  10. package/cli/right-release.mjs +39 -0
  11. package/deterministic-scans.mjs +966 -0
  12. package/development-signing.mjs +2 -1
  13. package/host-upload-refusal.mjs +90 -0
  14. package/local-publish.mjs +142 -19
  15. package/local-qualify.mjs +728 -0
  16. package/local-runtime.mjs +319 -0
  17. package/lock-graph.mjs +51 -0
  18. package/managed-cargo.mjs +4 -1
  19. package/ownership-approval.mjs +23 -6
  20. package/ownership-dependency-allowlist.json +2 -0
  21. package/ownership-mandatory-set.json +158 -68
  22. package/ownership-mandatory-set.mjs +82 -6
  23. package/package.json +6 -2
  24. package/pipeline-admission.mjs +375 -0
  25. package/pipeline-config.mjs +100 -0
  26. package/pipeline-generators.json +4 -0
  27. package/pipeline-generators.mjs +137 -0
  28. package/pipeline-receipts.mjs +302 -0
  29. package/pipeline-scan.mjs +285 -0
  30. package/pipeline-tools.mjs +171 -0
  31. package/production-mode.mjs +42 -0
  32. package/publication-gate.mjs +94 -32
  33. package/publish-cargo.mjs +24 -7
  34. package/publish-swift.mjs +12 -3
  35. package/qa-control-scan.mjs +4 -0
  36. package/release-config.mjs +330 -0
  37. package/release-switch-scan.mjs +44 -5
  38. package/release.mjs +14 -4
  39. package/rightkit-ownership.json +57 -0
  40. package/rightkit-sync.mjs +8 -0
  41. package/rightkit-versions.json +191 -131
  42. package/runtime-budget-classes.json +8 -0
  43. package/runtime-budget-crates.mjs +14 -0
  44. package/runtime-budgets.mjs +128 -31
  45. package/scan-digest.mjs +107 -0
  46. package/sign-qa-bundle.mjs +210 -0
  47. package/stage-sidecar.mjs +99 -15
  48. package/swap-helper-bundle.mjs +7 -3
  49. package/workflow-yaml.mjs +177 -0
  50. package/bin/darwin/rightkit-swap.sign.json +0 -6
@@ -0,0 +1,171 @@
1
+ // M24 R3: the build, sign, notarize, package and upload tool inventory, and the text scanner that finds those tools in a pipeline
2
+ // context (an app script, a package hook, a workflow step, a framework hook, a build.rs). Pure functions: no file access.
3
+ //
4
+ // Scope (Astra, M24 R3): the tools below are tracked when a pipeline context or a helper reachable from one runs them. Read-only
5
+ // forms (`gh release view`, `codesign --verify`, `signtool verify`, `stapler validate`, `aws s3 ls`, ...) are not an upload or a
6
+ // signature and are never reported. This is a static text scan: it proves nothing about what a native tool does, and ordinary
7
+ // product runtime code (src/, crates) is never scanned by it, so there is no blanket ban on a product that happens to sign
8
+ // or package something at runtime.
9
+
10
+ /** The tool ids R3 inventories, with the pipeline stage each one acts in. */
11
+ export const PIPELINE_TOOL_IDS = Object.freeze([
12
+ "codesign", "notarytool", "altool", "stapler", "signtool", "azuresigntool", "productbuild", "pkgbuild", "hdiutil", "create-dmg",
13
+ "makensis", "iscc", "wix", "wrangler", "rclone", "aws-s3", "gh-release", "cargo-publish", "npm-publish", "pnpm-publish",
14
+ "tauri-build", "xcode-archive",
15
+ ]);
16
+
17
+ const READ_ONLY = (list) => (rest) => list.includes(String(rest[0] ?? "").toLowerCase());
18
+
19
+ /** name (lowercased basename without a script or exe extension) -> classifier of the remaining argument tokens. */
20
+ const RULES = [
21
+ { id: "codesign", stage: "sign", names: ["codesign"], flagged: (rest) => {
22
+ const flags = rest.filter((arg) => arg.startsWith("-"));
23
+ const signs = flags.some((arg) => /^(?:-s|--sign|-f|--force|--remove-signature)$/.test(arg));
24
+ const readsOnly = flags.some((arg) => /^(?:--verify|-v+|-d+v*|--display|--test-requirement|-r+-?|-h|--help)$/.test(arg));
25
+ return signs || !readsOnly;
26
+ } },
27
+ { id: "notarytool", stage: "notarize", names: ["notarytool"], flagged: (rest) => !READ_ONLY(["info", "log", "history", "--help", "--version", "help"])(rest) },
28
+ { id: "altool", stage: "notarize", names: ["altool"], flagged: () => true },
29
+ { id: "stapler", stage: "notarize", names: ["stapler"], flagged: (rest) => !READ_ONLY(["validate", "--help", "help"])(rest) },
30
+ { id: "signtool", stage: "sign", names: ["signtool"], flagged: (rest) => !READ_ONLY(["verify", "/?", "-?", "--help"])(rest) },
31
+ { id: "azuresigntool", stage: "sign", names: ["azuresigntool", "trusted-signing-cli"], flagged: () => true },
32
+ { id: "productbuild", stage: "package", names: ["productbuild", "productsign"], flagged: () => true },
33
+ { id: "pkgbuild", stage: "package", names: ["pkgbuild"], flagged: () => true },
34
+ { id: "hdiutil", stage: "package", names: ["hdiutil"], flagged: (rest) => READ_ONLY(["create", "convert", "makehybrid", "resize", "compact", "burn"])(rest) },
35
+ { id: "create-dmg", stage: "package", names: ["create-dmg"], flagged: () => true },
36
+ { id: "makensis", stage: "package", names: ["makensis"], flagged: () => true },
37
+ { id: "iscc", stage: "package", names: ["iscc"], flagged: () => true },
38
+ { id: "wix", stage: "package", names: ["candle"], flagged: () => true },
39
+ { id: "wix", stage: "package", names: ["wix"], flagged: (rest) => READ_ONLY(["build", "msi", "burn", "convert", "extension"])(rest) },
40
+ { id: "wix", stage: "package", names: ["light"], flagged: (rest) => rest.some((arg) => /\.(?:wixobj|wxs|wixlib)$/i.test(arg) || /^-out$/i.test(arg)) },
41
+ { id: "wrangler", stage: "upload", names: ["wrangler"], flagged: (rest) => !READ_ONLY(["--version", "-v", "whoami", "--help", "-h"])(rest) },
42
+ { id: "rclone", stage: "upload", names: ["rclone"], flagged: (rest) => !READ_ONLY(["ls", "lsf", "lsd", "lsl", "lsjson", "size", "check", "version", "listremotes", "about", "cat", "--version", "--help"])(rest) },
43
+ { id: "aws-s3", stage: "upload", names: ["aws"], flagged: (rest) => (rest[0] === "s3" && !["ls", "help"].includes(String(rest[1] ?? "")))
44
+ || (rest[0] === "s3api" && /^(?:put-|create-multipart-upload|upload-part|complete-multipart-upload|copy-object|delete-)/.test(String(rest[1] ?? ""))) },
45
+ { id: "gh-release", stage: "upload", names: ["gh"], flagged: (rest) => rest[0] === "release" && ["create", "upload", "edit", "delete", "delete-asset"].includes(String(rest[1] ?? "")) },
46
+ { id: "cargo-publish", stage: "upload", names: ["cargo"], flagged: (rest) => rest[0] === "publish" || (rest[0] === "tauri" && ["build", "bundle", "signer"].includes(String(rest[1] ?? ""))) },
47
+ { id: "tauri-build", stage: "build", names: ["cargo-tauri"], flagged: (rest) => ["build", "bundle", "signer"].includes(String(rest[0] ?? "")) },
48
+ { id: "npm-publish", stage: "upload", names: ["npm"], flagged: (rest) => rest[0] === "publish" },
49
+ { id: "pnpm-publish", stage: "upload", names: ["pnpm"], flagged: (rest) => rest[0] === "publish" },
50
+ { id: "npm-publish", stage: "upload", names: ["yarn"], flagged: (rest) => rest[0] === "publish" || (rest[0] === "npm" && rest[1] === "publish") },
51
+ { id: "tauri-build", stage: "build", names: ["tauri"], flagged: (rest) => ["build", "bundle", "signer"].includes(String(rest[0] ?? "")) },
52
+ { id: "xcode-archive", stage: "package", names: ["xcodebuild"], flagged: (rest) => rest.some((arg) => /^(?:archive|-exportArchive|-archivePath|-exportOptionsPlist)$/.test(arg)) },
53
+ ];
54
+
55
+ /** `rightkit cargo publish` reaches the same crates.io upload through the managed client. */
56
+ const MANAGED_CLIENT_RULE = { id: "cargo-publish", stage: "upload" };
57
+
58
+ /** Wrapper words that run another command: skipped so the tool behind them is the one classified. */
59
+ const WRAPPERS = new Set(["sudo", "env", "time", "command", "exec", "call", "start", "nohup", "xcrun", "npx", "corepack", "pwsh", "powershell", "bash", "sh", "zsh", "cmd", "node", "python", "python3", "py"]);
60
+
61
+ const stripQuotes = (token) => String(token).replace(/^[\s"'`@&]+|[\s"'`,;]+$/g, "");
62
+ export const commandBaseName = (token) => stripQuotes(token).split(/[\\/]/).pop().toLowerCase().replace(/\.(?:exe|cmd|bat|ps1|sh|com|command)$/i, "");
63
+
64
+ /** Split one script line into command segments at the shell operators. */
65
+ export function splitSegments(line) {
66
+ return String(line).split(/&&|\|\||\$\(|[;|&`()]/).map((part) => part.trim()).filter(Boolean);
67
+ }
68
+
69
+ /** Tokens of one segment after the leading VAR=value assignments and wrapper words. */
70
+ export function segmentTokens(segment) {
71
+ let tokens = segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
72
+ tokens = tokens.map((token) => token.replace(/^["']|["']$/g, ""));
73
+ while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens = tokens.slice(1);
74
+ for (let guard = 0; guard < 8 && tokens.length; guard += 1) {
75
+ const name = commandBaseName(tokens[0]);
76
+ if (name === "pnpm" && (tokens[1] === "exec" || tokens[1] === "dlx")) { tokens = tokens.slice(2); continue; }
77
+ if (name === "npm" && tokens[1] === "exec") { const dash = tokens.indexOf("--"); tokens = dash >= 0 ? tokens.slice(dash + 1) : tokens.slice(2); continue; }
78
+ if (WRAPPERS.has(name) && tokens.length > 1) {
79
+ // Interpreters and shells run a script, not another tool, unless they take -c/-Command text: keep scanning the rest.
80
+ tokens = tokens.slice(1).filter((token, index) => !(index === 0 && /^-+(?:c|command|noprofile|nologo|noninteractive|executionpolicy|file|f|e|s)$/i.test(token)));
81
+ // `bash -c "tool args"` and `powershell -Command "tool args"`: the quoted text is the command line.
82
+ if (tokens.length && /\s/.test(tokens[0])) tokens = [...tokens[0].trim().split(/\s+/), ...tokens.slice(1)];
83
+ continue;
84
+ }
85
+ break;
86
+ }
87
+ return tokens;
88
+ }
89
+
90
+ /** Classify one command's tokens against the R3 inventory: `{ id, stage, tool }` for a pipeline tool run in a write form, else null. */
91
+ export function classifyTokens(tokens) {
92
+ if (!tokens.length) return null;
93
+ const name = commandBaseName(tokens[0]);
94
+ const rest = tokens.slice(1);
95
+ if (name === "rightkit" && rest[0] === "cargo" && rest[1] === "publish") return { ...MANAGED_CLIENT_RULE, tool: "rightkit cargo publish" };
96
+ for (const rule of RULES) {
97
+ if (rule.names.includes(name) && rule.flagged(rest)) return { id: rule.id, stage: rule.stage, tool: [name, ...rest.slice(0, 2)].join(" ").trim() };
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /** True when the tokens run RightKit's own pipeline CLI or managed client (a generator must never dispatch another pipeline). */
103
+ export function dispatchesRightKit(tokens) {
104
+ if (!tokens.length) return false;
105
+ const name = commandBaseName(tokens[0]);
106
+ if (name === "rightkit" || name === "right-release") return true;
107
+ return tokens.some((token) => /(?:^|[\\/])(?:@rightkit[\\/]release[\\/]cli[\\/]right-release|right-release)(?:\.mjs)?$/i.test(token));
108
+ }
109
+
110
+ const YAML_RUN_PREFIX = /^\s*(?:-\s+)?(?:run|script|command)\s*:\s*[|>]?[-+]?\s*/;
111
+ const COMMENT_LINE = /^\s*(?:#|\/\/|::|rem\s|@rem\s)/i;
112
+
113
+ /**
114
+ * Pipeline tool matches in the text of a shell-like source (a script, a hook, a workflow). `kind`:
115
+ * - "shell": every line is read as a command line (sh, ps1, bat, cmd, command, yml run steps, a package.json script value);
116
+ * - "code": quoted tool names in a js/mjs/py/rs source (`spawnSync("codesign", ...)`), qualified by the next literals.
117
+ * Returns `[{ line, id, stage, tool, text }]` (1-based lines).
118
+ */
119
+ export function scanTextForPipelineTools(text, { kind = "shell" } = {}) {
120
+ const found = [];
121
+ const lines = String(text).split(/\r?\n/);
122
+ if (kind === "code") {
123
+ const names = new Map();
124
+ for (const rule of RULES) for (const name of rule.names) names.set(name, rule);
125
+ lines.forEach((line, index) => {
126
+ if (COMMENT_LINE.test(line)) return;
127
+ for (const literal of line.matchAll(/["'`]([^"'`\n]{1,200})["'`]/g)) {
128
+ const tokens = literal[1].trim().split(/\s+/);
129
+ const first = commandBaseName(tokens[0]);
130
+ const rule = names.get(first) ?? (first === "rightkit" ? MANAGED_CLIENT_RULE : null);
131
+ if (!rule) continue;
132
+ // The arguments are the rest of this literal, then the next few literals of the same call (an argv array).
133
+ const tail = line.slice(literal.index + literal[0].length, literal.index + literal[0].length + 240);
134
+ const following = [...tail.matchAll(/["'`]([^"'`\n]{0,120})["'`]/g)].map((match) => match[1]);
135
+ const rest = [...tokens.slice(1), ...following].map((arg) => arg.trim()).filter(Boolean);
136
+ const hit = first === "rightkit" ? classifyTokens([first, ...rest]) : rule.flagged(rest) ? { id: rule.id, stage: rule.stage, tool: first } : null;
137
+ if (hit) found.push({ line: index + 1, ...hit, text: line.trim().slice(0, 160) });
138
+ }
139
+ });
140
+ return found;
141
+ }
142
+ lines.forEach((line, index) => {
143
+ if (COMMENT_LINE.test(line)) return;
144
+ const body = line.replace(YAML_RUN_PREFIX, "");
145
+ for (const segment of splitSegments(body)) {
146
+ const hit = classifyTokens(segmentTokens(segment));
147
+ if (hit) found.push({ line: index + 1, ...hit, text: line.trim().slice(0, 160) });
148
+ }
149
+ });
150
+ return found;
151
+ }
152
+
153
+ /** GitHub Actions that build, sign or upload on their own (R3: the same tools behind an action). */
154
+ export const PIPELINE_ACTIONS = Object.freeze([
155
+ { pattern: /^tauri-apps\/tauri-action(?:@|$)/i, id: "tauri-build", stage: "build" },
156
+ { pattern: /^apple-actions\//i, id: "codesign", stage: "sign" },
157
+ { pattern: /^azure\/(?:trusted-signing-action|artifact-signing-action)(?:@|$)/i, id: "azuresigntool", stage: "sign" },
158
+ { pattern: /^cloudflare\/wrangler-action(?:@|$)/i, id: "wrangler", stage: "upload" },
159
+ { pattern: /^softprops\/action-gh-release(?:@|$)/i, id: "gh-release", stage: "upload" },
160
+ { pattern: /^ncipollo\/release-action(?:@|$)/i, id: "gh-release", stage: "upload" },
161
+ { pattern: /^svenstaro\/upload-release-action(?:@|$)/i, id: "gh-release", stage: "upload" },
162
+ { pattern: /^actions\/upload-release-asset(?:@|$)/i, id: "gh-release", stage: "upload" },
163
+ { pattern: /^shogo82148\/actions-upload-release-asset(?:@|$)/i, id: "gh-release", stage: "upload" },
164
+ { pattern: /^jakejarvis\/s3-sync-action(?:@|$)/i, id: "aws-s3", stage: "upload" },
165
+ ]);
166
+
167
+ export function classifyAction(uses) {
168
+ const value = String(uses).trim().replace(/^["']|["']$/g, "");
169
+ for (const entry of PIPELINE_ACTIONS) if (entry.pattern.test(value)) return { id: entry.id, stage: entry.stage, tool: value };
170
+ return null;
171
+ }
@@ -0,0 +1,42 @@
1
+ // The release frontend build always resolves in production mode (M12: `@rightkit/license/testing` and
2
+ // other development-only conditional exports must not resolve into a release bundle). Release build
3
+ // children get NODE_ENV=production, which Vite's build honours ahead of a `.env` NODE_ENV and of any
4
+ // custom `--mode` (vite.dev/guide/env-and-mode, "NODE_ENV and Modes"). What the pin cannot override is
5
+ // a config that lists the `development` resolve condition explicitly, so the preflight refuses that.
6
+ // Package installs keep the caller's NODE_ENV (production would drop devDependencies).
7
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
8
+ import path from "node:path";
9
+
10
+ const SKIP_DIRS = new Set(["node_modules", "target", "dist", ".git", "src-tauri", "build", "out"]);
11
+ const VITE_CONFIG = /^vite\.config\.(?:[cm]?[jt]s)$/;
12
+
13
+ export function productionBuildEnv(env) {
14
+ return { ...env, NODE_ENV: "production" };
15
+ }
16
+
17
+ function walk(dir, depth, visit) {
18
+ let names;
19
+ try { names = readdirSync(dir); } catch { return; }
20
+ for (const name of names) {
21
+ const full = path.join(dir, name);
22
+ let stat;
23
+ try { stat = statSync(full); } catch { continue; }
24
+ if (stat.isDirectory()) {
25
+ if (depth > 0 && !SKIP_DIRS.has(name) && !name.startsWith(".")) walk(full, depth - 1, visit);
26
+ } else visit(name, full);
27
+ }
28
+ }
29
+
30
+ /** Vite configs that would resolve development-only exports even in a production build. A text scan:
31
+ * a computed conditions array is not seen (the post-build web-bundle marker scan is the backstop). */
32
+ export function productionModeProblems({ appRoot, depth = 3 }) {
33
+ const problems = [];
34
+ if (!appRoot || !existsSync(appRoot)) return problems;
35
+ walk(appRoot, depth, (name, full) => {
36
+ if (!VITE_CONFIG.test(name)) return;
37
+ if (/conditions\s*:\s*\[[^\]]*["'`]development["'`]/.test(readFileSync(full, "utf8"))) {
38
+ problems.push(`${path.relative(appRoot, full)} lists a "development" resolve condition; a release build must not resolve development-only exports`);
39
+ }
40
+ });
41
+ return problems;
42
+ }
@@ -8,7 +8,8 @@ import fs from "node:fs";
8
8
  import os from "node:os";
9
9
  import path from "node:path";
10
10
  import { spawnSync } from "./background-process.mjs";
11
- import { recordPublished } from "./rightkit-sync.mjs";
11
+ import { assertPublishCapability, refuseHostUpload } from "./host-upload-refusal.mjs";
12
+ import { recordPublished, workspaceFeedOptions } from "./rightkit-sync.mjs";
12
13
  import { fileURLToPath } from "node:url";
13
14
  import { digestBytes, readVerifiedArtifact } from "./publication-artifacts.mjs";
14
15
 
@@ -93,6 +94,27 @@ export function batchReceiptProblems(batch, evidence, tree) {
93
94
  return problems;
94
95
  }
95
96
 
97
+ /**
98
+ * Feature sets of this crate that a batch receipt did NOT lint (0.4.1 review A2). Batch mode lints each crate's tested
99
+ * feature sets in shared `-p a -p b` runs, so a receipt from before every set was linted (0.4.0: first set only)
100
+ * still satisfies `batchReceiptProblems`, which accepts any ONE clippy row covering the crate. Replay therefore
101
+ * needs one passing row per feature set: bound to the evidence commit and the batch tree, naming the crate (`-p <crate>`
102
+ * and `crates`), and ending `--no-deps --all-targets <features> -- -D warnings`. `featureSets` is the crate's
103
+ * `ci/gate.config.json` list (`[[]]` when unset). Returns the missing sets as argument arrays.
104
+ */
105
+ export function batchClippyMissingFeatureSets(batch, evidence, featureSets) {
106
+ const rows = (Array.isArray(batch?.checks) ? batch.checks : []).filter((row) => row?.name === "clippy" && row.ok === true
107
+ && row.commit === evidence.commit && row.tree === batch.tree && Array.isArray(row.crates) && row.crates.includes(evidence.name) && text(row.command));
108
+ const sets = (Array.isArray(featureSets) && featureSets.length ? featureSets : [[]]).map((set) => (Array.isArray(set) ? set : []));
109
+ return sets.filter((features) => {
110
+ const tail = ` --no-deps --all-targets${features.length ? ` ${features.join(" ")}` : ""} -- -D warnings`;
111
+ return !rows.some((row) => {
112
+ const words = row.command.split(" ");
113
+ return row.command.endsWith(tail) && words.some((word, index) => word === "-p" && words[index + 1] === evidence.name);
114
+ });
115
+ });
116
+ }
117
+
96
118
  /**
97
119
  * Which per-crate publication steps the evidence ALREADY proves for this exact commit, so the publisher may
98
120
  * skip repeating them: `tests` (a passing `cargo test -p <name>` step that executed tests), `clippy` (a passing
@@ -137,40 +159,44 @@ function invalidRunFields(run, evidence, tree) {
137
159
  return bad;
138
160
  }
139
161
 
140
- // Returns { ok, missing[] }. Pure apart from the injected git/readFile.
141
- export function checkPublicationGate({ kind, name, version, packagePath, evidencePath, artifactBytes, git = defaultGit, readFile = (p) => fs.readFileSync(p, "utf8") }) {
162
+ function gateResult(missing, evidence) {
163
+ const result = { ok: missing.length === 0, missing };
164
+ Object.defineProperty(result, "evidence", { value: evidence });
165
+ return Object.freeze(result);
166
+ }
167
+
168
+ /** Validate supplied evidence against caller-authenticated source identity, without reading Git or files.
169
+ * The caller owns authentication of expected.commit/tree and requiredHosts (for example, committed CI policy).
170
+ * Evidence cannot relax host requirements: only an explicit, nonempty subset supplied by the caller can.
171
+ * This proves receipt contents only; it makes no claim about working-tree cleanliness or receipt provenance. */
172
+ export function validatePublicationEvidence({ expected = {}, evidence, artifactBytes }) {
173
+ const { kind, name, version, commit, tree } = expected;
142
174
  const missing = [];
143
- const head = git(["rev-parse", "HEAD"], packagePath);
144
- if (!head || !SHA.test(head)) missing.push(`cannot read current commit SHA for ${packagePath}`);
145
- const dirty = git(["status", "--porcelain", "--", "."], packagePath);
146
- if (dirty === null) missing.push(`cannot read git status for ${packagePath}`);
147
- else if (dirty !== "") missing.push(`tree is not clean for ${packagePath}`);
148
- let evidence = null;
149
- try { evidence = JSON.parse(readFile(evidencePath)); } catch {
150
- missing.push(`gate evidence file missing or unreadable: ${evidencePath}`);
175
+ if (!["npm", "cargo", "swift"].includes(kind)) missing.push("expected kind must be npm, cargo or swift");
176
+ if (!text(name)) missing.push("expected name must be nonempty");
177
+ if (!SHA.test(String(commit ?? ""))) missing.push("expected commit is not a full 40-character SHA");
178
+ const defaults = requiredHosts(kind);
179
+ let hosts = defaults;
180
+ if (expected.requiredHosts !== undefined) {
181
+ const requested = expected.requiredHosts;
182
+ if (!(Array.isArray(requested) && requested.length > 0 && new Set(requested).size === requested.length
183
+ && Array.from(requested).every((host) => defaults.includes(host)))) {
184
+ missing.push(`expected requiredHosts must be a nonempty unique subset of ${defaults.join(", ")}`);
185
+ } else hosts = requested;
151
186
  }
152
- if (evidence) {
187
+ if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
188
+ missing.push("gate evidence must be an object");
189
+ } else {
153
190
  if (evidence.schema !== GATE_SCHEMA) missing.push(`evidence schema must be ${GATE_SCHEMA}`);
154
191
  if (evidence.kind !== kind || evidence.name !== name) missing.push(`evidence is for ${evidence.kind}:${evidence.name}, expected ${kind}:${name}`);
155
192
  // A missing publishing version must refuse: a skipped comparison would qualify any evidence version.
156
193
  if (typeof version !== "string" || version.trim() === "") missing.push("publishing version is unknown (manifest has no literal version), cannot bind the receipt to it");
157
194
  else if (evidence.version !== version) missing.push(`evidence version ${evidence.version} != ${version}`);
158
195
  if (!SHA.test(String(evidence.commit ?? ""))) missing.push("evidence commit is not a full 40-character SHA");
159
- else if (head && evidence.commit !== head) {
160
- missing.push(`evidence commit ${evidence.commit} != current HEAD ${head} (${describeCommitMismatch(evidence.commit, head, packagePath, git)})`);
161
- }
196
+ else if (SHA.test(String(commit ?? "")) && evidence.commit !== commit) missing.push(`evidence commit ${evidence.commit} != expected commit ${commit}`);
162
197
  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}`);
167
- // npm packages and crates may ship on macOS evidence first when Adrian decides so; the decision must be
168
- // recorded in the evidence (hostPolicy "darwin-only" + a non-empty hostPolicyReason).
169
- // Crates may ship Mac-first too (Adrian 2026-10-07): the Windows run follows; the reason is recorded.
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)) {
198
+ if (runs.some((run) => run?.batch !== undefined) && !SHA.test(String(tree ?? ""))) missing.push("expected tree is not a full 40-character SHA for batch evidence");
199
+ for (const host of hosts) {
174
200
  const label = host === "darwin" ? "macOS" : "Windows";
175
201
  const matching = runs.filter((run) => run?.host?.os === host);
176
202
  if (!matching.length) { missing.push(`no ${label} host run receipt`); continue; }
@@ -183,9 +209,34 @@ export function checkPublicationGate({ kind, name, version, packagePath, evidenc
183
209
  if (!/^[0-9a-f]{64}$/.test(String(evidence.artifactSha256 ?? ""))) missing.push("evidence artifactSha256 must be a 64-hex hash");
184
210
  }
185
211
  if (artifactBytes !== undefined && evidence && digestBytes(artifactBytes) !== evidence.artifactSha256) missing.push("artifact sha256 does not match evidence artifactSha256");
186
- const result = { ok: missing.length === 0, missing };
187
- Object.defineProperty(result, "evidence", { value: evidence });
188
- return Object.freeze(result);
212
+ return gateResult(missing, evidence);
213
+ }
214
+
215
+ // Acquire local source identity and cleanliness, then use the same content validator as CI.
216
+ export function checkPublicationGate({ kind, name, version, packagePath, evidencePath, artifactBytes, git = defaultGit, readFile = (p) => fs.readFileSync(p, "utf8") }) {
217
+ const missing = [];
218
+ const head = git(["rev-parse", "HEAD"], packagePath);
219
+ if (!head || !SHA.test(head)) missing.push(`cannot read current commit SHA for ${packagePath}`);
220
+ const dirty = git(["status", "--porcelain", "--", "."], packagePath);
221
+ if (dirty === null) missing.push(`cannot read git status for ${packagePath}`);
222
+ else if (dirty !== "") missing.push(`tree is not clean for ${packagePath}`);
223
+ let evidence = null;
224
+ try { evidence = JSON.parse(readFile(evidencePath)); } catch {
225
+ missing.push(`gate evidence file missing or unreadable: ${evidencePath}`);
226
+ }
227
+ const runs = Array.isArray(evidence?.runs) ? evidence.runs : [];
228
+ const tree = runs.some((run) => run?.batch !== undefined) ? git(["rev-parse", "HEAD^{tree}"], packagePath) : undefined;
229
+ if (tree === null) missing.push(`cannot read the HEAD tree for ${packagePath}`);
230
+ // Local publication retains its recorded single-host grant. CI callers own their policy separately.
231
+ const singleHost = (kind === "npm" || kind === "cargo") ? singleHostOfPolicy(evidence) : null;
232
+ const result = validatePublicationEvidence({ expected: { kind, name, version, commit: head, tree,
233
+ ...(singleHost ? { requiredHosts: [singleHost] } : {}) }, evidence, artifactBytes });
234
+ for (const problem of result.missing) {
235
+ if (problem === `evidence commit ${evidence?.commit} != expected commit ${head}`) {
236
+ missing.push(`evidence commit ${evidence.commit} != current HEAD ${head} (${describeCommitMismatch(evidence.commit, head, packagePath, git)})`);
237
+ } else missing.push(problem);
238
+ }
239
+ return gateResult(missing, evidence);
189
240
  }
190
241
 
191
242
  // Enforce: throws on missing evidence unless dryRun (then reports and continues).
@@ -251,17 +302,27 @@ export function runNpmPreflight(argv, workspaceRoot = process.cwd(), deps = {})
251
302
  return;
252
303
  }
253
304
  if (dryRun) throw new Error("--publish and --dry-run are mutually exclusive");
305
+ // Host uploads are retired on every host with no environment override; publishGatedTarball stays for direct calls only.
306
+ refuseHostUpload("right-release publish npm --publish");
254
307
  if (!tarball) throw new Error("--publish requires --tarball <gated .tgz>");
255
308
  enforcePublicationGate(options);
256
- publishGatedTarball({ name: pkg, version: manifest.version, tarball, evidencePath }, deps);
309
+ publishGatedTarball({ name: pkg, version: manifest.version, tarball, evidencePath, workspaceRoot }, deps);
257
310
  }
258
311
 
259
- export function publishGatedTarball({ name, version, tarball, evidencePath }, deps = {}) {
312
+ /**
313
+ * Publish one gated tarball. Exported for the reviewed publisher and its tests only: it reads registry credentials, so it refuses
314
+ * (HOST_UPLOAD_REFUSED) before reading anything unless `capability` is a publish capability (host-upload-refusal.mjs
315
+ * createPublishCapability) created from a validated admission that names this npm archive, and the bytes about to be sent must
316
+ * be the admitted archive's.
317
+ */
318
+ export function publishGatedTarball({ name, version, tarball, evidencePath, workspaceRoot, capability }, deps = {}) {
319
+ assertPublishCapability(capability, { what: "publishGatedTarball", registry: "npm", name, version });
260
320
  const run = deps.spawnSync ?? spawnSync;
261
321
  const bytes = fs.readFileSync(tarball);
262
322
  const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
263
323
  const sha256 = crypto.createHash("sha256").update(bytes).digest("hex");
264
324
  if (sha256 !== evidence.artifactSha256) throw new Error(`tarball sha256 ${sha256} != evidence artifactSha256 ${evidence.artifactSha256}`);
325
+ assertPublishCapability(capability, { what: "publishGatedTarball", registry: "npm", name, version, sha256 });
265
326
  const token = deps.readToken ? deps.readToken() : readKeychainToken(run);
266
327
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rk-npm-publish-"));
267
328
  const userconfig = path.join(dir, "npmrc");
@@ -278,7 +339,8 @@ export function publishGatedTarball({ name, version, tarball, evidencePath }, de
278
339
  }
279
340
  const sha1 = crypto.createHash("sha1").update(bytes).digest("hex");
280
341
  verifyNpmRegistryBytes({ name, version, sha1 }, deps);
281
- (deps.recordPublished ?? recordPublished)({ registry: "npm", name, version });
342
+ // Feed and consumer copies belong to the requested workspace, never to this module's own (possibly installed) location.
343
+ (deps.recordPublished ?? recordPublished)({ registry: "npm", name, version }, workspaceRoot ? workspaceFeedOptions(workspaceRoot) : undefined);
282
344
  }
283
345
 
284
346
  // CI and the authenticated helper share mandatory, fail-closed registry equality.
package/publish-cargo.mjs CHANGED
@@ -2,12 +2,13 @@
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, provenGateSteps, singleHostOfPolicy } from "./publication-gate.mjs";
5
+ import { enforcePublicationGate, defaultEvidencePath, provenGateSteps, singleHostOfPolicy, batchClippyMissingFeatureSets } from "./publication-gate.mjs";
6
6
  import { runCargoSync } from "./managed-cargo.mjs";
7
+ import { assertPublishCapability, refuseHostUpload } from "./host-upload-refusal.mjs";
7
8
  import { spawnSync } from "./background-process.mjs";
8
9
  import { readVerifiedArtifact, assertArtifactUnchanged } from "./publication-artifacts.mjs";
9
10
  import { verifyCargoRegistry } from "./cargo-registry-readback.mjs";
10
- import { recordPublished } from "./rightkit-sync.mjs";
11
+ import { recordPublished, workspaceFeedOptions } from "./rightkit-sync.mjs";
11
12
  import { readCargoPackage } from "./crate-graph.mjs";
12
13
  import { runtimeBudgetCheck, RUNTIME_BUDGET_CODE } from "./runtime-budgets.mjs";
13
14
  import { formatPreflight, PREFLIGHT_FAIL } from "./preflight.mjs";
@@ -147,14 +148,19 @@ export function scanCrateForSecrets(crateDir) {
147
148
  continue;
148
149
  }
149
150
  const bytes = fs.readFileSync(file);
150
- if (bytes.includes(0)) continue;
151
151
  const text = bytes.toString("utf8");
152
152
  if (SECRET_PATTERNS.some((pattern) => pattern.test(text))) violations.push(relative);
153
153
  }
154
154
  return [...new Set(violations)].sort();
155
155
  }
156
156
 
157
+ /**
158
+ * Package, qualify and (unless `options.dryRun`) publish one crate. A real publish is refused (HOST_UPLOAD_REFUSED) before anything
159
+ * runs unless `options.capability` is a publish capability (host-upload-refusal.mjs createPublishCapability) created from a
160
+ * validated admission that names this crate, and again before the registry upload unless the verified archive is the admitted one.
161
+ */
157
162
  export async function runCargoPublish(options, execute = (_command, args, settings) => runCargoSync(args, settings), deps = {}) {
163
+ if (!options.dryRun) assertPublishCapability(options.capability, { what: "runCargoPublish", registry: "crates", name: options.crate });
158
164
  const crateDir = path.join(options.workspaceRoot, "crates", options.crate);
159
165
  const manifest = path.join(crateDir, "Cargo.toml");
160
166
  if (!fs.existsSync(manifest)) throw new Error(`crate manifest not found: ${manifest}`);
@@ -176,8 +182,9 @@ export async function runCargoPublish(options, execute = (_command, args, settin
176
182
  const proven = (reuseEvidence || options.trustEvidenceSteps === true) && !options.dryRun ? provenGateSteps(gate.evidence) : {};
177
183
  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
184
  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");
185
+ const status = (deps.workspaceStatus ?? ((workspaceRoot) => spawnSync("git", ["status", "--porcelain", "--", "Cargo.toml", "Cargo.lock", "ci/gate.config.json", "ci/asset-gated.json"], { cwd: workspaceRoot, encoding: "utf8", windowsHide: true })))(options.workspaceRoot);
186
+ // The gate config decides the steps replayed below, so an uncommitted edit would change what is demanded (0.4.1 review A3).
187
+ if (status.error || status.status !== 0 || String(status.stdout ?? "").trim()) throw new Error("--from-evidence requires clean workspace Cargo.toml, Cargo.lock and ci gate config (ci/gate.config.json, ci/asset-gated.json)");
181
188
  const proofHost = singleHostOfPolicy(gate.evidence) ?? "darwin";
182
189
  const runs = gate.evidence.runs.filter((run) => run?.host?.os === proofHost);
183
190
  const command = `cargo package --locked -p ${options.crate}`;
@@ -201,6 +208,11 @@ export async function runCargoPublish(options, execute = (_command, args, settin
201
208
  throw new Error(`--from-evidence missing configured qualification step: ${text}`);
202
209
  }
203
210
  }
211
+ // A batch run's clippy lives in its batch receipt, which `expected` leaves out: demand one row per feature set (0.4.1 review A2).
212
+ if (run.batch) {
213
+ const missing = batchClippyMissingFeatureSets(run.batch, gate.evidence, config?.rust?.[options.crate]?.featureSets ?? [[]]);
214
+ if (missing.length) throw new Error(`--from-evidence missing batch clippy for feature set${missing.length === 1 ? "" : "s"}: ${missing.map((features) => features.join(" ") || "default").join("; ")}`);
215
+ }
204
216
  }
205
217
  }
206
218
  const plan = buildCargoPublishPlan({ ...options, crateDir, proven,
@@ -246,7 +258,10 @@ export async function runCargoPublish(options, execute = (_command, args, settin
246
258
  publication = await verifyCargoRegistry({ name: options.crate, version: cargoVersion, sha256: artifact.sha256 }, deps);
247
259
  continue;
248
260
  }
249
- if (step.label === "registry publish") assertArtifactUnchanged(artifact);
261
+ if (step.label === "registry publish") {
262
+ assertArtifactUnchanged(artifact);
263
+ assertPublishCapability(options.capability, { what: "runCargoPublish", registry: "crates", name: options.crate, version: cargoVersion, sha256: artifact.sha256 });
264
+ }
250
265
  // `execute` may be synchronous (the default) or async (the batch publisher overlaps uploads).
251
266
  const isReadOnlyInspection = ["package contents", "package location"].includes(step.label);
252
267
  const settings = {
@@ -313,10 +328,12 @@ function valueAfter(argv, flag) {
313
328
 
314
329
  async function main() {
315
330
  const options = parseCargoPublishArgs(process.argv.slice(2));
331
+ // Every non---dry-run call (including --from-evidence) is an upload: refused on every host, with no environment override.
332
+ if (!options.dryRun) refuseHostUpload("right-release publish cargo");
316
333
  const result = await runCargoPublish(options);
317
334
  process.stdout.write(`[right-release] ${options.dryRun ? "validated" : "published"} ${options.crate}\n`);
318
335
  if (!options.dryRun) {
319
- recordPublished({ registry: "cargo", name: result.name, version: result.version });
336
+ recordPublished({ registry: "cargo", name: result.name, version: result.version }, workspaceFeedOptions(options.workspaceRoot));
320
337
  }
321
338
  }
322
339
 
package/publish-swift.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // Publish RightKitSwift by the ONE Swift distribution contract: a plain semver git tag on this
2
- // repository (github.com/Orthic-Labs/rightkit), resolved by apps through the root Package.swift
2
+ // repository (the checkout's own origin, github.com/bogusyogi/rightkit), resolved by apps through the root Package.swift
3
3
  // (docs/RIGHTKIT-SHARED-PACKAGES.md, Swift section). There is no mirror and no second repository.
4
4
  //
5
5
  // `right-release publish swift [--dry-run] [--evidence <file>] [--workspace <dir>]` verifies, in order:
@@ -19,7 +19,14 @@ import { spawnSync } from "./background-process.mjs";
19
19
  import { digestBytes } from "./publication-artifacts.mjs";
20
20
  import { enforcePublicationGate, defaultEvidencePath } from "./publication-gate.mjs";
21
21
 
22
- export const SWIFT_DISTRIBUTION_URL = "https://github.com/Orthic-Labs/rightkit";
22
+ // The distribution URL is whatever repository this checkout's origin names (GITHUB_REPOSITORY when origin does not
23
+ // parse); it is never a constant, so a repository move cannot leave a stale name in the success message.
24
+ export function swiftDistributionUrl(origin, environment = process.env) {
25
+ const fromOrigin = /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/i.exec(String(origin ?? "").trim())?.[1];
26
+ const fromEnvironment = /^[\w.-]+\/[\w.-]+$/.test(environment?.GITHUB_REPOSITORY ?? "") ? environment.GITHUB_REPOSITORY : null;
27
+ const repository = fromOrigin ?? fromEnvironment;
28
+ return repository ? `https://github.com/${repository}` : String(origin ?? "").trim();
29
+ }
23
30
  const DRIFT_TEST = path.join("scripts", "swift-root-manifest.test.mjs");
24
31
 
25
32
  export function parseSwiftPublishArgs(argv) {
@@ -125,7 +132,9 @@ export function runSwiftPublish(options, execute = spawnSync) {
125
132
  catch (error) { git(["tag", "-d", version], toplevel, execute); throw error; }
126
133
  const remote = git(["ls-remote", "--tags", "origin", `refs/tags/${version}`], toplevel, execute);
127
134
  if (!remote.startsWith(sha)) throw new Error(`origin does not show tag ${version} at ${sha}`);
128
- out(`tagged ${version} at ${sha} on ${SWIFT_DISTRIBUTION_URL}`);
135
+ let origin = "";
136
+ try { origin = git(["config", "--get", "remote.origin.url"], toplevel, execute); } catch { /* the tag is already verified on origin; the name is cosmetic */ }
137
+ out(`tagged ${version} at ${sha} on ${swiftDistributionUrl(origin, options.environment)}`);
129
138
  return { name, version, commit: sha, published: true };
130
139
  }
131
140
 
@@ -391,6 +391,10 @@ export const WEB_MOCK_MARKERS = Object.freeze([
391
391
  { id: "isBrowserQaMode", pattern: /isBrowserQaMode/ },
392
392
  { id: "MockDataSource", pattern: /MockDataSource/ },
393
393
  { id: "qa-build-flag", pattern: /\bVITE_[A-Z0-9_]*_QA\b/ },
394
+
395
+ // @rightkit/license/testing (M12): its sentinel value, and the subpath a bundle that kept the import still names.
396
+ { id: "license-testing-sentinel", pattern: /\bRIGHTKIT_LICENSE_TESTING_DO_NOT_SHIP\b/ },
397
+ { id: "license-testing-entry", pattern: /@rightkit\/license\/testing/ },
394
398
  ]);
395
399
  const WEB_EXTENSIONS = /\.(?:m?js|cjs|html?|css|map)$/i;
396
400