@rightkit/release 0.2.49 → 0.2.51
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 +25 -8
- package/build-release.test.mjs +7 -0
- package/cache-command.mjs +1 -1
- package/cache-command.test.mjs +1 -0
- package/cache-policy.mjs +25 -3
- package/cache-policy.test.mjs +27 -1
- package/cargo-guard.mjs +52 -0
- package/cargo-guard.test.mjs +39 -0
- package/heavy-command.mjs +317 -0
- package/heavy-command.test.mjs +164 -0
- package/model-promote.mjs +21 -1
- package/model-promote.test.mjs +51 -7
- package/package.json +1 -1
- package/release-invocation.mjs +20 -1
- package/release-invocation.test.mjs +21 -0
- package/right-suite-contract.test.mjs +48 -2
- package/rightkit-versions.json +3 -3
- package/source-gate.mjs +19 -0
- package/source-gate.test.mjs +25 -0
- package/standalone-clone-verify.test.mjs +3 -3
- package/upload-release.mjs +8 -1
package/build-release.mjs
CHANGED
|
@@ -26,6 +26,8 @@ import { acquireCacheLease, acquireSuiteBuildSlot, applyCachePrune, assertWriteV
|
|
|
26
26
|
import { createTargetBridge } from "./target-bridge.mjs";
|
|
27
27
|
import { assertNsisInPlaceUpgradeContract } from "./nsis-upgrade-contract.mjs";
|
|
28
28
|
import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures } from "./preflight.mjs";
|
|
29
|
+
import { assertCleanSource } from "./source-gate.mjs";
|
|
30
|
+
import { acquireHeavyWorkSlot, heavyCommandEnvironment, terminateProcessTree } from "./heavy-command.mjs";
|
|
29
31
|
|
|
30
32
|
/** Assemble preflight inputs from the app's own files (mirrors release.mjs). */
|
|
31
33
|
function buildPreflight({ config, configPath, appRoot, repoRoot, platform }) {
|
|
@@ -79,11 +81,11 @@ if (relativeConfig.startsWith("../")) fail("release config must live inside the
|
|
|
79
81
|
const appRoot = path.dirname(configPath);
|
|
80
82
|
const layout = resolveReleaseLayout({ repoRoot, configPath });
|
|
81
83
|
const vaultRoot = layout.vaultRoot;
|
|
82
|
-
|
|
83
|
-
const lock = acquireLock(path.join(vaultRoot, "locks", `${platform}.lock.json`));
|
|
84
|
+
let lock;
|
|
84
85
|
let child = null;
|
|
85
86
|
let suiteSlot;
|
|
86
87
|
let cacheLease;
|
|
88
|
+
let heavySlot;
|
|
87
89
|
let interrupted;
|
|
88
90
|
|
|
89
91
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
@@ -97,6 +99,12 @@ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
|
97
99
|
try {
|
|
98
100
|
throwIfInterrupted();
|
|
99
101
|
const commit = git(repoRoot, ["rev-parse", "HEAD"]);
|
|
102
|
+
assertCleanSource({
|
|
103
|
+
status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]),
|
|
104
|
+
commandId: "right-release build",
|
|
105
|
+
});
|
|
106
|
+
mkdirSync(path.join(vaultRoot, "locks"), { recursive: true });
|
|
107
|
+
lock = acquireLock(path.join(vaultRoot, "locks", `${platform}.lock.json`));
|
|
100
108
|
const shortCommit = commit.slice(0, 8);
|
|
101
109
|
const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
|
|
102
110
|
if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
|
|
@@ -143,7 +151,7 @@ try {
|
|
|
143
151
|
// Perl first so the build behaves the same from any shell.
|
|
144
152
|
const pathPrefix = buildPathPrefix({ platform });
|
|
145
153
|
const env = {
|
|
146
|
-
...process.env,
|
|
154
|
+
...heavyCommandEnvironment(process.env),
|
|
147
155
|
...releaseEnvironment({ root: vaultRoot, cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, cacheKey, kind: "release", appRoot, mode: cacheMode }),
|
|
148
156
|
...(pathPrefix ? { PATH: `${pathPrefix}${path.delimiter}${process.env.PATH ?? ""}` } : {}),
|
|
149
157
|
RIGHT_RELEASE_REPO_ROOT: layout.repoRoot,
|
|
@@ -152,6 +160,7 @@ try {
|
|
|
152
160
|
if (cacheMode === "legacy") delete env.RIGHT_RELEASE_CACHE_OWNER;
|
|
153
161
|
if (cacheMode === "shared" && env.RIGHT_RELEASE_CACHE_OWNER !== "rightkit-v2") fail("shared cache ownership token was not configured");
|
|
154
162
|
if (cacheMode === "legacy" && !commandExists("sccache")) delete env.RUSTC_WRAPPER;
|
|
163
|
+
if (process.env.RIGHTSUITE_HEAVY_WORK_OWNER !== "1") heavySlot = acquireHeavyWorkSlot();
|
|
155
164
|
if (cacheMode === "shared") {
|
|
156
165
|
suiteSlot = acquireSuiteBuildSlot({ layout: sharedLayout, pid: process.pid, argv: process.argv, waitMs: Number(process.env.BUILD_GUARD_WAIT_SECS ?? 900) * 1000 });
|
|
157
166
|
cacheLease = acquireCacheLease({ layout: sharedLayout, pid: process.pid, argv: process.argv });
|
|
@@ -254,7 +263,8 @@ try {
|
|
|
254
263
|
} finally {
|
|
255
264
|
cacheLease?.release();
|
|
256
265
|
suiteSlot?.release();
|
|
257
|
-
|
|
266
|
+
heavySlot?.release();
|
|
267
|
+
lock?.release();
|
|
258
268
|
}
|
|
259
269
|
|
|
260
270
|
function hashInputs(files) {
|
|
@@ -354,7 +364,16 @@ async function runProgress(cmd, runArgs, cwd, env, watchDir) {
|
|
|
354
364
|
let lastMtime = Math.max(...watchDirs.map(newestMtime));
|
|
355
365
|
const started = Date.now();
|
|
356
366
|
await new Promise((resolve, reject) => {
|
|
357
|
-
child = spawn(cmd, runArgs, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: process.platform === "win32" });
|
|
367
|
+
child = spawn(cmd, runArgs, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: process.platform === "win32", detached: process.platform !== "win32" });
|
|
368
|
+
try {
|
|
369
|
+
if (child.pid && heavySlot) heavySlot.trackChild(child.pid, cmd);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
child.once("error", () => {});
|
|
372
|
+
if (child.pid) killTree(child.pid);
|
|
373
|
+
child = null;
|
|
374
|
+
reject(error);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
358
377
|
const closeWatchers = watchProgress(watchDirs, () => { lastProgress = Date.now(); });
|
|
359
378
|
for (const [stream, output] of [[child.stdout, process.stdout], [child.stderr, process.stderr]]) {
|
|
360
379
|
stream.on("data", (chunk) => { lastProgress = Date.now(); output.write(chunk); });
|
|
@@ -454,9 +473,7 @@ function runChecked(cmd, runArgs, cwd, env = process.env) {
|
|
|
454
473
|
}
|
|
455
474
|
|
|
456
475
|
function killTree(pid) {
|
|
457
|
-
|
|
458
|
-
if (process.platform === "win32") spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
459
|
-
else { try { process.kill(-pid, "SIGTERM"); } catch { try { process.kill(pid, "SIGTERM"); } catch { /* gone */ } } }
|
|
476
|
+
terminateProcessTree(pid);
|
|
460
477
|
}
|
|
461
478
|
|
|
462
479
|
function writeJson(file, value) {
|
package/build-release.test.mjs
CHANGED
|
@@ -7,15 +7,22 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
const source = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "build-release.mjs"), "utf8");
|
|
8
8
|
|
|
9
9
|
test("macOS and Windows builds default to Cache V2 while explicit legacy mode remains available", () => {
|
|
10
|
+
assert.match(source, /assertCleanSource/);
|
|
11
|
+
assert.match(source, /right-release build/);
|
|
10
12
|
assert.match(source, /RIGHT_RELEASE_CACHE_MODE/);
|
|
11
13
|
assert.match(source, /platform === "mac" \|\| platform === "win" \? "shared" : "legacy"/);
|
|
12
14
|
assert.match(source, /cacheMode !== "legacy" && cacheMode !== "shared"/);
|
|
13
15
|
assert.match(source, /resolveSharedCacheRoot/);
|
|
14
16
|
assert.match(source, /acquireSuiteBuildSlot/);
|
|
17
|
+
assert.match(source, /acquireHeavyWorkSlot/);
|
|
18
|
+
assert.match(source, /heavyCommandEnvironment/);
|
|
19
|
+
assert.match(source, /heavySlot\.trackChild/);
|
|
20
|
+
assert.match(source, /detached: process\.platform !== "win32"/);
|
|
15
21
|
assert.match(source, /acquireCacheLease/);
|
|
16
22
|
assert.match(source, /markCacheEntrySuccessful/);
|
|
17
23
|
assert.match(source, /RIGHT_RELEASE_CACHE_OWNER/);
|
|
18
24
|
assert.match(source, /suiteSlot\?\.release\(\)/);
|
|
25
|
+
assert.match(source, /heavySlot\?\.release\(\)/);
|
|
19
26
|
assert.match(source, /cacheLease\?\.release\(\)/);
|
|
20
27
|
assert.doesNotMatch(source, /process\.exit\(signal/);
|
|
21
28
|
assert.match(source, /throwIfInterrupted/);
|
package/cache-command.mjs
CHANGED
|
@@ -58,7 +58,7 @@ export async function runCacheCommand(args = process.argv.slice(2), { env = proc
|
|
|
58
58
|
|
|
59
59
|
function output(value, json, stdout) {
|
|
60
60
|
if (json) stdout(JSON.stringify(value));
|
|
61
|
-
else if (value.command === "status") stdout(`RightKit cache root: ${value.cacheRoot}\nTargets: ${value.targetBytes} bytes\nFree: ${(value.cacheFreeBytes / 1024 ** 3).toFixed(1)} GiB\nLeases: ${value.entries.filter((entry) => entry.leased).length}`);
|
|
61
|
+
else if (value.command === "status") stdout(`RightKit cache root: ${value.cacheRoot}\nTargets: ${value.targetBytes} bytes\nBackups: ${value.backupBytes} bytes\nFree: ${(value.cacheFreeBytes / 1024 ** 3).toFixed(1)} GiB\nLeases: ${value.entries.filter((entry) => entry.leased).length}`);
|
|
62
62
|
else stdout(`RightKit cache ${value.command}: ${value.dryRun ? "dry-run" : "applied"}\nRoot: ${value.cacheRoot}\nCandidates: ${(value.candidateIds ?? value.plan?.candidateIds ?? []).join(", ") || "none"}\nReclaimed: ${value.reclaimedBytes ?? 0} bytes`);
|
|
63
63
|
return value;
|
|
64
64
|
}
|
package/cache-command.test.mjs
CHANGED
|
@@ -19,6 +19,7 @@ test("cache status has human and schema-stable JSON output", () => {
|
|
|
19
19
|
const json = run(["status", "--json"], cacheRoot);
|
|
20
20
|
assert.equal(human.status, 0, human.stderr);
|
|
21
21
|
assert.match(human.stdout, /RightKit cache root:/);
|
|
22
|
+
assert.match(human.stdout, /Backups: 0 bytes/);
|
|
22
23
|
assert.equal(json.status, 0, json.stderr);
|
|
23
24
|
assert.equal(JSON.parse(json.stdout).schema, 1);
|
|
24
25
|
});
|
package/cache-policy.mjs
CHANGED
|
@@ -24,6 +24,7 @@ export const DEFAULT_TARGET_MAX_BYTES = 12 * 1024 ** 3;
|
|
|
24
24
|
export const DEFAULT_SCCACHE_MAX_BYTES = 32 * 1024 ** 3;
|
|
25
25
|
export const DEFAULT_DESIRED_FREE_BYTES = 60 * 1024 ** 3;
|
|
26
26
|
export const DEFAULT_HARD_FREE_BYTES = 25 * 1024 ** 3;
|
|
27
|
+
export const DEFAULT_BACKUP_RETENTION_DAYS = 7;
|
|
27
28
|
// Any build holds release -> suite slot -> GC -> entry lease; prune holds only GC.
|
|
28
29
|
export const CACHE_LOCK_ORDER = ["release", "suite-build-slot", "gc", "entry-lease"];
|
|
29
30
|
|
|
@@ -84,6 +85,7 @@ export function readCachePolicy(env = process.env) {
|
|
|
84
85
|
sccacheMaxBytes: readPositive(env.RIGHT_RELEASE_SCCACHE_MAX_BYTES, DEFAULT_SCCACHE_MAX_BYTES),
|
|
85
86
|
desiredFreeBytes: readPositive(env.RIGHT_RELEASE_DESIRED_FREE_BYTES, DEFAULT_DESIRED_FREE_BYTES),
|
|
86
87
|
hardFreeBytes: readPositive(env.RIGHT_RELEASE_HARD_FREE_BYTES, DEFAULT_HARD_FREE_BYTES),
|
|
88
|
+
backupRetentionDays: readPositive(env.RIGHT_RELEASE_BACKUP_RETENTION_DAYS, DEFAULT_BACKUP_RETENTION_DAYS),
|
|
87
89
|
};
|
|
88
90
|
}
|
|
89
91
|
|
|
@@ -197,7 +199,7 @@ export function markCacheEntrySuccessful({ layout, now = new Date() } = {}) {
|
|
|
197
199
|
|
|
198
200
|
export function inspectCache({ cacheRoot, statfs = statfsSync, now = new Date() } = {}) {
|
|
199
201
|
const root = readonlyRoot(cacheRoot);
|
|
200
|
-
if (!root) return { cacheRoot: path.resolve(cacheRoot), entries: [], targetBytes: 0, cacheFreeBytes: 0, device: undefined, inspectedAt: iso(now) };
|
|
202
|
+
if (!root) return { cacheRoot: path.resolve(cacheRoot), entries: [], backups: [], targetBytes: 0, backupBytes: 0, cacheFreeBytes: 0, device: undefined, inspectedAt: iso(now) };
|
|
201
203
|
const entries = [];
|
|
202
204
|
for (const kind of ["targets", "test-targets"]) {
|
|
203
205
|
const kindRoot = path.join(root, kind);
|
|
@@ -215,8 +217,16 @@ export function inspectCache({ cacheRoot, statfs = statfsSync, now = new Date()
|
|
|
215
217
|
entries.push({ id: marker.id, dir, marker, bytes: directoryBytes(dir), leased: live, lease: live ? lease : null });
|
|
216
218
|
}
|
|
217
219
|
}
|
|
220
|
+
const backups = [];
|
|
221
|
+
for (const namespace of BACKUP_NAMESPACES) {
|
|
222
|
+
const namespaceRoot = path.join(root, namespace);
|
|
223
|
+
for (const entry of safeDirEntries(namespaceRoot)) {
|
|
224
|
+
const stat = lstatSync(entry.path);
|
|
225
|
+
backups.push({ id: `backup:${namespace}:${entry.name}`, namespace, name: entry.name, dir: entry.path, bytes: directoryBytes(entry.path), mtimeMs: stat.mtimeMs });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
218
228
|
const fs = statfs(root, { bigint: false });
|
|
219
|
-
return { cacheRoot: root, entries, targetBytes: entries.filter((x) => x.marker.kind === "release").reduce((sum, x) => sum + x.bytes, 0), cacheFreeBytes: freeBytes(fs), device: fs.dev ?? safeDevice(root), inspectedAt: iso(now) };
|
|
229
|
+
return { cacheRoot: root, entries, backups, targetBytes: entries.filter((x) => x.marker.kind === "release").reduce((sum, x) => sum + x.bytes, 0), backupBytes: backups.reduce((sum, x) => sum + x.bytes, 0), cacheFreeBytes: freeBytes(fs), device: fs.dev ?? safeDevice(root), inspectedAt: iso(now) };
|
|
220
230
|
}
|
|
221
231
|
|
|
222
232
|
export function inspectWriteVolumes({ repoRoot, vaultRoot, cacheRoot, statfs = statfsSync } = {}) {
|
|
@@ -253,6 +263,10 @@ export function planCachePrune({ snapshot, policy = readCachePolicy(), protected
|
|
|
253
263
|
let bytes = snapshot.targetBytes ?? 0;
|
|
254
264
|
let free = snapshot.cacheFreeBytes ?? Infinity;
|
|
255
265
|
const selected = [];
|
|
266
|
+
const backupCutoff = new Date(snapshot.inspectedAt ?? Date.now()).getTime() - policy.backupRetentionDays * 86_400_000;
|
|
267
|
+
for (const entry of snapshot.backups ?? []) {
|
|
268
|
+
if (entry.mtimeMs < backupCutoff) selected.push({ id: entry.id, dir: entry.dir, bytes: entry.bytes, reason: "expired-backup" });
|
|
269
|
+
}
|
|
256
270
|
for (const entry of candidates) {
|
|
257
271
|
if (bytes <= policy.targetMaxBytes && free >= policy.desiredFreeBytes) break;
|
|
258
272
|
selected.push({ id: entry.id, dir: entry.dir, bytes: entry.bytes, reason: bytes > policy.targetMaxBytes ? "target-cap" : "free-space" });
|
|
@@ -426,10 +440,18 @@ function acquireAtomicLock(file, payload, label, now) {
|
|
|
426
440
|
function releaseFor(file, payload) { let released = false; return { release() { if (released) return; released = true; const current = readJson(file); if (current?.pid === payload.pid && current?.createdAt === payload.createdAt) safeUnlink(file); } }; }
|
|
427
441
|
function touchEntry(layout, now) { const entry = readJson(layout.markerPath); if (!validEntry(entry, layout)) throw new Error(`invalid cache entry marker: ${layout.markerPath}`); entry.lastUsedAt = iso(now); atomicJson(layout.markerPath, entry); }
|
|
428
442
|
function defaultSleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
443
|
+
const BACKUP_NAMESPACES = ["recovery", "manual-backups", "dev-target-backups", "legacy-backups"];
|
|
429
444
|
function safePruneCandidate(root, candidate) {
|
|
430
445
|
if (!candidate?.dir || path.resolve(candidate.dir) === root) return false;
|
|
431
446
|
if (!existsSync(candidate.dir) || lstatSync(candidate.dir).isSymbolicLink()) return false;
|
|
432
|
-
const dir = realpathSync(candidate.dir);
|
|
447
|
+
const dir = realpathSync(candidate.dir);
|
|
448
|
+
if (candidate.id?.startsWith("backup:")) {
|
|
449
|
+
const [, namespace, name] = candidate.id.split(":");
|
|
450
|
+
if (!BACKUP_NAMESPACES.includes(namespace) || !name || path.basename(dir) !== name) return false;
|
|
451
|
+
const backupRoot = path.join(root, namespace);
|
|
452
|
+
try { return path.dirname(dir) === backupRoot && realpathSync(backupRoot) === backupRoot; } catch { return false; }
|
|
453
|
+
}
|
|
454
|
+
const targets = [path.join(root, "targets"), path.join(root, "test-targets")];
|
|
433
455
|
const targetRoot = targets.find((item) => { try { assertLexicalInside(item, dir); return true; } catch { return false; } });
|
|
434
456
|
if (!targetRoot) return false;
|
|
435
457
|
try {
|
package/cache-policy.test.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
@@ -8,6 +8,7 @@ import { once } from "node:events";
|
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
CACHE_SCHEMA,
|
|
11
|
+
DEFAULT_BACKUP_RETENTION_DAYS,
|
|
11
12
|
DEFAULT_DESIRED_FREE_BYTES,
|
|
12
13
|
DEFAULT_HARD_FREE_BYTES,
|
|
13
14
|
DEFAULT_SCCACHE_MAX_BYTES,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
applyCachePrune,
|
|
18
19
|
assertWriteVolumeFloors,
|
|
19
20
|
ensureCacheEntry,
|
|
21
|
+
inspectCache,
|
|
20
22
|
inspectWriteVolumes,
|
|
21
23
|
markCacheEntrySuccessful,
|
|
22
24
|
migrateLegacyCache,
|
|
@@ -80,6 +82,30 @@ test("policy defaults are the approved target, compiler, desired and hard limits
|
|
|
80
82
|
assert.equal(policy.sccacheMaxBytes, DEFAULT_SCCACHE_MAX_BYTES);
|
|
81
83
|
assert.equal(policy.desiredFreeBytes, DEFAULT_DESIRED_FREE_BYTES);
|
|
82
84
|
assert.equal(policy.hardFreeBytes, DEFAULT_HARD_FREE_BYTES);
|
|
85
|
+
assert.equal(policy.backupRetentionDays, DEFAULT_BACKUP_RETENTION_DAYS);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("pruning expires only old direct backup directories", () => {
|
|
89
|
+
const base = root();
|
|
90
|
+
const old = path.join(base, "manual-backups", "old-target");
|
|
91
|
+
const recent = path.join(base, "recovery", "recent-target");
|
|
92
|
+
const outside = root();
|
|
93
|
+
mkdirSync(old, { recursive: true }); writeFileSync(path.join(old, "artifact"), "old");
|
|
94
|
+
mkdirSync(recent, { recursive: true }); writeFileSync(path.join(recent, "artifact"), "recent");
|
|
95
|
+
mkdirSync(outside, { recursive: true });
|
|
96
|
+
mkdirSync(path.join(base, "legacy-backups"), { recursive: true });
|
|
97
|
+
symlinkSync(outside, path.join(base, "legacy-backups", "escape"));
|
|
98
|
+
utimesSync(old, new Date("2026-07-01T00:00:00Z"), new Date("2026-07-01T00:00:00Z"));
|
|
99
|
+
utimesSync(recent, new Date("2026-08-07T00:00:00Z"), new Date("2026-08-07T00:00:00Z"));
|
|
100
|
+
const snapshot = inspectCache({ cacheRoot: base, now: new Date("2026-08-08T00:00:00Z"), statfs: () => ({ bavail: 100, bsize: 1 }) });
|
|
101
|
+
assert.deepEqual(snapshot.backups.map((entry) => entry.id).sort(), ["backup:manual-backups:old-target", "backup:recovery:recent-target"]);
|
|
102
|
+
const plan = planCachePrune({ snapshot, policy: { ...readCachePolicy({}), targetMaxBytes: 1, desiredFreeBytes: 0 } });
|
|
103
|
+
assert.deepEqual(plan.candidateIds, ["backup:manual-backups:old-target"]);
|
|
104
|
+
const result = applyCachePrune({ plan, cacheRoot: base, dryRun: false });
|
|
105
|
+
assert.deepEqual(result.removedIds, ["backup:manual-backups:old-target"]);
|
|
106
|
+
assert.equal(existsSync(old), false);
|
|
107
|
+
assert.equal(existsSync(recent), true);
|
|
108
|
+
assert.equal(existsSync(outside), true);
|
|
83
109
|
});
|
|
84
110
|
|
|
85
111
|
test("entry metadata is atomic, complete, and updates success only after requested", () => {
|
package/cargo-guard.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { runHeavyCommand } from "./heavy-command.mjs";
|
|
7
|
+
|
|
8
|
+
const LIGHT_COMMANDS = new Set(["fmt", "metadata", "tree", "fetch", "search", "locate-project", "read-manifest", "help", "version", "--version", "-V"]);
|
|
9
|
+
const GLOBAL_OPTIONS_WITH_VALUE = new Set(["--color", "--config", "-Z"]);
|
|
10
|
+
|
|
11
|
+
export function cargoSubcommand(args) {
|
|
12
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
13
|
+
const value = args[index];
|
|
14
|
+
if (value.startsWith("+") || value.startsWith("--color=") || value.startsWith("--config=")) continue;
|
|
15
|
+
if (GLOBAL_OPTIONS_WITH_VALUE.has(value)) { index += 1; continue; }
|
|
16
|
+
if (value.startsWith("-Z")) continue;
|
|
17
|
+
if (!value.startsWith("-") || value === "--version" || value === "-V") return value;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function shouldGuardCargo(args) {
|
|
23
|
+
const command = cargoSubcommand(args);
|
|
24
|
+
return command !== null && !LIGHT_COMMANDS.has(command);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function resolveRealCargo({ env = process.env, run = spawnSync } = {}) {
|
|
28
|
+
if (env.RIGHTSUITE_REAL_CARGO) return path.resolve(env.RIGHTSUITE_REAL_CARGO);
|
|
29
|
+
const rustup = process.platform === "win32" ? "rustup.exe" : "rustup";
|
|
30
|
+
const result = run(rustup, ["which", "cargo"], { encoding: "utf8", windowsHide: true });
|
|
31
|
+
const cargo = String(result.stdout ?? "").trim();
|
|
32
|
+
if (result.status !== 0 || !cargo) throw new Error(`cargo-guard could not resolve real Cargo: ${String(result.stderr ?? "").trim()}`);
|
|
33
|
+
return path.resolve(cargo);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function spawnCargo(command, args, env) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const child = spawn(command, args, { env, stdio: "inherit", windowsHide: true });
|
|
39
|
+
child.once("error", reject);
|
|
40
|
+
child.once("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function runCargoGuard(args, { env = process.env, resolveCargo = resolveRealCargo, runHeavy = runHeavyCommand, runLight = spawnCargo } = {}) {
|
|
45
|
+
const cargo = resolveCargo({ env });
|
|
46
|
+
if (shouldGuardCargo(args)) return runHeavy(["--", cargo, ...args], { env });
|
|
47
|
+
return runLight(cargo, args, env);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
51
|
+
runCargoGuard(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => { console.error(`cargo-guard: ${error.message}`); process.exitCode = 1; });
|
|
52
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { cargoSubcommand, resolveRealCargo, runCargoGuard, shouldGuardCargo } from "./cargo-guard.mjs";
|
|
5
|
+
|
|
6
|
+
test("Cargo guard serializes compiling commands but bypasses inspection and formatting", () => {
|
|
7
|
+
for (const args of [["build"], ["test"], ["check"], ["clippy"], ["nextest", "run"], ["clean"], ["+stable", "bench"]]) {
|
|
8
|
+
assert.equal(shouldGuardCargo(args), true, args.join(" "));
|
|
9
|
+
}
|
|
10
|
+
for (const args of [["fmt", "--check"], ["metadata"], ["tree"], ["fetch"], ["--version"], []]) {
|
|
11
|
+
assert.equal(shouldGuardCargo(args), false, args.join(" "));
|
|
12
|
+
}
|
|
13
|
+
assert.equal(cargoSubcommand(["+stable", "test"]), "test");
|
|
14
|
+
assert.equal(shouldGuardCargo(["--color", "always", "fmt"]), false);
|
|
15
|
+
assert.equal(shouldGuardCargo(["--config", "net.git-fetch-with-cli=true", "check"]), true);
|
|
16
|
+
assert.equal(shouldGuardCargo(["-Z", "unstable-options", "fmt"]), false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("Cargo guard resolves real Cargo through rustup or explicit override", () => {
|
|
20
|
+
assert.equal(resolveRealCargo({ env: { RIGHTSUITE_REAL_CARGO: "/opt/toolchain/cargo" } }), "/opt/toolchain/cargo");
|
|
21
|
+
const calls = [];
|
|
22
|
+
const cargo = resolveRealCargo({ env: {}, run: (command, args) => { calls.push([command, args]); return { status: 0, stdout: "/real/cargo\n" }; } });
|
|
23
|
+
assert.equal(cargo, "/real/cargo");
|
|
24
|
+
assert.deepEqual(calls[0][1], ["which", "cargo"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("Cargo guard routes heavy and light commands without recursion", async () => {
|
|
28
|
+
const calls = [];
|
|
29
|
+
const options = {
|
|
30
|
+
env: { TEST: "1" },
|
|
31
|
+
resolveCargo: () => "/real/cargo",
|
|
32
|
+
runHeavy: async (args, config) => { calls.push(["heavy", args, config.env]); return 0; },
|
|
33
|
+
runLight: async (command, args, env) => { calls.push(["light", command, args, env]); return 0; },
|
|
34
|
+
};
|
|
35
|
+
assert.equal(await runCargoGuard(["test"], options), 0);
|
|
36
|
+
assert.equal(await runCargoGuard(["fmt", "--check"], options), 0);
|
|
37
|
+
assert.deepEqual(calls[0], ["heavy", ["--", "/real/cargo", "test"], options.env]);
|
|
38
|
+
assert.deepEqual(calls[1], ["light", "/real/cargo", ["fmt", "--check"], options.env]);
|
|
39
|
+
});
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
const GB = 1024 ** 3;
|
|
10
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
11
|
+
|
|
12
|
+
export function heavyWorkRoot({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
13
|
+
if (env.RIGHTSUITE_HEAVY_WORK_ROOT) return path.resolve(env.RIGHTSUITE_HEAVY_WORK_ROOT);
|
|
14
|
+
if (platform === "win32" || platform === "win") {
|
|
15
|
+
if (!env.LOCALAPPDATA) throw new Error("LOCALAPPDATA is required for the RightSuite heavy-work guard");
|
|
16
|
+
return path.win32.resolve(env.LOCALAPPDATA, "RightSuite", "heavy-work");
|
|
17
|
+
}
|
|
18
|
+
if (platform === "darwin" || platform === "mac") return path.join(home, "Library", "Caches", "RightSuite", "heavy-work");
|
|
19
|
+
return path.resolve(env.XDG_CACHE_HOME || path.join(home, ".cache"), "rightsuite", "heavy-work");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function heavyCommandEnvironment(env = process.env) {
|
|
23
|
+
return {
|
|
24
|
+
...env,
|
|
25
|
+
CARGO_BUILD_JOBS: env.CARGO_BUILD_JOBS || env.BUILD_GUARD_CARGO_JOBS || "2",
|
|
26
|
+
RUST_TEST_THREADS: env.RUST_TEST_THREADS || env.BUILD_GUARD_TEST_THREADS || "2",
|
|
27
|
+
RIGHTSUITE_HEAVY_WORK_OWNER: "1",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function heavyChildDetached({ slot, platform = process.platform } = {}) {
|
|
32
|
+
return Boolean(slot) && platform !== "win32" && platform !== "win";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseMacResourceSnapshot({ memory = "", swap = "", thermal = "" } = {}) {
|
|
36
|
+
const freePercent = Number(memory.match(/memory free percentage:\s*(\d+(?:\.\d+)?)%/i)?.[1]);
|
|
37
|
+
const swapMatch = swap.match(/total\s*=\s*([\d.]+)M\s+used\s*=\s*([\d.]+)M/i);
|
|
38
|
+
const speedLimit = Number(thermal.match(/CPU_Speed_Limit\s*=\s*(\d+)/i)?.[1]);
|
|
39
|
+
const warning = Number(thermal.match(/(?:thermal|performance) warning level\s*[:=]\s*(\d+)/i)?.[1]);
|
|
40
|
+
return {
|
|
41
|
+
freePercent: Number.isFinite(freePercent) ? freePercent : null,
|
|
42
|
+
swapTotalBytes: swapMatch ? Number(swapMatch[1]) * 1024 ** 2 : null,
|
|
43
|
+
swapUsedBytes: swapMatch ? Number(swapMatch[2]) * 1024 ** 2 : null,
|
|
44
|
+
thermalLimited: Number.isFinite(speedLimit) ? speedLimit < 100 : Number.isFinite(warning) ? warning > 0 : false,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseWindowsResourceSnapshot({ system = "", fallbackTotalBytes = os.totalmem(), fallbackFreeBytes = os.freemem() } = {}) {
|
|
49
|
+
let parsed = {};
|
|
50
|
+
try { parsed = JSON.parse(String(system).trim().replace(/^\uFEFF/, "")); } catch { /* use Node memory fallback */ }
|
|
51
|
+
const totalMemoryBytes = Number(parsed.TotalBytes) > 0 ? Number(parsed.TotalBytes) : Number(fallbackTotalBytes);
|
|
52
|
+
const freeMemoryBytes = Number(parsed.FreeBytes) >= 0 ? Number(parsed.FreeBytes) : Number(fallbackFreeBytes);
|
|
53
|
+
const cpuLoadPercent = parsed.CpuLoadPercent === null || parsed.CpuLoadPercent === undefined ? null : Number(parsed.CpuLoadPercent);
|
|
54
|
+
return {
|
|
55
|
+
freePercent: totalMemoryBytes > 0 ? Math.round((freeMemoryBytes / totalMemoryBytes) * 1000) / 10 : null,
|
|
56
|
+
totalMemoryBytes,
|
|
57
|
+
freeMemoryBytes,
|
|
58
|
+
cpuLoadPercent: Number.isFinite(cpuLoadPercent) ? cpuLoadPercent : null,
|
|
59
|
+
thermalLimited: false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function systemResourceSnapshot({ platform = process.platform, run = spawnSync, totalmem = os.totalmem, freemem = os.freemem } = {}) {
|
|
64
|
+
if (platform === "win32" || platform === "win") {
|
|
65
|
+
const script = "$os=Get-CimInstance Win32_OperatingSystem;$cpu=(Get-CimInstance Win32_Processor|Measure-Object LoadPercentage -Average).Average;[pscustomobject]@{TotalBytes=[double]$os.TotalVisibleMemorySize*1024;FreeBytes=[double]$os.FreePhysicalMemory*1024;CpuLoadPercent=[double]$cpu}|ConvertTo-Json -Compress";
|
|
66
|
+
const result = run("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true });
|
|
67
|
+
return { platform, ...parseWindowsResourceSnapshot({ system: result.status === 0 ? result.stdout : "", fallbackTotalBytes: totalmem(), fallbackFreeBytes: freemem() }) };
|
|
68
|
+
}
|
|
69
|
+
if (platform !== "darwin" && platform !== "mac") return { platform, freePercent: null, thermalLimited: false };
|
|
70
|
+
const output = (command, args) => {
|
|
71
|
+
const result = run(command, args, { encoding: "utf8", windowsHide: true });
|
|
72
|
+
return result.status === 0 ? String(result.stdout ?? result.stderr ?? "") : "";
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
platform,
|
|
76
|
+
...parseMacResourceSnapshot({
|
|
77
|
+
memory: output("memory_pressure", ["-Q"]),
|
|
78
|
+
swap: output("sysctl", ["vm.swapusage"]),
|
|
79
|
+
thermal: output("pmset", ["-g", "therm"]),
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resourceBlockers(snapshot, { minFreePercent = 15, maxCpuLoadPercent = 90 } = {}) {
|
|
85
|
+
const blockers = [];
|
|
86
|
+
if (snapshot.freePercent !== null && snapshot.freePercent < minFreePercent) {
|
|
87
|
+
blockers.push(`memory free ${snapshot.freePercent}% < ${minFreePercent}%`);
|
|
88
|
+
}
|
|
89
|
+
if (Number.isFinite(snapshot.cpuLoadPercent) && snapshot.cpuLoadPercent > maxCpuLoadPercent) {
|
|
90
|
+
blockers.push(`CPU load ${snapshot.cpuLoadPercent}% > ${maxCpuLoadPercent}%`);
|
|
91
|
+
}
|
|
92
|
+
if (snapshot.thermalLimited) blockers.push("macOS reports thermal/performance limiting");
|
|
93
|
+
return blockers;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function formatResourceSnapshot(snapshot) {
|
|
97
|
+
const fields = [];
|
|
98
|
+
if (snapshot.freePercent !== null) fields.push(`memory-free=${snapshot.freePercent}%`);
|
|
99
|
+
if (Number.isFinite(snapshot.swapUsedBytes) && Number.isFinite(snapshot.swapTotalBytes)) {
|
|
100
|
+
fields.push(`swap=${(snapshot.swapUsedBytes / GB).toFixed(1)}/${(snapshot.swapTotalBytes / GB).toFixed(1)}GiB`);
|
|
101
|
+
}
|
|
102
|
+
if (Number.isFinite(snapshot.cpuLoadPercent)) fields.push(`cpu-load=${snapshot.cpuLoadPercent}%`);
|
|
103
|
+
if (snapshot.platform === "darwin" || snapshot.platform === "mac" || snapshot.thermalLimited) {
|
|
104
|
+
fields.push(`thermal=${snapshot.thermalLimited ? "limited" : "ok"}`);
|
|
105
|
+
}
|
|
106
|
+
return fields.join(" ");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function processAlive(pid) {
|
|
110
|
+
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
|
|
111
|
+
try { process.kill(Number(pid), 0); return true; } catch (error) { return error?.code === "EPERM"; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function processGroupAlive(pid) {
|
|
115
|
+
try { process.kill(-Number(pid), 0); return true; } catch (error) { return error?.code === "EPERM"; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sleep(ms) {
|
|
119
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readOwner(lockDir) {
|
|
123
|
+
try {
|
|
124
|
+
const owner = JSON.parse(readFileSync(path.join(lockDir, "owner.json"), "utf8"));
|
|
125
|
+
try { return { ...owner, ...JSON.parse(readFileSync(path.join(lockDir, "child.json"), "utf8")) }; } catch { return owner; }
|
|
126
|
+
} catch { return null; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function writeTrackedChild(lockDir, owner, child) {
|
|
130
|
+
const file = path.join(lockDir, "child.json");
|
|
131
|
+
const temporary = path.join(lockDir, `.child-${owner.token}.tmp`);
|
|
132
|
+
writeFileSync(temporary, `${JSON.stringify(child)}\n`, { mode: 0o600 });
|
|
133
|
+
renameSync(temporary, file);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function processStartedAt(pid, { platform = process.platform, run = spawnSync } = {}) {
|
|
137
|
+
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return null;
|
|
138
|
+
const result = platform === "win32" || platform === "win"
|
|
139
|
+
? run("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${Number(pid)} -ErrorAction Stop).StartTime.ToUniversalTime().ToString('o')`], { encoding: "utf8", windowsHide: true })
|
|
140
|
+
: run("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", windowsHide: true });
|
|
141
|
+
if (result.status !== 0) return null;
|
|
142
|
+
const value = Date.parse(String(result.stdout ?? "").trim());
|
|
143
|
+
return Number.isFinite(value) ? value : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function terminateProcessTree(pid, { platform = process.platform, run = spawnSync, kill = process.kill, treeAlive = processGroupAlive, pause = sleep } = {}) {
|
|
147
|
+
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return;
|
|
148
|
+
if (platform === "win32" || platform === "win") {
|
|
149
|
+
run("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
try { kill(-Number(pid), "SIGTERM"); } catch { try { kill(Number(pid), "SIGTERM"); } catch { return; } }
|
|
153
|
+
pause(500);
|
|
154
|
+
if (treeAlive(pid)) { try { kill(-Number(pid), "SIGKILL"); } catch { try { kill(Number(pid), "SIGKILL"); } catch { /* gone */ } } }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function watchOwnedProcessTree(
|
|
158
|
+
{ root, token, ownerPid, childPid, childStartedAtMs },
|
|
159
|
+
{ alive = processAlive, startedAt = processStartedAt, terminate = terminateProcessTree, pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {},
|
|
160
|
+
) {
|
|
161
|
+
const lockDir = path.join(root, "slot");
|
|
162
|
+
while (alive(ownerPid)) {
|
|
163
|
+
if (readOwner(lockDir)?.token !== token) return false;
|
|
164
|
+
await pause(500);
|
|
165
|
+
}
|
|
166
|
+
const owner = readOwner(lockDir);
|
|
167
|
+
if (owner?.token !== token || Number(owner.childPid) !== Number(childPid)) return false;
|
|
168
|
+
if (alive(childPid)) {
|
|
169
|
+
const observedStart = startedAt(childPid);
|
|
170
|
+
if (!Number.isFinite(observedStart) || Math.abs(observedStart - Number(childStartedAtMs)) > 2000) {
|
|
171
|
+
throw new Error(`watcher found reused child pid ${childPid}; refusing termination`);
|
|
172
|
+
}
|
|
173
|
+
terminate(childPid);
|
|
174
|
+
}
|
|
175
|
+
releaseOwned(lockDir, token);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function spawnOwnerWatcher(details) {
|
|
180
|
+
const payload = Buffer.from(JSON.stringify(details)).toString("base64url");
|
|
181
|
+
const watcher = spawn(process.execPath, [SELF, "--watch-owner-base64", payload], {
|
|
182
|
+
detached: true, env: process.env, stdio: "ignore", windowsHide: true,
|
|
183
|
+
});
|
|
184
|
+
watcher.once("error", () => {
|
|
185
|
+
const observedStart = processStartedAt(details.childPid);
|
|
186
|
+
if (Number.isFinite(observedStart) && Math.abs(observedStart - Number(details.childStartedAtMs)) <= 2000) {
|
|
187
|
+
terminateProcessTree(details.childPid);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
watcher.unref();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function acquireHeavyWorkSlot({
|
|
194
|
+
root = heavyWorkRoot(), pid = process.pid, argv = process.argv, waitMs = Number(process.env.BUILD_GUARD_WAIT_SECS ?? 900) * 1000,
|
|
195
|
+
pollMs = 1000, alive = processAlive, pause = sleep, snapshot = systemResourceSnapshot,
|
|
196
|
+
minFreePercent = Number(process.env.BUILD_GUARD_MIN_MEMORY_FREE_PERCENT ?? 15), log = (message) => console.error(`[heavy-work] ${message}`),
|
|
197
|
+
maxCpuLoadPercent = Number(process.env.BUILD_GUARD_MAX_CPU_LOAD_PERCENT ?? 90),
|
|
198
|
+
processStartedAt: startedAt = processStartedAt, terminate = terminateProcessTree, startWatcher = spawnOwnerWatcher,
|
|
199
|
+
} = {}) {
|
|
200
|
+
mkdirSync(root, { recursive: true });
|
|
201
|
+
const lockDir = path.join(root, "slot");
|
|
202
|
+
const token = randomUUID();
|
|
203
|
+
const started = Date.now();
|
|
204
|
+
let lastReport = 0;
|
|
205
|
+
for (;;) {
|
|
206
|
+
try {
|
|
207
|
+
mkdirSync(lockDir);
|
|
208
|
+
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`);
|
|
210
|
+
closeSync(fd);
|
|
211
|
+
break;
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (error.code !== "EEXIST") throw error;
|
|
214
|
+
const owner = readOwner(lockDir);
|
|
215
|
+
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 });
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (Date.now() - started >= waitMs) throw new Error(`heavy-work slot timed out after ${waitMs}ms; holder pid ${owner?.pid ?? "starting"}`);
|
|
229
|
+
if (Date.now() - lastReport >= 10_000) { log(`waiting for pid ${owner?.pid ?? "starting"}`); lastReport = Date.now(); }
|
|
230
|
+
pause(Math.min(pollMs, Math.max(1, waitMs - (Date.now() - started))));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (;;) {
|
|
235
|
+
const current = snapshot();
|
|
236
|
+
const blockers = resourceBlockers(current, { minFreePercent, maxCpuLoadPercent });
|
|
237
|
+
if (blockers.length === 0) {
|
|
238
|
+
log(`admitted ${formatResourceSnapshot(current)} jobs=${heavyCommandEnvironment().CARGO_BUILD_JOBS}`);
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
if (Date.now() - started >= waitMs) {
|
|
242
|
+
releaseOwned(lockDir, token);
|
|
243
|
+
throw new Error(`resource admission timed out: ${blockers.join(", ")}`);
|
|
244
|
+
}
|
|
245
|
+
if (Date.now() - lastReport >= 10_000) { log(`resource wait: ${blockers.join(", ")}`); lastReport = Date.now(); }
|
|
246
|
+
pause(Math.min(5000, Math.max(1, waitMs - (Date.now() - started))));
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
root,
|
|
250
|
+
trackChild(childPid, childCommand = "") {
|
|
251
|
+
const owner = readOwner(lockDir);
|
|
252
|
+
if (owner?.token !== token) throw new Error("heavy-work slot ownership changed before child tracking");
|
|
253
|
+
const childStartedAtMs = startedAt(childPid);
|
|
254
|
+
if (!Number.isFinite(childStartedAtMs)) {
|
|
255
|
+
if (!alive(childPid)) return false;
|
|
256
|
+
throw new Error(`heavy-work could not verify child pid ${childPid} start time`);
|
|
257
|
+
}
|
|
258
|
+
writeTrackedChild(lockDir, owner, { childPid: Number(childPid), childCommand: String(childCommand), childStartedAtMs });
|
|
259
|
+
startWatcher({ root, token, ownerPid: Number(pid), childPid: Number(childPid), childStartedAtMs });
|
|
260
|
+
return true;
|
|
261
|
+
},
|
|
262
|
+
release: () => releaseOwned(lockDir, token),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function releaseOwned(lockDir, token) {
|
|
267
|
+
const owner = readOwner(lockDir);
|
|
268
|
+
if (owner?.token === token) rmSync(lockDir, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function runHeavyCommand(args, { env = process.env } = {}) {
|
|
272
|
+
let command;
|
|
273
|
+
let commandArgs;
|
|
274
|
+
if (args[0] === "--shell-base64") {
|
|
275
|
+
const source = Buffer.from(args[1] || "", "base64").toString("utf8");
|
|
276
|
+
const shell = process.platform === "win32" ? (env.SHELL || "bash") : (env.SHELL || "/bin/zsh");
|
|
277
|
+
command = shell; commandArgs = ["-lc", source];
|
|
278
|
+
} else {
|
|
279
|
+
const separator = args.indexOf("--");
|
|
280
|
+
const values = separator >= 0 ? args.slice(separator + 1) : args;
|
|
281
|
+
[command, ...commandArgs] = values;
|
|
282
|
+
}
|
|
283
|
+
if (!command) throw new Error("heavy-command: expected -- <command> or --shell-base64 <value>");
|
|
284
|
+
if (env.RIGHTSUITE_HEAVY_WORK_OWNER === "1") return spawnAttached(command, commandArgs, env);
|
|
285
|
+
const slot = acquireHeavyWorkSlot();
|
|
286
|
+
try { return await spawnAttached(command, commandArgs, heavyCommandEnvironment(env), slot); } finally { slot.release(); }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function spawnAttached(command, args, env, slot) {
|
|
290
|
+
return new Promise((resolve, reject) => {
|
|
291
|
+
const child = spawn(command, args, { cwd: process.cwd(), env, stdio: "inherit", windowsHide: true, detached: heavyChildDetached({ slot }) });
|
|
292
|
+
try {
|
|
293
|
+
if (child.pid && slot) slot.trackChild(child.pid, command);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
child.once("error", () => {});
|
|
296
|
+
if (child.pid) terminateProcessTree(child.pid);
|
|
297
|
+
reject(error);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const handlers = new Map(["SIGINT", "SIGTERM", "SIGHUP"].map((signal) => [signal, () => {
|
|
301
|
+
if (child.pid) terminateProcessTree(child.pid);
|
|
302
|
+
process.exitCode = signal === "SIGINT" ? 130 : 143;
|
|
303
|
+
}]));
|
|
304
|
+
for (const [signal, handler] of handlers) process.once(signal, handler);
|
|
305
|
+
const cleanup = () => { for (const [signal, handler] of handlers) process.removeListener(signal, handler); };
|
|
306
|
+
child.once("error", (error) => { cleanup(); reject(error); });
|
|
307
|
+
child.once("exit", (code, signal) => { cleanup(); resolve(code ?? (signal ? 1 : 0)); });
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === SELF) {
|
|
312
|
+
const args = process.argv.slice(2);
|
|
313
|
+
const task = args[0] === "--watch-owner-base64"
|
|
314
|
+
? watchOwnedProcessTree(JSON.parse(Buffer.from(args[1] || "", "base64url").toString("utf8"))).then(() => 0)
|
|
315
|
+
: runHeavyCommand(args);
|
|
316
|
+
task.then((code) => { process.exitCode = code; }).catch((error) => { console.error(`heavy-command: ${error.message}`); process.exitCode = 1; });
|
|
317
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
acquireHeavyWorkSlot,
|
|
10
|
+
formatResourceSnapshot,
|
|
11
|
+
heavyChildDetached,
|
|
12
|
+
heavyCommandEnvironment,
|
|
13
|
+
heavyWorkRoot,
|
|
14
|
+
parseMacResourceSnapshot,
|
|
15
|
+
parseWindowsResourceSnapshot,
|
|
16
|
+
resourceBlockers,
|
|
17
|
+
runHeavyCommand,
|
|
18
|
+
systemResourceSnapshot,
|
|
19
|
+
terminateProcessTree,
|
|
20
|
+
watchOwnedProcessTree,
|
|
21
|
+
} from "./heavy-command.mjs";
|
|
22
|
+
|
|
23
|
+
test("heavy-work root is machine-wide rather than repository-local", () => {
|
|
24
|
+
assert.equal(heavyWorkRoot({ platform: "mac", home: "/Users/test", env: {} }), "/Users/test/Library/Caches/RightSuite/heavy-work");
|
|
25
|
+
assert.equal(heavyWorkRoot({ platform: "win", env: { LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local" } }), "C:\\Users\\test\\AppData\\Local\\RightSuite\\heavy-work");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("heavy commands default compiler and test concurrency to two", () => {
|
|
29
|
+
const env = heavyCommandEnvironment({});
|
|
30
|
+
assert.equal(env.CARGO_BUILD_JOBS, "2");
|
|
31
|
+
assert.equal(env.RUST_TEST_THREADS, "2");
|
|
32
|
+
assert.equal(env.RIGHTSUITE_HEAVY_WORK_OWNER, "1");
|
|
33
|
+
assert.equal(heavyCommandEnvironment({ CARGO_BUILD_JOBS: "3", RUST_TEST_THREADS: "1" }).CARGO_BUILD_JOBS, "3");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("only the slot owner creates a new POSIX process group", () => {
|
|
37
|
+
assert.equal(heavyChildDetached({ slot: {}, platform: "mac" }), true);
|
|
38
|
+
assert.equal(heavyChildDetached({ slot: null, platform: "mac" }), false);
|
|
39
|
+
assert.equal(heavyChildDetached({ slot: {}, platform: "win" }), false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("mac resource parser reports memory, swap and thermal limiting", () => {
|
|
43
|
+
const snapshot = parseMacResourceSnapshot({
|
|
44
|
+
memory: "System-wide memory free percentage: 9%",
|
|
45
|
+
swap: "vm.swapusage: total = 8192.00M used = 7750.62M free = 441.38M",
|
|
46
|
+
thermal: "CPU_Speed_Limit = 70",
|
|
47
|
+
});
|
|
48
|
+
assert.equal(snapshot.freePercent, 9);
|
|
49
|
+
assert.equal(Math.round(snapshot.swapUsedBytes / 1024 ** 2), 7751);
|
|
50
|
+
assert.equal(snapshot.thermalLimited, true);
|
|
51
|
+
assert.deepEqual(resourceBlockers(snapshot), ["memory free 9% < 15%", "macOS reports thermal/performance limiting"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("Windows admission uses physical memory and CPU load with a Node memory fallback", () => {
|
|
55
|
+
const snapshot = parseWindowsResourceSnapshot({
|
|
56
|
+
system: JSON.stringify({ TotalBytes: 16 * 1024 ** 3, FreeBytes: 4 * 1024 ** 3, CpuLoadPercent: 94 }),
|
|
57
|
+
});
|
|
58
|
+
assert.equal(snapshot.freePercent, 25);
|
|
59
|
+
assert.equal(snapshot.cpuLoadPercent, 94);
|
|
60
|
+
assert.deepEqual(resourceBlockers(snapshot), ["CPU load 94% > 90%"]);
|
|
61
|
+
|
|
62
|
+
const fallback = parseWindowsResourceSnapshot({ system: "not-json", fallbackTotalBytes: 8 * 1024 ** 3, fallbackFreeBytes: 2 * 1024 ** 3 });
|
|
63
|
+
assert.equal(fallback.freePercent, 25);
|
|
64
|
+
assert.equal(fallback.cpuLoadPercent, null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("Windows resource snapshot queries PowerShell without requiring optional modules", () => {
|
|
68
|
+
const calls = [];
|
|
69
|
+
const snapshot = systemResourceSnapshot({
|
|
70
|
+
platform: "win",
|
|
71
|
+
run: (command, args) => { calls.push({ command, args }); return { status: 0, stdout: '{"TotalBytes":8589934592,"FreeBytes":1073741824,"CpuLoadPercent":42}' }; },
|
|
72
|
+
totalmem: () => 1,
|
|
73
|
+
freemem: () => 1,
|
|
74
|
+
});
|
|
75
|
+
assert.equal(calls[0].command, "powershell.exe");
|
|
76
|
+
assert.deepEqual(calls[0].args.slice(0, 3), ["-NoLogo", "-NoProfile", "-NonInteractive"]);
|
|
77
|
+
assert.equal(snapshot.freePercent, 12.5);
|
|
78
|
+
assert.equal(snapshot.cpuLoadPercent, 42);
|
|
79
|
+
assert.equal(snapshot.thermalLimited, false);
|
|
80
|
+
assert.equal(formatResourceSnapshot(snapshot), "memory-free=12.5% cpu-load=42%");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("nested heavy command preserves ownership without another slot", async () => {
|
|
84
|
+
const code = await runHeavyCommand(["--", process.execPath, "-e", "process.exit(0)"], { env: { ...process.env, RIGHTSUITE_HEAVY_WORK_OWNER: "1" } });
|
|
85
|
+
assert.equal(code, 0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("owned process-tree termination targets a POSIX group or Windows task tree", () => {
|
|
89
|
+
const signals = [];
|
|
90
|
+
terminateProcessTree(202, { platform: "mac", kill: (pid, signal) => signals.push([pid, signal]), treeAlive: () => true, pause: () => {} });
|
|
91
|
+
assert.deepEqual(signals, [[-202, "SIGTERM"], [-202, "SIGKILL"]]);
|
|
92
|
+
const calls = [];
|
|
93
|
+
terminateProcessTree(303, { platform: "win", run: (command, args) => calls.push([command, args]) });
|
|
94
|
+
assert.deepEqual(calls, [["taskkill", ["/PID", "303", "/T", "/F"]]]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("slot serializes contenders and removes stale owners", () => {
|
|
98
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-"));
|
|
99
|
+
const quiet = () => {};
|
|
100
|
+
const snapshot = () => ({ freePercent: 50, swapUsedBytes: 0, swapTotalBytes: 0, thermalLimited: false });
|
|
101
|
+
const first = acquireHeavyWorkSlot({ root, pid: 101, alive: (pid) => pid === 101, snapshot, log: quiet });
|
|
102
|
+
assert.throws(
|
|
103
|
+
() => acquireHeavyWorkSlot({ root, pid: 202, waitMs: 1, pollMs: 1, alive: (pid) => pid === 101, pause: () => {}, snapshot, log: quiet }),
|
|
104
|
+
/holder pid 101/,
|
|
105
|
+
);
|
|
106
|
+
first.release();
|
|
107
|
+
|
|
108
|
+
const lockDir = path.join(root, "slot");
|
|
109
|
+
mkdirSync(lockDir);
|
|
110
|
+
writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 303, token: "stale" }));
|
|
111
|
+
const replacement = acquireHeavyWorkSlot({ root, pid: 404, alive: () => false, snapshot, log: quiet });
|
|
112
|
+
replacement.release();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("dead slot owner reaps its recorded matching child tree", () => {
|
|
116
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-owned-"));
|
|
117
|
+
const killed = [];
|
|
118
|
+
const options = {
|
|
119
|
+
root, snapshot: () => ({ freePercent: 50, thermalLimited: false }), log: () => {},
|
|
120
|
+
alive: (pid) => pid === 202,
|
|
121
|
+
processStartedAt: (pid) => pid === 202 ? 1234 : null,
|
|
122
|
+
terminate: (pid) => killed.push(pid),
|
|
123
|
+
startWatcher: () => {},
|
|
124
|
+
};
|
|
125
|
+
const first = acquireHeavyWorkSlot({ ...options, pid: 101 });
|
|
126
|
+
first.trackChild(202, "cargo");
|
|
127
|
+
assert.equal(JSON.parse(readFileSync(path.join(root, "slot", "child.json"), "utf8")).childPid, 202);
|
|
128
|
+
const replacement = acquireHeavyWorkSlot({ ...options, pid: 404 });
|
|
129
|
+
assert.deepEqual(killed, [202]);
|
|
130
|
+
replacement.release();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("dead slot owner refuses to kill a reused child PID", () => {
|
|
134
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-reused-"));
|
|
135
|
+
let observedStart = 1234;
|
|
136
|
+
const options = {
|
|
137
|
+
root, snapshot: () => ({ freePercent: 50, thermalLimited: false }), log: () => {},
|
|
138
|
+
alive: (pid) => pid === 202,
|
|
139
|
+
processStartedAt: (pid) => pid === 202 ? observedStart : null,
|
|
140
|
+
terminate: () => assert.fail("reused PID must not be terminated"),
|
|
141
|
+
startWatcher: () => {},
|
|
142
|
+
};
|
|
143
|
+
const first = acquireHeavyWorkSlot({ ...options, pid: 101 });
|
|
144
|
+
first.trackChild(202, "cargo");
|
|
145
|
+
observedStart = 5678;
|
|
146
|
+
assert.throws(() => acquireHeavyWorkSlot({ ...options, pid: 404 }), /identity changed/);
|
|
147
|
+
first.release();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("detached watcher reaps an owned child as soon as its owner disappears", async () => {
|
|
151
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-watch-"));
|
|
152
|
+
const lockDir = path.join(root, "slot");
|
|
153
|
+
mkdirSync(lockDir);
|
|
154
|
+
writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 101, token: "owned" }));
|
|
155
|
+
writeFileSync(path.join(lockDir, "child.json"), JSON.stringify({ childPid: 202, childStartedAtMs: 1234 }));
|
|
156
|
+
const killed = [];
|
|
157
|
+
const result = await watchOwnedProcessTree(
|
|
158
|
+
{ root, token: "owned", ownerPid: 101, childPid: 202, childStartedAtMs: 1234 },
|
|
159
|
+
{ alive: (pid) => pid === 202, startedAt: () => 1234, terminate: (pid) => killed.push(pid), pause: async () => {} },
|
|
160
|
+
);
|
|
161
|
+
assert.equal(result, true);
|
|
162
|
+
assert.deepEqual(killed, [202]);
|
|
163
|
+
assert.equal(existsSync(lockDir), false);
|
|
164
|
+
});
|
package/model-promote.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { createReadStream } from "node:fs";
|
|
|
4
4
|
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
|
-
import { spawn } from "node:child_process";
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
9
|
|
|
10
10
|
import {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "./runtime-artifact-manifest.mjs";
|
|
14
14
|
import { loadReleaseToken } from "./release-token.mjs";
|
|
15
15
|
import { registerRightAppsRelease } from "./rightapps-register.mjs";
|
|
16
|
+
import { assertCleanSource } from "./source-gate.mjs";
|
|
16
17
|
|
|
17
18
|
export const RUNTIME_ARTIFACT_TRUSTED_KEY_ID = "rightkit-runtime-artifacts-2026-07";
|
|
18
19
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -24,6 +25,8 @@ export async function promoteRuntimeArtifact(input, deps = {}) {
|
|
|
24
25
|
const register = deps.register;
|
|
25
26
|
const { config } = input;
|
|
26
27
|
assertPromotionConfig(config);
|
|
28
|
+
if (!input.verification) throw new Error("release source identity is required");
|
|
29
|
+
assertCleanSource({ status: input.verification.status, commandId: "right-release model promote" });
|
|
27
30
|
if (input.authority !== "heardright") throw new Error("model promotion authority must be heardright");
|
|
28
31
|
if (input.keyId !== RUNTIME_ARTIFACT_TRUSTED_KEY_ID) throw new Error(`untrusted runtime artifact key id: ${input.keyId}`);
|
|
29
32
|
if (!input.privateKey) throw new Error("runtime artifact signing key is required");
|
|
@@ -134,6 +137,7 @@ async function main() {
|
|
|
134
137
|
evidenceFile: path.resolve(configRoot, raw.evidence.file),
|
|
135
138
|
config,
|
|
136
139
|
dryRun: args.dryRun,
|
|
140
|
+
verification: readVerificationEvidence(args, configRoot),
|
|
137
141
|
}, runtime);
|
|
138
142
|
const manifest = result.envelope.manifest;
|
|
139
143
|
console.log(`${args.dryRun ? "[dry-run] " : ""}runtime artifact promotion validated`);
|
|
@@ -161,6 +165,9 @@ Options:
|
|
|
161
165
|
--signing-key-file <pem> Override the protected PEM path
|
|
162
166
|
--key-id <id> Must equal ${RUNTIME_ARTIFACT_TRUSTED_KEY_ID}
|
|
163
167
|
|
|
168
|
+
Promotion runs only from a clean checkout, so the promoted artifact binds to an
|
|
169
|
+
exact commit.
|
|
170
|
+
|
|
164
171
|
Signing key inputs (never printed): RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY or
|
|
165
172
|
RIGHTKIT_RUNTIME_ARTIFACT_PRIVATE_KEY_FILE. Windows defaults to
|
|
166
173
|
%APPDATA%/RightKit/runtime-artifact-signing-key.pem. Live promotion also uses
|
|
@@ -187,6 +194,19 @@ function parseArgs(args) {
|
|
|
187
194
|
return { authority, config, signingKeyFile, keyId, dryRun };
|
|
188
195
|
}
|
|
189
196
|
|
|
197
|
+
function readVerificationEvidence(args, cwd) {
|
|
198
|
+
return {
|
|
199
|
+
candidateCommit: git(cwd, ["rev-parse", "HEAD"]),
|
|
200
|
+
status: git(cwd, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function git(cwd, args) {
|
|
205
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true });
|
|
206
|
+
if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed`);
|
|
207
|
+
return result.stdout.trim();
|
|
208
|
+
}
|
|
209
|
+
|
|
190
210
|
async function fileMetadata(file) {
|
|
191
211
|
const info = await stat(file);
|
|
192
212
|
if (!info.isFile() || info.size <= 0) throw new Error(`promotion input is not a nonempty file: ${file}`);
|
package/model-promote.test.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { createHash, generateKeyPairSync } from "node:crypto";
|
|
2
|
+
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
5
5
|
import os from "node:os";
|
|
@@ -14,11 +14,17 @@ import {
|
|
|
14
14
|
promoteRuntimeArtifact,
|
|
15
15
|
runtimeArtifactUploadEnv,
|
|
16
16
|
} from "./model-promote.mjs";
|
|
17
|
-
|
|
18
17
|
const { privateKey } = generateKeyPairSync("ed25519");
|
|
19
18
|
const artifactDigest = "a".repeat(64);
|
|
20
19
|
const evidenceDigest = "b".repeat(64);
|
|
21
20
|
const packageRoot = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const candidateCommit = "a".repeat(40);
|
|
22
|
+
|
|
23
|
+
// Source identity as build/upload/promote read it: the exact commit, plus the
|
|
24
|
+
// porcelain status that must be empty for the promotion to run.
|
|
25
|
+
function verificationFixture(commit = candidateCommit, status = "") {
|
|
26
|
+
return { candidateCommit: commit, status };
|
|
27
|
+
}
|
|
22
28
|
|
|
23
29
|
function request(overrides = {}) {
|
|
24
30
|
return {
|
|
@@ -59,6 +65,7 @@ function request(overrides = {}) {
|
|
|
59
65
|
},
|
|
60
66
|
},
|
|
61
67
|
},
|
|
68
|
+
verification: verificationFixture(),
|
|
62
69
|
...overrides,
|
|
63
70
|
};
|
|
64
71
|
}
|
|
@@ -92,6 +99,25 @@ test("dry-run validates and signs without invoking process or network boundaries
|
|
|
92
99
|
assert.equal(result.envelope.signature.algorithm, "Ed25519");
|
|
93
100
|
});
|
|
94
101
|
|
|
102
|
+
test("model promotion refuses a dirty working tree", async () => {
|
|
103
|
+
const events = [];
|
|
104
|
+
const verification = verificationFixture(candidateCommit, " M packages/release/model-promote.mjs\0");
|
|
105
|
+
await assert.rejects(
|
|
106
|
+
promoteRuntimeArtifact(request({ verification, dryRun: true }), boundaries(events)),
|
|
107
|
+
/clean working tree/,
|
|
108
|
+
);
|
|
109
|
+
assert.deepEqual(events, []);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("model promotion requires source identity", async () => {
|
|
113
|
+
const events = [];
|
|
114
|
+
await assert.rejects(
|
|
115
|
+
promoteRuntimeArtifact(request({ verification: undefined, dryRun: true }), boundaries(events)),
|
|
116
|
+
/source identity is required/,
|
|
117
|
+
);
|
|
118
|
+
assert.deepEqual(events, []);
|
|
119
|
+
});
|
|
120
|
+
|
|
95
121
|
test("never registers or publishes a pointer after immutable upload failure", async () => {
|
|
96
122
|
const events = [];
|
|
97
123
|
const deps = boundaries(events);
|
|
@@ -169,7 +195,7 @@ test("model promote help documents the portable config and secure key inputs", (
|
|
|
169
195
|
assert.match(result.stdout, /RIGHTAPPS_RELEASE_TOKEN/);
|
|
170
196
|
});
|
|
171
197
|
|
|
172
|
-
test("portable CLI
|
|
198
|
+
test("portable CLI promotes from a clean checkout, refuses a dirty one, and rejects injected trust flags", async () => {
|
|
173
199
|
const root = await mkdtemp(path.join(os.tmpdir(), "rightkit-model-promote-test-"));
|
|
174
200
|
try {
|
|
175
201
|
const artifact = Buffer.from("tiny model fixture");
|
|
@@ -181,6 +207,10 @@ test("portable CLI dry-run validates real files and signs without network mutati
|
|
|
181
207
|
writeFile(path.join(root, "evidence.json"), evidence),
|
|
182
208
|
writeFile(path.join(root, "key.pem"), keyPem, { mode: 0o600 }),
|
|
183
209
|
]);
|
|
210
|
+
spawnSync("git", ["init"], { cwd: root, encoding: "utf8" });
|
|
211
|
+
spawnSync("git", ["add", "."], { cwd: root, encoding: "utf8" });
|
|
212
|
+
const committed = spawnSync("git", ["-c", "user.name=fixture", "-c", "user.email=fixture@example.invalid", "commit", "-m", "fixture"], { cwd: root, encoding: "utf8" });
|
|
213
|
+
assert.equal(committed.status, 0, committed.stderr);
|
|
184
214
|
const config = {
|
|
185
215
|
artifact: {
|
|
186
216
|
file: "model.onnx",
|
|
@@ -195,17 +225,31 @@ test("portable CLI dry-run validates real files and signs without network mutati
|
|
|
195
225
|
manifest: request().config.manifest,
|
|
196
226
|
};
|
|
197
227
|
await writeFile(path.join(root, "promotion.json"), JSON.stringify(config));
|
|
228
|
+
spawnSync("git", ["add", "."], { cwd: root, encoding: "utf8" });
|
|
229
|
+
spawnSync("git", ["-c", "user.name=fixture", "-c", "user.email=fixture@example.invalid", "commit", "-m", "config"], { cwd: root, encoding: "utf8" });
|
|
198
230
|
|
|
199
|
-
const
|
|
231
|
+
const promoteArgs = [
|
|
200
232
|
path.join(packageRoot, "cli/right-release.mjs"), "model", "promote",
|
|
201
233
|
"--authority", "heardright",
|
|
202
234
|
"--config", path.join(root, "promotion.json"),
|
|
203
235
|
"--signing-key-file", path.join(root, "key.pem"),
|
|
204
236
|
"--dry-run",
|
|
205
|
-
]
|
|
237
|
+
];
|
|
238
|
+
const result = spawnSync(process.execPath, promoteArgs, { cwd: root, encoding: "utf8", windowsHide: true });
|
|
206
239
|
assert.equal(result.status, 0, result.stderr);
|
|
207
|
-
|
|
208
|
-
|
|
240
|
+
|
|
241
|
+
await writeFile(path.join(root, "untracked.txt"), "dirty");
|
|
242
|
+
const dirty = spawnSync(process.execPath, promoteArgs, { cwd: root, encoding: "utf8", windowsHide: true });
|
|
243
|
+
assert.equal(dirty.status, 1);
|
|
244
|
+
assert.match(dirty.stderr, /clean working tree/);
|
|
245
|
+
|
|
246
|
+
const attacker = spawnSync(process.execPath, [
|
|
247
|
+
path.join(packageRoot, "cli/right-release.mjs"), "model", "promote",
|
|
248
|
+
"--authority", "heardright", "--config", path.join(root, "promotion.json"),
|
|
249
|
+
"--verification-public-key", path.join(root, "verification-public.pem"),
|
|
250
|
+
], { cwd: root, encoding: "utf8", windowsHide: true });
|
|
251
|
+
assert.equal(attacker.status, 1);
|
|
252
|
+
assert.match(attacker.stderr, /unknown model promote argument: --verification-public-key/);
|
|
209
253
|
assert.doesNotMatch(`${result.stdout}${result.stderr}`, /BEGIN PRIVATE KEY/);
|
|
210
254
|
assert.doesNotMatch(`${result.stdout}${result.stderr}`, /Uploading|POST https:/);
|
|
211
255
|
} finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/release",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.51",
|
|
4
4
|
"description": "Portable Right Suite release CLI/SDK: signed installers, updater artifacts, patch/update routing, hardening, R2 publish, and RightApps registration.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-invocation.mjs
CHANGED
|
@@ -16,7 +16,16 @@ function canonical(file) {
|
|
|
16
16
|
|
|
17
17
|
export function assertPrimaryReleaseCheckout(repoRoot) {
|
|
18
18
|
const worktrees = git(repoRoot, ["worktree", "list", "--porcelain"]);
|
|
19
|
-
const
|
|
19
|
+
const gitDir = git(repoRoot, ["rev-parse", "--git-dir"]).trim();
|
|
20
|
+
const configured = spawnSync("git", ["config", "--path", "--get", "core.worktree"], {
|
|
21
|
+
cwd: repoRoot, encoding: "utf8", windowsHide: true,
|
|
22
|
+
});
|
|
23
|
+
const primary = resolvePrimaryWorktree({
|
|
24
|
+
repoRoot,
|
|
25
|
+
worktrees,
|
|
26
|
+
gitDir,
|
|
27
|
+
coreWorktree: configured.status === 0 ? configured.stdout.trim() : "",
|
|
28
|
+
});
|
|
20
29
|
if (!primary) throw new Error("unable to resolve the primary Git worktree");
|
|
21
30
|
if (canonical(repoRoot) !== canonical(primary)) {
|
|
22
31
|
throw new Error(`right-release must be invoked from the primary Git worktree: ${primary}`);
|
|
@@ -25,6 +34,16 @@ export function assertPrimaryReleaseCheckout(repoRoot) {
|
|
|
25
34
|
if (branch.status !== 0) throw new Error("right-release requires a branch-attached primary Git checkout; detached HEAD is forbidden");
|
|
26
35
|
}
|
|
27
36
|
|
|
37
|
+
export function resolvePrimaryWorktree({ repoRoot, worktrees, gitDir, coreWorktree }) {
|
|
38
|
+
const listed = worktrees.match(/^worktree (.+)$/m)?.[1];
|
|
39
|
+
if (!listed) return undefined;
|
|
40
|
+
const resolvedGitDir = path.resolve(repoRoot, gitDir);
|
|
41
|
+
if (path.resolve(listed) === resolvedGitDir && coreWorktree) {
|
|
42
|
+
return path.resolve(resolvedGitDir, coreWorktree);
|
|
43
|
+
}
|
|
44
|
+
return listed;
|
|
45
|
+
}
|
|
46
|
+
|
|
28
47
|
export function resolveConfiguredBuildInputs(config, target, label = "release config") {
|
|
29
48
|
const configured = target?.buildInputs ?? config?.buildInputs;
|
|
30
49
|
if (!Array.isArray(configured?.include) || configured.include.length === 0) {
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { resolvePrimaryWorktree } from "./release-invocation.mjs";
|
|
4
|
+
|
|
5
|
+
test("resolves a submodule primary worktree from core.worktree", () => {
|
|
6
|
+
assert.equal(resolvePrimaryWorktree({
|
|
7
|
+
repoRoot: "/suite/membrane",
|
|
8
|
+
worktrees: "worktree /suite/.git/modules/membrane\nHEAD abc\n",
|
|
9
|
+
gitDir: "/suite/.git/modules/membrane",
|
|
10
|
+
coreWorktree: "../../../membrane",
|
|
11
|
+
}), "/suite/membrane");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("preserves an ordinary primary worktree", () => {
|
|
15
|
+
assert.equal(resolvePrimaryWorktree({
|
|
16
|
+
repoRoot: "/suite/app",
|
|
17
|
+
worktrees: "worktree /suite/app\nHEAD abc\n",
|
|
18
|
+
gitDir: "/suite/app/.git",
|
|
19
|
+
coreWorktree: "",
|
|
20
|
+
}), "/suite/app");
|
|
21
|
+
});
|
|
@@ -36,6 +36,13 @@ const apps = [
|
|
|
36
36
|
{ key: "coderight", root: "coderight/apps/coderight-tauri", tauri: "src-tauri/tauri.conf.json", releaseFiles: ["scripts/mac-dmg.mjs"] },
|
|
37
37
|
];
|
|
38
38
|
|
|
39
|
+
// ScreenRight is intentionally a macOS-only AppKit consumer. It has no Tauri
|
|
40
|
+
// manifest or Windows target, so keep it in an explicit registry lane rather
|
|
41
|
+
// than manufacturing unsupported platform entries.
|
|
42
|
+
const macOnlyApps = [
|
|
43
|
+
{ key: "screenright", root: "tools/screenright", releaseFiles: ["scripts/release-package.mjs"] },
|
|
44
|
+
];
|
|
45
|
+
|
|
39
46
|
function assertReleasePackageScripts(scripts, label) {
|
|
40
47
|
for (const platform of ["mac", "win"]) {
|
|
41
48
|
assert.equal(scripts[`release:build:${platform}`], `right-release build --platform ${platform}`, `${label} must use the tier-neutral ${platform} build entry point`);
|
|
@@ -442,10 +449,10 @@ test("RightKit exposes one current version manifest", () => {
|
|
|
442
449
|
"@rightkit/legal": "0.3.0",
|
|
443
450
|
"@rightkit/legal-ui": "0.1.0",
|
|
444
451
|
"@rightkit/license": "0.1.6",
|
|
445
|
-
"@rightkit/release": "0.2.
|
|
452
|
+
"@rightkit/release": "0.2.51",
|
|
446
453
|
});
|
|
447
454
|
assert.deepEqual(versions.legacyNpm, {
|
|
448
|
-
"@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"],
|
|
455
|
+
"@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"],
|
|
449
456
|
});
|
|
450
457
|
assert.ok(
|
|
451
458
|
new Set([versions.npm["@rightkit/release"], ...versions.legacyNpm["@rightkit/release"]]).has("0.2.42"),
|
|
@@ -608,6 +615,45 @@ for (const app of apps) {
|
|
|
608
615
|
});
|
|
609
616
|
}
|
|
610
617
|
|
|
618
|
+
for (const app of macOnlyApps) {
|
|
619
|
+
test(`${app.key} follows the macOS-only AppKit Right Release contract`, async () => {
|
|
620
|
+
const root = path.join(workspace, app.root);
|
|
621
|
+
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
|
622
|
+
assert.equal(pkg.scripts["release:doctor"], "right-release doctor");
|
|
623
|
+
assert.equal(pkg.scripts["release:build:mac"], "right-release build --platform mac");
|
|
624
|
+
assert.equal(pkg.scripts["release:upload:patch:mac"], "right-release upload --platform mac --tier patch");
|
|
625
|
+
assert.equal(pkg.scripts["release:build:win"], undefined, `${app.key} must not fake a Windows target`);
|
|
626
|
+
assert.equal(pkg.scripts["release:upload:patch:win"], undefined, `${app.key} must not fake a Windows target`);
|
|
627
|
+
assert.equal(pkg.scripts["release:upload:update:mac"], undefined, `${app.key} must not expose ungated feature-update upload`);
|
|
628
|
+
assert.equal(pkg.scripts["release:upload:update:win"], undefined);
|
|
629
|
+
|
|
630
|
+
const config = (await import(`${pathToFileURL(path.join(root, "right-release.config.mjs"))}?mac-only=${Date.now()}`)).default;
|
|
631
|
+
assert.equal(config.app, app.key);
|
|
632
|
+
assert.deepEqual(Object.keys(config.targets), ["mac"]);
|
|
633
|
+
assert.equal(config.targets.mac.signed, true);
|
|
634
|
+
assert.deepEqual(config.targets.mac.publish, {
|
|
635
|
+
cmd: "right-release",
|
|
636
|
+
args: ["publish-update", "--config", "right-release.config.mjs", "--platform", "mac"],
|
|
637
|
+
});
|
|
638
|
+
assert.equal(config.targets.mac.updater.artifacts.length, 1);
|
|
639
|
+
const updater = config.targets.mac.updater.artifacts[0];
|
|
640
|
+
assert.equal(updater.platform, "darwin-aarch64");
|
|
641
|
+
assert.match(updater.file, /\.dmg$/);
|
|
642
|
+
assert.equal(updater.signature, `${updater.file}.sig`);
|
|
643
|
+
assert.equal(updater.key, `${app.key}/updates/mac/current/ScreenRight.dmg`);
|
|
644
|
+
assertBuildInputs(config, config.targets.mac, `${app.key} mac`);
|
|
645
|
+
assertMacPackageEntry(config.targets.mac.package, pkg.scripts, app.key);
|
|
646
|
+
assert.ok(config.targets.mac.installer.artifacts.length >= 1);
|
|
647
|
+
assert.ok(config.checks.includes("legal:check"), `${app.key} must gate generated legal notices`);
|
|
648
|
+
assert.deepEqual(config.deps, { workdirs: ["."] });
|
|
649
|
+
assert.equal(existsSync(path.join(root, "src-tauri", "tauri.conf.json")), false, `${app.key} must not invent a Tauri manifest`);
|
|
650
|
+
for (const releaseFile of app.releaseFiles) {
|
|
651
|
+
const source = readFileSync(path.join(root, releaseFile), "utf8");
|
|
652
|
+
assert.match(source, /mirror-root-artifact/);
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
611
657
|
test("HeardRight and ScrapeRight expose one locked ASR promotion adapter contract", async () => {
|
|
612
658
|
const heard = (await import(`${pathToFileURL(path.join(workspace, "heardright/tauri-app-next/right-release.config.mjs"))}?asr=${Date.now()}`)).default;
|
|
613
659
|
const scrape = (await import(`${pathToFileURL(path.join(workspace, "scraperight/right-release.config.mjs"))}?asr=${Date.now()}`)).default;
|
package/rightkit-versions.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
|
-
"packageManager": "pnpm@11.
|
|
3
|
+
"packageManager": "pnpm@11.18.0",
|
|
4
4
|
"npm": {
|
|
5
5
|
"@rightkit/legal": "0.2.0",
|
|
6
6
|
"@rightkit/license": "0.1.5",
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
"@rightkit/legal": "0.3.0",
|
|
16
16
|
"@rightkit/legal-ui": "0.1.0",
|
|
17
17
|
"@rightkit/license": "0.1.6",
|
|
18
|
-
"@rightkit/release": "0.2.
|
|
18
|
+
"@rightkit/release": "0.2.51"
|
|
19
19
|
},
|
|
20
20
|
"legacyNpm": {
|
|
21
|
-
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46"]
|
|
21
|
+
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50"]
|
|
22
22
|
},
|
|
23
23
|
"cargo": {
|
|
24
24
|
"rightkit-license": "0.1.2",
|
package/source-gate.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Release source gate.
|
|
2
|
+
//
|
|
3
|
+
// A build or upload may only run from a clean checkout, so every sealed artifact
|
|
4
|
+
// binds to an exact commit that exists in history rather than to whatever happened
|
|
5
|
+
// to be in the working tree. Upload separately re-checks that the sealed manifest's
|
|
6
|
+
// commit still matches HEAD, so the pair pins source identity end to end.
|
|
7
|
+
//
|
|
8
|
+
// This replaces the r7 verification-ticket / admin-broker layer removed in 578d7ac5.
|
|
9
|
+
// That layer defended against a hostile local admin, which is not a threat here; the
|
|
10
|
+
// real risk is shipping stale, dirty, or untested source. Distribution trust still
|
|
11
|
+
// comes from OS code signing, notarization, and the Tauri updater key — untouched.
|
|
12
|
+
|
|
13
|
+
export function assertCleanSource({ status, commandId = "release" }) {
|
|
14
|
+
if (typeof status !== "string") throw new Error("release source status is required");
|
|
15
|
+
if (status.length !== 0) {
|
|
16
|
+
const files = status.split("\0").filter(Boolean).map((entry) => entry.slice(3)).slice(0, 10);
|
|
17
|
+
throw new Error(`${commandId} requires a clean working tree; commit or stash first:\n${files.map((file) => `- ${file}`).join("\n")}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { assertCleanSource } from "./source-gate.mjs";
|
|
5
|
+
|
|
6
|
+
test("a clean status passes the gate", () => {
|
|
7
|
+
assert.doesNotThrow(() => assertCleanSource({ status: "", commandId: "right-release build" }));
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("a dirty status names the command and the offending files", () => {
|
|
11
|
+
assert.throws(
|
|
12
|
+
() => assertCleanSource({ status: " M src/main.rs\0?? notes.txt\0", commandId: "right-release build" }),
|
|
13
|
+
(error) => {
|
|
14
|
+
assert.match(error.message, /right-release build requires a clean working tree/);
|
|
15
|
+
assert.match(error.message, /- src\/main\.rs/);
|
|
16
|
+
assert.match(error.message, /- notes\.txt/);
|
|
17
|
+
return true;
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("a missing status is refused rather than treated as clean", () => {
|
|
23
|
+
assert.throws(() => assertCleanSource({ commandId: "right-release upload" }), /status is required/);
|
|
24
|
+
assert.throws(() => assertCleanSource({ status: null }), /status is required/);
|
|
25
|
+
});
|
|
@@ -16,9 +16,9 @@ test("standalone verifier clones, installs, doctors from a nested app root, and
|
|
|
16
16
|
const fixtureRoot = mkdtempSync(path.join(tmpdir(), "rightkit-standalone-fixture-"));
|
|
17
17
|
t.after(() => rmSync(fixtureRoot, { recursive: true, force: true }));
|
|
18
18
|
const source = path.join(fixtureRoot, "source");
|
|
19
|
-
const
|
|
19
|
+
const outsideForge = path.join(fixtureRoot, "keep.txt");
|
|
20
20
|
mkdirSync(path.join(source, "apps", "desktop"), { recursive: true });
|
|
21
|
-
writeFileSync(
|
|
21
|
+
writeFileSync(outsideForge, "keep", "utf8");
|
|
22
22
|
writeFileSync(path.join(source, "apps", "desktop", "package.json"), JSON.stringify({
|
|
23
23
|
name: "standalone-fixture",
|
|
24
24
|
private: true,
|
|
@@ -44,7 +44,7 @@ test("standalone verifier clones, installs, doctors from a nested app root, and
|
|
|
44
44
|
assert.equal(result.apps[0].doctor.status, 0);
|
|
45
45
|
assert.match(result.apps[0].doctor.stdout, /fixture doctor passed/);
|
|
46
46
|
assert.equal(existsSync(result.workRoot), false, "generated clone tree must be removed");
|
|
47
|
-
assert.equal(readFileSync(
|
|
47
|
+
assert.equal(readFileSync(outsideForge, "utf8"), "keep", "cleanup must not escape the generated tree");
|
|
48
48
|
assert.deepEqual(JSON.parse(readFileSync(evidencePath, "utf8")).apps.map(({ key }) => key), ["fixture"]);
|
|
49
49
|
});
|
|
50
50
|
|
package/upload-release.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { spawnSync } from "node:child_process";
|
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
7
|
import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
|
|
8
8
|
import { runUploadStateMachine, verifySealedRelease } from "./release-state.mjs";
|
|
9
|
+
import { assertCleanSource } from "./source-gate.mjs";
|
|
9
10
|
|
|
10
11
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const PUBLISH_UPDATE = path.join(TOOL_ROOT, "publish-update.mjs");
|
|
@@ -36,9 +37,15 @@ if (platform !== "win" && platform !== "mac") fail("--platform must be win or ma
|
|
|
36
37
|
|
|
37
38
|
const repoRoot = git(process.cwd(), ["rev-parse", "--show-toplevel"]);
|
|
38
39
|
assertPrimaryReleaseCheckout(repoRoot);
|
|
40
|
+
const candidateCommit = git(repoRoot, ["rev-parse", "HEAD"]);
|
|
41
|
+
assertCleanSource({
|
|
42
|
+
status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]),
|
|
43
|
+
commandId: "right-release upload",
|
|
44
|
+
});
|
|
39
45
|
const platformDir = platform === "win" ? "windows" : "mac";
|
|
40
46
|
const sealedDir = path.join(repoRoot, ".right-release", "sealed", releaseId, platformDir);
|
|
41
47
|
const sealed = verifySealedRelease(sealedDir);
|
|
48
|
+
if (sealed.manifest.commit !== candidateCommit) fail("sealed release was built from a different commit than the current checkout");
|
|
42
49
|
const stateRoot = path.join(repoRoot, ".right-release", "state", releaseId, platformDir);
|
|
43
50
|
const backupRoot = path.join(stateRoot, "rollback", tier);
|
|
44
51
|
const verifiedMarker = path.join(stateRoot, `verified-${tier}.json`);
|
|
@@ -212,7 +219,7 @@ function writeJson(file, value) {
|
|
|
212
219
|
}
|
|
213
220
|
|
|
214
221
|
function usage(code) {
|
|
215
|
-
console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update");
|
|
222
|
+
console.log("usage: right-release upload --release <sealed-id> --platform win|mac --tier patch|update [--dry-run]");
|
|
216
223
|
process.exit(code);
|
|
217
224
|
}
|
|
218
225
|
|