@rightkit/release 0.2.49 → 0.2.50
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 +14 -4
- package/build-release.test.mjs +5 -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/heavy-command.mjs +199 -0
- package/heavy-command.test.mjs +95 -0
- package/model-promote.mjs +21 -1
- package/model-promote.test.mjs +51 -7
- package/package.json +1 -1
- package/right-suite-contract.test.mjs +2 -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 } 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) {
|
package/build-release.test.mjs
CHANGED
|
@@ -7,15 +7,20 @@ 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/);
|
|
15
19
|
assert.match(source, /acquireCacheLease/);
|
|
16
20
|
assert.match(source, /markCacheEntrySuccessful/);
|
|
17
21
|
assert.match(source, /RIGHT_RELEASE_CACHE_OWNER/);
|
|
18
22
|
assert.match(source, /suiteSlot\?\.release\(\)/);
|
|
23
|
+
assert.match(source, /heavySlot\?\.release\(\)/);
|
|
19
24
|
assert.match(source, /cacheLease\?\.release\(\)/);
|
|
20
25
|
assert.doesNotMatch(source, /process\.exit\(signal/);
|
|
21
26
|
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", () => {
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, 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
|
+
|
|
11
|
+
export function heavyWorkRoot({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
12
|
+
if (env.RIGHTSUITE_HEAVY_WORK_ROOT) return path.resolve(env.RIGHTSUITE_HEAVY_WORK_ROOT);
|
|
13
|
+
if (platform === "win32" || platform === "win") {
|
|
14
|
+
if (!env.LOCALAPPDATA) throw new Error("LOCALAPPDATA is required for the RightSuite heavy-work guard");
|
|
15
|
+
return path.win32.resolve(env.LOCALAPPDATA, "RightSuite", "heavy-work");
|
|
16
|
+
}
|
|
17
|
+
if (platform === "darwin" || platform === "mac") return path.join(home, "Library", "Caches", "RightSuite", "heavy-work");
|
|
18
|
+
return path.resolve(env.XDG_CACHE_HOME || path.join(home, ".cache"), "rightsuite", "heavy-work");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function heavyCommandEnvironment(env = process.env) {
|
|
22
|
+
return {
|
|
23
|
+
...env,
|
|
24
|
+
CARGO_BUILD_JOBS: env.CARGO_BUILD_JOBS || env.BUILD_GUARD_CARGO_JOBS || "2",
|
|
25
|
+
RUST_TEST_THREADS: env.RUST_TEST_THREADS || env.BUILD_GUARD_TEST_THREADS || "2",
|
|
26
|
+
RIGHTSUITE_HEAVY_WORK_OWNER: "1",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseMacResourceSnapshot({ memory = "", swap = "", thermal = "" } = {}) {
|
|
31
|
+
const freePercent = Number(memory.match(/memory free percentage:\s*(\d+(?:\.\d+)?)%/i)?.[1]);
|
|
32
|
+
const swapMatch = swap.match(/total\s*=\s*([\d.]+)M\s+used\s*=\s*([\d.]+)M/i);
|
|
33
|
+
const speedLimit = Number(thermal.match(/CPU_Speed_Limit\s*=\s*(\d+)/i)?.[1]);
|
|
34
|
+
const warning = Number(thermal.match(/(?:thermal|performance) warning level\s*[:=]\s*(\d+)/i)?.[1]);
|
|
35
|
+
return {
|
|
36
|
+
freePercent: Number.isFinite(freePercent) ? freePercent : null,
|
|
37
|
+
swapTotalBytes: swapMatch ? Number(swapMatch[1]) * 1024 ** 2 : null,
|
|
38
|
+
swapUsedBytes: swapMatch ? Number(swapMatch[2]) * 1024 ** 2 : null,
|
|
39
|
+
thermalLimited: Number.isFinite(speedLimit) ? speedLimit < 100 : Number.isFinite(warning) ? warning > 0 : false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parseWindowsResourceSnapshot({ system = "", fallbackTotalBytes = os.totalmem(), fallbackFreeBytes = os.freemem() } = {}) {
|
|
44
|
+
let parsed = {};
|
|
45
|
+
try { parsed = JSON.parse(String(system).trim().replace(/^\uFEFF/, "")); } catch { /* use Node memory fallback */ }
|
|
46
|
+
const totalMemoryBytes = Number(parsed.TotalBytes) > 0 ? Number(parsed.TotalBytes) : Number(fallbackTotalBytes);
|
|
47
|
+
const freeMemoryBytes = Number(parsed.FreeBytes) >= 0 ? Number(parsed.FreeBytes) : Number(fallbackFreeBytes);
|
|
48
|
+
const cpuLoadPercent = parsed.CpuLoadPercent === null || parsed.CpuLoadPercent === undefined ? null : Number(parsed.CpuLoadPercent);
|
|
49
|
+
return {
|
|
50
|
+
freePercent: totalMemoryBytes > 0 ? Math.round((freeMemoryBytes / totalMemoryBytes) * 1000) / 10 : null,
|
|
51
|
+
totalMemoryBytes,
|
|
52
|
+
freeMemoryBytes,
|
|
53
|
+
cpuLoadPercent: Number.isFinite(cpuLoadPercent) ? cpuLoadPercent : null,
|
|
54
|
+
thermalLimited: false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function systemResourceSnapshot({ platform = process.platform, run = spawnSync, totalmem = os.totalmem, freemem = os.freemem } = {}) {
|
|
59
|
+
if (platform === "win32" || platform === "win") {
|
|
60
|
+
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";
|
|
61
|
+
const result = run("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], { encoding: "utf8", windowsHide: true });
|
|
62
|
+
return { platform, ...parseWindowsResourceSnapshot({ system: result.status === 0 ? result.stdout : "", fallbackTotalBytes: totalmem(), fallbackFreeBytes: freemem() }) };
|
|
63
|
+
}
|
|
64
|
+
if (platform !== "darwin" && platform !== "mac") return { platform, freePercent: null, thermalLimited: false };
|
|
65
|
+
const output = (command, args) => {
|
|
66
|
+
const result = run(command, args, { encoding: "utf8", windowsHide: true });
|
|
67
|
+
return result.status === 0 ? String(result.stdout ?? result.stderr ?? "") : "";
|
|
68
|
+
};
|
|
69
|
+
return {
|
|
70
|
+
platform,
|
|
71
|
+
...parseMacResourceSnapshot({
|
|
72
|
+
memory: output("memory_pressure", ["-Q"]),
|
|
73
|
+
swap: output("sysctl", ["vm.swapusage"]),
|
|
74
|
+
thermal: output("pmset", ["-g", "therm"]),
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function resourceBlockers(snapshot, { minFreePercent = 15, maxCpuLoadPercent = 90 } = {}) {
|
|
80
|
+
const blockers = [];
|
|
81
|
+
if (snapshot.freePercent !== null && snapshot.freePercent < minFreePercent) {
|
|
82
|
+
blockers.push(`memory free ${snapshot.freePercent}% < ${minFreePercent}%`);
|
|
83
|
+
}
|
|
84
|
+
if (Number.isFinite(snapshot.cpuLoadPercent) && snapshot.cpuLoadPercent > maxCpuLoadPercent) {
|
|
85
|
+
blockers.push(`CPU load ${snapshot.cpuLoadPercent}% > ${maxCpuLoadPercent}%`);
|
|
86
|
+
}
|
|
87
|
+
if (snapshot.thermalLimited) blockers.push("macOS reports thermal/performance limiting");
|
|
88
|
+
return blockers;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function formatResourceSnapshot(snapshot) {
|
|
92
|
+
const fields = [];
|
|
93
|
+
if (snapshot.freePercent !== null) fields.push(`memory-free=${snapshot.freePercent}%`);
|
|
94
|
+
if (Number.isFinite(snapshot.swapUsedBytes) && Number.isFinite(snapshot.swapTotalBytes)) {
|
|
95
|
+
fields.push(`swap=${(snapshot.swapUsedBytes / GB).toFixed(1)}/${(snapshot.swapTotalBytes / GB).toFixed(1)}GiB`);
|
|
96
|
+
}
|
|
97
|
+
if (Number.isFinite(snapshot.cpuLoadPercent)) fields.push(`cpu-load=${snapshot.cpuLoadPercent}%`);
|
|
98
|
+
if (snapshot.platform === "darwin" || snapshot.platform === "mac" || snapshot.thermalLimited) {
|
|
99
|
+
fields.push(`thermal=${snapshot.thermalLimited ? "limited" : "ok"}`);
|
|
100
|
+
}
|
|
101
|
+
return fields.join(" ");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function processAlive(pid) {
|
|
105
|
+
if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return false;
|
|
106
|
+
try { process.kill(Number(pid), 0); return true; } catch { return false; }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sleep(ms) {
|
|
110
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readOwner(lockDir) {
|
|
114
|
+
try { return JSON.parse(readFileSync(path.join(lockDir, "owner.json"), "utf8")); } catch { return null; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function acquireHeavyWorkSlot({
|
|
118
|
+
root = heavyWorkRoot(), pid = process.pid, argv = process.argv, waitMs = Number(process.env.BUILD_GUARD_WAIT_SECS ?? 900) * 1000,
|
|
119
|
+
pollMs = 1000, alive = processAlive, pause = sleep, snapshot = systemResourceSnapshot,
|
|
120
|
+
minFreePercent = Number(process.env.BUILD_GUARD_MIN_MEMORY_FREE_PERCENT ?? 15), log = (message) => console.error(`[heavy-work] ${message}`),
|
|
121
|
+
maxCpuLoadPercent = Number(process.env.BUILD_GUARD_MAX_CPU_LOAD_PERCENT ?? 90),
|
|
122
|
+
} = {}) {
|
|
123
|
+
mkdirSync(root, { recursive: true });
|
|
124
|
+
const lockDir = path.join(root, "slot");
|
|
125
|
+
const token = randomUUID();
|
|
126
|
+
const started = Date.now();
|
|
127
|
+
let lastReport = 0;
|
|
128
|
+
for (;;) {
|
|
129
|
+
try {
|
|
130
|
+
mkdirSync(lockDir);
|
|
131
|
+
const fd = openSync(path.join(lockDir, "owner.json"), "wx");
|
|
132
|
+
writeFileSync(fd, `${JSON.stringify({ schema: 1, pid: Number(pid), token, argv: [...argv], createdAt: new Date().toISOString() })}\n`);
|
|
133
|
+
closeSync(fd);
|
|
134
|
+
break;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error.code !== "EEXIST") throw error;
|
|
137
|
+
const owner = readOwner(lockDir);
|
|
138
|
+
const incompleteAge = (() => { try { return Date.now() - statSync(lockDir).mtimeMs; } catch { return 0; } })();
|
|
139
|
+
if (owner ? !alive(owner.pid) : incompleteAge > 10_000) {
|
|
140
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (Date.now() - started >= waitMs) throw new Error(`heavy-work slot timed out after ${waitMs}ms; holder pid ${owner?.pid ?? "starting"}`);
|
|
144
|
+
if (Date.now() - lastReport >= 10_000) { log(`waiting for pid ${owner?.pid ?? "starting"}`); lastReport = Date.now(); }
|
|
145
|
+
pause(Math.min(pollMs, Math.max(1, waitMs - (Date.now() - started))));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (;;) {
|
|
150
|
+
const current = snapshot();
|
|
151
|
+
const blockers = resourceBlockers(current, { minFreePercent, maxCpuLoadPercent });
|
|
152
|
+
if (blockers.length === 0) {
|
|
153
|
+
log(`admitted ${formatResourceSnapshot(current)} jobs=${heavyCommandEnvironment().CARGO_BUILD_JOBS}`);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
if (Date.now() - started >= waitMs) {
|
|
157
|
+
releaseOwned(lockDir, token);
|
|
158
|
+
throw new Error(`resource admission timed out: ${blockers.join(", ")}`);
|
|
159
|
+
}
|
|
160
|
+
if (Date.now() - lastReport >= 10_000) { log(`resource wait: ${blockers.join(", ")}`); lastReport = Date.now(); }
|
|
161
|
+
pause(Math.min(5000, Math.max(1, waitMs - (Date.now() - started))));
|
|
162
|
+
}
|
|
163
|
+
return { root, release: () => releaseOwned(lockDir, token) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function releaseOwned(lockDir, token) {
|
|
167
|
+
const owner = readOwner(lockDir);
|
|
168
|
+
if (owner?.token === token) rmSync(lockDir, { recursive: true, force: true });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function runHeavyCommand(args, { env = process.env } = {}) {
|
|
172
|
+
let command;
|
|
173
|
+
let commandArgs;
|
|
174
|
+
if (args[0] === "--shell-base64") {
|
|
175
|
+
const source = Buffer.from(args[1] || "", "base64").toString("utf8");
|
|
176
|
+
const shell = process.platform === "win32" ? (env.SHELL || "bash") : (env.SHELL || "/bin/zsh");
|
|
177
|
+
command = shell; commandArgs = ["-lc", source];
|
|
178
|
+
} else {
|
|
179
|
+
const separator = args.indexOf("--");
|
|
180
|
+
const values = separator >= 0 ? args.slice(separator + 1) : args;
|
|
181
|
+
[command, ...commandArgs] = values;
|
|
182
|
+
}
|
|
183
|
+
if (!command) throw new Error("heavy-command: expected -- <command> or --shell-base64 <value>");
|
|
184
|
+
if (env.RIGHTSUITE_HEAVY_WORK_OWNER === "1") return spawnAttached(command, commandArgs, env);
|
|
185
|
+
const slot = acquireHeavyWorkSlot();
|
|
186
|
+
try { return await spawnAttached(command, commandArgs, heavyCommandEnvironment(env)); } finally { slot.release(); }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function spawnAttached(command, args, env) {
|
|
190
|
+
return new Promise((resolve, reject) => {
|
|
191
|
+
const child = spawn(command, args, { cwd: process.cwd(), env, stdio: "inherit", windowsHide: true });
|
|
192
|
+
child.once("error", reject);
|
|
193
|
+
child.once("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
198
|
+
runHeavyCommand(process.argv.slice(2)).then((code) => { process.exitCode = code; }).catch((error) => { console.error(`heavy-command: ${error.message}`); process.exitCode = 1; });
|
|
199
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync, 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
|
+
heavyCommandEnvironment,
|
|
12
|
+
heavyWorkRoot,
|
|
13
|
+
parseMacResourceSnapshot,
|
|
14
|
+
parseWindowsResourceSnapshot,
|
|
15
|
+
resourceBlockers,
|
|
16
|
+
runHeavyCommand,
|
|
17
|
+
systemResourceSnapshot,
|
|
18
|
+
} from "./heavy-command.mjs";
|
|
19
|
+
|
|
20
|
+
test("heavy-work root is machine-wide rather than repository-local", () => {
|
|
21
|
+
assert.equal(heavyWorkRoot({ platform: "mac", home: "/Users/test", env: {} }), "/Users/test/Library/Caches/RightSuite/heavy-work");
|
|
22
|
+
assert.equal(heavyWorkRoot({ platform: "win", env: { LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local" } }), "C:\\Users\\test\\AppData\\Local\\RightSuite\\heavy-work");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("heavy commands default compiler and test concurrency to two", () => {
|
|
26
|
+
const env = heavyCommandEnvironment({});
|
|
27
|
+
assert.equal(env.CARGO_BUILD_JOBS, "2");
|
|
28
|
+
assert.equal(env.RUST_TEST_THREADS, "2");
|
|
29
|
+
assert.equal(env.RIGHTSUITE_HEAVY_WORK_OWNER, "1");
|
|
30
|
+
assert.equal(heavyCommandEnvironment({ CARGO_BUILD_JOBS: "3", RUST_TEST_THREADS: "1" }).CARGO_BUILD_JOBS, "3");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("mac resource parser reports memory, swap and thermal limiting", () => {
|
|
34
|
+
const snapshot = parseMacResourceSnapshot({
|
|
35
|
+
memory: "System-wide memory free percentage: 9%",
|
|
36
|
+
swap: "vm.swapusage: total = 8192.00M used = 7750.62M free = 441.38M",
|
|
37
|
+
thermal: "CPU_Speed_Limit = 70",
|
|
38
|
+
});
|
|
39
|
+
assert.equal(snapshot.freePercent, 9);
|
|
40
|
+
assert.equal(Math.round(snapshot.swapUsedBytes / 1024 ** 2), 7751);
|
|
41
|
+
assert.equal(snapshot.thermalLimited, true);
|
|
42
|
+
assert.deepEqual(resourceBlockers(snapshot), ["memory free 9% < 15%", "macOS reports thermal/performance limiting"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("Windows admission uses physical memory and CPU load with a Node memory fallback", () => {
|
|
46
|
+
const snapshot = parseWindowsResourceSnapshot({
|
|
47
|
+
system: JSON.stringify({ TotalBytes: 16 * 1024 ** 3, FreeBytes: 4 * 1024 ** 3, CpuLoadPercent: 94 }),
|
|
48
|
+
});
|
|
49
|
+
assert.equal(snapshot.freePercent, 25);
|
|
50
|
+
assert.equal(snapshot.cpuLoadPercent, 94);
|
|
51
|
+
assert.deepEqual(resourceBlockers(snapshot), ["CPU load 94% > 90%"]);
|
|
52
|
+
|
|
53
|
+
const fallback = parseWindowsResourceSnapshot({ system: "not-json", fallbackTotalBytes: 8 * 1024 ** 3, fallbackFreeBytes: 2 * 1024 ** 3 });
|
|
54
|
+
assert.equal(fallback.freePercent, 25);
|
|
55
|
+
assert.equal(fallback.cpuLoadPercent, null);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("Windows resource snapshot queries PowerShell without requiring optional modules", () => {
|
|
59
|
+
const calls = [];
|
|
60
|
+
const snapshot = systemResourceSnapshot({
|
|
61
|
+
platform: "win",
|
|
62
|
+
run: (command, args) => { calls.push({ command, args }); return { status: 0, stdout: '{"TotalBytes":8589934592,"FreeBytes":1073741824,"CpuLoadPercent":42}' }; },
|
|
63
|
+
totalmem: () => 1,
|
|
64
|
+
freemem: () => 1,
|
|
65
|
+
});
|
|
66
|
+
assert.equal(calls[0].command, "powershell.exe");
|
|
67
|
+
assert.deepEqual(calls[0].args.slice(0, 3), ["-NoLogo", "-NoProfile", "-NonInteractive"]);
|
|
68
|
+
assert.equal(snapshot.freePercent, 12.5);
|
|
69
|
+
assert.equal(snapshot.cpuLoadPercent, 42);
|
|
70
|
+
assert.equal(snapshot.thermalLimited, false);
|
|
71
|
+
assert.equal(formatResourceSnapshot(snapshot), "memory-free=12.5% cpu-load=42%");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("nested heavy command preserves ownership without another slot", async () => {
|
|
75
|
+
const code = await runHeavyCommand(["--", process.execPath, "-e", "process.exit(0)"], { env: { ...process.env, RIGHTSUITE_HEAVY_WORK_OWNER: "1" } });
|
|
76
|
+
assert.equal(code, 0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("slot serializes contenders and removes stale owners", () => {
|
|
80
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "rightsuite-heavy-"));
|
|
81
|
+
const quiet = () => {};
|
|
82
|
+
const snapshot = () => ({ freePercent: 50, swapUsedBytes: 0, swapTotalBytes: 0, thermalLimited: false });
|
|
83
|
+
const first = acquireHeavyWorkSlot({ root, pid: 101, alive: (pid) => pid === 101, snapshot, log: quiet });
|
|
84
|
+
assert.throws(
|
|
85
|
+
() => acquireHeavyWorkSlot({ root, pid: 202, waitMs: 1, pollMs: 1, alive: (pid) => pid === 101, pause: () => {}, snapshot, log: quiet }),
|
|
86
|
+
/holder pid 101/,
|
|
87
|
+
);
|
|
88
|
+
first.release();
|
|
89
|
+
|
|
90
|
+
const lockDir = path.join(root, "slot");
|
|
91
|
+
mkdirSync(lockDir);
|
|
92
|
+
writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 303, token: "stale" }));
|
|
93
|
+
const replacement = acquireHeavyWorkSlot({ root, pid: 404, alive: () => false, snapshot, log: quiet });
|
|
94
|
+
replacement.release();
|
|
95
|
+
});
|
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.50",
|
|
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": {
|
|
@@ -442,10 +442,10 @@ test("RightKit exposes one current version manifest", () => {
|
|
|
442
442
|
"@rightkit/legal": "0.3.0",
|
|
443
443
|
"@rightkit/legal-ui": "0.1.0",
|
|
444
444
|
"@rightkit/license": "0.1.6",
|
|
445
|
-
"@rightkit/release": "0.2.
|
|
445
|
+
"@rightkit/release": "0.2.50",
|
|
446
446
|
});
|
|
447
447
|
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"],
|
|
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", "0.2.49"],
|
|
449
449
|
});
|
|
450
450
|
assert.ok(
|
|
451
451
|
new Set([versions.npm["@rightkit/release"], ...versions.legacyNpm["@rightkit/release"]]).has("0.2.42"),
|
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.50"
|
|
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"]
|
|
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
|
|