@rightkit/release 0.6.3 → 0.6.5
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 +67 -5
- package/admission-files.mjs +279 -0
- package/admission-host.mjs +403 -0
- package/admission.mjs +146 -0
- package/app-check.mjs +869 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +68 -29
- package/build-release.mjs +81 -21
- package/cli/right-release.mjs +39 -5
- package/deterministic-scans.mjs +42 -15
- package/development.mjs +3 -2
- package/hosted-workflow-policy.mjs +111 -0
- package/inventory-check.mjs +12 -1
- package/ios-dev.mjs +2 -2
- package/ios-host.mjs +7 -7
- package/ios-package-recipe.mjs +2 -2
- package/local-publish.mjs +1 -1
- package/local-qualify.mjs +3 -3
- package/local-runtime.mjs +1 -1
- package/ownership-dependency-allowlist.json +2 -0
- package/package.json +5 -3
- package/pipeline-admission.mjs +5 -4
- package/pipeline-receipts.mjs +302 -302
- package/pipeline-scan.mjs +3 -3
- package/pipeline-tools.mjs +175 -171
- package/prebuild-gates.mjs +191 -0
- package/progress-control.mjs +1 -1
- package/publish-update.mjs +4 -3
- package/qa-admission-policy.json +5 -0
- package/release-config.mjs +353 -330
- package/release-qa-audit.mjs +37 -11
- package/release-qa-command.mjs +46 -5
- package/release-qa-fixtures.mjs +127 -0
- package/release-qa-floor.json +6 -1
- package/release-qa-hardening.mjs +173 -0
- package/release-qa-policy.mjs +168 -0
- package/release-qa-receipt.mjs +21 -1
- package/release-switch-scan.mjs +137 -6
- package/release.mjs +333 -231
- package/rightapps-register.mjs +3 -1
- package/rightkit-drift.mjs +1 -1
- package/rightkit-ownership.json +15 -5
- package/rightkit-versions.json +50 -37
- package/stage-sidecar.mjs +17 -15
- package/suite-api-literal.mjs +109 -0
- package/suite-api.generated.mjs +78 -0
- package/swap-helper-bundle.mjs +2 -2
- package/win-package-recipe.mjs +7 -4
package/bin/darwin/rightkit-swap
CHANGED
|
Binary file
|
package/bin/rightkit-gate.mjs
CHANGED
|
@@ -24,10 +24,16 @@
|
|
|
24
24
|
// rightkit-gate [--base <ref>] [--plan] [--no-cache]
|
|
25
25
|
// run the gate (default base origin/main); --plan prints the scans and runs none;
|
|
26
26
|
// --no-cache (or RK_GATE_CACHE=0) reruns every scan
|
|
27
|
+
// rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
|
|
28
|
+
// the offline app check (../app-check.mjs): committed files only, no network, compile or
|
|
29
|
+
// broker, every finding in one run. Exit 0 pass, 1 findings, 2 could not run
|
|
27
30
|
// rightkit-gate install-hook [--force] write .githooks/pre-push and set core.hooksPath. core.hooksPath hides .git/hooks, so the
|
|
28
|
-
// pre-push hook runs the repository's own .git/hooks/pre-push first
|
|
29
|
-
// present in .git/hooks gets a
|
|
30
|
-
//
|
|
31
|
+
// pre-push hook runs the repository's own .git/hooks/pre-push first, then `rightkit-gate
|
|
32
|
+
// check --commit HEAD`, then the gate; every other hook present in .git/hooks gets a
|
|
33
|
+
// .githooks/<name> trampoline that runs it; the hook finds the gate binary in the root
|
|
34
|
+
// node_modules/.bin, then any workspace package's, then pnpm exec. Where @rightkit/release is
|
|
35
|
+
// installed it also writes the advisory ownership pre-commit step (`right-release hooks install`
|
|
36
|
+
// is an alias of this command)
|
|
31
37
|
//
|
|
32
38
|
// From the pre-push hook, git's ref lines arrive on stdin: the commit being pushed must be the checked-out HEAD (broker scans
|
|
33
39
|
// read the live checkout), and the remote's current commit is the diff base. To push an earlier commit of the local stack,
|
|
@@ -58,7 +64,7 @@ const text = (r) => String(r?.stdout ?? "");
|
|
|
58
64
|
const failed = (r) => !r || Boolean(r.error) || r.status !== 0;
|
|
59
65
|
const why = (r) => String(r?.error?.message ?? r?.stderr ?? "").trim().split(/\r?\n/).slice(-3).join(" | ");
|
|
60
66
|
|
|
61
|
-
const USAGE = `usage: rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | --version`;
|
|
67
|
+
const USAGE = `usage: rightkit-gate [--base <ref>] [--plan] [--no-cache] [--pre-push] | rightkit-gate install-hook [--force] | rightkit-gate check [--repo <dir>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>] | --version`;
|
|
62
68
|
|
|
63
69
|
// ---- pure helpers (exported for tests) ----------------------------------------------------------------------
|
|
64
70
|
|
|
@@ -340,13 +346,15 @@ export function lockScope({ changed, cargoDir = ".", unitNames, showBase, readLo
|
|
|
340
346
|
/**
|
|
341
347
|
* The pre-push hook. git's ref lines arrive on stdin and the gate consumes them, so stdin is buffered once and fed to both the
|
|
342
348
|
* repository's own .git/hooks/pre-push (which core.hooksPath=.githooks would otherwise hide; it runs first and a failure stops the
|
|
343
|
-
* push) and the gate.
|
|
344
|
-
*
|
|
345
|
-
*
|
|
349
|
+
* push) and the gate. Between them runs the app check, `rightkit-gate check --commit HEAD`: seconds, committed files only, so a
|
|
350
|
+
* red tree stops the push before the gate's scans start; a push that only deletes refs has no content to check and skips it. The
|
|
351
|
+
* gate binary is the root node_modules/.bin/rightkit-gate, else any workspace package's (up to three levels down: tauri-app-next,
|
|
352
|
+
* apps/*, packages/*), else `pnpm exec rightkit-gate`, else node with a package-relative path when pnpm is absent; the line it
|
|
353
|
+
* chose is printed once. No absolute path is written: everything is resolved from git at run time.
|
|
346
354
|
*/
|
|
347
355
|
export function hookText(nodePath) {
|
|
348
356
|
return `#!/bin/sh
|
|
349
|
-
# Mandatory local gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
|
|
357
|
+
# Mandatory local check and gate before every push (rightkit-gate install-hook). Never bypass with --no-verify.
|
|
350
358
|
cd "$(git rev-parse --show-toplevel)" || exit 1
|
|
351
359
|
stdin=$(mktemp "\${TMPDIR:-/tmp}/rightkit-gate.XXXXXX") || exit 1
|
|
352
360
|
trap 'rm -f "$stdin"' EXIT
|
|
@@ -358,18 +366,27 @@ if [ -x "$own" ]; then
|
|
|
358
366
|
"$own" "$@" < "$stdin" || exit $?
|
|
359
367
|
fi
|
|
360
368
|
# The gate binary: the root install, any workspace package's install, then pnpm exec.
|
|
369
|
+
found=""
|
|
361
370
|
for bin in node_modules/.bin/rightkit-gate */node_modules/.bin/rightkit-gate */*/node_modules/.bin/rightkit-gate */*/*/node_modules/.bin/rightkit-gate; do
|
|
362
|
-
if [ -x "$bin" ]; then
|
|
363
|
-
echo "rightkit-gate: using $bin" >&2
|
|
364
|
-
("$bin" --base origin/main) < "$stdin"; exit $?
|
|
365
|
-
fi
|
|
371
|
+
if [ -x "$bin" ]; then found="$bin"; break; fi
|
|
366
372
|
done
|
|
367
|
-
if
|
|
373
|
+
if [ -n "$found" ]; then
|
|
374
|
+
echo "rightkit-gate: using $found" >&2
|
|
375
|
+
rightkit_gate() { "$found" "$@"; }
|
|
376
|
+
elif command -v pnpm >/dev/null 2>&1; then
|
|
368
377
|
echo "rightkit-gate: using pnpm exec rightkit-gate" >&2
|
|
369
|
-
(
|
|
378
|
+
rightkit_gate() { pnpm exec rightkit-gate "$@"; }
|
|
379
|
+
else
|
|
380
|
+
echo "rightkit-gate: using node ${nodePath}" >&2
|
|
381
|
+
rightkit_gate() { node "${nodePath}" "$@"; }
|
|
370
382
|
fi
|
|
371
|
-
|
|
372
|
-
|
|
383
|
+
# The check first, over the committed files of HEAD; a push that only deletes refs has nothing to check.
|
|
384
|
+
if [ -s "$stdin" ] && ! awk '$2 !~ /^0+$/ { found = 1 } END { exit !found }' "$stdin"; then
|
|
385
|
+
echo "rightkit-gate: only deletions are pushed; nothing to check" >&2
|
|
386
|
+
else
|
|
387
|
+
rightkit_gate check --commit HEAD < /dev/null >&2 || exit $?
|
|
388
|
+
fi
|
|
389
|
+
rightkit_gate --base origin/main < "$stdin"; exit $?
|
|
373
390
|
`;
|
|
374
391
|
}
|
|
375
392
|
|
|
@@ -750,13 +767,13 @@ export function quietLeaseLane(o, { tag = "rightkit-gate:", reason = "rightkit-g
|
|
|
750
767
|
|
|
751
768
|
// ---- command resolution -------------------------------------------------------------------------------------
|
|
752
769
|
|
|
753
|
-
// Cargo only through the broker: `
|
|
770
|
+
// Cargo only through the broker: `rustright cargo`, or a `cargo` on PATH that is the managed shim (it runs cargo-guard.mjs).
|
|
754
771
|
function resolveCargoCommand(o) {
|
|
755
772
|
const which = (cmd) => {
|
|
756
773
|
const r = o.spawn(o.platform === "win32" ? "where" : "which", [cmd]);
|
|
757
774
|
return failed(r) ? [] : text(r).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
758
775
|
};
|
|
759
|
-
if (which("
|
|
776
|
+
if (which("rustright").length) return ["rustright", "cargo"];
|
|
760
777
|
const first = which("cargo")[0];
|
|
761
778
|
if (first) { try { if (o.readFile(first).includes("cargo-guard.mjs")) return ["cargo"]; } catch { /* unreadable: not the shim */ } }
|
|
762
779
|
return null;
|
|
@@ -780,7 +797,12 @@ function releaseProvider(o, root, git) {
|
|
|
780
797
|
return null;
|
|
781
798
|
}
|
|
782
799
|
|
|
783
|
-
|
|
800
|
+
// The advisory ownership pre-commit step (`right-release hooks install`, ownership-hooks.mjs) runs the pinned @rightkit/release, so
|
|
801
|
+
// it is written only where that is installed. Its own hook and the repository's .git/hooks/pre-commit both keep running.
|
|
802
|
+
const PINNED_CLI = "node_modules/@rightkit/release/cli/right-release.mjs";
|
|
803
|
+
const FOREIGN_PRE_COMMIT = "pre-commit.rightkit-foreign";
|
|
804
|
+
|
|
805
|
+
async function installHook(o, root, force) {
|
|
784
806
|
const git = (...a) => o.spawn("git", a, { cwd: root });
|
|
785
807
|
const hooksDir = path.join(root, ".githooks");
|
|
786
808
|
const hookPath = path.join(hooksDir, "pre-push");
|
|
@@ -797,15 +819,29 @@ function installHook(o, root, force) {
|
|
|
797
819
|
const gitDirResult = git("rev-parse", "--git-dir");
|
|
798
820
|
let present = [];
|
|
799
821
|
if (!failed(gitDirResult) && text(gitDirResult).trim()) { try { present = o.readdir(path.join(path.resolve(root, text(gitDirResult).trim()), "hooks")); } catch { present = []; } }
|
|
822
|
+
const gitHooksDir = !failed(gitDirResult) && text(gitDirResult).trim() ? path.join(path.resolve(root, text(gitDirResult).trim()), "hooks") : null;
|
|
800
823
|
const own = present.filter(chainableHook).sort();
|
|
801
|
-
|
|
824
|
+
// The ownership step takes .githooks/pre-commit. The repository's own pre-commit hook keeps running first, through a trampoline
|
|
825
|
+
// beside it, unless it is the ownership hook a plain `right-release hooks install` wrote (this one replaces that), whose own
|
|
826
|
+
// chained hook, pre-commit.rightkit-foreign, then runs instead. A name that is no plain hook name is reported, never chained.
|
|
827
|
+
const ownership = o.exists(path.join(root, PINNED_CLI)) ? await import("../ownership-hooks.mjs") : null;
|
|
828
|
+
const readHook = (name) => { try { return o.readFile(path.join(gitHooksDir, name)); } catch { return ""; } };
|
|
829
|
+
const chainName = !ownership ? null : own.includes("pre-commit") && !readHook("pre-commit").includes(ownership.HOOK_MARKER) ? "pre-commit" : present.includes(FOREIGN_PRE_COMMIT) ? FOREIGN_PRE_COMMIT : null;
|
|
830
|
+
const skipped = present.filter((name) => !name.endsWith(".sample") && !chainableHook(name) && !(ownership && name === FOREIGN_PRE_COMMIT)).sort();
|
|
802
831
|
if (skipped.length) o.err(`rightkit-gate: WARNING: not chaining .git/hooks/${skipped.join(", .git/hooks/")}: not a plain hook file name (letters, digits, - and _ only); run it from a hook of its own.`);
|
|
803
|
-
const trampolines = own.filter((name) => name !== "pre-push").map((name) => ({ name, file: path.join(hooksDir, name), wanted: trampolineText(name) }));
|
|
832
|
+
const trampolines = own.filter((name) => name !== "pre-push" && !(ownership && name === "pre-commit")).map((name) => ({ name, file: path.join(hooksDir, name), wanted: trampolineText(name), note: `runs .git/hooks/${name}` }));
|
|
833
|
+
if (ownership) {
|
|
834
|
+
trampolines.push({ name: "pre-commit", file: path.join(hooksDir, "pre-commit"), wanted: ownership.renderPreCommitHook(), ownership: true, replaces: [trampolineText("pre-commit")], marker: ownership.HOOK_MARKER, note: "the advisory ownership gate: right-release ownership --staged" });
|
|
835
|
+
if (chainName) trampolines.push({ name: FOREIGN_PRE_COMMIT, file: path.join(hooksDir, FOREIGN_PRE_COMMIT), wanted: trampolineText(chainName), note: `runs .git/hooks/${chainName}, before the ownership gate` });
|
|
836
|
+
}
|
|
804
837
|
for (const t of trampolines) {
|
|
805
838
|
t.exists = o.exists(t.file);
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
839
|
+
const found = t.exists ? o.readFile(t.file).replace(/\r\n/g, "\n") : null;
|
|
840
|
+
t.current = found === t.wanted;
|
|
841
|
+
// Our own earlier artifacts are updated in place: a plain trampoline where the ownership hook now goes, and an older ownership hook.
|
|
842
|
+
const ours = found !== null && (t.current || t.replaces?.includes(found) || (t.marker && found.includes(t.marker)));
|
|
843
|
+
if (t.exists && !ours && !force) {
|
|
844
|
+
o.err(`rightkit-gate: .githooks/${t.name} already exists and differs; nothing changed. Review it, or run \`rightkit-gate install-hook --force\` to replace it with ${t.ownership ? "the ownership pre-commit hook" : `a trampoline to .git/hooks/${chainName ?? t.name}`}.`);
|
|
809
845
|
return 1;
|
|
810
846
|
}
|
|
811
847
|
}
|
|
@@ -825,7 +861,7 @@ function installHook(o, root, force) {
|
|
|
825
861
|
else put(hookPath, wanted, `${hasHook ? "replaced" : "wrote"} .githooks/pre-push`, ` (${nodePath} without pnpm)`);
|
|
826
862
|
for (const t of trampolines) {
|
|
827
863
|
if (t.current) done.push(`.githooks/${t.name} already current`);
|
|
828
|
-
else put(t.file, t.wanted, `${t.exists ? "replaced" : "wrote"} .githooks/${t.name}`, ` (
|
|
864
|
+
else put(t.file, t.wanted, `${t.exists ? "replaced" : "wrote"} .githooks/${t.name}`, ` (${t.note})`);
|
|
829
865
|
}
|
|
830
866
|
if (existingPath === ".githooks") done.push("core.hooksPath already .githooks");
|
|
831
867
|
else {
|
|
@@ -836,6 +872,7 @@ function installHook(o, root, force) {
|
|
|
836
872
|
for (const line of done) o.out(`rightkit-gate: ${line}`);
|
|
837
873
|
const chained = own.length ? own.map((name) => (name === "pre-push" ? "pre-push (run first, then the gate)" : name)) : [];
|
|
838
874
|
o.out(`rightkit-gate: chained hooks from .git/hooks: ${chained.length ? chained.join(", ") : "none"}${own.includes("pre-push") ? "" : "; the pre-push hook still runs .git/hooks/pre-push first if one appears later"}`);
|
|
875
|
+
if (!ownership) o.out(`rightkit-gate: no ownership pre-commit step: ${PINNED_CLI} is not installed here; install @rightkit/release, then run install-hook again`);
|
|
839
876
|
const files = [".githooks/pre-push", ...trampolines.map((t) => `.githooks/${t.name}`)];
|
|
840
877
|
o.out(`rightkit-gate: commit the hooks with executable mode (git add --chmod=+x ${files.join(" ")}); the pre-push hook finds rightkit-gate in node_modules/.bin of the repository root, else of any workspace package, else through \`pnpm exec\`.`);
|
|
841
878
|
const provider = releaseProvider(o, root, git);
|
|
@@ -865,14 +902,16 @@ export async function main(argv, io = {}) {
|
|
|
865
902
|
}
|
|
866
903
|
if (args.includes("--help") || args.includes("-h")) { o.out(USAGE); return 0; }
|
|
867
904
|
const sub = args[0] && !args[0].startsWith("-") ? args[0] : null;
|
|
868
|
-
if (sub && sub !== "install-hook") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
|
|
905
|
+
if (sub && sub !== "install-hook" && sub !== "check") { o.err(`rightkit-gate: unknown command ${JSON.stringify(sub)}\n${USAGE}`); return 2; }
|
|
906
|
+
// The app check has its own export and reads no gate state: it needs neither the base ref nor the pushed refs on stdin.
|
|
907
|
+
if (sub === "check") return (o.appCheck ?? (await import("../app-check.mjs")).main)(args.slice(1), { out: o.out, err: o.err, label: "check", cwd: o.cwd });
|
|
869
908
|
if (args.includes("--base") && (!option("--base") || option("--base").startsWith("--"))) { o.err("rightkit-gate: --base needs a ref"); return 2; }
|
|
870
909
|
|
|
871
910
|
const top = o.spawn("git", ["rev-parse", "--show-toplevel"], { cwd: o.cwd });
|
|
872
911
|
if (failed(top) || !text(top).trim()) { o.err("rightkit-gate: not inside a git repository"); return 2; }
|
|
873
912
|
const root = path.resolve(text(top).trim());
|
|
874
913
|
const git = (...a) => o.spawn("git", a, { cwd: root });
|
|
875
|
-
if (sub === "install-hook") return installHook(o, root, args.includes("--force"));
|
|
914
|
+
if (sub === "install-hook") return await installHook(o, root, args.includes("--force"));
|
|
876
915
|
|
|
877
916
|
const planOnly = args.includes("--plan");
|
|
878
917
|
let base = option("--base") ?? "origin/main";
|
|
@@ -900,12 +939,12 @@ export async function main(argv, io = {}) {
|
|
|
900
939
|
const readLive = (f) => o.readFile(f);
|
|
901
940
|
|
|
902
941
|
// Units come from the live checkout (cargo reads it); a dirty checkout then blocks below.
|
|
903
|
-
let rust; let npm; let cargoCommand = ["
|
|
942
|
+
let rust; let npm; let cargoCommand = ["rustright", "cargo"];
|
|
904
943
|
try {
|
|
905
944
|
const manifests = tracked("Cargo.toml");
|
|
906
945
|
if (manifests.length) {
|
|
907
946
|
cargoCommand = resolveCargoCommand(o);
|
|
908
|
-
if (!cargoCommand) { o.err(`${tag} BLOCKED: neither \`
|
|
947
|
+
if (!cargoCommand) { o.err(`${tag} BLOCKED: neither \`rustright\` nor the managed cargo shim (cargo-guard.mjs) is first on PATH; plain cargo is never used`); return 1; }
|
|
909
948
|
if (!o.exists(path.join(root, GRANT_FILE))) { o.err(`${tag} BLOCKED: no local compile grant (${GRANT_FILE}); the broker refuses cargo, so the Rust units cannot be discovered or scanned`); return 1; }
|
|
910
949
|
}
|
|
911
950
|
// Untracked (not ignored) manifests only: git does not descend into ignored directories, so this stays cheap. A member of a
|
package/build-release.mjs
CHANGED
|
@@ -20,7 +20,9 @@ import {
|
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import { preflightOwnershipScope } from "./ownership-scope.mjs";
|
|
22
22
|
import { spawn, spawnSync } from "./background-process.mjs";
|
|
23
|
-
import { RightKitRegistryError, SNAPSHOT_ENV, SNAPSHOT_FILE, assertManifestRegistryEvidence, loadAdmittedSnapshot, prepareRightKitResolution, registryManifestFields, rightKitLockInventory, writeAdmissionRecord } from "./rightkit-registry-resolver.mjs";
|
|
23
|
+
import { RightKitRegistryError, SNAPSHOT_ENV, SNAPSHOT_FILE, assertManifestRegistryEvidence, loadAdmittedSnapshot, prepareRightKitResolution, registryManifestFields, rightKitLockInventory, writeAdmissionRecord, writeSnapshot } from "./rightkit-registry-resolver.mjs";
|
|
24
|
+
import { formatAdmission, formatNotRun, runAdmission } from "./admission.mjs";
|
|
25
|
+
import { sccacheRefusal } from "./admission-host.mjs";
|
|
24
26
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
25
27
|
import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
|
|
26
28
|
import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
|
|
@@ -129,12 +131,15 @@ for (let i = 0; i < args.length; i += 1) {
|
|
|
129
131
|
if (platform !== "win" && platform !== "mac" && platform !== "android" && platform !== "ios") fail("--platform must be win, mac, android or ios");
|
|
130
132
|
if (resumeFrom && !["prepackage", "sign", "package", "post-sign", "harden"].includes(resumeFrom)) fail("--resume-from must be prepackage, sign, package, post-sign, or harden");
|
|
131
133
|
|
|
132
|
-
|
|
134
|
+
// Native real paths: Git reports the long form of a Windows short name (RUNNER~1) and the target of a symlinked temp root
|
|
135
|
+
// (macOS /var -> /private/var). The config path is resolved the same way the doctor resolves it (release.mjs), so every path
|
|
136
|
+
// derived from it (app root, build inputs, scan roots) compares equal to what Git reports; a non-canonical app root made the
|
|
137
|
+
// build-inputs qualification drop every input and the clean-source gate speak before the dirty-inputs row.
|
|
138
|
+
const nativeReal = (file) => { try { return realpathSync.native(file); } catch { return file; } };
|
|
139
|
+
const configPath = nativeReal(path.resolve(configName));
|
|
133
140
|
const invocationRoot = path.dirname(configPath);
|
|
134
141
|
const repoRoot = git(invocationRoot, ["rev-parse", "--show-toplevel"]);
|
|
135
142
|
assertPrimaryReleaseCheckout(repoRoot);
|
|
136
|
-
// Native real paths: Git reports the long form of a Windows short name (RUNNER~1).
|
|
137
|
-
const nativeReal = (file) => { try { return realpathSync.native(file); } catch { return file; } };
|
|
138
143
|
const relativeConfig = path.relative(nativeReal(repoRoot), nativeReal(configPath)).replaceAll("\\", "/");
|
|
139
144
|
if (relativeConfig.startsWith("../")) fail("release config must live inside the Git repository");
|
|
140
145
|
const appRoot = path.dirname(configPath);
|
|
@@ -148,21 +153,19 @@ let heavySlot;
|
|
|
148
153
|
let interrupted;
|
|
149
154
|
let requestActiveRunStop = null;
|
|
150
155
|
|
|
151
|
-
//
|
|
152
|
-
//
|
|
156
|
+
// Admission (admission.mjs): every refusal the committed files, this host or the registries can decide is raised here, before the
|
|
157
|
+
// dry-run exit, the clean-source gate, the release lock, any state file, the install, a slot or the worker. `right-release doctor`
|
|
158
|
+
// runs the same rows with the same text, so the two cannot disagree (rightkit#946); a refused build leaves nothing behind.
|
|
159
|
+
const admitted = await admit();
|
|
160
|
+
|
|
161
|
+
// A dry-run is an inspection contract, not a partial build. It prints the admission rows and exits with their verdict, so it can
|
|
162
|
+
// never pass what the build refuses. It must finish before locks, cache directories, receipts, dependency installation, or any
|
|
153
163
|
// child build/sign process can exist.
|
|
154
164
|
if (dryRun) {
|
|
155
|
-
|
|
156
|
-
const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
|
|
157
|
-
if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
|
|
158
|
-
const config = (await import(`${pathToFileURL(configPath).href}?dry-run=${commit}`)).default;
|
|
159
|
-
if (!config?.app || !config?.version) fail("release config must expose app and version");
|
|
160
|
-
const target = config.targets?.[platform];
|
|
161
|
-
// Android builds with target.gradle (validated by release.mjs), not a package command; iOS with targets.ios.iosPackage.
|
|
162
|
-
if (platform !== "android" && platform !== "ios" && !target?.package && !packageCommandOptional(target, resolveProjectType({ appRoot, config, target }))) fail(`${config.app} has no ${platform} package command`);
|
|
163
|
-
console.log(`right-release build dry-run: ${config.app} ${config.version} ${platform}`);
|
|
165
|
+
console.log(`right-release build dry-run: ${admitted.config.app} ${admitted.config.version} ${platform}`);
|
|
164
166
|
console.log(`config: ${configPath}`);
|
|
165
|
-
console.log(`commit: ${commit}`);
|
|
167
|
+
console.log(`commit: ${admitted.commit}`);
|
|
168
|
+
for (const line of formatAdmission(admitted.result)) console.log(line);
|
|
166
169
|
process.exit(0);
|
|
167
170
|
}
|
|
168
171
|
|
|
@@ -396,7 +399,8 @@ try {
|
|
|
396
399
|
let rightKitResolution = null;
|
|
397
400
|
if (!legacySealed) {
|
|
398
401
|
try {
|
|
399
|
-
|
|
402
|
+
// A fresh build already asked the registries in admission(); the snapshot is bound to this release's state root below.
|
|
403
|
+
rightKitResolution = (!rightKitReuse && admitted.registry) || await prepareRightKitResolution({
|
|
400
404
|
root: appRoot,
|
|
401
405
|
extraLocks: rightKitLockInventory({ requiredInputs, cargoLockPaths: pinnedCargoLocks, nativeLayout, config, root: appRoot }),
|
|
402
406
|
versions: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-versions.json"), "utf8")),
|
|
@@ -414,6 +418,7 @@ try {
|
|
|
414
418
|
if (!(error instanceof RightKitRegistryError)) throw error;
|
|
415
419
|
fail(`${error.recoverable ? "RECOVERABLE " : ""}${error.message}`);
|
|
416
420
|
}
|
|
421
|
+
if (rightKitResolution === admitted.registry) writeSnapshot(rightKitSnapshotFile, rightKitResolution.snapshot);
|
|
417
422
|
for (const line of rightKitResolution.freshness.tasks) console.error(`[right-release] LANE TASK ${line}`);
|
|
418
423
|
for (const line of rightKitResolution.freshness.warnings) console.error(`[right-release] WARNING ${line}`);
|
|
419
424
|
console.error(`[right-release] rightkit-registry: ${rightKitResolution.summary}`);
|
|
@@ -552,6 +557,52 @@ try {
|
|
|
552
557
|
lock?.release();
|
|
553
558
|
}
|
|
554
559
|
|
|
560
|
+
// Read the config and run the admission rows (files, this host and, for a fresh build, the registries) with no side effect: no lock,
|
|
561
|
+
// state directory, cache directory, install or snapshot file. A finding is printed with its row and the process exits 1, no stack.
|
|
562
|
+
async function admit() {
|
|
563
|
+
let commit;
|
|
564
|
+
let config;
|
|
565
|
+
try {
|
|
566
|
+
commit = git(invocationRoot, ["rev-parse", "HEAD"]);
|
|
567
|
+
const dirtyConfig = dirtyBuildInputs(repoRoot, { include: [relativeConfig], required: [relativeConfig] }, commit);
|
|
568
|
+
if (dirtyConfig.length > 0) fail(`dirty release config cannot be executed:\n${dirtyConfig.map((file) => `- ${file}`).join("\n")}`);
|
|
569
|
+
try { config = (await import(`${pathToFileURL(configPath).href}?commit=${commit}`)).default; } catch (error) { fail(`release config could not be loaded (${configPath}): ${error instanceof Error ? error.message : String(error)}`); }
|
|
570
|
+
} catch (error) {
|
|
571
|
+
if (error?.reported) process.exit(1);
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
574
|
+
// A fresh build asks the registries once, here; a resume or a re-run over a sealed release reuses its recorded snapshot instead.
|
|
575
|
+
const platformName = platform === "win" ? "windows" : platform;
|
|
576
|
+
const sealed = config?.app && config?.version
|
|
577
|
+
&& existsSync(path.join(repoRoot, ".right-release", "sealed", `${config.app}-${config.version}-${commit.slice(0, 8)}`, platformName, "release-manifest.json"));
|
|
578
|
+
const reuse = Boolean(resumeFrom) || Boolean(sealed);
|
|
579
|
+
const result = await runAdmission({
|
|
580
|
+
root: appRoot, repoRoot, configPath, config: config ?? {}, platform, commit, skipChecks,
|
|
581
|
+
tiers: reuse ? ["files", "host"] : ["files", "host", "network"],
|
|
582
|
+
registry: async (ctx) => {
|
|
583
|
+
const inputs = ctx.buildInputs();
|
|
584
|
+
if (!inputs.ok) return undefined; // the build-inputs row reports it
|
|
585
|
+
return prepareRightKitResolution({
|
|
586
|
+
root: appRoot,
|
|
587
|
+
extraLocks: rightKitLockInventory({ requiredInputs: inputs.value.requiredInputs, cargoLockPaths: inputs.value.cargoLockPaths, nativeLayout: inputs.value.nativeLayout, config, root: appRoot }),
|
|
588
|
+
versions: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-versions.json"), "utf8")),
|
|
589
|
+
ownership: JSON.parse(readFileSync(path.join(TOOL_ROOT, "rightkit-ownership.json"), "utf8")),
|
|
590
|
+
releaseVersion: RELEASE_TOOL_VERSION,
|
|
591
|
+
// Build admission: refuse every pin behind the registry's latest. A dry run asks, but keeps no cache on disk.
|
|
592
|
+
admission: true,
|
|
593
|
+
...(dryRun ? { cacheDir: "" } : {}),
|
|
594
|
+
});
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
for (const finding of result.findings) console.error(`right-release build: ${finding.row}: ${finding.detail}`);
|
|
598
|
+
for (const line of formatNotRun(result)) console.error(`right-release build: ${line}`);
|
|
599
|
+
if (result.findings.length > 0) {
|
|
600
|
+
console.error(`right-release build: admission found ${result.findings.length} blocking problem(s); nothing was started`);
|
|
601
|
+
process.exit(1);
|
|
602
|
+
}
|
|
603
|
+
return { config, commit, result, registry: result.out.registry ?? null };
|
|
604
|
+
}
|
|
605
|
+
|
|
555
606
|
function hashInputs(files) {
|
|
556
607
|
const hashes = {};
|
|
557
608
|
for (const file of files) {
|
|
@@ -610,6 +661,14 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
|
|
|
610
661
|
current.add(role);
|
|
611
662
|
sources.set(source, current);
|
|
612
663
|
};
|
|
664
|
+
// Named refusals for a config the admission's seal-routes row already refused, so nothing here crashes on an undefined field.
|
|
665
|
+
for (const artifact of [...(target.installer?.artifacts ?? []), ...(target.updater?.artifacts ?? [])]) {
|
|
666
|
+
if (typeof artifact?.file !== "string" || !artifact.file) fail("installer and updater artifacts must each declare a file");
|
|
667
|
+
}
|
|
668
|
+
for (const artifact of target.updater?.artifacts ?? []) {
|
|
669
|
+
if (target.updater?.kind !== "appinstaller" && (typeof artifact.signature !== "string" || !artifact.signature)) fail(`updater artifact ${artifact.file} must declare the signature file sign-updater writes next to it`);
|
|
670
|
+
if (artifact.key === undefined && config.distribution?.provider !== "github-releases") fail(`updater artifact ${artifact.file} must declare the R2 route key (key); only github-releases names the route from the file`);
|
|
671
|
+
}
|
|
613
672
|
for (const artifact of target.installer?.artifacts ?? []) addSource(resolveArtifactPath(artifact.file), "installer");
|
|
614
673
|
for (const artifact of target.updater?.artifacts ?? []) {
|
|
615
674
|
addSource(resolveArtifactPath(artifact.file), "updater");
|
|
@@ -741,9 +800,9 @@ function sealRelease({ configRoot, managedCargoTarget, androidTarget, iosSpec, n
|
|
|
741
800
|
// an unsound seal, not a portable release.
|
|
742
801
|
signing: signingIdentity ? { ...signingIdentity, receipts: (() => {
|
|
743
802
|
const present = signingIdentity.receiptInputs.filter((file) => existsSync(file));
|
|
744
|
-
if (present.length === 0)
|
|
803
|
+
if (present.length === 0) fail(`no signing receipts found to seal; expected at least ${signingIdentity.receiptInputs[0]}`);
|
|
745
804
|
if (platform === "mac") {
|
|
746
|
-
if (present.length !== signingIdentity.receiptInputs.length)
|
|
805
|
+
if (present.length !== signingIdentity.receiptInputs.length) fail("missing current-build macOS signing or notarization receipt");
|
|
747
806
|
validateNotarizationReceipt({ receiptPath: signingIdentity.receiptInputs[1], archive: resolveArtifactPath(target.notarize.file) });
|
|
748
807
|
}
|
|
749
808
|
return present.map((file) => ({ file, sha256: hashFile(file) }));
|
|
@@ -941,7 +1000,7 @@ function commandExists(name) {
|
|
|
941
1000
|
|
|
942
1001
|
function assertSccacheVersion(version) {
|
|
943
1002
|
const match = String(version ?? "").match(/sccache\s+(\d+)\.(\d+)\.(\d+)/i);
|
|
944
|
-
if (!match || Number(match[1]) === 0 && Number(match[2]) < 15) fail(
|
|
1003
|
+
if (!match || Number(match[1]) === 0 && Number(match[2]) < 15) fail(sccacheRefusal(match ? match.slice(1).join(".") : null));
|
|
945
1004
|
}
|
|
946
1005
|
|
|
947
1006
|
function commandOutput(cmd, runArgs) {
|
|
@@ -1000,7 +1059,8 @@ function usage(code) {
|
|
|
1000
1059
|
|
|
1001
1060
|
function fail(message) {
|
|
1002
1061
|
console.error(`right-release build: ${message}`);
|
|
1003
|
-
|
|
1062
|
+
// `reported` marks an error whose text is already printed, so a caller that exits on it prints nothing twice.
|
|
1063
|
+
throw Object.assign(new Error(message), { reported: true });
|
|
1004
1064
|
}
|
|
1005
1065
|
|
|
1006
1066
|
function throwIfInterrupted() { if (interrupted) throw interrupted; }
|
package/cli/right-release.mjs
CHANGED
|
@@ -54,7 +54,17 @@ const commands = new Map([
|
|
|
54
54
|
|
|
55
55
|
const args = process.argv.slice(2);
|
|
56
56
|
const first = args[0];
|
|
57
|
+
// The commands that compile, package, sign, ship or inspect a release run the app check (../app-check.mjs, CHECKED_COMMANDS) in this
|
|
58
|
+
// process before they start a child. A red check prints its findings and exits 1; there is no flag that skips it.
|
|
59
|
+
async function requireGreenCheck() {
|
|
60
|
+
const { guardCommand } = await import("../app-check.mjs");
|
|
61
|
+
const refused = guardCommand({ args });
|
|
62
|
+
if (!refused) return;
|
|
63
|
+
for (const line of refused.lines) console.error(line);
|
|
64
|
+
process.exit(refused.exitCode);
|
|
65
|
+
}
|
|
57
66
|
if (first === "dev") {
|
|
67
|
+
await requireGreenCheck();
|
|
58
68
|
import("../development.mjs").then(({ main }) => main(args.slice(1))).catch((error) => {
|
|
59
69
|
console.error(`right-release dev: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
|
|
60
70
|
process.exitCode = 1;
|
|
@@ -81,6 +91,8 @@ if (!first || first === "-h" || first === "--help") {
|
|
|
81
91
|
process.exit(0);
|
|
82
92
|
}
|
|
83
93
|
|
|
94
|
+
await requireGreenCheck();
|
|
95
|
+
|
|
84
96
|
if (first === "--version" || first === "-v") {
|
|
85
97
|
run("release.mjs", ["--version"]);
|
|
86
98
|
} else if (first === "doctor") {
|
|
@@ -89,6 +101,18 @@ if (first === "--version" || first === "-v") {
|
|
|
89
101
|
} else {
|
|
90
102
|
run("release.mjs", ["--doctor", ...args.slice(1)]);
|
|
91
103
|
}
|
|
104
|
+
} else if (first === "check") {
|
|
105
|
+
// The same offline check as `rightkit-gate check`; the repository is the git top-level of --config's folder, else the cwd.
|
|
106
|
+
const { appDirectory, main } = await import("../app-check.mjs");
|
|
107
|
+
process.exit(await main(args.slice(1), { label: "check", cwd: appDirectory(args, process.cwd()) }));
|
|
108
|
+
} else if (first === "hooks" && args[1] === "install") {
|
|
109
|
+
// One installer: `hooks install` is `rightkit-gate install-hook` (pre-push check and gate, ownership pre-commit step) run in the app.
|
|
110
|
+
const app = args.indexOf("--app");
|
|
111
|
+
if (app >= 0 && (!args[app + 1] || args[app + 1].startsWith("-"))) {
|
|
112
|
+
console.error("right-release hooks install: --app needs a directory");
|
|
113
|
+
process.exit(2);
|
|
114
|
+
}
|
|
115
|
+
run("bin/rightkit-gate.mjs", ["install-hook", ...(args.includes("--force") ? ["--force"] : [])], { cwd: app >= 0 ? path.resolve(args[app + 1]) : process.cwd() });
|
|
92
116
|
} else if (first === "hooks") {
|
|
93
117
|
run("ownership-hooks.mjs", args.slice(1));
|
|
94
118
|
} else if (first === "suite-doctor") {
|
|
@@ -170,9 +194,9 @@ if (first === "--version" || first === "-v") {
|
|
|
170
194
|
process.exit(2);
|
|
171
195
|
}
|
|
172
196
|
|
|
173
|
-
function run(script, scriptArgs) {
|
|
197
|
+
function run(script, scriptArgs, { cwd = process.cwd() } = {}) {
|
|
174
198
|
const child = spawn(process.execPath, [path.join(packageRoot, script), ...scriptArgs], {
|
|
175
|
-
cwd
|
|
199
|
+
cwd,
|
|
176
200
|
env: process.env,
|
|
177
201
|
stdio: "inherit",
|
|
178
202
|
windowsHide: true,
|
|
@@ -270,7 +294,8 @@ Commands:
|
|
|
270
294
|
upload --platform ios --release <id> [--apply] Sealed IPA to TestFlight, then the exact build into the beta groups; dry run unless --apply (macOS)
|
|
271
295
|
ios-credentials store --key-id <KEYID> --issuer <uuid> | check
|
|
272
296
|
Put the App Store Connect API key (stdin), key id and issuer into the login Keychain once, or check them; never a file (macOS)
|
|
273
|
-
qa --stage source|installed --platform mac|win [--release <id>] Run rightkit-qa and write the sealed QA receipt
|
|
297
|
+
qa --stage source|installed --platform mac|win [--release <id>] Run rightkit-qa (and the journeys lane when the config sets qa.journeys) and write the sealed QA receipt. Refused inside a QA run. The receipt must match the platform and release id, come from an approved runner digest, and show every release-critical journey passed
|
|
298
|
+
Admission is per app in qa-admission-policy.json: warn (default) records a refusal and continues; refuse blocks build, upload and publish. Only adrian moves an app from refuse back to warn
|
|
274
299
|
qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>] List source/installed QA status per release; exit 1 if an uploaded build lacks passing QA
|
|
275
300
|
publish-update --release <id> --platform mac|win --tier patch|update
|
|
276
301
|
Upload sealed bytes to R2 & register RightApps
|
|
@@ -307,12 +332,21 @@ Commands:
|
|
|
307
332
|
apple profile renew [--config <file>] [--profile <file>] [--dry-run]
|
|
308
333
|
Renew macOS profile for current Keychain signer
|
|
309
334
|
signing-agent install|uninstall|status Manage invisible Aqua-session signing service
|
|
310
|
-
|
|
335
|
+
check [--repo <dir>] [--config <file>] [--commit <rev> | --worktree] [--only <rule,...>] [--list] [--print-baseline] [--baseline <file>]
|
|
336
|
+
The offline app check, same as rightkit-gate check: committed files only, every finding in one run (exit 0 pass, 1 findings, 2 could not run).
|
|
337
|
+
Build, package, dev, test, web-build, swift-package, recipe-build, stage-sidecar, qa, upload, publish, publish-update, github, doctor,
|
|
338
|
+
sign-windows, sign-updater, sign-candidate, sign-dev-artifact, addon build and create-mac-updater run it first and start nothing when it is red; there is no flag that skips it.
|
|
339
|
+
Baseline: .rightkit/app-check-baseline.json (entries carry issue, added and sunset; the sunset is at most 90 days after added)
|
|
340
|
+
doctor [--platform mac|win] [--gates] [--inspect] [--strict]
|
|
341
|
+
Inspect one app's release config the way the build would refuse it; --gates also runs the build's pre-build source gates
|
|
342
|
+
--inspect lists admission findings (newer RightKit versions) as information instead of refusing them
|
|
343
|
+
--strict makes a row that could not run on this host (NOT RUN) a blocking problem; without it NOT RUN is printed and does not fail
|
|
344
|
+
--admission is still accepted and does nothing (admission is the default); it cannot be combined with --inspect
|
|
311
345
|
doctor --all Verify all Right Suite app release contracts
|
|
312
346
|
suite-doctor Verify all local Right Suite repositories
|
|
313
347
|
ownership [--app <dir>] [--json] Fail on app copies of RightKit-owned capabilities
|
|
314
348
|
ownership --staged [--app <dir>] [--json] Pre-commit gate: scan the staged index for block-tier findings
|
|
315
|
-
hooks install [--app <dir>]
|
|
349
|
+
hooks install [--app <dir>] [--force] Alias of rightkit-gate install-hook: the pre-push check and gate, and the advisory pre-commit ownership gate (pinned @rightkit/release)
|
|
316
350
|
ownership enroll [--root] [--name <device>] [--root-fingerprint <sha256>] [--app <dir>]
|
|
317
351
|
Enroll one native key or create a signed-device request
|
|
318
352
|
ownership approve-key <fingerprint|request.json> Approve one pending device key from an active host
|
package/deterministic-scans.mjs
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
// repository has none. A deferred scan is listed by name (result.deferred, scanDeferrals), is never a
|
|
11
11
|
// pass (result.ok and result.complete are false while any scan is deferred) and cannot become a SCAN_B receipt.
|
|
12
12
|
// Content scans run on the exported HEAD (contentRoot). Only a scan that inherently needs the live
|
|
13
|
-
// checkout uses liveRoot: `
|
|
13
|
+
// checkout uses liveRoot: `rustright cargo` scans (the broker binds to the registered checkout), the
|
|
14
14
|
// tsc, eslint and package tests (need installed node_modules).
|
|
15
15
|
// Class B runs only when every selected class A scan passed (SCAN_B is distinct from SCAN_A and
|
|
16
|
-
// follows it). Rust commands go through `
|
|
16
|
+
// follows it). Rust commands go through `rustright cargo`, JavaScript tools through `pnpm exec`.
|
|
17
17
|
// The runner takes injectable spawn/exists/readFile/readdir so tests execute nothing real. The
|
|
18
18
|
// result is a plain object (per-scan status, tool versions, scanSetDigest) usable as receipt content
|
|
19
19
|
// (packages/release/pipeline-receipts.mjs); it carries no authority by itself.
|
|
@@ -34,7 +34,7 @@ export const SCAN_CLASS = Object.freeze({ A: "A", B: "B" });
|
|
|
34
34
|
export const CLASS_A_SCAN_PREFIXES = Object.freeze([
|
|
35
35
|
"rustfmt", "node-check", "json-parse", "toml-parse", "yaml-parse", "ownership-catalog",
|
|
36
36
|
"actionlint", "pnpm-lockfile", "pnpm-audit", "cargo-metadata", "cargo-deny", "cargo-audit", "cargo-package-list",
|
|
37
|
-
"changelog-version", "license", "secret-scan", "path-policy", "advisory-exceptions",
|
|
37
|
+
"changelog-version", "license", "secret-scan", "path-policy", "advisory-exceptions", "suite-api",
|
|
38
38
|
]);
|
|
39
39
|
export const CLASS_B_SCAN_PREFIXES = Object.freeze([
|
|
40
40
|
"clippy", "check", "check-all-features", "check-features", "check-dependent", "doc", "tsc", "eslint", "swift-build", "test", "generated-docs",
|
|
@@ -205,10 +205,13 @@ const WORKSPACE_JS_FILE = /^(package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml|
|
|
|
205
205
|
const ESLINT_FILES = ["eslint.config.js", "eslint.config.mjs", ".eslintrc.json"];
|
|
206
206
|
const ROOT_LINT_FILES = [...ESLINT_FILES, "biome.json"];
|
|
207
207
|
// Everything scripts/generate-index.mjs reads or writes (its header and loadInventory): crate and package manifests and
|
|
208
|
-
// READMEs, the root Cargo.toml (workspace version), the versions and ownership data, the ownership-availability helper it imports, itself,
|
|
209
|
-
|
|
208
|
+
// READMEs, the root Cargo.toml (workspace version), the versions and ownership data, the ownership-availability helper it imports, itself,
|
|
209
|
+
// the port matrix generator it imports, and its outputs (the port matrix's MD and HTML are checked for consistency with its JSON).
|
|
210
|
+
// The Right Suite API contract and every projection scripts/gen-suite-api.mjs writes from it (the test-vector copy included).
|
|
211
|
+
const SUITE_API_INPUT = /^(contracts\/right-suite-api\.v1\.json|scripts\/gen-suite-api\.mjs|crates\/rightkit-license\/src\/suite_api_generated\.rs|packages\/app-update\/src\/suite-api\.generated\.ts|packages\/release\/suite-api\.generated\.mjs|crates\/rightkit-license\/test-vectors\/right-suite-api\.v1\.json)$/;
|
|
212
|
+
const GENERATOR_INPUT = /^(crates\/[^/]+\/(Cargo\.toml|README\.md)|packages\/[^/]+\/(package\.json|README\.md)|Cargo\.toml|packages\/release\/(rightkit-versions\.json|rightkit-ownership\.json|ownership-availability\.mjs)|scripts\/generate-(index|port-matrix)\.mjs|INDEX\.md|docs\/(ADOPTION|CAPABILITY-MAP|PORT-MATRIX)\.md|docs\/port-matrix\.(html|json))$/;
|
|
210
213
|
const PROBES = Object.freeze({
|
|
211
|
-
rustfmt: ["--version"], node: ["--version"],
|
|
214
|
+
rustfmt: ["--version"], node: ["--version"], rustright: ["cargo", "--version"], pnpm: ["--version"],
|
|
212
215
|
actionlint: ["-version"], "right-git": ["--version"], swift: ["--version"],
|
|
213
216
|
});
|
|
214
217
|
|
|
@@ -221,7 +224,7 @@ function commandText(d) {
|
|
|
221
224
|
/**
|
|
222
225
|
* The scan plan for a change: derived from the changed files plus the manifests they belong to.
|
|
223
226
|
* `contentRoot` is a clean export of the commit (pure content checks); `liveRoot` is the checkout the
|
|
224
|
-
* managed `
|
|
227
|
+
* managed `rustright cargo` and `pnpm` run in. A scan whose files did not change is not planned.
|
|
225
228
|
*/
|
|
226
229
|
export function planScans({
|
|
227
230
|
changed = [], contentRoot, liveRoot = contentRoot, exists = fs.existsSync, readFile = (f) => fs.readFileSync(f, "utf8"),
|
|
@@ -230,8 +233,8 @@ export function planScans({
|
|
|
230
233
|
// CI shards (one crate per runner, where the port, pipe, desktop and wall-clock conflicts that force the serial lane do not exist)
|
|
231
234
|
// and records a note naming them (Adrian 2026-10-22: compile locally on the warm cache, qualify the conflicting tests on VMs).
|
|
232
235
|
quietTests = "local",
|
|
233
|
-
// The command prefix every cargo scan runs through: `
|
|
234
|
-
cargoCommand = ["
|
|
236
|
+
// The command prefix every cargo scan runs through: `rustright cargo` on a managed host, plain `cargo` in CI.
|
|
237
|
+
cargoCommand = ["rustright", "cargo"],
|
|
235
238
|
// Physical containment: a changed path whose real path leaves the real content root (a symlink) is refused.
|
|
236
239
|
realpath = (p) => fs.realpathSync(p),
|
|
237
240
|
// Bytes of a changed file for the input digest (defaults to readFile; the gates pass the raw reader).
|
|
@@ -260,8 +263,8 @@ export function planScans({
|
|
|
260
263
|
rustLocks = null,
|
|
261
264
|
} = {}) {
|
|
262
265
|
if (!contentRoot) throw new TypeError("planScans: contentRoot is required");
|
|
263
|
-
if (!Array.isArray(cargoCommand) || cargoCommand.length === 0 || !["
|
|
264
|
-
throw new TypeError('planScans: cargoCommand must be ["
|
|
266
|
+
if (!Array.isArray(cargoCommand) || cargoCommand.length === 0 || !["rustright", "cargo"].includes(cargoCommand[0]) || cargoCommand.slice(1).some((a) => a !== "cargo")) {
|
|
267
|
+
throw new TypeError('planScans: cargoCommand must be ["rustright", "cargo"] or ["cargo"]');
|
|
265
268
|
}
|
|
266
269
|
const refusals = []; const accepted = [];
|
|
267
270
|
for (const raw of new Set(changed)) {
|
|
@@ -356,10 +359,25 @@ export function planScans({
|
|
|
356
359
|
// ---- class A: content checks on the exported commit
|
|
357
360
|
// A vendor/ file keeps upstream formatting only when vendor/upstream-files.json records it and its
|
|
358
361
|
// bytes still match that upstream digest; any other vendor/ .rs (new, patched) is rustfmt-checked.
|
|
359
|
-
|
|
360
|
-
|
|
362
|
+
// A patched vendor/ file (rightkit#915) keeps upstream formatting only while its bytes match the
|
|
363
|
+
// reviewed `patched` record, which names its upstream base digest and the note describing the patch;
|
|
364
|
+
// reformatting it would bury the patch. Any edit changes the digest, so the file is checked until re-recorded.
|
|
365
|
+
let upstream = {}; let patched = {};
|
|
366
|
+
try {
|
|
367
|
+
const manifest = JSON.parse(readFile(path.join(contentRoot, "vendor/upstream-files.json")));
|
|
368
|
+
upstream = manifest.files ?? {}; patched = manifest.patched ?? {};
|
|
369
|
+
} catch { /* no manifest: nothing exempt */ }
|
|
370
|
+
const DIGEST = /^[0-9a-f]{64}$/;
|
|
361
371
|
const verbatimUpstream = (f) => f.startsWith("vendor/") && typeof upstream[f] === "string" && bytesDigest(f) === upstream[f];
|
|
362
|
-
const
|
|
372
|
+
const recordedPatch = (f) => f.startsWith("vendor/") && DIGEST.test(patched[f]?.base ?? "") && typeof patched[f]?.note === "string"
|
|
373
|
+
&& patched[f].note.startsWith("vendor/") && DIGEST.test(patched[f]?.sha256 ?? "") && bytesDigest(f) === patched[f].sha256;
|
|
374
|
+
// Generated Rust is not hand-formatted: bindgen output, or rustfmt's own `@generated` marker in the first five lines.
|
|
375
|
+
const generatedRust = (f) => {
|
|
376
|
+
let head = "";
|
|
377
|
+
try { head = readFile(path.join(contentRoot, f)).split("\n", 5).join("\n"); } catch { return false; }
|
|
378
|
+
return /@generated\b|automatically generated by rust-bindgen\b/.test(head);
|
|
379
|
+
};
|
|
380
|
+
const rustFiles = files.filter((f) => f.endsWith(".rs") && !verbatimUpstream(f) && !recordedPatch(f) && !generatedRust(f));
|
|
363
381
|
const byEdition = new Map();
|
|
364
382
|
for (const f of rustFiles) {
|
|
365
383
|
let dir = path.dirname(path.join(contentRoot, f));
|
|
@@ -434,6 +452,9 @@ export function planScans({
|
|
|
434
452
|
|
|
435
453
|
// Exceptions expire by date, not by change: checked on every run, whatever changed.
|
|
436
454
|
if (exists(path.join(contentRoot, "ci/advisory-exceptions.json"))) add({ id: "advisory-exceptions", class: A, internal: "advisory-exceptions" });
|
|
455
|
+
// Like generated-docs, planned only when a changed file is one of the contract's inputs or projections. The generator runs from
|
|
456
|
+
// the trusted scanner checkout (the import below); the exported contract and projections are data it checks.
|
|
457
|
+
if (files.some((f) => SUITE_API_INPUT.test(f)) && exists(path.join(contentRoot, "contracts/right-suite-api.v1.json"))) add({ id: "suite-api", class: A, internal: "suite-api" });
|
|
437
458
|
if (rustTouched) {
|
|
438
459
|
for (const ws of touchedWs) add({ id: `cargo-metadata${wsIdSuffix(ws)}`, class: A, ...cargoIn(ws, ...metadataArgs()) });
|
|
439
460
|
for (const name of crateNames.filter((n) => rustMap?.get(n).publish !== false)) add({ id: rustScanId.packageList(name), class: A, ...cargoIn(wsOf(name), "package", "-p", crateOf(name), "--list", "--allow-dirty", "--locked") });
|
|
@@ -578,7 +599,7 @@ export function planScans({
|
|
|
578
599
|
const deferred = [];
|
|
579
600
|
if (!localCompile) {
|
|
580
601
|
scans = scans.map((x) => {
|
|
581
|
-
if (x.tool !== "
|
|
602
|
+
if (x.tool !== "rustright" && !(x.tool === "cargo" && cargoCommand.length === 1)) return x;
|
|
582
603
|
deferred.push(x.id);
|
|
583
604
|
return { ...x, deferred: "no local compile grant; the broker refuses this scan" };
|
|
584
605
|
});
|
|
@@ -758,6 +779,12 @@ const INTERNAL = {
|
|
|
758
779
|
// Every RustSec exception is dated, scoped to the locked versions and identical in deny.toml and
|
|
759
780
|
// .cargo/audit.toml (ci/advisory-exceptions.json; Astra 2026-10-22). Expiry fails every run.
|
|
760
781
|
"advisory-exceptions": (s, ctx) => checkAdvisoryExceptions({ root: ctx.contentRoot, readFile: ctx.read }),
|
|
782
|
+
// Every Right Suite API projection must equal what the generator writes from the contract. The pure check is imported from the
|
|
783
|
+
// scanner's own checkout (scripts/ sits beside packages/release), never from the content root.
|
|
784
|
+
"suite-api": async (s, ctx) => {
|
|
785
|
+
const { suiteApiProblems } = await import("../../scripts/gen-suite-api.mjs");
|
|
786
|
+
return suiteApiProblems((f) => ctx.read(f));
|
|
787
|
+
},
|
|
761
788
|
"ownership-catalog": (s, ctx) => (ctx.read(s.files[0]) === ctx.read(s.files[1]) ? [] : ["copy packages/release/rightkit-ownership.json to crates/rightkit-ownership/catalog/ownership-catalog.json"]),
|
|
762
789
|
"changelog-version": (s, ctx) => {
|
|
763
790
|
const version = (ctx.read(s.files[0]).match(/^version\s*=\s*"([^"]+)"/m) || [])[1];
|