@rightkit/release 0.6.10 → 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 +7 -5
- package/admission-files.mjs +3 -0
- package/admission.mjs +2 -2
- package/advisory-exceptions.mjs +41 -5
- package/android-play-upload.mjs +16 -15
- package/app-check.mjs +13 -9
- package/bin/rightkit-gate.mjs +762 -345
- package/broker-pins.mjs +152 -0
- package/build-release.mjs +80 -27
- package/cargo-ledger.mjs +62 -0
- package/cargo-target-shim.mjs +98 -26
- package/cli/right-release.mjs +28 -4
- package/clippy-equivalence.mjs +202 -0
- package/clippy-route.mjs +543 -0
- package/committed-source.mjs +39 -5
- package/crate-workspaces.mjs +87 -0
- package/create-mac-updater.mjs +14 -2
- package/deterministic-scans.mjs +196 -21
- package/development.mjs +13 -4
- package/direct-receipt.mjs +135 -0
- package/door-policy.mjs +10 -0
- package/door.mjs +6 -6
- package/freshness-census.mjs +257 -0
- package/gate-stages.mjs +76 -10
- package/hook-retirement.mjs +65 -0
- package/init-command.mjs +1 -1
- package/inventory-check.mjs +11 -8
- package/ios-upload.mjs +20 -15
- package/local-publish.mjs +77 -41
- package/local-qualify.mjs +5 -3
- package/local-runtime.mjs +5 -4
- package/mac-package-recipe.mjs +4 -1
- package/mirror-root-artifact.mjs +18 -5
- 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 +35 -6
- package/package.json +1 -1
- package/pipeline-admission.mjs +4 -4
- package/preflight.mjs +27 -4
- package/publish-cargo.mjs +4 -3
- package/recipe-build.mjs +38 -14
- package/release-artifact-path.mjs +341 -0
- 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 +44 -17
- package/rightkit-versions.json +62 -42
- 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 +12 -6
- package/snapshot-request.mjs +207 -5
- package/snapshot.mjs +123 -10
- package/source-split.mjs +73 -0
- package/stage-commands.mjs +2 -2
- package/stage-ledger.mjs +204 -0
- package/stage-sidecar-command.mjs +5 -1
- package/stage-sidecar.mjs +100 -20
- package/swap-helper-bundle.mjs +5 -2
- package/unit-gate.mjs +45 -3
- package/web-build.mjs +6 -4
- package/win-package-recipe.mjs +20 -7
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The direct (Linux) host's evidence for a snapshot compile (item 7 rows 7.09, 7.12, 7.18; GATE-CONTRACT "Broker interface"):
|
|
2
|
+
// - the output directory, resolved in the PRIMARY CHECKOUT with the stage's environment before the source location changes, with
|
|
3
|
+
// its inputs recorded (7.09);
|
|
4
|
+
// - the receipt the gate writes itself, "the same field names minus the broker-only ones (admission, queue, reusedFrom)" (7.18),
|
|
5
|
+
// and the consumer-side matrix that refuses a receipt that lacks one, carries a broker-only one, or holds no census (7.22 Linux half).
|
|
6
|
+
// Pure functions with injected spawn and file system: the gate (bin/rightkit-gate.mjs) wires them to the host.
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { validateFreshness } from "./freshness-census.mjs";
|
|
11
|
+
|
|
12
|
+
export const DIRECT_RECEIPT_SCHEMA = "rightkit.gate-direct-receipt/1";
|
|
13
|
+
export const DIAGNOSTICS_SCHEMA = "rightkit.gate-direct-diagnostics/1";
|
|
14
|
+
/** The names GATE-CONTRACT lists as broker-only: a direct receipt never carries them. */
|
|
15
|
+
export const BROKER_ONLY_FIELDS = Object.freeze(["admission", "queue", "reusedFrom"]);
|
|
16
|
+
/** The contract's returned-evidence names a direct receipt carries (7.18). `snapshot.path|treeAtStart|treeAtExit` are checked inside `snapshot`. */
|
|
17
|
+
export const DIRECT_RECEIPT_REQUIRED = Object.freeze(["requestId", "brokerVersion", "executor", "commit", "snapshot", "declaredInputDigests", "verdict", "freshness", "timingsMs", "exit", "diagnosticsPath", "outputDir", "refreshed"]);
|
|
18
|
+
export const TIMING_FIELDS = Object.freeze(["bind", "cargo", "afterExitBind", "diagnostics"]);
|
|
19
|
+
/** The environment a `cargo metadata` target resolution reads: recorded by value (the rest of the environment by digest only). */
|
|
20
|
+
export const TARGET_ENV_NAMES = Object.freeze(["CARGO_TARGET_DIR", "CARGO_BUILD_TARGET_DIR", "CARGO_HOME", "RUSTUP_TOOLCHAIN"]);
|
|
21
|
+
|
|
22
|
+
const failed = (r) => !r || Boolean(r.error) || r.status !== 0;
|
|
23
|
+
const why = (r) => String(r?.error?.message ?? r?.stderr ?? "").trim().split(/\r?\n/).slice(-3).join(" | ");
|
|
24
|
+
|
|
25
|
+
/** True when `child` is `parent` or below it (lexically, after resolving both). */
|
|
26
|
+
export function isWithin(parent, child) {
|
|
27
|
+
const rel = path.relative(path.resolve(parent), path.resolve(child));
|
|
28
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The stage environment a resolution ran with: the target-relevant names by value, the whole environment by digest. */
|
|
32
|
+
export function envInputs(env) {
|
|
33
|
+
const record = {};
|
|
34
|
+
for (const name of TARGET_ENV_NAMES) record[name] = typeof env?.[name] === "string" ? env[name] : null;
|
|
35
|
+
const all = Object.keys(env ?? {}).sort().map((k) => `${k}=${env[k]}`).join("\n");
|
|
36
|
+
return { ...record, digest: createHash("sha256").update(all).digest("hex") };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The absolute output directory Cargo would use for one workspace, resolved where the contract says: `cargo metadata --format-version 1
|
|
41
|
+
* --no-deps` run with its working directory in the PRIMARY CHECKOUT (never the snapshot) and with the stage's environment, so
|
|
42
|
+
* `CARGO_TARGET_DIR`, `.cargo/config.toml` `build.target-dir` (relative to the checkout's config, or absolute) and the default `target/`
|
|
43
|
+
* all resolve as they do for a developer's own build. Returns `{ dir, inputs }` (the inputs are recorded in the receipt) or throws an
|
|
44
|
+
* `RK_TARGET_RESOLUTION` error whose message names why: a resolution made in the snapshot, no absolute directory, or a directory that
|
|
45
|
+
* lies inside the snapshot (a second output directory).
|
|
46
|
+
*/
|
|
47
|
+
export function resolveTargetDirectory({ spawn, cargoCommand, manifest, env, checkoutRoot, snapshotRoot = null, workspace = "." }) {
|
|
48
|
+
const fail = (message) => Object.assign(new Error(message), { code: "RK_TARGET_RESOLUTION" });
|
|
49
|
+
const cwd = path.dirname(manifest);
|
|
50
|
+
if (!isWithin(checkoutRoot, cwd)) throw fail(`the output directory of workspace ${workspace} must be resolved in the primary checkout ${checkoutRoot}, not in ${cwd}`);
|
|
51
|
+
if (snapshotRoot && isWithin(snapshotRoot, cwd)) throw fail(`the output directory of workspace ${workspace} must be resolved before the source location changes, not in the snapshot ${snapshotRoot}`);
|
|
52
|
+
const args = [...cargoCommand.slice(1), "metadata", "--offline", "--format-version", "1", "--no-deps", "--manifest-path", manifest];
|
|
53
|
+
const r = spawn(cargoCommand[0], args, { cwd, env });
|
|
54
|
+
let dir = null;
|
|
55
|
+
try { dir = failed(r) ? null : JSON.parse(String(r.stdout ?? "")).target_directory; } catch { dir = null; }
|
|
56
|
+
if (typeof dir !== "string" || !path.isAbsolute(dir)) throw fail(`cargo metadata in the checkout reported no absolute target_directory for ${workspace}${failed(r) ? ` (${why(r)})` : ""}`);
|
|
57
|
+
const resolved = path.resolve(dir);
|
|
58
|
+
if (snapshotRoot && isWithin(snapshotRoot, resolved)) throw fail(`the output directory of workspace ${workspace} resolves to ${resolved}, inside the snapshot ${snapshotRoot}; a snapshot compile writes only to the checkout's directory, never a second one under the commit`);
|
|
59
|
+
return { dir: resolved, inputs: { cwd, manifest, command: [cargoCommand[0], ...args], env: envInputs(env), targetDirectory: resolved } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The Class A and B result the receipt's `verdict` carries (a direct compile reuses nothing, so no `reusedFrom`). */
|
|
63
|
+
export function directVerdict({ scans = [], failures = [], deferrals = [] } = {}) {
|
|
64
|
+
const byClass = (c) => { const rows = scans.filter((s) => s.class === c); return { scans: rows.length, failed: rows.filter((s) => s.status === "fail").length }; };
|
|
65
|
+
return { result: failures.length ? "FAILED" : "PASSED", classA: byClass("A"), classB: byClass("B"), deferred: deferrals.length };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Writes the structured diagnostics of one direct compile and returns the file's absolute path (the receipt's `diagnosticsPath`). */
|
|
69
|
+
export function writeDirectDiagnostics({ dir, requestId, commit, exit, scans = [], failures = [], deferrals = [], notes = [], freshness = null, writeFile = fs.writeFileSync, mkdir = fs.mkdirSync }) {
|
|
70
|
+
mkdir(dir, { recursive: true });
|
|
71
|
+
const file = path.resolve(dir, `direct-diagnostics-${requestId}.json`);
|
|
72
|
+
const rows = scans.map((s) => ({ id: s.id, class: s.class ?? null, status: s.status, ...(s.code ? { code: s.code } : {}), ...(s.detail ? { detail: String(s.detail).slice(0, 1000) } : {}) }));
|
|
73
|
+
writeFile(file, `${JSON.stringify({ schema: DIAGNOSTICS_SCHEMA, requestId, commit, exit, scans: rows, failures, deferrals, notes, freshness }, null, 2)}\n`);
|
|
74
|
+
return file;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The receipt of one direct compile: the contract's returned-evidence names, no broker-only one. Throws nothing; `verifyDirectReceipt` judges it. */
|
|
78
|
+
export function buildDirectReceipt({ requestId, issuedAt, commit, snapshotPath, treeAtStart, treeAtExit, ownershipHandle = null, declaredInputDigests = [], verdict, freshness, timingsMs, exit, diagnosticsPath, outputDirs = {}, outputResolution = {}, cargoWrapper = null }) {
|
|
79
|
+
const dirs = [...new Set(Object.values(outputDirs))];
|
|
80
|
+
return {
|
|
81
|
+
schema: DIRECT_RECEIPT_SCHEMA, requestId, brokerVersion: null, executor: "direct", issuedAt,
|
|
82
|
+
commit: { sha: commit.sha, tree: commit.tree },
|
|
83
|
+
snapshot: { path: snapshotPath, treeAtStart, treeAtExit },
|
|
84
|
+
declaredInputDigests, ownership: { handle: ownershipHandle },
|
|
85
|
+
verdict, freshness, timingsMs, exit, exitStatus: exit, diagnosticsPath,
|
|
86
|
+
outputDir: dirs.length === 1 ? dirs[0] : null, outputDirs: { ...outputDirs }, outputResolution,
|
|
87
|
+
refreshed: [], cargoWrapper,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The consumer's matrix over a direct receipt (the Linux half of row 7.22): every contract name present with the right type, the
|
|
93
|
+
* snapshot bound to the commit's tree at start and exit, a well formed freshness census, and none of the broker-only names.
|
|
94
|
+
* Returns the list of problems; an empty list means the receipt verifies.
|
|
95
|
+
*/
|
|
96
|
+
export function verifyDirectReceipt(receipt) {
|
|
97
|
+
const problems = [];
|
|
98
|
+
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) return ["the receipt is not an object"];
|
|
99
|
+
if (receipt.schema !== DIRECT_RECEIPT_SCHEMA) problems.push(`schema is ${JSON.stringify(receipt.schema)}, not ${DIRECT_RECEIPT_SCHEMA}`);
|
|
100
|
+
for (const name of DIRECT_RECEIPT_REQUIRED) if (!Object.hasOwn(receipt, name)) problems.push(`${name} is absent`);
|
|
101
|
+
for (const name of BROKER_ONLY_FIELDS) if (Object.hasOwn(receipt, name)) problems.push(`${name} is a broker-only field; a direct receipt never carries it`);
|
|
102
|
+
const text = (v) => typeof v === "string" && v !== "";
|
|
103
|
+
if (Object.hasOwn(receipt, "requestId") && !text(receipt.requestId)) problems.push("requestId is not a string");
|
|
104
|
+
if (Object.hasOwn(receipt, "brokerVersion") && receipt.brokerVersion !== null) problems.push("brokerVersion is not null on a direct host");
|
|
105
|
+
if (Object.hasOwn(receipt, "executor") && receipt.executor !== "direct") problems.push(`executor is ${JSON.stringify(receipt.executor)}, not "direct"`);
|
|
106
|
+
const commit = receipt.commit;
|
|
107
|
+
if (Object.hasOwn(receipt, "commit") && !(commit && text(commit.sha) && text(commit.tree))) problems.push("commit has no sha and tree");
|
|
108
|
+
const snap = receipt.snapshot;
|
|
109
|
+
if (Object.hasOwn(receipt, "snapshot")) {
|
|
110
|
+
if (!(snap && text(snap.path) && text(snap.treeAtStart) && text(snap.treeAtExit))) problems.push("snapshot has no path, treeAtStart and treeAtExit");
|
|
111
|
+
else if (commit && text(commit.tree) && (snap.treeAtStart !== commit.tree || snap.treeAtExit !== commit.tree)) problems.push("snapshot.treeAtStart and treeAtExit are not the commit's tree");
|
|
112
|
+
}
|
|
113
|
+
if (Object.hasOwn(receipt, "declaredInputDigests") && !Array.isArray(receipt.declaredInputDigests)) problems.push("declaredInputDigests is not a list");
|
|
114
|
+
const verdict = receipt.verdict;
|
|
115
|
+
if (Object.hasOwn(receipt, "verdict") && !(verdict && (verdict.result === "PASSED" || verdict.result === "FAILED"))) problems.push("verdict has no PASSED or FAILED result");
|
|
116
|
+
if (Object.hasOwn(receipt, "freshness")) for (const p of validateFreshness(receipt.freshness)) problems.push(p);
|
|
117
|
+
const timings = receipt.timingsMs;
|
|
118
|
+
if (Object.hasOwn(receipt, "timingsMs")) {
|
|
119
|
+
if (!timings || typeof timings !== "object") problems.push("timingsMs is not an object");
|
|
120
|
+
else {
|
|
121
|
+
for (const name of TIMING_FIELDS) if (!(Number.isFinite(timings[name]) && timings[name] >= 0)) problems.push(`timingsMs.${name} is not a duration`);
|
|
122
|
+
for (const name of BROKER_ONLY_FIELDS) if (Object.hasOwn(timings, name)) problems.push(`timingsMs.${name} is a broker-only field`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (Object.hasOwn(receipt, "exit") && !Number.isInteger(receipt.exit)) problems.push("exit is not an exit status");
|
|
126
|
+
if (Object.hasOwn(receipt, "diagnosticsPath") && !(text(receipt.diagnosticsPath) && path.isAbsolute(receipt.diagnosticsPath))) problems.push("diagnosticsPath is not an absolute path");
|
|
127
|
+
if (Object.hasOwn(receipt, "outputDir")) {
|
|
128
|
+
const dirs = receipt.outputDirs && typeof receipt.outputDirs === "object" ? Object.values(receipt.outputDirs) : [];
|
|
129
|
+
if (text(receipt.outputDir)) { if (!path.isAbsolute(receipt.outputDir)) problems.push("outputDir is not absolute"); }
|
|
130
|
+
else if (!(receipt.outputDir === null && new Set(dirs).size !== 1)) problems.push("outputDir is not the resolved absolute path (null only when the commit has no Cargo workspace, or several workspaces name several directories)");
|
|
131
|
+
for (const d of dirs) if (!(text(d) && path.isAbsolute(d))) problems.push(`outputDirs holds ${JSON.stringify(d)}, not an absolute path`);
|
|
132
|
+
}
|
|
133
|
+
if (Object.hasOwn(receipt, "refreshed") && !Array.isArray(receipt.refreshed)) problems.push("refreshed is not a list");
|
|
134
|
+
return problems;
|
|
135
|
+
}
|
package/door-policy.mjs
CHANGED
|
@@ -144,6 +144,10 @@ function argvViolation(argv, depth = 0, stdin = null) {
|
|
|
144
144
|
else if (script) return null;
|
|
145
145
|
else { a = rest; tool = base(a[0]); }
|
|
146
146
|
}
|
|
147
|
+
// `rightkit-gate clippy [<cargo clippy args>]` is the agent entry for Clippy (GATE-CONTRACT §1 "Clippy route & batching", item 8 row
|
|
148
|
+
// 8.14): like every `rightkit-gate` subcommand it is the door, taken through the pinned gate (build-control-policy agentDoorViolation
|
|
149
|
+
// denies an unpinned copy as untrusted-gate) and run in the same order for a human and an agent. Direct `cargo clippy` and
|
|
150
|
+
// `cargo +<toolchain> clippy` stay denied by CARGO_COMPILES above, with the route named in the denial.
|
|
147
151
|
if (tool === "rightkit-gate" || tool === "rustright") return null;
|
|
148
152
|
if (tool === "tauri" && TAURI_BUILDS.has(a[1])) return `tauri ${a[1]}`;
|
|
149
153
|
// Apple: xcrun wraps the tool it names; xcodebuild builds by default (no action word is `build`); swift build|test|run and swiftc compile.
|
|
@@ -262,7 +266,13 @@ export function doorViolation(command, depth = 0) {
|
|
|
262
266
|
return null;
|
|
263
267
|
}
|
|
264
268
|
|
|
269
|
+
/** The agent entry for Clippy; a direct `cargo clippy` is denied in its favour. */
|
|
270
|
+
export const CLIPPY_ROUTE = "rightkit-gate clippy [<cargo clippy args>]";
|
|
271
|
+
|
|
265
272
|
export function doorDenial(violation) {
|
|
273
|
+
if (violation === "cargo clippy") {
|
|
274
|
+
return `[HOOK:one-door:block]\nWhy: \`${violation}\` builds outside the one door. Every check, compile, test, package and release goes through rightkit-gate, class A first.\nRequired: ${CLIPPY_ROUTE} (Clippy on HEAD's commit, class A first).`;
|
|
275
|
+
}
|
|
266
276
|
const mobile = /^(cargo )?tauri (android|ios)$|^xcodebuild (archive|install)|^gradle (assemble|bundle|build|publish|install)/.test(violation) ? (/ios|xcodebuild/.test(violation) ? "ios" : "android") : null;
|
|
267
277
|
const stage = /^right-release (dev|test)$/.test(violation) ? violation.slice("right-release ".length) : /^(cargo )?tauri dev$/.test(violation) ? "dev"
|
|
268
278
|
: /^right-release (upload|publish|publish-update|github)$/.test(violation) ? "release" : /^right-release qa$/.test(violation) ? "qualify"
|
package/door.mjs
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
// packager) that right-release runs inside a stage. The step proceeds only when a live `rightkit-gate --to` run for HEAD launched it,
|
|
3
3
|
// the same check right-release makes for itself (gate-stages.mjs requireDoorRun), so `node scripts/release/<step>.mjs` run by hand is
|
|
4
4
|
// refused instead of becoming a side door around class A.
|
|
5
|
-
import path from "node:path";
|
|
6
5
|
import { spawnSync } from "./background-process.mjs";
|
|
7
6
|
import { requireDoorRun } from "./gate-stages.mjs";
|
|
7
|
+
import { doorContextFor } from "./source-split.mjs";
|
|
8
8
|
|
|
9
|
-
/** null when a live door run for HEAD launched this process, else the refusal message. */
|
|
9
|
+
/** null when a live door run for HEAD (or, inside a source split, for the split's commit) launched this process, else the refusal message. */
|
|
10
10
|
export function doorRefusal({ cwd = process.cwd(), env = process.env, command = "this build step", run = spawnSync, alive } = {}) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (!
|
|
14
|
-
return requireDoorRun({ env, commonDir:
|
|
11
|
+
let context;
|
|
12
|
+
try { context = doorContextFor({ cwd, env, run }); } catch (error) { return `${command}: ${error.code ?? "RK_SOURCE_SPLIT"}: ${error.message}`; }
|
|
13
|
+
if (!context) return `${command} runs only in a git checkout, through the one door: rightkit-gate --to <stage>`;
|
|
14
|
+
return requireDoorRun({ env, commonDir: context.commonDir, commit: context.commit, command, ...(alive ? { alive } : {}) });
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/** Exits 2 with the refusal unless a live door run launched this process. */
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// The freshness census of a direct (Linux) snapshot compile (item 7 row 7.19; GATE-CONTRACT "Returned evidence", `freshness`:
|
|
2
|
+
// `{ unitsAtStart, fresh, notFresh, invalidationReasons }`, the census the measured-cache rule is judged from; E16: "the second
|
|
3
|
+
// build's census shows reuse").
|
|
4
|
+
//
|
|
5
|
+
// Cargo keeps no invalidation reason on disk: a fingerprint file holds the current hash only. The reason exists only in Cargo's own
|
|
6
|
+
// fingerprint log (`CARGO_LOG=cargo::core::compiler::fingerprint=info`: a `fingerprint dirty for <unit>` line, then `dirty: <reason>`),
|
|
7
|
+
// the same source the RustRight broker reads for its receipt (rustright-broker job_service/cargo_evidence.rs; attested-runs.md).
|
|
8
|
+
// The gate turns that log on for the cargo processes that compile a workspace, on THAT process only (never in the scan environment
|
|
9
|
+
// the repository's own tests and scripts inherit), reads it from the process's standard error, and keeps the lines out of the output
|
|
10
|
+
// unless the caller set CARGO_LOG itself (a caller's own CARGO_LOG is read the same way and left visible).
|
|
11
|
+
//
|
|
12
|
+
// Counts come from the target directory the way the broker takes them (rustright-policy cargo_freshness.rs): a unit is one directory
|
|
13
|
+
// of a `.fingerprint`; a unit whose newest file is at or after the census start was rebuilt (`notFresh`, a new unit included), the
|
|
14
|
+
// others were reused (`fresh`). Judging unexplained rebuilds belongs to item 11; this module only measures and records.
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
/** The `CARGO_LOG` filter the gate sets for a compiling cargo process (the broker's FINGERPRINT_LOG_FILTER). */
|
|
19
|
+
export const FINGERPRINT_LOG_FILTER = "cargo::core::compiler::fingerprint=info";
|
|
20
|
+
const FINGERPRINT_TARGET = "cargo::core::compiler::fingerprint";
|
|
21
|
+
export const FRESHNESS_SOURCE = "cargo-fingerprint-log+fingerprint-mtime";
|
|
22
|
+
/** Reasons kept in the receipt; the rest are counted. */
|
|
23
|
+
export const REASONS_CAP = 200;
|
|
24
|
+
/** Characters kept of one reason (Cargo prints it with `Debug`, paths and mtimes included). */
|
|
25
|
+
export const REASON_TEXT_CAP = 512;
|
|
26
|
+
/** Unit directories and files visited before the census is declared unavailable rather than partial. */
|
|
27
|
+
export const CENSUS_ENTRY_CAP = 250_000;
|
|
28
|
+
// A held partial line longer than this is not a log line.
|
|
29
|
+
const PARTIAL_CAP = 64 * 1024;
|
|
30
|
+
|
|
31
|
+
const stripAnsi = (text) => text.replace(/\u001b\[[^A-Za-z]*[A-Za-z]/g, "");
|
|
32
|
+
const afterPrefix = (message, prefixes) => {
|
|
33
|
+
for (const prefix of prefixes) if (message.startsWith(prefix)) return message.slice(prefix.length);
|
|
34
|
+
return null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** `name version` and the target of the unit a dirty line names (the span carries `target="..."`; the message `<name> v<ver> (<src>)/<mode>/...`). */
|
|
38
|
+
function unitOf(prefix, rest) {
|
|
39
|
+
let target = null;
|
|
40
|
+
const at = prefix.indexOf('target="');
|
|
41
|
+
if (at >= 0) {
|
|
42
|
+
const value = prefix.slice(at + 8);
|
|
43
|
+
const end = value.indexOf('"');
|
|
44
|
+
if (end >= 0) target = value.slice(0, end);
|
|
45
|
+
}
|
|
46
|
+
const head = rest.split("/")[0] ?? rest;
|
|
47
|
+
const unit = (head.split(" (")[0] ?? head).trim();
|
|
48
|
+
return { unit, target };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The reason's variant name (`FsStatusOutdated`, `RustflagsChanged`, ...): what a summary groups by. */
|
|
52
|
+
const reasonKind = (reason) => {
|
|
53
|
+
const kind = reason.split(/[^\p{L}\p{N}_]/u)[0];
|
|
54
|
+
return kind ? kind : "unknown";
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Reads Cargo's fingerprint log from a process's standard error; `strip` keeps its lines out of what the caller sees. */
|
|
58
|
+
export class FingerprintLog {
|
|
59
|
+
constructor({ strip = false } = {}) {
|
|
60
|
+
this.strip = strip;
|
|
61
|
+
this.partial = "";
|
|
62
|
+
this.current = null;
|
|
63
|
+
this.dirtyUnits = 0;
|
|
64
|
+
this.reasons = [];
|
|
65
|
+
this.truncated = 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One chunk of standard error in; what the caller sees out. Without stripping that is the chunk itself. */
|
|
69
|
+
feed(chunk) {
|
|
70
|
+
this.partial += String(chunk);
|
|
71
|
+
let shown = "";
|
|
72
|
+
for (let at = this.partial.indexOf("\n"); at >= 0; at = this.partial.indexOf("\n")) {
|
|
73
|
+
const line = this.partial.slice(0, at + 1);
|
|
74
|
+
this.partial = this.partial.slice(at + 1);
|
|
75
|
+
const isLog = this.#line(line);
|
|
76
|
+
if (!isLog || !this.strip) shown += line;
|
|
77
|
+
}
|
|
78
|
+
if (this.partial.length > PARTIAL_CAP) {
|
|
79
|
+
const rest = this.partial;
|
|
80
|
+
this.partial = "";
|
|
81
|
+
if (this.strip) shown += rest;
|
|
82
|
+
}
|
|
83
|
+
return this.strip ? shown : String(chunk);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The end of the stream: a held partial line is judged and, when stripping and it is not a log line, returned. */
|
|
87
|
+
finish() {
|
|
88
|
+
const rest = this.partial;
|
|
89
|
+
this.partial = "";
|
|
90
|
+
if (rest === "" || this.#line(rest) || !this.strip) return "";
|
|
91
|
+
return rest;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#line(raw) {
|
|
95
|
+
const line = stripAnsi(raw.replace(/\s+$/, ""));
|
|
96
|
+
const at = line.indexOf(FINGERPRINT_TARGET);
|
|
97
|
+
if (at < 0) return false;
|
|
98
|
+
const message = line.slice(at + FINGERPRINT_TARGET.length).replace(/^:+/, "").trim();
|
|
99
|
+
const unitLine = afterPrefix(message, ["fingerprint dirty for ", "fingerprint error for "]);
|
|
100
|
+
if (unitLine !== null) {
|
|
101
|
+
this.dirtyUnits += 1;
|
|
102
|
+
this.current = unitOf(line.slice(0, at), unitLine);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
const reason = afterPrefix(message, ["dirty:", "err:"]);
|
|
106
|
+
if (reason !== null) this.#record(reason.trim());
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#record(reason) {
|
|
111
|
+
if (this.reasons.length >= REASONS_CAP) { this.truncated += 1; return; }
|
|
112
|
+
const { unit, target } = this.current ?? { unit: "unknown", target: null };
|
|
113
|
+
const chars = Array.from(reason);
|
|
114
|
+
const text = chars.length > REASON_TEXT_CAP ? `${chars.slice(0, REASON_TEXT_CAP).join("")}…` : reason;
|
|
115
|
+
this.reasons.push({ unit, target, kind: reasonKind(reason), reason: text });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What another log's reading adds to this one (the census keeps one aggregate over every compiling process). */
|
|
119
|
+
merge(other) {
|
|
120
|
+
this.dirtyUnits += other.dirtyUnits;
|
|
121
|
+
this.truncated += other.truncated;
|
|
122
|
+
for (const reason of other.reasons) {
|
|
123
|
+
if (this.reasons.length >= REASONS_CAP) this.truncated += 1;
|
|
124
|
+
else this.reasons.push(reason);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Cargo subcommands that compile a workspace's units (the wrapper's COMPILE set without metadata and package, which the census
|
|
130
|
+
// does not count). `--` ends cargo's own arguments; `--color` and `--explain` take a value.
|
|
131
|
+
const COMPILING = new Set(["build", "b", "check", "c", "test", "t", "bench", "run", "r", "doc", "d", "rustc", "rustdoc", "clippy"]);
|
|
132
|
+
const GLOBAL_VALUE = new Set(["--color", "--explain"]);
|
|
133
|
+
|
|
134
|
+
/** True when `command args` is a cargo process that compiles a workspace's units (its fingerprint log is the census's). */
|
|
135
|
+
export function compilesWorkspace(command, args) {
|
|
136
|
+
if (path.basename(String(command)).replace(/\.exe$/i, "") !== "cargo") return false;
|
|
137
|
+
const words = Array.isArray(args) ? args : [];
|
|
138
|
+
let i = typeof words[0] === "string" && words[0].startsWith("+") ? 1 : 0;
|
|
139
|
+
for (; i < words.length; i += 1) {
|
|
140
|
+
const w = String(words[i]);
|
|
141
|
+
if (w === "--") return false;
|
|
142
|
+
if (GLOBAL_VALUE.has(w)) { i += 1; continue; }
|
|
143
|
+
if (w.startsWith("-")) continue;
|
|
144
|
+
return COMPILING.has(w);
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Directories never searched for `.fingerprint`s: rustdoc output and packaging verification are not a profile's units.
|
|
150
|
+
const NOT_PROFILES = new Set(["doc", "package", "tmp", "incremental"]);
|
|
151
|
+
|
|
152
|
+
/** The `.fingerprint` directories of one target directory: `<target>/<profile>/.fingerprint` and `<target>/<triple>/<profile>/.fingerprint`. */
|
|
153
|
+
export function fingerprintDirectories(targetDir, fsApi = fs) {
|
|
154
|
+
const found = [];
|
|
155
|
+
const subdirs = (dir) => {
|
|
156
|
+
try { return fsApi.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && !NOT_PROFILES.has(e.name)).map((e) => e.name); } catch { return []; }
|
|
157
|
+
};
|
|
158
|
+
const has = (dir) => { try { return fsApi.statSync(dir).isDirectory(); } catch { return false; } };
|
|
159
|
+
for (const first of subdirs(targetDir)) {
|
|
160
|
+
const direct = path.join(targetDir, first, ".fingerprint");
|
|
161
|
+
if (has(direct)) { found.push(direct); continue; }
|
|
162
|
+
for (const second of subdirs(path.join(targetDir, first))) {
|
|
163
|
+
const nested = path.join(targetDir, first, second, ".fingerprint");
|
|
164
|
+
if (has(nested)) found.push(nested);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return found;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Walks every fingerprint directory of `targetDirs`. `withTimes: false` only counts units; `withTimes: true` also takes each unit's
|
|
172
|
+
* newest file time. `capped` is set when more than `cap` entries (units and files) were visited.
|
|
173
|
+
*/
|
|
174
|
+
export function walkFingerprints(targetDirs, { withTimes, cap = CENSUS_ENTRY_CAP, fsApi = fs } = {}) {
|
|
175
|
+
const units = new Map();
|
|
176
|
+
let visited = 0;
|
|
177
|
+
for (const targetDir of targetDirs) {
|
|
178
|
+
for (const fpDir of fingerprintDirectories(targetDir, fsApi)) {
|
|
179
|
+
let names;
|
|
180
|
+
try { names = fsApi.readdirSync(fpDir); } catch { continue; }
|
|
181
|
+
for (const name of names) {
|
|
182
|
+
visited += 1;
|
|
183
|
+
if (visited > cap) return { units, capped: true };
|
|
184
|
+
const unitDir = path.join(fpDir, name);
|
|
185
|
+
if (!withTimes) { units.set(unitDir, 0); continue; }
|
|
186
|
+
let newest = 0;
|
|
187
|
+
try {
|
|
188
|
+
const own = fsApi.statSync(unitDir);
|
|
189
|
+
newest = own.mtimeMs;
|
|
190
|
+
if (own.isDirectory()) {
|
|
191
|
+
for (const file of fsApi.readdirSync(unitDir)) {
|
|
192
|
+
visited += 1;
|
|
193
|
+
if (visited > cap) return { units, capped: true };
|
|
194
|
+
try { newest = Math.max(newest, fsApi.statSync(path.join(unitDir, file)).mtimeMs); } catch { /* vanished: ignore */ }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch { /* vanished: counted with the time it had */ }
|
|
198
|
+
units.set(unitDir, newest);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return { units, capped: false };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* One census for one stage of a direct compile: `start()` before the scans, `wrapSpawn(spawn)` around the scan runner's spawner
|
|
207
|
+
* (it turns the fingerprint log on for the compiling cargo processes only), `finish()` after them.
|
|
208
|
+
*/
|
|
209
|
+
export function createFreshnessCensus({ targetDirs, now = Date.now, fsApi = fs, cap = CENSUS_ENTRY_CAP } = {}) {
|
|
210
|
+
const dirs = [...new Set(Object.values(targetDirs ?? {}).filter((d) => typeof d === "string" && d))];
|
|
211
|
+
const log = new FingerprintLog({ strip: false });
|
|
212
|
+
let since = null; let atStart = null; let processes = 0; let outputCut = false;
|
|
213
|
+
return {
|
|
214
|
+
start() { since = now(); atStart = walkFingerprints(dirs, { withTimes: false, cap, fsApi }); },
|
|
215
|
+
wrapSpawn(spawn) {
|
|
216
|
+
return async (command, args, options = {}) => {
|
|
217
|
+
if (!compilesWorkspace(command, args)) return spawn(command, args, options);
|
|
218
|
+
const env = options.env ?? process.env;
|
|
219
|
+
// A caller's own CARGO_LOG stands and stays visible; the gate's own is kept out of the output.
|
|
220
|
+
const ours = !Object.keys(env).some((k) => k.toUpperCase() === "CARGO_LOG");
|
|
221
|
+
const result = await spawn(command, args, ours ? { ...options, env: { ...env, CARGO_LOG: FINGERPRINT_LOG_FILTER } } : options);
|
|
222
|
+
processes += 1;
|
|
223
|
+
if (!result || typeof result.stderr !== "string") return result;
|
|
224
|
+
if (result.truncated) outputCut = true;
|
|
225
|
+
const reading = new FingerprintLog({ strip: ours });
|
|
226
|
+
const shown = reading.feed(result.stderr) + reading.finish();
|
|
227
|
+
log.merge(reading);
|
|
228
|
+
return { ...result, stderr: shown };
|
|
229
|
+
};
|
|
230
|
+
},
|
|
231
|
+
finish() {
|
|
232
|
+
const base = { source: FRESHNESS_SOURCE, cargoProcesses: processes, dirtyUnits: log.dirtyUnits, truncated: log.truncated, ...(outputCut ? { outputCut: true } : {}) };
|
|
233
|
+
const unavailable = (reason) => ({ unitsAtStart: 0, fresh: 0, notFresh: 0, invalidationReasons: log.reasons, available: false, reason, ...base });
|
|
234
|
+
if (since === null || atStart === null) return unavailable("fingerprint-census-not-started");
|
|
235
|
+
const end = walkFingerprints(dirs, { withTimes: true, cap, fsApi });
|
|
236
|
+
if (atStart.capped || end.capped) return unavailable("fingerprint-census-entry-cap-exceeded");
|
|
237
|
+
if (end.units.size === 0) return unavailable("no-fingerprint-units-found");
|
|
238
|
+
let notFresh = 0;
|
|
239
|
+
for (const newest of end.units.values()) if (newest >= since) notFresh += 1;
|
|
240
|
+
return { unitsAtStart: atStart.units.size, fresh: end.units.size - notFresh, notFresh, invalidationReasons: log.reasons, available: true, ...base };
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const isCount = (n) => Number.isInteger(n) && n >= 0;
|
|
246
|
+
|
|
247
|
+
/** What is wrong with a `freshness` object (an empty list: it is the census the contract names, well formed). */
|
|
248
|
+
export function validateFreshness(freshness) {
|
|
249
|
+
const problems = [];
|
|
250
|
+
if (!freshness || typeof freshness !== "object" || Array.isArray(freshness)) return ["freshness is absent"];
|
|
251
|
+
for (const key of ["unitsAtStart", "fresh", "notFresh"]) if (!isCount(freshness[key])) problems.push(`freshness.${key} is not a count`);
|
|
252
|
+
if (!Array.isArray(freshness.invalidationReasons)) problems.push("freshness.invalidationReasons is not a list");
|
|
253
|
+
else freshness.invalidationReasons.forEach((r, i) => { if (!r || typeof r.unit !== "string" || typeof r.kind !== "string" || typeof r.reason !== "string") problems.push(`freshness.invalidationReasons[${i}] has no unit, kind and reason`); });
|
|
254
|
+
if (freshness.available !== undefined && typeof freshness.available !== "boolean") problems.push("freshness.available is not a boolean");
|
|
255
|
+
if (freshness.available === false && typeof freshness.reason !== "string") problems.push("an unavailable census names no reason");
|
|
256
|
+
return problems;
|
|
257
|
+
}
|
package/gate-stages.mjs
CHANGED
|
@@ -15,9 +15,62 @@ import path from "node:path";
|
|
|
15
15
|
export const STAGES = Object.freeze(["check", "compile", "package", "qualify", "release"]);
|
|
16
16
|
export const RECEIPT_SCHEMA = "rightkit.gate-receipt/1";
|
|
17
17
|
export const RUN_ENV = "RK_GATE_RUN";
|
|
18
|
+
/** Set beside RUN_ENV for a run that carries a source split: the checkout whose git directory holds the run file. */
|
|
19
|
+
export const CHECKOUT_ENV = "RK_GATE_CHECKOUT";
|
|
18
20
|
|
|
19
21
|
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
20
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Where every stage the gate runs reads its source from (docs/GATE-CONTRACT.md "Commit-only build": every stage, `--to dev` and
|
|
25
|
+
* `--to test` included, builds a commit; uncommitted changes never enter a build). One row per stage name the gate dispatches:
|
|
26
|
+
* the STAGES, `dev` and `test`, `stage` (the hand-off to the publisher) and each STAGES entry again as a RightKit unit stage
|
|
27
|
+
* (`--to <stage> --unit <selector>`). `compiles` and `packages` say whether the stage starts a compiler or seals bytes.
|
|
28
|
+
* source "snapshot": the gate-owned snapshot of the selected commit (`wired: false` names a stage whose snapshot route is
|
|
29
|
+
* refused today, `blocker` saying why; it still never reads the checkout).
|
|
30
|
+
* source "sealed" or "publisher", with `basis`: the stage compiles nothing from source (it acts on bytes an earlier stage
|
|
31
|
+
* sealed, or runs only in the trusted publisher on hosted CI with its normal checkout, docs/GATE-CONTRACT.md).
|
|
32
|
+
* source "checkout", with `gap`: a known breach, listed so it cannot grow; stageSourceProblems ignores it, stageSourceGaps names it.
|
|
33
|
+
* door-stages.test.mjs fails on a stage with no row, on a compiling or packaging row that has none of these, and on a gap that is
|
|
34
|
+
* not in its expected list.
|
|
35
|
+
*/
|
|
36
|
+
export const STAGE_SOURCES = Object.freeze({
|
|
37
|
+
check: { compiles: false, packages: false, source: "snapshot" },
|
|
38
|
+
compile: { compiles: true, packages: false, source: "snapshot", interim: "Mac and Dell: the clean-HEAD checkout route until the cutover" },
|
|
39
|
+
// Wired in code (row 7.04: build-release.mjs, release.mjs, cli/right-release.mjs, release-source-identity.mjs): right-release builds and seals the selected commit from the snapshot. `wired` becomes true when door-package.test.mjs passes on L and on M and D (dev broker).
|
|
40
|
+
package: { compiles: true, packages: true, source: "snapshot", wired: false, blocker: "wired in code (rows 7.04 and 7.04c: release-artifact-path.mjs binds each Cargo manifest's target, one resolver reads every declared artifact path); awaiting 7.04c green on L, M and D: the dev-broker rows need the snapshot carrier (rows 7.20 to 7.24) and RustRight's item-4 dev pins" },
|
|
41
|
+
// Narrowed by decision 2 (7.06 correction): qualification rebuilds nothing, but its source reads (config, QA plan, test code) go through the split.
|
|
42
|
+
qualify: { compiles: false, packages: false, source: "snapshot", wired: false, blocker: "qa --stage installed takes its identity from the split (release-qa-command.mjs, row 7.04); awaiting its tests on every host" },
|
|
43
|
+
release: { compiles: false, packages: false, source: "publisher", basis: "--to release runs only in the trusted publisher on hosted CI with its normal checkout (docs/GATE-CONTRACT.md), and only uploads sealed bytes" },
|
|
44
|
+
dev: { compiles: true, packages: false, source: "snapshot", interim: "Mac and Dell: the checkout route (check --worktree) until the cutover" },
|
|
45
|
+
test: { compiles: true, packages: false, source: "snapshot", interim: "Mac and Dell: the checkout route (check --worktree) until the cutover" },
|
|
46
|
+
stage: { compiles: false, packages: false, source: "sealed", basis: "stage packs the sealed directory and its receipt into the publisher's draft release; it compiles nothing" },
|
|
47
|
+
// Row 7.06a: a unit stage builds the selected commit's snapshot (Linux now; Mac and Dell at the cutover commit, until which they take
|
|
48
|
+
// the clean-HEAD checkout route, decision 2 "Interim").
|
|
49
|
+
"unit:check": { compiles: false, packages: false, source: "snapshot", interim: "Mac and Dell: clean HEAD of the checkout until the cutover" },
|
|
50
|
+
"unit:compile": { compiles: true, packages: false, source: "snapshot", interim: "Mac and Dell: clean HEAD of the checkout until the cutover" },
|
|
51
|
+
"unit:package": { compiles: true, packages: true, source: "snapshot", interim: "Mac and Dell: clean HEAD of the checkout until the cutover" },
|
|
52
|
+
"unit:qualify": { compiles: false, packages: false, source: "snapshot", interim: "Mac and Dell: clean HEAD of the checkout until the cutover" },
|
|
53
|
+
"unit:release": { compiles: false, packages: false, source: "publisher", basis: "a unit release is dispatched to the trusted publisher (releaseUnit); it compiles nothing here" },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
/** Rows of `table` that compile or package without a snapshot source, a stated exclusion basis or a registered gap. */
|
|
57
|
+
export function stageSourceProblems(table = STAGE_SOURCES) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const [stage, row] of Object.entries(table)) {
|
|
60
|
+
if (!row.compiles && !row.packages) continue;
|
|
61
|
+
if (row.source === "snapshot") continue;
|
|
62
|
+
if (typeof row.basis === "string" && row.basis && ["sealed", "publisher"].includes(row.source)) continue;
|
|
63
|
+
if (row.source === "checkout" && typeof row.gap === "string" && row.gap) continue;
|
|
64
|
+
out.push({ stage, problem: `${stage} compiles or packages and its source is ${JSON.stringify(row.source)} with no snapshot source, exclusion basis or registered gap` });
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The registered breaches in `table`: stages that compile or package from the checkout. */
|
|
70
|
+
export function stageSourceGaps(table = STAGE_SOURCES) {
|
|
71
|
+
return Object.entries(table).filter(([, row]) => row.source === "checkout" && (row.compiles || row.packages)).map(([stage]) => stage);
|
|
72
|
+
}
|
|
73
|
+
|
|
21
74
|
/** The stages a run to `target` covers, in order; null when `target` is not a stage. */
|
|
22
75
|
export function stagesTo(target) {
|
|
23
76
|
const i = STAGES.indexOf(target);
|
|
@@ -29,14 +82,18 @@ export function gateStateDir(commonDir) {
|
|
|
29
82
|
return path.join(commonDir, "rightkit-gate");
|
|
30
83
|
}
|
|
31
84
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Starts a door run: writes the run file and returns { nonce, file, env }. The caller removes it with endRun. `source` is the
|
|
87
|
+
* commit-only build's source split (source-split.mjs): { checkout, snapshot, commit, tree, appDir }, written into the run so a
|
|
88
|
+
* right-release child can learn which snapshot it may read; its env then names the checkout the run file lives under.
|
|
89
|
+
*/
|
|
90
|
+
export function startRun({ commonDir, commit, tree, stage, source, pid = process.pid, write = fs.writeFileSync, mkdir = fs.mkdirSync }) {
|
|
34
91
|
const nonce = randomBytes(16).toString("hex");
|
|
35
92
|
const dir = path.join(gateStateDir(commonDir), "runs");
|
|
36
93
|
mkdir(dir, { recursive: true });
|
|
37
94
|
const file = path.join(dir, `${nonce}.json`);
|
|
38
|
-
write(file, `${JSON.stringify({ pid, commit, tree, stage, startedAt: new Date().toISOString() })}\n`);
|
|
39
|
-
return { nonce, file, env: { [RUN_ENV]: nonce } };
|
|
95
|
+
write(file, `${JSON.stringify({ pid, commit, tree, stage, ...(source ? { source } : {}), startedAt: new Date().toISOString() })}\n`);
|
|
96
|
+
return { nonce, file, env: { [RUN_ENV]: nonce, ...(source ? { [CHECKOUT_ENV]: source.checkout } : {}) } };
|
|
40
97
|
}
|
|
41
98
|
|
|
42
99
|
export function endRun(run, rm = fs.rmSync) {
|
|
@@ -48,16 +105,25 @@ export function endRun(run, rm = fs.rmSync) {
|
|
|
48
105
|
* whose pid is gone, or that names another commit, is no run.
|
|
49
106
|
*/
|
|
50
107
|
export function requireDoorRun({ env = process.env, commonDir, commit, readFile = (f) => fs.readFileSync(f, "utf8"), alive = pidAlive, command = "this command" }) {
|
|
51
|
-
const nonce = env[RUN_ENV];
|
|
52
108
|
const refusal = `${command} runs only through the one door: rightkit-gate --to <check|compile|package|qualify|release> (it runs class A first, then every stage in order)`;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (
|
|
57
|
-
if (commit && run.commit !== commit) return `${refusal}; the door run is for ${String(run.commit).slice(0, 12)}, HEAD is ${commit.slice(0, 12)}`;
|
|
109
|
+
const found = readDoorRun({ env, commonDir, readFile, alive });
|
|
110
|
+
if (found.problem === "none") return refusal;
|
|
111
|
+
if (found.problem === "unread") return `${refusal}; RK_GATE_RUN names no live door run`;
|
|
112
|
+
if (found.problem === "ended") return `${refusal}; the door run that launched it has ended`;
|
|
113
|
+
if (commit && found.run.commit !== commit) return `${refusal}; the door run is for ${String(found.run.commit).slice(0, 12)}, HEAD is ${commit.slice(0, 12)}`;
|
|
58
114
|
return null;
|
|
59
115
|
}
|
|
60
116
|
|
|
117
|
+
/** The live door run RK_GATE_RUN names: { run } or { problem: "none" | "unread" | "ended" }. */
|
|
118
|
+
export function readDoorRun({ env = process.env, commonDir, readFile = (f) => fs.readFileSync(f, "utf8"), alive = pidAlive }) {
|
|
119
|
+
const nonce = env[RUN_ENV];
|
|
120
|
+
if (!nonce || !/^[0-9a-f]{32}$/.test(nonce)) return { problem: "none" };
|
|
121
|
+
let run;
|
|
122
|
+
try { run = JSON.parse(readFile(path.join(gateStateDir(commonDir), "runs", `${nonce}.json`))); } catch { return { problem: "unread" }; }
|
|
123
|
+
if (!alive(run.pid)) return { problem: "ended" };
|
|
124
|
+
return { run };
|
|
125
|
+
}
|
|
126
|
+
|
|
61
127
|
function pidAlive(pid) {
|
|
62
128
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
63
129
|
try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Retirement of the git hooks RightKit used to write (docs/GATE-CONTRACT.md: "Commit and push run no gate and no local hook
|
|
2
|
+
// checks"). `rightkit-gate install-hook` and `right-release hooks install` no longer write a pre-push gate, the forwarders
|
|
3
|
+
// that chained .git/hooks beside it, or the advisory ownership pre-commit step; they remove the ones an earlier release wrote.
|
|
4
|
+
//
|
|
5
|
+
// A file is ours only by its own text, never by its name or its place:
|
|
6
|
+
// - the gate hook : line 2 is the header every published hook carries ("# Mandatory local [check and] gate before every
|
|
7
|
+
// push (rightkit-gate install-hook). Never bypass with --no-verify.");
|
|
8
|
+
// - a forwarder : line 2 is the forwarder header naming the hook it forwards, and that hook is the file's own name
|
|
9
|
+
// (pre-commit.rightkit-foreign may forward `pre-commit` or itself);
|
|
10
|
+
// - the ownership step : the HOOK_MARKER sits in the first three lines.
|
|
11
|
+
// Anything else is a foreign hook and is never read for change, moved, rewritten or deleted. A symbolic link is never ours.
|
|
12
|
+
// One exception to "never moved": the foreign pre-commit the ownership step preserved as pre-commit.rightkit-foreign goes back to
|
|
13
|
+
// pre-commit when the ownership step that displaced it is removed.
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
export const HOOK_MARKER = "rightkit-ownership-precommit v1";
|
|
17
|
+
export const FOREIGN_PRE_COMMIT = "pre-commit.rightkit-foreign";
|
|
18
|
+
|
|
19
|
+
const GATE_HEADER = /^# Mandatory local (?:check and )?gate before every push \(rightkit-gate install-hook\)\. Never bypass with --no-verify\.$/;
|
|
20
|
+
const FORWARDER_HEADER = /^# rightkit-gate install-hook: core\.hooksPath=\.githooks hides \.git\/hooks, so run this repository's own (\S+) hook from there\.$/;
|
|
21
|
+
|
|
22
|
+
const lines = (text) => String(text).replace(/\r\n/g, "\n").split("\n");
|
|
23
|
+
|
|
24
|
+
/** What a hook file is, by its text and its name: "gate", "forwarder", "ownership" or null (foreign). `scripts` is false for a directory the gate never wrote into. */
|
|
25
|
+
export function hookKind(name, text, { scripts = true } = {}) {
|
|
26
|
+
const [shebang, second = "", third = ""] = lines(text);
|
|
27
|
+
if (shebang !== "#!/bin/sh") return null;
|
|
28
|
+
if ([second, third].some((line) => line.startsWith(`# ${HOOK_MARKER}`))) return "ownership";
|
|
29
|
+
if (!scripts) return null;
|
|
30
|
+
if (name === "pre-push" && GATE_HEADER.test(second)) return "gate";
|
|
31
|
+
const forwarded = FORWARDER_HEADER.exec(second)?.[1];
|
|
32
|
+
if (forwarded && (forwarded === name || (name === FOREIGN_PRE_COMMIT && forwarded === "pre-commit"))) return "forwarder";
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Remove the hooks RightKit wrote from `dir`. `ops` = { readdir, readFile, lstat, unlink, rename, rmdir } (fs-shaped). Returns
|
|
38
|
+
* { found, removed: [name], restored: [name], remaining: [name] }: `found` is false when `dir` cannot be listed, `remaining` is every
|
|
39
|
+
* file left in it (foreign hooks), and an emptied directory is removed (`removedDir`).
|
|
40
|
+
*/
|
|
41
|
+
export function retireHooksIn(dir, ops, { scripts = true, removeEmptyDir = scripts } = {}) {
|
|
42
|
+
let names;
|
|
43
|
+
try { names = [...ops.readdir(dir)].map(String).sort(); } catch { return { found: false, removed: [], restored: [], remaining: [], removedDir: false }; }
|
|
44
|
+
const removed = []; const restored = []; const kinds = new Map();
|
|
45
|
+
for (const name of names) {
|
|
46
|
+
const file = path.join(dir, name);
|
|
47
|
+
let kind = null;
|
|
48
|
+
try {
|
|
49
|
+
const stat = ops.lstat(file);
|
|
50
|
+
if (stat.isFile() && !stat.isSymbolicLink()) kind = hookKind(name, ops.readFile(file), { scripts });
|
|
51
|
+
} catch { kind = null; }
|
|
52
|
+
kinds.set(name, kind);
|
|
53
|
+
if (!kind) continue;
|
|
54
|
+
try { ops.unlink(file); removed.push(name); } catch { kinds.set(name, null); }
|
|
55
|
+
}
|
|
56
|
+
// The ownership step displaced a foreign pre-commit into pre-commit.rightkit-foreign: it goes back where git runs it.
|
|
57
|
+
if (kinds.get("pre-commit") === "ownership" && removed.includes("pre-commit") && names.includes(FOREIGN_PRE_COMMIT) && kinds.get(FOREIGN_PRE_COMMIT) === null) {
|
|
58
|
+
try { ops.rename(path.join(dir, FOREIGN_PRE_COMMIT), path.join(dir, "pre-commit")); restored.push("pre-commit"); } catch { /* left where it is, still foreign */ }
|
|
59
|
+
}
|
|
60
|
+
let left = names.filter((name) => !removed.includes(name));
|
|
61
|
+
if (restored.length) left = [...left.filter((name) => name !== FOREIGN_PRE_COMMIT), "pre-commit"].sort();
|
|
62
|
+
let removedDir = false;
|
|
63
|
+
if (removeEmptyDir && removed.length && !left.length) { try { ops.rmdir(dir); removedDir = true; } catch { /* not empty after all */ } }
|
|
64
|
+
return { found: true, removed, restored, remaining: left, removedDir };
|
|
65
|
+
}
|