@rightkit/release 0.2.61 → 0.2.63

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/build-release.mjs CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  closeSync,
6
6
  copyFileSync,
7
7
  existsSync,
8
+ mkdtempSync,
8
9
  mkdirSync,
9
10
  openSync,
10
11
  readFileSync,
@@ -17,6 +18,7 @@ import {
17
18
  unlinkSync,
18
19
  writeFileSync,
19
20
  } from "node:fs";
21
+ import { tmpdir } from "node:os";
20
22
  import path from "node:path";
21
23
  import { spawn, spawnSync } from "node:child_process";
22
24
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -28,12 +30,14 @@ import { assertNsisInPlaceUpgradeContract } from "./nsis-upgrade-contract.mjs";
28
30
  import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
29
31
  import { assertCleanSource } from "./source-gate.mjs";
30
32
  import { acquireHeavyWorkSlot, heavyCommandEnvironment, terminateProcessTree } from "./heavy-command.mjs";
33
+ import { processGroupCpuSeconds } from "./process-liveness.mjs";
31
34
 
32
35
  // Fingerprint of the pipeline code that can change the bytes we ship or the way
33
36
  // they are signed — deliberately NOT the package version, which moves for docs
34
37
  // and test-only edits and would force a cold Rust rebuild of every app each time.
35
38
  // These four files are the ones whose behaviour the cached target directory can
36
39
  // outlive.
40
+
37
41
  const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs"];
38
42
  const PIPELINE_FINGERPRINT = createHash("sha256")
39
43
  .update(PIPELINE_FINGERPRINT_SOURCES.map((file) => `${file}:${createHash("sha256").update(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file))).digest("hex")}`).join("\n"))
@@ -163,6 +167,9 @@ try {
163
167
  if (signingIdentity) inputHashes[".right-release/signing-identity.json"] = hashFileText(JSON.stringify(signingIdentity));
164
168
  const cargoLock = requiredInputs.find((file) => /Cargo\.lock$/i.test(file));
165
169
  const cargoToml = requiredInputs.find((file) => /Cargo\.toml$/i.test(file));
170
+ const managedCargoTarget = process.env.RIGHTKIT_BUILD_BROKER_SOCKET
171
+ ? resolveManagedCargoTarget(cargoToml)
172
+ : null;
166
173
  const cacheIdentity = resolveSharedCacheIdentity({ cargoLockPath: cargoLock, cargoTomlPath: cargoToml, rustcVerbose: toolVersions.rustc, targetTriple: target.cargoTarget ?? target.targetTriple ?? target.rustTarget ?? toolVersions.rustHost, profile: target.profile ?? "release" });
167
174
  const cacheKey = hashFileText(JSON.stringify({ cache: cacheIdentity.fingerprint, signingIdentity })).slice(0, 16);
168
175
  const cacheMode = process.env.RIGHT_RELEASE_CACHE_MODE ?? (platform === "mac" || platform === "win" ? "shared" : "legacy");
@@ -197,6 +204,11 @@ try {
197
204
  RIGHT_RELEASE_REPO_ROOT: layout.repoRoot,
198
205
  RIGHT_RELEASE_APP_ROOT: layout.appRoot,
199
206
  };
207
+ if (managedCargoTarget) {
208
+ for (const name of ["CARGO_HOME", "CARGO_TARGET_DIR", "RUSTC_WRAPPER", "SCCACHE_DIR", "SCCACHE_BASEDIRS", "SCCACHE_CACHE_SIZE", "RUSTFLAGS"]) {
209
+ delete env[name];
210
+ }
211
+ }
200
212
  if (cacheMode === "legacy") delete env.RIGHT_RELEASE_CACHE_OWNER;
201
213
  if (cacheMode === "shared" && env.RIGHT_RELEASE_CACHE_OWNER !== "rightkit-v2") fail("shared cache ownership token was not configured");
202
214
  if (cacheMode === "legacy" && !commandExists("sccache")) delete env.RUSTC_WRAPPER;
@@ -208,7 +220,7 @@ try {
208
220
  throwIfInterrupted();
209
221
  const releaseId = `${config.app}-${config.version}-${shortCommit}`;
210
222
  const platformDir = platform === "win" ? "windows" : "mac";
211
- const buildRoot = path.join(vaultRoot, "build", config.app, config.version, shortCommit, platformDir);
223
+ const buildRoot = managedCargoTarget ? null : path.join(vaultRoot, "build", config.app, config.version, shortCommit, platformDir);
212
224
  const stateRoot = path.join(vaultRoot, "state", releaseId, platformDir);
213
225
  const lockPayload = {
214
226
  schema: 1,
@@ -229,8 +241,8 @@ try {
229
241
  // instead of hard-stopping the build.
230
242
  const targetBridge = createTargetBridge({
231
243
  link: targetLink,
232
- target: env.CARGO_TARGET_DIR,
233
- ownedRoot: path.dirname(env.CARGO_TARGET_DIR),
244
+ target: managedCargoTarget ?? env.CARGO_TARGET_DIR,
245
+ ownedRoot: path.dirname(managedCargoTarget ?? env.CARGO_TARGET_DIR),
234
246
  });
235
247
 
236
248
  const result = await targetBridge.run(async () => runBuildStateMachine({
@@ -256,22 +268,22 @@ try {
256
268
  assertExecutables(["git", config.packageManager ?? "pnpm", "cargo", "rustc", ...(target.preflight?.executables ?? [])]);
257
269
  for (const name of target.preflight?.env ?? []) if (!process.env[name]) fail(`missing required environment variable: ${name}`);
258
270
  for (const command of target.preflight?.commands ?? []) runChecked(command.cmd, command.args ?? [], path.resolve(appRoot, command.cwd ?? "."), env);
259
- mkdirSync(buildRoot, { recursive: true });
271
+ if (buildRoot) mkdirSync(buildRoot, { recursive: true });
260
272
  mkdirSync(stateRoot, { recursive: true });
261
- writeJson(path.join(buildRoot, "release-inputs.lock.json"), lockPayload);
273
+ if (buildRoot) writeJson(path.join(buildRoot, "release-inputs.lock.json"), lockPayload);
262
274
  writeJson(path.join(stateRoot, "release-inputs.lock.json"), lockPayload);
263
275
  checkpoint(stateRoot, "preflight_complete");
264
276
  },
265
277
  prepare: async () => {
266
278
  throwIfInterrupted();
267
- const cacheTarget = env.CARGO_TARGET_DIR;
279
+ const cacheTarget = managedCargoTarget ?? env.CARGO_TARGET_DIR;
268
280
  mkdirSync(cacheTarget, { recursive: true });
269
281
  if (cacheMode === "shared") targetBridge.ensure();
270
282
  else if (!existsSync(targetLink)) symlinkSync(cacheTarget, targetLink, process.platform === "win32" ? "junction" : "dir");
271
283
  else if (realpathSync(targetLink) !== realpathSync(cacheTarget)) {
272
284
  fail(`primary checkout target exists and is not the release cache link: ${targetLink}`);
273
285
  }
274
- await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget);
286
+ await runProgress(config.packageManager ?? "pnpm", ["install", "--frozen-lockfile"], appRoot, env, cacheTarget, path.join(stateRoot, "stall-install.log"));
275
287
  },
276
288
  build: async () => {
277
289
  throwIfInterrupted();
@@ -280,7 +292,8 @@ try {
280
292
  [WORKER, "--config", configPath, "--platform", platform, "--no-upload"],
281
293
  appRoot,
282
294
  env,
283
- [env.CARGO_TARGET_DIR, path.join(appRoot, ".cache")],
295
+ [managedCargoTarget ?? env.CARGO_TARGET_DIR, path.join(appRoot, ".cache")],
296
+ path.join(stateRoot, "stall-build.log"),
284
297
  );
285
298
  checkpoint(stateRoot, "build_complete");
286
299
  checkpoint(stateRoot, "signed");
@@ -391,18 +404,35 @@ function sealRelease({ configRoot, sealedDir, releaseId, config, target, platfor
391
404
  }
392
405
 
393
406
  function collectToolVersions(packageManager) {
394
- const rustcVerbose = commandOutput("rustc", ["-vV"]);
395
- return {
396
- node: process.version,
397
- packageManager: `${packageManager} ${commandOutput(packageManager, ["--version"])}`,
398
- cargo: commandOutput("cargo", ["--version"]),
399
- rustc: rustcVerbose,
400
- rustHost: rustcVerbose.match(/^host:\s*(.+)$/m)?.[1] ?? "unknown",
401
- sccache: commandExists("sccache") ? commandOutput("sccache", ["--version"]) : null,
402
- };
407
+ const probeDir = mkdtempSync(path.join(tmpdir(), "right-release-tool-probe-"));
408
+ try {
409
+ const rustcVerbose = commandOutputAt("rustc", ["-vV"], probeDir);
410
+ return {
411
+ node: process.version,
412
+ packageManager: `${packageManager} ${commandOutput(packageManager, ["--version"])}`,
413
+ cargo: commandOutputAt("cargo", ["--version"], probeDir),
414
+ rustc: rustcVerbose,
415
+ rustHost: rustcVerbose.match(/^host:\s*(.+)$/m)?.[1] ?? "unknown",
416
+ sccache: commandExists("sccache") ? commandOutput("sccache", ["--version"]) : null,
417
+ };
418
+ } finally {
419
+ rmSync(probeDir, { recursive: true, force: true });
420
+ }
421
+ }
422
+
423
+ function resolveManagedCargoTarget(manifestPath) {
424
+ if (!manifestPath) fail("managed release requires a Cargo.toml build input");
425
+ const output = commandOutputAt(
426
+ "cargo",
427
+ ["metadata", "--no-deps", "--offline", "--format-version", "1", "--manifest-path", manifestPath],
428
+ path.dirname(manifestPath),
429
+ );
430
+ const target = JSON.parse(output).target_directory;
431
+ if (!target || !path.isAbsolute(target)) fail("managed Cargo metadata returned no absolute target directory");
432
+ return target;
403
433
  }
404
434
 
405
- async function runProgress(cmd, runArgs, cwd, env, watchDir) {
435
+ async function runProgress(cmd, runArgs, cwd, env, watchDir, diagnosticPath = null) {
406
436
  const inactivityMs = Number(process.env.RIGHT_RELEASE_STALL_MS ?? 10 * 60 * 1000);
407
437
  const absoluteMs = Number(process.env.RIGHT_RELEASE_ABSOLUTE_MS ?? 90 * 60 * 1000);
408
438
  let lastProgress = Date.now();
@@ -421,17 +451,51 @@ async function runProgress(cmd, runArgs, cwd, env, watchDir) {
421
451
  return;
422
452
  }
423
453
  const closeWatchers = watchProgress(watchDirs, () => { lastProgress = Date.now(); });
454
+ // A killed step used to leave no trace of what it was doing, so every stall
455
+ // cost a forensic dig. Keep a bounded tail to write out if we do kill it.
456
+ let tail = "";
424
457
  for (const [stream, output] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
425
- stream.on("data", (chunk) => { lastProgress = Date.now(); output.write(chunk); });
458
+ stream.on("data", (chunk) => {
459
+ lastProgress = Date.now();
460
+ tail = `${tail}${chunk}`.slice(-64 * 1024);
461
+ output.write(chunk);
462
+ });
426
463
  }
464
+ let lastCpu = processGroupCpuSeconds(child.pid);
427
465
  const monitor = setInterval(() => {
428
466
  const mtime = Math.max(...watchDirs.map(newestMtime));
429
467
  if (mtime > lastMtime) { lastMtime = mtime; lastProgress = Date.now(); }
430
- if (Date.now() - started > absoluteMs) stop(`absolute limit exceeded (${Math.round(absoluteMs / 60000)}m)`);
431
- else if (Date.now() - lastProgress > inactivityMs) stop(`no output or file progress for ${Math.round(inactivityMs / 60000)}m`);
468
+ const cpu = processGroupCpuSeconds(child.pid);
469
+ if (cpu !== null && lastCpu !== null && cpu > lastCpu) lastProgress = Date.now();
470
+ if (cpu !== null) lastCpu = cpu;
471
+ if (Date.now() - started > absoluteMs) stop(`absolute limit exceeded (${Math.round(absoluteMs / 60000)}m)`, cpu);
472
+ else if (Date.now() - lastProgress > inactivityMs) stop(`no output, file, or CPU progress for ${Math.round(inactivityMs / 60000)}m`, cpu);
432
473
  }, Math.min(30_000, Math.max(250, Math.floor(inactivityMs / 4))));
433
474
  const cleanup = () => { clearInterval(monitor); closeWatchers(); };
434
- const stop = (reason) => { cleanup(); killTree(child.pid); reject(new Error(`release step stalled: ${reason}`)); };
475
+ const stop = (reason, cpu = null) => {
476
+ cleanup();
477
+ if (diagnosticPath) {
478
+ try {
479
+ mkdirSync(path.dirname(diagnosticPath), { recursive: true });
480
+ writeFileSync(diagnosticPath, [
481
+ `reason: ${reason}`,
482
+ `command: ${cmd} ${runArgs.join(" ")}`,
483
+ `cwd: ${cwd}`,
484
+ `watched: ${watchDirs.join(path.delimiter)}`,
485
+ `elapsedMs: ${Date.now() - started}`,
486
+ `cpuSeconds: ${cpu ?? "unavailable"}`,
487
+ `killedAt: ${new Date().toISOString()}`,
488
+ "",
489
+ "--- last output ---",
490
+ tail || "(the step produced no output at all)",
491
+ "",
492
+ ].join("\n"));
493
+ console.error(`right-release: stall diagnostic written to ${diagnosticPath}`);
494
+ } catch { /* diagnostics are best effort; never mask the stall itself */ }
495
+ }
496
+ killTree(child.pid);
497
+ reject(new Error(`release step stalled: ${reason}`));
498
+ };
435
499
  child.once("error", (error) => { cleanup(); reject(error); });
436
500
  child.once("exit", (code) => { cleanup(); child = null; code === 0 ? resolve() : reject(new Error(`${cmd} exited ${code}`)); });
437
501
  });
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { execFileSync, spawnSync } from "node:child_process";
6
6
  import test from "node:test";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { parseCpuSeconds, processGroupCpuSeconds } from "./process-liveness.mjs";
8
9
 
9
10
  const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "build-release.mjs"), "utf8");
10
11
  const build = path.join(path.dirname(fileURLToPath(import.meta.url)), "build-release.mjs");
@@ -66,11 +67,11 @@ test("dirty release config is rejected before the config module is imported", ()
66
67
  });
67
68
 
68
69
  test("shared Cache V2 target bridge is owned by a finally-cleaned lifecycle", () => {
69
- assert.match(source, /createTargetBridge\(\{\s*link: targetLink,\s*target: env\.CARGO_TARGET_DIR,/);
70
+ assert.match(source, /createTargetBridge\(\{\s*link: targetLink,\s*target: managedCargoTarget \?\? env\.CARGO_TARGET_DIR,/);
70
71
  // ownedRoot must stay wired: without it the bridge refuses every link left by
71
72
  // an earlier fingerprint, so any Cargo.lock or version bump hard-stops the
72
73
  // next build until someone deletes the link by hand.
73
- assert.match(source, /ownedRoot: path\.dirname\(env\.CARGO_TARGET_DIR\)/);
74
+ assert.match(source, /ownedRoot: path\.dirname\(managedCargoTarget \?\? env\.CARGO_TARGET_DIR\)/);
74
75
  assert.match(source, /targetBridge\.run\(.*runBuildStateMachine/s);
75
76
  assert.match(source, /if \(cacheMode === "shared"\) targetBridge\.ensure\(\)/);
76
77
  });
@@ -85,3 +86,31 @@ test("Windows seal and cache identity bind the signing contract, config, and rec
85
86
  assert.match(source, /receipts: signingIdentity\.receiptInputs\.map/);
86
87
  assert.match(readFileSync(new URL("./release.mjs", import.meta.url), "utf8"), /item\.after\?\.sha256 !== currentHashes/);
87
88
  });
89
+
90
+ test("CPU sampling separates a silent working step from a hang", () => {
91
+ // Real `ps -o cputime=` shapes, including a multi-process group.
92
+ assert.equal(parseCpuSeconds(" 0:03.21\n"), 3.21);
93
+ assert.equal(parseCpuSeconds(" 1:02:03\n"), 3723);
94
+ assert.equal(parseCpuSeconds(" 2-01:00:00\n"), 176400);
95
+ // A process group sums every member, which is how a linker under cargo shows up.
96
+ assert.equal(parseCpuSeconds(" 0:10.00\n 0:05.00\n"), 15);
97
+ // A vanished process yields no rows: null, not zero, so the caller falls back
98
+ // to output and mtime rather than treating it as "no CPU used".
99
+ assert.equal(parseCpuSeconds(""), null);
100
+ assert.equal(parseCpuSeconds(" \n"), null);
101
+ assert.equal(parseCpuSeconds("garbage"), null);
102
+ assert.equal(parseCpuSeconds(undefined), null);
103
+ });
104
+
105
+ test("CPU sampling degrades to the old behaviour rather than inventing a verdict", () => {
106
+ const ok = (stdout) => () => ({ status: 0, stdout });
107
+ // A working process group reports advancing CPU.
108
+ assert.equal(processGroupCpuSeconds(123, { platform: "darwin", run: ok(" 0:42.50\n") }), 42.5);
109
+ // Windows has no `ps`; sampling is skipped, not guessed.
110
+ assert.equal(processGroupCpuSeconds(123, { platform: "win32", run: ok(" 0:42.50\n") }), null);
111
+ // No pid, a failed probe, or an empty result must all be "cannot measure",
112
+ // never 0 — 0 would look like a hang and kill a healthy build.
113
+ assert.equal(processGroupCpuSeconds(0, { platform: "darwin", run: ok(" 0:42.50\n") }), null);
114
+ assert.equal(processGroupCpuSeconds(123, { platform: "darwin", run: () => ({ status: 1, stdout: "" }) }), null);
115
+ assert.equal(processGroupCpuSeconds(123, { platform: "darwin", run: ok("") }), null);
116
+ });
@@ -13,15 +13,16 @@ const CACHE_V2_ENVIRONMENT = ["CARGO_HOME", "CARGO_TARGET_DIR", "RUSTC_WRAPPER",
13
13
  export function isolatedCargoMetadataEnv(cargoHome, env = process.env) {
14
14
  const metadataEnv = { ...env };
15
15
  for (const name of CACHE_V2_ENVIRONMENT) delete metadataEnv[name];
16
+ if (metadataEnv.RIGHTKIT_BUILD_BROKER_SOCKET) return metadataEnv;
16
17
  return { ...metadataEnv, CARGO_HOME: cargoHome };
17
18
  }
18
19
 
19
20
  export function cargoExecutable(platform = process.platform) {
20
- return platform === "win32" ? "rustup" : "cargo";
21
+ return "cargo";
21
22
  }
22
23
 
23
24
  export function cargoArguments(args, platform = process.platform) {
24
- return platform === "win32" ? ["run", "stable", "cargo", ...args] : args;
25
+ return args;
25
26
  }
26
27
 
27
28
  export function validateRightKitCargoContract(root, publishedVersions, label = path.basename(root)) {
@@ -84,6 +84,7 @@ test("Cargo guard routes heavy and light commands without recursion", async () =
84
84
  env: { TEST: "1", ...cacheEnv() },
85
85
  policyOptions: { exists: () => false },
86
86
  resolveCargo: () => "/real/cargo",
87
+ resolveRustc: () => "/real/rustc",
87
88
  runHeavy: async (args, config) => { calls.push(["heavy", args, config.env]); return 0; },
88
89
  runLight: async (command, args, env) => { calls.push(["light", command, args, env]); return 0; },
89
90
  };
@@ -149,8 +149,8 @@ Commands:
149
149
  generate-dmg-background <options> Generate the branded multi-resolution DMG background
150
150
  mirror-root-artifact --file <path> --package-root <dir>
151
151
  Persist a worktree artifact in the primary repo root
152
- github --release <id> --platform mac|win --repo owner/repo [--dry-run]
153
- Attach one sealed installer to a verified GitHub Release
152
+ github --release <id> --platform mac|win [--repo owner/repo] [--dry-run]
153
+ Attach sealed signed artifacts to a verified GitHub Release
154
154
 
155
155
  Direct flags are treated as: right-release build <flags>.
156
156
  Build is tier-neutral. Upload requires an explicit tier. Unsigned/local smoke builds stay app-local.`);
@@ -24,24 +24,32 @@ export function prepareGitHubRelease({ repoRoot, releaseId, platform, repo, stat
24
24
  const manifestAsset = path.join(releaseState, `${sealed.manifest.app}-${sealed.manifest.version}-${platform}-release-manifest.json`);
25
25
  const checksumsAsset = path.join(releaseState, `${sealed.manifest.app}-${sealed.manifest.version}-${platform}-SHA256SUMS.txt`);
26
26
  copyFileSync(sealed.manifestPath, manifestAsset);
27
- writeFileSync(checksumsAsset, `${installers[0].sha256} ${installers[0].name}\n`);
27
+ writeFileSync(checksumsAsset, `${sealed.manifest.files.map((file) => `${file.sha256} ${file.name}`).join("\n")}\n`);
28
28
  const installer = path.join(sealed.sealedDir, installers[0].name);
29
+ const assets = [...sealed.manifest.files.map((file) => path.join(sealed.sealedDir, file.name)), manifestAsset, checksumsAsset];
29
30
  return {
30
31
  sealed,
31
32
  installer,
32
- assets: [installer, manifestAsset, checksumsAsset],
33
+ assets,
33
34
  tag: `v${sealed.manifest.version}`,
34
35
  title: `${sealed.manifest.app === "cutright" ? "CutRight Studio" : sealed.manifest.app} ${sealed.manifest.version}`,
35
36
  notes: [
36
37
  `Signed ${platform === "mac" ? "& notarized universal macOS" : "Windows"} installer.`,
37
38
  "",
38
39
  `Build commit: \`${sealed.manifest.commit}\``,
39
- `SHA-256: \`${installers[0].sha256}\``,
40
+ `Artifacts: ${sealed.manifest.files.map((file) => `\`${file.name}\``).join(", ")}`,
40
41
  ].join("\n"),
41
42
  notesFile: path.join(releaseState, "release-notes.md"),
42
43
  };
43
44
  }
44
45
 
46
+ export function repositoryFromRemote(repoRoot) {
47
+ const remote = runCommand("git", ["remote", "get-url", "origin"], { cwd: repoRoot });
48
+ const match = remote.trim().match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/i);
49
+ if (!match || !REPO_RE.test(match[1])) throw new Error(`origin is not a GitHub repository: ${remote.trim()}`);
50
+ return match[1];
51
+ }
52
+
45
53
  export function prepareGitHubAddonRelease({ repoRoot, config, configRoot = repoRoot, platform, repo }) {
46
54
  if (platform !== "mac" && platform !== "win") throw new Error("platform must be mac or win");
47
55
  if (!REPO_RE.test(repo)) throw new Error(`invalid GitHub repository: ${repo}`);
@@ -72,12 +80,16 @@ export function prepareGitHubAddonRelease({ repoRoot, config, configRoot = repoR
72
80
 
73
81
  export function publishGitHubRelease(plan, { repo, dryRun = false, run = runCommand } = {}) {
74
82
  const visibility = JSON.parse(run("gh", ["repo", "view", repo, "--json", "visibility"]));
75
- if (visibility.visibility !== "PUBLIC") throw new Error(`GitHub releases require a public repository: ${repo}`);
76
- if (plan.kind === "addon") verifyAddonTrust(plan);
77
- else verifyPlatformTrust(plan);
83
+ if (visibility.visibility !== "PUBLIC" && !(plan.kind !== "addon" && visibility.visibility === "PRIVATE")) {
84
+ throw new Error(`GitHub releases require a public or private repository: ${repo}`);
85
+ }
86
+ if (!dryRun) {
87
+ if (plan.kind === "addon") verifyAddonTrust(plan);
88
+ else verifyPlatformTrust(plan);
89
+ }
78
90
  const existing = run("gh", ["release", "view", plan.tag, "--repo", repo, "--json", "tagName"], { allowFailure: true });
79
91
  if (dryRun) return { status: existing.ok ? plan.kind === "addon" ? "would-verify" : "would-update" : "would-create", tag: plan.tag, assets: plan.assets };
80
- if (plan.kind === "addon" && existing.ok) {
92
+ if (existing.ok) {
81
93
  const missing = plan.assets.filter((asset) => inspectRemoteAsset(plan.tag, repo, asset, run) === "missing");
82
94
  if (missing.length === 0) return { status: "already-verified", tag: plan.tag, assets: plan.assets, manifestUrl: plan.manifestUrl };
83
95
  run("gh", ["release", "upload", plan.tag, ...missing, "--repo", repo]);
@@ -90,7 +102,7 @@ export function publishGitHubRelease(plan, { repo, dryRun = false, run = runComm
90
102
  run("gh", ["release", "create", plan.tag, "--repo", repo, "--title", plan.title, "--notes-file", notesFile]);
91
103
  rmSync(notesFile, { force: true });
92
104
  }
93
- run("gh", ["release", "upload", plan.tag, ...plan.assets, "--repo", repo, ...(plan.kind === "addon" ? [] : ["--clobber"])]);
105
+ run("gh", ["release", "upload", plan.tag, ...plan.assets, "--repo", repo]);
94
106
  for (const asset of plan.assets) verifyRemoteAsset(plan.tag, repo, asset, run);
95
107
  return { status: "verified", tag: plan.tag, assets: plan.assets, manifestUrl: plan.manifestUrl };
96
108
  }
@@ -107,7 +119,10 @@ function verifyAddonTrust(plan) {
107
119
  }
108
120
 
109
121
  function verifyPlatformTrust(plan) {
110
- if (plan.sealed.manifest.platform !== "mac") return;
122
+ if (plan.sealed.manifest.platform === "win") {
123
+ runCommand(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), "sign-windows.mjs"), "--verify-only", plan.installer]);
124
+ return;
125
+ }
111
126
  runCommand("spctl", ["--assess", "--type", "open", "--context", "context:primary-signature", "--verbose=2", plan.installer]);
112
127
  runCommand("xcrun", ["stapler", "validate", plan.installer]);
113
128
  }
@@ -179,7 +194,7 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
179
194
  if (config.distribution?.provider !== "github-releases") throw new Error("add-on config must select github-releases distribution");
180
195
  plan = prepareGitHubAddonRelease({ repoRoot, config, configRoot: path.dirname(configPath), ...options });
181
196
  } else {
182
- if (!options.repo) usage(2);
197
+ options.repo ||= repositoryFromRemote(repoRoot);
183
198
  plan = prepareGitHubRelease({ repoRoot, ...options });
184
199
  }
185
200
  const result = publishGitHubRelease(plan, options);
package/heavy-command.mjs CHANGED
@@ -133,6 +133,30 @@ function writeTrackedChild(lockDir, owner, child) {
133
133
  renameSync(temporary, file);
134
134
  }
135
135
 
136
+ function matchingProcessIdentity(pid, expectedStartedAtMs, { alive = processAlive, startedAt = processStartedAt, assumeCurrentWhenStartUnavailable = false } = {}) {
137
+ if (!alive(pid)) return false;
138
+ if (!Number.isFinite(Number(expectedStartedAtMs))) return assumeCurrentWhenStartUnavailable;
139
+ const observedStartedAtMs = startedAt(pid);
140
+ if (!Number.isFinite(observedStartedAtMs)) return assumeCurrentWhenStartUnavailable;
141
+ return Math.abs(observedStartedAtMs - Number(expectedStartedAtMs)) <= 2000;
142
+ }
143
+
144
+ export function sweepTrackedHeavyWorkOrphan({ root = heavyWorkRoot(), alive = processAlive, startedAt = processStartedAt, terminate = terminateProcessTree, log = (message) => console.error(`[heavy-work] ${message}`) } = {}) {
145
+ const lockDir = path.join(root, "slot");
146
+ const owner = readOwner(lockDir);
147
+ if (!owner) return false;
148
+ if (matchingProcessIdentity(owner.pid, owner.ownerStartedAtMs, { alive, startedAt, assumeCurrentWhenStartUnavailable: true })) return false;
149
+ if (owner.childPid && alive(owner.childPid)) {
150
+ if (!matchingProcessIdentity(owner.childPid, owner.childStartedAtMs, { alive, startedAt })) {
151
+ throw new Error(`dead heavy-work owner pid ${owner.pid}; tracked child pid ${owner.childPid} identity changed; refusing to kill or admit`);
152
+ }
153
+ log(`reaping owned process tree pid ${owner.childPid} after stale owner pid ${owner.pid}`);
154
+ terminate(owner.childPid);
155
+ }
156
+ rmSync(lockDir, { recursive: true, force: true });
157
+ return true;
158
+ }
159
+
136
160
  export function processStartedAt(pid, { platform = process.platform, run = spawnSync } = {}) {
137
161
  if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return null;
138
162
  const result = platform === "win32" || platform === "win"
@@ -206,23 +230,15 @@ export function acquireHeavyWorkSlot({
206
230
  try {
207
231
  mkdirSync(lockDir);
208
232
  const fd = openSync(path.join(lockDir, "owner.json"), "wx");
209
- writeFileSync(fd, `${JSON.stringify({ schema: 1, pid: Number(pid), token, argv: [...argv], createdAt: new Date().toISOString() })}\n`);
233
+ writeFileSync(fd, `${JSON.stringify({ schema: 1, pid: Number(pid), ownerStartedAtMs: startedAt(pid), token, argv: [...argv], createdAt: new Date().toISOString() })}\n`);
210
234
  closeSync(fd);
211
235
  break;
212
236
  } catch (error) {
213
237
  if (error.code !== "EEXIST") throw error;
214
238
  const owner = readOwner(lockDir);
215
239
  const incompleteAge = (() => { try { return Date.now() - statSync(lockDir).mtimeMs; } catch { return 0; } })();
216
- if (owner ? !alive(owner.pid) : incompleteAge > 10_000) {
217
- if (owner?.childPid && alive(owner.childPid)) {
218
- const observedStart = startedAt(owner.childPid);
219
- if (!Number.isFinite(observedStart) || Math.abs(observedStart - Number(owner.childStartedAtMs)) > 2000) {
220
- throw new Error(`dead heavy-work owner pid ${owner.pid}; tracked child pid ${owner.childPid} identity changed; refusing to kill or admit`);
221
- }
222
- log(`reaping owned process tree pid ${owner.childPid} after owner pid ${owner.pid} exited`);
223
- terminate(owner.childPid);
224
- }
225
- rmSync(lockDir, { recursive: true, force: true });
240
+ if (owner ? sweepTrackedHeavyWorkOrphan({ root, alive, startedAt, terminate, log }) : incompleteAge > 10_000) {
241
+ if (!owner) rmSync(lockDir, { recursive: true, force: true });
226
242
  continue;
227
243
  }
228
244
  if (Date.now() - started >= waitMs) throw new Error(`heavy-work slot timed out after ${waitMs}ms; holder pid ${owner?.pid ?? "starting"}`);
@@ -15,6 +15,7 @@ import {
15
15
  parseWindowsResourceSnapshot,
16
16
  resourceBlockers,
17
17
  runHeavyCommand,
18
+ sweepTrackedHeavyWorkOrphan,
18
19
  systemResourceSnapshot,
19
20
  terminateProcessTree,
20
21
  watchOwnedProcessTree,
@@ -147,6 +148,54 @@ test("dead slot owner refuses to kill a reused child PID", () => {
147
148
  first.release();
148
149
  });
149
150
 
151
+ test("preflight sweep reaps only a tracked child after an owner PID is reused", () => {
152
+ const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-preflight-"));
153
+ const lockDir = path.join(root, "slot");
154
+ mkdirSync(lockDir);
155
+ writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 101, ownerStartedAtMs: 100, token: "stale" }));
156
+ writeFileSync(path.join(lockDir, "child.json"), JSON.stringify({ childPid: 202, childStartedAtMs: 200 }));
157
+ const killed = [];
158
+ assert.equal(sweepTrackedHeavyWorkOrphan({
159
+ root,
160
+ alive: (pid) => pid === 101 || pid === 202,
161
+ startedAt: (pid) => pid === 101 ? 5_000 : pid === 202 ? 200 : null,
162
+ terminate: (pid) => killed.push(pid),
163
+ log: () => {},
164
+ }), true);
165
+ assert.deepEqual(killed, [202]);
166
+ assert.equal(existsSync(lockDir), false);
167
+ });
168
+
169
+ test("preflight sweep refuses a reused tracked child PID instead of killing broadly", () => {
170
+ const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-preflight-reused-"));
171
+ const lockDir = path.join(root, "slot");
172
+ mkdirSync(lockDir);
173
+ writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 101, ownerStartedAtMs: 100, token: "stale" }));
174
+ writeFileSync(path.join(lockDir, "child.json"), JSON.stringify({ childPid: 202, childStartedAtMs: 200 }));
175
+ assert.throws(() => sweepTrackedHeavyWorkOrphan({
176
+ root,
177
+ alive: (pid) => pid === 101 || pid === 202,
178
+ startedAt: (pid) => pid === 101 ? 5_000 : pid === 202 ? 4_000 : null,
179
+ terminate: () => assert.fail("reused child PID must not be terminated"),
180
+ log: () => {},
181
+ }), /identity changed/);
182
+ });
183
+
184
+ test("preflight sweep refuses a tracked child without a verifiable start identity", () => {
185
+ const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-preflight-unverified-"));
186
+ const lockDir = path.join(root, "slot");
187
+ mkdirSync(lockDir);
188
+ writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 101, ownerStartedAtMs: 100, token: "stale" }));
189
+ writeFileSync(path.join(lockDir, "child.json"), JSON.stringify({ childPid: 202, childStartedAtMs: 200 }));
190
+ assert.throws(() => sweepTrackedHeavyWorkOrphan({
191
+ root,
192
+ alive: (pid) => pid === 101 || pid === 202,
193
+ startedAt: (pid) => pid === 101 ? 5_000 : null,
194
+ terminate: () => assert.fail("unverified child PID must not be terminated"),
195
+ log: () => {},
196
+ }), /identity changed/);
197
+ });
198
+
150
199
  test("detached watcher reaps an owned child as soon as its owner disappears", async () => {
151
200
  const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-watch-"));
152
201
  const lockDir = path.join(root, "slot");
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.61",
4
- "description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts, patch/update routing, hardening, R2 publish, and RightApps registration.",
3
+ "version": "0.2.63",
4
+ "description": "Portable Right Suite release CLI/SDK: native-host signed installers, updater artifacts, hardening, immutable GitHub Release upload, and add-on adoption.",
5
+ "license": "MIT OR Apache-2.0",
5
6
  "type": "module",
6
7
  "bin": {
7
8
  "right-release": "cli/right-release.mjs"
@@ -20,7 +21,7 @@
20
21
  },
21
22
  "repository": {
22
23
  "type": "git",
23
- "url": "git+https://github.com/adrdsouza/claude.git",
24
+ "url": "git+https://github.com/bogusyogi/claude.git",
24
25
  "directory": "tools/rightkit/packages/release"
25
26
  },
26
27
  "scripts": {
@@ -0,0 +1,39 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ /**
4
+ * Total CPU seconds reported by `ps` for a process group, summed across every
5
+ * member. Accepts the `[[dd-]hh:]mm:ss[.ff]` forms `ps` emits.
6
+ * Returns null when no row parses, so callers can tell "no CPU used" apart from
7
+ * "could not measure".
8
+ */
9
+ export function parseCpuSeconds(text) {
10
+ if (typeof text !== "string") return null;
11
+ let total = null;
12
+ for (const line of text.split("\n")) {
13
+ const value = line.trim();
14
+ if (!value) continue;
15
+ const match = value.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/);
16
+ if (!match) continue;
17
+ const [, days, hours, minutes, seconds] = match;
18
+ total = (total ?? 0)
19
+ + Number(days ?? 0) * 86_400
20
+ + Number(hours ?? 0) * 3_600
21
+ + Number(minutes) * 60
22
+ + Number(seconds);
23
+ }
24
+ return total;
25
+ }
26
+
27
+ /**
28
+ * A link or LTO pass can run for many minutes producing no console output and
29
+ * no new files, which output alone cannot tell apart from a hang. Consuming CPU
30
+ * is the signal that separates the two, so a step burning CPU is never stalled.
31
+ * Returns null when CPU cannot be sampled, which leaves the caller on its
32
+ * output-and-mtime behaviour rather than inventing a verdict.
33
+ */
34
+ export function processGroupCpuSeconds(pid, { platform = process.platform, run = spawnSync } = {}) {
35
+ if (!pid || platform === "win32") return null;
36
+ const probe = run("ps", ["-o", "cputime=", "-g", String(pid)], { encoding: "utf8" });
37
+ if (!probe || probe.status !== 0) return null;
38
+ return parseCpuSeconds(probe.stdout ?? "");
39
+ }
@@ -61,12 +61,14 @@ test("upload requires an explicit patch or update tier before reading a release"
61
61
  assert.match(result.stderr, /tier is required.*patch\|update/i);
62
62
  });
63
63
 
64
- test("Windows upload trust verification uses signtool instead of PowerShell modules", () => {
64
+ test("GitHub upload delegates platform trust verification to the shared release lane", () => {
65
65
  const uploadSource = readFileSync(upload, "utf8");
66
+ const githubSource = readFileSync(path.join(root, "github-release.mjs"), "utf8");
66
67
  const signingSource = readFileSync(signWindows, "utf8");
67
- assert.match(uploadSource, /SIGN_WINDOWS/);
68
- assert.match(uploadSource, /--verify-only/);
69
- assert.doesNotMatch(uploadSource, /Get-AuthenticodeSignature/);
68
+ assert.match(uploadSource, /publishGitHubRelease/);
69
+ assert.match(githubSource, /sign-windows\.mjs/);
70
+ assert.match(githubSource, /--verify-only/);
71
+ assert.doesNotMatch(uploadSource, /CLOUDFLARE_API_TOKEN|wrangler|R2/i);
70
72
  assert.match(signingSource, /verifyOnly/);
71
73
  assert.match(signingSource, /\["verify", "\/pa", "\/v", file\]/);
72
74
  });
@@ -101,8 +103,17 @@ test("Windows signer verification accepts CRLF subjects and retains identity che
101
103
  );
102
104
  });
103
105
 
104
- test("upload uses only the pinned pnpm runner", () => {
106
+ test("upload uses GitHub CLI and never invokes the R2 runner", () => {
105
107
  const source = readFileSync(upload, "utf8");
106
- assert.match(source, /spawnSync\("pnpm", \["dlx", "wrangler@4"/);
108
+ const githubSource = readFileSync(path.join(root, "github-release.mjs"), "utf8");
109
+ assert.match(githubSource, /run\("gh", \["release", "upload"/);
110
+ assert.doesNotMatch(source, /wrangler|CLOUDFLARE_API_TOKEN|RIGHTAPPS_API_URL/i);
107
111
  assert.doesNotMatch(source, /\bnpx\b/);
108
112
  });
113
+
114
+ test("upload accepts only GitHub Releases as configured product distribution", () => {
115
+ const source = readFileSync(upload, "utf8");
116
+ assert.match(source, /distribution\.provider/);
117
+ assert.match(source, /github-releases/);
118
+ assert.match(source, /releaseConfig\?\.distribution\?\.repository/);
119
+ });