@rightkit/release 0.2.66 → 0.2.67
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 +60 -20
- package/build-release.test.mjs +18 -0
- package/cargo-guard.mjs +31 -1
- package/cargo-guard.test.mjs +31 -0
- package/github-release.test.mjs +3 -3
- package/package.json +2 -1
- package/progress-control.mjs +68 -0
- package/progress-control.test.mjs +39 -0
- package/registry-parity.mjs +79 -0
- package/registry-parity.test.mjs +30 -0
- package/right-suite-contract.test.mjs +3 -3
- package/rightkit-versions.json +3 -2
- 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
|
|
|
@@ -325,7 +328,7 @@ try {
|
|
|
325
328
|
checkpoint(stateRoot, "hardened");
|
|
326
329
|
},
|
|
327
330
|
seal: async ({ sealedDir }) => {
|
|
328
|
-
sealRelease({ configRoot: appRoot, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity });
|
|
331
|
+
sealRelease({ configRoot: appRoot, managedCargoTarget, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity });
|
|
329
332
|
verifySealedRelease(sealedDir);
|
|
330
333
|
if (cacheMode === "shared") markCacheEntrySuccessful({ layout: sharedLayout });
|
|
331
334
|
checkpoint(stateRoot, "sealed");
|
|
@@ -365,17 +368,32 @@ function sqlCipherFeatures(text) {
|
|
|
365
368
|
.filter((value) => /sqlcipher|openssl/i.test(value));
|
|
366
369
|
}
|
|
367
370
|
|
|
368
|
-
function sealRelease({ configRoot, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity }) {
|
|
371
|
+
function sealRelease({ configRoot, managedCargoTarget, sealedDir, releaseId, config, target, platform, commit, inputHashes, toolVersions, cacheKey, signingIdentity }) {
|
|
372
|
+
// Every app's right-release.config.mjs names installer/updater artifacts
|
|
373
|
+
// relative to the local `src-tauri/target`. On a broker-managed host that
|
|
374
|
+
// directory is never populated (the broker owns CARGO_TARGET_DIR and
|
|
375
|
+
// targetBridge.ensure() deliberately leaves a real local directory alone,
|
|
376
|
+
// see target-bridge.mjs) so those paths must resolve against the broker's
|
|
377
|
+
// own target root instead, or every managed build fails at seal after a
|
|
378
|
+
// full compile, sign, and notarize.
|
|
379
|
+
const targetPrefix = "src-tauri/target/";
|
|
380
|
+
const resolveArtifactPath = (file) => {
|
|
381
|
+
const normalized = file.replaceAll(path.win32.sep, "/");
|
|
382
|
+
if (managedCargoTarget && normalized.startsWith(targetPrefix)) {
|
|
383
|
+
return path.join(managedCargoTarget, normalized.slice(targetPrefix.length));
|
|
384
|
+
}
|
|
385
|
+
return path.resolve(configRoot, file);
|
|
386
|
+
};
|
|
369
387
|
const sources = new Map();
|
|
370
388
|
const addSource = (source, role) => {
|
|
371
389
|
const current = sources.get(source) ?? new Set();
|
|
372
390
|
current.add(role);
|
|
373
391
|
sources.set(source, current);
|
|
374
392
|
};
|
|
375
|
-
for (const artifact of target.installer?.artifacts ?? []) addSource(
|
|
393
|
+
for (const artifact of target.installer?.artifacts ?? []) addSource(resolveArtifactPath(artifact.file), "installer");
|
|
376
394
|
for (const artifact of target.updater?.artifacts ?? []) {
|
|
377
|
-
addSource(
|
|
378
|
-
addSource(
|
|
395
|
+
addSource(resolveArtifactPath(artifact.file), "updater");
|
|
396
|
+
addSource(resolveArtifactPath(artifact.signature), "updater-signature");
|
|
379
397
|
}
|
|
380
398
|
for (const file of sources.keys()) if (!existsSync(file)) fail(`cannot seal missing artifact: ${file}`);
|
|
381
399
|
const temp = `${sealedDir}.tmp-${process.pid}`;
|
|
@@ -392,14 +410,15 @@ function sealRelease({ configRoot, sealedDir, releaseId, config, target, platfor
|
|
|
392
410
|
}
|
|
393
411
|
const routes = { patch: [], update: [] };
|
|
394
412
|
for (const artifact of target.installer?.artifacts ?? []) {
|
|
395
|
-
const source =
|
|
413
|
+
const source = resolveArtifactPath(artifact.file);
|
|
396
414
|
routes.patch.push({ role: "installer", name: sourceNames.get(source), bucket: "public", key: artifact.key });
|
|
397
415
|
}
|
|
398
416
|
for (const artifact of target.updater?.artifacts ?? []) {
|
|
399
|
-
const source =
|
|
417
|
+
const source = resolveArtifactPath(artifact.file);
|
|
418
|
+
const signatureSource = resolveArtifactPath(artifact.signature);
|
|
400
419
|
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(
|
|
420
|
+
routes.patch.push({ role: "updater", name: sourceNames.get(source), signature: sourceNames.get(signatureSource), bucket: "public", key: patchKey, platform: artifact.platform });
|
|
421
|
+
routes.update.push({ role: "updater", name: sourceNames.get(source), signature: sourceNames.get(signatureSource), bucket: "private", key: artifact.key, platform: artifact.platform });
|
|
403
422
|
}
|
|
404
423
|
writeJson(path.join(temp, "hardening.json"), { schema: 1, passed: true, checkedAt: new Date().toISOString(), files: files.map((file) => file.sha256) });
|
|
405
424
|
const manifest = {
|
|
@@ -446,13 +465,17 @@ function collectToolVersions(packageManager) {
|
|
|
446
465
|
}
|
|
447
466
|
|
|
448
467
|
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
|
+
const { inactivityMs, absoluteMs } = releaseProgressLimits(process.env);
|
|
451
469
|
let lastProgress = Date.now();
|
|
452
470
|
const watchDirs = Array.isArray(watchDir) ? watchDir : [watchDir];
|
|
453
471
|
let lastMtime = Math.max(...watchDirs.map(newestMtime));
|
|
454
472
|
const started = Date.now();
|
|
455
473
|
await new Promise((resolve, reject) => {
|
|
474
|
+
let settled = false;
|
|
475
|
+
let stopping = false;
|
|
476
|
+
let managedRequestId = null;
|
|
477
|
+
const settleResolve = () => { if (!settled) { settled = true; resolve(); } };
|
|
478
|
+
const settleReject = (error) => { if (!settled) { settled = true; reject(error); } };
|
|
456
479
|
child = spawn(cmd, runArgs, { cwd, env, stdio: ["inherit", "pipe", "pipe"], windowsHide: true, shell: process.platform === "win32", detached: process.platform !== "win32" });
|
|
457
480
|
try {
|
|
458
481
|
if (child.pid && heavySlot) heavySlot.trackChild(child.pid, cmd);
|
|
@@ -471,6 +494,7 @@ async function runProgress(cmd, runArgs, cwd, env, watchDir, diagnosticPath = nu
|
|
|
471
494
|
stream.on("data", (chunk) => {
|
|
472
495
|
lastProgress = Date.now();
|
|
473
496
|
tail = `${tail}${chunk}`.slice(-64 * 1024);
|
|
497
|
+
if (!managedRequestId) managedRequestId = firstManagedRequestId(tail);
|
|
474
498
|
output.write(chunk);
|
|
475
499
|
});
|
|
476
500
|
}
|
|
@@ -481,12 +505,24 @@ async function runProgress(cmd, runArgs, cwd, env, watchDir, diagnosticPath = nu
|
|
|
481
505
|
const cpu = processGroupCpuSeconds(child.pid);
|
|
482
506
|
if (cpu !== null && lastCpu !== null && cpu > lastCpu) lastProgress = Date.now();
|
|
483
507
|
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);
|
|
508
|
+
if (Date.now() - started > absoluteMs) void stop(`absolute limit exceeded (${Math.round(absoluteMs / 60000)}m)`, cpu);
|
|
509
|
+
else if (Date.now() - lastProgress > inactivityMs) void stop(`no output, file, or CPU progress for ${Math.round(inactivityMs / 60000)}m`, cpu);
|
|
486
510
|
}, Math.min(30_000, Math.max(250, Math.floor(inactivityMs / 4))));
|
|
487
|
-
const cleanup = () => {
|
|
488
|
-
|
|
511
|
+
const cleanup = () => {
|
|
512
|
+
clearInterval(monitor);
|
|
513
|
+
closeWatchers();
|
|
514
|
+
if (requestActiveRunStop === stop) requestActiveRunStop = null;
|
|
515
|
+
};
|
|
516
|
+
const stop = async (reason, cpu = null) => {
|
|
517
|
+
if (stopping || settled) return;
|
|
518
|
+
stopping = true;
|
|
489
519
|
cleanup();
|
|
520
|
+
let cancellation = { status: "NO_MANAGED_REQUEST" };
|
|
521
|
+
try {
|
|
522
|
+
cancellation = await cancelAndReapManagedRequest(managedRequestId, { cwd, env });
|
|
523
|
+
} catch (error) {
|
|
524
|
+
cancellation = { status: "CANCEL_REAP_FAILED", error: error.message };
|
|
525
|
+
}
|
|
490
526
|
if (diagnosticPath) {
|
|
491
527
|
try {
|
|
492
528
|
mkdirSync(path.dirname(diagnosticPath), { recursive: true });
|
|
@@ -497,6 +533,8 @@ async function runProgress(cmd, runArgs, cwd, env, watchDir, diagnosticPath = nu
|
|
|
497
533
|
`watched: ${watchDirs.join(path.delimiter)}`,
|
|
498
534
|
`elapsedMs: ${Date.now() - started}`,
|
|
499
535
|
`cpuSeconds: ${cpu ?? "unavailable"}`,
|
|
536
|
+
`managedRequestId: ${managedRequestId ?? "none"}`,
|
|
537
|
+
`managedCancellation: ${JSON.stringify(cancellation)}`,
|
|
500
538
|
`killedAt: ${new Date().toISOString()}`,
|
|
501
539
|
"",
|
|
502
540
|
"--- last output ---",
|
|
@@ -506,11 +544,13 @@ async function runProgress(cmd, runArgs, cwd, env, watchDir, diagnosticPath = nu
|
|
|
506
544
|
console.error(`right-release: stall diagnostic written to ${diagnosticPath}`);
|
|
507
545
|
} catch { /* diagnostics are best effort; never mask the stall itself */ }
|
|
508
546
|
}
|
|
509
|
-
killTree(child.pid);
|
|
510
|
-
|
|
547
|
+
if (child?.pid) killTree(child.pid);
|
|
548
|
+
child = null;
|
|
549
|
+
settleReject(new Error(`release step stalled: ${reason}`));
|
|
511
550
|
};
|
|
512
|
-
|
|
513
|
-
child.once("
|
|
551
|
+
requestActiveRunStop = stop;
|
|
552
|
+
child.once("error", (error) => { cleanup(); child = null; if (!stopping) settleReject(error); });
|
|
553
|
+
child.once("exit", (code) => { cleanup(); child = null; if (!stopping) code === 0 ? settleResolve() : settleReject(new Error(`${cmd} exited ${code}`)); });
|
|
514
554
|
});
|
|
515
555
|
}
|
|
516
556
|
|
package/build-release.test.mjs
CHANGED
|
@@ -54,6 +54,24 @@ test("production builds package from the real primary app checkout", () => {
|
|
|
54
54
|
assert.match(source, /sealRelease\(\{ configRoot: appRoot,/);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
test("sealing resolves installer and updater artifacts against the broker target on a managed host", () => {
|
|
58
|
+
assert.match(source, /sealRelease\(\{ configRoot: appRoot, managedCargoTarget,/);
|
|
59
|
+
assert.match(source, /function sealRelease\(\{ configRoot, managedCargoTarget,/);
|
|
60
|
+
assert.match(source, /const targetPrefix = "src-tauri\/target\/";/);
|
|
61
|
+
assert.match(source, /if \(managedCargoTarget && normalized\.startsWith\(targetPrefix\)\) \{/);
|
|
62
|
+
// Every configRoot-relative artifact lookup inside sealRelease must route
|
|
63
|
+
// through the resolver, or a managed build fails at seal after a full
|
|
64
|
+
// compile, sign, and notarize because src-tauri/target is never populated
|
|
65
|
+
// locally on a broker-managed host (target-bridge.mjs leaves it alone).
|
|
66
|
+
assert.doesNotMatch(source, /addSource\(path\.resolve\(configRoot, artifact\.file\)/);
|
|
67
|
+
assert.doesNotMatch(source, /addSource\(path\.resolve\(configRoot, artifact\.signature\)/);
|
|
68
|
+
assert.match(source, /addSource\(resolveArtifactPath\(artifact\.file\), "installer"\)/);
|
|
69
|
+
assert.match(source, /addSource\(resolveArtifactPath\(artifact\.file\), "updater"\)/);
|
|
70
|
+
assert.match(source, /addSource\(resolveArtifactPath\(artifact\.signature\), "updater-signature"\)/);
|
|
71
|
+
assert.match(source, /const source = resolveArtifactPath\(artifact\.file\);/);
|
|
72
|
+
assert.match(source, /const signatureSource = resolveArtifactPath\(artifact\.signature\);/);
|
|
73
|
+
});
|
|
74
|
+
|
|
57
75
|
test("production builds never create or use an internal Git worktree", () => {
|
|
58
76
|
assert.doesNotMatch(source, /git", \["worktree", "add"/);
|
|
59
77
|
assert.doesNotMatch(source, /path\.join\(vaultRoot, "worktrees"/);
|
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/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.67",
|
|
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",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"test": "node --test *.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,68 @@
|
|
|
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
|
+
|
|
5
|
+
export function latestManagedRequestId(value, fallback = null) {
|
|
6
|
+
let latest = fallback;
|
|
7
|
+
for (const match of String(value ?? "").matchAll(MANAGED_REQUEST_PATTERN)) latest = match[1].toLowerCase();
|
|
8
|
+
return latest;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function firstManagedRequestId(value) {
|
|
12
|
+
MANAGED_REQUEST_PATTERN.lastIndex = 0;
|
|
13
|
+
return MANAGED_REQUEST_PATTERN.exec(String(value ?? ""))?.[1]?.toLowerCase() ?? null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function releaseProgressLimits(env = process.env) {
|
|
17
|
+
const inactivityMs = Number(env.RIGHT_RELEASE_STALL_MS ?? 10 * 60 * 1000);
|
|
18
|
+
const absoluteMs = Number(env.RIGHT_RELEASE_ABSOLUTE_MS ?? 4 * 60 * 60 * 1000);
|
|
19
|
+
if (![inactivityMs, absoluteMs].every((value) => Number.isFinite(value) && value > 0)) throw new Error("release progress limits must be positive finite milliseconds");
|
|
20
|
+
if (absoluteMs <= inactivityMs) throw new Error("RIGHT_RELEASE_ABSOLUTE_MS must exceed RIGHT_RELEASE_STALL_MS");
|
|
21
|
+
return Object.freeze({ inactivityMs, absoluteMs });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function runRightkitControl(args, { cwd, env, timeoutMs = 30_000 } = {}) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const control = spawn("rightkit", args, { cwd, env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: process.platform === "win32" });
|
|
27
|
+
let output = "";
|
|
28
|
+
let timedOut = false;
|
|
29
|
+
const append = (chunk) => { output = `${output}${chunk}`.slice(-64 * 1024); };
|
|
30
|
+
control.stdout.on("data", append);
|
|
31
|
+
control.stderr.on("data", append);
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
timedOut = true;
|
|
34
|
+
control.kill("SIGTERM");
|
|
35
|
+
setTimeout(() => control.kill("SIGKILL"), 1_000).unref();
|
|
36
|
+
}, timeoutMs);
|
|
37
|
+
timer.unref();
|
|
38
|
+
control.once("error", (error) => {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
resolve(Object.freeze({ code: 125, output, timedOut, error: error.message }));
|
|
41
|
+
});
|
|
42
|
+
control.once("exit", (code, signal) => {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
resolve(Object.freeze({ code: code ?? 125, signal, output, timedOut }));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function terminalResultFromOutput(output) {
|
|
50
|
+
for (const line of String(output ?? "").split(/\r?\n/).reverse()) {
|
|
51
|
+
try {
|
|
52
|
+
const candidate = JSON.parse(line);
|
|
53
|
+
if (candidate?.type === "RESULT" && typeof candidate.status === "string") return candidate;
|
|
54
|
+
} catch { /* streamed build output is not JSON */ }
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function cancelAndReapManagedRequest(id, { cwd = process.cwd(), env = process.env, run = runRightkitControl } = {}) {
|
|
60
|
+
if (!id) return Object.freeze({ status: "NO_MANAGED_REQUEST" });
|
|
61
|
+
const cancel = await run(["cancel", id], { cwd, env });
|
|
62
|
+
const attach = await run(["attach", id], { cwd, env });
|
|
63
|
+
const terminal = terminalResultFromOutput(attach.output);
|
|
64
|
+
if (!terminal) {
|
|
65
|
+
throw new Error(`broker cancellation for ${id} lacked terminal RESULT (cancel=${cancel.code}, attach=${attach.code}${attach.timedOut ? ", attach-timeout" : ""})`);
|
|
66
|
+
}
|
|
67
|
+
return Object.freeze({ status: terminal.status, id, exitCode: terminal.exitCode, receipt: terminal.receipt ?? null, cancelExitCode: cancel.code, attachExitCode: attach.code });
|
|
68
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
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("latest managed request id follows streamed root Cargo requests", () => {
|
|
14
|
+
const first = "11111111-1111-4111-8111-111111111111";
|
|
15
|
+
const second = "22222222-2222-4222-8222-222222222222";
|
|
16
|
+
assert.equal(latestManagedRequestId(`rightkit: request ${first}\nrightkit managed-agent: request ${second}`), second);
|
|
17
|
+
assert.equal(firstManagedRequestId(`rightkit: request ${first}\nrightkit managed-agent: request ${second}`), first);
|
|
18
|
+
assert.equal(latestManagedRequestId("ordinary output", first), first);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("managed cancellation issues CANCEL, waits for terminal RESULT, & returns reap receipt", async () => {
|
|
22
|
+
const id = "33333333-3333-4333-8333-333333333333";
|
|
23
|
+
const calls = [];
|
|
24
|
+
const run = async (args) => {
|
|
25
|
+
calls.push(args);
|
|
26
|
+
if (args[0] === "cancel") return { code: 0, output: JSON.stringify({ type: "CANCEL_REQUESTED", id }) };
|
|
27
|
+
return { code: 130, output: `compiler tail\n${JSON.stringify({ type: "RESULT", id, status: "CANCELLED", exitCode: 130, receipt: { reap: { reaped: true } } })}\n` };
|
|
28
|
+
};
|
|
29
|
+
assert.deepEqual(await cancelAndReapManagedRequest(id, { run }), {
|
|
30
|
+
status: "CANCELLED", id, exitCode: 130, receipt: { reap: { reaped: true } }, cancelExitCode: 0, attachExitCode: 130,
|
|
31
|
+
});
|
|
32
|
+
assert.deepEqual(calls, [["cancel", id], ["attach", id]]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("missing terminal broker result fails closed", async () => {
|
|
36
|
+
const run = async () => ({ code: 125, output: "broker closed", timedOut: false });
|
|
37
|
+
await assert.rejects(cancelAndReapManagedRequest("44444444-4444-4444-8444-444444444444", { run }), /lacked terminal RESULT/);
|
|
38
|
+
assert.equal(terminalResultFromOutput("noise\n"), null);
|
|
39
|
+
});
|
|
@@ -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.67',
|
|
25
|
+
});
|
|
26
|
+
await assert.rejects(
|
|
27
|
+
verifyRegistryParity({ fetchImpl: notFound }),
|
|
28
|
+
/registry metadata failed: HTTP 404/,
|
|
29
|
+
);
|
|
30
|
+
});
|
|
@@ -486,12 +486,12 @@ test("RightKit exposes one current version manifest", () => {
|
|
|
486
486
|
"@rightkit/legal": "0.3.0",
|
|
487
487
|
"@rightkit/legal-ui": "0.1.1",
|
|
488
488
|
"@rightkit/license": "0.1.6",
|
|
489
|
-
"@rightkit/release": "0.2.
|
|
489
|
+
"@rightkit/release": "0.2.67",
|
|
490
490
|
"@rightkit/qa": "0.2.0",
|
|
491
491
|
});
|
|
492
492
|
assert.deepEqual(versions.legacyNpm, {
|
|
493
493
|
"@rightkit/legal-ui": ["0.1.0"],
|
|
494
|
-
"@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"],
|
|
494
|
+
"@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"],
|
|
495
495
|
"@rightkit/qa": ["0.1.0"],
|
|
496
496
|
});
|
|
497
497
|
assert.ok(
|
|
@@ -862,7 +862,7 @@ const cargoTargetRootHelpers = [
|
|
|
862
862
|
test("shared target-root helpers still resolve build output via cargo metadata's target_directory", () => {
|
|
863
863
|
for (const helperPath of cargoTargetRootHelpers) {
|
|
864
864
|
const appRoot = path.join(workspace, helperPath.split("/scripts/lib/target-root.mjs")[0]);
|
|
865
|
-
if (!existsSync(appRoot)) continue;
|
|
865
|
+
if (!existsSync(path.join(appRoot, "package.json"))) continue;
|
|
866
866
|
const fullPath = path.join(workspace, helperPath);
|
|
867
867
|
assert.ok(existsSync(fullPath), `${helperPath} is missing; the app must keep its shared cargo-metadata target resolver`);
|
|
868
868
|
const source = readFileSync(fullPath, "utf8");
|
package/rightkit-versions.json
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"@rightkit/legal": "0.3.0",
|
|
20
20
|
"@rightkit/legal-ui": "0.1.1",
|
|
21
21
|
"@rightkit/license": "0.1.6",
|
|
22
|
-
"@rightkit/release": "0.2.
|
|
22
|
+
"@rightkit/release": "0.2.67",
|
|
23
23
|
"@rightkit/qa": "0.2.0"
|
|
24
24
|
},
|
|
25
25
|
"legacyNpm": {
|
|
@@ -48,7 +48,8 @@
|
|
|
48
48
|
"0.2.62",
|
|
49
49
|
"0.2.63",
|
|
50
50
|
"0.2.64",
|
|
51
|
-
"0.2.65"
|
|
51
|
+
"0.2.65",
|
|
52
|
+
"0.2.66"
|
|
52
53
|
],
|
|
53
54
|
"@rightkit/qa": [
|
|
54
55
|
"0.1.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.18.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");
|