@rightkit/release 0.3.0 → 0.4.1
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 +74 -10
- package/addon-command.mjs +3 -4
- package/adoption-evidence.mjs +650 -0
- package/android-play-upload.mjs +2 -1
- package/apple-profile-create.mjs +113 -10
- package/apple-profile-renew.mjs +11 -3
- package/artifact-storage.mjs +35 -2
- package/bin/darwin/rightkit-swap +0 -0
- package/binary-license-notices.mjs +161 -0
- package/build-release.mjs +138 -47
- package/cargo-license-notices.mjs +111 -0
- package/cli/right-release.mjs +21 -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 +222 -53
- 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 +3 -1
- package/ownership-evidence.mjs +30 -18
- package/ownership-key-command.mjs +69 -2
- package/ownership-mandatory-set.json +117 -67
- package/ownership-mandatory-set.mjs +141 -21
- 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/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-scan.mjs +285 -0
- package/pipeline-tools.mjs +171 -0
- package/preflight.mjs +14 -7
- package/production-mode.mjs +42 -0
- package/project-type.mjs +3 -1
- package/protected-credentials.mjs +28 -1
- package/publication-artifacts.mjs +30 -5
- package/publication-gate.mjs +94 -32
- package/publish-cargo.mjs +117 -25
- package/publish-swift.mjs +12 -3
- package/publish-update.mjs +15 -2
- package/qa-build-features.mjs +16 -13
- package/qa-contract.mjs +30 -2
- package/qa-control-scan.mjs +8 -0
- package/release-config.mjs +330 -0
- package/release-invocation.mjs +118 -8
- package/release-switch-registry.json +126 -27
- package/release-switch-scan.mjs +88 -21
- package/release.mjs +82 -20
- package/rightkit-ownership.json +1 -1
- package/rightkit-sync.mjs +8 -0
- package/rightkit-versions.json +428 -194
- package/runtime-budget-crates.mjs +14 -0
- package/runtime-budgets.mjs +248 -47
- package/sign-updater.mjs +56 -59
- package/source-gate.mjs +8 -1
- package/stage-sidecar.mjs +207 -40
- package/swap-helper-bundle.mjs +7 -3
- package/target-distribution.mjs +5 -0
- package/upload-release.mjs +7 -2
- package/bin/darwin/rightkit-swap.sign.json +0 -6
|
@@ -7,7 +7,8 @@ import { isManagedHost, refuseManagedEnvSecrets } from "./managed-host-secrets.m
|
|
|
7
7
|
// Protected credential loaders for release sites that previously read only
|
|
8
8
|
// ambient environment variables. The storage pattern matches release-token.mjs:
|
|
9
9
|
// macOS Keychain generic passwords (account $USER) and, on Windows, a protected
|
|
10
|
-
// token file under %APPDATA%\RightKit
|
|
10
|
+
// token file under %APPDATA%\RightKit for legacy non-updater sites. Updater
|
|
11
|
+
// signing uses M11's native OS-store child-exec boundary. Other values return to the caller only;
|
|
11
12
|
// they are never logged, and diagnostics name the secret, never its value.
|
|
12
13
|
export const PROTECTED_SECRET_MISSING = "PROTECTED_SECRET_MISSING";
|
|
13
14
|
|
|
@@ -16,6 +17,7 @@ export const PROTECTED_SECRET_MISSING = "PROTECTED_SECRET_MISSING";
|
|
|
16
17
|
export const PROTECTED_SECRETS = Object.freeze({
|
|
17
18
|
"cloudflare-api-token": { label: "Cloudflare API token", keychain: "rightkit-cloudflare-api-token", file: "cloudflare-api-token.token" },
|
|
18
19
|
"qa-receipt-key": { label: "RightKit QA receipt key", keychain: null, file: "qa-receipt-key.token", darwinFile: true },
|
|
20
|
+
"updater-signing-key": { label: "RightKit updater signing key", keychain: "rightsuite-updater-key", providerExec: true },
|
|
19
21
|
});
|
|
20
22
|
|
|
21
23
|
export class ProtectedSecretMissingError extends Error {
|
|
@@ -39,6 +41,7 @@ export function protectedSecretStoreCommand(name, platform = process.platform) {
|
|
|
39
41
|
if (platform === "darwin" && spec.darwinFile) return null;
|
|
40
42
|
if (platform === "darwin") return `security add-generic-password -a "$USER" -s ${spec.keychain} -U -w`;
|
|
41
43
|
if (platform === "win32") {
|
|
44
|
+
if (spec.providerExec) return `$rightkitSecure = Read-Host -AsSecureString '${spec.label}'; $rightkitPointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($rightkitSecure); try { [Runtime.InteropServices.Marshal]::PtrToStringBSTR($rightkitPointer) | & rightkit-credentials.exe set --namespace '${spec.keychain}' --account ([Environment]::UserName) --replace } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($rightkitPointer) }`;
|
|
42
45
|
return `powershell -NoProfile -Command "$s = Read-Host -AsSecureString '${spec.label}'; $d = Join-Path $env:APPDATA 'RightKit'; New-Item -ItemType Directory -Force -Path $d | Out-Null; [IO.File]::WriteAllText((Join-Path $d '${spec.file}'), [Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s)))"`;
|
|
43
46
|
}
|
|
44
47
|
return null;
|
|
@@ -78,6 +81,9 @@ export function createCredentialStore({
|
|
|
78
81
|
read(name) {
|
|
79
82
|
if (!PROTECTED_SECRETS[name]) throw new Error(`unknown protected secret: ${name}`);
|
|
80
83
|
const spec = PROTECTED_SECRETS[name];
|
|
84
|
+
// Signing keys stay inside the native provider's child-exec boundary.
|
|
85
|
+
// No JavaScript/file/environment fallback can export this credential.
|
|
86
|
+
if (spec.providerExec) throw new ProtectedSecretMissingError(name, protectedSecretStoreCommand(name, platform));
|
|
81
87
|
let value;
|
|
82
88
|
if (platform === "darwin") {
|
|
83
89
|
// Receipt MAC keys are provisioned by right-release itself as a 0600 file, never from the Keychain.
|
|
@@ -98,6 +104,7 @@ export function createCredentialStore({
|
|
|
98
104
|
// when the store has no value. A missing store entry on a managed host never
|
|
99
105
|
// permits the environment fallback.
|
|
100
106
|
export function resolveEnvBackedSecret({ name, envName, env = process.env, credentialStore, required = true }) {
|
|
107
|
+
if (PROTECTED_SECRETS[name]?.providerExec) throw new Error("updater signing requires the OS credential provider execution boundary");
|
|
101
108
|
refuseManagedEnvSecrets(env, [envName]);
|
|
102
109
|
if (!required) return undefined;
|
|
103
110
|
const store = credentialStore ?? createCredentialStore({ env });
|
|
@@ -111,6 +118,26 @@ export function resolveEnvBackedSecret({ name, envName, env = process.env, crede
|
|
|
111
118
|
}
|
|
112
119
|
}
|
|
113
120
|
|
|
121
|
+
// M11 owns OS acquisition & secret lifetime. The release adapter supplies only
|
|
122
|
+
// non-secret identity plus an absolute native child executable and literal argv.
|
|
123
|
+
export function execWithUpdaterCredential(command, args, {
|
|
124
|
+
env = process.env, cwd = process.cwd(), run = spawnSync,
|
|
125
|
+
provider = process.platform === "win32" ? "rightkit-credentials.exe" : "rightkit-credentials",
|
|
126
|
+
account = os.userInfo().username,
|
|
127
|
+
} = {}) {
|
|
128
|
+
for (const key of Object.keys(env)) {
|
|
129
|
+
if (["TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD"].includes(key.toUpperCase()) && env[key]) {
|
|
130
|
+
throw new Error("updater signing key/password must come from the OS credential provider");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!path.isAbsolute(command)) throw new Error("credential provider child must be an absolute native executable");
|
|
134
|
+
const result = run(provider, ["exec", "--namespace", PROTECTED_SECRETS["updater-signing-key"].keychain,
|
|
135
|
+
"--account", account, "--env", "TAURI_SIGNING_PRIVATE_KEY", "--", command, ...args],
|
|
136
|
+
{ cwd, env: { ...env, TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" }, stdio: "inherit", shell: false, windowsHide: true });
|
|
137
|
+
if (result.error?.code === "ENOENT") throw new Error("OS credential provider unavailable: install rightkit-credentials >=0.2.2 on PATH before updater signing");
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
114
141
|
export function resolveCloudflareApiToken({ env = process.env, credentialStore, required = true } = {}) {
|
|
115
142
|
return resolveEnvBackedSecret({ name: "cloudflare-api-token", envName: "CLOUDFLARE_API_TOKEN", env, credentialStore, required });
|
|
116
143
|
}
|
|
@@ -41,7 +41,9 @@ export function assertArtifactUnchanged(artifact) {
|
|
|
41
41
|
return artifact.bytes;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
// `streamRoles`: sealed entries of these roles are hash-verified by a streamed read and returned WITHOUT their bytes (the caller reads
|
|
45
|
+
// byte ranges of `file`), so a large IPA is never held in memory.
|
|
46
|
+
export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit, app, version, platform, configPath, releaseId, streamRoles = [], ownershipScope = null }, deps = {}) {
|
|
45
47
|
let sealed;
|
|
46
48
|
try { sealed = verifySealedRelease(sealedDir); }
|
|
47
49
|
catch (error) {
|
|
@@ -52,7 +54,7 @@ export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit,
|
|
|
52
54
|
if (!/^[a-f0-9]{40}$/.test(commit ?? "") || manifest.commit !== commit
|
|
53
55
|
|| (app && manifest.app !== app) || (version && manifest.version !== version)
|
|
54
56
|
|| manifest.platform !== platform || (releaseId && manifest.releaseId !== releaseId)) throw new PublicationError("PUBLICATION_SOURCE_MISMATCH", "sealed release source or product identity mismatch");
|
|
55
|
-
const ownership = (deps.verifyOwnership ?? verifyOwnershipEvidence)(manifest.ownershipEvidence, repoRoot, appRoot);
|
|
57
|
+
const ownership = (deps.verifyOwnership ?? verifyOwnershipEvidence)(manifest.ownershipEvidence, repoRoot, appRoot, ownershipScope);
|
|
56
58
|
if (!ownership.ok || manifest.ownershipEvidence?.commit !== commit) throw new PublicationError("PUBLICATION_OWNERSHIP_INVALID", `sealed ownership evidence rejected: ${ownership.reason ?? "commit mismatch"}`);
|
|
57
59
|
if (!manifest.checkpoints?.includes("signed") || !manifest.checkpoints?.includes("hardened")) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "signed & hardened seal checkpoints are required");
|
|
58
60
|
if (configPath && digestBytes(readFileSync(configPath)) !== manifest.signing?.configSha256) throw new PublicationError("PUBLICATION_POLICY_MISMATCH", "release config differs from sealed signing policy");
|
|
@@ -65,14 +67,16 @@ export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit,
|
|
|
65
67
|
let notarized = false;
|
|
66
68
|
let signed = false;
|
|
67
69
|
let androidVerified = false;
|
|
70
|
+
let iosVerified = false;
|
|
68
71
|
for (const receipt of receipts) {
|
|
69
72
|
const artifact = readVerifiedArtifact(path.resolve(appRoot, receipt.file), receipt);
|
|
70
73
|
let value;
|
|
71
74
|
try { value = JSON.parse(artifact.bytes); } catch { throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", "invalid sealed signing receipt"); }
|
|
72
75
|
if (value.schema !== 1) throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", "malformed sealed signing receipt");
|
|
73
76
|
if (value.contract !== undefined) {
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
if (value.contract === IOS_RECEIPT_CONTRACT) { verifyIosPublicationReceipt(value, manifest, platform); iosVerified = true; }
|
|
78
|
+
else { verifyAndroidPublicationReceipt(value, manifest, platform); androidVerified = true; }
|
|
79
|
+
signed = true;
|
|
76
80
|
continue;
|
|
77
81
|
}
|
|
78
82
|
if (Array.isArray(value.files)) {
|
|
@@ -93,10 +97,11 @@ export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit,
|
|
|
93
97
|
if (!signed) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed native signing evidence is required");
|
|
94
98
|
if (platform === "mac" && !notarized) throw new PublicationError("PUBLICATION_NOTARIZATION_MISSING", "sealed notarization evidence is required");
|
|
95
99
|
if (platform === "android" && !androidVerified) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed android AAB upload-key receipt is required");
|
|
100
|
+
if (platform === "ios" && !iosVerified) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed iOS IPA build receipt is required");
|
|
96
101
|
const artifacts = new Map();
|
|
97
102
|
for (const entry of manifest.files ?? []) {
|
|
98
103
|
if (typeof entry.name !== "string" || path.basename(entry.name) !== entry.name || artifacts.has(entry.name)) throw new PublicationError("PUBLICATION_SEAL_INVALID", "unsafe or duplicate sealed artifact name");
|
|
99
|
-
artifacts.set(entry.name, readVerifiedArtifact(path.join(sealedDir, entry.name), entry));
|
|
104
|
+
artifacts.set(entry.name, streamRoles.includes(entry.role) ? verifyArtifactFile(path.join(sealedDir, entry.name), entry) : readVerifiedArtifact(path.join(sealedDir, entry.name), entry));
|
|
100
105
|
}
|
|
101
106
|
if (!artifacts.size) throw new PublicationError("PUBLICATION_SEAL_INVALID", "sealed release has no artifacts");
|
|
102
107
|
return { ...sealed, artifacts };
|
|
@@ -124,6 +129,26 @@ function verifyAndroidPublicationReceipt(value, manifest, platform) {
|
|
|
124
129
|
if (value.file.name !== aab.name) invalid("android AAB receipt names a different file than the sealed aab entry");
|
|
125
130
|
}
|
|
126
131
|
|
|
132
|
+
// iOS IPA receipt (ios-package-recipe.mjs: contract "ios-xcodebuild-archive-automatic-v1"). The receipt was already read from the
|
|
133
|
+
// manifest's signing.receipts and hash-verified by the caller; here it must describe exactly the sealed "ipa" file, for the team
|
|
134
|
+
// and bundle id pinned in the sealed signing policy. Fails closed with a specific message on any mismatch.
|
|
135
|
+
const IOS_RECEIPT_CONTRACT = "ios-xcodebuild-archive-automatic-v1";
|
|
136
|
+
function verifyIosPublicationReceipt(value, manifest, platform) {
|
|
137
|
+
const invalid = (message) => { throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", message); };
|
|
138
|
+
if (platform !== "ios" || manifest.platform !== "ios") invalid("iOS IPA receipt in a non-iOS release");
|
|
139
|
+
const pinnedTeam = manifest.signing?.teamId;
|
|
140
|
+
if (!/^[A-Z0-9]{10}$/.test(pinnedTeam ?? "")) invalid("sealed signing policy has no pinned iOS team id");
|
|
141
|
+
if (value.teamId !== pinnedTeam) invalid("iOS IPA receipt team id is not the pinned team");
|
|
142
|
+
if (!manifest.signing?.bundleId || value.bundleId !== manifest.signing.bundleId) invalid("iOS IPA receipt bundle id is not the pinned bundle id");
|
|
143
|
+
if (!value.marketingVersion || !value.buildNumber) invalid("iOS IPA receipt has no version identity");
|
|
144
|
+
const ipas = (manifest.files ?? []).filter((entry) => entry.role === "ipa");
|
|
145
|
+
if (ipas.length !== 1) invalid(ipas.length ? "sealed release has more than one ipa entry" : "sealed release has no ipa entry");
|
|
146
|
+
const [ipa] = ipas;
|
|
147
|
+
if (!/^[a-f0-9]{64}$/.test(value.file?.sha256 ?? "") || value.file.sha256 !== ipa.sha256) invalid("iOS IPA receipt sha256 differs from the sealed ipa entry");
|
|
148
|
+
if (!Number.isInteger(value.file.sizeBytes) || value.file.sizeBytes !== ipa.sizeBytes) invalid("iOS IPA receipt size differs from the sealed ipa entry");
|
|
149
|
+
if (value.file.name !== ipa.name) invalid("iOS IPA receipt names a different file than the sealed ipa entry");
|
|
150
|
+
}
|
|
151
|
+
|
|
127
152
|
// Bounded memory for large artifacts. Transport must read back against this digest.
|
|
128
153
|
export function verifyArtifactFile(file, evidence) {
|
|
129
154
|
if (!/^[a-f0-9]{64}$/.test(evidence?.sha256 ?? "")) throw new PublicationError("PUBLICATION_SEAL_INVALID", "publication hash evidence is required");
|
package/publication-gate.mjs
CHANGED
|
@@ -8,11 +8,12 @@ import fs from "node:fs";
|
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { spawnSync } from "./background-process.mjs";
|
|
11
|
-
import { recordPublished } from "./rightkit-sync.mjs";
|
|
11
|
+
import { recordPublished, workspaceFeedOptions } from "./rightkit-sync.mjs";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { digestBytes, readVerifiedArtifact } from "./publication-artifacts.mjs";
|
|
14
14
|
|
|
15
15
|
export const GATE_SCHEMA = 1;
|
|
16
|
+
export const MAX_CARGO_JOBS = 64;
|
|
16
17
|
export const REQUIRED_HOSTS = Object.freeze(["darwin", "win32"]);
|
|
17
18
|
// Swift packages (RightKitSwift) are Apple-only; Windows cannot run them.
|
|
18
19
|
export const requiredHosts = (kind) => (kind === "swift" ? Object.freeze(["darwin"]) : REQUIRED_HOSTS);
|
|
@@ -20,6 +21,11 @@ const SHA = /^[0-9a-f]{40}$/;
|
|
|
20
21
|
// The one definition of a recorded darwin-only host policy (release evidence and the runtime budget gate share it).
|
|
21
22
|
export const isDarwinOnlyPolicy = (source) => source?.hostPolicy === "darwin-only"
|
|
22
23
|
&& typeof source.hostPolicyReason === "string" && source.hostPolicyReason.trim() !== "";
|
|
24
|
+
// The symmetric recorded win32-only host policy: a crate published from the Windows host under the local-build grant.
|
|
25
|
+
export const isWin32OnlyPolicy = (source) => source?.hostPolicy === "win32-only"
|
|
26
|
+
&& typeof source.hostPolicyReason === "string" && source.hostPolicyReason.trim() !== "";
|
|
27
|
+
/** The one host a recorded single-host policy names ("darwin" or "win32"), or null when none is recorded. */
|
|
28
|
+
export const singleHostOfPolicy = (source) => (isDarwinOnlyPolicy(source) ? "darwin" : isWin32OnlyPolicy(source) ? "win32" : null);
|
|
23
29
|
|
|
24
30
|
export function defaultEvidencePath(workspaceRoot, kind, name) {
|
|
25
31
|
return path.join(workspaceRoot, "release-evidence", "gate", `${kind}-${name.replace(/^@/, "").replace("/", "-")}.json`);
|
|
@@ -87,17 +93,40 @@ export function batchReceiptProblems(batch, evidence, tree) {
|
|
|
87
93
|
return problems;
|
|
88
94
|
}
|
|
89
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Feature sets of this crate that a batch receipt did NOT lint (0.4.1 review A2). Batch mode lints each crate's tested
|
|
98
|
+
* feature sets in shared `-p a -p b` runs, so a receipt from before every set was linted (0.4.0: first set only)
|
|
99
|
+
* still satisfies `batchReceiptProblems`, which accepts any ONE clippy row covering the crate. Replay therefore
|
|
100
|
+
* needs one passing row per feature set: bound to the evidence commit and the batch tree, naming the crate (`-p <crate>`
|
|
101
|
+
* and `crates`), and ending `--no-deps --all-targets <features> -- -D warnings`. `featureSets` is the crate's
|
|
102
|
+
* `ci/gate.config.json` list (`[[]]` when unset). Returns the missing sets as argument arrays.
|
|
103
|
+
*/
|
|
104
|
+
export function batchClippyMissingFeatureSets(batch, evidence, featureSets) {
|
|
105
|
+
const rows = (Array.isArray(batch?.checks) ? batch.checks : []).filter((row) => row?.name === "clippy" && row.ok === true
|
|
106
|
+
&& row.commit === evidence.commit && row.tree === batch.tree && Array.isArray(row.crates) && row.crates.includes(evidence.name) && text(row.command));
|
|
107
|
+
const sets = (Array.isArray(featureSets) && featureSets.length ? featureSets : [[]]).map((set) => (Array.isArray(set) ? set : []));
|
|
108
|
+
return sets.filter((features) => {
|
|
109
|
+
const tail = ` --no-deps --all-targets${features.length ? ` ${features.join(" ")}` : ""} -- -D warnings`;
|
|
110
|
+
return !rows.some((row) => {
|
|
111
|
+
const words = row.command.split(" ");
|
|
112
|
+
return row.command.endsWith(tail) && words.some((word, index) => word === "-p" && words[index + 1] === evidence.name);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
90
117
|
/**
|
|
91
118
|
* Which per-crate publication steps the evidence ALREADY proves for this exact commit, so the publisher may
|
|
92
119
|
* skip repeating them: `tests` (a passing `cargo test -p <name>` step that executed tests), `clippy` (a passing
|
|
93
120
|
* `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
|
|
121
|
+
* `secretScan` (a valid batch receipt covering the crate). Every run on the proving host (darwin, or win32 under a win32-only policy) at the evidence commit must prove a
|
|
95
122
|
* step for it to count. Call after the gate accepted the evidence (the gate binds commit, tree and artifact).
|
|
96
123
|
*/
|
|
97
124
|
export function provenGateSteps(evidence) {
|
|
98
125
|
const none = { tests: false, clippy: false, fmt: false, secretScan: false };
|
|
99
126
|
if (!evidence || !SHA.test(String(evidence.commit ?? "")) || !text(evidence.name)) return none;
|
|
100
|
-
|
|
127
|
+
// The proving host is the one a recorded win32-only policy names; every other evidence is proved on darwin.
|
|
128
|
+
const proofHost = isWin32OnlyPolicy(evidence) ? "win32" : "darwin";
|
|
129
|
+
const runs = (Array.isArray(evidence.runs) ? evidence.runs : []).filter((run) => run?.host?.os === proofHost);
|
|
101
130
|
if (!runs.length || runs.some((run) => run.commit !== evidence.commit)) return none;
|
|
102
131
|
const name = escapeRegExp(evidence.name);
|
|
103
132
|
const stepProves = (run, pattern, needTests) => (Array.isArray(run.steps) ? run.steps : []).some((step) => step?.ok === true
|
|
@@ -124,42 +153,49 @@ function invalidRunFields(run, evidence, tree) {
|
|
|
124
153
|
if (!text(run.timestamp)) bad.push("timestamp");
|
|
125
154
|
if (!(Number.isInteger(run.passed) && run.passed > 0)) bad.push("passed (must be > 0)");
|
|
126
155
|
if (run.failed !== 0) bad.push("failed (must be 0)");
|
|
156
|
+
if (run.jobs !== undefined && !(Number.isInteger(run.jobs) && run.jobs >= 1 && run.jobs <= MAX_CARGO_JOBS)) bad.push(`jobs (must be an integer from 1 to ${MAX_CARGO_JOBS})`);
|
|
127
157
|
if (run.batch !== undefined) bad.push(...batchReceiptProblems(run.batch, evidence, tree ?? null).map((problem) => `batch: ${problem}`));
|
|
128
158
|
return bad;
|
|
129
159
|
}
|
|
130
160
|
|
|
131
|
-
|
|
132
|
-
|
|
161
|
+
function gateResult(missing, evidence) {
|
|
162
|
+
const result = { ok: missing.length === 0, missing };
|
|
163
|
+
Object.defineProperty(result, "evidence", { value: evidence });
|
|
164
|
+
return Object.freeze(result);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Validate supplied evidence against caller-authenticated source identity, without reading Git or files.
|
|
168
|
+
* The caller owns authentication of expected.commit/tree and requiredHosts (for example, committed CI policy).
|
|
169
|
+
* Evidence cannot relax host requirements: only an explicit, nonempty subset supplied by the caller can.
|
|
170
|
+
* This proves receipt contents only; it makes no claim about working-tree cleanliness or receipt provenance. */
|
|
171
|
+
export function validatePublicationEvidence({ expected = {}, evidence, artifactBytes }) {
|
|
172
|
+
const { kind, name, version, commit, tree } = expected;
|
|
133
173
|
const missing = [];
|
|
134
|
-
|
|
135
|
-
if (!
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
174
|
+
if (!["npm", "cargo", "swift"].includes(kind)) missing.push("expected kind must be npm, cargo or swift");
|
|
175
|
+
if (!text(name)) missing.push("expected name must be nonempty");
|
|
176
|
+
if (!SHA.test(String(commit ?? ""))) missing.push("expected commit is not a full 40-character SHA");
|
|
177
|
+
const defaults = requiredHosts(kind);
|
|
178
|
+
let hosts = defaults;
|
|
179
|
+
if (expected.requiredHosts !== undefined) {
|
|
180
|
+
const requested = expected.requiredHosts;
|
|
181
|
+
if (!(Array.isArray(requested) && requested.length > 0 && new Set(requested).size === requested.length
|
|
182
|
+
&& Array.from(requested).every((host) => defaults.includes(host)))) {
|
|
183
|
+
missing.push(`expected requiredHosts must be a nonempty unique subset of ${defaults.join(", ")}`);
|
|
184
|
+
} else hosts = requested;
|
|
142
185
|
}
|
|
143
|
-
if (evidence) {
|
|
186
|
+
if (!evidence || typeof evidence !== "object" || Array.isArray(evidence)) {
|
|
187
|
+
missing.push("gate evidence must be an object");
|
|
188
|
+
} else {
|
|
144
189
|
if (evidence.schema !== GATE_SCHEMA) missing.push(`evidence schema must be ${GATE_SCHEMA}`);
|
|
145
190
|
if (evidence.kind !== kind || evidence.name !== name) missing.push(`evidence is for ${evidence.kind}:${evidence.name}, expected ${kind}:${name}`);
|
|
146
191
|
// A missing publishing version must refuse: a skipped comparison would qualify any evidence version.
|
|
147
192
|
if (typeof version !== "string" || version.trim() === "") missing.push("publishing version is unknown (manifest has no literal version), cannot bind the receipt to it");
|
|
148
193
|
else if (evidence.version !== version) missing.push(`evidence version ${evidence.version} != ${version}`);
|
|
149
194
|
if (!SHA.test(String(evidence.commit ?? ""))) missing.push("evidence commit is not a full 40-character SHA");
|
|
150
|
-
else if (
|
|
151
|
-
missing.push(`evidence commit ${evidence.commit} != current HEAD ${head} (${describeCommitMismatch(evidence.commit, head, packagePath, git)})`);
|
|
152
|
-
}
|
|
195
|
+
else if (SHA.test(String(commit ?? "")) && evidence.commit !== commit) missing.push(`evidence commit ${evidence.commit} != expected commit ${commit}`);
|
|
153
196
|
const runs = Array.isArray(evidence.runs) ? evidence.runs : [];
|
|
154
|
-
|
|
155
|
-
|
|
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}`);
|
|
158
|
-
// npm packages and crates may ship on macOS evidence first when Adrian decides so; the decision must be
|
|
159
|
-
// recorded in the evidence (hostPolicy "darwin-only" + a non-empty hostPolicyReason).
|
|
160
|
-
// Crates may ship Mac-first too (Adrian 2026-10-07): the Windows run follows; the reason is recorded.
|
|
161
|
-
const darwinOnly = (kind === "npm" || kind === "cargo") && isDarwinOnlyPolicy(evidence);
|
|
162
|
-
for (const host of darwinOnly ? ["darwin"] : requiredHosts(kind)) {
|
|
197
|
+
if (runs.some((run) => run?.batch !== undefined) && !SHA.test(String(tree ?? ""))) missing.push("expected tree is not a full 40-character SHA for batch evidence");
|
|
198
|
+
for (const host of hosts) {
|
|
163
199
|
const label = host === "darwin" ? "macOS" : "Windows";
|
|
164
200
|
const matching = runs.filter((run) => run?.host?.os === host);
|
|
165
201
|
if (!matching.length) { missing.push(`no ${label} host run receipt`); continue; }
|
|
@@ -172,9 +208,34 @@ export function checkPublicationGate({ kind, name, version, packagePath, evidenc
|
|
|
172
208
|
if (!/^[0-9a-f]{64}$/.test(String(evidence.artifactSha256 ?? ""))) missing.push("evidence artifactSha256 must be a 64-hex hash");
|
|
173
209
|
}
|
|
174
210
|
if (artifactBytes !== undefined && evidence && digestBytes(artifactBytes) !== evidence.artifactSha256) missing.push("artifact sha256 does not match evidence artifactSha256");
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
211
|
+
return gateResult(missing, evidence);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Acquire local source identity and cleanliness, then use the same content validator as CI.
|
|
215
|
+
export function checkPublicationGate({ kind, name, version, packagePath, evidencePath, artifactBytes, git = defaultGit, readFile = (p) => fs.readFileSync(p, "utf8") }) {
|
|
216
|
+
const missing = [];
|
|
217
|
+
const head = git(["rev-parse", "HEAD"], packagePath);
|
|
218
|
+
if (!head || !SHA.test(head)) missing.push(`cannot read current commit SHA for ${packagePath}`);
|
|
219
|
+
const dirty = git(["status", "--porcelain", "--", "."], packagePath);
|
|
220
|
+
if (dirty === null) missing.push(`cannot read git status for ${packagePath}`);
|
|
221
|
+
else if (dirty !== "") missing.push(`tree is not clean for ${packagePath}`);
|
|
222
|
+
let evidence = null;
|
|
223
|
+
try { evidence = JSON.parse(readFile(evidencePath)); } catch {
|
|
224
|
+
missing.push(`gate evidence file missing or unreadable: ${evidencePath}`);
|
|
225
|
+
}
|
|
226
|
+
const runs = Array.isArray(evidence?.runs) ? evidence.runs : [];
|
|
227
|
+
const tree = runs.some((run) => run?.batch !== undefined) ? git(["rev-parse", "HEAD^{tree}"], packagePath) : undefined;
|
|
228
|
+
if (tree === null) missing.push(`cannot read the HEAD tree for ${packagePath}`);
|
|
229
|
+
// Local publication retains its recorded single-host grant. CI callers own their policy separately.
|
|
230
|
+
const singleHost = (kind === "npm" || kind === "cargo") ? singleHostOfPolicy(evidence) : null;
|
|
231
|
+
const result = validatePublicationEvidence({ expected: { kind, name, version, commit: head, tree,
|
|
232
|
+
...(singleHost ? { requiredHosts: [singleHost] } : {}) }, evidence, artifactBytes });
|
|
233
|
+
for (const problem of result.missing) {
|
|
234
|
+
if (problem === `evidence commit ${evidence?.commit} != expected commit ${head}`) {
|
|
235
|
+
missing.push(`evidence commit ${evidence.commit} != current HEAD ${head} (${describeCommitMismatch(evidence.commit, head, packagePath, git)})`);
|
|
236
|
+
} else missing.push(problem);
|
|
237
|
+
}
|
|
238
|
+
return gateResult(missing, evidence);
|
|
178
239
|
}
|
|
179
240
|
|
|
180
241
|
// Enforce: throws on missing evidence unless dryRun (then reports and continues).
|
|
@@ -242,10 +303,10 @@ export function runNpmPreflight(argv, workspaceRoot = process.cwd(), deps = {})
|
|
|
242
303
|
if (dryRun) throw new Error("--publish and --dry-run are mutually exclusive");
|
|
243
304
|
if (!tarball) throw new Error("--publish requires --tarball <gated .tgz>");
|
|
244
305
|
enforcePublicationGate(options);
|
|
245
|
-
publishGatedTarball({ name: pkg, version: manifest.version, tarball, evidencePath }, deps);
|
|
306
|
+
publishGatedTarball({ name: pkg, version: manifest.version, tarball, evidencePath, workspaceRoot }, deps);
|
|
246
307
|
}
|
|
247
308
|
|
|
248
|
-
export function publishGatedTarball({ name, version, tarball, evidencePath }, deps = {}) {
|
|
309
|
+
export function publishGatedTarball({ name, version, tarball, evidencePath, workspaceRoot }, deps = {}) {
|
|
249
310
|
const run = deps.spawnSync ?? spawnSync;
|
|
250
311
|
const bytes = fs.readFileSync(tarball);
|
|
251
312
|
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
|
|
@@ -267,7 +328,8 @@ export function publishGatedTarball({ name, version, tarball, evidencePath }, de
|
|
|
267
328
|
}
|
|
268
329
|
const sha1 = crypto.createHash("sha1").update(bytes).digest("hex");
|
|
269
330
|
verifyNpmRegistryBytes({ name, version, sha1 }, deps);
|
|
270
|
-
|
|
331
|
+
// Feed and consumer copies belong to the requested workspace, never to this module's own (possibly installed) location.
|
|
332
|
+
(deps.recordPublished ?? recordPublished)({ registry: "npm", name, version }, workspaceRoot ? workspaceFeedOptions(workspaceRoot) : undefined);
|
|
271
333
|
}
|
|
272
334
|
|
|
273
335
|
// CI and the authenticated helper share mandatory, fail-closed registry equality.
|
package/publish-cargo.mjs
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
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, provenGateSteps } from "./publication-gate.mjs";
|
|
5
|
+
import { enforcePublicationGate, defaultEvidencePath, provenGateSteps, singleHostOfPolicy, batchClippyMissingFeatureSets } from "./publication-gate.mjs";
|
|
6
6
|
import { runCargoSync } from "./managed-cargo.mjs";
|
|
7
|
+
import { spawnSync } from "./background-process.mjs";
|
|
7
8
|
import { readVerifiedArtifact, assertArtifactUnchanged } from "./publication-artifacts.mjs";
|
|
8
9
|
import { verifyCargoRegistry } from "./cargo-registry-readback.mjs";
|
|
9
|
-
import { recordPublished } from "./rightkit-sync.mjs";
|
|
10
|
+
import { recordPublished, workspaceFeedOptions } from "./rightkit-sync.mjs";
|
|
10
11
|
import { readCargoPackage } from "./crate-graph.mjs";
|
|
11
12
|
import { runtimeBudgetCheck, RUNTIME_BUDGET_CODE } from "./runtime-budgets.mjs";
|
|
12
13
|
import { formatPreflight, PREFLIGHT_FAIL } from "./preflight.mjs";
|
|
@@ -23,7 +24,7 @@ export function parseCargoPublishArgs(argv, cwd = process.cwd()) {
|
|
|
23
24
|
const crate = valueAfter(argv, "--crate");
|
|
24
25
|
if (!crate) throw new Error("publish cargo requires --crate <name>");
|
|
25
26
|
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(crate)) throw new Error(`invalid crate name: ${crate}`);
|
|
26
|
-
const allowed = new Set(["--crate", "--workspace", "--dry-run", "--allow-dirty", "--evidence"]);
|
|
27
|
+
const allowed = new Set(["--crate", "--workspace", "--dry-run", "--allow-dirty", "--evidence", "--from-evidence"]);
|
|
27
28
|
for (let index = 0; index < argv.length; index += 1) {
|
|
28
29
|
const arg = argv[index];
|
|
29
30
|
if (!arg.startsWith("--")) continue;
|
|
@@ -33,12 +34,45 @@ export function parseCargoPublishArgs(argv, cwd = process.cwd()) {
|
|
|
33
34
|
const dryRun = argv.includes("--dry-run");
|
|
34
35
|
const allowDirty = argv.includes("--allow-dirty");
|
|
35
36
|
if (allowDirty && !dryRun) throw new Error("--allow-dirty is only valid with --dry-run");
|
|
37
|
+
const fromEvidence = valueAfter(argv, "--from-evidence");
|
|
38
|
+
if (argv.includes("--from-evidence") && (!fromEvidence || fromEvidence.startsWith("--"))) throw new Error("--from-evidence requires a gate evidence file");
|
|
39
|
+
if (fromEvidence && (dryRun || allowDirty || argv.includes("--evidence"))) throw new Error("--from-evidence cannot be combined with --dry-run, --allow-dirty or --evidence");
|
|
36
40
|
return {
|
|
37
41
|
crate,
|
|
38
42
|
dryRun,
|
|
39
43
|
allowDirty,
|
|
40
44
|
workspaceRoot: path.resolve(valueAfter(argv, "--workspace") ?? cwd),
|
|
41
|
-
evidence: valueAfter(argv, "--evidence"),
|
|
45
|
+
evidence: fromEvidence ?? valueAfter(argv, "--evidence"),
|
|
46
|
+
...(fromEvidence ? { fromEvidence: true } : {}),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const OFFLINE_CACHE_MISS = /(?:attempting to make an HTTP request, but --offline was specified|as a reminder, you're using offline mode(?: \(--offline\))?)/i;
|
|
51
|
+
const OFFLINE_READ_ONLY_COMMANDS = new Set(["metadata", "package"]);
|
|
52
|
+
|
|
53
|
+
/** Retry only a Cargo offline cache miss for metadata/package inspection, once with normal network access. */
|
|
54
|
+
export async function runCargoReadOnly(args, settings, execute, onOfflineFailure = () => {}) {
|
|
55
|
+
const subcommand = args[0];
|
|
56
|
+
if (!OFFLINE_READ_ONLY_COMMANDS.has(subcommand) || !args.includes("--offline")) {
|
|
57
|
+
return execute("cargo", args, settings);
|
|
58
|
+
}
|
|
59
|
+
const first = await execute("cargo", args, settings);
|
|
60
|
+
if (!first.error && first.status === 0) return first;
|
|
61
|
+
const diagnostic = `${first.stdout ?? ""}\n${first.stderr ?? ""}`;
|
|
62
|
+
if (!OFFLINE_CACHE_MISS.test(diagnostic)) return first;
|
|
63
|
+
onOfflineFailure(first);
|
|
64
|
+
const retryArgs = args.filter((arg) => arg !== "--offline");
|
|
65
|
+
const retry = await execute("cargo", retryArgs, settings);
|
|
66
|
+
if (!retry.error && retry.status === 0) return { ...retry, usedOfflineFallback: true };
|
|
67
|
+
const labeled = (result) => String(result ?? "");
|
|
68
|
+
const stdout = `[offline attempt]\n${labeled(first.stdout)}\n[online retry]\n${labeled(retry.stdout)}`;
|
|
69
|
+
const stderr = `[offline attempt]\n${labeled(first.stderr)}\n[online retry]\n${labeled(retry.stderr)}`;
|
|
70
|
+
return {
|
|
71
|
+
...retry,
|
|
72
|
+
stdout,
|
|
73
|
+
stderr,
|
|
74
|
+
...(retry.error ? { error: new Error(`${retry.error.message}\n${stdout}\n${stderr}`) } : {}),
|
|
75
|
+
offlineFallbackFailed: true,
|
|
42
76
|
};
|
|
43
77
|
}
|
|
44
78
|
|
|
@@ -61,11 +95,13 @@ const PROVEN_STEP = Object.freeze({ "secret scan": "secretScan", format: "fmt",
|
|
|
61
95
|
* already proves for this exact commit; the runtime budget, package contents, archive hash and registry steps
|
|
62
96
|
* are never skippable. `verifyOnPublish` (real publish only) drops the separate registry dry-run and lets the
|
|
63
97
|
* 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.
|
|
98
|
+
* the upload, instead of in a dry-run and not at all in the publish. `packageVerified` is set only after
|
|
99
|
+
* explicit evidence reuse proves a successful standalone package verification; hash checking stays mandatory.
|
|
65
100
|
*/
|
|
66
|
-
export function buildCargoPublishPlan({ crate, crateDir, dryRun, allowDirty = false, proven = {}, verifyOnPublish = false }) {
|
|
101
|
+
export function buildCargoPublishPlan({ crate, crateDir, dryRun, allowDirty = false, proven = {}, verifyOnPublish = false, packageVerified = false, offlineReadOnly = false }) {
|
|
102
|
+
const offline = offlineReadOnly ? ["--offline"] : [];
|
|
67
103
|
const dryRunArgs = ["publish", "--dry-run", "-p", crate];
|
|
68
|
-
const packageArgs = ["package", "--list", "-p", crate];
|
|
104
|
+
const packageArgs = ["package", "--list", ...offline, "-p", crate];
|
|
69
105
|
if (allowDirty) dryRunArgs.push("--allow-dirty");
|
|
70
106
|
if (allowDirty) packageArgs.push("--allow-dirty");
|
|
71
107
|
const verifyHere = verifyOnPublish && !dryRun;
|
|
@@ -78,15 +114,18 @@ export function buildCargoPublishPlan({ crate, crateDir, dryRun, allowDirty = fa
|
|
|
78
114
|
{ label: "tests", command: "cargo", args: ["test", "-p", crate, "--", "--test-threads=1"] },
|
|
79
115
|
{ label: "clippy", command: "cargo", args: ["clippy", "-p", crate, "--all-targets", "--", "-D", "warnings"] },
|
|
80
116
|
{ label: "package contents", command: "cargo", args: packageArgs, capture: true },
|
|
81
|
-
...(verifyHere ? [] : [{ label: "registry dry-run", command: "cargo", args: dryRunArgs }]),
|
|
117
|
+
...((verifyHere || packageVerified) ? [] : [{ label: "registry dry-run", command: "cargo", args: dryRunArgs }]),
|
|
82
118
|
].filter((step) => !(PROVEN_STEP[step.label] && proven[PROVEN_STEP[step.label]] === true));
|
|
83
119
|
if (!dryRun) plan.push(
|
|
84
120
|
// `publish --dry-run` leaves its archive under target/package/tmp-crate on current Cargo; package
|
|
85
|
-
// explicitly to bind target/package/<name>-<version>.crate to evidence before Cargo uploads.
|
|
86
|
-
|
|
87
|
-
|
|
121
|
+
// explicitly to bind target/package/<name>-<version>.crate to evidence before Cargo uploads. Always
|
|
122
|
+
// online and --locked, like qualification: offline packaging resolves the archive's Cargo.lock from
|
|
123
|
+
// only the .crate files already downloaded, so a sibling published since then silently resolves to an
|
|
124
|
+
// older version and the archive no longer matches its evidence hash.
|
|
125
|
+
{ label: "package archive", command: "cargo", args: ["package", "--locked", "--no-verify", "-p", crate, ...(allowDirty ? ["--allow-dirty"] : [])] },
|
|
126
|
+
{ label: "package location", command: "cargo", args: ["metadata", ...offline, "--format-version", "1", "--no-deps"], capture: true },
|
|
88
127
|
{ label: "archive hash check", internal: "verify-archive" },
|
|
89
|
-
{ label: "registry publish", command: "cargo", args: verifyHere ? ["publish", "--locked", "-p", crate] : ["publish", "--no-verify", "-p", crate] },
|
|
128
|
+
{ label: "registry publish", command: "cargo", args: verifyHere ? ["publish", "--locked", "-p", crate] : ["publish", ...(packageVerified ? ["--locked"] : []), "--no-verify", "-p", crate] },
|
|
90
129
|
{ label: "registry readback", internal: "registry-readback" },
|
|
91
130
|
);
|
|
92
131
|
return plan;
|
|
@@ -108,7 +147,6 @@ export function scanCrateForSecrets(crateDir) {
|
|
|
108
147
|
continue;
|
|
109
148
|
}
|
|
110
149
|
const bytes = fs.readFileSync(file);
|
|
111
|
-
if (bytes.includes(0)) continue;
|
|
112
150
|
const text = bytes.toString("utf8");
|
|
113
151
|
if (SECRET_PATTERNS.some((pattern) => pattern.test(text))) violations.push(relative);
|
|
114
152
|
}
|
|
@@ -130,11 +168,48 @@ export async function runCargoPublish(options, execute = (_command, args, settin
|
|
|
130
168
|
};
|
|
131
169
|
const gate = (deps.enforceGate ?? enforcePublicationGate)(gateOptions, { dryRun: options.dryRun });
|
|
132
170
|
const expectedSha256 = gate.evidence?.artifactSha256;
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
171
|
+
// Explicit evidence reuse enters through the same gate: current commit, version, clean source, host runs
|
|
172
|
+
// and artifact digest must match. Windows evidence keeps its actual win32 host policy on the Mac.
|
|
173
|
+
const reuseEvidence = options.fromEvidence === true;
|
|
174
|
+
if (reuseEvidence && (options.dryRun || options.allowDirty || !options.evidence)) throw new Error("--from-evidence requires a real publish, clean source and explicit evidence");
|
|
175
|
+
const proven = (reuseEvidence || options.trustEvidenceSteps === true) && !options.dryRun ? provenGateSteps(gate.evidence) : {};
|
|
176
|
+
if (reuseEvidence && (proven.tests !== true || proven.clippy !== true)) throw new Error("--from-evidence requires gate-proven tests and clippy for this crate and commit");
|
|
177
|
+
if (reuseEvidence) {
|
|
178
|
+
const status = (deps.workspaceStatus ?? ((workspaceRoot) => spawnSync("git", ["status", "--porcelain", "--", "Cargo.toml", "Cargo.lock", "ci/gate.config.json", "ci/asset-gated.json"], { cwd: workspaceRoot, encoding: "utf8", windowsHide: true })))(options.workspaceRoot);
|
|
179
|
+
// The gate config decides the steps replayed below, so an uncommitted edit would change what is demanded (0.4.1 review A3).
|
|
180
|
+
if (status.error || status.status !== 0 || String(status.stdout ?? "").trim()) throw new Error("--from-evidence requires clean workspace Cargo.toml, Cargo.lock and ci gate config (ci/gate.config.json, ci/asset-gated.json)");
|
|
181
|
+
const proofHost = singleHostOfPolicy(gate.evidence) ?? "darwin";
|
|
182
|
+
const runs = gate.evidence.runs.filter((run) => run?.host?.os === proofHost);
|
|
183
|
+
const command = `cargo package --locked -p ${options.crate}`;
|
|
184
|
+
if (!runs.length || !runs.every((run) => run.steps?.some((step) => step.ok === true && step.command === command))) {
|
|
185
|
+
throw new Error("--from-evidence requires a verified cargo package --locked receipt for this artifact");
|
|
186
|
+
}
|
|
187
|
+
// Load the shared planner after module initialization: local-publish itself imports this publisher.
|
|
188
|
+
// Validate the complete current qualification matrix, not merely one passing test and lint command.
|
|
189
|
+
const { localTestPlan } = await import("./local-publish.mjs");
|
|
190
|
+
let config;
|
|
191
|
+
try { config = JSON.parse(fs.readFileSync(path.join(options.workspaceRoot, "ci", "gate.config.json"), "utf8")); }
|
|
192
|
+
catch { throw new Error("--from-evidence cannot read current ci/gate.config.json"); }
|
|
193
|
+
for (const run of runs) {
|
|
194
|
+
if (!Array.isArray(run.steps) || run.steps.some((step) => step?.ok !== true || (step.failed ?? 0) !== 0)) {
|
|
195
|
+
throw new Error("--from-evidence refuses an unsuccessful qualification step");
|
|
196
|
+
}
|
|
197
|
+
const expected = localTestPlan({ kind: "cargo", unit: options.crate, dir: `crates/${options.crate}`, config, batch: Boolean(run.batch), platform: proofHost });
|
|
198
|
+
for (const step of expected.steps) {
|
|
199
|
+
const text = `${step.command} ${step.args.join(" ")}`;
|
|
200
|
+
if (!run.steps.some((row) => row.command === text && row.ok === true && (!step.test || (row.executed > 0 && row.failed === 0)))) {
|
|
201
|
+
throw new Error(`--from-evidence missing configured qualification step: ${text}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// A batch run's clippy lives in its batch receipt, which `expected` leaves out: demand one row per feature set (0.4.1 review A2).
|
|
205
|
+
if (run.batch) {
|
|
206
|
+
const missing = batchClippyMissingFeatureSets(run.batch, gate.evidence, config?.rust?.[options.crate]?.featureSets ?? [[]]);
|
|
207
|
+
if (missing.length) throw new Error(`--from-evidence missing batch clippy for feature set${missing.length === 1 ? "" : "s"}: ${missing.map((features) => features.join(" ") || "default").join("; ")}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const plan = buildCargoPublishPlan({ ...options, crateDir, proven,
|
|
212
|
+
verifyOnPublish: !reuseEvidence && options.verifyOnPublish === true, packageVerified: reuseEvidence, offlineReadOnly: options.offlineReadOnly === true });
|
|
138
213
|
for (const [label, key] of Object.entries(PROVEN_STEP)) {
|
|
139
214
|
if (proven[key] === true) process.stdout.write(`[right-release] cargo ${label}: proven by the gate evidence for this commit, not repeated\n`);
|
|
140
215
|
}
|
|
@@ -178,21 +253,38 @@ export async function runCargoPublish(options, execute = (_command, args, settin
|
|
|
178
253
|
}
|
|
179
254
|
if (step.label === "registry publish") assertArtifactUnchanged(artifact);
|
|
180
255
|
// `execute` may be synchronous (the default) or async (the batch publisher overlaps uploads).
|
|
181
|
-
const
|
|
256
|
+
const isReadOnlyInspection = ["package contents", "package location"].includes(step.label);
|
|
257
|
+
const settings = {
|
|
182
258
|
cwd: options.workspaceRoot,
|
|
183
259
|
encoding: "utf8",
|
|
184
260
|
windowsHide: true,
|
|
185
|
-
stdio: step.capture ? "pipe" : "inherit",
|
|
186
|
-
}
|
|
187
|
-
|
|
261
|
+
stdio: step.capture || (options.offlineReadOnly === true && isReadOnlyInspection) ? "pipe" : "inherit",
|
|
262
|
+
};
|
|
263
|
+
const result = options.offlineReadOnly === true && isReadOnlyInspection
|
|
264
|
+
? await runCargoReadOnly(step.args, settings, execute, (failed) => {
|
|
265
|
+
process.stderr.write(`[right-release] offline Cargo inspection missed cache; retrying online once\n${failed.stdout ?? ""}${failed.stderr ? `\n${failed.stderr}` : ""}`);
|
|
266
|
+
})
|
|
267
|
+
: await execute(step.command, step.args, settings);
|
|
268
|
+
if (result.error) {
|
|
269
|
+
if (result.offlineFallbackFailed) throw new Error(`${step.label} failed: ${result.error.message}`);
|
|
270
|
+
throw result.error;
|
|
271
|
+
}
|
|
188
272
|
if (result.status !== 0) {
|
|
189
|
-
if (step.
|
|
190
|
-
|
|
273
|
+
if (result.offlineFallbackFailed || step.label === "package archive") {
|
|
274
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
275
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
276
|
+
} else if (step.capture && result.stderr) process.stderr.write(result.stderr);
|
|
277
|
+
const fallback = result.offlineFallbackFailed ? `\n${result.stdout ?? ""}\n${result.stderr ?? ""}` : "";
|
|
278
|
+
throw new Error(`${step.label} failed with exit code ${result.status}${fallback}`);
|
|
191
279
|
}
|
|
192
280
|
if (step.label === "package location") {
|
|
281
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
193
282
|
const metadata = JSON.parse(result.stdout);
|
|
194
283
|
if (typeof metadata.target_directory !== "string" || !path.isAbsolute(metadata.target_directory)) throw new Error("invalid Cargo package target directory");
|
|
195
284
|
targetDir = metadata.target_directory;
|
|
285
|
+
} else if (step.label === "package archive") {
|
|
286
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
287
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
196
288
|
} else if (step.capture) {
|
|
197
289
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
198
290
|
const files = String(result.stdout ?? "").split(/\r?\n/).filter(Boolean);
|
|
@@ -229,7 +321,7 @@ async function main() {
|
|
|
229
321
|
const result = await runCargoPublish(options);
|
|
230
322
|
process.stdout.write(`[right-release] ${options.dryRun ? "validated" : "published"} ${options.crate}\n`);
|
|
231
323
|
if (!options.dryRun) {
|
|
232
|
-
recordPublished({ registry: "cargo", name: result.name, version: result.version });
|
|
324
|
+
recordPublished({ registry: "cargo", name: result.name, version: result.version }, workspaceFeedOptions(options.workspaceRoot));
|
|
233
325
|
}
|
|
234
326
|
}
|
|
235
327
|
|