@rightkit/release 0.2.62 → 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
@@ -30,12 +30,14 @@ import { assertNsisInPlaceUpgradeContract } from "./nsis-upgrade-contract.mjs";
30
30
  import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
31
31
  import { assertCleanSource } from "./source-gate.mjs";
32
32
  import { acquireHeavyWorkSlot, heavyCommandEnvironment, terminateProcessTree } from "./heavy-command.mjs";
33
+ import { processGroupCpuSeconds } from "./process-liveness.mjs";
33
34
 
34
35
  // Fingerprint of the pipeline code that can change the bytes we ship or the way
35
36
  // they are signed — deliberately NOT the package version, which moves for docs
36
37
  // and test-only edits and would force a cold Rust rebuild of every app each time.
37
38
  // These four files are the ones whose behaviour the cached target directory can
38
39
  // outlive.
40
+
39
41
  const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs"];
40
42
  const PIPELINE_FINGERPRINT = createHash("sha256")
41
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"))
@@ -281,7 +283,7 @@ try {
281
283
  else if (realpathSync(targetLink) !== realpathSync(cacheTarget)) {
282
284
  fail(`primary checkout target exists and is not the release cache link: ${targetLink}`);
283
285
  }
284
- 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"));
285
287
  },
286
288
  build: async () => {
287
289
  throwIfInterrupted();
@@ -291,6 +293,7 @@ try {
291
293
  appRoot,
292
294
  env,
293
295
  [managedCargoTarget ?? env.CARGO_TARGET_DIR, path.join(appRoot, ".cache")],
296
+ path.join(stateRoot, "stall-build.log"),
294
297
  );
295
298
  checkpoint(stateRoot, "build_complete");
296
299
  checkpoint(stateRoot, "signed");
@@ -429,7 +432,7 @@ function resolveManagedCargoTarget(manifestPath) {
429
432
  return target;
430
433
  }
431
434
 
432
- async function runProgress(cmd, runArgs, cwd, env, watchDir) {
435
+ async function runProgress(cmd, runArgs, cwd, env, watchDir, diagnosticPath = null) {
433
436
  const inactivityMs = Number(process.env.RIGHT_RELEASE_STALL_MS ?? 10 * 60 * 1000);
434
437
  const absoluteMs = Number(process.env.RIGHT_RELEASE_ABSOLUTE_MS ?? 90 * 60 * 1000);
435
438
  let lastProgress = Date.now();
@@ -448,17 +451,51 @@ async function runProgress(cmd, runArgs, cwd, env, watchDir) {
448
451
  return;
449
452
  }
450
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 = "";
451
457
  for (const [stream, output] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
452
- 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
+ });
453
463
  }
464
+ let lastCpu = processGroupCpuSeconds(child.pid);
454
465
  const monitor = setInterval(() => {
455
466
  const mtime = Math.max(...watchDirs.map(newestMtime));
456
467
  if (mtime > lastMtime) { lastMtime = mtime; lastProgress = Date.now(); }
457
- if (Date.now() - started > absoluteMs) stop(`absolute limit exceeded (${Math.round(absoluteMs / 60000)}m)`);
458
- 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);
459
473
  }, Math.min(30_000, Math.max(250, Math.floor(inactivityMs / 4))));
460
474
  const cleanup = () => { clearInterval(monitor); closeWatchers(); };
461
- 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
+ };
462
499
  child.once("error", (error) => { cleanup(); reject(error); });
463
500
  child.once("exit", (code) => { cleanup(); child = null; code === 0 ? resolve() : reject(new Error(`${cmd} exited ${code}`)); });
464
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");
@@ -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
+ });
@@ -18,11 +18,11 @@ export function isolatedCargoMetadataEnv(cargoHome, env = process.env) {
18
18
  }
19
19
 
20
20
  export function cargoExecutable(platform = process.platform) {
21
- return platform === "win32" ? "rustup" : "cargo";
21
+ return "cargo";
22
22
  }
23
23
 
24
24
  export function cargoArguments(args, platform = process.platform) {
25
- return platform === "win32" ? ["run", "stable", "cargo", ...args] : args;
25
+ return args;
26
26
  }
27
27
 
28
28
  export function validateRightKitCargoContract(root, publishedVersions, label = path.basename(root)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.62",
3
+ "version": "0.2.63",
4
4
  "description": "Portable Right Suite release CLI/SDK: native-host signed installers, updater artifacts, hardening, immutable GitHub Release upload, and add-on adoption.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
@@ -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
+ }
package/release-state.mjs CHANGED
@@ -94,6 +94,11 @@ export function releaseEnvironment({ root, cacheRoot, platform, architecture, ap
94
94
  SCCACHE_CACHE_SIZE: `${policy.sccacheMaxBytes / 1024 ** 3}G`,
95
95
  SCCACHE_BASEDIRS: [path.resolve(root), path.resolve(appRoot)].join(path.delimiter),
96
96
  RUSTC_WRAPPER: "sccache",
97
+ // Doc-mandated (architecture section 5): a dropped sccache connection must
98
+ // fall through to plain rustc, not fail the build. Without this, a single
99
+ // transient server disconnect (seen on Windows as error 10054) kills a
100
+ // build that would otherwise have compiled fine uncached.
101
+ SCCACHE_IGNORE_SERVER_IO_ERROR: "1",
97
102
  RIGHT_RELEASE_CACHE_OWNER: "rightkit-v2",
98
103
  };
99
104
  }
@@ -105,6 +105,9 @@ test("shared release environment is isolated from the app vault", () => {
105
105
  assert.match(env.CARGO_HOME, /RightSuite[\\/]release[\\/]cargo-home$/);
106
106
  assert.equal(env.RIGHT_RELEASE_CACHE_OWNER, "rightkit-v2");
107
107
  assert.equal(env.SCCACHE_CACHE_SIZE, "32G");
108
+ // A dropped sccache server connection must fall through to plain rustc
109
+ // instead of failing the build; seen on Windows as a bare error 10054.
110
+ assert.equal(env.SCCACHE_IGNORE_SERVER_IO_ERROR, "1");
108
111
  });
109
112
 
110
113
  test("shared release environment honors the cache policy override", () => {
@@ -467,12 +467,12 @@ test("RightKit exposes one current version manifest", () => {
467
467
  "@rightkit/legal": "0.3.0",
468
468
  "@rightkit/legal-ui": "0.1.1",
469
469
  "@rightkit/license": "0.1.6",
470
- "@rightkit/release": "0.2.62",
470
+ "@rightkit/release": "0.2.63",
471
471
  "@rightkit/qa": "0.2.0",
472
472
  });
473
473
  assert.deepEqual(versions.legacyNpm, {
474
474
  "@rightkit/legal-ui": ["0.1.0"],
475
- "@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", "0.2.54", "0.2.55", "0.2.56", "0.2.61"],
475
+ "@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", "0.2.54", "0.2.55", "0.2.56", "0.2.61", "0.2.62"],
476
476
  "@rightkit/qa": ["0.1.0"],
477
477
  });
478
478
  assert.ok(
@@ -498,9 +498,9 @@ test("RightKit exposes one current version manifest", () => {
498
498
  getCurrentCargoVersionContract();
499
499
  });
500
500
 
501
- test("Cargo metadata uses native rustup on Windows", () => {
502
- assert.equal(cargoExecutable("win32"), "rustup");
503
- assert.deepEqual(cargoArguments(["metadata"], "win32"), ["run", "stable", "cargo", "metadata"]);
501
+ test("Cargo metadata uses managed Cargo directly on Windows", () => {
502
+ assert.equal(cargoExecutable("win32"), "cargo");
503
+ assert.deepEqual(cargoArguments(["metadata"], "win32"), ["metadata"]);
504
504
  assert.equal(cargoExecutable("darwin"), "cargo");
505
505
  });
506
506
 
@@ -19,7 +19,7 @@
19
19
  "@rightkit/legal": "0.3.0",
20
20
  "@rightkit/legal-ui": "0.1.1",
21
21
  "@rightkit/license": "0.1.6",
22
- "@rightkit/release": "0.2.62",
22
+ "@rightkit/release": "0.2.63",
23
23
  "@rightkit/qa": "0.2.0"
24
24
  },
25
25
  "legacyNpm": {
@@ -44,7 +44,8 @@
44
44
  "0.2.54",
45
45
  "0.2.55",
46
46
  "0.2.56",
47
- "0.2.61"
47
+ "0.2.61",
48
+ "0.2.62"
48
49
  ],
49
50
  "@rightkit/qa": [
50
51
  "0.1.0"
package/target-bridge.mjs CHANGED
@@ -51,7 +51,7 @@ export function createTargetBridge({
51
51
  }
52
52
  mkdir(target, { recursive: true });
53
53
  if (!entry) {
54
- symlink(target, link, platform === "win32" ? "junction" : "dir");
54
+ symlink(target, link, "dir");
55
55
  entry = lstat(link);
56
56
  if (!entry.isSymbolicLink()) throw new Error(`RightKit target bridge was not created as a symbolic link: ${link}`);
57
57
  if (!sameRealPath(link, target, realpath, platform)) {
@@ -64,7 +64,7 @@ export function createTargetBridge({
64
64
  // Stale link from an earlier fingerprint, or one whose cache entry a
65
65
  // prune already removed. Both are ours to repoint.
66
66
  unlink(link);
67
- symlink(target, link, platform === "win32" ? "junction" : "dir");
67
+ symlink(target, link, "dir");
68
68
  entry = lstat(link);
69
69
  if (!sameRealPath(link, target, realpath, platform)) {
70
70
  throw new Error(`RightKit target bridge did not repoint to the owned shared cache target: ${link}`);
@@ -27,6 +27,23 @@ test("removes an invocation-created bridge after success without touching the sh
27
27
  assert.equal(readFileSync(path.join(fx.target, "keep.txt"), "utf8"), "shared-cache\n");
28
28
  });
29
29
 
30
+ test("Windows creates a directory symlink instead of an untrusted junction", () => {
31
+ const fx = fixture();
32
+ let requestedType;
33
+ const bridge = createTargetBridge({
34
+ ...fx,
35
+ platform: "win32",
36
+ symlink(target, link, type) {
37
+ requestedType = type;
38
+ symlinkSync(target, link, type);
39
+ },
40
+ });
41
+ bridge.ensure();
42
+ assert.equal(requestedType, "dir");
43
+ assert.equal(realpathSync(fx.link), realpathSync(fx.target));
44
+ bridge.release();
45
+ });
46
+
30
47
  test("removes an invocation-created bridge after the build throws", async () => {
31
48
  const fx = fixture();
32
49
  const bridge = createTargetBridge(fx);