@rightkit/release 0.2.173 → 0.3.0
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 +188 -5
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/cli/right-release.mjs +34 -6
- package/command-spec.mjs +80 -0
- package/deps-cargo.mjs +55 -0
- package/deps.mjs +7 -0
- package/development-signing.mjs +135 -4
- package/development.mjs +12 -11
- package/dist-scan.mjs +102 -0
- package/dmg-build.mjs +53 -4
- package/icon-pack.mjs +117 -0
- package/in-place-install.mjs +32 -7
- package/init-command.mjs +116 -0
- package/install-app-windows.mjs +108 -17
- package/install-app.mjs +68 -11
- package/install-tauri-devid.mjs +106 -47
- package/local-publish.mjs +317 -29
- package/mac-bundle-signing.mjs +143 -1
- package/mac-signing-matrix.mjs +73 -6
- package/macho-approval-hash.mjs +181 -0
- package/macos-signing-session.mjs +12 -2
- package/managed-cargo.mjs +49 -1
- package/media-sidecar.mjs +20 -4
- package/mirror-root-artifact.mjs +69 -21
- package/npm-license-notices.mjs +159 -0
- package/ownership-dependency-allowlist.json +1 -0
- package/ownership.mjs +37 -4
- package/package-recipe-targets.mjs +180 -16
- package/package.json +1 -1
- package/pe-content.mjs +133 -0
- package/portable-development.mjs +9 -9
- package/publication-gate.mjs +70 -3
- package/publish-cargo.mjs +26 -7
- package/qa-build-features.mjs +7 -1
- package/recipe-build.mjs +346 -0
- package/release-dependencies-command.mjs +45 -0
- package/release-dependencies.mjs +60 -0
- package/release-switch-scan.mjs +17 -7
- package/release.mjs +98 -12
- package/rightkit-ownership.json +46 -2
- package/rightkit-signing-agent.mjs +10 -4
- package/rightkit-versions.json +11 -6
- package/runtime-budgets.mjs +28 -12
- package/stage-binary.mjs +1 -1
- package/stage-sidecar-command.mjs +36 -4
- package/stage-sidecar.mjs +926 -74
- package/test-command.mjs +190 -0
- package/toolchain-swift.mjs +239 -0
- package/web-build.mjs +67 -0
package/pe-content.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Signature-independent PE content digest and import-closure check (bogusyogi/rightkit#445).
|
|
3
|
+
// right-release pe digest <file> [--algorithm sha256|sha384|sha512] [--json]
|
|
4
|
+
// right-release pe imports <file> [--allow <dll[,dll...]>]... [--allow-file <path>] [--json]
|
|
5
|
+
// Pure Buffer parsing, so both run on any host. The digest follows the Authenticode image hash: the CheckSum field,
|
|
6
|
+
// the Certificate Table directory entry and the certificate data itself are excluded, so re-signing or timestamping
|
|
7
|
+
// a binary never changes it.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { readFileSync } from "node:fs";
|
|
10
|
+
import { readPeImports } from "./pe-imports.mjs";
|
|
11
|
+
|
|
12
|
+
const ALGORITHMS = new Set(["sha256", "sha384", "sha512"]);
|
|
13
|
+
function fail(message, code = "PE_INVALID") { throw Object.assign(new Error(message), { code }); }
|
|
14
|
+
|
|
15
|
+
function layout(buf) {
|
|
16
|
+
if (buf.length < 0x40 || buf.readUInt16LE(0) !== 0x5a4d) fail("not a PE file (missing MZ)");
|
|
17
|
+
const pe = buf.readUInt32LE(0x3c);
|
|
18
|
+
if (pe + 24 > buf.length || buf.readUInt32LE(pe) !== 0x00004550) fail("not a PE file (missing PE signature)");
|
|
19
|
+
const sectionCount = buf.readUInt16LE(pe + 6);
|
|
20
|
+
const optionalSize = buf.readUInt16LE(pe + 20);
|
|
21
|
+
const optional = pe + 24;
|
|
22
|
+
const magic = buf.readUInt16LE(optional);
|
|
23
|
+
if (magic !== 0x20b && magic !== 0x10b) fail("unsupported PE optional header");
|
|
24
|
+
const directories = optional + (magic === 0x20b ? 112 : 96);
|
|
25
|
+
const numberOfDirectories = buf.readUInt32LE(directories - 4);
|
|
26
|
+
const checksumAt = optional + 64;
|
|
27
|
+
const sizeOfHeaders = buf.readUInt32LE(optional + 60);
|
|
28
|
+
const certEntryAt = directories + 4 * 8;
|
|
29
|
+
const hasCert = numberOfDirectories > 4;
|
|
30
|
+
if (checksumAt + 4 > buf.length || sizeOfHeaders > buf.length || (hasCert && certEntryAt + 8 > buf.length)) fail("truncated PE headers");
|
|
31
|
+
const sectionsAt = optional + optionalSize;
|
|
32
|
+
const sections = [];
|
|
33
|
+
for (let i = 0; i < sectionCount; i++) {
|
|
34
|
+
const at = sectionsAt + i * 40;
|
|
35
|
+
if (at + 40 > buf.length) fail("truncated PE section table");
|
|
36
|
+
const rawSize = buf.readUInt32LE(at + 16);
|
|
37
|
+
const rawPtr = buf.readUInt32LE(at + 20);
|
|
38
|
+
if (rawSize > 0 && rawPtr + rawSize > buf.length) fail("PE section extends past the end of the file");
|
|
39
|
+
sections.push({ rawSize, rawPtr });
|
|
40
|
+
}
|
|
41
|
+
// The certificate table directory holds a FILE OFFSET (not an RVA).
|
|
42
|
+
const cert = hasCert ? { offset: buf.readUInt32LE(certEntryAt), size: buf.readUInt32LE(certEntryAt + 4) } : { offset: 0, size: 0 };
|
|
43
|
+
if (cert.size === 0 && cert.offset !== 0) fail("PE certificate table has an offset but no size");
|
|
44
|
+
if (cert.size > 0) {
|
|
45
|
+
if (cert.offset + cert.size > buf.length) fail("PE certificate table extends past the end of the file");
|
|
46
|
+
// The certificate table must be the terminal region: bytes after it would otherwise escape the digest.
|
|
47
|
+
if (cert.offset + cert.size !== buf.length) fail("PE certificate table is not the terminal region of the file (bytes follow it)");
|
|
48
|
+
if (cert.offset < sizeOfHeaders) fail("PE certificate table overlaps the headers");
|
|
49
|
+
for (const s of sections) if (s.rawSize > 0 && s.rawPtr + s.rawSize > cert.offset) fail("PE certificate table overlaps a section");
|
|
50
|
+
}
|
|
51
|
+
return { checksumAt, sizeOfHeaders, certEntryAt, hasCert, sections, cert };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Authenticode-style SHA-2 digest of a PE image, independent of its signature, timestamp and CheckSum. */
|
|
55
|
+
export function peContentDigest(bytes, { algorithm = "sha256" } = {}) {
|
|
56
|
+
if (!ALGORITHMS.has(algorithm)) fail(`unsupported digest algorithm: ${algorithm}`, "PE_USAGE");
|
|
57
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
|
|
58
|
+
const l = layout(buf);
|
|
59
|
+
const hash = createHash(algorithm);
|
|
60
|
+
hash.update(buf.subarray(0, l.checksumAt));
|
|
61
|
+
if (l.hasCert) {
|
|
62
|
+
hash.update(buf.subarray(l.checksumAt + 4, l.certEntryAt));
|
|
63
|
+
hash.update(buf.subarray(l.certEntryAt + 8, l.sizeOfHeaders));
|
|
64
|
+
} else {
|
|
65
|
+
hash.update(buf.subarray(l.checksumAt + 4, l.sizeOfHeaders));
|
|
66
|
+
}
|
|
67
|
+
// Every byte after the headers up to the certificate table (or end of file) is hashed: sections, inter-section gaps and overlay.
|
|
68
|
+
const trailEnd = l.cert.size > 0 ? l.cert.offset : buf.length;
|
|
69
|
+
if (trailEnd > l.sizeOfHeaders) hash.update(buf.subarray(l.sizeOfHeaders, trailEnd));
|
|
70
|
+
return hash.digest("hex");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const normalize = (name) => String(name).trim().toLowerCase();
|
|
74
|
+
|
|
75
|
+
function allowMatcher(allowed) {
|
|
76
|
+
const entries = allowed.map(normalize).filter(Boolean);
|
|
77
|
+
return (dll) => entries.some((entry) => (entry.endsWith("*") ? dll.startsWith(entry.slice(0, -1)) : dll === entry));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check that every DLL a PE imports (regular and delay-load) is in `allowedDlls`. Names compare case-insensitively;
|
|
82
|
+
* a trailing `*` allows a prefix (`api-ms-win-*`). An empty allow list refuses every import.
|
|
83
|
+
* @returns {{ok: boolean, architecture: string, imports: string[], delayImports: string[], unexpected: string[]}}
|
|
84
|
+
*/
|
|
85
|
+
export function verifyImportClosure(bytes, allowedDlls) {
|
|
86
|
+
if (!Array.isArray(allowedDlls)) fail("allowedDlls must be an array", "PE_USAGE");
|
|
87
|
+
const info = readPeImports(bytes);
|
|
88
|
+
const allowed = allowMatcher(allowedDlls);
|
|
89
|
+
const all = [...new Set([...info.imports, ...info.delayImports].map(normalize))].sort();
|
|
90
|
+
const unexpected = all.filter((dll) => !allowed(dll));
|
|
91
|
+
return { ok: unexpected.length === 0, architecture: info.architecture, imports: info.imports, delayImports: info.delayImports, unexpected };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const USAGE = "usage: right-release pe digest <file> [--algorithm sha256|sha384|sha512] [--json] | pe imports <file> [--allow dll,...] [--allow-file <path>] [--json]";
|
|
95
|
+
|
|
96
|
+
function parseArgs(argv) {
|
|
97
|
+
const [sub, file, ...rest] = argv;
|
|
98
|
+
const opts = { sub, file, algorithm: "sha256", allow: [], json: false };
|
|
99
|
+
for (let i = 0; i < rest.length; i++) {
|
|
100
|
+
const arg = rest[i];
|
|
101
|
+
if (arg === "--json") opts.json = true;
|
|
102
|
+
else if (["--algorithm", "--allow", "--allow-file"].includes(arg)) {
|
|
103
|
+
const value = rest[++i];
|
|
104
|
+
if (value === undefined || value.startsWith("--")) fail(`${arg} needs a value`, "PE_USAGE");
|
|
105
|
+
if (arg === "--algorithm") opts.algorithm = value;
|
|
106
|
+
else if (arg === "--allow") opts.allow.push(...value.split(","));
|
|
107
|
+
else opts.allow.push(...readFileSync(value, "utf8").split(/\r?\n/).map((l) => l.replace(/#.*/, "").trim()).filter(Boolean));
|
|
108
|
+
} else fail(`unknown pe argument: ${arg}`, "PE_USAGE");
|
|
109
|
+
}
|
|
110
|
+
if (!["digest", "imports"].includes(sub) || !file) fail(USAGE, "PE_USAGE");
|
|
111
|
+
return opts;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function main(argv = process.argv.slice(2), out = console) {
|
|
115
|
+
let opts;
|
|
116
|
+
try { opts = parseArgs(argv); } catch (error) { out.error(error.message); return 2; }
|
|
117
|
+
try {
|
|
118
|
+
const bytes = readFileSync(opts.file);
|
|
119
|
+
if (opts.sub === "digest") {
|
|
120
|
+
const digest = peContentDigest(bytes, { algorithm: opts.algorithm });
|
|
121
|
+
out.log(opts.json ? JSON.stringify({ file: opts.file, algorithm: opts.algorithm, digest }) : `${opts.algorithm}:${digest}`);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
const result = verifyImportClosure(bytes, opts.allow);
|
|
125
|
+
out.log(opts.json ? JSON.stringify({ file: opts.file, ...result }) : [`${opts.file} (${result.architecture})`, ...result.imports.map((d) => ` import ${d}`), ...result.delayImports.map((d) => ` delay-import ${d}`), ...result.unexpected.map((d) => ` UNEXPECTED ${d}`)].join("\n"));
|
|
126
|
+
return result.ok ? 0 : 1;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
out.error(error.message);
|
|
129
|
+
return error.code === "PE_USAGE" ? 2 : 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href) process.exit(main());
|
package/portable-development.mjs
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import {
|
|
4
|
+
import { stagingPathOf, swapInPlace } from "./in-place-install.mjs";
|
|
5
5
|
|
|
6
6
|
const CHUNK_BYTES = 1024 * 1024;
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Install an unsigned development payload in place at the fixed path `<installRoot>/current` (a real directory).
|
|
10
|
-
* No versions/ folder, no digest folder, no symlink or junction.
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* No versions/ folder, no digest folder, no symlink or junction. NO old copy is kept (Orthic-Labs/rightkit#348): the
|
|
11
|
+
* old `current` is removed and the verified staging copy is renamed over it, and a stale `current.previous` left by an
|
|
12
|
+
* older RightKit is removed. A failed install leaves `current` absent or partial; reinstall from an older build. A
|
|
13
|
+
* legacy `current` symlink or junction from the old layout is removed the same way.
|
|
13
14
|
*/
|
|
14
15
|
export function installPortableDevelopment(descriptor, { root, platform = process.platform, env = process.env } = {}) {
|
|
15
16
|
const emit = (event, fields = {}) => process.stdout.write(`${JSON.stringify({ event: `DEV_INSTALL_${event}`, ...fields })}
|
|
@@ -33,7 +34,6 @@ export function installPortableDevelopment(descriptor, { root, platform = proces
|
|
|
33
34
|
digest = digestTree(source);
|
|
34
35
|
current = path.join(installRoot, "current");
|
|
35
36
|
const staging = stagingPathOf(current);
|
|
36
|
-
const previous = previousPathOf(current);
|
|
37
37
|
emit("STAGE", { sourceDirectory: source, current, staging, digest });
|
|
38
38
|
if (exists(current) && !fs.lstatSync(current).isDirectory() && !fs.lstatSync(current).isSymbolicLink()) throw new Error(`development current is not a directory; refusing to replace: ${current}`);
|
|
39
39
|
|
|
@@ -49,15 +49,15 @@ export function installPortableDevelopment(descriptor, { root, platform = proces
|
|
|
49
49
|
const sourceEntryDigest = hashFile(sourceEntrypoint);
|
|
50
50
|
let entryDigest;
|
|
51
51
|
swapInPlace({
|
|
52
|
-
staging, destination: current, previous,
|
|
52
|
+
staging, destination: current, previous: null,
|
|
53
53
|
verify: (destination) => {
|
|
54
54
|
entryDigest = hashFile(resolveContained(destination, descriptor.entrypoint, "entrypoint"));
|
|
55
55
|
if (entryDigest !== sourceEntryDigest) throw new Error("development entrypoint readback did not match source");
|
|
56
56
|
},
|
|
57
57
|
});
|
|
58
|
-
emit("ACTIVATE", { current
|
|
59
|
-
emit("SUCCEEDED", { installRoot, current,
|
|
60
|
-
return { installRoot, current,
|
|
58
|
+
emit("ACTIVATE", { current });
|
|
59
|
+
emit("SUCCEEDED", { installRoot, current, digest, entrypoint: descriptor.entrypoint, entrypointDigest: entryDigest });
|
|
60
|
+
return { installRoot, current, versionRoot: current, digest, entrypoint: path.join(current, descriptor.entrypoint), entrypointDigest: entryDigest };
|
|
61
61
|
} catch (error) {
|
|
62
62
|
emit("FAILED", { installRoot: descriptor?.installRoot, current, digest, message: error?.message ?? String(error) });
|
|
63
63
|
throw error;
|
package/publication-gate.mjs
CHANGED
|
@@ -48,11 +48,73 @@ function describeCommitMismatch(evidenceCommit, head, packagePath, git) {
|
|
|
48
48
|
return parts.join("; ");
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
const text = (value) => typeof value === "string" && value.trim() !== "";
|
|
52
|
+
const escapeRegExp = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
53
|
+
|
|
54
|
+
// A batch publication (local-publish.mjs --crates) runs fmt, the secret scan and clippy ONCE for the whole
|
|
55
|
+
// batch at one commit and tree, then copies that receipt into every crate's evidence run as `run.batch`.
|
|
56
|
+
// The receipt proves, for THIS crate, the same three checks a per-crate run proves; it is bound to the evidence
|
|
57
|
+
// commit and to the HEAD tree (the git tree of that commit), so it cannot be carried to another commit.
|
|
58
|
+
export const BATCH_CHECKS = Object.freeze(["fmt", "secret-scan", "clippy"]);
|
|
59
|
+
const BATCH_COMMAND = Object.freeze({
|
|
60
|
+
fmt: /\bfmt\b.*--check\b/,
|
|
61
|
+
"secret-scan": /\S/,
|
|
62
|
+
clippy: /\bclippy\b.*--all-targets\b.*-D warnings\s*$/,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** Problems with a batch receipt for the crate `evidence.name` (empty when it proves fmt, secret scan and clippy).
|
|
66
|
+
* `tree` is the current HEAD tree when known; `undefined` skips only that comparison. */
|
|
67
|
+
export function batchReceiptProblems(batch, evidence, tree) {
|
|
68
|
+
if (!batch || typeof batch !== "object" || Array.isArray(batch)) return ["batch receipt must be an object"];
|
|
69
|
+
const problems = [];
|
|
70
|
+
if (!text(batch.id)) problems.push("batch.id");
|
|
71
|
+
if (batch.commit !== evidence.commit) problems.push(`batch.commit (must equal ${evidence.commit})`);
|
|
72
|
+
if (!SHA.test(String(batch.tree ?? ""))) problems.push("batch.tree (must be a 40-hex git tree)");
|
|
73
|
+
else if (tree !== undefined && batch.tree !== tree) problems.push(`batch.tree (must equal the HEAD tree ${tree})`);
|
|
74
|
+
if (!(Array.isArray(batch.crates) && batch.crates.includes(evidence.name))) problems.push("batch.crates (must name this crate)");
|
|
75
|
+
const checks = Array.isArray(batch.checks) ? batch.checks : [];
|
|
76
|
+
for (const name of BATCH_CHECKS) {
|
|
77
|
+
const rows = checks.filter((row) => row?.name === name);
|
|
78
|
+
if (!rows.length) { problems.push(`batch check ${name} missing`); continue; }
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
if (row.ok !== true) problems.push(`batch check ${name} not ok`);
|
|
81
|
+
if (row.commit !== evidence.commit || row.tree !== batch.tree) problems.push(`batch check ${name} not bound to the batch commit and tree`);
|
|
82
|
+
if (!(text(row.command) && BATCH_COMMAND[name].test(row.command))) problems.push(`batch check ${name} command`);
|
|
83
|
+
}
|
|
84
|
+
// fmt is workspace-wide; clippy and the secret scan name the crates they covered.
|
|
85
|
+
if (name !== "fmt" && !rows.some((row) => Array.isArray(row.crates) && row.crates.includes(evidence.name))) problems.push(`batch check ${name} does not cover this crate`);
|
|
86
|
+
}
|
|
87
|
+
return problems;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Which per-crate publication steps the evidence ALREADY proves for this exact commit, so the publisher may
|
|
92
|
+
* skip repeating them: `tests` (a passing `cargo test -p <name>` step that executed tests), `clippy` (a passing
|
|
93
|
+
* `cargo clippy -p <name> ... -D warnings` step, or a valid batch receipt covering the crate), and `fmt` and
|
|
94
|
+
* `secretScan` (a valid batch receipt covering the crate). Every darwin run at the evidence commit must prove a
|
|
95
|
+
* step for it to count. Call after the gate accepted the evidence (the gate binds commit, tree and artifact).
|
|
96
|
+
*/
|
|
97
|
+
export function provenGateSteps(evidence) {
|
|
98
|
+
const none = { tests: false, clippy: false, fmt: false, secretScan: false };
|
|
99
|
+
if (!evidence || !SHA.test(String(evidence.commit ?? "")) || !text(evidence.name)) return none;
|
|
100
|
+
const runs = (Array.isArray(evidence.runs) ? evidence.runs : []).filter((run) => run?.host?.os === "darwin");
|
|
101
|
+
if (!runs.length || runs.some((run) => run.commit !== evidence.commit)) return none;
|
|
102
|
+
const name = escapeRegExp(evidence.name);
|
|
103
|
+
const stepProves = (run, pattern, needTests) => (Array.isArray(run.steps) ? run.steps : []).some((step) => step?.ok === true
|
|
104
|
+
&& pattern.test(String(step.command ?? "")) && (!needTests || (step.executed > 0 && step.failed === 0)));
|
|
105
|
+
const batchOk = (run) => run.batch !== undefined && batchReceiptProblems(run.batch, evidence, undefined).length === 0;
|
|
106
|
+
return {
|
|
107
|
+
tests: runs.every((run) => stepProves(run, new RegExp(`^cargo test -p ${name}( |$)`), true)),
|
|
108
|
+
clippy: runs.every((run) => batchOk(run) || stepProves(run, new RegExp(`^cargo clippy -p ${name} .*-- -D warnings$`), false)),
|
|
109
|
+
fmt: runs.every(batchOk),
|
|
110
|
+
secretScan: runs.every(batchOk),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
51
114
|
// Names of the fields of one host run that do not qualify (empty when the run qualifies). Every run binds to
|
|
52
115
|
// the same unit, commit and artifact: commit, host arch, feature set and artifactSha256 equal to the evidence.
|
|
53
|
-
function invalidRunFields(run, evidence) {
|
|
116
|
+
function invalidRunFields(run, evidence, tree) {
|
|
54
117
|
const bad = [];
|
|
55
|
-
const text = (value) => typeof value === "string" && value.trim() !== "";
|
|
56
118
|
if (run.commit !== evidence.commit) bad.push(`commit (must equal ${evidence.commit})`);
|
|
57
119
|
if (!text(run.host?.arch)) bad.push("host.arch");
|
|
58
120
|
if (!(Array.isArray(run.features) && run.features.length > 0 && run.features.every(text))) bad.push("features");
|
|
@@ -62,6 +124,7 @@ function invalidRunFields(run, evidence) {
|
|
|
62
124
|
if (!text(run.timestamp)) bad.push("timestamp");
|
|
63
125
|
if (!(Number.isInteger(run.passed) && run.passed > 0)) bad.push("passed (must be > 0)");
|
|
64
126
|
if (run.failed !== 0) bad.push("failed (must be 0)");
|
|
127
|
+
if (run.batch !== undefined) bad.push(...batchReceiptProblems(run.batch, evidence, tree ?? null).map((problem) => `batch: ${problem}`));
|
|
65
128
|
return bad;
|
|
66
129
|
}
|
|
67
130
|
|
|
@@ -88,6 +151,10 @@ export function checkPublicationGate({ kind, name, version, packagePath, evidenc
|
|
|
88
151
|
missing.push(`evidence commit ${evidence.commit} != current HEAD ${head} (${describeCommitMismatch(evidence.commit, head, packagePath, git)})`);
|
|
89
152
|
}
|
|
90
153
|
const runs = Array.isArray(evidence.runs) ? evidence.runs : [];
|
|
154
|
+
// A batch receipt is bound to the HEAD tree as well as the commit; read it only when a run carries one.
|
|
155
|
+
// `tree ?? null` below makes an unreadable tree fail the comparison (null never equals a 40-hex tree).
|
|
156
|
+
const tree = runs.some((run) => run?.batch !== undefined) ? git(["rev-parse", "HEAD^{tree}"], packagePath) : undefined;
|
|
157
|
+
if (tree === null) missing.push(`cannot read the HEAD tree for ${packagePath}`);
|
|
91
158
|
// npm packages and crates may ship on macOS evidence first when Adrian decides so; the decision must be
|
|
92
159
|
// recorded in the evidence (hostPolicy "darwin-only" + a non-empty hostPolicyReason).
|
|
93
160
|
// Crates may ship Mac-first too (Adrian 2026-10-07): the Windows run follows; the reason is recorded.
|
|
@@ -98,7 +165,7 @@ export function checkPublicationGate({ kind, name, version, packagePath, evidenc
|
|
|
98
165
|
if (!matching.length) { missing.push(`no ${label} host run receipt`); continue; }
|
|
99
166
|
// Every run on the host must qualify: a failing run is not masked by a passing run beside it.
|
|
100
167
|
matching.forEach((run, index) => {
|
|
101
|
-
const fields = invalidRunFields(run, evidence);
|
|
168
|
+
const fields = invalidRunFields(run, evidence, tree);
|
|
102
169
|
if (fields.length) missing.push(`${label} run receipt ${index} invalid: ${fields.join(", ")}`);
|
|
103
170
|
});
|
|
104
171
|
}
|
package/publish-cargo.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { enforcePublicationGate, defaultEvidencePath } from "./publication-gate.mjs";
|
|
5
|
+
import { enforcePublicationGate, defaultEvidencePath, provenGateSteps } from "./publication-gate.mjs";
|
|
6
6
|
import { runCargoSync } from "./managed-cargo.mjs";
|
|
7
7
|
import { readVerifiedArtifact, assertArtifactUnchanged } from "./publication-artifacts.mjs";
|
|
8
8
|
import { verifyCargoRegistry } from "./cargo-registry-readback.mjs";
|
|
@@ -53,11 +53,22 @@ export function runtimeBudgetDeferral(crateDir, crate) {
|
|
|
53
53
|
return `${data.reason} (${data.approvedBy}, ${data.approved}; ci/runtime-budget-deferred.json)`;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// Plan step label -> the `provenGateSteps` key whose proof lets that step be skipped.
|
|
57
|
+
const PROVEN_STEP = Object.freeze({ "secret scan": "secretScan", format: "fmt", tests: "tests", clippy: "clippy" });
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* `proven` (from `provenGateSteps(evidence)`) removes a secret scan, format, tests or clippy step the evidence
|
|
61
|
+
* already proves for this exact commit; the runtime budget, package contents, archive hash and registry steps
|
|
62
|
+
* are never skippable. `verifyOnPublish` (real publish only) drops the separate registry dry-run and lets the
|
|
63
|
+
* real `cargo publish` run Cargo's own verify build (no `--no-verify`), so the package compiles once, before
|
|
64
|
+
* the upload, instead of in a dry-run and not at all in the publish.
|
|
65
|
+
*/
|
|
66
|
+
export function buildCargoPublishPlan({ crate, crateDir, dryRun, allowDirty = false, proven = {}, verifyOnPublish = false }) {
|
|
57
67
|
const dryRunArgs = ["publish", "--dry-run", "-p", crate];
|
|
58
68
|
const packageArgs = ["package", "--list", "-p", crate];
|
|
59
69
|
if (allowDirty) dryRunArgs.push("--allow-dirty");
|
|
60
70
|
if (allowDirty) packageArgs.push("--allow-dirty");
|
|
71
|
+
const verifyHere = verifyOnPublish && !dryRun;
|
|
61
72
|
const plan = [
|
|
62
73
|
{ label: "secret scan", internal: "secret-scan", crateDir },
|
|
63
74
|
// Runtime-budget crates (runtime-budgets.mjs) must carry a measured runtime-budget.json for this
|
|
@@ -67,15 +78,15 @@ export function buildCargoPublishPlan({ crate, crateDir, dryRun, allowDirty = fa
|
|
|
67
78
|
{ label: "tests", command: "cargo", args: ["test", "-p", crate, "--", "--test-threads=1"] },
|
|
68
79
|
{ label: "clippy", command: "cargo", args: ["clippy", "-p", crate, "--all-targets", "--", "-D", "warnings"] },
|
|
69
80
|
{ label: "package contents", command: "cargo", args: packageArgs, capture: true },
|
|
70
|
-
{ label: "registry dry-run", command: "cargo", args: dryRunArgs },
|
|
71
|
-
];
|
|
81
|
+
...(verifyHere ? [] : [{ label: "registry dry-run", command: "cargo", args: dryRunArgs }]),
|
|
82
|
+
].filter((step) => !(PROVEN_STEP[step.label] && proven[PROVEN_STEP[step.label]] === true));
|
|
72
83
|
if (!dryRun) plan.push(
|
|
73
84
|
// `publish --dry-run` leaves its archive under target/package/tmp-crate on current Cargo; package
|
|
74
85
|
// explicitly to bind target/package/<name>-<version>.crate to evidence before Cargo uploads.
|
|
75
86
|
{ label: "package archive", command: "cargo", args: ["package", "--no-verify", "-p", crate, ...(allowDirty ? ["--allow-dirty"] : [])] },
|
|
76
87
|
{ label: "package location", command: "cargo", args: ["metadata", "--format-version", "1", "--no-deps"], capture: true },
|
|
77
88
|
{ label: "archive hash check", internal: "verify-archive" },
|
|
78
|
-
{ label: "registry publish", command: "cargo", args: ["publish", "--no-verify", "-p", crate] },
|
|
89
|
+
{ label: "registry publish", command: "cargo", args: verifyHere ? ["publish", "--locked", "-p", crate] : ["publish", "--no-verify", "-p", crate] },
|
|
79
90
|
{ label: "registry readback", internal: "registry-readback" },
|
|
80
91
|
);
|
|
81
92
|
return plan;
|
|
@@ -119,7 +130,14 @@ export async function runCargoPublish(options, execute = (_command, args, settin
|
|
|
119
130
|
};
|
|
120
131
|
const gate = (deps.enforceGate ?? enforcePublicationGate)(gateOptions, { dryRun: options.dryRun });
|
|
121
132
|
const expectedSha256 = gate.evidence?.artifactSha256;
|
|
122
|
-
|
|
133
|
+
// Only the local publisher (local-publish.mjs) sets `trustEvidenceSteps`: it ran the tests and clippy for this
|
|
134
|
+
// exact commit itself and wrote them into the evidence the gate above just validated. A plain publish proves
|
|
135
|
+
// nothing in advance, so `proven` stays empty and every step runs.
|
|
136
|
+
const proven = options.trustEvidenceSteps === true && !options.dryRun ? provenGateSteps(gate.evidence) : {};
|
|
137
|
+
const plan = buildCargoPublishPlan({ ...options, crateDir, proven, verifyOnPublish: options.verifyOnPublish === true });
|
|
138
|
+
for (const [label, key] of Object.entries(PROVEN_STEP)) {
|
|
139
|
+
if (proven[key] === true) process.stdout.write(`[right-release] cargo ${label}: proven by the gate evidence for this commit, not repeated\n`);
|
|
140
|
+
}
|
|
123
141
|
|
|
124
142
|
let targetDir;
|
|
125
143
|
let artifact;
|
|
@@ -159,7 +177,8 @@ export async function runCargoPublish(options, execute = (_command, args, settin
|
|
|
159
177
|
continue;
|
|
160
178
|
}
|
|
161
179
|
if (step.label === "registry publish") assertArtifactUnchanged(artifact);
|
|
162
|
-
|
|
180
|
+
// `execute` may be synchronous (the default) or async (the batch publisher overlaps uploads).
|
|
181
|
+
const result = await execute(step.command, step.args, {
|
|
163
182
|
cwd: options.workspaceRoot,
|
|
164
183
|
encoding: "utf8",
|
|
165
184
|
windowsHide: true,
|
package/qa-build-features.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// Dev builds are unaffected: `development.mjs` never calls this.
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
|
+
import { expandRecipeBuildCommands } from "./recipe-build.mjs";
|
|
13
14
|
|
|
14
15
|
export const FORBIDDEN_FEATURES = Object.freeze(["devtools", "content-devtools", "qa-native", "qa-control", "dev-native", "dev-inspect",
|
|
15
16
|
// Per-app test-automation features (M20 owner gap): HeardRight `qa-shot`/`qa-tools`, ViewRight `dev-control`/`bench-native`.
|
|
@@ -206,7 +207,12 @@ export function buildFeatureIssues({ root, commands, devtools, cargoManifest, ma
|
|
|
206
207
|
let scripts = {};
|
|
207
208
|
try { scripts = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).scripts ?? {}; } catch { /* no package.json */ }
|
|
208
209
|
const issues = [];
|
|
209
|
-
for (const { label, command } of commands)
|
|
210
|
+
for (const { label, command } of commands) {
|
|
211
|
+
if (!command) continue;
|
|
212
|
+
// A `node recipe-build.mjs --plan <JSON>` wrapper hides every step's flags in one argument: scan each step too.
|
|
213
|
+
const expanded = expandRecipeBuildCommands(command, { root });
|
|
214
|
+
expanded.forEach((part, index) => issues.push(...commandFeatureIssues(part, { label: index === 0 ? label : `${label} -> recipe-build step ${index}`, scripts, allowDevtools })));
|
|
215
|
+
}
|
|
210
216
|
const { rel } = resolveCargoManifestPath({ root, cargoManifest, manifest });
|
|
211
217
|
if (rel) issues.push(...cargoManifestIssues(readFileSync(path.join(root, rel), "utf8"), rel, { allowDevtools }));
|
|
212
218
|
return issues;
|