@rightkit/release 0.2.66 → 0.2.68
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/addon-command.test.mjs +5 -5
- package/addon-contract.test.mjs +3 -3
- package/build-release.mjs +64 -22
- package/build-release.test.mjs +29 -0
- package/cargo-guard.mjs +31 -1
- package/cargo-guard.test.mjs +31 -0
- package/cargo-target.mjs +3 -2
- package/cargo-target.test.mjs +42 -93
- package/github-release.test.mjs +3 -3
- package/package.json +3 -2
- package/progress-control.mjs +75 -0
- package/progress-control.test.mjs +56 -0
- package/registry-parity.mjs +79 -0
- package/registry-parity.test.mjs +30 -0
- package/release.mjs +2 -1
- package/release.test.mjs +7 -0
- package/right-suite-contract.test.mjs +111 -20
- package/rightkit-versions.json +25 -9
- package/standalone-clone-evidence.json +1 -1
- package/standalone-clone-verify.test.mjs +1 -1
package/addon-command.test.mjs
CHANGED
|
@@ -13,8 +13,8 @@ function fixture() {
|
|
|
13
13
|
mkdirSync(path.join(root, "legal")); mkdirSync(path.join(root, "assets"));
|
|
14
14
|
for (const name of ["LICENSE", "EULA.txt", "PRIVACY.md", "THIRD-PARTY-NOTICES.txt"]) writeFileSync(path.join(root, "legal", name), name);
|
|
15
15
|
writeFileSync(path.join(root, "assets", "tab.png"), "icon");
|
|
16
|
-
const files = [["command", "membrane", "out/membrane", true], ["service", "
|
|
17
|
-
writeFileSync(path.join(root, "right-addon.config.mjs"), `export default ${JSON.stringify({ schema: 1, kind: "headless-addon", addon: "membrane", version: "0.1.0", packageManager: "pnpm", checks: [], buildInputs: { include: ["right-addon.config.mjs"] }, consumer: { contract: "
|
|
16
|
+
const files = [["command", "membrane", "out/membrane", true], ["service", "membrane-service", "out/membrane-service", true], ["icon", "membrane-tab-icon.png", "assets/tab.png", false], ["license", "LICENSE", "legal/LICENSE", false], ["eula", "EULA.txt", "legal/EULA.txt", false], ["privacy", "PRIVACY.md", "legal/PRIVACY.md", false], ["third-party-notices", "THIRD-PARTY-NOTICES.txt", "legal/THIRD-PARTY-NOTICES.txt", false]].map(([role, name, source, executable]) => ({ role, name, source, executable }));
|
|
17
|
+
writeFileSync(path.join(root, "right-addon.config.mjs"), `export default ${JSON.stringify({ schema: 1, kind: "headless-addon", addon: "membrane", version: "0.1.0", packageManager: "pnpm", checks: [], buildInputs: { include: ["right-addon.config.mjs"] }, consumer: { contract: "membrane-product-v1" }, targets: { mac: { targetTriple: "aarch64-apple-darwin", build: { cmd: "false", args: [] }, signing: { contract: "apple-developer-id-executable-v1", teamId: "6KLGD3LLKF" }, files }, win: { targetTriple: "x86_64-pc-windows-msvc", build: { cmd: "false", args: [] }, signing: { contract: "azure-artifact-signing-v1" }, files } } }, null, 2)};\n`);
|
|
18
18
|
execFileSync("git", ["init", "--initial-branch", "main"], { cwd: root }); execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: root }); execFileSync("git", ["config", "user.name", "Test"], { cwd: root }); execFileSync("git", ["add", "."], { cwd: root }); execFileSync("git", ["commit", "-m", "init"], { cwd: root });
|
|
19
19
|
return root;
|
|
20
20
|
}
|
|
@@ -25,7 +25,7 @@ test("add-on dry-run creates no release state and never executes build", () => {
|
|
|
25
25
|
|
|
26
26
|
test("local adoption verifies lock and stages portable plus Tauri-qualified binaries", async () => {
|
|
27
27
|
const root = fixture(); const config = (await import(`${pathToFileURL(path.join(root, "right-addon.config.mjs")).href}?test=${Date.now()}`)).default;
|
|
28
|
-
mkdirSync(path.join(root, "out")); writeFileSync(path.join(root, "out", "membrane"), "command"); writeFileSync(path.join(root, "out", "
|
|
28
|
+
mkdirSync(path.join(root, "out")); writeFileSync(path.join(root, "out", "membrane"), "command"); writeFileSync(path.join(root, "out", "membrane-service"), "service");
|
|
29
29
|
const manifest = createAddonManifest({ config, root, platform: "mac", commit: "d".repeat(40), signing: { command: { contract: "test-fixture-v1", status: "verified" }, service: { contract: "test-fixture-v1", status: "verified" } } });
|
|
30
30
|
const sealed = path.join(root, "sealed"); mkdirSync(sealed);
|
|
31
31
|
for (const file of manifest.files) writeFileSync(path.join(sealed, file.name), readFileSync(path.join(root, config.targets.mac.files.find((entry) => entry.role === file.role).source)));
|
|
@@ -44,11 +44,11 @@ test("local adoption verifies lock and stages portable plus Tauri-qualified bina
|
|
|
44
44
|
|
|
45
45
|
test("remote downloader accepts only immutable manifest plus file routes", async () => {
|
|
46
46
|
const root = fixture(); const config = (await import(`${pathToFileURL(path.join(root, "right-addon.config.mjs")).href}?remote=${Date.now()}`)).default;
|
|
47
|
-
mkdirSync(path.join(root, "out")); writeFileSync(path.join(root, "out", "membrane"), "command"); writeFileSync(path.join(root, "out", "
|
|
47
|
+
mkdirSync(path.join(root, "out")); writeFileSync(path.join(root, "out", "membrane"), "command"); writeFileSync(path.join(root, "out", "membrane-service"), "service");
|
|
48
48
|
const manifest = createAddonManifest({ config, root, platform: "mac", commit: "e".repeat(40), signing: { command: { contract: "test-fixture-v1", status: "verified" }, service: { contract: "test-fixture-v1", status: "verified" } } });
|
|
49
49
|
const digest = addonManifestSha256(manifest); const base = `https://example.test/membrane/addons/mac/sha256/${digest}/`; const bytes = new Map([[`${base}addon-manifest.json`, Buffer.from(canonicalAddonManifest(manifest))], ...manifest.files.map((file) => [base + encodeURIComponent(file.name), readFileSync(path.join(root, config.targets.mac.files.find((entry) => entry.role === file.role).source))])]);
|
|
50
50
|
const destination = path.join(root, "remote"); mkdirSync(destination);
|
|
51
51
|
await downloadImmutableAddOn(`${base}addon-manifest.json`, destination, { fetchImpl: async (url) => ({ ok: bytes.has(url), status: bytes.has(url) ? 200 : 404, arrayBuffer: async () => bytes.get(url) }) });
|
|
52
|
-
assert.equal(readFileSync(path.join(destination, "
|
|
52
|
+
assert.equal(readFileSync(path.join(destination, "membrane-service"), "utf8"), "service");
|
|
53
53
|
assert.equal(immutableAddonManifestUrl(manifest, `https://github.com/Orthic-Labs/Membrane/releases/download/addon-membrane-v0.1.0-mac-sha256-${digest}/addon-manifest.json`, digest), true);
|
|
54
54
|
});
|
package/addon-contract.test.mjs
CHANGED
|
@@ -8,12 +8,12 @@ import { addonManifestSha256, addonRoutes, createAddonManifest, validateAddonCon
|
|
|
8
8
|
function fixture() {
|
|
9
9
|
const root = mkdtempSync(path.join(os.tmpdir(), "right-addon-contract-"));
|
|
10
10
|
mkdirSync(path.join(root, "bin")); mkdirSync(path.join(root, "assets")); mkdirSync(path.join(root, "legal"));
|
|
11
|
-
for (const [file, bytes] of [["bin/membrane", "membrane"], ["bin/
|
|
11
|
+
for (const [file, bytes] of [["bin/membrane", "membrane"], ["bin/membrane-service", "service"], ["assets/tab.png", "icon"], ["legal/LICENSE", "license"], ["legal/EULA.txt", "eula"], ["legal/PRIVACY.md", "privacy"], ["legal/THIRD-PARTY-NOTICES.txt", "notice"]]) writeFileSync(path.join(root, file), bytes);
|
|
12
12
|
const files = [
|
|
13
|
-
["command", "membrane", "bin/membrane", true], ["service", "
|
|
13
|
+
["command", "membrane", "bin/membrane", true], ["service", "membrane-service", "bin/membrane-service", true], ["icon", "membrane-tab-icon.png", "assets/tab.png", false],
|
|
14
14
|
["license", "LICENSE", "legal/LICENSE", false], ["eula", "EULA.txt", "legal/EULA.txt", false], ["privacy", "PRIVACY.md", "legal/PRIVACY.md", false], ["third-party-notices", "THIRD-PARTY-NOTICES.txt", "legal/THIRD-PARTY-NOTICES.txt", false],
|
|
15
15
|
].map(([role, name, source, executable]) => ({ role, name, source, executable }));
|
|
16
|
-
return { root, config: { schema: 1, kind: "headless-addon", addon: "membrane", version: "0.1.0", packageManager: "pnpm", checks: ["test"], buildInputs: { include: ["right-addon.config.mjs"] }, consumer: { contract: "
|
|
16
|
+
return { root, config: { schema: 1, kind: "headless-addon", addon: "membrane", version: "0.1.0", packageManager: "pnpm", checks: ["test"], buildInputs: { include: ["right-addon.config.mjs"] }, consumer: { contract: "membrane-product-v1" }, targets: { mac: { targetTriple: "aarch64-apple-darwin", build: { cmd: "cargo", args: ["build"] }, signing: { contract: "apple-developer-id-executable-v1", teamId: "6KLGD3LLKF" }, files }, win: { targetTriple: "x86_64-pc-windows-msvc", build: { cmd: "cargo", args: ["build"] }, signing: { contract: "azure-artifact-signing-v1" }, files } } } };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
test("add-on manifest has portable immutable identity and routes", () => {
|
package/build-release.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { buildPathPrefix, collectPreflight, formatPreflight, preflightFailures }
|
|
|
33
33
|
import { assertCleanSource } from "./source-gate.mjs";
|
|
34
34
|
import { acquireHeavyWorkSlot, heavyCommandEnvironment, terminateProcessTree } from "./heavy-command.mjs";
|
|
35
35
|
import { processGroupCpuSeconds } from "./process-liveness.mjs";
|
|
36
|
+
import { cancelAndReapManagedRequest, firstManagedRequestId, releaseProgressLimits } from "./progress-control.mjs";
|
|
36
37
|
|
|
37
38
|
// Fingerprint of the pipeline code that can change the bytes we ship or the way
|
|
38
39
|
// they are signed — deliberately NOT the package version, which moves for docs
|
|
@@ -104,6 +105,7 @@ let suiteSlot;
|
|
|
104
105
|
let cacheLease;
|
|
105
106
|
let heavySlot;
|
|
106
107
|
let interrupted;
|
|
108
|
+
let requestActiveRunStop = null;
|
|
107
109
|
|
|
108
110
|
// A dry-run is an inspection contract, not a partial build. It must finish
|
|
109
111
|
// before locks, cache directories, receipts, dependency installation, or any
|
|
@@ -124,9 +126,10 @@ if (dryRun) {
|
|
|
124
126
|
|
|
125
127
|
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
126
128
|
process.once(signal, () => {
|
|
127
|
-
if (child?.pid) killTree(child.pid);
|
|
128
129
|
interrupted = new Error(`release interrupted by ${signal}`);
|
|
129
130
|
process.exitCode = signal === "SIGINT" ? 130 : 143;
|
|
131
|
+
if (requestActiveRunStop) void requestActiveRunStop(interrupted.message);
|
|
132
|
+
else if (child?.pid) killTree(child.pid);
|
|
130
133
|
});
|
|
131
134
|
}
|
|
132
135
|
|
|
@@ -319,13 +322,14 @@ try {
|
|
|
319
322
|
env,
|
|
320
323
|
[managedCargoTarget ?? env.CARGO_TARGET_DIR, path.join(appRoot, ".cache")],
|
|
321
324
|
path.join(stateRoot, "stall-build.log"),
|
|
325
|
+
{ livenessOwnerTimeoutMs: target.package.livenessOwnerTimeoutMs ?? null },
|
|
322
326
|
);
|
|
323
327
|
checkpoint(stateRoot, "build_complete");
|
|
324
328
|
checkpoint(stateRoot, "signed");
|
|
325
329
|
checkpoint(stateRoot, "hardened");
|
|
326
330
|
},
|
|
327
331
|
seal: async ({ sealedDir }) => {
|
|
328
|
-
sealRelease({ configRoot: appRoot, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity });
|
|
332
|
+
sealRelease({ configRoot: appRoot, managedCargoTarget, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity });
|
|
329
333
|
verifySealedRelease(sealedDir);
|
|
330
334
|
if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
|
|
331
335
|
checkpoint(stateRoot, "sealed");
|
|
@@ -365,17 +369,32 @@ function sqlCipherFeatures(text) {
|
|
|
365
369
|
.filter((value) => /sqlcipher|openssl/i.test(value));
|
|
366
370
|
}
|
|
367
371
|
|
|
368
|
-
function sealRelease({ configRoot, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity }) {
|
|
372
|
+
function sealRelease({ configRoot, managedCargoTarget, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity }) {
|
|
373
|
+
// Every app's right-release.config.mjs names installer/updater artifacts
|
|
374
|
+
// relative to the local `src-tauri/target`. On a broker-managed host that
|
|
375
|
+
// directory is never populated (the broker owns CARGO_TARGET_DIR and
|
|
376
|
+
// targetBridge.ensure() deliberately leaves a real local directory alone,
|
|
377
|
+
// see target-bridge.mjs) so those paths must resolve against the broker's
|
|
378
|
+
// own target root instead, or every managed build fails at seal after a
|
|
379
|
+
// full compile, sign, and notarize.
|
|
380
|
+
const targetPrefix = "src-tauri/target/";
|
|
381
|
+
const resolveArtifactPath = (file) => {
|
|
382
|
+
const normalized = file.replaceAll(path.win32.sep, "/");
|
|
383
|
+
if (managedCargoTarget && normalized.startsWith(targetPrefix)) {
|
|
384
|
+
return path.join(managedCargoTarget, normalized.slice(targetPrefix.length));
|
|
385
|
+
}
|
|
386
|
+
return path.resolve(configRoot, file);
|
|
387
|
+
};
|
|
369
388
|
const sources = new Map();
|
|
370
389
|
const addSource = (source, role) => {
|
|
371
390
|
const current = sources.get(source) ?? new Set();
|
|
372
391
|
current.add(role);
|
|
373
392
|
sources.set(source, current);
|
|
374
393
|
};
|
|
375
|
-
for (const artifact of target.installer?.artifacts ?? []) addSource(
|
|
394
|
+
for (const artifact of target.installer?.artifacts ?? []) addSource(resolveArtifactPath(artifact.file), "installer");
|
|
376
395
|
for (const artifact of target.updater?.artifacts ?? []) {
|
|
377
|
-
addSource(
|
|
378
|
-
addSource(
|
|
396
|
+
addSource(resolveArtifactPath(artifact.file), "updater");
|
|
397
|
+
addSource(resolveArtifactPath(artifact.signature), "updater-signature");
|
|
379
398
|
}
|
|
380
399
|
for (const file of sources.keys()) if (!existsSync(file)) fail(`cannot seal missing artifact: ${file}`);
|
|
381
400
|
const temp = `${sealedDir}.tmp-${process.pid}`;
|
|
@@ -392,14 +411,15 @@ function sealRelease({ configRoot, sealedDir, releaseId, config, target, platfor
|
|
|
392
411
|
}
|
|
393
412
|
const routes = { patch: [], update: [] };
|
|
394
413
|
for (const artifact of target.installer?.artifacts ?? []) {
|
|
395
|
-
const source =
|
|
414
|
+
const source = resolveArtifactPath(artifact.file);
|
|
396
415
|
routes.patch.push({ role: "installer", name: sourceNames.get(source), bucket: "public", key: artifact.key });
|
|
397
416
|
}
|
|
398
417
|
for (const artifact of target.updater?.artifacts ?? []) {
|
|
399
|
-
const source =
|
|
418
|
+
const source = resolveArtifactPath(artifact.file);
|
|
419
|
+
const signatureSource = resolveArtifactPath(artifact.signature);
|
|
400
420
|
const patchKey = artifact.patchKey ?? artifact.key.replace("/updates/", "/installers/");
|
|
401
|
-
routes.patch.push({ role: "updater", name: sourceNames.get(source), signature: sourceNames.get(
|
|
402
|
-
routes.update.push({ role: "updater", name: sourceNames.get(source), signature: sourceNames.get(
|
|
421
|
+
routes.patch.push({ role: "updater", name: sourceNames.get(source), signature: sourceNames.get(signatureSource), bucket: "public", key: patchKey, platform: artifact.platform });
|
|
422
|
+
routes.update.push({ role: "updater", name: sourceNames.get(source), signature: sourceNames.get(signatureSource), bucket: "private", key: artifact.key, platform: artifact.platform });
|
|
403
423
|
}
|
|
404
424
|
writeJson(path.join(temp, "hardening.json"), { schema: 1, passed: true, checkedAt: new Date().toISOString(), files: files.map((file) => file.sha256) });
|
|
405
425
|
const manifest = {
|
|
@@ -445,15 +465,20 @@ function collectToolVersions(packageManager) {
|
|
|
445
465
|
}
|
|
446
466
|
}
|
|
447
467
|
|
|
448
|
-
async function runProgress(cmd, runArgs, cwd, env, watchDir, diagnosticPath = null) {
|
|
449
|
-
const inactivityMs =
|
|
450
|
-
const absoluteMs = Number(process.env.RIGHT_RELEASE_ABSOLUTE_MS ?? 90 * 60 * 1000);
|
|
468
|
+
async function runProgress(cmd, runArgs, cwd, env, watchDir, diagnosticPath = null, { livenessOwnerTimeoutMs = null } = {}) {
|
|
469
|
+
const { inactivityMs, absoluteMs } = releaseProgressLimits(env, { livenessOwnerTimeoutMs });
|
|
451
470
|
let lastProgress = Date.now();
|
|
452
471
|
const watchDirs = Array.isArray(watchDir) ? watchDir : [watchDir];
|
|
453
472
|
let lastMtime = Math.max(...watchDirs.map(newestMtime));
|
|
454
473
|
const started = Date.now();
|
|
455
474
|
await new Promise((resolve, reject) => {
|
|
456
|
-
|
|
475
|
+
let settled = false;
|
|
476
|
+
let stopping = false;
|
|
477
|
+
let managedRequestId = null;
|
|
478
|
+
const settleResolve = () => { if (!settled) { settled = true; resolve(); } };
|
|
479
|
+
const settleReject = (error) => { if (!settled) { settled = true; reject(error); } };
|
|
480
|
+
const useShell = process.platform === "win32" && !/\.(?:exe|com)$/i.test(cmd);
|
|
481
|
+
child = spawn(cmd, runArgs, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: useShell, detached: process.platform !== "win32" });
|
|
457
482
|
try {
|
|
458
483
|
if (child.pid && heavySlot) heavySlot.trackChild(child.pid, cmd);
|
|
459
484
|
} catch (error) {
|
|
@@ -471,6 +496,7 @@ async function runProgress(cmd, runArgs, cwd, env, watchDir, diagnosticPath = nu
|
|
|
471
496
|
stream.on("data", (chunk) => {
|
|
472
497
|
lastProgress = Date.now();
|
|
473
498
|
tail = `${tail}${chunk}`.slice(-64 * 1024);
|
|
499
|
+
if (!managedRequestId) managedRequestId = firstManagedRequestId(tail);
|
|
474
500
|
output.write(chunk);
|
|
475
501
|
});
|
|
476
502
|
}
|
|
@@ -481,12 +507,24 @@ async function runProgress(cmd, runArgs, cwd, env, watchDir, diagnosticPath = nu
|
|
|
481
507
|
const cpu = processGroupCpuSeconds(child.pid);
|
|
482
508
|
if (cpu !== null && lastCpu !== null && cpu > lastCpu) lastProgress = Date.now();
|
|
483
509
|
if (cpu !== null) lastCpu = cpu;
|
|
484
|
-
if (Date.now() - started > absoluteMs) stop(`absolute limit exceeded (${Math.round(absoluteMs / 60000)}m)`, cpu);
|
|
485
|
-
else if (Date.now() - lastProgress > inactivityMs) stop(`no output, file, or CPU progress for ${Math.round(inactivityMs / 60000)}m`, cpu);
|
|
510
|
+
if (Date.now() - started > absoluteMs) void stop(`absolute limit exceeded (${Math.round(absoluteMs / 60000)}m)`, cpu);
|
|
511
|
+
else if (Date.now() - lastProgress > inactivityMs) void stop(`no output, file, or CPU progress for ${Math.round(inactivityMs / 60000)}m`, cpu);
|
|
486
512
|
}, Math.min(30_000, Math.max(250, Math.floor(inactivityMs / 4))));
|
|
487
|
-
const cleanup = () => {
|
|
488
|
-
|
|
513
|
+
const cleanup = () => {
|
|
514
|
+
clearInterval(monitor);
|
|
515
|
+
closeWatchers();
|
|
516
|
+
if (requestActiveRunStop === stop) requestActiveRunStop = null;
|
|
517
|
+
};
|
|
518
|
+
const stop = async (reason, cpu = null) => {
|
|
519
|
+
if (stopping || settled) return;
|
|
520
|
+
stopping = true;
|
|
489
521
|
cleanup();
|
|
522
|
+
let cancellation = { status: "NO_MANAGED_REQUEST" };
|
|
523
|
+
try {
|
|
524
|
+
cancellation = await cancelAndReapManagedRequest(managedRequestId, { cwd, env });
|
|
525
|
+
} catch (error) {
|
|
526
|
+
cancellation = { status: "CANCEL_REAP_FAILED", error: error.message };
|
|
527
|
+
}
|
|
490
528
|
if (diagnosticPath) {
|
|
491
529
|
try {
|
|
492
530
|
mkdirSync(path.dirname(diagnosticPath), { recursive: true });
|
|
@@ -497,6 +535,8 @@ async function runProgress(cmd, runArgs, cwd, env, watchDir, diagnosticPath = nu
|
|
|
497
535
|
`watched: ${watchDirs.join(path.delimiter)}`,
|
|
498
536
|
`elapsedMs: ${Date.now() - started}`,
|
|
499
537
|
`cpuSeconds: ${cpu ?? "unavailable"}`,
|
|
538
|
+
`managedRequestId: ${managedRequestId ?? "none"}`,
|
|
539
|
+
`managedCancellation: ${JSON.stringify(cancellation)}`,
|
|
500
540
|
`killedAt: ${new Date().toISOString()}`,
|
|
501
541
|
"",
|
|
502
542
|
"--- last output ---",
|
|
@@ -506,11 +546,13 @@ async function runProgress(cmd, runArgs, cwd, env, watchDir, diagnosticPath = nu
|
|
|
506
546
|
console.error(`right-release: stall diagnostic written to ${diagnosticPath}`);
|
|
507
547
|
} catch { /* diagnostics are best effort; never mask the stall itself */ }
|
|
508
548
|
}
|
|
509
|
-
killTree(child.pid);
|
|
510
|
-
|
|
549
|
+
if (child?.pid) killTree(child.pid);
|
|
550
|
+
child = null;
|
|
551
|
+
settleReject(new Error(`release step stalled: ${reason}`));
|
|
511
552
|
};
|
|
512
|
-
|
|
513
|
-
child.once("
|
|
553
|
+
requestActiveRunStop = stop;
|
|
554
|
+
child.once("error", (error) => { cleanup(); child = null; if (!stopping) settleReject(error); });
|
|
555
|
+
child.once("exit", (code) => { cleanup(); child = null; if (!stopping) code === 0 ? settleResolve() : settleReject(new Error(`${cmd} exited ${code}`)); });
|
|
514
556
|
});
|
|
515
557
|
}
|
|
516
558
|
|
package/build-release.test.mjs
CHANGED
|
@@ -54,6 +54,30 @@ test("production builds package from the real primary app checkout", () => {
|
|
|
54
54
|
assert.match(source, /sealRelease\(\{ configRoot: appRoot,/);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
test("Windows native executables preserve arguments containing spaces", () => {
|
|
58
|
+
assert.match(source, /const useShell = process\.platform === "win32" && !\/\\\.\(\?:exe\|com\)\$\/i\.test\(cmd\);/);
|
|
59
|
+
assert.match(source, /shell: useShell/);
|
|
60
|
+
assert.doesNotMatch(source, /shell: process\.platform === "win32"/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("sealing resolves installer and updater artifacts against the broker target on a managed host", () => {
|
|
64
|
+
assert.match(source, /sealRelease\(\{ configRoot: appRoot, managedCargoTarget,/);
|
|
65
|
+
assert.match(source, /function sealRelease\(\{ configRoot, managedCargoTarget,/);
|
|
66
|
+
assert.match(source, /const targetPrefix = "src-tauri\/target\/";/);
|
|
67
|
+
assert.match(source, /if \(managedCargoTarget && normalized\.startsWith\(targetPrefix\)\) \{/);
|
|
68
|
+
// Every configRoot-relative artifact lookup inside sealRelease must route
|
|
69
|
+
// through the resolver, or a managed build fails at seal after a full
|
|
70
|
+
// compile, sign, and notarize because src-tauri/target is never populated
|
|
71
|
+
// locally on a broker-managed host (target-bridge.mjs leaves it alone).
|
|
72
|
+
assert.doesNotMatch(source, /addSource\(path\.resolve\(configRoot, artifact\.file\)/);
|
|
73
|
+
assert.doesNotMatch(source, /addSource\(path\.resolve\(configRoot, artifact\.signature\)/);
|
|
74
|
+
assert.match(source, /addSource\(resolveArtifactPath\(artifact\.file\), "installer"\)/);
|
|
75
|
+
assert.match(source, /addSource\(resolveArtifactPath\(artifact\.file\), "updater"\)/);
|
|
76
|
+
assert.match(source, /addSource\(resolveArtifactPath\(artifact\.signature\), "updater-signature"\)/);
|
|
77
|
+
assert.match(source, /const source = resolveArtifactPath\(artifact\.file\);/);
|
|
78
|
+
assert.match(source, /const signatureSource = resolveArtifactPath\(artifact\.signature\);/);
|
|
79
|
+
});
|
|
80
|
+
|
|
57
81
|
test("production builds never create or use an internal Git worktree", () => {
|
|
58
82
|
assert.doesNotMatch(source, /git", \["worktree", "add"/);
|
|
59
83
|
assert.doesNotMatch(source, /path\.join\(vaultRoot, "worktrees"/);
|
|
@@ -139,6 +163,11 @@ test("CPU sampling degrades to the old behaviour rather than inventing a verdict
|
|
|
139
163
|
assert.equal(processGroupCpuSeconds(123, { platform: "darwin", run: ok("") }), null);
|
|
140
164
|
});
|
|
141
165
|
|
|
166
|
+
test("package liveness owner timeout floors outer watchdog through shared release limits", () => {
|
|
167
|
+
assert.match(source, /livenessOwnerTimeoutMs: target\.package\.livenessOwnerTimeoutMs \?\? null/);
|
|
168
|
+
assert.match(source, /releaseProgressLimits\(env, \{ livenessOwnerTimeoutMs \}\)/);
|
|
169
|
+
});
|
|
170
|
+
|
|
142
171
|
// Regression: the managed-host gate must not depend on RIGHTKIT_BUILD_BROKER_SOCKET
|
|
143
172
|
// alone. That variable is exported by login shells only, while the managed cargo
|
|
144
173
|
// shim is on PATH in every shell — so an agent shell took the Cache V2 branch,
|
package/cargo-guard.mjs
CHANGED
|
@@ -129,6 +129,24 @@ export function resolveRealRustc({ env = process.env, run = spawnSync } = {}) {
|
|
|
129
129
|
return path.resolve(rustc);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
// Locate the broker's own managed cargo shim on PATH (the agent-bin launcher
|
|
133
|
+
// that routes into the build broker). Used to hand a whole invocation to the
|
|
134
|
+
// broker on a managed host instead of resolving & running the real toolchain
|
|
135
|
+
// ourselves — which would bypass managed build control entirely.
|
|
136
|
+
export function resolveManagedCargoShim({ env = process.env, platform = process.platform, exists = existsSync } = {}) {
|
|
137
|
+
const win = platform === "win32" || platform === "win";
|
|
138
|
+
const delimiter = win ? ";" : ":";
|
|
139
|
+
const agentBin = /(?:^|[\\/])(?:\.rightkit-managed|rightkitmanagedagent)[\\/]agent-bin[\\/]?$/i;
|
|
140
|
+
for (const directory of String(env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
141
|
+
if (!agentBin.test(directory)) continue;
|
|
142
|
+
for (const name of win ? ["cargo.exe", "cargo.cmd"] : ["cargo"]) {
|
|
143
|
+
const candidate = path.join(directory, name);
|
|
144
|
+
if (exists(candidate)) return candidate;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
132
150
|
function pathApi(platform) {
|
|
133
151
|
return platform === "win32" || platform === "win" ? path.win32 : path.posix;
|
|
134
152
|
}
|
|
@@ -209,8 +227,20 @@ function spawnCargo(command, args, env) {
|
|
|
209
227
|
});
|
|
210
228
|
}
|
|
211
229
|
|
|
212
|
-
export async function runCargoGuard(args, { env = process.env, policyOptions = {}, resolveCargo = resolveRealCargo, resolveRustc = resolveRealRustc, runHeavy = runHeavyCommand, runLight = spawnCargo } = {}) {
|
|
230
|
+
export async function runCargoGuard(args, { env = process.env, policyOptions = {}, resolveCargo = resolveRealCargo, resolveRustc = resolveRealRustc, runHeavy = runHeavyCommand, runLight = spawnCargo, resolveShim = resolveManagedCargoShim, isBrokerHost = brokerManagedHost } = {}) {
|
|
213
231
|
assertComputePolicyAllowsCargo(args, { ...policyOptions, env });
|
|
232
|
+
// On a broker-managed host the build broker owns the toolchain, cache, and
|
|
233
|
+
// quotas. Resolving the real Cargo here and running it via runHeavy would
|
|
234
|
+
// bypass the broker (and, on Windows, resolveReal* itself fails: `rustup
|
|
235
|
+
// which` routes through the managed shim into the broker's isolated
|
|
236
|
+
// RUSTUP_HOME, which has no default toolchain). Hand the whole invocation to
|
|
237
|
+
// the broker's own cargo shim instead — everything through the broker.
|
|
238
|
+
if (isBrokerHost(env)) {
|
|
239
|
+
const shim = resolveShim({ env });
|
|
240
|
+
if (shim) return runLight(shim, args, env);
|
|
241
|
+
// No shim found despite a broker signal: fall through to legacy resolution
|
|
242
|
+
// rather than silently dropping the guard.
|
|
243
|
+
}
|
|
214
244
|
const cargo = resolveCargo({ env });
|
|
215
245
|
if (shouldGuardCargo(args)) {
|
|
216
246
|
const guarded = cargoCacheEnvironment(args, { env });
|
package/cargo-guard.test.mjs
CHANGED
|
@@ -86,6 +86,37 @@ test("Cargo guard rejects cache escapes and alternate compiler wrappers", async
|
|
|
86
86
|
await assert.rejects(run(["build"], { RUSTC_WRAPPER: "rustc-wrapper" }), /RUSTC_WRAPPER/);
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
test("Cargo guard hands the whole invocation to the broker on a managed host", async () => {
|
|
90
|
+
const calls = [];
|
|
91
|
+
let resolvedReal = false;
|
|
92
|
+
const options = {
|
|
93
|
+
env: { TEST: "1" },
|
|
94
|
+
policyOptions: { exists: () => false },
|
|
95
|
+
isBrokerHost: () => true,
|
|
96
|
+
resolveShim: () => "/managed/agent-bin/cargo",
|
|
97
|
+
resolveCargo: () => { resolvedReal = true; return "/real/cargo"; },
|
|
98
|
+
resolveRustc: () => { resolvedReal = true; return "/real/rustc"; },
|
|
99
|
+
runHeavy: async () => { throw new Error("must not run heavy on a broker host"); },
|
|
100
|
+
runLight: async (command, args) => { calls.push([command, args]); return 0; },
|
|
101
|
+
};
|
|
102
|
+
// A guarded command (build) and a light command (--version) both delegate to
|
|
103
|
+
// the broker shim verbatim, and the real-toolchain resolver is never touched.
|
|
104
|
+
assert.equal(await runCargoGuard(["build", "--release"], options), 0);
|
|
105
|
+
assert.equal(await runCargoGuard(["--version"], options), 0);
|
|
106
|
+
assert.deepEqual(calls, [["/managed/agent-bin/cargo", ["build", "--release"]], ["/managed/agent-bin/cargo", ["--version"]]]);
|
|
107
|
+
assert.equal(resolvedReal, false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("Cargo guard falls back to real resolution when a broker signal has no shim", async () => {
|
|
111
|
+
const calls = [];
|
|
112
|
+
await runCargoGuard(["build"], {
|
|
113
|
+
env: cacheEnv(), isBrokerHost: () => true, resolveShim: () => null,
|
|
114
|
+
resolveCargo: () => "/real/cargo", resolveRustc: () => "/real/rustc",
|
|
115
|
+
runHeavy: async (args) => { calls.push(args); return 0; },
|
|
116
|
+
});
|
|
117
|
+
assert.deepEqual(calls[0].slice(0, 2), ["--", "/real/cargo"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
89
120
|
test("Cargo guard routes heavy and light commands without recursion", async () => {
|
|
90
121
|
const calls = [];
|
|
91
122
|
const options = {
|
package/cargo-target.mjs
CHANGED
|
@@ -13,11 +13,12 @@ import { execFileSync } from "node:child_process";
|
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
|
|
16
|
-
export function resolveTargetRoot(manifestPath) {
|
|
16
|
+
export function resolveTargetRoot(manifestPath, options = {}) {
|
|
17
17
|
if (!manifestPath) throw new Error("resolveTargetRoot requires a Cargo.toml manifest path");
|
|
18
|
+
const execute = options.execFileSync ?? execFileSync;
|
|
18
19
|
let output;
|
|
19
20
|
try {
|
|
20
|
-
output =
|
|
21
|
+
output = execute(
|
|
21
22
|
"cargo",
|
|
22
23
|
["metadata", "--offline", "--format-version", "1", "--no-deps", "--manifest-path", manifestPath],
|
|
23
24
|
{ cwd: path.dirname(manifestPath), encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
|
package/cargo-target.test.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
@@ -11,91 +11,42 @@ import { resolveTargetRoot } from "./cargo-target.mjs";
|
|
|
11
11
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
const cli = path.join(here, "cargo-target.mjs");
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
// shapes are deterministic and don't depend on the host's actual crate graph.
|
|
16
|
-
// It branches on a marker embedded in the manifest path so each test controls
|
|
17
|
-
// its own outcome independently.
|
|
18
|
-
function fakeCargoBin() {
|
|
14
|
+
function fakeCargo() {
|
|
19
15
|
const bin = mkdtempSync(path.join(os.tmpdir(), "rightkit-fake-cargo-"));
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
].join("\r\n")
|
|
30
|
-
: [
|
|
31
|
-
"#!/bin/sh",
|
|
32
|
-
'case "$*" in',
|
|
33
|
-
' *fail-manifest*) echo boom 1>&2; exit 1 ;;',
|
|
34
|
-
' *bad-target-manifest*) echo \'{"target_directory":"relative/target"}\'; exit 0 ;;',
|
|
35
|
-
` *) echo '{"target_directory":"${path.join(bin, "target")}"}'; exit 0 ;;`,
|
|
36
|
-
"esac",
|
|
37
|
-
].join("\n");
|
|
38
|
-
writeFileSync(script, body);
|
|
39
|
-
if (process.platform !== "win32") chmodSync(script, 0o755);
|
|
40
|
-
return bin;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function withFakeCargo(fn) {
|
|
44
|
-
const bin = fakeCargoBin();
|
|
45
|
-
const env = { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}` };
|
|
46
|
-
return fn({ bin, env });
|
|
16
|
+
return {
|
|
17
|
+
bin,
|
|
18
|
+
execFileSync(_command, args) {
|
|
19
|
+
const manifest = args.at(-1);
|
|
20
|
+
if (manifest.includes("fail-manifest")) throw new Error("boom");
|
|
21
|
+
if (manifest.includes("bad-target-manifest")) return '{"target_directory":"relative/target"}';
|
|
22
|
+
return JSON.stringify({ target_directory: path.join(bin, "target") });
|
|
23
|
+
},
|
|
24
|
+
};
|
|
47
25
|
}
|
|
48
26
|
|
|
49
27
|
test("resolveTargetRoot returns the absolute target_directory Cargo metadata reports", () => {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const previousPath = process.env.PATH;
|
|
55
|
-
process.env.PATH = env.PATH;
|
|
56
|
-
try {
|
|
57
|
-
const result = resolveTargetRoot(manifest);
|
|
58
|
-
assert.equal(result, path.resolve(path.join(bin, "target")));
|
|
59
|
-
} finally {
|
|
60
|
-
process.env.PATH = previousPath;
|
|
61
|
-
}
|
|
62
|
-
});
|
|
28
|
+
const { bin, execFileSync } = fakeCargo();
|
|
29
|
+
const manifest = path.join(mkdtempSync(path.join(os.tmpdir(), "rightkit-cargo-target-fixture-")), "Cargo.toml");
|
|
30
|
+
const result = resolveTargetRoot(manifest, { execFileSync });
|
|
31
|
+
assert.equal(result, path.resolve(path.join(bin, "target")));
|
|
63
32
|
});
|
|
64
33
|
|
|
65
34
|
test("resolveTargetRoot fails closed, naming the manifest, when target_directory is not absolute", () => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
assert.throws(
|
|
74
|
-
() => resolveTargetRoot(manifest),
|
|
75
|
-
new RegExp(`metadata for .*bad-target-manifest-Cargo\\.toml did not report an absolute target_directory`),
|
|
76
|
-
);
|
|
77
|
-
} finally {
|
|
78
|
-
process.env.PATH = previousPath;
|
|
79
|
-
}
|
|
80
|
-
});
|
|
35
|
+
const { execFileSync } = fakeCargo();
|
|
36
|
+
const manifest = path.join(mkdtempSync(path.join(os.tmpdir(), "rightkit-cargo-target-fixture-")), "bad-target-manifest-Cargo.toml");
|
|
37
|
+
assert.throws(
|
|
38
|
+
() => resolveTargetRoot(manifest, { execFileSync }),
|
|
39
|
+
new RegExp(`metadata for .*bad-target-manifest-Cargo\\.toml did not report an absolute target_directory`),
|
|
40
|
+
);
|
|
81
41
|
});
|
|
82
42
|
|
|
83
43
|
test("resolveTargetRoot fails closed, naming the manifest, when the metadata command exits non-zero", () => {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
try {
|
|
91
|
-
assert.throws(
|
|
92
|
-
() => resolveTargetRoot(manifest),
|
|
93
|
-
new RegExp(`metadata failed for .*fail-manifest-Cargo\\.toml`),
|
|
94
|
-
);
|
|
95
|
-
} finally {
|
|
96
|
-
process.env.PATH = previousPath;
|
|
97
|
-
}
|
|
98
|
-
});
|
|
44
|
+
const { execFileSync } = fakeCargo();
|
|
45
|
+
const manifest = path.join(mkdtempSync(path.join(os.tmpdir(), "rightkit-cargo-target-fixture-")), "fail-manifest-Cargo.toml");
|
|
46
|
+
assert.throws(
|
|
47
|
+
() => resolveTargetRoot(manifest, { execFileSync }),
|
|
48
|
+
new RegExp(`metadata failed for .*fail-manifest-Cargo\\.toml`),
|
|
49
|
+
);
|
|
99
50
|
});
|
|
100
51
|
|
|
101
52
|
test("resolveTargetRoot throws when no manifest path is given", () => {
|
|
@@ -104,26 +55,24 @@ test("resolveTargetRoot throws when no manifest path is given", () => {
|
|
|
104
55
|
});
|
|
105
56
|
|
|
106
57
|
test("CLI mode writes only the resolved path to stdout on success", () => {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
58
|
+
const manifestDir = mkdtempSync(path.join(os.tmpdir(), "rightkit-cargo-target-fixture-"));
|
|
59
|
+
const manifest = path.join(manifestDir, "Cargo.toml");
|
|
60
|
+
mkdirSync(path.join(manifestDir, "src"));
|
|
61
|
+
writeFileSync(path.join(manifestDir, "src", "lib.rs"), "");
|
|
62
|
+
writeFileSync(manifest, "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n");
|
|
63
|
+
const result = spawnSync(process.execPath, [cli, manifest], { encoding: "utf8" });
|
|
64
|
+
assert.equal(result.status, 0, result.stderr);
|
|
65
|
+
assert.equal(path.isAbsolute(result.stdout.trim()), true);
|
|
115
66
|
});
|
|
116
67
|
|
|
117
68
|
test("CLI mode writes a message to stderr and exits 1 on failure", () => {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
assert.match(result.stderr, /metadata failed for .*fail-manifest-Cargo\.toml/);
|
|
126
|
-
});
|
|
69
|
+
const manifestDir = mkdtempSync(path.join(os.tmpdir(), "rightkit-cargo-target-fixture-"));
|
|
70
|
+
const manifest = path.join(manifestDir, "fail-manifest-Cargo.toml");
|
|
71
|
+
writeFileSync(manifest, "not valid toml");
|
|
72
|
+
const result = spawnSync(process.execPath, [cli, manifest], { encoding: "utf8" });
|
|
73
|
+
assert.equal(result.status, 1);
|
|
74
|
+
assert.equal(result.stdout, "");
|
|
75
|
+
assert.match(result.stderr, /metadata failed for .*fail-manifest-Cargo\.toml/);
|
|
127
76
|
});
|
|
128
77
|
|
|
129
78
|
test("CLI mode requires a manifest argument", () => {
|
package/github-release.test.mjs
CHANGED
|
@@ -27,13 +27,13 @@ function fixture() {
|
|
|
27
27
|
function addonFixture() {
|
|
28
28
|
const root = mkdtempSync(path.join(os.tmpdir(), "right-release-github-addon-"));
|
|
29
29
|
mkdirSync(path.join(root, "out"));
|
|
30
|
-
for (const [name, bytes] of [["membrane", "command"], ["
|
|
30
|
+
for (const [name, bytes] of [["membrane", "command"], ["membrane-service", "service"], ["icon.png", "icon"], ["LICENSE", "license"], ["EULA.txt", "eula"], ["PRIVACY.md", "privacy"], ["THIRD-PARTY-NOTICES.txt", "notices"]]) writeFileSync(path.join(root, "out", name), bytes);
|
|
31
31
|
execFileSync("git", ["init", "--initial-branch", "main"], { cwd: root });
|
|
32
32
|
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: root });
|
|
33
33
|
execFileSync("git", ["config", "user.name", "Test"], { cwd: root });
|
|
34
34
|
writeFileSync(path.join(root, "tracked"), "source"); execFileSync("git", ["add", "."], { cwd: root }); execFileSync("git", ["commit", "-m", "source"], { cwd: root });
|
|
35
|
-
const files = [["command", "membrane", true], ["service", "
|
|
36
|
-
const config = { schema: 1, kind: "headless-addon", addon: "membrane", version: "0.1.0", packageManager: "pnpm", distribution: { provider: "github-releases", repository: "Orthic-Labs/Membrane" }, checks: [], buildInputs: { include: ["tracked"] }, consumer: { contract: "
|
|
35
|
+
const files = [["command", "membrane", true], ["service", "membrane-service", true], ["icon", "icon.png", false], ["license", "LICENSE", false], ["eula", "EULA.txt", false], ["privacy", "PRIVACY.md", false], ["third-party-notices", "THIRD-PARTY-NOTICES.txt", false]].map(([role, name, executable]) => ({ role, name, source: `out/${name}`, executable }));
|
|
36
|
+
const config = { schema: 1, kind: "headless-addon", addon: "membrane", version: "0.1.0", packageManager: "pnpm", distribution: { provider: "github-releases", repository: "Orthic-Labs/Membrane" }, checks: [], buildInputs: { include: ["tracked"] }, consumer: { contract: "membrane-product-v1" }, targets: { win: { targetTriple: "x86_64-pc-windows-msvc", build: { cmd: "false", args: [] }, signing: { contract: "azure-artifact-signing-v1" }, files } } };
|
|
37
37
|
const commit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
|
|
38
38
|
const manifest = createAddonManifest({ config, root, platform: "win", commit, signing: { command: { contract: "test-fixture-v1", status: "verified" }, service: { contract: "test-fixture-v1", status: "verified" } } });
|
|
39
39
|
const sealed = path.join(root, ".right-release", "addons", "membrane", "0.1.0", commit.slice(0, 8), "win"); mkdirSync(sealed, { recursive: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/release",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.68",
|
|
4
4
|
"description": "Portable Right Suite release CLI/SDK: native-host signed installers, updater artifacts, hardening, immutable GitHub Release upload, and add-on adoption.",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"directory": "tools/rightkit/packages/release"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
|
-
"test": "node --test *.test.mjs",
|
|
28
|
+
"test": "node --test --test-concurrency=1 --test-force-exit *.test.mjs",
|
|
29
|
+
"test:registry-parity": "node registry-parity.mjs --allow-unpublished",
|
|
29
30
|
"doctor:all": "node --test right-suite-contract.test.mjs",
|
|
30
31
|
"verify:standalone": "node standalone-clone-verify.mjs"
|
|
31
32
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const MANAGED_REQUEST_PATTERN = /\brightkit(?: managed-agent)?: request ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi;
|
|
4
|
+
export const RELEASE_LIVENESS_OWNER_MARGIN_MS = 5 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
export function latestManagedRequestId(value, fallback = null) {
|
|
7
|
+
let latest = fallback;
|
|
8
|
+
for (const match of String(value ?? "").matchAll(MANAGED_REQUEST_PATTERN)) latest = match[1].toLowerCase();
|
|
9
|
+
return latest;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function firstManagedRequestId(value) {
|
|
13
|
+
MANAGED_REQUEST_PATTERN.lastIndex = 0;
|
|
14
|
+
return MANAGED_REQUEST_PATTERN.exec(String(value ?? ""))?.[1]?.toLowerCase() ?? null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function releaseProgressLimits(env = process.env, { livenessOwnerTimeoutMs = null } = {}) {
|
|
18
|
+
const configuredInactivityMs = Number(env.RIGHT_RELEASE_STALL_MS ?? 10 * 60 * 1000);
|
|
19
|
+
const absoluteMs = Number(env.RIGHT_RELEASE_ABSOLUTE_MS ?? 4 * 60 * 60 * 1000);
|
|
20
|
+
const ownerTimeoutMs = livenessOwnerTimeoutMs === null ? null : Number(livenessOwnerTimeoutMs);
|
|
21
|
+
if (![configuredInactivityMs, absoluteMs, ...(ownerTimeoutMs === null ? [] : [ownerTimeoutMs])].every((value) => Number.isFinite(value) && value > 0)) {
|
|
22
|
+
throw new Error("release progress limits must be positive finite milliseconds");
|
|
23
|
+
}
|
|
24
|
+
const inactivityMs = ownerTimeoutMs === null
|
|
25
|
+
? configuredInactivityMs
|
|
26
|
+
: Math.max(configuredInactivityMs, ownerTimeoutMs + RELEASE_LIVENESS_OWNER_MARGIN_MS);
|
|
27
|
+
if (absoluteMs <= inactivityMs) throw new Error("RIGHT_RELEASE_ABSOLUTE_MS must exceed effective release inactivity limit");
|
|
28
|
+
return Object.freeze({ inactivityMs, absoluteMs });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function runRightkitControl(args, { cwd, env, timeoutMs = 30_000 } = {}) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
const control = spawn("rightkit", args, { cwd, env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: process.platform === "win32" });
|
|
34
|
+
let output = "";
|
|
35
|
+
let timedOut = false;
|
|
36
|
+
const append = (chunk) => { output = `${output}${chunk}`.slice(-64 * 1024); };
|
|
37
|
+
control.stdout.on("data", append);
|
|
38
|
+
control.stderr.on("data", append);
|
|
39
|
+
const timer = setTimeout(() => {
|
|
40
|
+
timedOut = true;
|
|
41
|
+
control.kill("SIGTERM");
|
|
42
|
+
setTimeout(() => control.kill("SIGKILL"), 1_000).unref();
|
|
43
|
+
}, timeoutMs);
|
|
44
|
+
timer.unref();
|
|
45
|
+
control.once("error", (error) => {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
resolve(Object.freeze({ code: 125, output, timedOut, error: error.message }));
|
|
48
|
+
});
|
|
49
|
+
control.once("exit", (code, signal) => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
resolve(Object.freeze({ code: code ?? 125, signal, output, timedOut }));
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function terminalResultFromOutput(output) {
|
|
57
|
+
for (const line of String(output ?? "").split(/\r?\n/).reverse()) {
|
|
58
|
+
try {
|
|
59
|
+
const candidate = JSON.parse(line);
|
|
60
|
+
if (candidate?.type === "RESULT" && typeof candidate.status === "string") return candidate;
|
|
61
|
+
} catch { /* streamed build output is not JSON */ }
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function cancelAndReapManagedRequest(id, { cwd = process.cwd(), env = process.env, run = runRightkitControl } = {}) {
|
|
67
|
+
if (!id) return Object.freeze({ status: "NO_MANAGED_REQUEST" });
|
|
68
|
+
const cancel = await run(["cancel", id], { cwd, env });
|
|
69
|
+
const attach = await run(["attach", id], { cwd, env });
|
|
70
|
+
const terminal = terminalResultFromOutput(attach.output);
|
|
71
|
+
if (!terminal) {
|
|
72
|
+
throw new Error(`broker cancellation for ${id} lacked terminal RESULT (cancel=${cancel.code}, attach=${attach.code}${attach.timedOut ? ", attach-timeout" : ""})`);
|
|
73
|
+
}
|
|
74
|
+
return Object.freeze({ status: terminal.status, id, exitCode: terminal.exitCode, receipt: terminal.receipt ?? null, cancelExitCode: cancel.code, attachExitCode: attach.code });
|
|
75
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { cancelAndReapManagedRequest, firstManagedRequestId, latestManagedRequestId, releaseProgressLimits, terminalResultFromOutput } from "./progress-control.mjs";
|
|
5
|
+
|
|
6
|
+
test("release progress stays bounded without killing a healthy long native build at 90 minutes", () => {
|
|
7
|
+
assert.deepEqual(releaseProgressLimits({}), { inactivityMs: 600_000, absoluteMs: 14_400_000 });
|
|
8
|
+
assert.deepEqual(releaseProgressLimits({ RIGHT_RELEASE_STALL_MS: "1000", RIGHT_RELEASE_ABSOLUTE_MS: "5000" }), { inactivityMs: 1_000, absoluteMs: 5_000 });
|
|
9
|
+
assert.throws(() => releaseProgressLimits({ RIGHT_RELEASE_STALL_MS: "1000", RIGHT_RELEASE_ABSOLUTE_MS: "1000" }), /must exceed/);
|
|
10
|
+
assert.throws(() => releaseProgressLimits({ RIGHT_RELEASE_STALL_MS: "nope" }), /positive finite/);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("declared inner liveness owner always exits before the outer watchdog", () => {
|
|
14
|
+
const ownerTimeoutMs = 45 * 60 * 1000;
|
|
15
|
+
assert.deepEqual(
|
|
16
|
+
releaseProgressLimits({}, { livenessOwnerTimeoutMs: ownerTimeoutMs }),
|
|
17
|
+
{ inactivityMs: 50 * 60 * 1000, absoluteMs: 4 * 60 * 60 * 1000 },
|
|
18
|
+
);
|
|
19
|
+
assert.deepEqual(
|
|
20
|
+
releaseProgressLimits({ RIGHT_RELEASE_STALL_MS: String(60 * 60 * 1000) }, { livenessOwnerTimeoutMs: ownerTimeoutMs }),
|
|
21
|
+
{ inactivityMs: 60 * 60 * 1000, absoluteMs: 4 * 60 * 60 * 1000 },
|
|
22
|
+
);
|
|
23
|
+
assert.throws(
|
|
24
|
+
() => releaseProgressLimits({ RIGHT_RELEASE_ABSOLUTE_MS: String(49 * 60 * 1000) }, { livenessOwnerTimeoutMs: ownerTimeoutMs }),
|
|
25
|
+
/must exceed effective release inactivity limit/,
|
|
26
|
+
);
|
|
27
|
+
assert.throws(() => releaseProgressLimits({}, { livenessOwnerTimeoutMs: 0 }), /positive finite/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("latest managed request id follows streamed root Cargo requests", () => {
|
|
31
|
+
const first = "11111111-1111-4111-8111-111111111111";
|
|
32
|
+
const second = "22222222-2222-4222-8222-222222222222";
|
|
33
|
+
assert.equal(latestManagedRequestId(`rightkit: request ${first}\nrightkit managed-agent: request ${second}`), second);
|
|
34
|
+
assert.equal(firstManagedRequestId(`rightkit: request ${first}\nrightkit managed-agent: request ${second}`), first);
|
|
35
|
+
assert.equal(latestManagedRequestId("ordinary output", first), first);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("managed cancellation issues CANCEL, waits for terminal RESULT, & returns reap receipt", async () => {
|
|
39
|
+
const id = "33333333-3333-4333-8333-333333333333";
|
|
40
|
+
const calls = [];
|
|
41
|
+
const run = async (args) => {
|
|
42
|
+
calls.push(args);
|
|
43
|
+
if (args[0] === "cancel") return { code: 0, output: JSON.stringify({ type: "CANCEL_REQUESTED", id }) };
|
|
44
|
+
return { code: 130, output: `compiler tail\n${JSON.stringify({ type: "RESULT", id, status: "CANCELLED", exitCode: 130, receipt: { reap: { reaped: true } } })}\n` };
|
|
45
|
+
};
|
|
46
|
+
assert.deepEqual(await cancelAndReapManagedRequest(id, { run }), {
|
|
47
|
+
status: "CANCELLED", id, exitCode: 130, receipt: { reap: { reaped: true } }, cancelExitCode: 0, attachExitCode: 130,
|
|
48
|
+
});
|
|
49
|
+
assert.deepEqual(calls, [["cancel", id], ["attach", id]]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("missing terminal broker result fails closed", async () => {
|
|
53
|
+
const run = async () => ({ code: 125, output: "broker closed", timedOut: false });
|
|
54
|
+
await assert.rejects(cancelAndReapManagedRequest("44444444-4444-4444-8444-444444444444", { run }), /lacked terminal RESULT/);
|
|
55
|
+
assert.equal(terminalResultFromOutput("noise\n"), null);
|
|
56
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { basename, join } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const packageRoot = fileURLToPath(new URL('.', import.meta.url));
|
|
10
|
+
|
|
11
|
+
async function files(root, relative = '') {
|
|
12
|
+
const entries = await readdir(join(root, relative), { withFileTypes: true });
|
|
13
|
+
const result = [];
|
|
14
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
15
|
+
const path = relative ? `${relative}/${entry.name}` : entry.name;
|
|
16
|
+
if (entry.isDirectory()) result.push(...await files(root, path));
|
|
17
|
+
else if (entry.isFile()) result.push(path);
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function packedTree(root) {
|
|
23
|
+
const paths = await files(root);
|
|
24
|
+
const values = [];
|
|
25
|
+
for (const path of paths) {
|
|
26
|
+
const bytes = await readFile(join(root, path));
|
|
27
|
+
values.push([path, createHash('sha256').update(bytes).digest('hex')]);
|
|
28
|
+
}
|
|
29
|
+
return values;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function comparePackedTrees(local, published) {
|
|
33
|
+
const left = JSON.stringify(local); const right = JSON.stringify(published);
|
|
34
|
+
if (left === right) return Object.freeze({ equal: true, differences: [] });
|
|
35
|
+
const localMap = new Map(local); const publishedMap = new Map(published);
|
|
36
|
+
const differences = [...new Set([...localMap.keys(), ...publishedMap.keys()])]
|
|
37
|
+
.sort()
|
|
38
|
+
.filter((path) => localMap.get(path) !== publishedMap.get(path));
|
|
39
|
+
return Object.freeze({ equal: false, differences: Object.freeze(differences) });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function extract(archive, destination) {
|
|
43
|
+
await mkdir(destination, { recursive: true });
|
|
44
|
+
execFileSync('tar', ['-xzf', archive, '-C', destination], { stdio: 'pipe' });
|
|
45
|
+
return join(destination, 'package');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function verifyRegistryParity({ allowUnpublished = false, fetchImpl = fetch } = {}) {
|
|
49
|
+
const manifest = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'));
|
|
50
|
+
const encodedName = encodeURIComponent(manifest.name);
|
|
51
|
+
const metadata = await fetchImpl(`https://registry.npmjs.org/${encodedName}/${manifest.version}`);
|
|
52
|
+
if (metadata.status === 404 && allowUnpublished) return Object.freeze({ status: 'UNPUBLISHED', name: manifest.name, version: manifest.version });
|
|
53
|
+
if (!metadata.ok) throw new Error(`registry metadata failed: HTTP ${metadata.status}`);
|
|
54
|
+
const record = await metadata.json();
|
|
55
|
+
if (typeof record?.dist?.tarball !== 'string') throw new Error('registry metadata omitted dist.tarball');
|
|
56
|
+
const root = await mkdtemp(join(tmpdir(), 'rightkit-release-parity-'));
|
|
57
|
+
try {
|
|
58
|
+
const localDir = join(root, 'local'); const publishedArchive = join(root, 'published.tgz');
|
|
59
|
+
await mkdir(localDir, { recursive: true });
|
|
60
|
+
const packed = JSON.parse(execFileSync('pnpm', ['pack', '--pack-destination', localDir, '--json'], { cwd: packageRoot, encoding: 'utf8' }));
|
|
61
|
+
const localArchive = join(localDir, basename(packed[0]?.filename ?? ''));
|
|
62
|
+
const response = await fetchImpl(record.dist.tarball);
|
|
63
|
+
if (!response.ok) throw new Error(`registry tarball failed: HTTP ${response.status}`);
|
|
64
|
+
await writeFile(publishedArchive, Buffer.from(await response.arrayBuffer()));
|
|
65
|
+
const localTree = await packedTree(await extract(localArchive, join(root, 'local-tree')));
|
|
66
|
+
const publishedTree = await packedTree(await extract(publishedArchive, join(root, 'published-tree')));
|
|
67
|
+
const comparison = comparePackedTrees(localTree, publishedTree);
|
|
68
|
+
if (!comparison.equal) throw new Error(`published ${manifest.name}@${manifest.version} differs from source pack: ${comparison.differences.join(', ')}`);
|
|
69
|
+
return Object.freeze({ status: 'MATCH', name: manifest.name, version: manifest.version, files: localTree.length });
|
|
70
|
+
} finally {
|
|
71
|
+
await rm(root, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
76
|
+
verifyRegistryParity({ allowUnpublished: process.argv.includes('--allow-unpublished') })
|
|
77
|
+
.then((result) => process.stdout.write(`${JSON.stringify(result)}\n`))
|
|
78
|
+
.catch((error) => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; });
|
|
79
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { comparePackedTrees, verifyRegistryParity } from './registry-parity.mjs';
|
|
4
|
+
|
|
5
|
+
test('registry parity rejects same-version packed-byte drift', () => {
|
|
6
|
+
assert.deepEqual(comparePackedTrees([['build-release.mjs', 'new']], [['build-release.mjs', 'old']]), {
|
|
7
|
+
equal: false,
|
|
8
|
+
differences: ['build-release.mjs'],
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('registry parity accepts identical packed trees', () => {
|
|
13
|
+
assert.deepEqual(comparePackedTrees([['a.mjs', 'one'], ['package.json', 'two']], [['a.mjs', 'one'], ['package.json', 'two']]), {
|
|
14
|
+
equal: true,
|
|
15
|
+
differences: [],
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('registry parity distinguishes an unpublished version from registry failure', async () => {
|
|
20
|
+
const notFound = async () => ({ status: 404, ok: false });
|
|
21
|
+
assert.deepEqual(await verifyRegistryParity({ allowUnpublished: true, fetchImpl: notFound }), {
|
|
22
|
+
status: 'UNPUBLISHED',
|
|
23
|
+
name: '@rightkit/release',
|
|
24
|
+
version: '0.2.68',
|
|
25
|
+
});
|
|
26
|
+
await assert.rejects(
|
|
27
|
+
verifyRegistryParity({ fetchImpl: notFound }),
|
|
28
|
+
/registry metadata failed: HTTP 404/,
|
|
29
|
+
);
|
|
30
|
+
});
|
package/release.mjs
CHANGED
|
@@ -521,11 +521,12 @@ function run(cmd, runArgs, cwd, env = {}, options = {}) {
|
|
|
521
521
|
}
|
|
522
522
|
const started = Date.now();
|
|
523
523
|
return new Promise((resolve) => {
|
|
524
|
+
const useShell = process.platform === "win32" && !/\.(?:exe|com)$/i.test(cmd);
|
|
524
525
|
const child = spawn(cmd, runArgs, {
|
|
525
526
|
cwd,
|
|
526
527
|
env: { ...process.env, ...releaseEnv, ...env },
|
|
527
528
|
stdio: "inherit",
|
|
528
|
-
shell:
|
|
529
|
+
shell: useShell,
|
|
529
530
|
windowsHide: true,
|
|
530
531
|
});
|
|
531
532
|
const timer = options.timeoutMs
|
package/release.test.mjs
CHANGED
|
@@ -199,6 +199,13 @@ test("release worker reuses owned process-tree termination for Windows and remot
|
|
|
199
199
|
assert.match(source, /function killProcessTree\(pid\) \{\s*terminateProcessTree\(pid\);\s*\}/s);
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
+
test("Windows native release tools preserve artifact arguments containing spaces", () => {
|
|
203
|
+
const source = readFileSync(release, "utf8");
|
|
204
|
+
assert.match(source, /const useShell = process\.platform === "win32" && !\/\\\.\(\?:exe\|com\)\$\/i\.test\(cmd\);/);
|
|
205
|
+
assert.match(source, /shell: useShell/);
|
|
206
|
+
assert.doesNotMatch(source, /shell: process\.platform === "win32"/);
|
|
207
|
+
});
|
|
208
|
+
|
|
202
209
|
test("accepts patch and exposes it to the signed package command", () => {
|
|
203
210
|
const result = run(fixture(), "--tier=patch");
|
|
204
211
|
assert.equal(result.status, 0, result.stderr);
|
|
@@ -481,18 +481,25 @@ test("RightKit exposes one current version manifest", () => {
|
|
|
481
481
|
assert.equal(versions.npm["@rightkit/updates"], "0.2.3");
|
|
482
482
|
assert.deepEqual(versions.stagedNpm, {
|
|
483
483
|
"@rightkit/ax": "0.2.0",
|
|
484
|
-
"@rightkit/git": "0.2.
|
|
485
|
-
"@rightkit/hooks": "0.1.
|
|
486
|
-
"@rightkit/legal": "0.3.
|
|
484
|
+
"@rightkit/git": "0.2.1",
|
|
485
|
+
"@rightkit/hooks": "0.1.1",
|
|
486
|
+
"@rightkit/legal": "0.3.1",
|
|
487
487
|
"@rightkit/legal-ui": "0.1.1",
|
|
488
|
-
"@rightkit/license": "0.1.
|
|
489
|
-
"@rightkit/
|
|
490
|
-
"@rightkit/
|
|
488
|
+
"@rightkit/license": "0.1.7",
|
|
489
|
+
"@rightkit/logs": "0.1.4",
|
|
490
|
+
"@rightkit/platform-ui": "0.1.1",
|
|
491
|
+
"@rightkit/qa": "0.2.1",
|
|
492
|
+
"@rightkit/release": "0.2.68",
|
|
493
|
+
"@rightkit/tauri": "0.1.1",
|
|
494
|
+
"@rightkit/updates": "0.2.4",
|
|
491
495
|
});
|
|
492
496
|
assert.deepEqual(versions.legacyNpm, {
|
|
493
497
|
"@rightkit/legal-ui": ["0.1.0"],
|
|
494
|
-
"@rightkit/
|
|
495
|
-
"@rightkit/
|
|
498
|
+
"@rightkit/hooks": ["0.1.0"],
|
|
499
|
+
"@rightkit/legal": ["0.3.0"],
|
|
500
|
+
"@rightkit/license": ["0.1.6"],
|
|
501
|
+
"@rightkit/release": ["0.2.22", "0.2.29", "0.2.30", "0.2.31", "0.2.41", "0.2.42", "0.2.43", "0.2.44", "0.2.45", "0.2.46", "0.2.49", "0.2.50", "0.2.51", "0.2.53", "0.2.54", "0.2.55", "0.2.56", "0.2.61", "0.2.62", "0.2.63", "0.2.64", "0.2.65", "0.2.66", "0.2.67"],
|
|
502
|
+
"@rightkit/qa": ["0.1.0", "0.2.0"],
|
|
496
503
|
});
|
|
497
504
|
assert.ok(
|
|
498
505
|
new Set([versions.npm["@rightkit/release"], ...versions.legacyNpm["@rightkit/release"]]).has("0.2.42"),
|
|
@@ -853,32 +860,26 @@ test("consuming suite apps resolve Cargo build output from cargo metadata, never
|
|
|
853
860
|
const cargoTargetRootHelpers = [
|
|
854
861
|
"coderight/apps/coderight-tauri/scripts/lib/target-root.mjs",
|
|
855
862
|
"heardright/tauri-app-next/scripts/lib/target-root.mjs",
|
|
863
|
+
"genright/scripts/lib/target-root.mjs",
|
|
856
864
|
"mailright/scripts/lib/target-root.mjs",
|
|
857
865
|
"orthic/scripts/lib/target-root.mjs",
|
|
858
866
|
"viewright/scripts/lib/target-root.mjs",
|
|
859
867
|
"membrane/apps/membrane-hub/scripts/lib/target-root.mjs",
|
|
860
868
|
];
|
|
861
869
|
|
|
862
|
-
test("
|
|
870
|
+
test("consumer target-root helpers delegate to RightKit's canonical resolver", () => {
|
|
863
871
|
for (const helperPath of cargoTargetRootHelpers) {
|
|
864
872
|
const appRoot = path.join(workspace, helperPath.split("/scripts/lib/target-root.mjs")[0]);
|
|
865
|
-
if (!existsSync(appRoot)) continue;
|
|
873
|
+
if (!existsSync(path.join(appRoot, "package.json"))) continue;
|
|
866
874
|
const fullPath = path.join(workspace, helperPath);
|
|
867
875
|
assert.ok(existsSync(fullPath), `${helperPath} is missing; the app must keep its shared cargo-metadata target resolver`);
|
|
868
876
|
const source = readFileSync(fullPath, "utf8");
|
|
869
|
-
assert.match(source,
|
|
870
|
-
assert.match(source, /
|
|
871
|
-
assert.match(source, /target_directory/, `${helperPath} must read target_directory from cargo metadata's output`);
|
|
877
|
+
assert.match(source, /@rightkit\/release\/cargo-target\.mjs/, `${helperPath} must import RightKit's resolver`);
|
|
878
|
+
assert.match(source, /resolveTargetRoot/, `${helperPath} must delegate target resolution to RightKit`);
|
|
872
879
|
}
|
|
873
880
|
});
|
|
874
881
|
|
|
875
|
-
|
|
876
|
-
// become a thin re-export of the shared tools/rightkit resolveTargetRoot
|
|
877
|
-
// (cargo-target.mjs) rather than its own copy of the cargo-metadata call. Until
|
|
878
|
-
// that migration lands, cargoTargetRootHelpers above still point at full local
|
|
879
|
-
// implementations, so this stays skipped to avoid failing the still-unmigrated
|
|
880
|
-
// app repos. Enable it once every app's target-root.mjs is a re-export shim.
|
|
881
|
-
test("consuming app target-root helpers are thin re-export shims, not local resolver re-implementations", { skip: true }, () => {
|
|
882
|
+
test("consuming app target-root helpers do not reimplement Cargo metadata", () => {
|
|
882
883
|
const LOCAL_RESOLVER_PATTERN = /function\s+(?:cargoTargetRoot|resolveManagedCargoTarget)\s*\([^)]*\)\s*\{[^}]*cargo[^}]*metadata/s;
|
|
883
884
|
for (const helperPath of cargoTargetRootHelpers) {
|
|
884
885
|
const appRoot = path.join(workspace, helperPath.split("/scripts/lib/target-root.mjs")[0]);
|
|
@@ -894,6 +895,96 @@ test("consuming app target-root helpers are thin re-export shims, not local reso
|
|
|
894
895
|
}
|
|
895
896
|
});
|
|
896
897
|
|
|
898
|
+
const cargoAuthorityRoots = [
|
|
899
|
+
...cargoTargetDirGuardApps,
|
|
900
|
+
"citadel",
|
|
901
|
+
"heardright",
|
|
902
|
+
"legion",
|
|
903
|
+
"rightsites",
|
|
904
|
+
"rightsuite",
|
|
905
|
+
"sellright",
|
|
906
|
+
"tools/screenright",
|
|
907
|
+
"voiceright",
|
|
908
|
+
"workright",
|
|
909
|
+
];
|
|
910
|
+
const cargoAuthorityNames = [
|
|
911
|
+
"CARGO_HOME",
|
|
912
|
+
"CARGO_TARGET_DIR",
|
|
913
|
+
"CARGO_BUILD_TARGET_DIR",
|
|
914
|
+
"CARGO_BUILD_BUILD_DIR",
|
|
915
|
+
"CARGO_BUILD_JOBS",
|
|
916
|
+
"CARGO_ENCODED_RUSTFLAGS",
|
|
917
|
+
"RUSTFLAGS",
|
|
918
|
+
"RUSTC_WRAPPER",
|
|
919
|
+
"RUSTC_WORKSPACE_WRAPPER",
|
|
920
|
+
"SCCACHE_DIR",
|
|
921
|
+
"SCCACHE_BASEDIRS",
|
|
922
|
+
"SCCACHE_CACHE_SIZE",
|
|
923
|
+
];
|
|
924
|
+
const cargoAuthorityExtensions = new Set([".mjs", ".js", ".cjs", ".ts", ".sh", ".ps1", ".py", ".json"]);
|
|
925
|
+
|
|
926
|
+
function findFirstPartyBuildScripts(root) {
|
|
927
|
+
const found = [];
|
|
928
|
+
const visit = (dir) => {
|
|
929
|
+
if (!existsSync(dir)) return;
|
|
930
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
931
|
+
if (entry.isDirectory() && [
|
|
932
|
+
".agent", ".audit", ".cache", ".git", ".right-release", "bakeoff", "dist", "docs",
|
|
933
|
+
"node_modules", "reference", "target", "tests", "vendor",
|
|
934
|
+
].includes(entry.name)) continue;
|
|
935
|
+
const full = path.join(dir, entry.name);
|
|
936
|
+
if (entry.isDirectory()) {
|
|
937
|
+
visit(full);
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
if (!entry.isFile() || !cargoAuthorityExtensions.has(path.extname(entry.name))) continue;
|
|
941
|
+
const relative = path.relative(root, full).replaceAll(path.sep, "/");
|
|
942
|
+
if (/(?:^|\/)(?:test-|[^/]+\.test\.)/.test(relative)) continue;
|
|
943
|
+
if (
|
|
944
|
+
relative.includes("/scripts/")
|
|
945
|
+
|| relative.startsWith("scripts/")
|
|
946
|
+
|| /(?:^|\/)(?:package\.json|package\.(?:sh|ps1)|right-release\.config\.mjs)$/.test(relative)
|
|
947
|
+
|| (!relative.includes("/") && /\.(?:sh|ps1)$/.test(relative))
|
|
948
|
+
) found.push(full);
|
|
949
|
+
}
|
|
950
|
+
};
|
|
951
|
+
visit(root);
|
|
952
|
+
return found;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function executableScriptSource(source) {
|
|
956
|
+
return source
|
|
957
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
958
|
+
.split("\n")
|
|
959
|
+
.filter((line) => !/^\s*(?:\/\/|#)/.test(line))
|
|
960
|
+
.join("\n");
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
test("product scripts never override or destroy RightKit-owned Cargo cache state", () => {
|
|
964
|
+
const authorityName = cargoAuthorityNames.join("|");
|
|
965
|
+
const jsMutation = new RegExp(`(?:delete\\s+process\\.env\\.(?:${authorityName})|process\\.env\\.(?:${authorityName})\\s*=|\\b(?:${authorityName})\\s*:)`);
|
|
966
|
+
const shellMutation = new RegExp(`(?:^|\\n)\\s*(?:export\\s+|unset\\s+|env\\s+(?:-[^\\n ]+\\s+)*-u\\s+)?(?:${authorityName})(?:\\s*=|\\b)`);
|
|
967
|
+
const powershellMutation = new RegExp(`\\$env:(?:${authorityName})\\s*=`, "i");
|
|
968
|
+
const cargoClean = /\bcargo\s+clean\b|["']cargo["'][\s\S]{0,400}?["']clean["']/;
|
|
969
|
+
|
|
970
|
+
for (const appRoot of cargoAuthorityRoots) {
|
|
971
|
+
const root = path.join(workspace, appRoot);
|
|
972
|
+
if (!existsSync(root)) continue;
|
|
973
|
+
for (const filePath of findFirstPartyBuildScripts(root)) {
|
|
974
|
+
const source = executableScriptSource(readFileSync(filePath, "utf8"));
|
|
975
|
+
const relative = path.relative(workspace, filePath);
|
|
976
|
+
assert.doesNotMatch(source, jsMutation, `${relative} mutates RightKit-owned Cargo/cache environment`);
|
|
977
|
+
assert.doesNotMatch(source, shellMutation, `${relative} mutates RightKit-owned Cargo/cache environment`);
|
|
978
|
+
assert.doesNotMatch(source, powershellMutation, `${relative} mutates RightKit-owned Cargo/cache environment`);
|
|
979
|
+
assert.doesNotMatch(source, cargoClean, `${relative} destroys reusable Cargo output with cargo clean`);
|
|
980
|
+
assert.doesNotMatch(source, /--target-dir\b/, `${relative} bypasses RightKit target ownership`);
|
|
981
|
+
assert.doesNotMatch(source, /(?:^|[\\/])\.cargo[\\/]bin(?:[\\/]|\b)/i, `${relative} bypasses managed Cargo by injecting a toolchain directory`);
|
|
982
|
+
assert.doesNotMatch(source, /build-guard\.mjs/, `${relative} depends on obsolete product-local build/cache control`);
|
|
983
|
+
}
|
|
984
|
+
assert.equal(existsSync(path.join(root, "scripts", "build-guard.mjs")), false, `${appRoot} must not carry a product-local build guard`);
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
|
|
897
988
|
test("Right Suite has no hosted workflow files", () => {
|
|
898
989
|
for (const root of ["viewright", "scraperight", "heardright", "mailright", "coderight", "genright", "voiceright", "tools/rightkit"]) {
|
|
899
990
|
const workflowDir = path.join(workspace, root, ".github", "workflows");
|
package/rightkit-versions.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 2,
|
|
3
|
-
"packageManager": "pnpm@11.
|
|
3
|
+
"packageManager": "pnpm@11.24.0",
|
|
4
4
|
"npm": {
|
|
5
5
|
"@rightkit/git": "0.2.0",
|
|
6
6
|
"@rightkit/legal": "0.2.0",
|
|
@@ -14,18 +14,31 @@
|
|
|
14
14
|
},
|
|
15
15
|
"stagedNpm": {
|
|
16
16
|
"@rightkit/ax": "0.2.0",
|
|
17
|
-
"@rightkit/git": "0.2.
|
|
18
|
-
"@rightkit/hooks": "0.1.
|
|
19
|
-
"@rightkit/legal": "0.3.
|
|
17
|
+
"@rightkit/git": "0.2.1",
|
|
18
|
+
"@rightkit/hooks": "0.1.1",
|
|
19
|
+
"@rightkit/legal": "0.3.1",
|
|
20
20
|
"@rightkit/legal-ui": "0.1.1",
|
|
21
|
-
"@rightkit/license": "0.1.
|
|
22
|
-
"@rightkit/
|
|
23
|
-
"@rightkit/
|
|
21
|
+
"@rightkit/license": "0.1.7",
|
|
22
|
+
"@rightkit/logs": "0.1.4",
|
|
23
|
+
"@rightkit/platform-ui": "0.1.1",
|
|
24
|
+
"@rightkit/qa": "0.2.1",
|
|
25
|
+
"@rightkit/release": "0.2.68",
|
|
26
|
+
"@rightkit/tauri": "0.1.1",
|
|
27
|
+
"@rightkit/updates": "0.2.4"
|
|
24
28
|
},
|
|
25
29
|
"legacyNpm": {
|
|
26
30
|
"@rightkit/legal-ui": [
|
|
27
31
|
"0.1.0"
|
|
28
32
|
],
|
|
33
|
+
"@rightkit/hooks": [
|
|
34
|
+
"0.1.0"
|
|
35
|
+
],
|
|
36
|
+
"@rightkit/legal": [
|
|
37
|
+
"0.3.0"
|
|
38
|
+
],
|
|
39
|
+
"@rightkit/license": [
|
|
40
|
+
"0.1.6"
|
|
41
|
+
],
|
|
29
42
|
"@rightkit/release": [
|
|
30
43
|
"0.2.22",
|
|
31
44
|
"0.2.29",
|
|
@@ -48,10 +61,13 @@
|
|
|
48
61
|
"0.2.62",
|
|
49
62
|
"0.2.63",
|
|
50
63
|
"0.2.64",
|
|
51
|
-
"0.2.65"
|
|
64
|
+
"0.2.65",
|
|
65
|
+
"0.2.66",
|
|
66
|
+
"0.2.67"
|
|
52
67
|
],
|
|
53
68
|
"@rightkit/qa": [
|
|
54
|
-
"0.1.0"
|
|
69
|
+
"0.1.0",
|
|
70
|
+
"0.2.0"
|
|
55
71
|
]
|
|
56
72
|
},
|
|
57
73
|
"cargo": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"remote": "https://github.com/bogusyogi/viewright.git",
|
|
9
9
|
"appDir": ".",
|
|
10
10
|
"revision": "21a4171fa8add2d8114bc5f07498b60d7e8eafe5",
|
|
11
|
-
|
|
11
|
+
"packageManager": "pnpm@11.24.0",
|
|
12
12
|
"clone": {
|
|
13
13
|
"command": "git clone --depth 1 --single-branch https://github.com/bogusyogi/viewright.git C:\\Users\\adrds\\AppData\\Local\\Temp\\rightkit-standalone-sBI0iR\\viewright",
|
|
14
14
|
"status": 0,
|
|
@@ -22,7 +22,7 @@ test("standalone verifier clones, installs, doctors from a nested app root, and
|
|
|
22
22
|
writeFileSync(path.join(source, "apps", "desktop", "package.json"), JSON.stringify({
|
|
23
23
|
name: "standalone-fixture",
|
|
24
24
|
private: true,
|
|
25
|
-
packageManager: "pnpm@11.
|
|
25
|
+
packageManager: "pnpm@11.24.0",
|
|
26
26
|
scripts: { "release:doctor": "node doctor.mjs" },
|
|
27
27
|
}), "utf8");
|
|
28
28
|
writeFileSync(path.join(source, "apps", "desktop", "pnpm-lock.yaml"), "lockfileVersion: '9.0'\nsettings:\n autoInstallPeers: true\n excludeLinksFromLockfile: false\nimporters:\n .: {}\n", "utf8");
|