@rightkit/release 0.3.0 → 0.4.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 +25 -4
- package/addon-command.mjs +3 -4
- package/android-play-upload.mjs +2 -1
- package/apple-profile-renew.mjs +11 -3
- package/artifact-storage.mjs +35 -2
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/build-release.mjs +138 -47
- package/cli/right-release.mjs +11 -2
- package/codesign-environment.mjs +2 -2
- package/content-digest.mjs +209 -0
- package/development.mjs +17 -6
- package/direct-bootstrap.mjs +5 -6
- package/disk-command.mjs +1 -0
- package/disk-policy.json +6 -2
- package/disk-stable-root.mjs +13 -9
- package/github-updater-release.mjs +70 -7
- package/in-place-install.mjs +17 -14
- package/install-app-windows.mjs +24 -7
- package/install-app.mjs +21 -40
- package/ios-asc-credentials.mjs +163 -0
- package/ios-build-context.mjs +129 -0
- package/ios-build-upload.mjs +282 -0
- package/ios-credentials-command.mjs +70 -0
- package/ios-dev.mjs +248 -0
- package/ios-host.mjs +76 -0
- package/ios-lock.mjs +75 -0
- package/ios-package-recipe.mjs +707 -0
- package/ios-testflight.mjs +266 -0
- package/ios-upload.mjs +313 -0
- package/local-publish.mjs +92 -37
- package/managed-cargo.mjs +8 -0
- package/mirror-root-artifact.mjs +5 -1
- package/native-cargo-layout.mjs +47 -4
- package/ownership-command.mjs +2 -1
- package/ownership-dependency-allowlist.json +1 -1
- package/ownership-evidence.mjs +30 -18
- package/ownership-key-command.mjs +69 -2
- package/ownership-mandatory-set.mjs +66 -16
- package/ownership-repositories.json +1 -1
- package/ownership-scope.mjs +94 -0
- package/ownership.mjs +49 -7
- package/package-recipe-targets.mjs +41 -1
- package/package.json +1 -1
- package/preflight.mjs +14 -7
- package/project-type.mjs +3 -1
- package/protected-credentials.mjs +28 -1
- package/publication-artifacts.mjs +30 -5
- package/publication-gate.mjs +15 -4
- package/publish-cargo.mjs +109 -22
- package/publish-update.mjs +15 -2
- package/qa-build-features.mjs +16 -13
- package/qa-contract.mjs +30 -2
- package/qa-control-scan.mjs +4 -0
- package/release-invocation.mjs +118 -8
- package/release-switch-registry.json +126 -27
- package/release-switch-scan.mjs +45 -17
- package/release.mjs +69 -17
- package/rightkit-ownership.json +1 -1
- package/rightkit-versions.json +379 -189
- package/runtime-budgets.mjs +120 -16
- package/sign-updater.mjs +56 -59
- package/source-gate.mjs +8 -1
- package/stage-sidecar.mjs +160 -32
- package/target-distribution.mjs +5 -0
- package/upload-release.mjs +7 -2
package/local-publish.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// `right-release publish npm|cargo ... --local`: publish ONE @rightkit npm package or crate from the Mac
|
|
3
|
-
// Adrian's local-build grant (2026-10-14, committed
|
|
4
|
-
// publication gate: it runs the unit's own CI tests
|
|
5
|
-
// scripts/ci/gate.mjs semantics), packs the artifact, writes a
|
|
6
|
-
// (hostPolicy "darwin-only"
|
|
2
|
+
// `right-release publish npm|cargo ... --local`: publish ONE @rightkit npm package or crate from the Mac (npm and
|
|
3
|
+
// crates) or from Windows (crates only) under Adrian's local-build grant (2026-10-14, committed
|
|
4
|
+
// .rightkit-local-build.json). It does not weaken the publication gate: it runs the unit's own CI tests
|
|
5
|
+
// (ci/gate.config.json, ci/asset-gated.json, scripts/ci/gate.mjs semantics), packs the artifact, writes a
|
|
6
|
+
// schema-1 evidence file with one receipt for the publishing host (hostPolicy "darwin-only" on the Mac,
|
|
7
|
+
// "win32-only" on Windows), then calls the EXISTING enforcePublicationGate and the EXISTING publisher
|
|
7
8
|
// (publishGatedTarball for npm, runCargoPublish for cargo). There is no way to skip a test or the evidence.
|
|
8
9
|
//
|
|
9
10
|
// Batch mode (`cargo --crates a,b,c | --crates-file f --local`): fmt, the secret scan and clippy run ONCE for
|
|
@@ -20,8 +21,8 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
20
21
|
import { spawnSync } from "./background-process.mjs";
|
|
21
22
|
import { runCargoAsync, runCargoSync, spawnCollect } from "./managed-cargo.mjs";
|
|
22
23
|
import { localBuildGranted, LOCAL_BUILD_FILE } from "./execution-policy.mjs";
|
|
23
|
-
import { GATE_SCHEMA, defaultEvidencePath, enforcePublicationGate, publishGatedTarball } from "./publication-gate.mjs";
|
|
24
|
-
import { runCargoPublish, scanCrateForSecrets } from "./publish-cargo.mjs";
|
|
24
|
+
import { GATE_SCHEMA, MAX_CARGO_JOBS, defaultEvidencePath, enforcePublicationGate, publishGatedTarball } from "./publication-gate.mjs";
|
|
25
|
+
import { runCargoPublish, runCargoReadOnly, scanCrateForSecrets } from "./publish-cargo.mjs";
|
|
25
26
|
import { cargoIndexUrl } from "./cargo-registry-readback.mjs";
|
|
26
27
|
import { pathDependencyDirs, readCargoPackage } from "./crate-graph.mjs";
|
|
27
28
|
import { recordPublished, stageSourceVersions } from "./rightkit-sync.mjs";
|
|
@@ -29,6 +30,10 @@ import { prepareSwapHelper, SWAP_HELPER_CARGO_ARGS, SWAP_HELPER_PACKAGE } from "
|
|
|
29
30
|
|
|
30
31
|
export const LOCAL_OPERATOR = "agent (local-build grant)";
|
|
31
32
|
export const LOCAL_HOST_POLICY_REASON = "Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); Windows covered by the next CI run";
|
|
33
|
+
export const LOCAL_WIN32_HOST_POLICY_REASON = "Adrian 2026-10-14 local-build grant (.rightkit-local-build.json); published from the Windows host, the macOS run is not part of this receipt";
|
|
34
|
+
// The hosts `--local` accepts and the host policy each records in the evidence.
|
|
35
|
+
export const LOCAL_HOST_POLICIES = Object.freeze({ darwin: "darwin-only", win32: "win32-only" });
|
|
36
|
+
const localHostPolicyReason = (platform) => (platform === "win32" ? LOCAL_WIN32_HOST_POLICY_REASON : LOCAL_HOST_POLICY_REASON);
|
|
32
37
|
const STEP_TIMEOUT_MS = 30 * 60 * 1000;
|
|
33
38
|
|
|
34
39
|
export class LocalPublishError extends Error {
|
|
@@ -39,6 +44,11 @@ const refuse = (message) => { throw new LocalPublishError(message); };
|
|
|
39
44
|
export const DEFAULT_CONCURRENCY = 4;
|
|
40
45
|
const MAX_CONCURRENCY = 8;
|
|
41
46
|
const CRATE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
47
|
+
const JOBS_SUBCOMMANDS = new Set(["test", "clippy", "check", "package"]);
|
|
48
|
+
export function withCargoJobs(args, jobs) {
|
|
49
|
+
return jobs !== undefined && JOBS_SUBCOMMANDS.has(args[0])
|
|
50
|
+
? [args[0], "--jobs", String(jobs), ...args.slice(1)] : args;
|
|
51
|
+
}
|
|
42
52
|
|
|
43
53
|
const crateList = (text, what) => {
|
|
44
54
|
const names = String(text ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
@@ -103,23 +113,37 @@ export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
|
|
|
103
113
|
const flag = kind === "npm" ? "--package" : "--crate";
|
|
104
114
|
const batchFlags = kind === "cargo" ? ", --crates <a,b,c>, --crates-file <file>, --concurrency <n>, --allow-ignored-units <a,b>" : "";
|
|
105
115
|
let unit; let workspace; let local = false; let allowIgnored = false;
|
|
116
|
+
let qualifyOnly = false; let bundle; let jobsText;
|
|
106
117
|
let crates; let cratesFile; let concurrency = DEFAULT_CONCURRENCY; let allowIgnoredUnits = [];
|
|
107
118
|
for (let i = 0; i < argv.length; i += 1) {
|
|
108
119
|
const arg = argv[i];
|
|
109
120
|
if (arg === "--local") local = true;
|
|
110
121
|
else if (arg === flag) unit = argv[++i];
|
|
111
122
|
else if (arg === "--workspace") workspace = argv[++i];
|
|
123
|
+
else if (kind === "cargo" && arg === "--qualify-only") qualifyOnly = true;
|
|
124
|
+
else if (kind === "cargo" && arg === "--bundle") bundle = argv[++i];
|
|
112
125
|
// Same meaning as publish.yml's allow_ignored: this unit may have ignored tests; the count is recorded.
|
|
113
126
|
else if (arg === "--allow-ignored") allowIgnored = true;
|
|
114
127
|
else if (kind === "cargo" && arg === "--crates") crates = crateList(argv[++i], "--crates");
|
|
115
128
|
else if (kind === "cargo" && arg === "--crates-file") cratesFile = argv[++i];
|
|
116
129
|
else if (kind === "cargo" && arg === "--concurrency") concurrency = Number(argv[++i]);
|
|
117
130
|
else if (kind === "cargo" && arg === "--allow-ignored-units") allowIgnoredUnits = crateList(argv[++i], "--allow-ignored-units");
|
|
131
|
+
else if (kind === "cargo" && arg === "--jobs") jobsText = argv[++i] ?? "";
|
|
118
132
|
else refuse(`unknown publish ${kind} --local option: ${arg} (accepted: ${flag} <name>${batchFlags}, --workspace <dir>, --allow-ignored, --local)`);
|
|
119
133
|
}
|
|
120
134
|
if (!local) refuse("--local is required");
|
|
121
135
|
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) refuse(`--concurrency must be an integer from 1 to ${MAX_CONCURRENCY}`);
|
|
122
136
|
const workspaceRoot = path.resolve(workspace ?? cwd);
|
|
137
|
+
if (qualifyOnly && (crates !== undefined || cratesFile !== undefined)) refuse("--qualify-only currently requires one --crate");
|
|
138
|
+
if (qualifyOnly && !String(bundle ?? "").trim()) refuse("--qualify-only requires --bundle <new-directory>");
|
|
139
|
+
if (bundle !== undefined && !qualifyOnly) refuse("--bundle requires --qualify-only");
|
|
140
|
+
const bundleDir = bundle === undefined ? undefined : path.resolve(cwd, bundle);
|
|
141
|
+
let jobs;
|
|
142
|
+
if (jobsText !== undefined) {
|
|
143
|
+
if (!/^[0-9]+$/.test(jobsText) || Number(jobsText) < 1 || Number(jobsText) > MAX_CARGO_JOBS) refuse(`--jobs must be an integer from 1 to ${MAX_CARGO_JOBS}`);
|
|
144
|
+
if (!qualifyOnly) refuse("--jobs requires --qualify-only");
|
|
145
|
+
jobs = Number(jobsText);
|
|
146
|
+
}
|
|
123
147
|
if (kind === "cargo" && (crates !== undefined || cratesFile !== undefined)) {
|
|
124
148
|
if (unit !== undefined) refuse("--crate cannot be combined with --crates or --crates-file");
|
|
125
149
|
if (crates !== undefined && cratesFile !== undefined) refuse("use --crates or --crates-file, not both");
|
|
@@ -131,7 +155,7 @@ export function parseLocalPublishArgs(kind, argv, cwd = process.cwd()) {
|
|
|
131
155
|
}
|
|
132
156
|
if (kind === "npm" && !/^@rightkit\/[a-z0-9-]+$/.test(unit ?? "")) refuse("publish npm --local requires --package @rightkit/<name>");
|
|
133
157
|
if (kind === "cargo" && !CRATE_NAME.test(unit ?? "")) refuse("publish cargo --local requires --crate <name> (or --crates / --crates-file)");
|
|
134
|
-
return { kind, unit, allowIgnored, allowIgnoredUnits, workspaceRoot, concurrency, batch: undefined };
|
|
158
|
+
return { kind, unit, allowIgnored, allowIgnoredUnits, workspaceRoot, concurrency, batch: undefined, ...(qualifyOnly ? { qualifyOnly, bundleDir } : {}), ...(jobs === undefined ? {} : { jobs }) };
|
|
135
159
|
}
|
|
136
160
|
|
|
137
161
|
function defaultGit(args, cwd) {
|
|
@@ -213,7 +237,7 @@ export function allFeatureCheckSets(entry = {}) {
|
|
|
213
237
|
}
|
|
214
238
|
|
|
215
239
|
/** The CI gate's commands for one unit (scripts/ci/gate.mjs npmGate/rustGate), mirrored exactly. */
|
|
216
|
-
export function localTestPlan({ kind, unit, dir, config, manifest, batch = false }) {
|
|
240
|
+
export function localTestPlan({ kind, unit, dir, config, manifest, batch = false, platform = "darwin" }) {
|
|
217
241
|
if (kind === "npm") {
|
|
218
242
|
if (!manifest?.scripts?.test) refuse(`${unit} has no test script; --local never publishes an untested package`);
|
|
219
243
|
return {
|
|
@@ -225,16 +249,19 @@ export function localTestPlan({ kind, unit, dir, config, manifest, batch = false
|
|
|
225
249
|
};
|
|
226
250
|
}
|
|
227
251
|
const entry = config?.rust?.[unit] ?? {};
|
|
228
|
-
|
|
252
|
+
// gate.config.json names hosts "macos" and "windows" (scripts/ci/gate.mjs HOST).
|
|
253
|
+
const gateHost = platform === "win32" ? "windows" : "macos";
|
|
254
|
+
if (entry.os && !entry.os.includes(gateHost)) refuse(`${unit} is restricted to ${entry.os.join(", ")} by ci/gate.config.json; it cannot be qualified on this ${platform === "win32" ? "Windows host" : "Mac"}`);
|
|
229
255
|
const featureSets = entry.featureSets ?? [[]];
|
|
256
|
+
// Stream Cargo artifact records to the broker; libtest summaries remain plain text for ResultScanner.
|
|
230
257
|
const steps = [];
|
|
231
258
|
for (const features of featureSets) {
|
|
232
|
-
steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--show-output"], test: true });
|
|
233
|
-
if (entry.alsoSerial) steps.push({ command: "cargo", args: ["test", "-p", unit, ...features, "--no-fail-fast", "--", "--test-threads=1", "--show-output"], test: true });
|
|
259
|
+
steps.push({ command: "cargo", args: ["test", "-p", unit, "--message-format=json-render-diagnostics", ...features, "--no-fail-fast", "--", "--show-output"], test: true });
|
|
260
|
+
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 });
|
|
234
261
|
}
|
|
235
262
|
// Batch mode lints the whole batch once (batchClippyInvocations) and records it as a batch receipt.
|
|
236
|
-
if (!batch) steps.push({ command: "cargo", args: ["clippy", "-p", unit, "--no-deps", "--all-targets", ...featureSets[0], "--", "-D", "warnings"], test: false });
|
|
237
|
-
for (const features of allFeatureCheckSets(entry)) steps.push({ command: "cargo", args: ["check", "-p", unit, "--all-targets", ...features], test: false });
|
|
263
|
+
if (!batch) steps.push({ command: "cargo", args: ["clippy", "-p", unit, "--message-format=json-render-diagnostics", "--no-deps", "--all-targets", ...featureSets[0], "--", "-D", "warnings"], test: false });
|
|
264
|
+
for (const features of allFeatureCheckSets(entry)) steps.push({ command: "cargo", args: ["check", "-p", unit, "--message-format=json-render-diagnostics", "--all-targets", ...features], test: false });
|
|
238
265
|
return { id: `rust:${unit}`, featureLabels: featureSets.map((features) => features.join(" ") || "default"), steps };
|
|
239
266
|
}
|
|
240
267
|
|
|
@@ -258,7 +285,7 @@ export function batchClippyInvocations(layer, { featuresOf = () => [], assetGate
|
|
|
258
285
|
}
|
|
259
286
|
return [...groups.values()].map(({ crates, features, assetGated }) => ({
|
|
260
287
|
crates, assetGated,
|
|
261
|
-
args: ["clippy", ...crates.flatMap((unit) => ["-p", unit]), "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"],
|
|
288
|
+
args: ["clippy", ...crates.flatMap((unit) => ["-p", unit]), "--message-format=json-render-diagnostics", "--no-deps", "--all-targets", ...features, "--", "-D", "warnings"],
|
|
262
289
|
}));
|
|
263
290
|
}
|
|
264
291
|
|
|
@@ -273,10 +300,12 @@ function testEnv(deps, isAssetGated) {
|
|
|
273
300
|
return { ...inherited, RIGHTKIT_REQUIRE_ASSETS: isAssetGated ? "0" : "1", RIGHTKIT_ISOLATED_CHECKOUT: "1", CARGO_TERM_COLOR: "never" };
|
|
274
301
|
}
|
|
275
302
|
|
|
276
|
-
function assertLocalHost(deps, workspaceRoot) {
|
|
303
|
+
function assertLocalHost(deps, workspaceRoot, kind) {
|
|
277
304
|
const platform = deps.platform ?? process.platform;
|
|
278
305
|
if (!(deps.localBuildGranted ?? localBuildGranted)(workspaceRoot)) refuse(`--local needs the local-build grant: a committed ${LOCAL_BUILD_FILE} (allow "build-test-publish") naming this repository at ${workspaceRoot}`);
|
|
279
|
-
if (platform
|
|
306
|
+
if (!Object.hasOwn(LOCAL_HOST_POLICIES, platform)) refuse(`--local publishes from the Mac only (host policy: darwin-only) or from Windows (host policy: win32-only); this host is ${platform}`);
|
|
307
|
+
// The npm path reads the registry token from the macOS Keychain and ships a Developer-ID-signed helper: Mac only.
|
|
308
|
+
if (kind === "npm" && platform !== "darwin") refuse(`publish npm --local needs the macOS Keychain npm token and runs on the Mac only; Windows publishes crates (publish cargo --local); this host is ${platform}`);
|
|
280
309
|
return platform;
|
|
281
310
|
}
|
|
282
311
|
|
|
@@ -300,7 +329,8 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
300
329
|
const run = deps.run ?? (batch ? defaultRunAsync : defaultRun);
|
|
301
330
|
|
|
302
331
|
// 1. Preconditions, fail closed.
|
|
303
|
-
const platform = assertLocalHost(deps, workspaceRoot);
|
|
332
|
+
const platform = assertLocalHost(deps, workspaceRoot, kind);
|
|
333
|
+
if (parsed.qualifyOnly && fs.existsSync(parsed.bundleDir)) refuse(`qualification bundle already exists: ${parsed.bundleDir}`);
|
|
304
334
|
const dir = kind === "npm" ? `packages/${unit.slice("@rightkit/".length)}` : `crates/${unit}`;
|
|
305
335
|
const unitPath = path.join(workspaceRoot, dir);
|
|
306
336
|
if (!fs.existsSync(unitPath)) refuse(`unit directory not found: ${unitPath}`);
|
|
@@ -324,18 +354,23 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
324
354
|
const tree = git(["rev-parse", "HEAD^{tree}"], workspaceRoot);
|
|
325
355
|
if (tree !== batch.tree) refuse(`${unit}: HEAD tree ${tree} differs from the batch tree ${batch.tree}`);
|
|
326
356
|
}
|
|
327
|
-
const dirty = git(["status", "--porcelain", "--", dir], workspaceRoot);
|
|
357
|
+
const dirty = git(["status", "--porcelain", "--", dir, ...(parsed.qualifyOnly ? ["Cargo.toml", "Cargo.lock"] : [])], workspaceRoot);
|
|
328
358
|
if (dirty === null) refuse(`cannot read git status for ${dir}`);
|
|
329
359
|
if (dirty !== "") refuse(`tree is not clean for ${dir}; commit first:\n${dirty}`);
|
|
330
360
|
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`);
|
|
331
|
-
log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant,
|
|
361
|
+
log(`[right-release] local publish ${kind}:${unit}@${version} at ${head.slice(0, 12)} (local-build grant, ${LOCAL_HOST_POLICIES[platform]} policy)`);
|
|
332
362
|
|
|
333
363
|
// 2. The unit's own tests, exactly as the CI gate defines them.
|
|
334
364
|
const gateConfig = readJson(path.join(workspaceRoot, "ci", "gate.config.json")) ?? {};
|
|
335
365
|
const assetGated = readJson(path.join(workspaceRoot, "ci", "asset-gated.json")) ?? {};
|
|
336
366
|
const gatedSection = assetGated[kind === "npm" ? "npm" : "crates"] ?? {};
|
|
337
367
|
const isAssetGated = Object.hasOwn(gatedSection, unit);
|
|
338
|
-
const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest, batch: Boolean(batch) });
|
|
368
|
+
const plan = localTestPlan({ kind, unit, dir, config: gateConfig, manifest, batch: Boolean(batch), platform });
|
|
369
|
+
if (parsed.qualifyOnly) {
|
|
370
|
+
const violations = (deps.scanSecrets ?? scanCrateForSecrets)(unitPath);
|
|
371
|
+
if (violations.length) refuse(`qualification secret scan rejected: ${violations.join(", ")}`);
|
|
372
|
+
plan.steps.unshift({ command: "cargo", args: ["fmt", "-p", unit, "--check"], test: false });
|
|
373
|
+
}
|
|
339
374
|
const modules = await (deps.loadWorkspaceModules ?? defaultLoadWorkspaceModules)(workspaceRoot);
|
|
340
375
|
const { ResultScanner, TEST_COMMAND, judgeUnit } = modules.results;
|
|
341
376
|
const env = testEnv(deps, isAssetGated);
|
|
@@ -343,15 +378,15 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
343
378
|
// written reason in ci/gate.config.json (rust.<crate>.allowIgnored). The count stays in the evidence.
|
|
344
379
|
const configReason = kind === "cargo" && typeof gateConfig?.rust?.[unit]?.allowIgnored === "string" ? gateConfig.rust[unit].allowIgnored.trim() : "";
|
|
345
380
|
const ignoredAllowed = Boolean(allowIgnored || parsed.allowIgnoredUnits?.includes(unit) || configReason);
|
|
346
|
-
const steps = [];
|
|
381
|
+
const steps = parsed.qualifyOnly ? [{ command: `scanCrateForSecrets ${dir}`, ok: true }] : [];
|
|
347
382
|
for (const step of plan.steps) {
|
|
348
383
|
const commandText = `${step.command} ${step.args.join(" ")}`;
|
|
349
384
|
log(`[right-release] ${plan.id}: ${commandText}`);
|
|
350
|
-
const result = await run(step.command, step.args, { cwd: workspaceRoot, env });
|
|
385
|
+
const result = await run(step.command, step.command === "cargo" ? withCargoJobs(step.args, parsed.jobs) : step.args, { cwd: workspaceRoot, env });
|
|
351
386
|
const row = { command: commandText, ok: false, isTest: step.test && TEST_COMMAND.test(commandText) };
|
|
352
387
|
if (result.error || result.status !== 0) {
|
|
353
388
|
steps.push(row);
|
|
354
|
-
refuse(`${plan.id} step failed (${result.error?.message ?? `exit ${result.status}`}): ${commandText}\n${
|
|
389
|
+
refuse(`${plan.id} step failed (${result.error?.message ?? `exit ${result.status}`}): ${commandText}\nstdout:\n${result.stdout ?? ""}\nstderr:\n${result.stderr ?? ""}`);
|
|
355
390
|
}
|
|
356
391
|
row.ok = true;
|
|
357
392
|
if (row.isTest) {
|
|
@@ -391,16 +426,23 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
391
426
|
const checked = modules.checkNpmTarball({ file: artifactFile, name: unit, version, dir, spawn: deps.spawnSync ?? spawnSync });
|
|
392
427
|
if (checked.problems.length) refuse(`${unit}: packed tarball refused:\n - ${checked.problems.join("\n - ")}`);
|
|
393
428
|
} else {
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
|
|
429
|
+
// A combined publish verifies once in `cargo publish`; detached qualification verifies here before
|
|
430
|
+
// its evidence leaves this host, so the publishing host can reuse that exact archive's proof.
|
|
431
|
+
const packageArgs = ["package", "--locked", ...(parsed.qualifyOnly ? [] : ["--no-verify"]), "-p", unit];
|
|
432
|
+
packCommands.push(`cargo ${packageArgs.join(" ")}`);
|
|
433
|
+
const packed = await run("cargo", withCargoJobs(packageArgs, parsed.jobs), { cwd: workspaceRoot, env });
|
|
398
434
|
if (packed.error || packed.status !== 0) refuse(`cargo package failed for ${unit}\n${tail(`${packed.stdout ?? ""}\n${packed.stderr ?? ""}`)}`);
|
|
399
|
-
|
|
400
|
-
const
|
|
435
|
+
if (parsed.qualifyOnly) steps.push({ command: `cargo ${packageArgs.join(" ")}`, ok: true });
|
|
436
|
+
const metadataArgs = ["metadata", "--offline", "--format-version", "1", "--no-deps"];
|
|
437
|
+
const meta = await runCargoReadOnly(metadataArgs, { cwd: workspaceRoot, env }, (_command, args, settings) => run("cargo", args, settings),
|
|
438
|
+
(failed) => log(`[right-release] offline Cargo metadata missed cache; retrying online once\n${failed.stdout ?? ""}${failed.stderr ? `\n${failed.stderr}` : ""}`));
|
|
439
|
+
const actualMetadataArgs = meta.usedOfflineFallback ? metadataArgs.filter((arg) => arg !== "--offline") : metadataArgs;
|
|
440
|
+
packCommands.push(`cargo ${actualMetadataArgs.join(" ")}`);
|
|
401
441
|
let targetDir;
|
|
402
442
|
try { targetDir = JSON.parse(meta.stdout).target_directory; } catch { /* refused below */ }
|
|
403
|
-
if (meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir))
|
|
443
|
+
if (meta.error || meta.status !== 0 || typeof targetDir !== "string" || !path.isAbsolute(targetDir)) {
|
|
444
|
+
refuse(`cannot read the Cargo target directory\n${tail(`${meta.error?.message ?? ""}\n${meta.stdout ?? ""}\n${meta.stderr ?? ""}`)}`);
|
|
445
|
+
}
|
|
404
446
|
artifactFile = path.join(targetDir, "package", `${unit}-${version}.crate`);
|
|
405
447
|
if (!fs.existsSync(artifactFile)) refuse(`cargo package did not produce ${artifactFile}`);
|
|
406
448
|
}
|
|
@@ -411,13 +453,14 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
411
453
|
const timestamp = (deps.now ?? (() => new Date().toISOString()))();
|
|
412
454
|
const evidence = {
|
|
413
455
|
schema: GATE_SCHEMA, kind, name: unit, version, commit: head, artifactSha256,
|
|
414
|
-
hostPolicy:
|
|
456
|
+
hostPolicy: LOCAL_HOST_POLICIES[platform], hostPolicyReason: localHostPolicyReason(platform),
|
|
415
457
|
runs: [{
|
|
416
|
-
host: { os:
|
|
458
|
+
host: { os: platform, arch: deps.arch ?? process.arch }, commit: head, features: plan.featureLabels, artifactSha256,
|
|
417
459
|
command: commands.join(" && "), operator: LOCAL_OPERATOR, timestamp,
|
|
418
460
|
passed: verdict.executed, failed: 0, skipped: verdict.skipped, ignored: verdict.ignored, allowIgnored: ignoredAllowed,
|
|
419
461
|
...(configReason ? { allowIgnoredReason: configReason } : {}),
|
|
420
462
|
requireAssets: env.RIGHTKIT_REQUIRE_ASSETS, assetGated: isAssetGated,
|
|
463
|
+
...(parsed.jobs === undefined ? {} : { jobs: parsed.jobs }),
|
|
421
464
|
...(batch ? { batch: structuredClone(batch.receipt) } : {}),
|
|
422
465
|
steps: steps.map(({ command, ok, executed, failed, ignored, skipped, binaries }) => ({ command, ok, ...(executed === undefined ? {} : { executed, failed, ignored, skipped, binaries }) })),
|
|
423
466
|
}],
|
|
@@ -429,6 +472,18 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
429
472
|
const gateOptions = { kind, name: unit, version, packagePath: unitPath, evidencePath };
|
|
430
473
|
(deps.enforceGate ?? enforcePublicationGate)(gateOptions, { write: (line) => log(line.replace(/\n$/, "")) });
|
|
431
474
|
|
|
475
|
+
if (parsed.qualifyOnly) {
|
|
476
|
+
// Keep bytes and their normal gate evidence together outside the checkout. Never overwrite a bundle.
|
|
477
|
+
fs.mkdirSync(parsed.bundleDir, { recursive: false });
|
|
478
|
+
const bundledArtifact = path.join(parsed.bundleDir, path.basename(artifactFile));
|
|
479
|
+
const bundledEvidence = path.join(parsed.bundleDir, "gate.json");
|
|
480
|
+
fs.copyFileSync(artifactFile, bundledArtifact, fs.constants.COPYFILE_EXCL);
|
|
481
|
+
if (sha256(fs.readFileSync(bundledArtifact)) !== artifactSha256) refuse("qualification archive changed while copying");
|
|
482
|
+
fs.copyFileSync(evidencePath, bundledEvidence, fs.constants.COPYFILE_EXCL);
|
|
483
|
+
log(`[right-release] qualified ${unit}@${version}; bundle ${parsed.bundleDir}; publish cargo --crate ${unit} --from-evidence ${bundledEvidence}`);
|
|
484
|
+
return { kind, name: unit, version, commit: head, artifactSha256, evidencePath: bundledEvidence, artifactFile: bundledArtifact, qualifiedOnly: true };
|
|
485
|
+
}
|
|
486
|
+
|
|
432
487
|
// 5. The EXISTING publish path.
|
|
433
488
|
if (kind === "npm") {
|
|
434
489
|
(deps.publishGatedTarball ?? publishGatedTarball)({ name: unit, version, tarball: artifactFile, evidencePath }, deps.publishDeps ?? {});
|
|
@@ -437,7 +492,7 @@ async function publishUnit(parsed, deps, batch) {
|
|
|
437
492
|
// and clippy, so the publisher does not repeat them; verifyOnPublish: Cargo's verify build runs in the
|
|
438
493
|
// publish itself. Package contents, the runtime budget, the archive hash and the readback always run.
|
|
439
494
|
const execute = deps.cargoExecute ?? (batch ? batchCargoExecute(unit) : undefined);
|
|
440
|
-
const published = await (deps.runCargoPublish ?? runCargoPublish)({ crate: unit, dryRun: false, allowDirty: false, workspaceRoot, evidence: evidencePath, trustEvidenceSteps: true, verifyOnPublish: true }, execute, deps.cargoDeps ?? {});
|
|
495
|
+
const published = await (deps.runCargoPublish ?? runCargoPublish)({ crate: unit, dryRun: false, allowDirty: false, workspaceRoot, evidence: evidencePath, trustEvidenceSteps: true, verifyOnPublish: true, offlineReadOnly: true }, execute, deps.cargoDeps ?? {});
|
|
441
496
|
await withVersionsLock(() => (deps.recordPublished ?? recordPublished)({ registry: "cargo", name: published.name, version: published.version }));
|
|
442
497
|
}
|
|
443
498
|
log(`[right-release] published ${unit}@${version}`);
|
|
@@ -460,7 +515,7 @@ async function runLocalPublishBatch(parsed, deps) {
|
|
|
460
515
|
const git = deps.git ?? defaultGit;
|
|
461
516
|
const readJson = deps.readJson ?? defaultReadJson;
|
|
462
517
|
const run = deps.run ?? defaultRunAsync;
|
|
463
|
-
assertLocalHost(deps, workspaceRoot);
|
|
518
|
+
const platform = assertLocalHost(deps, workspaceRoot, "cargo");
|
|
464
519
|
|
|
465
520
|
// 1. The crate list and its dependency layers.
|
|
466
521
|
const requested = parsed.batch.file ? parseCrateLayers((deps.readText ?? ((file) => fs.readFileSync(file, "utf8")))(parsed.batch.file)) : [parsed.batch.crates];
|
|
@@ -483,7 +538,7 @@ async function runLocalPublishBatch(parsed, deps) {
|
|
|
483
538
|
if (pkg.name !== unit) refuse(`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crates entry ${unit}`);
|
|
484
539
|
if (typeof pkg.version !== "string" || !pkg.version.trim()) refuse(`${unit} has no literal version`);
|
|
485
540
|
versions.set(unit, pkg.version);
|
|
486
|
-
localTestPlan({ kind: "cargo", unit, dir, config: gateConfig, batch: true }); // refuses an OS-restricted crate now, not mid-batch
|
|
541
|
+
localTestPlan({ kind: "cargo", unit, dir, config: gateConfig, batch: true, platform }); // refuses an OS-restricted crate now, not mid-batch
|
|
487
542
|
}
|
|
488
543
|
const head = git(["rev-parse", "HEAD"], workspaceRoot);
|
|
489
544
|
if (!head || !/^[0-9a-f]{40}$/.test(head)) refuse("cannot resolve HEAD");
|
|
@@ -496,7 +551,7 @@ async function runLocalPublishBatch(parsed, deps) {
|
|
|
496
551
|
const registryHas = deps.registryHas ?? defaultRegistryHas;
|
|
497
552
|
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);
|
|
498
553
|
if (published.length) refuse(`already on the crates.io registry: ${published.join(", ")}; bump the version or remove them from the batch`);
|
|
499
|
-
log(`[right-release] local publish cargo batch: ${crates.length} crate(s) in ${layers.length} layer(s) at ${head.slice(0, 12)}, concurrency ${concurrency} (local-build grant,
|
|
554
|
+
log(`[right-release] local publish cargo batch: ${crates.length} crate(s) in ${layers.length} layer(s) at ${head.slice(0, 12)}, concurrency ${concurrency} (local-build grant, ${LOCAL_HOST_POLICIES[platform]} policy)`);
|
|
500
555
|
|
|
501
556
|
// 3. Batch-level checks, once, at the batch commit. Each is recorded in every crate's evidence.
|
|
502
557
|
const env = testEnv(deps, false);
|
package/managed-cargo.mjs
CHANGED
|
@@ -18,6 +18,14 @@ export function managedRuntimeClient(env = process.env, platform = process.platf
|
|
|
18
18
|
const client = path.posix.join(env.HOME || homedir(), 'Library', 'Application Support', 'RustRight', 'runtime', 'rustright');
|
|
19
19
|
if (exists(client)) return client;
|
|
20
20
|
}
|
|
21
|
+
// RustRight's own install on Windows (2026-10-18 cutover): the runtime moved out of the control root to
|
|
22
|
+
// <drive>\.rustright\runtime, beside the control root's drive, and runtime-current was retired.
|
|
23
|
+
if (platform === 'win32') {
|
|
24
|
+
for (const root of roots) {
|
|
25
|
+
const client = path.win32.join(path.win32.parse(root).root, '.rustright', 'runtime', 'rustright.exe');
|
|
26
|
+
if (exists(client)) return client;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
21
29
|
return undefined;
|
|
22
30
|
}
|
|
23
31
|
|
package/mirror-root-artifact.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// `{ file, name?, pruneSuffix? }`. `pruneSuffix` (for example "-setup.exe") then removes the other files with that suffix
|
|
7
7
|
// from the managed directory, so it holds exactly the current artifact. Run it as `targets.<platform>.postSign`.
|
|
8
8
|
// Output goes only to RIGHT_RELEASE_ARTIFACT_DIR (outside the repository); it never writes into the repository root.
|
|
9
|
+
import { configuredManagedRootSpellings } from "./artifact-storage.mjs";
|
|
9
10
|
import { copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
|
|
10
11
|
import { execFileSync } from "./background-process.mjs";
|
|
11
12
|
import path from "node:path";
|
|
@@ -113,11 +114,14 @@ function atomicCopy(from, to) {
|
|
|
113
114
|
function assertSafePath(target) {
|
|
114
115
|
const absolute = path.resolve(target);
|
|
115
116
|
const root = path.parse(absolute).root;
|
|
117
|
+
// Only the configured managed root itself may be a symlink (same rule as artifact-storage assertSafeMutation).
|
|
118
|
+
const managedRoots = configuredManagedRootSpellings();
|
|
119
|
+
const configured = (candidate) => managedRoots.has(process.platform === "win32" ? path.resolve(candidate).toLowerCase() : path.resolve(candidate));
|
|
116
120
|
let cursor = root;
|
|
117
121
|
for (const segment of path.relative(root, absolute).split(path.sep).filter(Boolean)) {
|
|
118
122
|
cursor = path.join(cursor, segment);
|
|
119
123
|
try {
|
|
120
|
-
if (lstatSync(cursor).isSymbolicLink()) throw new Error(`refusing filesystem mutation through symlink: ${cursor}`);
|
|
124
|
+
if (lstatSync(cursor).isSymbolicLink() && !configured(cursor)) throw new Error(`refusing filesystem mutation through symlink: ${cursor}`);
|
|
121
125
|
} catch (error) {
|
|
122
126
|
if (error.code !== "ENOENT") throw error;
|
|
123
127
|
}
|
package/native-cargo-layout.mjs
CHANGED
|
@@ -18,6 +18,8 @@ export function resolveNativeCargoLayout({
|
|
|
18
18
|
read = (file) => readFileSync(file, "utf8"),
|
|
19
19
|
requireManifest = false,
|
|
20
20
|
requireLock = false,
|
|
21
|
+
// The repository the app lives in. A manifest outside the app searches for its workspace only up to this directory.
|
|
22
|
+
repoRoot,
|
|
21
23
|
} = {}) {
|
|
22
24
|
if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
|
|
23
25
|
const resolvedAppRoot = path.resolve(appRoot);
|
|
@@ -31,7 +33,7 @@ export function resolveNativeCargoLayout({
|
|
|
31
33
|
const explicitWorkspace = nativeAssembly?.workspaceRoot ?? nativeAssembly?.cargoWorkspaceRoot;
|
|
32
34
|
const workspaceRoot = explicitWorkspace
|
|
33
35
|
? normalizeWorkspaceRoot(resolveDeclaredPath(resolvedAppRoot, explicitWorkspace), exists)
|
|
34
|
-
: discoverWorkspaceRoot({ appRoot: resolvedAppRoot, manifestPath, manifestDir, manifestText, exists, read });
|
|
36
|
+
: discoverWorkspaceRoot({ appRoot: resolvedAppRoot, manifestPath, manifestDir, manifestText, exists, read, repoRoot: repoRoot ? path.resolve(repoRoot) : undefined });
|
|
35
37
|
|
|
36
38
|
const lock = resolveCargoLock({
|
|
37
39
|
appRoot: resolvedAppRoot,
|
|
@@ -84,6 +86,28 @@ export function resolveNativeCargoLayout({
|
|
|
84
86
|
return layout;
|
|
85
87
|
}
|
|
86
88
|
|
|
89
|
+
/**
|
|
90
|
+
* True when the app has a native Cargo manifest the release must hash and build against: the config declares one
|
|
91
|
+
* (nativeAssembly.cargoManifest / manifest) or the historical src-tauri/Cargo.toml exists. A native iOS app (xcodegen
|
|
92
|
+
* project, Rust static library built by iosPackage.prepare) has neither, and then owns no Cargo layout at all.
|
|
93
|
+
*/
|
|
94
|
+
export function nativeCargoDeclared({ appRoot, config, nativeAssembly = config?.nativeAssembly, exists = existsSync } = {}) {
|
|
95
|
+
if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
|
|
96
|
+
if (nativeAssembly?.cargoManifest ?? nativeAssembly?.manifest) return true;
|
|
97
|
+
return exists(path.resolve(appRoot, DEFAULT_MANIFEST));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The manifest nativeAssembly names, if any (a path relative to the app root, or absolute). */
|
|
101
|
+
export function declaredCargoManifest(config, nativeAssembly = config?.nativeAssembly) {
|
|
102
|
+
return nativeAssembly?.cargoManifest ?? nativeAssembly?.manifest;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** True when the historical Tauri host manifest exists. */
|
|
106
|
+
export function hasDefaultTauriManifest({ appRoot, exists = existsSync } = {}) {
|
|
107
|
+
if (!appRoot) throw new TypeError("native Cargo layout requires appRoot");
|
|
108
|
+
return exists(path.resolve(appRoot, DEFAULT_MANIFEST));
|
|
109
|
+
}
|
|
110
|
+
|
|
87
111
|
export const resolveCargoLayout = resolveNativeCargoLayout;
|
|
88
112
|
export const resolveNativeCargoManifest = resolveNativeCargoLayout;
|
|
89
113
|
export const resolveCargoManifest = resolveNativeCargoLayout;
|
|
@@ -160,20 +184,29 @@ function resolveCargoLock({ appRoot, manifestPath, manifestDir, workspaceRoot, n
|
|
|
160
184
|
return { path: found, source: found ? orderedSources[candidates.indexOf(found)] : undefined, candidates };
|
|
161
185
|
}
|
|
162
186
|
|
|
163
|
-
function discoverWorkspaceRoot({ appRoot, manifestPath, manifestDir, manifestText, exists, read }) {
|
|
187
|
+
function discoverWorkspaceRoot({ appRoot, manifestPath, manifestDir, manifestText, exists, read, repoRoot }) {
|
|
164
188
|
const parsed = parseCargoManifest(manifestText);
|
|
165
189
|
const declaredWorkspace = parsed.workspacePath ?? parsed.packageWorkspace;
|
|
166
190
|
if (declaredWorkspace) {
|
|
167
191
|
return normalizeWorkspaceRoot(resolveDeclaredPath(manifestDir, declaredWorkspace), exists);
|
|
168
192
|
}
|
|
169
193
|
if (parsed.hasWorkspace) return manifestDir;
|
|
194
|
+
// A manifest inside the app searches up to the app root (unchanged); one outside it (a shared crates/ tree) searches up to the
|
|
195
|
+
// nearest directory that contains both, which is where its workspace and Cargo.lock live.
|
|
196
|
+
// Only a caller that names the repository opts in to the climb above the app (native iOS); every other caller keeps the
|
|
197
|
+
// exact historical search, which never leaves the app root.
|
|
198
|
+
let boundary = appRoot;
|
|
199
|
+
if (repoRoot && !isWithin(appRoot, manifestDir)) {
|
|
200
|
+
boundary = commonAncestor(appRoot, manifestDir);
|
|
201
|
+
if (!isWithin(repoRoot, boundary)) boundary = repoRoot;
|
|
202
|
+
}
|
|
170
203
|
let current = manifestDir;
|
|
171
204
|
for (;;) {
|
|
172
205
|
const candidate = path.join(current, "Cargo.toml");
|
|
173
206
|
if (candidate !== manifestPath && exists(candidate) && parseCargoManifest(readIfPresent(candidate, exists, read)).hasWorkspace) return current;
|
|
174
|
-
if (current ===
|
|
207
|
+
if (current === boundary) break;
|
|
175
208
|
const parent = path.dirname(current);
|
|
176
|
-
if (parent === current || !isWithin(
|
|
209
|
+
if (parent === current || !isWithin(boundary, parent)) break;
|
|
177
210
|
current = parent;
|
|
178
211
|
}
|
|
179
212
|
return manifestDir;
|
|
@@ -231,6 +264,16 @@ function readIfPresent(file, exists, read) {
|
|
|
231
264
|
try { return exists(file) ? read(file) : ""; } catch { return ""; }
|
|
232
265
|
}
|
|
233
266
|
|
|
267
|
+
function commonAncestor(left, right) {
|
|
268
|
+
let current = path.resolve(left);
|
|
269
|
+
while (!isWithin(current, path.resolve(right))) {
|
|
270
|
+
const parent = path.dirname(current);
|
|
271
|
+
if (parent === current) return current;
|
|
272
|
+
current = parent;
|
|
273
|
+
}
|
|
274
|
+
return current;
|
|
275
|
+
}
|
|
276
|
+
|
|
234
277
|
function isWithin(root, candidate) {
|
|
235
278
|
const relative = path.relative(root, candidate);
|
|
236
279
|
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
package/ownership-command.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// right-release ownership [--app <dir>] --audit-json (Legion governance.capability-ownership provider)
|
|
7
7
|
// right-release ownership trust-compare [--json] (print this host's protected-trust digest; policy-bundle drift is a warning, never a block)
|
|
8
8
|
// right-release ownership trust-sync (retired: prints why and exits 0)
|
|
9
|
+
// right-release ownership trust-refresh [--dry-run] [--app <dir>] (elevated, key-neutral: merge new repository registrations and floors from the installed release into protected trust)
|
|
9
10
|
// right-release ownership --write-manifest [--rightkit <rightkit>] [--approve-new-capabilities]
|
|
10
11
|
// --approve-new-capabilities permits new ids during local generation only.
|
|
11
12
|
// Existing owner changes are always refused; installation needs protected authorization.
|
|
@@ -75,7 +76,7 @@ export async function main(args, { evidenceFactory = createOwnershipEvidence, gi
|
|
|
75
76
|
if (args[0] === "trust-compare") return trustCompare(args, { print, trustLoader });
|
|
76
77
|
if (args.includes("--staged")) return stagedMain(args, { print });
|
|
77
78
|
if (args[0] === "trust-sync") { print(`[right-release] ${TRUST_SYNC_RETIRED}`); return 0; }
|
|
78
|
-
if (["enroll", "approve-key", "revoke-key"].includes(args[0])) {
|
|
79
|
+
if (["enroll", "approve-key", "revoke-key", "trust-refresh"].includes(args[0])) {
|
|
79
80
|
const { main: keyMain } = await import("./ownership-key-command.mjs");
|
|
80
81
|
return keyMain(args, { runner: gitRunner, print });
|
|
81
82
|
}
|
package/ownership-evidence.mjs
CHANGED
|
@@ -23,6 +23,7 @@ function rootContains(root, candidate) {
|
|
|
23
23
|
|
|
24
24
|
function createEvidence(repoRoot, {
|
|
25
25
|
scanRoot = repoRoot,
|
|
26
|
+
scope: requestedScope = null,
|
|
26
27
|
manifestPath = path.join(path.dirname(fileURLToPath(import.meta.url)), MANIFEST_FILE),
|
|
27
28
|
scan = checkApp,
|
|
28
29
|
trustLoader = loadTrustedExternal,
|
|
@@ -33,6 +34,7 @@ function createEvidence(repoRoot, {
|
|
|
33
34
|
policyBundleProvider = runningPolicyBundle,
|
|
34
35
|
} = {}, readAuthority = readCommittedKeyAuthority, scanCommitted = checkCommittedApp) {
|
|
35
36
|
const root = path.resolve(repoRoot);
|
|
37
|
+
const scope = requestedScope ? [...new Set(requestedScope)].sort() : null;
|
|
36
38
|
const manifestBytes = readFileSync(manifestPath);
|
|
37
39
|
const manifest = JSON.parse(manifestBytes.toString("utf8"));
|
|
38
40
|
let trustState = trustLoader();
|
|
@@ -114,21 +116,27 @@ function createEvidence(repoRoot, {
|
|
|
114
116
|
}
|
|
115
117
|
}
|
|
116
118
|
const registration = registrationCheck?.registration ?? null;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
if (scope && !useCommittedSource) throw new Error("a scoped ownership scan requires the committed-source scan; this host's trust enrollment cannot provide it");
|
|
120
|
+
// A scoped scan covers its scope roots; the unscoped scan covers the requested root.
|
|
121
|
+
const coverageTargets = scope ?? [requestedRoot];
|
|
122
|
+
const unregisteredTargets = registration ? coverageTargets.filter((target) => !registration.roots.some((registered) => rootContains(registered, target))) : [];
|
|
123
|
+
const requestedRootRegistered = unregisteredTargets.length === 0;
|
|
124
|
+
for (const target of unregisteredTargets) {
|
|
125
|
+
if (!enrollmentPending) throw new Error(`release app root is outside protected ownership roots: ${target}`);
|
|
126
|
+
evidenceDiagnostics.push(`requested scan root is outside protected ownership roots during pending enrollment: ${target}`);
|
|
121
127
|
}
|
|
122
128
|
|
|
123
129
|
// The release app's manifest/config can be nested, but the external registry
|
|
124
130
|
// defines repository coverage. Scan every protected root so a nested release
|
|
125
131
|
// cannot omit sibling packages or engine source by narrowing scanRoot.
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
const
|
|
132
|
+
const overlapsScope = (registered) => !scope || scope.some((target) => rootContains(registered, target) || rootContains(target, registered));
|
|
133
|
+
const scanRoots = registration?.roots ? registration.roots.filter(overlapsScope) : [];
|
|
134
|
+
if (scope) { for (const target of unregisteredTargets) if (!scanRoots.includes(target)) scanRoots.push(target); }
|
|
135
|
+
else if (!requestedRootRegistered && !scanRoots.includes(requestedRoot)) scanRoots.push(requestedRoot);
|
|
136
|
+
const scans = (scanRoots.length ? scanRoots : [scope ? "." : requestedRoot]).map((registered) => {
|
|
129
137
|
const absolute = registered === "." ? root : path.resolve(root, ...registered.split("/"));
|
|
130
138
|
const scanned = useCommittedSource
|
|
131
|
-
? scanCommitted(absolute, manifest, { trust: trustState.trust, expectedCommit: commit, expectedTree: tree, source })
|
|
139
|
+
? scanCommitted(absolute, manifest, { trust: trustState.trust, expectedCommit: commit, expectedTree: tree, source, ...(scope ? { scope } : {}) })
|
|
132
140
|
: scan(absolute, manifest, { trust: { trust: trustState.trust, enrolled: trustState.enrolled, pending: enrollmentPending }, scannerVersion, catalogVersion, ...(enrollmentPending ? { enrollmentPendingAdvisory: true } : {}) });
|
|
133
141
|
if (repositoryIdentity && canonicalRepositoryIdentity(scanned.metadata?.repositoryIdentity) !== repositoryIdentity) throw new Error(`ownership evidence: scanner observed a different repository identity under ${registered}`);
|
|
134
142
|
if (useCommittedSource && scanned.metadata?.commit !== commit) throw new Error(`ownership evidence: scanner observed a different source commit under ${registered}`);
|
|
@@ -181,6 +189,7 @@ function createEvidence(repoRoot, {
|
|
|
181
189
|
floorOk: floor.ok,
|
|
182
190
|
visibility: registration?.visibility ?? null,
|
|
183
191
|
registrationDigest: registration ? sha256(registration) : null,
|
|
192
|
+
...(scope ? { ownershipScope: scope, ownershipScopeClosed: [...new Set(scans.flatMap(({ scanned }) => scanned.metadata?.ownershipScopeClosed ?? scope))].sort() } : {}),
|
|
184
193
|
registeredRoots: registration ? [...registration.roots] : [],
|
|
185
194
|
contentDigest: enrollmentPending ? null : contentDigest,
|
|
186
195
|
checkedAt: new Date().toISOString(),
|
|
@@ -190,13 +199,13 @@ function createEvidence(repoRoot, {
|
|
|
190
199
|
}
|
|
191
200
|
|
|
192
201
|
/** Create scan evidence bound to committed source, catalog, and signed exceptions. */
|
|
193
|
-
export function createOwnershipEvidence(repoRoot, scanRoot = repoRoot) {
|
|
194
|
-
return createEvidence(repoRoot, { scanRoot, committed: true, requireCleanCheckout: true, allowPendingAdvisory: true });
|
|
202
|
+
export function createOwnershipEvidence(repoRoot, scanRoot = repoRoot, scope = null) {
|
|
203
|
+
return createEvidence(repoRoot, { scanRoot, scope, committed: true, requireCleanCheckout: true, allowPendingAdvisory: true });
|
|
195
204
|
}
|
|
196
205
|
|
|
197
206
|
/** Alias spelling makes the immutable-source contract explicit at adapter boundaries. */
|
|
198
|
-
export function createCommittedOwnershipEvidence(repoRoot, scanRoot = repoRoot) {
|
|
199
|
-
return createEvidence(repoRoot, { scanRoot, committed: true, requireCleanCheckout: true });
|
|
207
|
+
export function createCommittedOwnershipEvidence(repoRoot, scanRoot = repoRoot, scope = null) {
|
|
208
|
+
return createEvidence(repoRoot, { scanRoot, scope, committed: true, requireCleanCheckout: true });
|
|
200
209
|
}
|
|
201
210
|
|
|
202
211
|
// Injection seam is deliberately named for tests and is not used by release commands.
|
|
@@ -219,21 +228,24 @@ export function runProtectedLocalOwnershipCheck(repoRoot, scanRoot = repoRoot) {
|
|
|
219
228
|
}
|
|
220
229
|
|
|
221
230
|
/** Re-scan exact source and compare every sealed binding before release upload. */
|
|
222
|
-
export function verifyOwnershipEvidence(expected, repoRoot, scanRoot = repoRoot) {
|
|
223
|
-
return verifyEvidence(expected, repoRoot, scanRoot, () => createCommittedOwnershipEvidence(repoRoot, scanRoot));
|
|
231
|
+
export function verifyOwnershipEvidence(expected, repoRoot, scanRoot = repoRoot, scope = null) {
|
|
232
|
+
return verifyEvidence(expected, repoRoot, scanRoot, () => createCommittedOwnershipEvidence(repoRoot, scanRoot, scope), scope);
|
|
224
233
|
}
|
|
225
234
|
|
|
226
235
|
export function verifyOwnershipEvidenceForTest(expected, repoRoot, options) {
|
|
227
|
-
return verifyEvidence(expected, repoRoot, options.scanRoot ?? repoRoot, () => createOwnershipEvidenceForTest(repoRoot, options));
|
|
236
|
+
return verifyEvidence(expected, repoRoot, options.scanRoot ?? repoRoot, () => createOwnershipEvidenceForTest(repoRoot, options), options.scope ?? null);
|
|
228
237
|
}
|
|
229
238
|
|
|
230
|
-
function verifyEvidence(expected, repoRoot, _scanRoot, createCurrent) {
|
|
239
|
+
function verifyEvidence(expected, repoRoot, _scanRoot, createCurrent, scope = null) {
|
|
231
240
|
if (!expected || expected.schema !== 1) return { ok: false, reason: "sealed ownership evidence is missing or malformed" };
|
|
241
|
+
// The sealed scope must equal what the current config yields; the re-scan below uses that same scope.
|
|
242
|
+
const wanted = scope ? [...new Set(scope)].sort() : null;
|
|
243
|
+
if (JSON.stringify(expected.ownershipScope ?? null) !== JSON.stringify(wanted)) return { ok: false, reason: "ownership evidence ownershipScope changed after build" };
|
|
232
244
|
let current;
|
|
233
245
|
try { current = createCurrent(); }
|
|
234
246
|
catch (error) { return { ok: false, reason: error.message }; }
|
|
235
|
-
for (const key of ["repositoryIdentity", "source", "commit", "tree", "catalogDigest", "catalogVersion", "scannerVersion", "approvalsDigest", "keyLogDigest", "keyCheckpoint", "rootFingerprint", "keyAuthority", "visibility", "registrationDigest", "registeredRoots", "contentDigest"]) {
|
|
236
|
-
const unchanged = key === "registeredRoots" || key === "keyCheckpoint" || key === "keyAuthority"
|
|
247
|
+
for (const key of ["repositoryIdentity", "source", "commit", "tree", "catalogDigest", "catalogVersion", "scannerVersion", "approvalsDigest", "keyLogDigest", "keyCheckpoint", "rootFingerprint", "keyAuthority", "visibility", "registrationDigest", "registeredRoots", "contentDigest", "ownershipScopeClosed"]) {
|
|
248
|
+
const unchanged = key === "registeredRoots" || key === "ownershipScopeClosed" || key === "keyCheckpoint" || key === "keyAuthority"
|
|
237
249
|
? JSON.stringify(expected[key]) === JSON.stringify(current.evidence[key])
|
|
238
250
|
: expected[key] === current.evidence[key];
|
|
239
251
|
if (!unchanged) return { ok: false, reason: `ownership evidence ${key} changed after build` };
|