@rightkit/release 0.2.52 → 0.2.54

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/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.52",
3
+ "version": "0.2.54",
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", () => {
@@ -64,7 +64,13 @@ function assertReleasePackageScripts(scripts, label) {
64
64
 
65
65
  function assertMacPackageEntry(packageCommand, scripts, label) {
66
66
  assert.equal(packageCommand.cmd, "pnpm", `${label} must use the shared pnpm package entry point`);
67
- assert.deepEqual(packageCommand.args, ["run", "rightkit:package:mac"], `${label} R2 release must use the dedicated signed and notarized macOS package entry point`);
67
+ assert.ok(
68
+ [
69
+ ["run", "rightkit:package:mac"],
70
+ ["run", "rightkit:package:mac", "--", "--notarize"],
71
+ ].some((allowed) => JSON.stringify(packageCommand.args) === JSON.stringify(allowed)),
72
+ `${label} R2 release must use the dedicated signed and notarized macOS package entry point`,
73
+ );
68
74
  const implementation = scripts["rightkit:package:mac"];
69
75
  assert.equal(typeof implementation, "string", `${label} must define rightkit:package:mac`);
70
76
  assert.doesNotMatch(implementation, /\bright-release\b/, `${label} rightkit:package:mac must package directly instead of recursing into the release build`);
@@ -417,6 +423,11 @@ test("release package scripts reject a generic direct Tauri alias but allow name
417
423
  test("mac release config uses a dedicated non-recursive package entry point", () => {
418
424
  const command = { cmd: "pnpm", args: ["run", "rightkit:package:mac"] };
419
425
  assert.doesNotThrow(() => assertMacPackageEntry(command, { "rightkit:package:mac": "node scripts/mac-dmg.mjs --notarize" }, "fixture"));
426
+ assert.doesNotThrow(() => assertMacPackageEntry(
427
+ { cmd: "pnpm", args: ["run", "rightkit:package:mac", "--", "--notarize"] },
428
+ { "rightkit:package:mac": "node scripts/mac-dmg.mjs" },
429
+ "fixture",
430
+ ));
420
431
  assert.throws(
421
432
  () => assertMacPackageEntry(command, { "rightkit:package:mac": "right-release build --platform mac" }, "fixture"),
422
433
  /instead of recursing into the release build/i,
@@ -450,10 +461,10 @@ test("RightKit exposes one current version manifest", () => {
450
461
  "@rightkit/legal": "0.3.0",
451
462
  "@rightkit/legal-ui": "0.1.0",
452
463
  "@rightkit/license": "0.1.6",
453
- "@rightkit/release": "0.2.52",
464
+ "@rightkit/release": "0.2.54",
454
465
  });
455
466
  assert.deepEqual(versions.legacyNpm, {
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"],
467
+ "@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", "0.2.53"],
457
468
  });
458
469
  assert.ok(
459
470
  new Set([versions.npm["@rightkit/release"], ...versions.legacyNpm["@rightkit/release"]]).has("0.2.42"),
@@ -641,12 +652,21 @@ for (const app of macOnlyApps) {
641
652
  cmd: "right-release",
642
653
  args: ["publish-update", "--config", "right-release.config.mjs", "--platform", "mac"],
643
654
  });
644
- assert.equal(config.targets.mac.updater.artifacts.length, 1);
645
- const updater = config.targets.mac.updater.artifacts[0];
646
- assert.equal(updater.platform, "darwin-aarch64");
655
+ const updaters = config.targets.mac.updater.artifacts;
656
+ assert.deepEqual(
657
+ updaters.map(({ platform }) => platform).sort(),
658
+ ["darwin-aarch64", "darwin-x86_64"],
659
+ `${app.key} universal DMG must serve both Mac architectures`,
660
+ );
661
+ const updater = updaters[0];
647
662
  assert.match(updater.file, /\.dmg$/);
648
663
  assert.equal(updater.signature, `${updater.file}.sig`);
649
664
  assert.equal(updater.key, `${app.key}/updates/mac/current/ScreenRight.dmg`);
665
+ for (const candidate of updaters) {
666
+ assert.equal(candidate.file, updater.file, `${app.key} updater rows must share one universal DMG`);
667
+ assert.equal(candidate.signature, updater.signature, `${app.key} updater rows must share one signature`);
668
+ assert.equal(candidate.key, updater.key, `${app.key} updater rows must share one current R2 object`);
669
+ }
650
670
  assertBuildInputs(config, config.targets.mac, `${app.key} mac`);
651
671
  assertMacPackageEntry(config.targets.mac.package, pkg.scripts, app.key);
652
672
  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.52"
19
+ "@rightkit/release": "0.2.54"
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", "0.2.51"]
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", "0.2.53"]
22
23
  },
23
24
  "cargo": {
24
25
  "rightkit-license": "0.1.2",