@rightkit/release 0.4.0 → 0.5.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 +59 -7
- package/adhoc-entitlements.mjs +8 -0
- package/adoption-evidence.mjs +650 -0
- package/advisory-exceptions.mjs +371 -0
- package/apple-profile-create.mjs +113 -10
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +1079 -0
- package/binary-license-notices.mjs +161 -0
- package/cargo-license-notices.mjs +111 -0
- package/cli/right-release.mjs +39 -0
- package/deterministic-scans.mjs +966 -0
- package/development-signing.mjs +2 -1
- package/host-upload-refusal.mjs +90 -0
- package/local-publish.mjs +142 -19
- package/local-qualify.mjs +728 -0
- package/local-runtime.mjs +319 -0
- package/lock-graph.mjs +51 -0
- package/managed-cargo.mjs +4 -1
- package/ownership-approval.mjs +23 -6
- package/ownership-dependency-allowlist.json +2 -0
- package/ownership-mandatory-set.json +158 -68
- package/ownership-mandatory-set.mjs +82 -6
- package/package.json +6 -2
- package/pipeline-admission.mjs +375 -0
- package/pipeline-config.mjs +100 -0
- package/pipeline-generators.json +4 -0
- package/pipeline-generators.mjs +137 -0
- package/pipeline-receipts.mjs +302 -0
- package/pipeline-scan.mjs +285 -0
- package/pipeline-tools.mjs +171 -0
- package/production-mode.mjs +42 -0
- package/publication-gate.mjs +94 -32
- package/publish-cargo.mjs +24 -7
- package/publish-swift.mjs +12 -3
- package/qa-control-scan.mjs +4 -0
- package/release-config.mjs +330 -0
- package/release-switch-scan.mjs +44 -5
- package/release.mjs +14 -4
- package/rightkit-ownership.json +57 -0
- package/rightkit-sync.mjs +8 -0
- package/rightkit-versions.json +191 -131
- package/runtime-budget-classes.json +8 -0
- package/runtime-budget-crates.mjs +14 -0
- package/runtime-budgets.mjs +128 -31
- package/scan-digest.mjs +107 -0
- package/sign-qa-bundle.mjs +210 -0
- package/stage-sidecar.mjs +99 -15
- package/swap-helper-bundle.mjs +7 -3
- package/workflow-yaml.mjs +177 -0
- package/bin/darwin/rightkit-swap.sign.json +0 -6
package/development-signing.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { closeSync, copyFileSync, existsSync, mkdtempSync, openSync, readFileSyn
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { spawnSync } from "./background-process.mjs";
|
|
6
|
+
import { adhocSafeEntitlements } from "./adhoc-entitlements.mjs";
|
|
6
7
|
import { runMacCodesignCommands } from "./macos-signing-session.mjs";
|
|
7
8
|
import { bundleRelativePath, compileBundlePathMap, globToRegExp, isGlobPattern, lookupBundlePath, resolveBundleIdentifier, validateBundleRelativeKey, validateIdentifierTemplate, validateSigningIdentifier } from "./mac-bundle-signing.mjs";
|
|
8
9
|
|
|
@@ -308,7 +309,7 @@ export function signMacosDevelopmentBundle(options) {
|
|
|
308
309
|
const entitlementFile = (file) => {
|
|
309
310
|
if (!signing.adhoc) return file;
|
|
310
311
|
if (!safeFiles.has(file)) {
|
|
311
|
-
const safe =
|
|
312
|
+
const safe = adhocSafeEntitlements(readPlist(file, context));
|
|
312
313
|
const out = path.join(scratch, `adhoc-${safeFiles.size}.plist`);
|
|
313
314
|
writeFileSync(out, JSON.stringify(safe));
|
|
314
315
|
checked("plutil", ["-convert", "xml1", out], context, "DEV_MAC_SIGNING_CONFIG_INVALID", identity);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Dependency-free (no imports) refusal shared by every module that can put bytes in a registry. RightKit crates and @rightkit
|
|
2
|
+
// packages reach a registry from CI only (publish.yml qualification, the approval commit in ci/approved-artifacts.json,
|
|
3
|
+
// publish-reviewed.yml). No environment variable, flag or caller-set GITHUB_ACTIONS lifts this: CI uploads go through
|
|
4
|
+
// scripts/ci/publish.mjs and scripts/ci/reviewed-candidate.mjs, never through right-release.
|
|
5
|
+
export const HOST_UPLOAD_REFUSED = "RK_HOST_UPLOAD_REFUSED";
|
|
6
|
+
export function hostUploadRefusalMessage(what) {
|
|
7
|
+
return `${what}: host uploads are retired; RightKit publishes only through CI's approved-hash path (publish.yml, approval in ci/approved-artifacts.json, publish-reviewed.yml). Use --dry-run, preflight or --local --qualify-only to qualify locally.`;
|
|
8
|
+
}
|
|
9
|
+
export function refuseHostUpload(what) {
|
|
10
|
+
throw Object.assign(new Error(hostUploadRefusalMessage(what)), { code: HOST_UPLOAD_REFUSED });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// ---- publish capability -------------------------------------------------------------------------------------------------
|
|
14
|
+
// The publisher internals (publishGatedTarball in publication-gate.mjs, runCargoPublish in publish-cargo.mjs) are exported and read
|
|
15
|
+
// registry credentials, so being importable must not mean being able to publish. Each of them refuses (refuseHostUpload) unless
|
|
16
|
+
// it is handed a CAPABILITY, and a capability exists only when createPublishCapability accepted a validated admission:
|
|
17
|
+
// - the hashed admission record the credentialed job builds (schema 1, "semantic-admission" or "local-admission": original
|
|
18
|
+
// binding, source tree, verifier SHA, run and attempt, one passing verdict per archive);
|
|
19
|
+
// - an approval commit entry (ci/approved-artifacts.json: registry, name, version, sha256, reviewer) for every archive in it;
|
|
20
|
+
// - the CI run it names: GITHUB_ACTIONS=true on refs/heads/main of the repository, GITHUB_SHA equal to the admission's verifier
|
|
21
|
+
// SHA, GITHUB_RUN_ID and GITHUB_RUN_ATTEMPT equal to its run.
|
|
22
|
+
// The capability is an object registered in a module-private WeakMap at creation: an object literal, a copy or a mutated clone is
|
|
23
|
+
// not registered and is refused. It is bound to the units of the admission (name, version, archive sha256, registry), so one
|
|
24
|
+
// capability publishes nothing else, and the publisher also compares the sha256 of the bytes it is about to send. Nothing here
|
|
25
|
+
// reads an environment variable to LIFT a refusal: the environment is one of the conditions the admission must match.
|
|
26
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
27
|
+
const HEX40 = /^[0-9a-f]{40}$/;
|
|
28
|
+
const POSITIVE = /^[1-9][0-9]*$/;
|
|
29
|
+
export const PUBLISH_REPOSITORY = "bogusyogi/rightkit";
|
|
30
|
+
const CAPABILITY_REGISTRIES = ["npm", "crates"];
|
|
31
|
+
const ADMISSION_KINDS = ["semantic-admission", "local-admission"];
|
|
32
|
+
const minted = new WeakMap();
|
|
33
|
+
const plain = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
|
|
35
|
+
/** Every reason `admission` is not a valid, passing admission record for `registry` in this CI run (empty means valid). */
|
|
36
|
+
export function admissionProblems(admission, { registry, env = process.env, approved = [] } = {}) {
|
|
37
|
+
const problems = [];
|
|
38
|
+
const bad = (text) => problems.push(text);
|
|
39
|
+
if (!CAPABILITY_REGISTRIES.includes(registry)) bad(`registry ${JSON.stringify(registry)} is not npm or crates`);
|
|
40
|
+
if (!plain(admission)) return [...problems, "the admission is not an object"];
|
|
41
|
+
if (admission.schema !== 1 || !ADMISSION_KINDS.includes(admission.kind)) bad("the admission schema or kind is not a reviewed admission");
|
|
42
|
+
const original = admission.original;
|
|
43
|
+
if (!plain(original) || typeof original.candidateId !== "string" || !original.candidateId || !HEX64.test(original.candidateSha256 ?? "") || !HEX40.test(original.sourceSha ?? "") || !HEX64.test(original.manifestSha256 ?? "") || !Array.isArray(original.archives) || original.archives.length === 0) bad("the admission does not bind an original candidate, manifest and archives");
|
|
44
|
+
if (!HEX40.test(admission.sourceTree ?? "") || !HEX40.test(admission.verifierSha ?? "") || !POSITIVE.test(String(admission.runId ?? "")) || !POSITIVE.test(String(admission.runAttempt ?? ""))) bad("the admission does not name its source tree, verifier commit and run");
|
|
45
|
+
const archives = Array.isArray(original?.archives) ? original.archives : [];
|
|
46
|
+
const units = Array.isArray(admission.units) ? admission.units : [];
|
|
47
|
+
if (units.length !== archives.length) bad("the admission holds no verdict for exactly its archives");
|
|
48
|
+
const names = new Set();
|
|
49
|
+
for (const archive of archives) {
|
|
50
|
+
const label = `${archive?.name}@${archive?.version}`;
|
|
51
|
+
if (!plain(archive) || typeof archive.name !== "string" || !archive.name || typeof archive.version !== "string" || !archive.version || !HEX64.test(archive.sha256 ?? "")) { bad(`archive ${label} is malformed`); continue; }
|
|
52
|
+
if (names.has(archive.name)) bad(`archive ${label} is listed twice`);
|
|
53
|
+
names.add(archive.name);
|
|
54
|
+
const verdict = units.find((row) => row?.name === archive.name);
|
|
55
|
+
if (!verdict || verdict.version !== archive.version || verdict.archiveSha256 !== archive.sha256 || verdict.ok !== true || !Array.isArray(verdict.failures) || verdict.failures.length !== 0 || !HEX64.test(verdict.evidenceSha256 ?? "")) bad(`${label} has no passing verdict for its archive`);
|
|
56
|
+
const entries = (Array.isArray(approved) ? approved : []).filter((entry) => entry?.registry === registry && entry.name === archive.name && entry.version === archive.version);
|
|
57
|
+
if (entries.length === 0 || entries.some((entry) => entry.sha256 !== archive.sha256)) bad(`${label} is not approved in ci/approved-artifacts.json with this archive hash`);
|
|
58
|
+
}
|
|
59
|
+
if (env.GITHUB_ACTIONS !== "true" || env.GITHUB_REF !== "refs/heads/main" || env.GITHUB_REPOSITORY !== PUBLISH_REPOSITORY) bad("not running in GitHub Actions on refs/heads/main of the RightKit repository");
|
|
60
|
+
if (!HEX40.test(env.GITHUB_SHA ?? "") || env.GITHUB_SHA !== admission.verifierSha || env.GITHUB_RUN_ID !== String(admission.runId) || env.GITHUB_RUN_ATTEMPT !== String(admission.runAttempt)) bad("the admission was built by another commit, run or attempt");
|
|
61
|
+
return problems;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The capability to publish exactly the archives of one validated admission to `registry`. Throws (HOST_UPLOAD_REFUSED) unless
|
|
66
|
+
* `admission` passes admissionProblems against the current CI environment and the approval entries in `approved`
|
|
67
|
+
* (ci/approved-artifacts.json `artifacts`, as scripts/ci/upload.mjs loadApprovedArtifacts returns them).
|
|
68
|
+
*/
|
|
69
|
+
export function createPublishCapability({ admission, approved, registry }) {
|
|
70
|
+
const problems = admissionProblems(admission, { registry, env: process.env, approved });
|
|
71
|
+
if (problems.length) refuseHostUpload(`a publish capability needs a validated admission (${problems.join("; ")})`);
|
|
72
|
+
const record = Object.freeze({ kind: "rightkit-publish-capability", registry });
|
|
73
|
+
minted.set(record, { registry, units: new Map(admission.original.archives.map((archive) => [archive.name, { version: archive.version, sha256: archive.sha256 }])), admissionVerifier: admission.verifierSha });
|
|
74
|
+
return record;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Refuses (HOST_UPLOAD_REFUSED) unless `capability` was created by createPublishCapability for this `registry` and names the
|
|
79
|
+
* unit. When `version` and `sha256` are given they must equal the admitted archive's: the publisher passes the version and the
|
|
80
|
+
* sha256 of the bytes it is about to send.
|
|
81
|
+
*/
|
|
82
|
+
export function assertPublishCapability(capability, { what, registry, name, version, sha256 }) {
|
|
83
|
+
const grant = typeof capability === "object" && capability !== null ? minted.get(capability) : undefined;
|
|
84
|
+
if (!grant) refuseHostUpload(`${what}: no publish capability (a validated admission is required)`);
|
|
85
|
+
if (grant.registry !== registry) refuseHostUpload(`${what}: the publish capability is for ${grant.registry}, not ${registry}`);
|
|
86
|
+
const unit = grant.units.get(name);
|
|
87
|
+
if (!unit) refuseHostUpload(`${what}: ${name} is not an archive of the admission behind the publish capability`);
|
|
88
|
+
if (version !== undefined && unit.version !== version) refuseHostUpload(`${what}: ${name}@${version} is not the admitted ${unit.version}`);
|
|
89
|
+
if (sha256 !== undefined && unit.sha256 !== sha256) refuseHostUpload(`${what}: the bytes of ${name} (${String(sha256).slice(0, 12)}) are not the admitted archive (${unit.sha256.slice(0, 12)})`);
|
|
90
|
+
}
|
package/local-publish.mjs
CHANGED
|
@@ -24,8 +24,9 @@ import { localBuildGranted, LOCAL_BUILD_FILE } from "./execution-policy.mjs";
|
|
|
24
24
|
import { GATE_SCHEMA, MAX_CARGO_JOBS, defaultEvidencePath, enforcePublicationGate, publishGatedTarball } from "./publication-gate.mjs";
|
|
25
25
|
import { runCargoPublish, runCargoReadOnly, scanCrateForSecrets } from "./publish-cargo.mjs";
|
|
26
26
|
import { cargoIndexUrl } from "./cargo-registry-readback.mjs";
|
|
27
|
+
import { refuseHostUpload } from "./host-upload-refusal.mjs";
|
|
27
28
|
import { pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
|
|
28
|
-
import { recordPublished, stageSourceVersions } from "./rightkit-sync.mjs";
|
|
29
|
+
import { recordPublished, stageSourceVersions, workspaceFeedOptions } from "./rightkit-sync.mjs";
|
|
29
30
|
import { prepareSwapHelper, SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "./swap-helper-bundle.mjs";
|
|
30
31
|
|
|
31
32
|
export const LOCAL_OPERATOR = "agent (local-build grant)";
|
|
@@ -236,6 +237,99 @@ export function allFeatureCheckSets(entry = {}) {
|
|
|
236
237
|
return entry.checkFeatureSets ?? [["--all-features"]];
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
const CONSUMER_ROW_KEYS = new Set(["manifestPath", "args", "kind", "targets", "locked"]);
|
|
241
|
+
const CONSUMER_KINDS = new Set(["check"]);
|
|
242
|
+
const CONSUMER_MANIFEST = /^ci\/consumers\/(?:[A-Za-z0-9._-]+\/)+Cargo\.toml$/;
|
|
243
|
+
const CONSUMER_TARGET = /^[A-Za-z0-9_]+(?:-[A-Za-z0-9_.]+){2,3}$/;
|
|
244
|
+
// The row owns manifest, lock, target and output format; arguments may only choose features and target kinds.
|
|
245
|
+
// An ALLOWLIST (0.4.1 review A1): any token not listed is refused, so --help/-h/--version (a "passing" no-op),
|
|
246
|
+
// --target-dir, -C/--directory, --color, --timings, -Z and every other Cargo option stay out of a row.
|
|
247
|
+
// Keep in step with scripts/ci/gate.mjs.
|
|
248
|
+
const CONSUMER_ARG_FLAGS = new Set(["--no-default-features", "--all-features", "--lib", "--bins", "--tests", "--examples", "--benches", "--all-targets"]);
|
|
249
|
+
const CONSUMER_FEATURE_FLAGS = new Set(["--features", "-F"]);
|
|
250
|
+
const CONSUMER_FEATURE_LIST = /^[A-Za-z0-9_][A-Za-z0-9_.+/?:,-]*$/;
|
|
251
|
+
const CONSUMER_ARGS_ALLOWED = `--features <list> (or --features=<list>, -F <list>), ${[...CONSUMER_ARG_FLAGS].join(", ")}`;
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Validate the `consumerRows` of one crate's ci/gate.config.json entry and return them normalised. A consumer row
|
|
255
|
+
* checks the owning crate through a SEPARATE Cargo workspace (its own Cargo.lock), run as
|
|
256
|
+
* `cargo check --manifest-path <manifestPath> --locked [--target <t>] <args>`. Unknown fields are refused.
|
|
257
|
+
*/
|
|
258
|
+
export function consumerRows(unit, entry = {}) {
|
|
259
|
+
const rows = entry.consumerRows;
|
|
260
|
+
if (rows === undefined) return [];
|
|
261
|
+
const where = (index) => `${unit} consumerRows[${index}]`;
|
|
262
|
+
if (!Array.isArray(rows)) refuse(`${unit}: consumerRows must be an array`);
|
|
263
|
+
const seen = new Set();
|
|
264
|
+
return rows.map((row, index) => {
|
|
265
|
+
if (row === null || typeof row !== "object" || Array.isArray(row)) refuse(`${where(index)} must be an object`);
|
|
266
|
+
for (const key of Object.keys(row)) if (!CONSUMER_ROW_KEYS.has(key)) refuse(`${where(index)}: unknown field ${JSON.stringify(key)} (allowed: ${[...CONSUMER_ROW_KEYS].join(", ")})`);
|
|
267
|
+
const manifestPath = row.manifestPath;
|
|
268
|
+
if (typeof manifestPath !== "string" || !CONSUMER_MANIFEST.test(manifestPath) || manifestPath.split("/").some((part) => part === "." || part === "..")) {
|
|
269
|
+
refuse(`${where(index)}: manifestPath must be a relative ci/consumers/<name>/Cargo.toml path with forward slashes, got ${JSON.stringify(manifestPath)}`);
|
|
270
|
+
}
|
|
271
|
+
const kind = row.kind ?? "check";
|
|
272
|
+
if (!CONSUMER_KINDS.has(kind)) refuse(`${where(index)}: kind must be one of ${[...CONSUMER_KINDS].join(", ")}, got ${JSON.stringify(kind)}`);
|
|
273
|
+
if (row.locked !== undefined && row.locked !== true) refuse(`${where(index)}: locked must be true; a consumer row never runs without its lock file`);
|
|
274
|
+
const args = row.args ?? [];
|
|
275
|
+
if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string" || arg === "")) refuse(`${where(index)}: args must be an array of non-empty strings`);
|
|
276
|
+
for (let position = 0; position < args.length; position += 1) {
|
|
277
|
+
const arg = args[position];
|
|
278
|
+
if (CONSUMER_ARG_FLAGS.has(arg)) continue;
|
|
279
|
+
if (CONSUMER_FEATURE_FLAGS.has(arg)) {
|
|
280
|
+
const list = args[position + 1];
|
|
281
|
+
if (typeof list !== "string" || !CONSUMER_FEATURE_LIST.test(list)) refuse(`${where(index)}: ${arg} needs a feature list (names separated by commas, not starting with "-"), got ${JSON.stringify(list)}`);
|
|
282
|
+
position += 1;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (arg.startsWith("--features=") && CONSUMER_FEATURE_LIST.test(arg.slice("--features=".length))) continue;
|
|
286
|
+
refuse(`${where(index)}: args may not contain ${JSON.stringify(arg)}; the row sets manifest path, lock, target and output itself, and allows only ${CONSUMER_ARGS_ALLOWED}`);
|
|
287
|
+
}
|
|
288
|
+
const targets = row.targets ?? [];
|
|
289
|
+
if (!Array.isArray(targets) || targets.some((target) => typeof target !== "string" || !CONSUMER_TARGET.test(target))) refuse(`${where(index)}: targets must be an array of target triples`);
|
|
290
|
+
if (new Set(targets).size !== targets.length) refuse(`${where(index)}: targets lists a triple twice`);
|
|
291
|
+
const key = JSON.stringify([manifestPath, args, targets]);
|
|
292
|
+
if (seen.has(key)) refuse(`${where(index)} repeats an earlier row`);
|
|
293
|
+
seen.add(key);
|
|
294
|
+
return { manifestPath, kind, args: [...args], targets: [...targets], locked: true };
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** One Cargo step per target (or one host step when no target is named) for one consumer row. */
|
|
299
|
+
export function consumerRowInvocations(row) {
|
|
300
|
+
const targets = row.targets.length ? row.targets : [null];
|
|
301
|
+
return targets.map((target) => ({ command: "cargo", args: [row.kind, "--manifest-path", row.manifestPath, "--locked", ...(target ? ["--target", target] : []), ...row.args], test: false }));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Refuse a consumer row whose manifest or lock is missing from the workspace, is not tracked by git, or resolves
|
|
306
|
+
* (through a symlink) to anywhere but its own path inside the workspace (fail closed before any command;
|
|
307
|
+
* 0.4.1 review A5). The clean-tree check covers only the files git sees under the consumer directory, so a
|
|
308
|
+
* symlinked or ignored file would otherwise be a checked file nobody reviewed.
|
|
309
|
+
*/
|
|
310
|
+
function assertConsumerFiles(workspaceRoot, unit, rows, git = defaultGit) {
|
|
311
|
+
let realRoot;
|
|
312
|
+
try { realRoot = fs.realpathSync(workspaceRoot); } catch { refuse(`${unit}: cannot resolve the workspace ${workspaceRoot}`); }
|
|
313
|
+
for (const row of rows) {
|
|
314
|
+
for (const file of [row.manifestPath, row.manifestPath.replace(/Cargo\.toml$/, "Cargo.lock")]) {
|
|
315
|
+
const absolute = path.join(workspaceRoot, file);
|
|
316
|
+
if (!fs.existsSync(absolute)) refuse(`${unit}: consumer row needs ${file}, which is not in the workspace`);
|
|
317
|
+
let real;
|
|
318
|
+
try { real = fs.realpathSync(absolute); } catch { refuse(`${unit}: consumer row file ${file} cannot be resolved`); }
|
|
319
|
+
if (path.relative(realRoot, real) !== path.normalize(file)) refuse(`${unit}: consumer row file ${file} resolves outside its own path in the workspace (${real}); a symlink is not allowed`);
|
|
320
|
+
if (git(["ls-files", "--error-unmatch", "--", file], workspaceRoot) === null) refuse(`${unit}: consumer row file ${file} is not tracked by git; commit it first`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The directories whose cleanliness a set of consumer rows depends on. */
|
|
326
|
+
const consumerDirs = (rows) => [...new Set(rows.map((row) => path.posix.dirname(row.manifestPath)))];
|
|
327
|
+
|
|
328
|
+
// The files that decide WHICH steps a publish runs: an uncommitted edit would change the plan the evidence is bound to
|
|
329
|
+
// (0.4.1 review A3). Chosen over reading the HEAD blob so the publisher, the replay and the batch apply one rule:
|
|
330
|
+
// the file on disk must be the committed file, or the publish is refused.
|
|
331
|
+
export const GATE_CONFIG_FILES = Object.freeze(["ci/gate.config.json", "ci/asset-gated.json"]);
|
|
332
|
+
|
|
239
333
|
/** The CI gate's commands for one unit (scripts/ci/gate.mjs npmGate/rustGate), mirrored exactly. */
|
|
240
334
|
export function localTestPlan({ kind, unit, dir, config, manifest, batch = false, platform = "darwin" }) {
|
|
241
335
|
if (kind === "npm") {
|
|
@@ -260,9 +354,13 @@ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false
|
|
|
260
354
|
if (entry.alsoSerial) steps.push({ command: "cargo", args: ["test", "-p", unit, "--message-format=json-render-diagnostics", ...features, "--no-fail-fast", "--", "--test-threads=1", "--show-output"], test: true });
|
|
261
355
|
}
|
|
262
356
|
// Batch mode lints the whole batch once (batchClippyInvocations) and records it as a batch receipt.
|
|
263
|
-
|
|
357
|
+
// Every tested feature set is linted (-D warnings), not only the first.
|
|
358
|
+
if (!batch) for (const features of featureSets) steps.push({ command: "cargo", args: ["clippy", "-p", unit, "--message-format=json-render-diagnostics", "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"], test: false });
|
|
264
359
|
for (const features of allFeatureCheckSets(entry)) steps.push({ command: "cargo", args: ["check", "-p", unit, "--message-format=json-render-diagnostics", "--all-targets", ...features], test: false });
|
|
265
|
-
|
|
360
|
+
// Consumer rows (separate Cargo workspaces) gate only this crate: they come from this crate's own entry.
|
|
361
|
+
const consumers = consumerRows(unit, entry);
|
|
362
|
+
for (const row of consumers) steps.push(...consumerRowInvocations(row));
|
|
363
|
+
return { id: `rust:${unit}`, featureLabels: featureSets.map((features) => features.join(" ") || "default"), consumerRows: consumers, steps };
|
|
266
364
|
}
|
|
267
365
|
|
|
268
366
|
/**
|
|
@@ -274,14 +372,19 @@ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false
|
|
|
274
372
|
* unify extra features into a selected crate and each is linted with exactly the features it was before.
|
|
275
373
|
* Crates with a different RIGHTKIT_REQUIRE_ASSETS (asset-gated) stay apart so the environment is unchanged.
|
|
276
374
|
*/
|
|
277
|
-
export function batchClippyInvocations(layer, { featuresOf = () => [], assetGatedOf = () => false } = {}) {
|
|
375
|
+
export function batchClippyInvocations(layer, { featuresOf = () => [], featureSetsOf = (unit) => [featuresOf(unit)], assetGatedOf = () => false } = {}) {
|
|
278
376
|
const groups = new Map();
|
|
279
377
|
for (const unit of layer) {
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
378
|
+
// Every tested feature set of a crate is linted, in order; identical sets of one crate collapse to one run.
|
|
379
|
+
const sets = featureSetsOf(unit) ?? [[]];
|
|
380
|
+
const unique = sets.filter((set, index) => sets.findIndex((other) => JSON.stringify(other ?? []) === JSON.stringify(set ?? [])) === index);
|
|
381
|
+
for (const set of unique) {
|
|
382
|
+
const features = set ?? [];
|
|
383
|
+
const shared = features.length === 0 || (features.length === 1 && features[0] === "--all-features");
|
|
384
|
+
const key = shared ? JSON.stringify([features, assetGatedOf(unit)]) : `unit:${unit}:${JSON.stringify(features)}`;
|
|
385
|
+
if (!groups.has(key)) groups.set(key, { crates: [], features, assetGated: assetGatedOf(unit) });
|
|
386
|
+
groups.get(key).crates.push(unit);
|
|
387
|
+
}
|
|
285
388
|
}
|
|
286
389
|
return [...groups.values()].map(({ crates, features, assetGated }) => ({
|
|
287
390
|
crates, assetGated,
|
|
@@ -309,7 +412,15 @@ function assertLocalHost(deps, workspaceRoot, kind) {
|
|
|
309
412
|
return platform;
|
|
310
413
|
}
|
|
311
414
|
|
|
415
|
+
// Host uploads are retired: only --qualify-only (no upload) runs. The upload engine below is kept for direct calls in
|
|
416
|
+
// tests; no CLI path reaches it (see host-upload-refusal.mjs).
|
|
312
417
|
export async function runLocalPublish(kind, argv, deps = {}) {
|
|
418
|
+
const parsed = parseLocalPublishArgs(kind, argv, deps.cwd);
|
|
419
|
+
if (!parsed.qualifyOnly) refuseHostUpload(`right-release publish ${kind} --local`);
|
|
420
|
+
return runLocalPublishUploadEngine(kind, argv, deps);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function runLocalPublishUploadEngine(kind, argv, deps = {}) {
|
|
313
424
|
const parsed = parseLocalPublishArgs(kind, argv, deps.cwd);
|
|
314
425
|
if (parsed.batch) return runLocalPublishBatch(parsed, deps);
|
|
315
426
|
return publishUnit(parsed, deps, null);
|
|
@@ -354,9 +465,9 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
354
465
|
const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
|
|
355
466
|
if (tree !== batch.tree) refuse(`${unit}: HEAD tree ${tree} differs from the batch tree ${batch.tree}`);
|
|
356
467
|
}
|
|
357
|
-
const dirty = git(["status", "--porcelain", "--", dir, ...(parsed.qualifyOnly ? ["Cargo.toml", "Cargo.lock"] : [])], workspaceRoot);
|
|
468
|
+
const dirty = git(["status", "--porcelain", "--", dir, ...GATE_CONFIG_FILES, ...(parsed.qualifyOnly ? ["Cargo.toml", "Cargo.lock"] : [])], workspaceRoot);
|
|
358
469
|
if (dirty === null) refuse(`cannot read git status for ${dir}`);
|
|
359
|
-
if (dirty !== "") refuse(`tree is not clean for ${dir}; commit first:\n${dirty}`);
|
|
470
|
+
if (dirty !== "") refuse(`tree is not clean for ${dir} or the gate config (${GATE_CONFIG_FILES.join(", ")}); commit first:\n${dirty}`);
|
|
360
471
|
if (await (deps.registryHas ?? defaultRegistryHas)({ kind, name: unit, version })) refuse(`${unit}@${version} is already on the ${kind === "npm" ? "npm" : "crates.io"} registry; bump the version`);
|
|
361
472
|
log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant, ${LOCAL_HOST_POLICIES[platform]} policy)`);
|
|
362
473
|
|
|
@@ -366,6 +477,13 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
366
477
|
const gatedSection = assetGated[kind === "npm" ? "npm" : "crates"] ?? {};
|
|
367
478
|
const isAssetGated = Object.hasOwn(gatedSection, unit);
|
|
368
479
|
const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest, batch: Boolean(batch), platform });
|
|
480
|
+
if (plan.consumerRows?.length) {
|
|
481
|
+
// The consumer workspaces are part of the qualified commit: present, and unmodified like the unit itself.
|
|
482
|
+
assertConsumerFiles(workspaceRoot, unit, plan.consumerRows, git);
|
|
483
|
+
const consumerDirty = git(["status", "--porcelain", "--", ...consumerDirs(plan.consumerRows)], workspaceRoot);
|
|
484
|
+
if (consumerDirty === null) refuse(`cannot read git status for the consumer rows of ${unit}`);
|
|
485
|
+
if (consumerDirty !== "") refuse(`tree is not clean for the consumer rows of ${unit}; commit first:\n${consumerDirty}`);
|
|
486
|
+
}
|
|
369
487
|
if (parsed.qualifyOnly) {
|
|
370
488
|
const violations = (deps.scanSecrets ?? scanCrateForSecrets)(unitPath);
|
|
371
489
|
if (violations.length) refuse(`qualification secret scan rejected: ${violations.join(", ")}`);
|
|
@@ -486,14 +604,14 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
486
604
|
|
|
487
605
|
// 5. The EXISTING publish path.
|
|
488
606
|
if (kind === "npm") {
|
|
489
|
-
(deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath }, deps.publishDeps ?? {});
|
|
607
|
+
(deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath, workspaceRoot }, deps.publishDeps ?? {});
|
|
490
608
|
} else {
|
|
491
609
|
// trustEvidenceSteps: the evidence just written (and validated by the gate above) proves this commit's tests
|
|
492
610
|
// and clippy, so the publisher does not repeat them; verifyOnPublish: Cargo's verify build runs in the
|
|
493
611
|
// publish itself. Package contents, the runtime budget, the archive hash and the readback always run.
|
|
494
612
|
const execute = deps.cargoExecute ?? (batch ? batchCargoExecute(unit) : undefined);
|
|
495
613
|
const published = await (deps.runCargoPublish ?? runCargoPublish)({ crate: unit, dryRun: false, allowDirty: false, workspaceRoot, evidence: evidencePath, trustEvidenceSteps: true, verifyOnPublish: true, offlineReadOnly: true }, execute, deps.cargoDeps ?? {});
|
|
496
|
-
await withVersionsLock(() => (deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version }));
|
|
614
|
+
await withVersionsLock(() => (deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version }, workspaceFeedOptions(workspaceRoot)));
|
|
497
615
|
}
|
|
498
616
|
log(`[right-release] published ${unit}@${version}`);
|
|
499
617
|
return { kind, name: unit, version, commit: head, artifactSha256, evidencePath };
|
|
@@ -531,6 +649,7 @@ async function runLocalPublishBatch(parsed, deps) {
|
|
|
531
649
|
const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
|
|
532
650
|
const isAssetGated = (unit) => Object.hasOwn(assetGated.crates ?? {}, unit);
|
|
533
651
|
const versions = new Map();
|
|
652
|
+
const batchConsumerDirs = new Set();
|
|
534
653
|
for (const unit of crates) {
|
|
535
654
|
const dir = `crates/${unit}`;
|
|
536
655
|
if (!fs.existsSync(path.join(workspaceRoot, dir))) refuse(`unit directory not found: ${path.join(workspaceRoot, dir)}`);
|
|
@@ -538,16 +657,20 @@ async function runLocalPublishBatch(parsed, deps) {
|
|
|
538
657
|
if (pkg.name !== unit) refuse(`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crates entry ${unit}`);
|
|
539
658
|
if (typeof pkg.version !== "string" || !pkg.version.trim()) refuse(`${unit} has no literal version`);
|
|
540
659
|
versions.set(unit, pkg.version);
|
|
541
|
-
|
|
660
|
+
// Refuses an OS-restricted crate, a malformed consumer row, or an untracked or symlinked consumer file now, not mid-batch.
|
|
661
|
+
const rows = localTestPlan({ kind: "cargo", unit, dir, config: gateConfig, batch: true, platform }).consumerRows ?? [];
|
|
662
|
+
assertConsumerFiles(workspaceRoot, unit, rows, git);
|
|
663
|
+
for (const consumerDir of consumerDirs(rows)) batchConsumerDirs.add(consumerDir);
|
|
542
664
|
}
|
|
543
665
|
const head = git(["rev-parse", "HEAD"], workspaceRoot);
|
|
544
666
|
if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
|
|
545
667
|
const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
|
|
546
668
|
if (!tree || !/^[0-9a-f]{40}$/.test(tree)) refuse("cannot resolve the HEAD tree");
|
|
547
|
-
// Every batch crate
|
|
548
|
-
|
|
669
|
+
// Every batch crate, the workspace manifest and lock, the gate config and EVERY crate's consumer directories must
|
|
670
|
+
// match the commit the batch checks are bound to, before the first layer publishes (0.4.1 review A3, A4).
|
|
671
|
+
const dirty = git(["status", "--porcelain", "--", ...crates.map((unit) => `crates/${unit}`), "Cargo.toml", "Cargo.lock", ...GATE_CONFIG_FILES, ...batchConsumerDirs], workspaceRoot);
|
|
549
672
|
if (dirty === null) refuse("cannot read git status for the batch");
|
|
550
|
-
if (dirty !== "") refuse(`tree is not clean for the batch crates
|
|
673
|
+
if (dirty !== "") refuse(`tree is not clean for the batch crates, the workspace manifest, the gate config or a consumer directory; commit first:\n${dirty}`);
|
|
551
674
|
const registryHas = deps.registryHas ?? defaultRegistryHas;
|
|
552
675
|
const published = (await Promise.all(crates.map(async (unit) => ((await registryHas({ kind: "cargo", name: unit, version: versions.get(unit) })) ? `${unit}@${versions.get(unit)}` : null)))).filter(Boolean);
|
|
553
676
|
if (published.length) refuse(`already on the crates.io registry: ${published.join(", ")}; bump the version or remove them from the batch`);
|
|
@@ -573,9 +696,9 @@ async function runLocalPublishBatch(parsed, deps) {
|
|
|
573
696
|
const scanText = `scanCrateForSecrets ${crates.map((unit) => `crates/${unit}`).join(" ")}`;
|
|
574
697
|
checks.push({ name: "secret-scan", command: scanText, ok: true, ...stamp, crates });
|
|
575
698
|
commands.push(scanText);
|
|
576
|
-
const
|
|
699
|
+
const featureSetsOf = (unit) => gateConfig?.rust?.[unit]?.featureSets ?? [[]];
|
|
577
700
|
for (const layer of layers) {
|
|
578
|
-
for (const invocation of batchClippyInvocations(layer, {
|
|
701
|
+
for (const invocation of batchClippyInvocations(layer, { featureSetsOf, assetGatedOf: isAssetGated })) {
|
|
579
702
|
await batchCheck("clippy", "cargo", invocation.args, invocation.crates, testEnv(deps, invocation.assetGated));
|
|
580
703
|
}
|
|
581
704
|
}
|