@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 +43 -6
- package/build-release.test.mjs +29 -0
- package/cargo-contract.mjs +2 -2
- package/package.json +1 -1
- package/process-liveness.mjs +39 -0
- package/release-state.mjs +5 -0
- package/release-state.test.mjs +3 -0
- package/right-suite-contract.test.mjs +5 -5
- package/rightkit-versions.json +3 -2
- package/target-bridge.mjs +2 -2
- package/target-bridge.test.mjs +17 -0
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) => {
|
|
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
|
-
|
|
458
|
-
|
|
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
|
|
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
|
});
|
package/build-release.test.mjs
CHANGED
|
@@ -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
|
+
});
|
package/cargo-contract.mjs
CHANGED
|
@@ -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
|
|
21
|
+
return "cargo";
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export function cargoArguments(args, platform = process.platform) {
|
|
25
|
-
return
|
|
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.
|
|
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
|
}
|
package/release-state.test.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
502
|
-
assert.equal(cargoExecutable("win32"), "
|
|
503
|
-
assert.deepEqual(cargoArguments(["metadata"], "win32"), ["
|
|
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
|
|
package/rightkit-versions.json
CHANGED
|
@@ -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.
|
|
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,
|
|
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,
|
|
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}`);
|
package/target-bridge.test.mjs
CHANGED
|
@@ -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);
|