@rightkit/release 0.2.51 → 0.2.53

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.
@@ -16,6 +16,10 @@ export function isolatedCargoMetadataEnv(cargoHome, env = process.env) {
16
16
  return { ...metadataEnv, CARGO_HOME: cargoHome };
17
17
  }
18
18
 
19
+ export function cargoExecutable(platform = process.platform) {
20
+ return platform === "win32" ? "cargo.exe" : "cargo";
21
+ }
22
+
19
23
  export function validateRightKitCargoContract(root, publishedVersions, label = path.basename(root)) {
20
24
  const scanRoot = path.resolve(root);
21
25
  const boundary = findRepositoryRoot(scanRoot);
@@ -72,7 +76,7 @@ export function assertNoRightKitCargoOverrides(manifestPath, repoRoot, label) {
72
76
 
73
77
  function readCargoManifestDependencies(manifestPath, cargoHome, label) {
74
78
  const result = spawnSync(
75
- "cargo",
79
+ cargoExecutable(),
76
80
  ["metadata", "--no-deps", "--offline", "--format-version", "1", "--manifest-path", manifestPath],
77
81
  {
78
82
  cwd: path.dirname(manifestPath),
package/cargo-guard.mjs CHANGED
@@ -1,8 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import os from "node:os";
3
6
  import path from "node:path";
4
7
  import { fileURLToPath } from "node:url";
5
8
 
9
+ import { resolveSharedCacheRoot } from "./cache-policy.mjs";
6
10
  import { runHeavyCommand } from "./heavy-command.mjs";
7
11
 
8
12
  const LIGHT_COMMANDS = new Set(["fmt", "metadata", "tree", "fetch", "search", "locate-project", "read-manifest", "help", "version", "--version", "-V"]);
@@ -24,6 +28,81 @@ export function shouldGuardCargo(args) {
24
28
  return command !== null && !LIGHT_COMMANDS.has(command);
25
29
  }
26
30
 
31
+ export function computePolicyPath({ env = process.env, platform = process.platform, home = os.userInfo().homedir } = {}) {
32
+ if (env.RIGHTSUITE_COMPUTE_POLICY) return path.resolve(env.RIGHTSUITE_COMPUTE_POLICY);
33
+ if (platform === "win32" || platform === "win") {
34
+ return path.win32.join(env.LOCALAPPDATA || path.win32.join(home, "AppData", "Local"), "RightSuite", "compute-policy.json");
35
+ }
36
+ if (platform === "darwin" || platform === "mac") return path.join(home, "Library", "Application Support", "RightSuite", "compute-policy.json");
37
+ return path.join(env.XDG_CONFIG_HOME || path.join(home, ".config"), "rightsuite", "compute-policy.json");
38
+ }
39
+
40
+ export function loadComputePolicy(options = {}) {
41
+ const policyPath = computePolicyPath(options);
42
+ if (!(options.exists ?? existsSync)(policyPath)) return { schemaVersion: 1, cargoTest: "allow" };
43
+ const policy = JSON.parse((options.read ?? readFileSync)(policyPath, "utf8"));
44
+ if (!policy || Object.keys(policy).sort().join(",") !== "cargoTest,schemaVersion"
45
+ || policy.schemaVersion !== 1 || !["allow", "deny"].includes(policy.cargoTest)) {
46
+ throw new Error(`invalid compute policy: ${policyPath}`);
47
+ }
48
+ return policy;
49
+ }
50
+
51
+ function shellSegments(command) {
52
+ const segments = [[]];
53
+ let word = "", quote = null, escaped = false;
54
+ const push = () => { if (word) segments.at(-1).push(word); word = ""; };
55
+ for (const char of String(command)) {
56
+ if (escaped) { word += char; escaped = false; continue; }
57
+ if (char === "\\" && quote !== "'") { escaped = true; continue; }
58
+ if (quote) { if (char === quote) quote = null; else word += char; continue; }
59
+ if (char === "'" || char === '"') { quote = char; continue; }
60
+ if (/\s/.test(char)) { push(); if (char === "\n" && segments.at(-1).length) segments.push([]); continue; }
61
+ if (";&|".includes(char)) { push(); if (segments.at(-1).length) segments.push([]); continue; }
62
+ word += char;
63
+ }
64
+ push();
65
+ return segments.filter((segment) => segment.length);
66
+ }
67
+
68
+ function segmentRunsCargoTest(segment) {
69
+ let index = 0;
70
+ while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(segment[index] ?? "")) index += 1;
71
+ if (path.basename(segment[index] ?? "").toLowerCase() === "env") {
72
+ index += 1;
73
+ while ((segment[index] ?? "").startsWith("-") || /^[A-Za-z_][A-Za-z0-9_]*=/.test(segment[index] ?? "")) index += 1;
74
+ }
75
+ const executable = path.basename(segment[index] ?? "").toLowerCase();
76
+ if (["cargo", "cargo.exe"].includes(executable)) return cargoSubcommand(segment.slice(index + 1)) === "test";
77
+ if (["rustup", "rustup.exe"].includes(executable) && segment[index + 1] === "run") {
78
+ const cargoIndex = segment.findIndex((value, offset) => offset > index + 1 && ["cargo", "cargo.exe"].includes(path.basename(value).toLowerCase()));
79
+ return cargoIndex >= 0 && cargoSubcommand(segment.slice(cargoIndex + 1)) === "test";
80
+ }
81
+ if (["sh", "bash", "zsh"].includes(executable)) {
82
+ const commandIndex = segment.findIndex((value, offset) => offset > index && value === "-c");
83
+ return commandIndex >= 0 && shellRunsCargoTest(segment[commandIndex + 1] ?? "");
84
+ }
85
+ return false;
86
+ }
87
+
88
+ export function shellRunsCargoTest(command) {
89
+ return shellSegments(command).some(segmentRunsCargoTest);
90
+ }
91
+
92
+ export function assertComputePolicyAllowsCargo(args, options = {}) {
93
+ if (cargoSubcommand(args) === "test" && loadComputePolicy(options).cargoTest === "deny") {
94
+ throw new Error("cargo test denied by global RightSuite compute policy");
95
+ }
96
+ }
97
+
98
+ export function checkHookInput(input, options = {}) {
99
+ const event = JSON.parse(String(input));
100
+ const command = event?.tool_input?.command ?? event?.toolInput?.command ?? "";
101
+ if (loadComputePolicy(options).cargoTest === "deny" && shellRunsCargoTest(command)) {
102
+ throw new Error("cargo test denied by global RightSuite compute policy");
103
+ }
104
+ }
105
+
27
106
  export function resolveRealCargo({ env = process.env, run = spawnSync } = {}) {
28
107
  if (env.RIGHTSUITE_REAL_CARGO) return path.resolve(env.RIGHTSUITE_REAL_CARGO);
29
108
  const rustup = process.platform === "win32" ? "rustup.exe" : "rustup";
@@ -33,6 +112,79 @@ export function resolveRealCargo({ env = process.env, run = spawnSync } = {}) {
33
112
  return path.resolve(cargo);
34
113
  }
35
114
 
115
+ export function resolveRealRustc({ env = process.env, run = spawnSync } = {}) {
116
+ if (env.RIGHTSUITE_REAL_RUSTC) return path.resolve(env.RIGHTSUITE_REAL_RUSTC);
117
+ const rustup = process.platform === "win32" ? "rustup.exe" : "rustup";
118
+ const result = run(rustup, ["which", "rustc"], { encoding: "utf8", windowsHide: true });
119
+ const rustc = String(result.stdout ?? "").trim();
120
+ if (result.status !== 0 || !rustc) throw new Error(`cargo-guard could not resolve real rustc: ${String(result.stderr ?? "").trim()}`);
121
+ return path.resolve(rustc);
122
+ }
123
+
124
+ function pathApi(platform) {
125
+ return platform === "win32" || platform === "win" ? path.win32 : path.posix;
126
+ }
127
+
128
+ export function cargoProjectRoot(args, { cwd = process.cwd(), platform = process.platform, exists = existsSync } = {}) {
129
+ const paths = pathApi(platform);
130
+ const manifestIndex = args.findIndex((value) => value === "--manifest-path");
131
+ const manifestValue = args.find((value) => value.startsWith("--manifest-path="))?.slice("--manifest-path=".length)
132
+ ?? (manifestIndex >= 0 ? args[manifestIndex + 1] : null);
133
+ let current = paths.resolve(manifestValue ? paths.dirname(paths.resolve(cwd, manifestValue)) : cwd);
134
+ let selected = null;
135
+ for (;;) {
136
+ if (exists(paths.join(current, "Cargo.toml"))) selected = current;
137
+ const parent = paths.dirname(current);
138
+ if (parent === current) break;
139
+ current = parent;
140
+ }
141
+ return selected ?? paths.resolve(cwd);
142
+ }
143
+
144
+ function assertInsideCache(candidate, cacheRoot, label, { cwd, platform }) {
145
+ const paths = pathApi(platform);
146
+ const resolved = paths.resolve(cwd, candidate);
147
+ const relative = paths.relative(paths.resolve(cacheRoot), resolved);
148
+ if (relative === ".." || relative.startsWith(`..${paths.sep}`) || paths.isAbsolute(relative)) {
149
+ throw new Error(`${label} must stay inside shared cache root ${cacheRoot}; got ${candidate}`);
150
+ }
151
+ return resolved;
152
+ }
153
+
154
+ export function cargoCacheEnvironment(args, {
155
+ env = process.env, cwd = process.cwd(), platform = process.platform, exists = existsSync,
156
+ } = {}) {
157
+ const paths = pathApi(platform);
158
+ const cacheRoot = resolveSharedCacheRoot({ platform, env });
159
+ const projectRoot = cargoProjectRoot(args, { cwd, platform, exists });
160
+ const projectName = paths.basename(projectRoot).replace(/[^A-Za-z0-9._-]+/g, "-") || "cargo";
161
+ const projectHash = createHash("sha256").update(platform === "win32" || platform === "win" ? projectRoot.toLowerCase() : projectRoot).digest("hex").slice(0, 12);
162
+ const defaultTarget = paths.join(cacheRoot, "dev-targets", `${projectName}-${projectHash}`);
163
+ const defaultSccache = paths.join(cacheRoot, "sccache");
164
+ const targetOption = args.find((value) => value.startsWith("--target-dir="));
165
+ const targetIndex = args.findIndex((value) => value === "--target-dir");
166
+ const explicitTarget = targetOption?.slice("--target-dir=".length) ?? (targetIndex >= 0 ? args[targetIndex + 1] : null);
167
+ if (explicitTarget) assertInsideCache(explicitTarget, cacheRoot, "--target-dir", { cwd, platform });
168
+ const targetDir = env.CARGO_TARGET_DIR
169
+ ? assertInsideCache(env.CARGO_TARGET_DIR, cacheRoot, "CARGO_TARGET_DIR", { cwd, platform })
170
+ : defaultTarget;
171
+ const sccacheDir = env.SCCACHE_DIR
172
+ ? assertInsideCache(env.SCCACHE_DIR, cacheRoot, "SCCACHE_DIR", { cwd, platform })
173
+ : defaultSccache;
174
+ const wrapper = String(env.RUSTC_WRAPPER ?? "sccache");
175
+ if (!["sccache", "sccache.exe"].includes(paths.basename(wrapper).toLowerCase())) {
176
+ throw new Error(`RUSTC_WRAPPER must be sccache; got ${wrapper || "<empty>"}`);
177
+ }
178
+ return {
179
+ ...env,
180
+ CARGO_TARGET_DIR: targetDir,
181
+ RUSTC_WRAPPER: env.RUSTC_WRAPPER || "sccache",
182
+ SCCACHE_DIR: sccacheDir,
183
+ SCCACHE_CACHE_SIZE: env.SCCACHE_CACHE_SIZE || "32G",
184
+ RIGHTSUITE_CARGO_CACHE_GUARD: "1",
185
+ };
186
+ }
187
+
36
188
  function spawnCargo(command, args, env) {
37
189
  return new Promise((resolve, reject) => {
38
190
  const child = spawn(command, args, { env, stdio: "inherit", windowsHide: true });
@@ -41,12 +193,27 @@ function spawnCargo(command, args, env) {
41
193
  });
42
194
  }
43
195
 
44
- export async function runCargoGuard(args, { env = process.env, resolveCargo = resolveRealCargo, runHeavy = runHeavyCommand, runLight = spawnCargo } = {}) {
196
+ export async function runCargoGuard(args, { env = process.env, policyOptions = {}, resolveCargo = resolveRealCargo, resolveRustc = resolveRealRustc, runHeavy = runHeavyCommand, runLight = spawnCargo } = {}) {
197
+ assertComputePolicyAllowsCargo(args, { ...policyOptions, env });
45
198
  const cargo = resolveCargo({ env });
46
- if (shouldGuardCargo(args)) return runHeavy(["--", cargo, ...args], { env });
199
+ if (shouldGuardCargo(args)) {
200
+ const guarded = cargoCacheEnvironment(args, { env });
201
+ // Pin RUSTC on every platform, not just Windows: cargo otherwise resolves
202
+ // bare `rustc` from its child PATH, and on this Mac that reached a stale
203
+ // 1.78.0 shim while cargo 1.97 passed --check-cfg — failing any crate
204
+ // build with "the `-Z unstable-options` flag must also be passed".
205
+ if (!guarded.RUSTC) guarded.RUSTC = resolveRustc({ env });
206
+ return runHeavy(["--", cargo, ...args], { env: guarded });
207
+ }
47
208
  return runLight(cargo, args, env);
48
209
  }
49
210
 
50
211
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
51
- runCargoGuard(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => { console.error(`cargo-guard: ${error.message}`); process.exitCode = 1; });
212
+ const args = process.argv.slice(2);
213
+ if (args[0] === "--check-hook-base64") {
214
+ try { checkHookInput(Buffer.from(args[1] ?? "", "base64url").toString("utf8")); }
215
+ catch (error) { console.error(`cargo-guard: ${error.message}`); process.exitCode = 2; }
216
+ } else {
217
+ runCargoGuard(args).then((code) => { process.exitCode = code; }).catch((error) => { console.error(`cargo-guard: ${error.message}`); process.exitCode = 1; });
218
+ }
52
219
  }
@@ -1,7 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
+ import path from "node:path";
2
3
  import test from "node:test";
3
4
 
4
- import { cargoSubcommand, resolveRealCargo, runCargoGuard, shouldGuardCargo } from "./cargo-guard.mjs";
5
+ import { assertComputePolicyAllowsCargo, cargoCacheEnvironment, cargoProjectRoot, cargoSubcommand, checkHookInput, resolveRealCargo, resolveRealRustc, runCargoGuard, shellRunsCargoTest, shouldGuardCargo } from "./cargo-guard.mjs";
5
6
 
6
7
  test("Cargo guard serializes compiling commands but bypasses inspection and formatting", () => {
7
8
  for (const args of [["build"], ["test"], ["check"], ["clippy"], ["nextest", "run"], ["clean"], ["+stable", "bench"]]) {
@@ -16,24 +17,114 @@ test("Cargo guard serializes compiling commands but bypasses inspection and form
16
17
  assert.equal(shouldGuardCargo(["-Z", "unstable-options", "fmt"]), false);
17
18
  });
18
19
 
20
+ test("global policy denies Cargo test before Cargo resolution", async () => {
21
+ const policy = { exists: () => true, read: () => '{"schemaVersion":1,"cargoTest":"deny"}', env: {} };
22
+ assert.throws(() => assertComputePolicyAllowsCargo(["test", "-p", "app"], policy), /denied by global/);
23
+ assert.doesNotThrow(() => assertComputePolicyAllowsCargo(["check"], policy));
24
+ let resolved = false;
25
+ await assert.rejects(runCargoGuard(["test"], {
26
+ env: { RIGHTSUITE_COMPUTE_POLICY: "/policy.json" },
27
+ policyOptions: { exists: () => true, read: () => '{"schemaVersion":1,"cargoTest":"deny"}' },
28
+ resolveCargo: () => { resolved = true; return "/real/cargo"; },
29
+ }), /denied by global/);
30
+ assert.equal(resolved, false);
31
+ });
32
+
33
+ test("Bash policy detects direct, absolute, rustup and nested Cargo tests only", () => {
34
+ for (const command of ["cargo test -p app", "cd app && /toolchain/bin/cargo +stable test", "rustup run stable cargo test", "bash -c 'cargo test'"]) {
35
+ assert.equal(shellRunsCargoTest(command), true, command);
36
+ }
37
+ for (const command of ["cargo check", "rg 'cargo test' docs", "echo cargo test", "cargo fmt --check"]) {
38
+ assert.equal(shellRunsCargoTest(command), false, command);
39
+ }
40
+ assert.throws(() => checkHookInput(JSON.stringify({ tool_input: { command: "cargo test" } }), {
41
+ exists: () => true, read: () => '{"schemaVersion":1,"cargoTest":"deny"}', env: {},
42
+ }), /denied by global/);
43
+ });
44
+
19
45
  test("Cargo guard resolves real Cargo through rustup or explicit override", () => {
20
- assert.equal(resolveRealCargo({ env: { RIGHTSUITE_REAL_CARGO: "/opt/toolchain/cargo" } }), "/opt/toolchain/cargo");
46
+ assert.equal(resolveRealCargo({ env: { RIGHTSUITE_REAL_CARGO: "/opt/toolchain/cargo" } }), path.resolve("/opt/toolchain/cargo"));
21
47
  const calls = [];
22
48
  const cargo = resolveRealCargo({ env: {}, run: (command, args) => { calls.push([command, args]); return { status: 0, stdout: "/real/cargo\n" }; } });
23
- assert.equal(cargo, "/real/cargo");
49
+ assert.equal(cargo, path.resolve("/real/cargo"));
24
50
  assert.deepEqual(calls[0][1], ["which", "cargo"]);
51
+ assert.equal(resolveRealRustc({ env: { RIGHTSUITE_REAL_RUSTC: "/opt/toolchain/rustc" } }), "/opt/toolchain/rustc");
52
+ assert.equal(resolveRealRustc({ env: {}, run: () => ({ status: 0, stdout: "/real/rustc\n" }) }), "/real/rustc");
53
+ });
54
+
55
+ function cacheEnv(overrides = {}) {
56
+ return { ...(process.platform === "win32" ? { LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local" } : { RIGHT_RELEASE_CACHE_ROOT: "/tmp/rightkit-release-cache" }), ...overrides };
57
+ }
58
+
59
+ test("Cargo guard injects project-keyed target and sccache directories", async () => {
60
+ let guarded;
61
+ await runCargoGuard(["build"], {
62
+ env: cacheEnv(), resolveCargo: () => "/real/cargo", resolveRustc: () => "/real/rustc",
63
+ runHeavy: async (_args, config) => { guarded = config.env; return 0; },
64
+ });
65
+ assert.match(guarded.CARGO_TARGET_DIR, /dev-targets[/\\][^/\\]+-[a-f0-9]{12}$/);
66
+ assert.match(guarded.SCCACHE_DIR, /[/\\]sccache$/);
67
+ assert.equal(guarded.RUSTC_WRAPPER, "sccache");
68
+ });
69
+
70
+ test("Cargo guard rejects cache escapes and alternate compiler wrappers", async () => {
71
+ const run = (args, overrides) => runCargoGuard(args, {
72
+ env: cacheEnv(overrides), resolveCargo: () => "/real/cargo", resolveRustc: () => "/real/rustc",
73
+ runHeavy: async () => 0,
74
+ });
75
+ await assert.rejects(run(["build"], { CARGO_TARGET_DIR: path.resolve("outside-target") }), /CARGO_TARGET_DIR/);
76
+ await assert.rejects(run(["build"], { SCCACHE_DIR: path.resolve("outside-sccache") }), /SCCACHE_DIR/);
77
+ await assert.rejects(run(["build", `--target-dir=${path.resolve("outside-flag")}`]), /--target-dir/);
78
+ await assert.rejects(run(["build"], { RUSTC_WRAPPER: "rustc-wrapper" }), /RUSTC_WRAPPER/);
25
79
  });
26
80
 
27
81
  test("Cargo guard routes heavy and light commands without recursion", async () => {
28
82
  const calls = [];
29
83
  const options = {
30
- env: { TEST: "1" },
84
+ env: { TEST: "1", RIGHT_RELEASE_CACHE_ROOT: "/cache" },
85
+ policyOptions: { exists: () => false },
31
86
  resolveCargo: () => "/real/cargo",
32
87
  runHeavy: async (args, config) => { calls.push(["heavy", args, config.env]); return 0; },
33
88
  runLight: async (command, args, env) => { calls.push(["light", command, args, env]); return 0; },
34
89
  };
35
90
  assert.equal(await runCargoGuard(["test"], options), 0);
36
91
  assert.equal(await runCargoGuard(["fmt", "--check"], options), 0);
37
- assert.deepEqual(calls[0], ["heavy", ["--", "/real/cargo", "test"], options.env]);
92
+ assert.deepEqual(calls[0].slice(0, 2), ["heavy", ["--", "/real/cargo", "test"]]);
93
+ assert.equal(calls[0][2].CARGO_TARGET_DIR.startsWith("/cache/dev-targets/"), true);
94
+ assert.equal(calls[0][2].RUSTC_WRAPPER, "sccache");
38
95
  assert.deepEqual(calls[1], ["light", "/real/cargo", ["fmt", "--check"], options.env]);
39
96
  });
97
+
98
+ test("Cargo guard forces target and compiler caches for each project", () => {
99
+ const existing = new Set(["/repo/Cargo.toml", "/repo/crate/Cargo.toml"]);
100
+ assert.equal(cargoProjectRoot(["test"], { cwd: "/repo/crate", platform: "mac", exists: (file) => existing.has(file) }), "/repo");
101
+ const env = cargoCacheEnvironment(["test"], {
102
+ cwd: "/repo/crate", platform: "mac", exists: (file) => existing.has(file),
103
+ env: { RIGHT_RELEASE_CACHE_ROOT: "/cache" },
104
+ });
105
+ assert.match(env.CARGO_TARGET_DIR, /^\/cache\/dev-targets\/repo-[a-f0-9]{12}$/);
106
+ assert.equal(env.RUSTC_WRAPPER, "sccache");
107
+ assert.equal(env.SCCACHE_DIR, "/cache/sccache");
108
+ assert.equal(env.RIGHTSUITE_CARGO_CACHE_GUARD, "1");
109
+ });
110
+
111
+ test("Cargo guard rejects cache bypasses and permits RightKit-owned cache paths", () => {
112
+ const base = { cwd: "/repo", platform: "mac", exists: () => false };
113
+ assert.throws(() => cargoCacheEnvironment(["test"], { ...base, env: { RIGHT_RELEASE_CACHE_ROOT: "/cache", CARGO_TARGET_DIR: "/tmp/target" } }), /CARGO_TARGET_DIR must stay inside/);
114
+ assert.throws(() => cargoCacheEnvironment(["test", "--target-dir", "/tmp/target"], { ...base, env: { RIGHT_RELEASE_CACHE_ROOT: "/cache" } }), /--target-dir must stay inside/);
115
+ assert.throws(() => cargoCacheEnvironment(["test"], { ...base, env: { RIGHT_RELEASE_CACHE_ROOT: "/cache", RUSTC_WRAPPER: "rustc-wrapper" } }), /must be sccache/);
116
+ const env = cargoCacheEnvironment(["test", "--target-dir=/cache/test-targets/app"], {
117
+ ...base,
118
+ env: { RIGHT_RELEASE_CACHE_ROOT: "/cache", CARGO_TARGET_DIR: "/cache/test-targets/app", SCCACHE_DIR: "/cache/sccache", RUSTC_WRAPPER: "/usr/bin/sccache" },
119
+ });
120
+ assert.equal(env.CARGO_TARGET_DIR, "/cache/test-targets/app");
121
+ });
122
+
123
+ test("Cargo guard derives native Windows cache paths", () => {
124
+ const env = cargoCacheEnvironment(["build"], {
125
+ cwd: "D:\\Claude\\citadel", platform: "win", exists: (file) => file === "D:\\Claude\\citadel\\Cargo.toml",
126
+ env: { LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local" },
127
+ });
128
+ assert.match(env.CARGO_TARGET_DIR, /^C:\\Users\\test\\AppData\\Local\\RightSuite\\Cache\\release\\dev-targets\\citadel-[a-f0-9]{12}$/);
129
+ assert.equal(env.SCCACHE_DIR, "C:\\Users\\test\\AppData\\Local\\RightSuite\\Cache\\release\\sccache");
130
+ });
@@ -19,6 +19,7 @@ const commands = new Map([
19
19
  ["sign-updater", "sign-updater.mjs"],
20
20
  ["create-mac-updater", "create-mac-updater.mjs"],
21
21
  ["mirror-root-artifact", "mirror-root-artifact.mjs"],
22
+ ["github", "github-release.mjs"],
22
23
  ]);
23
24
 
24
25
  const args = process.argv.slice(2);
@@ -145,6 +146,8 @@ Commands:
145
146
  generate-dmg-background <options> Generate the branded multi-resolution DMG background
146
147
  mirror-root-artifact --file <path> --package-root <dir>
147
148
  Persist a worktree artifact in the primary repo root
149
+ github --release <id> --platform mac|win --repo owner/repo [--dry-run]
150
+ Attach one sealed installer to a verified GitHub Release
148
151
 
149
152
  Direct flags are treated as: right-release build <flags>.
150
153
  Build is tier-neutral. Upload requires an explicit tier. Unsigned/local smoke builds stay app-local.`);
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from "node:crypto";
3
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
6
+ import { fileURLToPath } from "node:url";
7
+ import { verifySealedRelease } from "./release-state.mjs";
8
+
9
+ const REPO_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
10
+ const RELEASE_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/;
11
+
12
+ export function prepareGitHubRelease({ repoRoot, releaseId, platform, repo, stateRoot }) {
13
+ if (!RELEASE_RE.test(releaseId)) throw new Error(`invalid release id: ${releaseId}`);
14
+ if (platform !== "mac" && platform !== "win") throw new Error("platform must be mac or win");
15
+ if (!REPO_RE.test(repo)) throw new Error(`invalid GitHub repository: ${repo}`);
16
+ const platformDir = platform === "win" ? "windows" : "mac";
17
+ const sealed = verifySealedRelease(path.join(repoRoot, ".right-release", "sealed", releaseId, platformDir));
18
+ if (sealed.manifest.platform !== platform) throw new Error("sealed release platform mismatch");
19
+ const installers = sealed.manifest.files.filter((file) => file.role === "installer");
20
+ if (installers.length !== 1) throw new Error(`sealed release must contain exactly one installer; found ${installers.length}`);
21
+ const releaseState = stateRoot ?? path.join(repoRoot, ".right-release", "state", releaseId, platformDir, "github");
22
+ mkdirSync(releaseState, { recursive: true });
23
+ const manifestAsset = path.join(releaseState, `${sealed.manifest.app}-${sealed.manifest.version}-${platform}-release-manifest.json`);
24
+ const checksumsAsset = path.join(releaseState, `${sealed.manifest.app}-${sealed.manifest.version}-${platform}-SHA256SUMS.txt`);
25
+ copyFileSync(sealed.manifestPath, manifestAsset);
26
+ writeFileSync(checksumsAsset, `${installers[0].sha256} ${installers[0].name}\n`);
27
+ const installer = path.join(sealed.sealedDir, installers[0].name);
28
+ return {
29
+ sealed,
30
+ installer,
31
+ assets: [installer, manifestAsset, checksumsAsset],
32
+ tag: `v${sealed.manifest.version}`,
33
+ title: `${sealed.manifest.app === "cutright" ? "CutRight Studio" : sealed.manifest.app} ${sealed.manifest.version}`,
34
+ notes: [
35
+ `Signed ${platform === "mac" ? "& notarized universal macOS" : "Windows"} installer.`,
36
+ "",
37
+ `Build commit: \`${sealed.manifest.commit}\``,
38
+ `SHA-256: \`${installers[0].sha256}\``,
39
+ ].join("\n"),
40
+ };
41
+ }
42
+
43
+ export function publishGitHubRelease(plan, { repo, dryRun = false, run = runCommand } = {}) {
44
+ const visibility = JSON.parse(run("gh", ["repo", "view", repo, "--json", "visibility"]));
45
+ if (visibility.visibility !== "PUBLIC") throw new Error(`GitHub releases require a public repository: ${repo}`);
46
+ verifyPlatformTrust(plan);
47
+ const existing = run("gh", ["release", "view", plan.tag, "--repo", repo, "--json", "tagName"], { allowFailure: true });
48
+ if (dryRun) return { status: existing.ok ? "would-update" : "would-create", tag: plan.tag, assets: plan.assets };
49
+ if (!existing.ok) {
50
+ const notesFile = `${plan.assets[1]}.notes.md`;
51
+ writeFileSync(notesFile, `${plan.notes}\n`);
52
+ run("gh", ["release", "create", plan.tag, "--repo", repo, "--title", plan.title, "--notes-file", notesFile]);
53
+ rmSync(notesFile, { force: true });
54
+ }
55
+ run("gh", ["release", "upload", plan.tag, ...plan.assets, "--repo", repo, "--clobber"]);
56
+ for (const asset of plan.assets) verifyRemoteAsset(plan.tag, repo, asset, run);
57
+ return { status: "verified", tag: plan.tag, assets: plan.assets };
58
+ }
59
+
60
+ function verifyPlatformTrust(plan) {
61
+ if (plan.sealed.manifest.platform !== "mac") return;
62
+ runCommand("spctl", ["--assess", "--type", "open", "--context", "context:primary-signature", "--verbose=2", plan.installer]);
63
+ runCommand("xcrun", ["stapler", "validate", plan.installer]);
64
+ }
65
+
66
+ function verifyRemoteAsset(tag, repo, asset, run) {
67
+ const verifyDir = `${path.dirname(asset)}/verify-${path.basename(asset)}`;
68
+ rmSync(verifyDir, { recursive: true, force: true });
69
+ mkdirSync(verifyDir, { recursive: true });
70
+ run("gh", ["release", "download", tag, "--repo", repo, "--pattern", path.basename(asset), "--dir", verifyDir]);
71
+ const downloaded = path.join(verifyDir, path.basename(asset));
72
+ if (!existsSync(downloaded) || sha256(downloaded) !== sha256(asset)) throw new Error(`GitHub release asset hash mismatch: ${path.basename(asset)}`);
73
+ rmSync(verifyDir, { recursive: true, force: true });
74
+ }
75
+
76
+ function sha256(file) {
77
+ return createHash("sha256").update(readFileSync(file)).digest("hex");
78
+ }
79
+
80
+ function runCommand(command, args, { allowFailure = false } = {}) {
81
+ const result = spawnSync(command, args, { encoding: "utf8", windowsHide: true });
82
+ const output = result.stdout?.trim() ?? "";
83
+ if (result.status !== 0) {
84
+ if (allowFailure) return { ok: false, output, error: result.stderr?.trim() ?? "" };
85
+ throw new Error(`${command} ${args.join(" ")} failed: ${result.stderr?.trim() || `exit ${result.status}`}`);
86
+ }
87
+ return allowFailure ? { ok: true, output } : output;
88
+ }
89
+
90
+ function usage(code) {
91
+ console.log("usage: right-release github --release <sealed-id> --platform mac|win --repo owner/repo [--dry-run]");
92
+ process.exit(code);
93
+ }
94
+
95
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
96
+ const args = process.argv.slice(2);
97
+ const options = { platform: process.platform === "win32" ? "win" : "mac", releaseId: "", repo: "", dryRun: false };
98
+ for (let index = 0; index < args.length; index += 1) {
99
+ if (args[index] === "--platform") options.platform = args[++index];
100
+ else if (args[index] === "--release") options.releaseId = args[++index];
101
+ else if (args[index] === "--repo") options.repo = args[++index];
102
+ else if (args[index] === "--dry-run") options.dryRun = true;
103
+ else if (args[index] === "-h" || args[index] === "--help") usage(0);
104
+ else throw new Error(`unknown argument: ${args[index]}`);
105
+ }
106
+ if (!options.releaseId || !options.repo) usage(2);
107
+ const repoRoot = runCommand("git", ["rev-parse", "--show-toplevel"]);
108
+ const plan = prepareGitHubRelease({ repoRoot, ...options });
109
+ const result = publishGitHubRelease(plan, options);
110
+ console.log(`right-release github: ${result.status} ${result.tag}`);
111
+ }
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+ import { prepareGitHubRelease, publishGitHubRelease } from "./github-release.mjs";
8
+
9
+ const sha256 = (value) => createHash("sha256").update(value).digest("hex");
10
+
11
+ function fixture() {
12
+ const root = mkdtempSync(path.join(os.tmpdir(), "right-release-github-"));
13
+ const releaseId = "cutright-1.2.3-abcdef12";
14
+ const sealedDir = path.join(root, ".right-release", "sealed", releaseId, "windows");
15
+ mkdirSync(sealedDir, { recursive: true });
16
+ writeFileSync(path.join(sealedDir, "CutRight.exe"), "signed-installer");
17
+ writeFileSync(path.join(sealedDir, "release-manifest.json"), `${JSON.stringify({
18
+ schema: 1, releaseId, app: "cutright", version: "1.2.3", commit: "abcdef1234567890", platform: "win",
19
+ files: [{ role: "installer", name: "CutRight.exe", sha256: sha256("signed-installer"), sizeBytes: 16 }],
20
+ checkpoints: ["sealed"],
21
+ })}\n`);
22
+ return { root, releaseId };
23
+ }
24
+
25
+ test("GitHub plan derives tag, notes, installer, manifest & checksums only from sealed bytes", () => {
26
+ const fx = fixture();
27
+ const plan = prepareGitHubRelease({ repoRoot: fx.root, releaseId: fx.releaseId, platform: "win", repo: "Orthic-Labs/CutRight" });
28
+ assert.equal(plan.tag, "v1.2.3");
29
+ assert.equal(plan.assets.length, 3);
30
+ assert.match(plan.notes, /abcdef1234567890/);
31
+ assert.equal(readFileSync(plan.assets[2], "utf8"), `${sha256("signed-installer")} CutRight.exe\n`);
32
+ });
33
+
34
+ test("dry-run checks public visibility without creating or uploading a release", () => {
35
+ const fx = fixture();
36
+ const plan = prepareGitHubRelease({ repoRoot: fx.root, releaseId: fx.releaseId, platform: "win", repo: "Orthic-Labs/CutRight" });
37
+ const calls = [];
38
+ const run = (_command, args, options = {}) => {
39
+ calls.push(args);
40
+ if (args[0] === "repo") return JSON.stringify({ visibility: "PUBLIC" });
41
+ if (args[0] === "release" && args[1] === "view") return options.allowFailure ? { ok: false } : "";
42
+ throw new Error(`unexpected mutation: ${args.join(" ")}`);
43
+ };
44
+ assert.equal(publishGitHubRelease(plan, { repo: "Orthic-Labs/CutRight", dryRun: true, run }).status, "would-create");
45
+ assert.equal(calls.length, 2);
46
+ });
package/heavy-command.mjs CHANGED
@@ -15,8 +15,8 @@ export function heavyWorkRoot({ platform = process.platform, env = process.env,
15
15
  if (!env.LOCALAPPDATA) throw new Error("LOCALAPPDATA is required for the RightSuite heavy-work guard");
16
16
  return path.win32.resolve(env.LOCALAPPDATA, "RightSuite", "heavy-work");
17
17
  }
18
- if (platform === "darwin" || platform === "mac") return path.join(home, "Library", "Caches", "RightSuite", "heavy-work");
19
- return path.resolve(env.XDG_CACHE_HOME || path.join(home, ".cache"), "rightsuite", "heavy-work");
18
+ if (platform === "darwin" || platform === "mac") return path.posix.join(home, "Library", "Caches", "RightSuite", "heavy-work");
19
+ return path.posix.resolve(env.XDG_CACHE_HOME || path.posix.join(home, ".cache"), "rightsuite", "heavy-work");
20
20
  }
21
21
 
22
22
  export function heavyCommandEnvironment(env = process.env) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.51",
3
+ "version": "0.2.53",
4
4
  "description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts, patch/update routing, hardening, R2 publish, and RightApps registration.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,6 +17,8 @@ import {
17
17
  verifySealedRelease,
18
18
  } from "./release-state.mjs";
19
19
 
20
+ const expectedPnpmVersion = JSON.parse(readFileSync(new URL("./rightkit-versions.json", import.meta.url), "utf8")).packageManager.replace(/^pnpm@/, "");
21
+
20
22
  test("progress watcher observes writes in nested Cargo target directories", async () => {
21
23
  const root = mkdtempSync(path.join(os.tmpdir(), "right-release-watch-"));
22
24
  const nested = path.join(root, "release", "build", "openssl");
@@ -36,7 +38,7 @@ test("progress watcher observes writes in nested Cargo target directories", asyn
36
38
  });
37
39
 
38
40
  test("portable command capture resolves Windows command shims", { skip: process.platform !== "win32" }, () => {
39
- assert.match(commandOutputPortable("pnpm", ["--version"]), /^11\.12\.0$/);
41
+ assert.equal(commandOutputPortable("pnpm", ["--version"]), expectedPnpmVersion);
40
42
  });
41
43
 
42
44
  test("nested app configs keep the vault at repo root and build from the app root", () => {
@@ -8,6 +8,7 @@ import test, { after } from "node:test";
8
8
  import {
9
9
  assertNoRightKitCargoOverrides,
10
10
  assertPublishedRightKitCargoDependencies,
11
+ cargoExecutable,
11
12
  validateRightKitCargoContract,
12
13
  } from "./cargo-contract.mjs";
13
14
  import { assertAsrAdapterPair } from "./asr-artifact-adoption.mjs";
@@ -449,10 +450,10 @@ test("RightKit exposes one current version manifest", () => {
449
450
  "@rightkit/legal": "0.3.0",
450
451
  "@rightkit/legal-ui": "0.1.0",
451
452
  "@rightkit/license": "0.1.6",
452
- "@rightkit/release": "0.2.51",
453
+ "@rightkit/release": "0.2.52",
453
454
  });
454
455
  assert.deepEqual(versions.legacyNpm, {
455
- "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50"],
456
+ "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50", "0.2.51"],
456
457
  });
457
458
  assert.ok(
458
459
  new Set([versions.npm["@rightkit/release"], ...versions.legacyNpm["@rightkit/release"]]).has("0.2.42"),
@@ -473,6 +474,11 @@ test("RightKit exposes one current version manifest", () => {
473
474
  getCurrentCargoVersionContract();
474
475
  });
475
476
 
477
+ test("Cargo metadata uses the native Windows executable", () => {
478
+ assert.equal(cargoExecutable("win32"), "cargo.exe");
479
+ assert.equal(cargoExecutable("darwin"), "cargo");
480
+ });
481
+
476
482
  test("license v2 public vector is identical at every portable consumer boundary", () => {
477
483
  const canonical = readFileSync(
478
484
  path.join(workspace, "tools/rightkit/crates/rightkit-license/test-vectors/license-v2.json"),
@@ -635,12 +641,21 @@ for (const app of macOnlyApps) {
635
641
  cmd: "right-release",
636
642
  args: ["publish-update", "--config", "right-release.config.mjs", "--platform", "mac"],
637
643
  });
638
- assert.equal(config.targets.mac.updater.artifacts.length, 1);
639
- const updater = config.targets.mac.updater.artifacts[0];
640
- assert.equal(updater.platform, "darwin-aarch64");
644
+ const updaters = config.targets.mac.updater.artifacts;
645
+ assert.deepEqual(
646
+ updaters.map(({ platform }) => platform).sort(),
647
+ ["darwin-aarch64", "darwin-x86_64"],
648
+ `${app.key} universal DMG must serve both Mac architectures`,
649
+ );
650
+ const updater = updaters[0];
641
651
  assert.match(updater.file, /\.dmg$/);
642
652
  assert.equal(updater.signature, `${updater.file}.sig`);
643
653
  assert.equal(updater.key, `${app.key}/updates/mac/current/ScreenRight.dmg`);
654
+ for (const candidate of updaters) {
655
+ assert.equal(candidate.file, updater.file, `${app.key} updater rows must share one universal DMG`);
656
+ assert.equal(candidate.signature, updater.signature, `${app.key} updater rows must share one signature`);
657
+ assert.equal(candidate.key, updater.key, `${app.key} updater rows must share one current R2 object`);
658
+ }
644
659
  assertBuildInputs(config, config.targets.mac, `${app.key} mac`);
645
660
  assertMacPackageEntry(config.targets.mac.package, pkg.scripts, app.key);
646
661
  assert.ok(config.targets.mac.installer.artifacts.length >= 1);
@@ -2,6 +2,7 @@
2
2
  "schema": 1,
3
3
  "packageManager": "pnpm@11.18.0",
4
4
  "npm": {
5
+ "@rightkit/git": "0.2.0",
5
6
  "@rightkit/legal": "0.2.0",
6
7
  "@rightkit/license": "0.1.5",
7
8
  "@rightkit/logs": "0.1.3",
@@ -15,10 +16,10 @@
15
16
  "@rightkit/legal": "0.3.0",
16
17
  "@rightkit/legal-ui": "0.1.0",
17
18
  "@rightkit/license": "0.1.6",
18
- "@rightkit/release": "0.2.51"
19
+ "@rightkit/release": "0.2.53"
19
20
  },
20
21
  "legacyNpm": {
21
- "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50"]
22
+ "@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50", "0.2.51"]
22
23
  },
23
24
  "cargo": {
24
25
  "rightkit-license": "0.1.2",