@rightkit/release 0.2.110 → 0.2.112

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.
package/development.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from "./background-process.mjs";
2
2
  import { createHash } from "node:crypto";
3
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
3
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
@@ -48,7 +48,8 @@ export function validateDevelopmentConfig(config, platform) {
48
48
  if (target.install.manifestPath && path.isAbsolute(target.install.manifestPath)) throw new Error("install.manifestPath must be relative to app root");
49
49
  } else if (target.install?.kind === "portable") {
50
50
  const descriptor = target.install;
51
- if (descriptor.activation?.kind !== "current" || !path.isAbsolute(descriptor.installRoot ?? "")) throw new Error("portable development installer requires absolute installRoot and current activation");
51
+ const absoluteInstallRoot = platform === "win" ? path.win32.isAbsolute(descriptor.installRoot ?? "") : path.isAbsolute(descriptor.installRoot ?? "");
52
+ if (descriptor.activation?.kind !== "current" || !absoluteInstallRoot) throw new Error("portable development installer requires absolute installRoot and current activation");
52
53
  for (const key of ["sourceDirectory", "entrypoint"]) {
53
54
  if (typeof descriptor[key] !== "string" || !descriptor[key] || path.isAbsolute(descriptor[key]) || path.win32.isAbsolute(descriptor[key]) || descriptor[key].split(/[\\/]/).includes("..")) throw new Error(`portable development ${key} must be a contained relative path`);
54
55
  }
@@ -88,7 +89,11 @@ function install(descriptor, { root, platform, env, run, sinceMs = null }) {
88
89
  const { bundleRoot, installer: app } = resolveTauriBundleDirectory(descriptor, { root, platform, env, sinceMs });
89
90
  const built = path.resolve(bundleRoot, "..", "..", path.basename(descriptor.expectedInstalledPath));
90
91
  assertNonEmptyExecutable(built);
91
- const result = run(app, ["/S"], { cwd: root, env, stdio: "inherit", windowsHide: true, shell: false });
92
+ // Tauri's silent installer force-kills a running copy of the app (utils.nsh: IfSilent kill). When it
93
+ // was running, pass /R so the installer relaunches it as the user (RunAsUser, outside this process tree).
94
+ const running = isWindowsProcessRunning(path.basename(descriptor.expectedInstalledPath), { run });
95
+ if (running) console.log(`right-release dev: ${descriptor.productName} is running; the installer will close it and relaunch it`);
96
+ const result = run(app, windowsInstallerArguments({ running }), { cwd: root, env, stdio: "inherit", windowsHide: true, shell: false });
92
97
  if (result.status !== 0) throw new Error(`development installer failed (${result.status}): ${app}`);
93
98
  const installed = path.resolve(root, descriptor.expectedInstalledPath);
94
99
  assertInstalledExecutableMatches(built, installed);
@@ -142,6 +147,14 @@ export function resolveTauriBundleDirectory(descriptor, { root, platform, env =
142
147
  return { bundleRoot, installer: candidates[0] };
143
148
  }
144
149
 
150
+ export function windowsInstallerArguments({ running = false } = {}) { return running ? ["/S", "/R"] : ["/S"]; }
151
+
152
+ export function isWindowsProcessRunning(imageName, { run = spawnSync, systemRoot = process.env.SystemRoot ?? "C:\\Windows" } = {}) {
153
+ const result = run(path.join(systemRoot, "System32", "tasklist.exe"), ["/FI", `IMAGENAME eq ${imageName}`, "/FO", "CSV", "/NH"], { encoding: "utf8", windowsHide: true, shell: false });
154
+ if (result.status !== 0 || typeof result.stdout !== "string") return false;
155
+ return result.stdout.toLowerCase().includes(`"${imageName.toLowerCase()}"`);
156
+ }
157
+
145
158
  export function assertInstalledExecutableMatches(built, installed, read = readFileSync) {
146
159
  assertNonEmptyExecutable(built);
147
160
  assertNonEmptyExecutable(installed);
@@ -212,10 +225,27 @@ export function resolvePnpmCli({ root, env = process.env } = {}) {
212
225
  const parent = path.dirname(dir);
213
226
  if (parent === dir) break;
214
227
  }
215
- const selected = candidates.find((candidate) => existsSync(candidate));
228
+ // A global pnpm (no Corepack in Node >= 25) exposes its JS entry only through its PATH shim.
229
+ for (const dir of String(env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean)) {
230
+ const shim = path.join(dir, "pnpm.CMD");
231
+ if (!existsSync(shim)) continue;
232
+ const match = /"%~dp0\\([^"]*pnpm\.(?:mjs|cjs|js))"/i.exec(readFileSync(shim, "utf8"));
233
+ if (match) candidates.push(path.resolve(dir, match[1]));
234
+ }
235
+ // Unix global pnpm exposes its JavaScript entry through a PATH symlink.
236
+ for (const dir of String(env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean)) candidates.push(path.join(dir, "pnpm"));
237
+ const selected = candidates.map(resolveJavaScriptEntrypoint).find(Boolean);
216
238
  if (!selected) throw new Error("could not resolve pnpm JavaScript CLI entrypoint; install/pin pnpm through Corepack");
217
239
  return selected;
218
240
  }
241
+ function resolveJavaScriptEntrypoint(candidate) {
242
+ try {
243
+ const resolved = realpathSync(candidate);
244
+ return /pnpm\.(?:mjs|cjs|js)$/i.test(resolved) && statSync(resolved).isFile() ? resolved : undefined;
245
+ } catch {
246
+ return undefined;
247
+ }
248
+ }
219
249
  function git(cwd, args) { const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true }); if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed`); return result.stdout.trim(); }
220
250
  function inside(root, child) { const rel = path.relative(path.resolve(root), path.resolve(child)); return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)); }
221
251
  export async function main(args = process.argv.slice(2)) {
package/hardeningscan.mjs CHANGED
@@ -1,144 +1,144 @@
1
- #!/usr/bin/env node
2
- import { createHash } from "node:crypto";
3
- import { createReadStream, readFileSync, realpathSync } from "node:fs";
4
- import { lstat, readdir } from "node:fs/promises";
5
- import path from "node:path";
6
- import { HARDENING_RULES } from "./hardening-evidence.mjs";
7
-
8
- const rawArgs = process.argv.slice(2);
9
- if (rawArgs.length === 0 || rawArgs.includes("-h") || rawArgs.includes("--help")) {
10
- console.error("usage: node hardeningscan.mjs [--allow-evidence <json>] <artifact-path>...");
11
- process.exit(rawArgs.length === 0 ? 2 : 0);
12
- }
13
- const allowIndex = rawArgs.indexOf("--allow-evidence");
14
- const allowEvidencePath = allowIndex >= 0 ? rawArgs[allowIndex + 1] : null;
15
- if (allowIndex >= 0 && !allowEvidencePath) throw new Error("--allow-evidence requires a JSON path");
16
- // Only strip the flag pair when the flag is present: with allowIndex = -1,
17
- // filtering out index `allowIndex + 1` would silently drop the first
18
- // artifact path (a lone artifact arg then failed as "no artifact path").
19
- const args = allowIndex >= 0
20
- ? rawArgs.filter((_, index) => index !== allowIndex && index !== allowIndex + 1)
21
- : rawArgs;
22
- if (!args.length) throw new Error("at least one artifact path is required");
23
-
24
- const root = realpathSync(process.cwd());
25
- const findings = [];
26
- const evidence = allowEvidencePath ? JSON.parse(readFileSync(allowEvidencePath, "utf8")) : { allowances: [] };
27
- if (allowEvidencePath && (evidence.schemaVersion !== 1 || evidence.kind !== "rightkit-hardening-evidence")) {
28
- throw new Error("invalid hardening evidence");
29
- }
30
- const allowances = evidence.allowances ?? [];
31
- for (const allowance of allowances) {
32
- const sourceMatch = typeof allowance.sourceEvidence === "string" ? /^(.*):([1-9]\d*)$/.exec(allowance.sourceEvidence) : null;
33
- const knownRule = HARDENING_RULES[allowance.rule];
34
- if (!knownRule || allowance.exact !== knownRule.exact || !sourceMatch || !/^[a-f0-9]{64}$/.test(allowance.artifactSha256 ?? "") || !/^[a-f0-9]{64}$/.test(allowance.sourceSha256 ?? "") || allowance.sourceLine !== Number(sourceMatch[2]) || typeof allowance.rationale !== "string" || allowance.rationale.trim().length < 12) {
35
- throw new Error("invalid hardening allowance source binding");
36
- }
37
- const sourcePath = realpathSync(path.resolve(root, sourceMatch[1]));
38
- const sourceRelative = path.relative(root, sourcePath);
39
- if (sourceRelative === ".." || sourceRelative.startsWith("../") || sourceRelative.startsWith("..\\") || path.isAbsolute(sourceRelative)) {
40
- throw new Error("hardening allowance source escapes release root");
41
- }
42
- const sourceBytes = readFileSync(sourcePath);
43
- if (createHash("sha256").update(sourceBytes).digest("hex") !== allowance.sourceSha256) throw new Error("hardening allowance source digest mismatch");
44
- const line = sourceBytes.toString("utf8").split(/\r?\n/)[allowance.sourceLine - 1];
45
- if (typeof line !== "string" || !line.includes(allowance.exact)) throw new Error("hardening allowance source token mismatch");
46
- }
47
-
48
- const fileNameRules = [
49
- [/\.map$/i, "source map shipped"],
50
- [/\.pdb$/i, "Windows debug symbols shipped"],
51
- [/\.dSYM(?:$|[/\\])/i, "macOS debug symbols shipped"],
52
- [/\.mlpackage(?:$|[/\\])/i, "raw CoreML package shipped"],
53
- [/[/\\]docs[/\\]experiments(?:$|[/\\])/i, "experiment docs shipped"],
54
- [/[/\\]\.scratch(?:$|[/\\])/i, "scratch directory shipped"],
55
- [/[/\\]\.git(?:$|[/\\])/i, "git metadata shipped"],
56
- [/[/\\]node_modules(?:$|[/\\])/i, "node_modules shipped"],
57
- [/[/\\](?:src|tests?|benches|benchmarks)(?:$|[/\\])/i, "source/test tree shipped"],
58
- [/[/\\]tdt_w15_p6(?:$|[/\\])/i, "stale TDT p6 model shipped"],
59
- ];
60
-
61
- const contentRules = [
62
- ["local-source-path", /\/Users\/adrdsouza\/claude/gi, "local source path leaked"],
63
- ["local-cargo-path", /\/Users\/adrdsouza\/\.cargo/gi, "local cargo path leaked"],
64
- ["local-windows-path", /C:\\Users\\adrdsouza\\(?:claude|\.cargo)/gi, "local Windows source path leaked"],
65
- ["experiment-path", /docs\/experiments/gi, "experiment path leaked"],
66
- ["cleanup-prompt-marker", /l3_cleanup_prompt/gi, "L3 cleanup prompt marker leaked"],
67
- ["system-prompt-marker", /SYSTEM_PROMPT/gi, "system prompt marker leaked"],
68
- ["secret-env-name", /OPENAI_API_KEY|ANTHROPIC_API_KEY|APPLE_PASSWORD|NOTARY_PROFILE/gi, "secret/env key name leaked"],
69
- ["gpl-flag", /--enable-gpl|--enable-nonfree/gi, "GPL/nonfree ffmpeg flag leaked"],
70
- ["stale-model", /tdt_w15_p6/gi, "stale TDT p6 string leaked"],
71
- ["experiment-model", /Canary-Qwen|Nemotron|dqlstm|bias-experiments/gi, "experiment/model-history marker leaked"],
72
- ];
73
-
74
- const maxPatternLen = Math.max(...contentRules.map(([, rule]) => String(rule).length), 256);
75
-
76
- function add(kind, file, message, detail = "") {
77
- findings.push({ kind, file: path.relative(root, file) || file, message, detail });
78
- }
79
-
80
- async function walk(target) {
81
- const st = await lstat(target);
82
- const normalized = target.split(path.sep).join("/");
83
- for (const [rule, message] of fileNameRules) if (rule.test(normalized)) add("path", target, message);
84
- if (st.isDirectory()) {
85
- const entries = await readdir(target);
86
- await Promise.all(entries.map((name) => walk(path.join(target, name))));
87
- return;
88
- }
89
- if (st.isFile()) await scanFile(target);
90
- }
91
-
92
- function scanFile(file) {
93
- return new Promise((resolveScan) => {
94
- const artifactSha256 = createHash("sha256").update(readFileSync(file)).digest("hex");
95
- const stream = createReadStream(file, { highWaterMark: 256 * 1024 });
96
- let tail = "";
97
- stream.on("data", (chunk) => {
98
- const text = tail + chunk.toString("latin1");
99
- for (const [ruleId, rule, message] of contentRules) {
100
- if (/ffmpeg-LICENSE\.txt$/i.test(file) && ruleId === "gpl-flag") continue;
101
- rule.lastIndex = 0;
102
- for (const match of text.matchAll(rule)) {
103
- const allowed = allowances.some((allowance) =>
104
- allowance.rule === ruleId &&
105
- allowance.artifactSha256 === artifactSha256 &&
106
- allowance.exact === match[0] &&
107
- typeof allowance.sourceEvidence === "string" &&
108
- typeof allowance.rationale === "string"
109
- );
110
- if (!allowed) add("content", file, message, rule.source);
111
- }
112
- }
113
- tail = text.slice(-maxPatternLen);
114
- });
115
- stream.on("error", (error) => {
116
- add("read", file, "could not scan file", error.message);
117
- resolveScan();
118
- });
119
- stream.on("end", resolveScan);
120
- });
121
- }
122
-
123
- for (const arg of args) {
124
- const target = path.resolve(arg);
125
- try {
126
- await walk(target);
127
- } catch (error) {
128
- add("target", target, "could not scan target", error.message);
129
- }
130
- }
131
-
132
- const unique = new Map();
133
- for (const finding of findings) unique.set([finding.kind, finding.file, finding.message].join("\0"), finding);
134
- const deduped = [...unique.values()].sort((left, right) =>
135
- (left.file + " " + left.message).localeCompare(right.file + " " + right.message),
136
- );
137
-
138
- if (deduped.length) {
139
- console.error("hardeningscan: " + deduped.length + " finding(s)");
140
- for (const finding of deduped) console.error("- [" + finding.kind + "] " + finding.file + ": " + finding.message);
141
- process.exit(1);
142
- }
143
-
144
- console.log("hardeningscan: clean");
1
+ #!/usr/bin/env node
2
+ import { createHash } from "node:crypto";
3
+ import { createReadStream, readFileSync, realpathSync } from "node:fs";
4
+ import { lstat, readdir } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import { HARDENING_RULES } from "./hardening-evidence.mjs";
7
+
8
+ const rawArgs = process.argv.slice(2);
9
+ if (rawArgs.length === 0 || rawArgs.includes("-h") || rawArgs.includes("--help")) {
10
+ console.error("usage: node hardeningscan.mjs [--allow-evidence <json>] <artifact-path>...");
11
+ process.exit(rawArgs.length === 0 ? 2 : 0);
12
+ }
13
+ const allowIndex = rawArgs.indexOf("--allow-evidence");
14
+ const allowEvidencePath = allowIndex >= 0 ? rawArgs[allowIndex + 1] : null;
15
+ if (allowIndex >= 0 && !allowEvidencePath) throw new Error("--allow-evidence requires a JSON path");
16
+ // Only strip the flag pair when the flag is present: with allowIndex = -1,
17
+ // filtering out index `allowIndex + 1` would silently drop the first
18
+ // artifact path (a lone artifact arg then failed as "no artifact path").
19
+ const args = allowIndex >= 0
20
+ ? rawArgs.filter((_, index) => index !== allowIndex && index !== allowIndex + 1)
21
+ : rawArgs;
22
+ if (!args.length) throw new Error("at least one artifact path is required");
23
+
24
+ const root = realpathSync(process.cwd());
25
+ const findings = [];
26
+ const evidence = allowEvidencePath ? JSON.parse(readFileSync(allowEvidencePath, "utf8")) : { allowances: [] };
27
+ if (allowEvidencePath && (evidence.schemaVersion !== 1 || evidence.kind !== "rightkit-hardening-evidence")) {
28
+ throw new Error("invalid hardening evidence");
29
+ }
30
+ const allowances = evidence.allowances ?? [];
31
+ for (const allowance of allowances) {
32
+ const sourceMatch = typeof allowance.sourceEvidence === "string" ? /^(.*):([1-9]\d*)$/.exec(allowance.sourceEvidence) : null;
33
+ const knownRule = HARDENING_RULES[allowance.rule];
34
+ if (!knownRule || allowance.exact !== knownRule.exact || !sourceMatch || !/^[a-f0-9]{64}$/.test(allowance.artifactSha256 ?? "") || !/^[a-f0-9]{64}$/.test(allowance.sourceSha256 ?? "") || allowance.sourceLine !== Number(sourceMatch[2]) || typeof allowance.rationale !== "string" || allowance.rationale.trim().length < 12) {
35
+ throw new Error("invalid hardening allowance source binding");
36
+ }
37
+ const sourcePath = realpathSync(path.resolve(root, sourceMatch[1]));
38
+ const sourceRelative = path.relative(root, sourcePath);
39
+ if (sourceRelative === ".." || sourceRelative.startsWith("../") || sourceRelative.startsWith("..\\") || path.isAbsolute(sourceRelative)) {
40
+ throw new Error("hardening allowance source escapes release root");
41
+ }
42
+ const sourceBytes = readFileSync(sourcePath);
43
+ if (createHash("sha256").update(sourceBytes).digest("hex") !== allowance.sourceSha256) throw new Error("hardening allowance source digest mismatch");
44
+ const line = sourceBytes.toString("utf8").split(/\r?\n/)[allowance.sourceLine - 1];
45
+ if (typeof line !== "string" || !line.includes(allowance.exact)) throw new Error("hardening allowance source token mismatch");
46
+ }
47
+
48
+ const fileNameRules = [
49
+ [/\.map$/i, "source map shipped"],
50
+ [/\.pdb$/i, "Windows debug symbols shipped"],
51
+ [/\.dSYM(?:$|[/\\])/i, "macOS debug symbols shipped"],
52
+ [/\.mlpackage(?:$|[/\\])/i, "raw CoreML package shipped"],
53
+ [/[/\\]docs[/\\]experiments(?:$|[/\\])/i, "experiment docs shipped"],
54
+ [/[/\\]\.scratch(?:$|[/\\])/i, "scratch directory shipped"],
55
+ [/[/\\]\.git(?:$|[/\\])/i, "git metadata shipped"],
56
+ [/[/\\]node_modules(?:$|[/\\])/i, "node_modules shipped"],
57
+ [/[/\\](?:src|tests?|benches|benchmarks)(?:$|[/\\])/i, "source/test tree shipped"],
58
+ [/[/\\]tdt_w15_p6(?:$|[/\\])/i, "stale TDT p6 model shipped"],
59
+ ];
60
+
61
+ const contentRules = [
62
+ ["local-source-path", /\/Users\/adrdsouza\/claude/gi, "local source path leaked"],
63
+ ["local-cargo-path", /\/Users\/adrdsouza\/\.cargo/gi, "local cargo path leaked"],
64
+ ["local-windows-path", /C:\\Users\\adrdsouza\\(?:claude|\.cargo)/gi, "local Windows source path leaked"],
65
+ ["experiment-path", /docs\/experiments/gi, "experiment path leaked"],
66
+ ["cleanup-prompt-marker", /l3_cleanup_prompt/gi, "L3 cleanup prompt marker leaked"],
67
+ ["system-prompt-marker", /SYSTEM_PROMPT/gi, "system prompt marker leaked"],
68
+ ["secret-env-name", /OPENAI_API_KEY|ANTHROPIC_API_KEY|APPLE_PASSWORD|NOTARY_PROFILE/gi, "secret/env key name leaked"],
69
+ ["gpl-flag", /--enable-gpl|--enable-nonfree/gi, "GPL/nonfree ffmpeg flag leaked"],
70
+ ["stale-model", /tdt_w15_p6/gi, "stale TDT p6 string leaked"],
71
+ ["experiment-model", /Canary-Qwen|Nemotron|dqlstm|bias-experiments/gi, "experiment/model-history marker leaked"],
72
+ ];
73
+
74
+ const maxPatternLen = Math.max(...contentRules.map(([, rule]) => String(rule).length), 256);
75
+
76
+ function add(kind, file, message, detail = "") {
77
+ findings.push({ kind, file: path.relative(root, file) || file, message, detail });
78
+ }
79
+
80
+ async function walk(target) {
81
+ const st = await lstat(target);
82
+ const normalized = target.split(path.sep).join("/");
83
+ for (const [rule, message] of fileNameRules) if (rule.test(normalized)) add("path", target, message);
84
+ if (st.isDirectory()) {
85
+ const entries = await readdir(target);
86
+ await Promise.all(entries.map((name) => walk(path.join(target, name))));
87
+ return;
88
+ }
89
+ if (st.isFile()) await scanFile(target);
90
+ }
91
+
92
+ function scanFile(file) {
93
+ return new Promise((resolveScan) => {
94
+ const artifactSha256 = createHash("sha256").update(readFileSync(file)).digest("hex");
95
+ const stream = createReadStream(file, { highWaterMark: 256 * 1024 });
96
+ let tail = "";
97
+ stream.on("data", (chunk) => {
98
+ const text = tail + chunk.toString("latin1");
99
+ for (const [ruleId, rule, message] of contentRules) {
100
+ if (/ffmpeg-LICENSE\.txt$/i.test(file) && ruleId === "gpl-flag") continue;
101
+ rule.lastIndex = 0;
102
+ for (const match of text.matchAll(rule)) {
103
+ const allowed = allowances.some((allowance) =>
104
+ allowance.rule === ruleId &&
105
+ allowance.artifactSha256 === artifactSha256 &&
106
+ allowance.exact === match[0] &&
107
+ typeof allowance.sourceEvidence === "string" &&
108
+ typeof allowance.rationale === "string"
109
+ );
110
+ if (!allowed) add("content", file, message, rule.source);
111
+ }
112
+ }
113
+ tail = text.slice(-maxPatternLen);
114
+ });
115
+ stream.on("error", (error) => {
116
+ add("read", file, "could not scan file", error.message);
117
+ resolveScan();
118
+ });
119
+ stream.on("end", resolveScan);
120
+ });
121
+ }
122
+
123
+ for (const arg of args) {
124
+ const target = path.resolve(arg);
125
+ try {
126
+ await walk(target);
127
+ } catch (error) {
128
+ add("target", target, "could not scan target", error.message);
129
+ }
130
+ }
131
+
132
+ const unique = new Map();
133
+ for (const finding of findings) unique.set([finding.kind, finding.file, finding.message].join("\0"), finding);
134
+ const deduped = [...unique.values()].sort((left, right) =>
135
+ (left.file + " " + left.message).localeCompare(right.file + " " + right.message),
136
+ );
137
+
138
+ if (deduped.length) {
139
+ console.error("hardeningscan: " + deduped.length + " finding(s)");
140
+ for (const finding of deduped) console.error("- [" + finding.kind + "] " + finding.file + ": " + finding.message);
141
+ process.exit(1);
142
+ }
143
+
144
+ console.log("hardeningscan: clean");
@@ -1,13 +1,13 @@
1
- import { spawnSync } from "./background-process.mjs";
2
-
3
- export function resolveMacosDeveloperIdIdentity({ env = process.env, run = spawnSync } = {}) {
4
- const configured = env.APPLE_DEVELOPER_ID?.trim();
5
- if (configured) return configured;
6
- const result = run("security", ["find-identity", "-v", "-p", "codesigning"], { encoding: "utf8" });
7
- if (result.status !== 0) throw new Error("could not inspect macOS signing identities in Keychain");
8
- const identities = [...String(result.stdout ?? "").matchAll(/"([^"\n]*Developer ID Application:[^"]+)"/g)]
9
- .map((match) => match[1]);
10
- if (identities.length === 1) return identities[0];
11
- if (!identities.length) throw new Error("no Developer ID Application signing identity found in Keychain");
12
- throw new Error(`multiple Developer ID Application signing identities found in Keychain; set APPLE_DEVELOPER_ID explicitly (${identities.join(", ")})`);
13
- }
1
+ import { spawnSync } from "./background-process.mjs";
2
+
3
+ export function resolveMacosDeveloperIdIdentity({ env = process.env, run = spawnSync } = {}) {
4
+ const configured = env.APPLE_DEVELOPER_ID?.trim();
5
+ if (configured) return configured;
6
+ const result = run("security", ["find-identity", "-v", "-p", "codesigning"], { encoding: "utf8" });
7
+ if (result.status !== 0) throw new Error("could not inspect macOS signing identities in Keychain");
8
+ const identities = [...String(result.stdout ?? "").matchAll(/"([^"\n]*Developer ID Application:[^"]+)"/g)]
9
+ .map((match) => match[1]);
10
+ if (identities.length === 1) return identities[0];
11
+ if (!identities.length) throw new Error("no Developer ID Application signing identity found in Keychain");
12
+ throw new Error(`multiple Developer ID Application signing identities found in Keychain; set APPLE_DEVELOPER_ID explicitly (${identities.join(", ")})`);
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.110",
3
+ "version": "0.2.112",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ // Publication gate: refuse to publish a shared npm package, crate, or Swift tag unless a gate
3
+ // evidence file names the exact current commit SHA (clean tree for the package path) and carries
4
+ // passing host run receipts from that same SHA: macOS and Windows, or macOS only for Swift.
5
+ // Format: docs/RIGHTKIT-SHARED-PACKAGES.md, "Publication gate" -> "Evidence file format".
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { spawnSync } from "./background-process.mjs";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ export const GATE_SCHEMA = 1;
12
+ export const REQUIRED_HOSTS = Object.freeze(["darwin", "win32"]);
13
+ // Swift packages (RightKitSwift) are Apple-only; Windows cannot run them.
14
+ export const requiredHosts = (kind) => (kind === "swift" ? Object.freeze(["darwin"]) : REQUIRED_HOSTS);
15
+ const SHA = /^[0-9a-f]{40}$/;
16
+
17
+ export function defaultEvidencePath(workspaceRoot, kind, name) {
18
+ return path.join(workspaceRoot, "release-evidence", "gate", `${kind}-${name.replace(/^@/, "").replace("/", "-")}.json`);
19
+ }
20
+
21
+ function defaultGit(args, cwd) {
22
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
23
+ if (result.error || result.status !== 0) return null;
24
+ return (result.stdout ?? "").trim();
25
+ }
26
+
27
+ // Returns { ok, missing[] }. Pure apart from the injected git/readFile.
28
+ export function checkPublicationGate({ kind, name, version, packagePath, evidencePath, git = defaultGit, readFile = (p) => fs.readFileSync(p, "utf8") }) {
29
+ const missing = [];
30
+ const head = git(["rev-parse", "HEAD"], packagePath);
31
+ if (!head || !SHA.test(head)) missing.push(`cannot read current commit SHA for ${packagePath}`);
32
+ const dirty = git(["status", "--porcelain", "--", "."], packagePath);
33
+ if (dirty === null) missing.push(`cannot read git status for ${packagePath}`);
34
+ else if (dirty !== "") missing.push(`tree is not clean for ${packagePath}`);
35
+ let evidence = null;
36
+ try { evidence = JSON.parse(readFile(evidencePath)); } catch {
37
+ missing.push(`gate evidence file missing or unreadable: ${evidencePath}`);
38
+ }
39
+ if (evidence) {
40
+ if (evidence.schema !== GATE_SCHEMA) missing.push(`evidence schema must be ${GATE_SCHEMA}`);
41
+ if (evidence.kind !== kind || evidence.name !== name) missing.push(`evidence is for ${evidence.kind}:${evidence.name}, expected ${kind}:${name}`);
42
+ if (version && evidence.version !== version) missing.push(`evidence version ${evidence.version} != ${version}`);
43
+ if (!SHA.test(String(evidence.commit ?? ""))) missing.push("evidence commit is not a full 40-character SHA");
44
+ else if (head && evidence.commit !== head) missing.push(`evidence commit ${evidence.commit} != current HEAD ${head}`);
45
+ const runs = Array.isArray(evidence.runs) ? evidence.runs : [];
46
+ for (const host of requiredHosts(kind)) {
47
+ const label = host === "darwin" ? "macOS" : "Windows";
48
+ const matching = runs.filter((run) => run?.host?.os === host);
49
+ if (!matching.length) { missing.push(`no ${label} host run receipt`); continue; }
50
+ const good = matching.some((run) => run.commit === evidence.commit
51
+ && Number.isInteger(run.passed) && run.passed > 0 && run.failed === 0
52
+ && typeof run.command === "string" && run.command.trim()
53
+ && typeof run.operator === "string" && run.operator.trim()
54
+ && typeof run.timestamp === "string" && run.timestamp.trim());
55
+ if (!good) missing.push(`${label} run receipt must name commit ${evidence.commit}, command, operator, timestamp, passed>0 and failed=0`);
56
+ }
57
+ if (!/^[0-9a-f]{64}$/.test(String(evidence.artifactSha256 ?? ""))) missing.push("evidence artifactSha256 must be a 64-hex hash");
58
+ }
59
+ return Object.freeze({ ok: missing.length === 0, missing });
60
+ }
61
+
62
+ // Enforce: throws on missing evidence unless dryRun (then reports and continues).
63
+ export function enforcePublicationGate(options, { dryRun = false, write = (line) => process.stdout.write(line) } = {}) {
64
+ const result = checkPublicationGate(options);
65
+ if (result.ok) { write(`[right-release] publication gate OK for ${options.kind}:${options.name}\n`); return result; }
66
+ const report = `publication gate not satisfied for ${options.kind}:${options.name}:\n - ${result.missing.join("\n - ")}`;
67
+ if (dryRun) { write(`[right-release] ${report}\n[right-release] (dry-run: a real publish would be refused)\n`); return result; }
68
+ throw new Error(report);
69
+ }
70
+
71
+ // `right-release publish npm --package <name> [--evidence <file>] [--dry-run]`: preflight only.
72
+ export function runNpmPreflight(argv, workspaceRoot = process.cwd()) {
73
+ let pkg; let evidence; const dryRun = argv.includes("--dry-run");
74
+ for (let i = 0; i < argv.length; i += 1) {
75
+ if (argv[i] === "--package") pkg = argv[++i];
76
+ else if (argv[i] === "--evidence") evidence = argv[++i];
77
+ else if (argv[i] === "--workspace") workspaceRoot = path.resolve(argv[++i]);
78
+ else if (argv[i] !== "--dry-run") throw new Error(`unknown publish npm option: ${argv[i]}`);
79
+ }
80
+ if (!pkg || !/^@rightkit\/[a-z0-9-]+$/.test(pkg)) throw new Error("publish npm requires --package @rightkit/<name>");
81
+ const packagePath = path.join(workspaceRoot, "packages", pkg.slice("@rightkit/".length));
82
+ const manifest = JSON.parse(fs.readFileSync(path.join(packagePath, "package.json"), "utf8"));
83
+ const options = { kind: "npm", name: pkg, version: manifest.version, packagePath, evidencePath: path.resolve(evidence ?? defaultEvidencePath(workspaceRoot, "npm", pkg)) };
84
+ enforcePublicationGate(options, { dryRun });
85
+ process.stdout.write("[right-release] npm preflight only: publish stays on the authenticated Windows primary checkout (pnpm publish)\n");
86
+ }
87
+
88
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
89
+ try { runNpmPreflight(process.argv.slice(2)); } catch (error) {
90
+ process.stderr.write(`right-release publish npm: ${error.message}\n`); process.exitCode = 1;
91
+ }
92
+ }
package/publish-cargo.mjs CHANGED
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "./background-process.mjs";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { enforcePublicationGate, defaultEvidencePath } from "./publication-gate.mjs";
6
7
 
7
8
  const FORBIDDEN_PACKAGE_PATH = /(^|\/)(?:\.env(?:\.[^/]*)?|target(?:\/.*)?|node_modules(?:\/.*)?|[^/]+\.(?:p8|p12|pem|key))$/i;
8
9
  const SECRET_PATTERNS = [
@@ -16,7 +17,7 @@ export function parseCargoPublishArgs(argv, cwd = process.cwd()) {
16
17
  const crate = valueAfter(argv, "--crate");
17
18
  if (!crate) throw new Error("publish cargo requires --crate <name>");
18
19
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(crate)) throw new Error(`invalid crate name: ${crate}`);
19
- const allowed = new Set(["--crate", "--workspace", "--dry-run", "--allow-dirty"]);
20
+ const allowed = new Set(["--crate", "--workspace", "--dry-run", "--allow-dirty", "--evidence"]);
20
21
  for (let index = 0; index < argv.length; index += 1) {
21
22
  const arg = argv[index];
22
23
  if (!arg.startsWith("--")) continue;
@@ -31,6 +32,7 @@ export function parseCargoPublishArgs(argv, cwd = process.cwd()) {
31
32
  dryRun,
32
33
  allowDirty,
33
34
  workspaceRoot: path.resolve(valueAfter(argv, "--workspace") ?? cwd),
35
+ evidence: valueAfter(argv, "--evidence"),
34
36
  };
35
37
  }
36
38
 
@@ -41,8 +43,8 @@ export function buildCargoPublishPlan({ crate, crateDir, dryRun, allowDirty = fa
41
43
  if (allowDirty) packageArgs.push("--allow-dirty");
42
44
  const plan = [
43
45
  { label: "secret scan", internal: "secret-scan", crateDir },
44
- { label: "format", command: "cargo", args: ["fmt", "--all", "--check"] },
45
- { label: "tests", command: "cargo", args: ["test", "-p", crate] },
46
+ { label: "format", command: "cargo", args: ["fmt", "-p", crate, "--check"] },
47
+ { label: "tests", command: "cargo", args: ["test", "-p", crate, "--", "--test-threads=1"] },
46
48
  { label: "clippy", command: "cargo", args: ["clippy", "-p", crate, "--all-targets", "--", "-D", "warnings"] },
47
49
  { label: "package contents", command: "cargo", args: packageArgs, capture: true },
48
50
  { label: "registry dry-run", command: "cargo", args: dryRunArgs },
@@ -78,6 +80,11 @@ export function runCargoPublish(options, execute = spawnSync) {
78
80
  const crateDir = path.join(options.workspaceRoot, "crates", options.crate);
79
81
  const manifest = path.join(crateDir, "Cargo.toml");
80
82
  if (!fs.existsSync(manifest)) throw new Error(`crate manifest not found: ${manifest}`);
83
+ const cargoVersion = fs.readFileSync(manifest, "utf8").match(/^version\s*=\s*"([^"]+)"/m)?.[1];
84
+ enforcePublicationGate({
85
+ kind: "cargo", name: options.crate, version: cargoVersion, packagePath: crateDir,
86
+ evidencePath: path.resolve(options.evidence ?? defaultEvidencePath(options.workspaceRoot, "cargo", options.crate)),
87
+ }, { dryRun: options.dryRun });
81
88
  const plan = buildCargoPublishPlan({ ...options, crateDir });
82
89
 
83
90
  for (const step of plan) {