@rightkit/release 0.6.8 → 0.6.11
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 +30 -12
- package/admission-files.mjs +4 -1
- package/admission.mjs +2 -2
- package/advisory-exceptions.mjs +41 -5
- package/android-play-upload.mjs +16 -15
- package/app-check.mjs +105 -27
- 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 +1608 -345
- package/broker-pins.mjs +152 -0
- package/build-launch.mjs +88 -0
- package/build-ownership.mjs +1231 -0
- package/build-release.mjs +82 -28
- package/cargo-contract.mjs +3 -1
- package/cargo-ledger.mjs +62 -0
- package/cargo-target-shim.mjs +238 -0
- package/class-a-launch-guard.mjs +126 -0
- package/cli/right-release.mjs +31 -5
- package/clippy-equivalence.mjs +202 -0
- package/clippy-route.mjs +543 -0
- package/codesign-shim.mjs +11 -1
- package/committed-source.mjs +39 -5
- package/crate-workspaces.mjs +87 -0
- package/create-mac-updater.mjs +14 -2
- package/deterministic-scans.mjs +997 -71
- package/development-signing.mjs +4 -1
- package/development.mjs +120 -21
- package/direct-receipt.mjs +135 -0
- package/door-policy.mjs +71 -6
- package/door.mjs +6 -6
- package/freshness-census.mjs +257 -0
- package/gate-executor.mjs +14 -2
- package/gate-stages.mjs +91 -15
- package/heavy-command.mjs +3 -2
- package/hook-retirement.mjs +65 -0
- package/init-command.mjs +1 -1
- package/install-app.mjs +11 -2
- package/install-assembled-app.mjs +423 -0
- package/inventory-check.mjs +11 -8
- package/ios-dev.mjs +70 -3
- package/ios-upload.mjs +20 -15
- package/linux-artifacts.mjs +93 -0
- package/local-publish.mjs +224 -53
- package/local-qualify.mjs +5 -3
- package/local-runtime.mjs +5 -4
- package/mac-package-recipe.mjs +4 -1
- package/managed-cargo.mjs +47 -5
- package/managed-root-guard.mjs +34 -0
- package/mirror-root-artifact.mjs +18 -5
- package/native-helpers.mjs +355 -0
- package/ownership-class-rules.mjs +1 -1
- package/ownership-evidence.mjs +37 -13
- package/ownership-hooks.mjs +20 -59
- package/ownership-lockfiles.mjs +14 -1
- package/ownership-policy-bundle.mjs +3 -2
- package/ownership-staged.mjs +2 -1
- package/ownership.mjs +24 -0
- package/package-recipe-targets.mjs +84 -10
- package/package.json +4 -3
- package/pipeline-admission.mjs +4 -4
- package/pipeline-receipts.mjs +302 -302
- package/preflight.mjs +27 -4
- package/project-type.mjs +2 -2
- package/provenance-verify.mjs +115 -0
- package/publish-cargo.mjs +12 -9
- package/qa-admission-policy.json +10 -1
- package/recipe-build.mjs +106 -14
- package/release-artifact-path.mjs +341 -0
- package/release-config.mjs +1 -1
- package/release-invocation.mjs +8 -1
- package/release-qa-command.mjs +25 -9
- package/release-source-brand.mjs +17 -0
- package/release-source-identity.mjs +171 -0
- package/release.mjs +59 -22
- package/rightkit-versions.json +73 -47
- package/runtime-budgets.mjs +6 -2
- package/scan-digest.mjs +71 -5
- package/sign-updater.mjs +6 -3
- package/snapshot-broker.mjs +172 -0
- package/snapshot-check.mjs +312 -0
- package/snapshot-git.mjs +166 -0
- package/snapshot-request.mjs +363 -0
- package/snapshot-root-config.mjs +76 -0
- package/snapshot.mjs +969 -0
- package/source-split.mjs +73 -0
- package/stage-commands.mjs +60 -0
- package/stage-ledger.mjs +204 -0
- package/stage-sidecar-command.mjs +5 -1
- package/stage-sidecar.mjs +157 -27
- package/swap-helper-bundle.mjs +5 -2
- package/test-command.mjs +17 -4
- package/unit-gate.mjs +301 -0
- package/web-build.mjs +40 -6
- package/win-package-recipe.mjs +20 -7
package/source-split.mjs
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The right-release source split (docs/GATE-CONTRACT.md "Commit-only build"; item 7 rows 7.03 to 7.05).
|
|
2
|
+
//
|
|
3
|
+
// Under the commit-only build the gate selects a commit, syncs it into a gate-owned snapshot and runs every stage from there. A
|
|
4
|
+
// right-release child the door starts for a stage (dev, test, stage-sidecar) therefore runs with its cwd inside the SNAPSHOT: its
|
|
5
|
+
// sources, its config and every command it spawns resolve there. What stays with the CHECKOUT is the git context the door run was
|
|
6
|
+
// made in (the run file, the primary-checkout rule, the commit's object database) and every output or cache right-release owns
|
|
7
|
+
// (`.right-release/...`).
|
|
8
|
+
//
|
|
9
|
+
// The split is never taken from a flag or from the child's own idea of the repository. The door writes it into its live run file
|
|
10
|
+
// (gate-stages.mjs startRun `source`) and names the checkout in RK_GATE_CHECKOUT; this module re-reads the run file, requires the
|
|
11
|
+
// run alive, and requires the process to sit inside the snapshot that run names. Anything inconsistent throws (fail closed): a
|
|
12
|
+
// child told it is split that cannot prove it never builds from where it stands.
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { spawnSync } from "./background-process.mjs";
|
|
16
|
+
import { CHECKOUT_ENV, readDoorRun } from "./gate-stages.mjs";
|
|
17
|
+
|
|
18
|
+
export { CHECKOUT_ENV };
|
|
19
|
+
export const SOURCE_SPLIT_CODE = "RK_SOURCE_SPLIT";
|
|
20
|
+
export const SOURCE_SPLIT_UNSUPPORTED = "RK_SOURCE_SPLIT_UNSUPPORTED";
|
|
21
|
+
|
|
22
|
+
export class SourceSplitError extends Error {
|
|
23
|
+
constructor(message, code = SOURCE_SPLIT_CODE) { super(message); this.name = "SourceSplitError"; this.code = code; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const real = (p) => { try { return fs.realpathSync.native(p); } catch { return path.resolve(p); } };
|
|
27
|
+
const fold = (p) => (process.platform === "win32" || process.platform === "darwin" ? p.toLowerCase() : p);
|
|
28
|
+
const contains = (parent, child) => { const rel = path.relative(fold(real(parent)), fold(real(child))); return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The split this process runs under, or null when RK_GATE_CHECKOUT is not set (the checkout route: hosted CI, or no door).
|
|
32
|
+
* Throws SourceSplitError when it is set and anything about it cannot be proven. `run` runs git (tests inject a double).
|
|
33
|
+
*/
|
|
34
|
+
export function readSourceSplit({ env = process.env, cwd = process.cwd(), run = spawnSync, readFile, alive } = {}) {
|
|
35
|
+
const named = env[CHECKOUT_ENV];
|
|
36
|
+
if (named === undefined || named === "") return null;
|
|
37
|
+
if (!path.isAbsolute(named)) throw new SourceSplitError(`${CHECKOUT_ENV} must be an absolute path (got ${JSON.stringify(named)})`);
|
|
38
|
+
const common = run("git", ["rev-parse", "--git-common-dir"], { cwd: named, encoding: "utf8", windowsHide: true });
|
|
39
|
+
if (common.status !== 0 || !String(common.stdout).trim()) throw new SourceSplitError(`${CHECKOUT_ENV} ${named} is not a git checkout`);
|
|
40
|
+
const commonDir = path.resolve(named, String(common.stdout).trim());
|
|
41
|
+
const found = readDoorRun({ env, commonDir, ...(readFile ? { readFile } : {}), ...(alive ? { alive } : {}) });
|
|
42
|
+
if (found.problem) throw new SourceSplitError(`no live door run ${found.problem === "ended" ? "(it has ended)" : "(RK_GATE_RUN names none)"} under ${named}; a source split exists only inside one`);
|
|
43
|
+
const source = found.run.source;
|
|
44
|
+
if (!source || typeof source !== "object") throw new SourceSplitError("the live door run carries no source split");
|
|
45
|
+
for (const key of ["checkout", "snapshot"]) if (typeof source[key] !== "string" || !path.isAbsolute(source[key])) throw new SourceSplitError(`the door run's source.${key} is not an absolute path`);
|
|
46
|
+
for (const key of ["commit", "tree"]) if (!/^[0-9a-f]{40}([0-9a-f]{24})?$/.test(source[key] ?? "")) throw new SourceSplitError(`the door run's source.${key} is not an object id`);
|
|
47
|
+
if (typeof source.appDir !== "string" || source.appDir.startsWith("/") || source.appDir.split("/").includes("..") || source.appDir.includes("\\")) throw new SourceSplitError("the door run's source.appDir is not a contained relative path");
|
|
48
|
+
if (real(source.checkout) !== real(named)) throw new SourceSplitError(`the door run was made in ${source.checkout}, not in ${named}`);
|
|
49
|
+
if (found.run.commit !== source.commit) throw new SourceSplitError("the door run's commit and its source split's commit differ");
|
|
50
|
+
const appRoot = source.appDir === "." ? source.snapshot : path.join(source.snapshot, ...source.appDir.split("/"));
|
|
51
|
+
if (!contains(source.snapshot, cwd)) throw new SourceSplitError(`this process runs in ${cwd}, outside the snapshot ${source.snapshot} its door run names; a split child runs only inside the snapshot`);
|
|
52
|
+
return Object.freeze({
|
|
53
|
+
checkout: source.checkout, snapshot: source.snapshot, commit: source.commit, tree: source.tree, appDir: source.appDir, commonDir,
|
|
54
|
+
appRoot, checkoutAppRoot: source.appDir === "." ? source.checkout : path.join(source.checkout, ...source.appDir.split("/")),
|
|
55
|
+
stage: found.run.stage,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether `file` lies inside the split's snapshot. */
|
|
60
|
+
export const inSnapshot = (split, file) => contains(split.snapshot, file);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The git context a door-checked step needs: `{ commonDir, commit }` of the live run for a split process, else of the git
|
|
64
|
+
* repository at `cwd` (HEAD), else null. door.mjs, web-build and the right-release CLI ask the same question, so they share it.
|
|
65
|
+
*/
|
|
66
|
+
export function doorContextFor({ cwd = process.cwd(), env = process.env, run = spawnSync } = {}) {
|
|
67
|
+
const split = readSourceSplit({ env, cwd, run });
|
|
68
|
+
if (split) return { commonDir: split.commonDir, commit: split.commit, split };
|
|
69
|
+
const git = (args) => { const r = run("git", args, { cwd, encoding: "utf8" }); return r.status === 0 ? String(r.stdout).trim() : ""; };
|
|
70
|
+
const common = git(["rev-parse", "--git-common-dir"]);
|
|
71
|
+
if (!common) return null;
|
|
72
|
+
return { commonDir: path.resolve(cwd, common), commit: git(["rev-parse", "HEAD"]), split: null };
|
|
73
|
+
}
|
|
@@ -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_METADATA_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(SWAP_HELPER_METADATA_ARGS), 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-ledger.mjs
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// The stage-output ledger (docs/GATE-CONTRACT.md "Commit-only build"; item 7 decision 2, question 1, rows 7.05/7.05b/7.07/7.08).
|
|
2
|
+
//
|
|
3
|
+
// A real build writes into the directory it builds from: Tauri's `gen/schemas`, staged sidecars, a frontend `dist`. The snapshot is
|
|
4
|
+
// exactly the commit's tree (plus the gate's installed dependencies) at the start of every stage; what a stage's own children write
|
|
5
|
+
// during it may remain at the stage's end only as LEDGER ENTRIES, each of which must be:
|
|
6
|
+
// 1. not tracked (not in `ls-tree <commit>`) and ignored by the commit's own ignore rules, judged with an empty bare GIT_DIR and a null
|
|
7
|
+
// core.excludesFile, so neither the checkout's `info/exclude` nor a global excludes file ever widens what is ignored;
|
|
8
|
+
// 2. a regular file (a directory only as the parent of one), never a link, and with exactly one hard link;
|
|
9
|
+
// 3. never under a reserved path: a `target` segment, `.right-release`, `.git`, or a `node_modules` outside the gate's own dependency roots;
|
|
10
|
+
// 4. recorded by THIS walk, never by a child: the ledger lives in the door's memory and in the door receipt under the checkout's git
|
|
11
|
+
// common directory, never inside the snapshot (GC:162).
|
|
12
|
+
// This module only walks, classifies and compares. The exact-snapshot verify is snapshot.mjs verifySnapshot, which takes the ledger.
|
|
13
|
+
import crypto from "node:crypto";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { spawnSync } from "./background-process.mjs";
|
|
18
|
+
|
|
19
|
+
export const LEDGER_RESERVED_SEGMENTS = Object.freeze(["target", ".right-release", ".git", "node_modules"]);
|
|
20
|
+
export const NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null";
|
|
21
|
+
|
|
22
|
+
const fold = (platform = process.platform) => (platform === "darwin" || platform === "win32" ? (s) => s.toLowerCase() : (s) => s);
|
|
23
|
+
|
|
24
|
+
/** The comparable form of an entry: what the next stage's start verify must find unchanged. */
|
|
25
|
+
export const entryKey = (e) => `${e.path}\0${e.type}\0${e.size}\0${e.sha256}\0${e.execBit ? 1 : 0}`;
|
|
26
|
+
|
|
27
|
+
/** A clean env for the git calls below: nothing of the caller's GIT_* and no global or system configuration. */
|
|
28
|
+
function gitEnv(env = process.env) {
|
|
29
|
+
const clean = Object.fromEntries(Object.entries(env).filter(([k]) => !/^GIT_/i.test(k)));
|
|
30
|
+
return { ...clean, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: NULL_DEVICE, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Which of `paths` (posix, relative to `dir`) the commit's own ignore rules ignore: `git check-ignore --no-index` run with an empty
|
|
35
|
+
* bare GIT_DIR, the snapshot as the work tree and a null core.excludesFile, so only the snapshot's own `.gitignore` files apply.
|
|
36
|
+
* Throws when git cannot answer (the caller fails closed).
|
|
37
|
+
*/
|
|
38
|
+
export function neutralIgnored({ dir, paths, run = spawnSync, env = process.env }) {
|
|
39
|
+
if (!paths.length) return new Set();
|
|
40
|
+
const bare = fs.mkdtempSync(path.join(os.tmpdir(), "rk-ignore-oracle-"));
|
|
41
|
+
try {
|
|
42
|
+
const made = run("git", ["init", "--bare", "-q", "--template=", bare], { encoding: "utf8", env: gitEnv(env), windowsHide: true });
|
|
43
|
+
if (made.error || made.status !== 0) throw new Error(`cannot create the empty git directory for the ignore check: ${made.error?.message ?? String(made.stderr).trim()}`);
|
|
44
|
+
const r = run("git", [`--git-dir=${bare}`, `--work-tree=${dir}`, "-c", `core.excludesFile=${NULL_DEVICE}`, "check-ignore", "--no-index", "-z", "--stdin"], {
|
|
45
|
+
cwd: dir, input: `${paths.join("\0")}\0`, encoding: "utf8", env: gitEnv(env), windowsHide: true, maxBuffer: 1 << 28,
|
|
46
|
+
});
|
|
47
|
+
// 0: some path is ignored; 1: none is; anything else is git failing.
|
|
48
|
+
if (r.error || (r.status !== 0 && r.status !== 1)) throw new Error(`git check-ignore failed (${r.error?.message ?? `exit ${r.status}: ${String(r.stderr).trim()}`})`);
|
|
49
|
+
return new Set(String(r.stdout ?? "").split("\0").filter(Boolean));
|
|
50
|
+
} finally { fs.rmSync(bare, { recursive: true, force: true }); }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Walks `dir` and returns what it holds beyond the commit: `{ entries, problems }`. `tracked` is the set of every path in
|
|
55
|
+
* `ls-tree <commit>` (posix); `dependencyRoots` the `node_modules` directories the gate installed under 5.09a (posix, relative; their
|
|
56
|
+
* bytes are bound by the class A environment digest, not here). `entries` are admissible ledger entries; `problems` name every write
|
|
57
|
+
* that cannot be one (not ignored, a link, a hard link, reserved, an empty directory, a `.git` holding more than the gate's config).
|
|
58
|
+
*/
|
|
59
|
+
export function walkSnapshot({ dir, tracked, dependencyRoots = new Set(), run = spawnSync, env = process.env, platform = process.platform }) {
|
|
60
|
+
const f = fold(platform);
|
|
61
|
+
const trackedFolded = new Set([...tracked].map(f));
|
|
62
|
+
const trackedDirs = new Set();
|
|
63
|
+
for (const p of tracked) { const parts = f(p).split("/"); for (let i = 1; i < parts.length; i++) trackedDirs.add(parts.slice(0, i).join("/")); }
|
|
64
|
+
const deps = new Set([...dependencyRoots].map(f));
|
|
65
|
+
const candidates = []; const problems = [];
|
|
66
|
+
const reserved = (rel) => rel.split("/").find((seg) => LEDGER_RESERVED_SEGMENTS.includes(f(seg)));
|
|
67
|
+
const walk = (abs, rel) => {
|
|
68
|
+
let names;
|
|
69
|
+
try { names = fs.readdirSync(abs, { withFileTypes: true }); } catch (e) { problems.push(`${rel || "."} cannot be read: ${e.message}`); return; }
|
|
70
|
+
names.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
71
|
+
let any = false;
|
|
72
|
+
for (const d of names) {
|
|
73
|
+
const r = rel ? `${rel}/${d.name}` : d.name;
|
|
74
|
+
const key = f(r);
|
|
75
|
+
const p = path.join(abs, d.name);
|
|
76
|
+
if (deps.has(key)) continue;
|
|
77
|
+
any = true;
|
|
78
|
+
if (key === ".git" && rel === "") {
|
|
79
|
+
// 5.09b: only the gate's config-only origin file; its exact bytes are item 5's to verify.
|
|
80
|
+
let inner = [];
|
|
81
|
+
try { inner = fs.readdirSync(p); } catch { /* reported below */ }
|
|
82
|
+
if (inner.length !== 1 || inner[0] !== "config") problems.push(`${r} holds more than the gate's config-only file (.git is reserved)`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
let st;
|
|
86
|
+
try { st = fs.lstatSync(p); } catch (e) { problems.push(`${r} cannot be read: ${e.message}`); continue; }
|
|
87
|
+
if (st.isSymbolicLink()) { if (!trackedFolded.has(key)) problems.push(`${r} is a link the commit does not have (a stage output is never a link)`); continue; }
|
|
88
|
+
if (st.isDirectory()) {
|
|
89
|
+
if (trackedDirs.has(key)) { walk(p, r); continue; }
|
|
90
|
+
const bad = reserved(r);
|
|
91
|
+
if (bad) { problems.push(`${r} is under the reserved path segment ${JSON.stringify(bad)} (a stage output is never written there)`); continue; }
|
|
92
|
+
const before = candidates.length + problems.length;
|
|
93
|
+
walk(p, r);
|
|
94
|
+
if (candidates.length + problems.length === before) problems.push(`${r} is an empty directory the commit does not have`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!st.isFile()) { problems.push(`${r} is neither a regular file nor a directory`); continue; }
|
|
98
|
+
if (trackedFolded.has(key)) continue; // a tracked file: its bytes are verifySnapshot's
|
|
99
|
+
const bad = reserved(r);
|
|
100
|
+
if (bad) { problems.push(`${r} is under the reserved path segment ${JSON.stringify(bad)} (a stage output is never written there)`); continue; }
|
|
101
|
+
if (st.nlink > 1) { problems.push(`${r} has ${st.nlink} hard links (a stage output has exactly one)`); continue; }
|
|
102
|
+
candidates.push({ rel: r, abs: p, size: st.size, execBit: platform !== "win32" && (st.mode & 0o111) !== 0 });
|
|
103
|
+
}
|
|
104
|
+
return any;
|
|
105
|
+
};
|
|
106
|
+
walk(dir, "");
|
|
107
|
+
let ignored;
|
|
108
|
+
try { ignored = neutralIgnored({ dir, paths: candidates.map((c) => c.rel), run, env }); }
|
|
109
|
+
catch (e) { return { entries: [], problems: [...problems, `the ignore rules of the commit could not be evaluated: ${e.message}`] }; }
|
|
110
|
+
const ignoredFolded = new Set([...ignored].map(f));
|
|
111
|
+
const entries = [];
|
|
112
|
+
for (const c of candidates) {
|
|
113
|
+
// A split package stage writes a declared root output (a DMG, an updater tarball) into the snapshot as a generated entry (row 7.04c R-T2 rule 5): it must be
|
|
114
|
+
// ignored by the commit itself, so the message names the declared fields such an output comes from and the fix.
|
|
115
|
+
if (!ignoredFolded.has(f(c.rel))) { problems.push(`${c.rel} is not ignored by the commit's own ignore rules (the checkout's info/exclude and global excludes never count); if the release config declares it (updater.artifacts[].file, installer.artifacts[].file, notarize.file, artifacts[] or a postSign output), add ${c.rel} to the commit's .gitignore`); continue; }
|
|
116
|
+
let bytes;
|
|
117
|
+
try { bytes = fs.readFileSync(c.abs); } catch (e) { problems.push(`${c.rel} cannot be read: ${e.message}`); continue; }
|
|
118
|
+
entries.push({ path: c.rel, type: "file", size: bytes.length, sha256: crypto.createHash("sha256").update(bytes).digest("hex"), execBit: c.execBit });
|
|
119
|
+
}
|
|
120
|
+
entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
121
|
+
return { entries, problems };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** What changed between two entry lists: `{ added, changed, removed }` as paths. */
|
|
125
|
+
export function diffEntries(before, after) {
|
|
126
|
+
const b = new Map(before.map((e) => [e.path, e])); const a = new Map(after.map((e) => [e.path, e]));
|
|
127
|
+
return {
|
|
128
|
+
added: [...a.keys()].filter((p) => !b.has(p)),
|
|
129
|
+
changed: [...a.keys()].filter((p) => b.has(p) && entryKey(b.get(p)) !== entryKey(a.get(p))),
|
|
130
|
+
removed: [...b.keys()].filter((p) => !a.has(p)),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const sameEntries = (x, y) => { const d = diffEntries(x, y); return !d.added.length && !d.changed.length && !d.removed.length; };
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The attribution diff of a staging step: every path the step added must be one it reported (or its receipt), and every path it
|
|
138
|
+
* reported must exist. Anything else is an unattributed write. `reported` and `receipt` are snapshot-relative posix paths.
|
|
139
|
+
*/
|
|
140
|
+
export function attributionProblems({ before, after, reported, receipt }) {
|
|
141
|
+
const d = diffEntries(before, after);
|
|
142
|
+
const allowed = new Set([...reported, ...(receipt ? [receipt] : [])]);
|
|
143
|
+
const problems = [];
|
|
144
|
+
for (const p of [...d.added, ...d.changed]) if (!allowed.has(p)) problems.push(`${p} was written by the staging step but it did not report it`);
|
|
145
|
+
for (const p of d.removed) problems.push(`${p} was removed by the staging step`);
|
|
146
|
+
const present = new Set(after.map((e) => e.path));
|
|
147
|
+
for (const p of allowed) if (!present.has(p)) problems.push(`${p} was reported by the staging step but is not in the snapshot as a ledger entry`);
|
|
148
|
+
return problems;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Every `path` string a staging receipt or its `--json` result carries (files, their notices), app-relative. */
|
|
152
|
+
export function reportedPaths(result) {
|
|
153
|
+
const out = new Set();
|
|
154
|
+
const visit = (v) => {
|
|
155
|
+
if (Array.isArray(v)) { v.forEach(visit); return; }
|
|
156
|
+
if (v && typeof v === "object") { for (const [k, x] of Object.entries(v)) { if (k === "path" && typeof x === "string") out.add(x.replaceAll("\\", "/")); else visit(x); } }
|
|
157
|
+
};
|
|
158
|
+
visit(result?.files ?? []);
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The cross-check with `.rightkit/compile-inputs.json` (decision 2, Q1): for each declared input that covers ledger entries, the digest
|
|
164
|
+
* the compile receipt names must equal the gate's own digest of those bytes (compile-inputs.mjs judgeCompileInputs, the broker's
|
|
165
|
+
* algorithm). Returns problems; `judge` is judgeCompileInputs.
|
|
166
|
+
*/
|
|
167
|
+
export function declaredInputProblems({ snapshot, entries, receiptDigests, judge }) {
|
|
168
|
+
const judged = judge(snapshot);
|
|
169
|
+
const problems = judged.findings.map((x) => `declared compile input: ${x}`);
|
|
170
|
+
const named = new Map((receiptDigests ?? []).map((d) => [d.path, d.sha256]));
|
|
171
|
+
for (const input of judged.inputs) {
|
|
172
|
+
const covers = entries.some((e) => e.path === input.path || e.path.startsWith(`${input.path}/`));
|
|
173
|
+
if (!covers) continue;
|
|
174
|
+
if (!named.has(input.path)) problems.push(`declared compile input ${input.path} covers ledger entries but the receipt carries no digest for it`);
|
|
175
|
+
else if (named.get(input.path) !== input.sha256) problems.push(`declared compile input ${input.path}: the receipt's digest ${named.get(input.path)} differs from the gate's digest ${input.sha256} of the ledger bytes`);
|
|
176
|
+
}
|
|
177
|
+
return problems;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether the entries the snapshot verify admitted (snapshot.mjs verifySnapshot / snapshot-check.mjs verifySnapshotDir with a ledger,
|
|
182
|
+
* row 5.09d) agree with the entries this gate's own walk recorded. The verify's `admitted` lists FILES and the DIRECTORIES that parent them
|
|
183
|
+
* (`type: "dir"`); the walk records files only (a directory is implied by its files). They agree when the admitted files are exactly the
|
|
184
|
+
* walk's files (same path, size, digest and exec bit when the verify names them) and every admitted directory parents one of them.
|
|
185
|
+
* Returns a message naming the first disagreement, or null.
|
|
186
|
+
*/
|
|
187
|
+
export function admittedMismatch(admitted, entries) {
|
|
188
|
+
const files = admitted.filter((e) => (e.type ?? "file") !== "dir");
|
|
189
|
+
const dirs = admitted.filter((e) => e.type === "dir");
|
|
190
|
+
const mine = new Map(entries.map((e) => [e.path, e])); const theirs = new Map(files.map((e) => [e.path, e]));
|
|
191
|
+
for (const p of mine.keys()) if (!theirs.has(p)) return `${p} is in the door's walk but the verify did not admit it`;
|
|
192
|
+
for (const p of theirs.keys()) if (!mine.has(p)) return `${p} was admitted by the verify but the door's walk did not record it`;
|
|
193
|
+
for (const [p, e] of theirs) {
|
|
194
|
+
const w = mine.get(p);
|
|
195
|
+
if (e.sha256 !== undefined && (e.sha256 !== w.sha256 || e.size !== w.size || Boolean(e.execBit) !== Boolean(w.execBit))) return `${p} differs between the verify's entry and the door's walk`;
|
|
196
|
+
}
|
|
197
|
+
const parents = new Set();
|
|
198
|
+
for (const p of mine.keys()) { const parts = p.split("/"); for (let i = 1; i < parts.length; i++) parents.add(parts.slice(0, i).join("/")); }
|
|
199
|
+
for (const d of dirs) if (!parents.has(d.path)) return `the directory ${d.path} was admitted by the verify but parents no ledger entry`;
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The receipt form of the ledger: no absolute path, no bytes. */
|
|
204
|
+
export const receiptLedger = (entries) => entries.map((e) => ({ path: e.path, kind: e.kind, type: e.type, size: e.size, sha256: e.sha256, execBit: e.execBit, producer: e.producer }));
|
|
@@ -6,6 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
|
|
8
8
|
import { planSidecarStaging, resolveTargetTriple, stageSidecars } from "./stage-sidecar.mjs";
|
|
9
|
+
import { readSourceSplit } from "./source-split.mjs";
|
|
9
10
|
|
|
10
11
|
export function parseStageSidecarArgs(argv, cwd = process.cwd()) {
|
|
11
12
|
const opts = { config: defaultReleaseConfigFile(cwd), target: undefined, json: false, check: false };
|
|
@@ -50,6 +51,9 @@ async function main() {
|
|
|
50
51
|
const opts = parseStageSidecarArgs(process.argv.slice(2));
|
|
51
52
|
const configPath = path.resolve(opts.config);
|
|
52
53
|
const appRoot = path.dirname(configPath);
|
|
54
|
+
// Inside the door's source split the config and every declared source are the snapshot's; the caches and build trees right-release
|
|
55
|
+
// owns stay in the checkout's app directory (stage-sidecar.mjs `cacheRoot`).
|
|
56
|
+
const split = readSourceSplit({ cwd: appRoot });
|
|
53
57
|
const config = await importReleaseConfig(configPath, { lenient: true });
|
|
54
58
|
if (config?.sidecars === undefined) throw new Error(`${opts.config} declares no sidecars`);
|
|
55
59
|
if (opts.check) {
|
|
@@ -61,7 +65,7 @@ async function main() {
|
|
|
61
65
|
}
|
|
62
66
|
const target = resolveTargetTriple({ target: opts.target, cwd: appRoot });
|
|
63
67
|
// Warnings (a never-run row, a catalog row that is not staged) go to stderr; stdout stays the staged-file list or the --json document.
|
|
64
|
-
const result = await stageSidecars({ appRoot, manifest: config.sidecars, target, deps: { warn: (line) => console.error(line) } });
|
|
68
|
+
const result = await stageSidecars({ appRoot, manifest: config.sidecars, target, ...(split ? { cacheRoot: split.checkoutAppRoot } : {}), deps: { warn: (line) => console.error(line) } });
|
|
65
69
|
if (opts.json) console.log(JSON.stringify(result, null, 2));
|
|
66
70
|
else {
|
|
67
71
|
for (const file of result.files ?? []) console.log(`right-release stage-sidecar: staged ${typeof file === "string" ? file : file.path ?? JSON.stringify(file)}`);
|