@rightkit/release 0.6.8 → 0.6.10
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/README.md +25 -9
- package/admission-files.mjs +1 -1
- package/app-check.mjs +94 -20
- package/assemble-app-command.mjs +151 -0
- package/assembled-app.mjs +445 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/bin/rightkit-gate.mjs +928 -82
- package/build-launch.mjs +88 -0
- package/build-ownership.mjs +1231 -0
- package/build-release.mjs +2 -1
- package/cargo-contract.mjs +3 -1
- package/cargo-target-shim.mjs +166 -0
- package/class-a-launch-guard.mjs +126 -0
- package/cli/right-release.mjs +3 -1
- package/codesign-shim.mjs +11 -1
- package/deterministic-scans.mjs +807 -56
- package/development-signing.mjs +4 -1
- package/development.mjs +110 -20
- package/door-policy.mjs +61 -6
- package/gate-executor.mjs +14 -2
- package/gate-stages.mjs +15 -5
- package/heavy-command.mjs +3 -2
- package/install-app.mjs +11 -2
- package/install-assembled-app.mjs +423 -0
- package/ios-dev.mjs +70 -3
- package/linux-artifacts.mjs +93 -0
- package/local-publish.mjs +161 -26
- package/managed-cargo.mjs +47 -5
- package/managed-root-guard.mjs +34 -0
- package/native-helpers.mjs +355 -0
- package/package-recipe-targets.mjs +52 -7
- package/package.json +4 -3
- package/pipeline-receipts.mjs +302 -302
- package/project-type.mjs +2 -2
- package/provenance-verify.mjs +115 -0
- package/publish-cargo.mjs +8 -6
- package/qa-admission-policy.json +10 -1
- package/recipe-build.mjs +73 -5
- package/release-config.mjs +1 -1
- package/release.mjs +15 -5
- package/rightkit-versions.json +13 -7
- package/snapshot-check.mjs +306 -0
- package/snapshot-git.mjs +166 -0
- package/snapshot-request.mjs +161 -0
- package/snapshot-root-config.mjs +76 -0
- package/snapshot.mjs +856 -0
- package/stage-commands.mjs +60 -0
- package/stage-sidecar.mjs +57 -7
- package/test-command.mjs +17 -4
- package/unit-gate.mjs +259 -0
- package/web-build.mjs +38 -6
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// What a unit's compile and package broker jobs must have run, derived from the code that builds those commands (local-publish.mjs
|
|
2
|
+
// localTestPlan and the pack argv builders), so the gate (which chooses the receipts to carry) and the hosted admission (which checks
|
|
3
|
+
// the signed requestedArgv of each) share one definition and cannot drift from the stages. EVERY command of a stage must be covered by
|
|
4
|
+
// a signed receipt of its own job; a missing command is a refusal that names it.
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { CARGO_METADATA_ARGS, cargoPackageArgs, localTestPlan, npmPackArgs, qualifyFmtArgs } from "./local-publish.mjs";
|
|
7
|
+
import { SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "./swap-helper-bundle.mjs";
|
|
8
|
+
|
|
9
|
+
/** Stands for the one argument that is a fresh absolute scratch path (`pnpm pack --pack-destination <mkdtemp>`). */
|
|
10
|
+
export const ANY_ABSOLUTE_PATH = Object.freeze({ anyAbsolutePath: true });
|
|
11
|
+
const PNPM = (...argvs) => ({ kind: "adapter", adapter: "rightkit-pnpm", argvs });
|
|
12
|
+
const CARGO = (...argvs) => ({ kind: "cargo", argvs });
|
|
13
|
+
|
|
14
|
+
/** Readable name of an expected command (scratch paths shown as <scratch>). */
|
|
15
|
+
export const describeCommand = (row) => `${row.kind === "adapter" ? "pnpm" : "cargo"} ${row.argvs[0].map((a) => (typeof a === "string" ? a : "<scratch>")).join(" ")}`;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The commands of the compile and package stages of one unit, one { kind, adapter?, argvs } row per job the stage launches
|
|
19
|
+
* (`argvs` are the accepted spellings of that one command: cargo metadata retries without --offline). Inputs are what the dispatched
|
|
20
|
+
* commit holds: `config` = ci/gate.config.json, `manifest` = the npm package's package.json.
|
|
21
|
+
*/
|
|
22
|
+
export function expectedStageCommands({ kind, unit, config, manifest, platform }) {
|
|
23
|
+
const dir = kind === "npm" ? `packages/${unit.slice("@rightkit/".length)}` : `crates/${unit}`;
|
|
24
|
+
const plan = localTestPlan({ kind, unit, dir, config, manifest, batch: false, platform });
|
|
25
|
+
const compile = plan.steps.map((step) => (step.command === "pnpm" ? PNPM(step.args) : CARGO(step.args)));
|
|
26
|
+
const pack = [];
|
|
27
|
+
if (kind === "npm") {
|
|
28
|
+
if (unit === SWAP_HELPER_PACKAGE) {
|
|
29
|
+
// The swap helper builds and signs a binary, then packs from a scratch copy of its directory.
|
|
30
|
+
pack.push(CARGO(SWAP_HELPER_CARGO_ARGS), CARGO(["metadata", "--format-version", "1", "--no-deps"]), PNPM(npmPackArgs(ANY_ABSOLUTE_PATH, ANY_ABSOLUTE_PATH)));
|
|
31
|
+
} else pack.push(PNPM(npmPackArgs(dir, ANY_ABSOLUTE_PATH)));
|
|
32
|
+
} else {
|
|
33
|
+
compile.unshift(CARGO(qualifyFmtArgs(unit)));
|
|
34
|
+
pack.push(CARGO(cargoPackageArgs(unit, true)), CARGO([...CARGO_METADATA_ARGS], CARGO_METADATA_ARGS.filter((arg) => arg !== "--offline")));
|
|
35
|
+
}
|
|
36
|
+
return { compile, package: pack };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const argvMatches = (expected, actual) => Array.isArray(actual) && expected.length === actual.length
|
|
40
|
+
&& expected.every((want, i) => (want === ANY_ABSOLUTE_PATH ? typeof actual[i] === "string" && (path.posix.isAbsolute(actual[i]) || /^[A-Za-z]:[\\/]/.test(actual[i])) : want === actual[i]));
|
|
41
|
+
|
|
42
|
+
/** Whether a signed payload's request is this command: request.kind, adapter (when any) and requestedArgv. */
|
|
43
|
+
export function requestIs(payload, row) {
|
|
44
|
+
const request = payload?.request;
|
|
45
|
+
return Boolean(request && typeof request === "object" && request.kind === row.kind && (row.adapter === undefined || request.adapter === row.adapter) && row.argvs.some((argv) => argvMatches(argv, request.requestedArgv)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Assigns each job (`{ id, payload }`) to a distinct command of the stage. Returns { assigned: Map(id -> row), unmatched: [id...],
|
|
50
|
+
* missing: [row...] }: a job that is none of the commands is unmatched; a command no job covers is missing.
|
|
51
|
+
*/
|
|
52
|
+
export function assignJobs(rows, jobs) {
|
|
53
|
+
const free = rows.map((row) => ({ row, taken: false }));
|
|
54
|
+
const assigned = new Map(), unmatched = [];
|
|
55
|
+
for (const job of jobs) {
|
|
56
|
+
const slot = free.find((s) => !s.taken && requestIs(job.payload, s.row));
|
|
57
|
+
if (slot) { slot.taken = true; assigned.set(job.id, slot.row); } else unmatched.push(job.id);
|
|
58
|
+
}
|
|
59
|
+
return { assigned, unmatched, missing: free.filter((s) => !s.taken).map((s) => s.row) };
|
|
60
|
+
}
|
package/stage-sidecar.mjs
CHANGED
|
@@ -38,7 +38,7 @@ import os from "node:os";
|
|
|
38
38
|
import path from "node:path";
|
|
39
39
|
import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
|
|
40
40
|
import { resolveCommand } from "./development.mjs";
|
|
41
|
-
import { managedHost } from "./managed-cargo.mjs";
|
|
41
|
+
import { directPlatform, managedHost } from "./managed-cargo.mjs";
|
|
42
42
|
import { APPROVAL_MACHO_STRIP_LINKEDIT_V1, APPROVAL_RAW_SHA256, approvalDigest } from "./macho-approval-hash.mjs";
|
|
43
43
|
import {
|
|
44
44
|
bundledFileName, planMediaSidecar, sourceOfferText, stagedFileName, validateMediaSidecarConfig, verifyStagedSidecars, writeMediaSidecarNotices,
|
|
@@ -111,7 +111,7 @@ const KEYS = Object.freeze({
|
|
|
111
111
|
"native-library": ["kind", "library", "version", "catalog", "resourceDir", "licenseDir", "cacheDir", "as", "files", "companions", "source", "platforms"],
|
|
112
112
|
resource: ["kind", "from", "to", "sha256", "machO", "clean", "platforms"],
|
|
113
113
|
command: ["kind", "name", "cmd", "args", "cwd", "output", "externalBin", "dest", "clean", "platforms"],
|
|
114
|
-
remote: ["kind", "name", "url", "sha256", "dest", "to", "size", "extract", "memberSha256", "executable", "clean", "platforms"],
|
|
114
|
+
remote: ["kind", "name", "url", "sha256", "dest", "to", "size", "extract", "extractKind", "memberSha256", "executable", "clean", "platforms"],
|
|
115
115
|
cmake: ["kind", "name", "repo", "commit", "target", "output", "cmakeArgs", "platformArgs", "generator", "platformGenerators", "architecture", "cmakeBin", "smoke", "sha256", "dest", "clean", "platforms"],
|
|
116
116
|
prebuilt: ["kind", "name", "from", "manifest", "dest", "platforms"],
|
|
117
117
|
});
|
|
@@ -476,10 +476,15 @@ export function validateSidecarManifest(raw, { target, mode = "release" } = {})
|
|
|
476
476
|
if (item.size !== undefined && (!Number.isSafeInteger(item.size) || item.size <= 0)) errors.push(`${where}: size must be a positive integer (bytes)`);
|
|
477
477
|
if (item.extract !== undefined) { const member = normalizeArchiveMember(item.extract); if (member.error || (!member.byPath && !NAME.test(member.value))) errors.push(`${where}: extract must be the bare file name of one archive member or its archive path (posix, no leading /, no .. segment): ${member.error ?? "invalid file name"}`); }
|
|
478
478
|
if (item.memberSha256 !== undefined && (item.extract === undefined || !SHA256.test(item.memberSha256 ?? ""))) errors.push(`${where}: memberSha256 must be a lowercase 64-hex SHA-256 and needs extract`);
|
|
479
|
+
if (item.extractKind !== undefined && item.extractKind !== "nsis") errors.push(`${where}: extractKind must be "nsis" (omit it for tar-readable zip/tgz/nupkg archives)`);
|
|
480
|
+
if (item.extractKind === "nsis") {
|
|
481
|
+
if (item.extract === undefined) errors.push(`${where}: extractKind "nsis" needs extract (the installer member path, e.g. bin/smartctl.exe)`);
|
|
482
|
+
if (typeof item.memberSha256 !== "string" || !SHA256.test(item.memberSha256)) errors.push(`${where}: extractKind "nsis" requires memberSha256 (the extracted member's lowercase 64-hex SHA-256); an NSIS member is never staged unpinned`);
|
|
483
|
+
}
|
|
479
484
|
if (item.executable !== undefined && typeof item.executable !== "boolean") errors.push(`${where}: executable must be a boolean`);
|
|
480
485
|
const clean = cleanOf();
|
|
481
486
|
if (dest && !skipped) claim(dest, where);
|
|
482
|
-
Object.assign(entry, { name, url: item.url, sha256: item.sha256, dest, ...(item.size ? { size: item.size } : {}), ...(item.extract ? { extract: item.extract } : {}), ...(item.memberSha256 ? { memberSha256: item.memberSha256 } : {}), ...(item.executable ? { executable: true } : {}), ...(clean ? { clean } : {}) });
|
|
487
|
+
Object.assign(entry, { name, url: item.url, sha256: item.sha256, dest, ...(item.extractKind ? { extractKind: item.extractKind } : {}), ...(item.size ? { size: item.size } : {}), ...(item.extract ? { extract: item.extract } : {}), ...(item.memberSha256 ? { memberSha256: item.memberSha256 } : {}), ...(item.executable ? { executable: true } : {}), ...(clean ? { clean } : {}) });
|
|
483
488
|
} else if (item.kind === "cmake") {
|
|
484
489
|
const name = item.name;
|
|
485
490
|
if (typeof name !== "string" || !NAME.test(name) || /\.exe$/i.test(name) || name !== path.basename(name)) errors.push(`${where}: name must be a bare sidecar name (no path, no .exe, no target triple)`);
|
|
@@ -611,6 +616,45 @@ export function defaultExtractMember({ archive, fileName, destDir, run = default
|
|
|
611
616
|
return out;
|
|
612
617
|
}
|
|
613
618
|
|
|
619
|
+
/** 7-Zip candidates: an explicit path (deps.sevenZip or RIGHTKIT_SEVEN_ZIP), then `7z` on PATH, then 7z.exe under Program Files on win32. */
|
|
620
|
+
export function sevenZipCandidates({ explicit, env = process.env, platform = process.platform } = {}) {
|
|
621
|
+
const list = [];
|
|
622
|
+
if (explicit) list.push(explicit);
|
|
623
|
+
else if (env.RIGHTKIT_SEVEN_ZIP) list.push(env.RIGHTKIT_SEVEN_ZIP);
|
|
624
|
+
list.push("7z");
|
|
625
|
+
if (platform === "win32") for (const base of [env.ProgramFiles ?? "C:\\Program Files", env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"]) list.push(path.win32.join(base, "7-Zip", "7z.exe"));
|
|
626
|
+
return [...new Set(list)];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Extract one member of an NSIS installer with 7-Zip (`x -y -o<dir> <installer> <member>`; nothing in the installer is run), then
|
|
631
|
+
* require its SHA-256 to equal the pinned one. Fails closed, naming the installer digest, member, expected and actual digests, on a
|
|
632
|
+
* missing tool, a failed extraction, a missing member or any mismatch. Returns the extracted file path under destDir.
|
|
633
|
+
*/
|
|
634
|
+
export function defaultExtractNsisMember({ archive, fileName, destDir, expectedSha256, installerSha256, run = defaultRun, sevenZip, env = process.env, platform = process.platform, readFile = nodeFs.readFileSync, exists = nodeFs.existsSync }) {
|
|
635
|
+
const wanted = normalizeArchiveMember(fileName);
|
|
636
|
+
const who = `NSIS installer ${installerSha256 ?? archive} member ${JSON.stringify(fileName)}`;
|
|
637
|
+
if (wanted.error) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who} ${wanted.error}`);
|
|
638
|
+
if (!SHA256.test(expectedSha256 ?? "")) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who}: a pinned member sha256 is required`);
|
|
639
|
+
let tool;
|
|
640
|
+
let lastError = "not found";
|
|
641
|
+
for (const candidate of sevenZipCandidates({ explicit: sevenZip, env, platform })) {
|
|
642
|
+
const result = run(candidate, ["x", "-y", `-o${destDir}`, archive, wanted.value], { cwd: path.dirname(archive) });
|
|
643
|
+
if (result.error || result.status === null || result.status === undefined) { lastError = result.error?.message ?? "could not start"; continue; }
|
|
644
|
+
tool = candidate;
|
|
645
|
+
if (result.status !== 0) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who}: ${candidate} exited ${result.status}; nothing staged`);
|
|
646
|
+
break;
|
|
647
|
+
}
|
|
648
|
+
if (!tool) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who}: 7-Zip is required and was not found (${lastError}); install 7-Zip or set RIGHTKIT_SEVEN_ZIP; nothing staged`);
|
|
649
|
+
const out = path.join(destDir, ...wanted.value.split("/"));
|
|
650
|
+
const inside = path.relative(path.resolve(destDir), path.resolve(out));
|
|
651
|
+
if (!inside || inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who} resolves outside the extraction directory`);
|
|
652
|
+
if (!exists(out)) fail("STAGE_SIDECAR_EXTRACT_FAILED", `${who}: member not found after extraction; nothing staged`);
|
|
653
|
+
const actual = sha256Of(readFile(out));
|
|
654
|
+
if (actual !== expectedSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${who}: sha256 is ${actual}, expected ${expectedSha256}; nothing staged`, { expected: expectedSha256, actual });
|
|
655
|
+
return out;
|
|
656
|
+
}
|
|
657
|
+
|
|
614
658
|
async function defaultFetchBytes(url) {
|
|
615
659
|
if (!url.startsWith("https://")) fail("STAGE_SIDECAR_CONFIG_INVALID", `refusing a non-https archive URL: ${url}`);
|
|
616
660
|
const response = await fetch(url);
|
|
@@ -1453,7 +1497,9 @@ async function stageRemote(entry, ctx) {
|
|
|
1453
1497
|
let memberSha256;
|
|
1454
1498
|
if (entry.extract) {
|
|
1455
1499
|
scratch = ctx.makeTempDir();
|
|
1456
|
-
source =
|
|
1500
|
+
source = entry.extractKind === "nsis"
|
|
1501
|
+
? await ctx.extractNsisMember({ archive: cached, fileName: entry.extract, destDir: scratch, expectedSha256: entry.memberSha256, installerSha256: entry.sha256 })
|
|
1502
|
+
: await ctx.extractMember({ archive: cached, fileName: entry.extract, destDir: scratch });
|
|
1457
1503
|
memberSha256 = sha256Of(fs.readFileSync(source));
|
|
1458
1504
|
if (entry.memberSha256 && memberSha256 !== entry.memberSha256) fail("STAGE_SIDECAR_HASH_MISMATCH", `${entry.extract} from ${entry.url} is ${memberSha256}, expected ${entry.memberSha256}; nothing staged`, { expected: entry.memberSha256, actual: memberSha256 });
|
|
1459
1505
|
}
|
|
@@ -1462,7 +1508,7 @@ async function stageRemote(entry, ctx) {
|
|
|
1462
1508
|
const copied = copyInto(ctx, source, dest, { executable: entry.executable === true });
|
|
1463
1509
|
const expected = entry.extract ? entry.memberSha256 : entry.sha256;
|
|
1464
1510
|
if (expected && copied.sha256 !== expected) fail("STAGE_SIDECAR_HASH_MISMATCH", `staged ${entry.dest} is ${copied.sha256}, expected ${expected}`, { expected, actual: copied.sha256 });
|
|
1465
|
-
return [{ kind: "remote", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256: memberSha256 ?? entry.sha256, url: entry.url, cached: fromCache, externalBin: false, ...(entry.extract ? { archiveSha256: entry.sha256, member: entry.extract } : {}) }];
|
|
1511
|
+
return [{ kind: "remote", name: entry.name, path: rel(ctx, dest), ...copied, sourceSha256: memberSha256 ?? entry.sha256, url: entry.url, cached: fromCache, externalBin: false, ...(entry.extract ? { archiveSha256: entry.sha256, member: entry.extract, memberSha256 } : {}), ...(entry.extractKind ? { extractKind: entry.extractKind } : {}) }];
|
|
1466
1512
|
} finally {
|
|
1467
1513
|
if (scratch) ctx.removeDir(scratch);
|
|
1468
1514
|
}
|
|
@@ -1687,10 +1733,13 @@ function wipeStaleStageDir(ctx, plan, produced) {
|
|
|
1687
1733
|
* call can reuse (acquireManagedRunLease records a per-process lease file; no RK_* variable carries it to a child), so
|
|
1688
1734
|
* the only safe behaviour is to refuse early. RIGHTKIT_STAGE_SIDECAR_ALLOW_NESTED=1 opts out.
|
|
1689
1735
|
*/
|
|
1690
|
-
export function isNestedBrokerContext(env = process.env) {
|
|
1736
|
+
export function isNestedBrokerContext(env = process.env, platform = process.platform) {
|
|
1737
|
+
// A direct (Linux) host has no broker, so no outer broker build to nest in, whatever flag or socket the env carries (heavy-command sets
|
|
1738
|
+
// RIGHTSUITE_HEAVY_WORK_OWNER on every platform).
|
|
1739
|
+
if (directPlatform(platform)) return false;
|
|
1691
1740
|
if (env?.RIGHTKIT_STAGE_SIDECAR_ALLOW_NESTED === "1") return false;
|
|
1692
1741
|
const insideTauri = Boolean(env?.TAURI_ENV_PLATFORM || env?.TAURI_ENV_TARGET_TRIPLE);
|
|
1693
|
-
return insideTauri && (managedHost(env ?? {}) || env?.RIGHTSUITE_HEAVY_WORK_OWNER === "1");
|
|
1742
|
+
return insideTauri && (managedHost(env ?? {}, platform) || env?.RIGHTSUITE_HEAVY_WORK_OWNER === "1");
|
|
1694
1743
|
}
|
|
1695
1744
|
|
|
1696
1745
|
/** True when staging would call `rustright cargo` for a non-skipped entry. */
|
|
@@ -1754,6 +1803,7 @@ export async function stageSidecars({ appRoot, manifest, target, deps = {} } = {
|
|
|
1754
1803
|
fetchBytes: deps.fetchBytes ?? defaultFetchBytes,
|
|
1755
1804
|
fetch: deps.fetch ?? ((...args) => globalThis.fetch(...args)),
|
|
1756
1805
|
extractMember: deps.extractMember ?? ((args) => defaultExtractMember({ ...args, run })),
|
|
1806
|
+
extractNsisMember: deps.extractNsisMember ?? ((args) => defaultExtractNsisMember({ ...args, run, env, sevenZip: deps.sevenZip, readFile: fs.readFileSync, exists: fs.existsSync })),
|
|
1757
1807
|
loadMediaCatalog: deps.loadMediaCatalog ?? (() => undefined),
|
|
1758
1808
|
loadNativeCatalog: deps.loadNativeCatalog ?? (() => undefined),
|
|
1759
1809
|
makeTempDir: deps.makeTempDir ?? (() => nodeFs.mkdtempSync(path.join(os.tmpdir(), "rightkit-stage-"))),
|
package/test-command.mjs
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
// from the app root. `--suite journeys` selects every chunk of that group. `--runner vitest -- run --project unit` runs
|
|
22
22
|
// the runner once with the arguments after `--` (the watch or one-off case); `-- <args>` after `--suite` is allowed when
|
|
23
23
|
// exactly one suite is selected. Exit code: 0 when every selected suite passed, else the failing suite's exit code.
|
|
24
|
-
import { readFileSync } from "node:fs";
|
|
24
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
25
|
+
import { buildLaunch } from "./build-launch.mjs";
|
|
25
26
|
import path from "node:path";
|
|
26
27
|
import { pathToFileURL } from "node:url";
|
|
27
28
|
import { spawn } from "./background-process.mjs";
|
|
@@ -39,10 +40,11 @@ export function parseTestArgs(argv) {
|
|
|
39
40
|
for (let i = 0; i < argv.length; i += 1) {
|
|
40
41
|
const arg = argv[i];
|
|
41
42
|
if (arg === "--") { opts.passthrough = argv.slice(i + 1); break; }
|
|
42
|
-
if (arg === "--config" || arg === "--suite" || arg === "--runner") {
|
|
43
|
+
if (arg === "--config" || arg === "--suite" || arg === "--runner" || arg === "--platform") {
|
|
43
44
|
const value = argv[i + 1];
|
|
44
45
|
if (!value || value.startsWith("--")) throw new Error(`${arg} needs a value`);
|
|
45
46
|
if (arg === "--suite") opts.suites.push(...value.split(",").filter(Boolean));
|
|
47
|
+
else if (arg === "--platform") opts.platform = value;
|
|
46
48
|
else opts[arg.slice(2)] = value;
|
|
47
49
|
i += 1;
|
|
48
50
|
} else if (arg === "--list") opts.list = true;
|
|
@@ -147,14 +149,16 @@ export function eventPrefix(config) {
|
|
|
147
149
|
* Run the planned suites in order; stop at the first non-zero exit. `spawnFn` and `log` are injectable.
|
|
148
150
|
* Resolves `{ exitCode, results: [{ suite, exitCode, durationMs }] }`; a spawn error or a signal rejects.
|
|
149
151
|
*/
|
|
150
|
-
export async function runSuites(plan, { appRoot, prefix = "test", env = process.env, spawnFn = spawn, log = console.log } = {}) {
|
|
152
|
+
export async function runSuites(plan, { appRoot, prefix = "test", env = process.env, spawnFn = spawn, log = console.log, door = buildLaunch } = {}) {
|
|
151
153
|
const results = [];
|
|
152
154
|
for (const item of plan) {
|
|
153
155
|
const invocation = resolveInvocation(item.step, { appRoot, baseEnv: env, extraArgs: item.extraArgs });
|
|
154
156
|
log(JSON.stringify({ event: `${prefix}_suite_started`, suite: item.name }));
|
|
155
157
|
const started = Date.now();
|
|
156
158
|
const exitCode = await new Promise((resolve, reject) => {
|
|
157
|
-
|
|
159
|
+
// A test suite goes through the one door (build-launch.mjs): a broker job on a managed host, which builds it under the app's claim.
|
|
160
|
+
const target = door(invocation.cmd, invocation.args, { env: invocation.env, cwd: invocation.cwd, exists: existsSync });
|
|
161
|
+
const child = spawnFn(target.command, target.args, { cwd: invocation.cwd, env: invocation.env, stdio: "inherit" });
|
|
158
162
|
child.once("error", reject);
|
|
159
163
|
child.once("exit", (code, signal) => {
|
|
160
164
|
if (signal) reject(new Error(`${item.name} stopped by ${signal}`));
|
|
@@ -172,6 +176,15 @@ export async function runSuites(plan, { appRoot, prefix = "test", env = process.
|
|
|
172
176
|
export async function main(argv = process.argv.slice(2)) {
|
|
173
177
|
const opts = parseTestArgs(argv);
|
|
174
178
|
const { config, appRoot } = await loadAppConfig(opts.config);
|
|
179
|
+
// `--platform ios`: the app's declared XCUITest journeys (development.targets.ios.uiTests) on its dev device or simulator.
|
|
180
|
+
if (opts.platform !== undefined) {
|
|
181
|
+
if (opts.platform !== "ios") throw new Error(`--platform ${opts.platform}: only ios has platform UI test journeys; other suites are declared in tests.suites`);
|
|
182
|
+
if (opts.suites.length || opts.runner || opts.passthrough.length) throw new Error("--platform ios runs the declared journeys; it takes no --suite, --runner or arguments");
|
|
183
|
+
const { runDevelopment } = await import("./development.mjs");
|
|
184
|
+
const result = runDevelopment({ root: appRoot, platform: "ios", config, uiTests: true, dryRun: opts.list });
|
|
185
|
+
if (opts.list) console.log(JSON.stringify(result.plan ?? result, null, 2));
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
175
188
|
const plan = planTests(config, { suites: opts.suites, runner: opts.runner, passthrough: opts.passthrough, appRoot });
|
|
176
189
|
if (opts.list) {
|
|
177
190
|
const view = plan.map((item) => ({ suite: item.name, ...resolveInvocation(item.step, { appRoot, baseEnv: {}, extraArgs: item.extraArgs }), env: undefined }));
|
package/unit-gate.mjs
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// RightKit's own units use the same door, but never reuse mutable unit receipts as evidence.
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { localBuildGranted } from "./execution-policy.mjs";
|
|
7
|
+
import { readCargoPackage } from "./crate-graph.mjs";
|
|
8
|
+
import { spawnSync as nodeSpawnSync } from "./background-process.mjs";
|
|
9
|
+
import { endRun, startRun, stagesTo } from "./gate-stages.mjs";
|
|
10
|
+
import { qualificationNonce } from "./provenance-verify.mjs";
|
|
11
|
+
import { assignJobs, describeCommand, expectedStageCommands } from "./stage-commands.mjs";
|
|
12
|
+
|
|
13
|
+
const GH = process.platform === "win32" ? "C:\\Program Files\\GitHub CLI\\gh.exe" : "/opt/homebrew/bin/gh";
|
|
14
|
+
const defaultRun = (cmd, args, options = {}) => nodeSpawnSync(cmd, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, ...options });
|
|
15
|
+
const defaultReadJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The gate-owned release of one RightKit unit. Runs only on a broker-managed host and only after this process's check, compile,
|
|
19
|
+
* package and qualify stages: (1) the unit's local pre-flight through the broker (rk-pipeline preflight: the compile rows the dispatch
|
|
20
|
+
* adapter admits), (2) the broker-admitted dispatch of publish.yml (rk-pipeline dispatch -> rustright run rightkit-dispatch; the
|
|
21
|
+
* broker's request id is the run's correlation token), (3) rk-pipeline watch --wait: the exact run (sha, attempt, event, workflow,
|
|
22
|
+
* token), then its publish-direct upload run, then the registry, (4) the upload run's publication receipt (the sealed bytes the
|
|
23
|
+
* registry serves: published && registryVerified) read back against the registry itself. A queued dispatch, a missing upload run,
|
|
24
|
+
* an ambiguous receipt or differing registry bytes is a failure; nothing is retried or re-dispatched here (reconcile rk-pipeline's
|
|
25
|
+
* state by hand). The local bundle's digest is reported beside the hosted one, never equated with it.
|
|
26
|
+
*/
|
|
27
|
+
export const QUALIFICATION_SCHEMA = "rightkit.unit-qualification/1";
|
|
28
|
+
/** The stages whose broker jobs the hosted admission authenticates (their signed receipts ride in the qualification), and how many
|
|
29
|
+
* receipts one stage may carry (every command of the stage needs its own; the dispatch input is capped at 65535 characters in all and
|
|
30
|
+
* each signed envelope is ~3 KB, which writeQualification checks). */
|
|
31
|
+
export const ATTESTED_STAGES = ["compile", "package"];
|
|
32
|
+
export const MAX_STAGE_RECEIPTS = 64;
|
|
33
|
+
const MAX_QUALIFICATION_BYTES = 58_000;
|
|
34
|
+
|
|
35
|
+
/** `rustright status <id>`: the broker's own record of one job, reduced to what a verifier needs (id + signed envelope). A job the
|
|
36
|
+
* broker cannot report, or one without a signed envelope, fails the stage: an unauthenticated qualification is worth nothing. */
|
|
37
|
+
/** The commands each stage must have run for this unit, from the same builders the stages use, over the checkout's own config. */
|
|
38
|
+
export function defaultStageCommands({ unit, root, platform, readJson = defaultReadJson }) {
|
|
39
|
+
const manifest = unit.kind === "npm" ? readJson(path.join(root, "packages", unit.name.slice("@rightkit/".length), "package.json")) : undefined;
|
|
40
|
+
return expectedStageCommands({ kind: unit.kind, unit: unit.name, config: readJson(path.join(root, "ci", "gate.config.json")), manifest, platform });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readBrokerReceipt(id, { run = defaultRun } = {}) {
|
|
44
|
+
const r = run("rustright", ["status", id], { encoding: "utf8", timeout: 60_000 });
|
|
45
|
+
if (r.error || r.status !== 0) throw new Error(`the broker cannot report job ${id} (rustright status failed)`);
|
|
46
|
+
let doc;
|
|
47
|
+
try { doc = JSON.parse(r.stdout); } catch { throw new Error(`the broker's status of job ${id} is not JSON`); }
|
|
48
|
+
const receipt = doc?.receipt ?? doc;
|
|
49
|
+
if (receipt?.id !== id || !receipt.provenance?.payload || !receipt.provenance.signature) throw new Error(`job ${id} has no signed provenance receipt`);
|
|
50
|
+
return { id, provenance: receipt.provenance };
|
|
51
|
+
}
|
|
52
|
+
/** `<bundle>/qualification.json`: the local stages that passed and the one artifact sha256 they bound, for the dispatcher. */
|
|
53
|
+
export function writeQualification({ report, artifact, unit, commit, tree, runtime, executor, write = fs.writeFileSync }) {
|
|
54
|
+
for (const stage of ["check", "compile", "package", "qualify"]) if (report.stages?.[stage]?.ok !== true) throw new Error(`release needs stage ${stage} ok before it runs`);
|
|
55
|
+
if (!artifact?.artifactFile || !/^[0-9a-f]{64}$/.test(artifact.artifactSha256 ?? "")) throw new Error("release needs the qualified artifact");
|
|
56
|
+
const receipt = { schema: QUALIFICATION_SCHEMA, unit: { kind: unit.kind, name: unit.name, version: unit.version }, commit, tree, runtime, executor: executor?.kind ?? null,
|
|
57
|
+
stages: Object.fromEntries(["check", "compile", "package", "qualify"].map((s) => [s, { ...report.stages[s] }])), artifactSha256: artifact.artifactSha256, writtenAt: new Date().toISOString() };
|
|
58
|
+
// The broker's signed receipts for the compile and package stages are what the hosted admission verifies; without them the
|
|
59
|
+
// booleans above are only this process's say-so.
|
|
60
|
+
for (const stage of ATTESTED_STAGES) {
|
|
61
|
+
const list = report.stages[stage].receipts;
|
|
62
|
+
if (!Array.isArray(list) || list.length < 1 || list.length > MAX_STAGE_RECEIPTS) throw new Error(`release needs 1 to ${MAX_STAGE_RECEIPTS} signed broker receipts of the ${stage} stage (found ${Array.isArray(list) ? list.length : "none"})`);
|
|
63
|
+
}
|
|
64
|
+
if (JSON.stringify(receipt).length > MAX_QUALIFICATION_BYTES) throw new Error("the qualification receipt with its broker receipts exceeds the workflow dispatch input limit");
|
|
65
|
+
const file = path.join(path.dirname(artifact.artifactFile), "qualification.json");
|
|
66
|
+
write(file, `${JSON.stringify(receipt, null, 2)}\n`);
|
|
67
|
+
return file;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const requireBrokerHost = (executor) => { if (executor?.kind !== "broker") throw new Error("unit release runs only on a broker-managed host (the hosted qualification is dispatched by the RustRight rightkit-dispatch adapter); a direct host has no gate-owned release route, so no upload was started"); };
|
|
71
|
+
|
|
72
|
+
export async function releaseUnit({ unit, root, commit, executor, env, artifact, qualification, platform = process.platform, run = defaultRun, readJson = defaultReadJson, out = console.log, scratch = platform === "win32" ? "D:\\scratch" : "/Volumes/D/scratch", registryBytes = defaultRegistryBytes }) {
|
|
73
|
+
requireBrokerHost(executor);
|
|
74
|
+
if (typeof qualification !== "string" || !fs.existsSync(qualification)) throw new Error("release needs the unit qualification receipt (written by the gate after the qualify stage); nothing is dispatched without it");
|
|
75
|
+
const registry = unit.kind === "npm" ? "npm" : "crates";
|
|
76
|
+
const pipeline = path.join(root, "scripts", "ci", "rk-pipeline.mjs");
|
|
77
|
+
const stateFile = platform === "win32" ? "D:\\scratch\\rk-pipeline\\state.json" : "/Volumes/D/scratch/rk-pipeline/state.json";
|
|
78
|
+
const step = (label, args, { accept = [0], acceptWhen = null } = {}) => {
|
|
79
|
+
out(`rightkit-gate: [release] ${label}`);
|
|
80
|
+
const r = run(process.execPath, [pipeline, ...args], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
81
|
+
const accepted = r.status === 0 || (accept.includes(r.status) && (!acceptWhen || acceptWhen(r)));
|
|
82
|
+
if (!accepted) {
|
|
83
|
+
const said = `${String(r.stdout ?? "")}\n${String(r.stderr ?? "")}`.split("\n").map((l) => l.trim()).filter((l) => l && !l.includes("note:")).slice(-4).join(" | ");
|
|
84
|
+
throw new Error(`${label} failed (exit ${r.status}): ${said.slice(0, 600)}`);
|
|
85
|
+
}
|
|
86
|
+
return r;
|
|
87
|
+
};
|
|
88
|
+
const findDispatch = () => {
|
|
89
|
+
const found = Object.values(readJson(stateFile).dispatches ?? {}).filter((d) => d.unit === unit.name && d.sha === commit && d.registry === registry);
|
|
90
|
+
if (found.length > 1) throw new Error(`${found.length} dispatch records for ${unit.name} at ${commit.slice(0, 12)} in rk-pipeline state; reconcile before any retry`);
|
|
91
|
+
return found[0];
|
|
92
|
+
};
|
|
93
|
+
// The pipeline canary (rk-release-canary: the candidate's `right-release doctor` against every app on both hosts, versus the
|
|
94
|
+
// registry's version) runs before the pre-flight. rk-pipeline exits 2 both for "no pipeline canary for this unit" and for any
|
|
95
|
+
// internal error, so 2 is accepted only when it says the former (reviewer, 2026-10-27); a canary error never reads as no canary.
|
|
96
|
+
step(`pipeline canary of ${unit.name} (doctor on every app, both hosts, candidate vs registry)`, ["canary", unit.name],
|
|
97
|
+
{ accept: [2], acceptWhen: (r) => /no pipeline canary for this unit/.test(`${r.stdout ?? ""}\n${r.stderr ?? ""}`) });
|
|
98
|
+
step(`local pre-flight of ${unit.name} through the broker`, ["preflight", unit.name, "--registry", registry]);
|
|
99
|
+
step(`broker-admitted hosted qualification dispatch of ${unit.name} at ${commit.slice(0, 12)}`, ["dispatch", unit.name, "--registry", registry, "--qualification", qualification]);
|
|
100
|
+
const dispatched = findDispatch();
|
|
101
|
+
if (!dispatched?.runId || !dispatched.runAttempt) throw new Error(`no dispatch record with a run for ${unit.name} at ${commit.slice(0, 12)}; nothing is claimed (reconcile rk-pipeline state before any retry)`);
|
|
102
|
+
step(`waiting for publish.yml run ${dispatched.runId}, its publish-direct upload run and the registry`, ["watch", "--wait"]);
|
|
103
|
+
const done = findDispatch();
|
|
104
|
+
if (done?.status !== "published" || done.version !== unit.version || !done.uploadRunId) throw new Error(`${unit.name}: the watch ended without a published record (${done?.status ?? "none"}${done?.conclusion ? `: ${done.conclusion}` : ""}); nothing is claimed`);
|
|
105
|
+
const receipt = readPublicationReceipt({ uploadRunId: done.uploadRunId, runId: done.runId, runAttempt: done.runAttempt, run, env, scratch });
|
|
106
|
+
// The receipt is written by the publish-direct UPLOAD run (its runId/runAttempt are that run's: upload.mjs records GITHUB_RUN_ID);
|
|
107
|
+
// the qualification run it consumed is its `candidate` (reviewed-candidate.mjs prepareQualified: id qualified-<run>-<attempt>,
|
|
108
|
+
// runId, runAttempt, sourceSha). Both are bound to what rk-pipeline dispatched and watched, plus commit, registry and repository.
|
|
109
|
+
const candidate = receipt.candidate ?? {};
|
|
110
|
+
const bound = String(receipt.runId) === String(done.uploadRunId)
|
|
111
|
+
&& receipt.commit === commit && receipt.registry === registry && receipt.repository === "bogusyogi/rightkit" && receipt.dryRun !== true
|
|
112
|
+
&& candidate.source === "qualified" && String(candidate.runId) === String(done.runId) && String(candidate.runAttempt) === String(done.runAttempt) && candidate.sourceSha === commit;
|
|
113
|
+
if (!bound) {
|
|
114
|
+
throw new Error(`${unit.name}@${unit.version}: the publication receipt of upload run ${done.uploadRunId} is not that run's receipt for qualification run ${done.runId} attempt ${done.runAttempt} of ${commit.slice(0, 12)} (${registry}); nothing is claimed`);
|
|
115
|
+
}
|
|
116
|
+
const rows = (receipt.receipts ?? []).filter((r) => r.unit === unit.name && r.version === unit.version);
|
|
117
|
+
if (rows.length !== 1) throw new Error(`${unit.name}@${unit.version}: the publication receipt holds ${rows.length} row(s) for this unit; exactly one is required`);
|
|
118
|
+
const [row] = rows;
|
|
119
|
+
if (row.published !== true || row.registryVerified !== true || row.outcome !== "published" || !/^[0-9a-f]{64}$/.test(row.sha256 ?? "")) throw new Error(`${unit.name}@${unit.version}: the publication receipt of upload run ${done.uploadRunId} records no verified publication (${row.outcome})`);
|
|
120
|
+
// Read the registry back independently of the upload run: npm's dist.shasum (sha1) and the served tarball's sha256 (the receipt
|
|
121
|
+
// row carries sha256 + sha1, not the sha512 integrity); crates: the sparse index cksum (sha256).
|
|
122
|
+
const served = await registryBytes({ unit, registry, run, env, expected: row, scratch });
|
|
123
|
+
const same = registry === "npm" ? served.shasum === row.sha1 && served.sha256 === row.sha256 : served.cksum === row.sha256;
|
|
124
|
+
if (!same) throw new Error(`${unit.name}@${unit.version}: the registry serves different bytes from the upload receipt (served sha256 ${served.sha256 ?? served.cksum ?? "?"}, receipt ${row.sha256})`);
|
|
125
|
+
return {
|
|
126
|
+
registry, runId: done.runId, runAttempt: done.runAttempt, requestId: done.requestId ?? null, uploadRunId: done.uploadRunId,
|
|
127
|
+
hostedSha256: row.sha256, hostedSha1: row.sha1 ?? null, registryVerified: true,
|
|
128
|
+
localArtifactSha256: artifact?.artifactSha256 ?? null, hostedEqualsLocal: artifact ? artifact.artifactSha256 === row.sha256 : null,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The one `publication-receipt-*` artifact of the upload run, downloaded and parsed. More or fewer than one is ambiguous. */
|
|
133
|
+
export function readPublicationReceipt({ uploadRunId, runId, runAttempt, run, env, scratch }) {
|
|
134
|
+
const listed = run(GH, ["api", `repos/bogusyogi/rightkit/actions/runs/${uploadRunId}/artifacts`], { env, encoding: "utf8" });
|
|
135
|
+
if (listed.status !== 0) throw new Error(`cannot list the artifacts of upload run ${uploadRunId}`);
|
|
136
|
+
// publish-direct names its receipt `publication-receipt-<qualification run id>-<its attempt>-<upload attempt>`.
|
|
137
|
+
const pattern = new RegExp(`^publication-receipt-${runId}-${runAttempt}-\\d+$`);
|
|
138
|
+
const names = (JSON.parse(listed.stdout).artifacts ?? []).map((a) => a.name).filter((n) => pattern.test(n));
|
|
139
|
+
if (names.length !== 1) throw new Error(`upload run ${uploadRunId} has ${names.length} publication receipt artifact(s) for run ${runId} attempt ${runAttempt}; exactly one is required`);
|
|
140
|
+
const dir = path.join(scratch, "rk-unit-receipts", `${uploadRunId}-${Date.now()}`);
|
|
141
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
142
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
143
|
+
const fetched = run(GH, ["run", "download", String(uploadRunId), "-R", "bogusyogi/rightkit", "--name", names[0], "--dir", dir], { env, encoding: "utf8" });
|
|
144
|
+
if (fetched.status !== 0) throw new Error(`cannot download ${names[0]} of upload run ${uploadRunId}`);
|
|
145
|
+
const file = path.join(dir, "publication-receipt.json");
|
|
146
|
+
if (!fs.existsSync(file)) throw new Error(`${names[0]} holds no publication-receipt.json`);
|
|
147
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What the registry serves for the unit's version: npm dist.integrity + dist.shasum; crates the sparse index cksum. */
|
|
151
|
+
export async function defaultRegistryBytes({ unit, registry, run, env, expected, scratch }) {
|
|
152
|
+
if (registry === "npm") {
|
|
153
|
+
const r = run("npm", ["view", `${unit.name}@${unit.version}`, "dist.shasum", "--json", "--prefer-online"], { env, encoding: "utf8" });
|
|
154
|
+
if (r.status !== 0) throw new Error(`npm view ${unit.name}@${unit.version} failed`);
|
|
155
|
+
const shasum = JSON.parse(r.stdout);
|
|
156
|
+
// The served tarball itself, hashed here: `npm pack` downloads exactly what the registry serves for that version.
|
|
157
|
+
const dir = path.join(scratch, "rk-unit-readback", `${unit.name.replace("/", "__")}-${unit.version}-${Date.now()}`);
|
|
158
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
159
|
+
const packed = run("npm", ["pack", `${unit.name}@${unit.version}`, "--pack-destination", dir, "--ignore-scripts"], { env, encoding: "utf8", cwd: dir });
|
|
160
|
+
if (packed.status !== 0) throw new Error(`npm pack ${unit.name}@${unit.version} failed`);
|
|
161
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".tgz"));
|
|
162
|
+
if (files.length !== 1) throw new Error(`npm pack of ${unit.name}@${unit.version} produced ${files.length} tarball(s)`);
|
|
163
|
+
return { shasum: typeof shasum === "string" ? shasum : shasum?.["dist.shasum"], sha256: hash(fs.readFileSync(path.join(dir, files[0]))) };
|
|
164
|
+
}
|
|
165
|
+
// The sparse index must serve exactly the receipt's sha256 (verifyCargoRegistry throws PUBLICATION_REGISTRY_MISMATCH otherwise).
|
|
166
|
+
const { verifyCargoRegistry } = await import("./cargo-registry-readback.mjs");
|
|
167
|
+
await verifyCargoRegistry({ name: unit.name, version: unit.version, sha256: expected.sha256 });
|
|
168
|
+
return { cksum: expected.sha256 };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
const hash = (data) => createHash("sha256").update(data).digest("hex");
|
|
173
|
+
|
|
174
|
+
export function resolveUnit(selector, root, { granted = localBuildGranted, readJson = (p) => JSON.parse(fs.readFileSync(p, "utf8")), readCargo = readCargoPackage } = {}) {
|
|
175
|
+
const match = /^(npm):(@rightkit\/[a-z0-9-]+)$|^(cargo):(rightkit-[a-z0-9-]+)$/.exec(selector ?? "");
|
|
176
|
+
if (!match || !granted(root)) throw new Error("unit needs npm:@rightkit/<name> or cargo:rightkit-<name> in the locally granted RightKit repository");
|
|
177
|
+
const kind = match[1] ?? match[3], name = match[2] ?? match[4];
|
|
178
|
+
const dir = path.join(root, kind === "npm" ? "packages" : "crates", kind === "npm" ? name.slice("@rightkit/".length) : name);
|
|
179
|
+
const manifestFile = path.join(dir, kind === "npm" ? "package.json" : "Cargo.toml");
|
|
180
|
+
if (!fs.realpathSync(manifestFile).startsWith(`${fs.realpathSync(root)}${path.sep}`)) throw new Error("unit manifest escapes the repository");
|
|
181
|
+
const manifest = kind === "npm" ? readJson(path.join(dir, "package.json")) : readCargo(dir);
|
|
182
|
+
if (manifest.name !== name || manifest.private === true || manifest.manifest?.package?.publish === false || typeof manifest.version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version)) throw new Error(`invalid canonical manifest for ${selector}`);
|
|
183
|
+
return { kind, name, version: manifest.version };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** One invocation reruns all owed stages; reports are never read as admission evidence. */
|
|
187
|
+
export async function runUnitGate({ selector, to, root, commonDir, commit, tree, executor, env, platform, check, loadOwner = () => import("./local-publish.mjs"), out = console.log, err = console.error, releaser = null, resolve = resolveUnit, writeReport, fetchReceipt = readBrokerReceipt, stageCommands = defaultStageCommands }) {
|
|
188
|
+
const stages = stagesTo(to);
|
|
189
|
+
if (!stages) { err(`rightkit-gate: invalid unit stage ${to}`); return 2; }
|
|
190
|
+
let unit;
|
|
191
|
+
try { unit = resolve(selector, root); } catch (error) { err(`rightkit-gate: BLOCKED: ${error.message}`); return 1; }
|
|
192
|
+
const scratch = platform === "win32" ? "D:\\scratch" : "/Volumes/D/scratch";
|
|
193
|
+
const bundle = path.join(scratch, `rk-unit-${randomBytes(12).toString("hex")}`);
|
|
194
|
+
const argv = ["--local", unit.kind === "npm" ? "--package" : "--crate", unit.name, "--workspace", root, "--qualify-only", "--bundle", bundle];
|
|
195
|
+
const runtimeDir = path.dirname(fileURLToPath(import.meta.url));
|
|
196
|
+
const runtimeFiles = [...fs.readdirSync(runtimeDir).filter((name) => name.endsWith(".mjs")).sort().map((name) => path.join(runtimeDir, name)), path.join(runtimeDir, "bin", "rightkit-gate.mjs")];
|
|
197
|
+
const runtime = hash(runtimeFiles.map((file) => `${path.relative(runtimeDir, file)}\0${hash(fs.readFileSync(file))}`).join("\n"));
|
|
198
|
+
const report = { schema: "rightkit.unit-gate-report/1", unit, commit, tree, runtime, executor: executor.kind, stages: {} };
|
|
199
|
+
const save = () => { if (writeReport) writeReport(report); };
|
|
200
|
+
let state, owner, artifact, currentStage, checkTier = null;
|
|
201
|
+
// A run headed to release has its compile and package broker jobs sent with a nonce bound to this commit, tree, unit and stage
|
|
202
|
+
// (RIGHTKIT_ATTEST_NONCE), so each job's signed receipt can be verified hosted for exactly that and nothing else.
|
|
203
|
+
const attested = stages.includes("release") && executor?.kind === "broker";
|
|
204
|
+
const stageEnv = async (stage, action) => {
|
|
205
|
+
const run = startRun({ commonDir, commit, tree, stage });
|
|
206
|
+
const nonce = attested && ATTESTED_STAGES.includes(stage) ? { RIGHTKIT_ATTEST_NONCE: qualificationNonce({ commit, tree, unit: `${unit.kind}:${unit.name}`, stage }) } : {};
|
|
207
|
+
try { return await action({ ...env, ...nonce, ...run.env }); } finally { endRun(run); }
|
|
208
|
+
};
|
|
209
|
+
const receipts = {};
|
|
210
|
+
const collectReceipts = (stage, ids) => {
|
|
211
|
+
if (!attested || !ATTESTED_STAGES.includes(stage)) return;
|
|
212
|
+
// Every job of the stage is fetched; the stage owes a signed receipt for EVERY command it runs (hosted admission requires the same),
|
|
213
|
+
// so a missing one fails here, before any dispatch, naming it. Jobs that are none of the stage's commands are not carried.
|
|
214
|
+
const fetched = [...new Set(ids ?? [])].map((id) => fetchReceipt(id));
|
|
215
|
+
const { assigned, missing } = assignJobs(stageCommands({ unit, root, platform })[stage], fetched.map((r) => ({ id: r.id, payload: r.provenance.payload })));
|
|
216
|
+
if (missing.length) throw new Error(`the ${stage} stage's ${describeCommand(missing[0])} has no broker job receipt (${missing.length} required command(s) missing)`);
|
|
217
|
+
receipts[stage] = fetched.filter((r) => assigned.has(r.id));
|
|
218
|
+
};
|
|
219
|
+
const verifyArtifact = (result) => {
|
|
220
|
+
if (!result || result.kind !== unit.kind || result.name !== unit.name || result.version !== unit.version || result.commit !== commit || !/^[0-9a-f]{64}$/.test(result.artifactSha256 ?? "") || hash(fs.readFileSync(result.artifactFile)) !== result.artifactSha256) throw new Error("unit artifact identity or digest differs from the completed stage");
|
|
221
|
+
};
|
|
222
|
+
try {
|
|
223
|
+
for (const stage of stages) {
|
|
224
|
+
currentStage = stage;
|
|
225
|
+
// A unit run headed to qualify or release owes the release tier: it runs here, before the compile, and qualify does not run it again.
|
|
226
|
+
if (stage === "check") { checkTier = stages.includes("qualify") ? "release" : "compile"; if (await check(checkTier) !== 0) throw new Error(`class A check (${checkTier} tier) failed`); }
|
|
227
|
+
else if (stage === "compile") {
|
|
228
|
+
owner = await loadOwner();
|
|
229
|
+
state = await stageEnv(stage, (runEnv) => owner.compileLocalUnit(unit.kind, argv, { env: runEnv }));
|
|
230
|
+
collectReceipts(stage, owner.localUnitCompileRequests?.(state));
|
|
231
|
+
} else if (stage === "package") {
|
|
232
|
+
artifact = await stageEnv(stage, (runEnv) => owner.packageLocalUnit(state, { env: runEnv }));
|
|
233
|
+
verifyArtifact(artifact);
|
|
234
|
+
collectReceipts(stage, artifact.brokerRequests);
|
|
235
|
+
} else if (stage === "qualify") {
|
|
236
|
+
if (checkTier !== "release" && await check("release") !== 0) throw new Error("release-tier check failed");
|
|
237
|
+
const qualified = await stageEnv(stage, (runEnv) => owner.qualifyLocalUnit(state, { env: runEnv }));
|
|
238
|
+
verifyArtifact(qualified);
|
|
239
|
+
if (!qualified.qualifiedOnly || !qualified.evidencePath || qualified.artifactSha256 !== artifact.artifactSha256) throw new Error("qualification did not bind the packaged artifact");
|
|
240
|
+
artifact = qualified;
|
|
241
|
+
}
|
|
242
|
+
let released = null;
|
|
243
|
+
if (stage === "release") {
|
|
244
|
+
// The qualification receipt the dispatcher carries to publish.yml: the hosted run is bound to these bytes and stages.
|
|
245
|
+
requireBrokerHost(executor); // a direct host has no broker receipts to carry: refuse for that reason, not for the missing receipts
|
|
246
|
+
const qualification = writeQualification({ report, artifact, unit, commit, tree, runtime, executor });
|
|
247
|
+
released = await stageEnv(stage, (runEnv) => (releaser ?? releaseUnit)({ unit, root, commit, executor, env: runEnv, artifact, qualification, platform, out }));
|
|
248
|
+
}
|
|
249
|
+
report.stages[stage] = { ok: true, ...(stage === "package" || stage === "qualify" ? { artifactFile: artifact.artifactFile, artifactSha256: artifact.artifactSha256 } : {}), ...(receipts[stage] ? { receipts: receipts[stage] } : {}), ...(released ?? {}) };
|
|
250
|
+
save();
|
|
251
|
+
out(`rightkit-gate: [${stage}] ${unit.kind}:${unit.name} passed`);
|
|
252
|
+
}
|
|
253
|
+
return 0;
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (currentStage) { report.stages[currentStage] = { ok: false }; save(); }
|
|
256
|
+
err(`rightkit-gate: unit ${selector} stopped: ${error.message}`);
|
|
257
|
+
return 1;
|
|
258
|
+
} finally { if (state && owner) await owner.closeLocalUnit(state); }
|
|
259
|
+
}
|
package/web-build.mjs
CHANGED
|
@@ -12,10 +12,17 @@
|
|
|
12
12
|
//
|
|
13
13
|
// A step is `{ cmd | node, args?, cwd?, env?, nodeFlags?, nodeOptions? }` (see command-spec.mjs). `--dry-run` prints the
|
|
14
14
|
// resolved commands and runs nothing. Exit code: 0, or the failing step's exit code.
|
|
15
|
+
//
|
|
16
|
+
// The door (gate-stages.mjs requireDoorRun) is checked before the config is imported: a .mjs config is code, so even a dry run
|
|
17
|
+
// runs only under a live door run. The exported runner checks it again, so no in-process caller spawns a step around it.
|
|
18
|
+
import path from "node:path";
|
|
15
19
|
import { pathToFileURL } from "node:url";
|
|
16
20
|
import { spawnSync } from "./background-process.mjs";
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { buildLaunch } from "./build-launch.mjs";
|
|
17
23
|
import { CommandSpecError, loadAppConfig, resolveInvocation, validateStep } from "./command-spec.mjs";
|
|
18
24
|
import { defaultReleaseConfigFile } from "./release-config.mjs";
|
|
25
|
+
import { requireDoorRun } from "./gate-stages.mjs";
|
|
19
26
|
|
|
20
27
|
export function parseWebBuildArgs(argv) {
|
|
21
28
|
const opts = { config: defaultReleaseConfigFile(), dryRun: false };
|
|
@@ -41,23 +48,48 @@ export function planWebBuild(config) {
|
|
|
41
48
|
return steps.map((step, index) => validateStep(step, `web.build[${index}]`));
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
51
|
+
/**
|
|
52
|
+
* The door refusal for a web build of the app at `appRoot`, or null under a live door run. Outside a git repository nothing can be
|
|
53
|
+
* bound to a commit: refused. `spawnSyncFn` runs git; `requireDoor` is gate-stages.mjs requireDoorRun.
|
|
54
|
+
*/
|
|
55
|
+
export function webBuildDoorRefusal({ appRoot, env = process.env, spawnSyncFn = spawnSync, requireDoor = requireDoorRun }) {
|
|
56
|
+
const gitOut = (a) => { const r = spawnSyncFn("git", a, { cwd: appRoot, encoding: "utf8" }); return r.status === 0 ? String(r.stdout).trim() : ""; };
|
|
57
|
+
const common = gitOut(["rev-parse", "--git-common-dir"]);
|
|
58
|
+
if (!common) return "right-release web-build runs only inside a git repository, under a door run";
|
|
59
|
+
return requireDoor({ env, commonDir: path.resolve(appRoot, common), commit: gitOut(["rev-parse", "HEAD"]), command: "right-release web-build" });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function doorError(refusal) { const error = new Error(refusal); error.code = "RK_DOOR"; return error; }
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Run the steps in order under a live door run; `spawnSyncFn`, `requireDoor` and `log` are injectable. Returns the exit code. The
|
|
66
|
+
* door is checked here as well as in main: this is the only place a step is spawned.
|
|
67
|
+
*/
|
|
68
|
+
export function runWebBuild(steps, { appRoot, env = process.env, dryRun = false, spawnSyncFn = spawnSync, requireDoor = requireDoorRun, log = console.log, door = buildLaunch } = {}) {
|
|
69
|
+
const refusal = webBuildDoorRefusal({ appRoot, env, spawnSyncFn, requireDoor });
|
|
70
|
+
if (refusal) throw doorError(refusal);
|
|
46
71
|
for (const step of steps) {
|
|
47
72
|
const invocation = resolveInvocation(step, { appRoot, baseEnv: env });
|
|
48
73
|
log(`${dryRun ? "[dry-run] " : ""}${[invocation.cmd, ...invocation.args].join(" ")}`);
|
|
49
74
|
if (dryRun) continue;
|
|
50
|
-
|
|
75
|
+
// A web build is a build: through the one door (build-launch.mjs), a broker job on a managed host.
|
|
76
|
+
const target = door(invocation.cmd, invocation.args, { env: invocation.env, cwd: invocation.cwd, exists: existsSync });
|
|
77
|
+
const result = spawnSyncFn(target.command, target.args, { cwd: invocation.cwd, env: invocation.env, stdio: "inherit" });
|
|
51
78
|
if (result.error) throw result.error;
|
|
52
79
|
if (result.status !== 0) return result.status ?? 1;
|
|
53
80
|
}
|
|
54
81
|
return 0;
|
|
55
82
|
}
|
|
56
83
|
|
|
57
|
-
export async function main(argv = process.argv.slice(2)) {
|
|
84
|
+
export async function main(argv = process.argv.slice(2), { env = process.env, requireDoor = requireDoorRun, spawnSyncFn = spawnSync, loadConfig = loadAppConfig, log = console.log } = {}) {
|
|
58
85
|
const opts = parseWebBuildArgs(argv);
|
|
59
|
-
|
|
60
|
-
|
|
86
|
+
// A web build is a compile: like right-release build, the direct entry runs only under a door run (rightkit-gate --to compile runs
|
|
87
|
+
// class A first). The door is checked before the config is imported, since a .mjs config is code; a dry run is no exception.
|
|
88
|
+
const appRoot = path.dirname(path.resolve(opts.config));
|
|
89
|
+
const refusal = webBuildDoorRefusal({ appRoot, env, spawnSyncFn, requireDoor });
|
|
90
|
+
if (refusal) throw doorError(refusal);
|
|
91
|
+
const { config } = await loadConfig(opts.config);
|
|
92
|
+
return runWebBuild(planWebBuild(config), { appRoot, env, dryRun: opts.dryRun, spawnSyncFn, requireDoor, log });
|
|
61
93
|
}
|
|
62
94
|
|
|
63
95
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|