@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 +86 -22
- package/build-release.test.mjs +31 -2
- package/cargo-contract.mjs +3 -2
- package/cargo-guard.test.mjs +1 -0
- package/cli/right-release.mjs +2 -2
- package/github-release.mjs +25 -10
- package/heavy-command.mjs +27 -11
- package/heavy-command.test.mjs +49 -0
- package/package.json +4 -3
- package/process-liveness.mjs +39 -0
- package/release-cli-contract.test.mjs +17 -6
- package/release-state.mjs +27 -3
- package/release-state.test.mjs +48 -6
- package/release.mjs +15 -15
- package/release.test.mjs +24 -2
- package/right-suite-contract.test.mjs +22 -6
- package/rightkit-versions.json +5 -2
- package/standalone-clone-evidence.json +32 -0
- package/target-bridge.mjs +2 -2
- package/target-bridge.test.mjs +17 -0
- package/upload-release.mjs +36 -170
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
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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) => {
|
|
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
|
-
|
|
431
|
-
|
|
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
|
|
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
|
});
|
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");
|
|
@@ -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
|
+
});
|
package/cargo-contract.mjs
CHANGED
|
@@ -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
|
|
21
|
+
return "cargo";
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export function cargoArguments(args, platform = process.platform) {
|
|
24
|
-
return
|
|
25
|
+
return args;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export function validateRightKitCargoContract(root, publishedVersions, label = path.basename(root)) {
|
package/cargo-guard.test.mjs
CHANGED
|
@@ -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
|
};
|
package/cli/right-release.mjs
CHANGED
|
@@ -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
|
|
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.`);
|
package/github-release.mjs
CHANGED
|
@@ -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, `${
|
|
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
|
|
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
|
-
`
|
|
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"
|
|
76
|
-
|
|
77
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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 ?
|
|
217
|
-
if (owner
|
|
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"}`);
|
package/heavy-command.test.mjs
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts,
|
|
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/
|
|
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("
|
|
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, /
|
|
68
|
-
assert.match(
|
|
69
|
-
assert.
|
|
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
|
|
106
|
+
test("upload uses GitHub CLI and never invokes the R2 runner", () => {
|
|
105
107
|
const source = readFileSync(upload, "utf8");
|
|
106
|
-
|
|
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
|
+
});
|