@rightkit/release 0.2.135 → 0.2.136
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 +62 -0
- package/addon-command.mjs +53 -34
- package/apple-profile-renew.mjs +30 -12
- package/artifact-storage.mjs +47 -10
- package/background-process.mjs +18 -0
- package/build-release.mjs +1 -1
- package/cache-command.mjs +4 -3
- package/cargo-contract.mjs +1 -1
- package/cargo-guard.mjs +4 -3
- package/cargo-registry-readback.mjs +51 -0
- package/cargo-target.mjs +3 -1
- package/cli/right-release.mjs +26 -0
- package/codesign-environment.mjs +26 -2
- package/codesign-shim.mjs +2 -1
- package/committed-source.mjs +3 -3
- package/development.mjs +7 -7
- package/direct-bootstrap.mjs +19 -9
- package/dmg-build.mjs +117 -0
- package/dmg-layout.mjs +123 -0
- package/github-release.mjs +74 -37
- package/heavy-command.mjs +3 -2
- package/install-app.mjs +358 -0
- package/inventory-check.mjs +980 -0
- package/macos-signing-session.mjs +3 -0
- package/managed-cargo.mjs +23 -19
- package/managed-host-secrets.mjs +30 -0
- package/managed-roots.mjs +39 -0
- package/model-promote.mjs +31 -37
- package/notary-auth.mjs +4 -0
- package/osascript-guard.mjs +24 -0
- package/ownership-approval-command.mjs +15 -4
- package/ownership-approval.mjs +114 -49
- package/ownership-availability.mjs +25 -0
- package/ownership-catalog-config.json +18 -0
- package/ownership-catalog.mjs +94 -0
- package/ownership-command.mjs +17 -10
- package/ownership-declaration.json +15 -0
- package/ownership-default-policy.json +1 -11
- package/ownership-evidence.mjs +10 -8
- package/ownership-git-inventory.mjs +29 -1
- package/ownership-helper-paths.json +2 -2
- package/ownership-hooks.mjs +107 -0
- package/ownership-internal-owners.json +44 -0
- package/ownership-key-authority.mjs +112 -7
- package/ownership-key-command.mjs +76 -19
- package/ownership-key-trust.mjs +97 -9
- package/ownership-lockfiles.mjs +311 -0
- package/ownership-report-command.mjs +12 -3
- package/ownership-report.mjs +20 -17
- package/ownership-repositories.json +19 -0
- package/ownership-scan-worker.mjs +96 -11
- package/ownership-source.mjs +39 -0
- package/ownership-staged.mjs +193 -0
- package/ownership-verdict.mjs +74 -0
- package/ownership.mjs +215 -107
- package/package.json +1 -1
- package/protected-credentials.mjs +111 -0
- package/prune-r2.mjs +4 -3
- package/pub-upload.mjs +3 -85
- package/publication-artifacts.mjs +108 -0
- package/publication-gate.mjs +28 -7
- package/publish-cargo.mjs +47 -16
- package/publish-swift.mjs +46 -17
- package/publish-update.mjs +61 -39
- package/r2-verified-upload.mjs +41 -0
- package/registry-parity.mjs +5 -3
- package/release-state.mjs +1 -0
- package/release-token.mjs +2 -0
- package/rightkit-drift.mjs +3 -2
- package/rightkit-ownership.json +2028 -139
- package/rightkit-signing-agent.mjs +5 -3
- package/rightkit-sync.mjs +6 -2
- package/rightkit-versions.json +213 -103
- package/runtime-artifact-manifest.mjs +20 -6
- package/sign-updater.mjs +5 -0
- package/signing-agent-client.mjs +2 -1
- package/signing-agent-install.mjs +3 -2
- package/standalone-clone-verify.mjs +3 -2
- package/tauri-bundle-marker.mjs +3 -2
- package/upload-large.mjs +36 -82
- package/verifier.mjs +5 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { spawnSync } from "./background-process.mjs";
|
|
5
|
+
import { isManagedHost, refuseManagedEnvSecrets } from "./managed-host-secrets.mjs";
|
|
6
|
+
|
|
7
|
+
// Protected credential loaders for release sites that previously read only
|
|
8
|
+
// ambient environment variables. The storage pattern matches release-token.mjs:
|
|
9
|
+
// macOS Keychain generic passwords (account $USER) and, on Windows, a protected
|
|
10
|
+
// token file under %APPDATA%\RightKit. Values are returned to the caller only;
|
|
11
|
+
// they are never logged, and diagnostics name the secret, never its value.
|
|
12
|
+
export const PROTECTED_SECRET_MISSING = "PROTECTED_SECRET_MISSING";
|
|
13
|
+
|
|
14
|
+
// Apple App Store Connect values are not stored here: the key ID and issuer come
|
|
15
|
+
// from the release config or environment, and the .p8 is read from its file.
|
|
16
|
+
export const PROTECTED_SECRETS = Object.freeze({
|
|
17
|
+
"cloudflare-api-token": { label: "Cloudflare API token", keychain: "rightkit-cloudflare-api-token", file: "cloudflare-api-token.token" },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export class ProtectedSecretMissingError extends Error {
|
|
21
|
+
constructor(name, storeCommand) {
|
|
22
|
+
const { label } = PROTECTED_SECRETS[name];
|
|
23
|
+
super(storeCommand
|
|
24
|
+
? `${label} is not in the protected credential store; store it once with: ${storeCommand}`
|
|
25
|
+
: `${label} is not in a protected credential store on this platform`);
|
|
26
|
+
this.name = "ProtectedSecretMissingError";
|
|
27
|
+
this.code = PROTECTED_SECRET_MISSING;
|
|
28
|
+
this.secret = name;
|
|
29
|
+
this.storeCommand = storeCommand ?? null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// One-line command the operator runs once per host. macOS prompts for the value
|
|
34
|
+
// (-w with no argument); Windows prompts with Read-Host -AsSecureString.
|
|
35
|
+
export function protectedSecretStoreCommand(name, platform = process.platform) {
|
|
36
|
+
const spec = PROTECTED_SECRETS[name];
|
|
37
|
+
if (!spec) throw new Error(`unknown protected secret: ${name}`);
|
|
38
|
+
if (platform === "darwin") return `security add-generic-password -a "$USER" -s ${spec.keychain} -U -w`;
|
|
39
|
+
if (platform === "win32") {
|
|
40
|
+
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)))"`;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Reads a generic password without echoing stderr; a missing item is undefined.
|
|
46
|
+
function readKeychainSecret({ service, account }) {
|
|
47
|
+
const result = spawnSync("security", ["find-generic-password", "-a", account, "-s", service, "-w"], {
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
50
|
+
});
|
|
51
|
+
if (result.error || result.status !== 0) return undefined;
|
|
52
|
+
return result.stdout;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readProtectedFile(file) {
|
|
56
|
+
try {
|
|
57
|
+
return readFileSync(file, "utf8");
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error.code === "ENOENT") return undefined;
|
|
60
|
+
throw new Error("protected credential file is unreadable");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The store is injectable so tests can use fakes without touching a real
|
|
65
|
+
// Keychain or the Windows profile directory.
|
|
66
|
+
export function createCredentialStore({
|
|
67
|
+
env = process.env,
|
|
68
|
+
platform = process.platform,
|
|
69
|
+
readKeychain = readKeychainSecret,
|
|
70
|
+
readFile = readProtectedFile,
|
|
71
|
+
} = {}) {
|
|
72
|
+
return {
|
|
73
|
+
platform,
|
|
74
|
+
read(name) {
|
|
75
|
+
if (!PROTECTED_SECRETS[name]) throw new Error(`unknown protected secret: ${name}`);
|
|
76
|
+
const spec = PROTECTED_SECRETS[name];
|
|
77
|
+
let value;
|
|
78
|
+
if (platform === "darwin") {
|
|
79
|
+
value = readKeychain({ service: spec.keychain, account: env.USER || os.userInfo().username });
|
|
80
|
+
} else if (platform === "win32") {
|
|
81
|
+
const appData = env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
82
|
+
value = readFile(path.win32.join(appData, "RightKit", spec.file));
|
|
83
|
+
}
|
|
84
|
+
const trimmed = typeof value === "string" ? value.trim() : "";
|
|
85
|
+
if (!trimmed) throw new ProtectedSecretMissingError(name, protectedSecretStoreCommand(name, platform));
|
|
86
|
+
return trimmed;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Managed hosts refuse the named environment variable before any credential is
|
|
92
|
+
// read. Unmanaged hosts try the protected store first and use the variable only
|
|
93
|
+
// when the store has no value. A missing store entry on a managed host never
|
|
94
|
+
// permits the environment fallback.
|
|
95
|
+
export function resolveEnvBackedSecret({ name, envName, env = process.env, credentialStore, required = true }) {
|
|
96
|
+
refuseManagedEnvSecrets(env, [envName]);
|
|
97
|
+
if (!required) return undefined;
|
|
98
|
+
const store = credentialStore ?? createCredentialStore({ env });
|
|
99
|
+
try {
|
|
100
|
+
return store.read(name);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (!(error instanceof ProtectedSecretMissingError)) throw error;
|
|
103
|
+
const fallback = isManagedHost(env) ? "" : String(env[envName] ?? "").trim();
|
|
104
|
+
if (fallback) return fallback;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function resolveCloudflareApiToken({ env = process.env, credentialStore, required = true } = {}) {
|
|
110
|
+
return resolveEnvBackedSecret({ name: "cloudflare-api-token", envName: "CLOUDFLARE_API_TOKEN", env, credentialStore, required });
|
|
111
|
+
}
|
package/prune-r2.mjs
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
+
import { resolveCloudflareApiToken } from "./protected-credentials.mjs";
|
|
2
|
+
|
|
1
3
|
const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID || "03ae77ccd7a07bcbb2dcfde47fa7ba3a";
|
|
2
4
|
const BUCKETS = { public: "rightapps-downloads", private: "rightapps-updates" };
|
|
3
5
|
|
|
4
|
-
export async function pruneReleaseObjects({ app, platform, keep, dryRun = false }) {
|
|
6
|
+
export async function pruneReleaseObjects({ app, platform, keep, dryRun = false, env = process.env, credentialStore }) {
|
|
5
7
|
if (!new Set(["mac", "windows"]).has(platform)) throw new Error(`invalid R2 prune platform: ${platform}`);
|
|
8
|
+
const token = resolveCloudflareApiToken({ env, credentialStore, required: !dryRun });
|
|
6
9
|
for (const [alias, bucket] of Object.entries(BUCKETS)) {
|
|
7
10
|
const retained = new Set(keep[alias] ?? []);
|
|
8
11
|
if (dryRun) {
|
|
9
12
|
console.log(`[dry-run] PRUNE ${bucket}/${app}/${platform} keep=${[...retained].sort().join(",") || "<none>"}`);
|
|
10
13
|
continue;
|
|
11
14
|
}
|
|
12
|
-
const token = process.env.CLOUDFLARE_API_TOKEN;
|
|
13
|
-
if (!token) throw new Error("CLOUDFLARE_API_TOKEN is required to prune stale R2 release objects");
|
|
14
15
|
for (const key of await listObjects(bucket, app, token)) {
|
|
15
16
|
if (!isReleaseObject(app, platform, key) || retained.has(key)) continue;
|
|
16
17
|
console.log(`DELETE ${bucket}/${key}`);
|
package/pub-upload.mjs
CHANGED
|
@@ -1,86 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const ACCOUNT_ID = "03ae77ccd7a07bcbb2dcfde47fa7ba3a";
|
|
7
|
-
const BUCKETS = new Map([
|
|
8
|
-
["public", "rightapps-downloads"],
|
|
9
|
-
["private", "rightapps-updates"],
|
|
10
|
-
]);
|
|
11
|
-
const RUNNER = { cmd: "pnpm", prefix: ["dlx"] };
|
|
12
|
-
|
|
13
|
-
const [, , localFile, r2Key, bucketAlias = "public"] = process.argv;
|
|
14
|
-
if (!localFile || !r2Key) {
|
|
15
|
-
console.error("Usage: node upload-large.mjs <localFile> <r2Key> [public|private]");
|
|
16
|
-
process.exit(1);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const bucket = BUCKETS.get(bucketAlias);
|
|
20
|
-
if (!bucket) {
|
|
21
|
-
console.error(`upload-large: invalid bucket alias ${bucketAlias}; expected public|private`);
|
|
22
|
-
process.exit(1);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
accessSync(localFile);
|
|
27
|
-
} catch {
|
|
28
|
-
console.error(`upload-large: missing local file: ${localFile}`);
|
|
29
|
-
process.exit(1);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const fileSize = statSync(localFile).size;
|
|
33
|
-
const object = `${bucket}/${r2Key}`;
|
|
34
|
-
const wranglerArgs = ["wrangler@4", "r2", "object", "put", object, "--file", path.resolve(localFile), "--remote"];
|
|
35
|
-
const env = cleanCloudflareEnv(process.env);
|
|
36
|
-
|
|
37
|
-
console.log(`Uploading ${path.basename(localFile)} (${(fileSize / 1024 / 1024).toFixed(1)} MB) -> ${object}`);
|
|
38
|
-
console.log(`wrangler r2 object put ${object} --file ${path.resolve(localFile)} --remote`);
|
|
39
|
-
|
|
40
|
-
// Resolve the package runner before the dry-run exit: a release box without one cannot upload,
|
|
41
|
-
// and that must fail here rather than after a signed build has already been produced.
|
|
42
|
-
const runner = resolveRunner(env);
|
|
43
|
-
if (!runner) {
|
|
44
|
-
console.error(
|
|
45
|
-
"upload-large: no package runner found. Tried: " +
|
|
46
|
-
"pnpm dlx. Install the pinned workspace package manager before release.",
|
|
47
|
-
);
|
|
48
|
-
process.exit(1);
|
|
49
|
-
}
|
|
50
|
-
console.log(`runner: ${[runner.cmd, ...runner.prefix].join(" ")}`);
|
|
51
|
-
|
|
52
|
-
if (process.env.RIGHT_RELEASE_UPLOAD_DRY_RUN === "1") {
|
|
53
|
-
process.exit(0);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const result = spawnSync(runner.cmd, [...runner.prefix, ...wranglerArgs], {
|
|
57
|
-
env,
|
|
58
|
-
stdio: "inherit",
|
|
59
|
-
shell: process.platform === "win32",
|
|
60
|
-
windowsHide: true,
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
if (result.error) {
|
|
64
|
-
console.error(`upload-large: failed to start wrangler: ${result.error.message}`);
|
|
65
|
-
process.exit(1);
|
|
66
|
-
}
|
|
67
|
-
process.exit(result.status ?? 1);
|
|
68
|
-
|
|
69
|
-
function resolveRunner(env) {
|
|
70
|
-
const probe = spawnSync(RUNNER.cmd, ["--version"], {
|
|
71
|
-
env,
|
|
72
|
-
stdio: "ignore",
|
|
73
|
-
shell: process.platform === "win32",
|
|
74
|
-
windowsHide: true,
|
|
75
|
-
});
|
|
76
|
-
return !probe.error && probe.status === 0 ? RUNNER : null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function cleanCloudflareEnv(source) {
|
|
80
|
-
const env = { ...source, CLOUDFLARE_ACCOUNT_ID: source.CLOUDFLARE_ACCOUNT_ID || ACCOUNT_ID };
|
|
81
|
-
delete env.CLOUDFLARE_API_KEY;
|
|
82
|
-
delete env.CLOUDFLARE_EMAIL;
|
|
83
|
-
delete env.CF_API_KEY;
|
|
84
|
-
delete env.CF_EMAIL;
|
|
85
|
-
return env;
|
|
86
|
-
}
|
|
2
|
+
import { runLargeUpload } from "./upload-large.mjs";
|
|
3
|
+
try { runLargeUpload(process.argv.slice(2)); }
|
|
4
|
+
catch (error) { console.error(`pub-upload: ${error.message}`); process.exitCode = 1; }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync, statSync, openSync, fstatSync, readSync, closeSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { verifySealedRelease } from "./release-state.mjs";
|
|
5
|
+
import { verifyOwnershipEvidence } from "./ownership-evidence.mjs";
|
|
6
|
+
|
|
7
|
+
export class PublicationError extends Error {
|
|
8
|
+
constructor(code, message) { super(`[${code}] ${message}`); this.name = "PublicationError"; this.code = code; }
|
|
9
|
+
}
|
|
10
|
+
export const digestBytes = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
11
|
+
|
|
12
|
+
// Read once. Upload boundaries consume these bytes, never reopen their source path.
|
|
13
|
+
export function readVerifiedArtifact(file, evidence) {
|
|
14
|
+
let bytes, identity;
|
|
15
|
+
try {
|
|
16
|
+
const before = statSync(file);
|
|
17
|
+
bytes = readFileSync(file);
|
|
18
|
+
identity = statSync(file);
|
|
19
|
+
if (!sameIdentity(before, identity)) throw new PublicationError("PUBLICATION_SOURCE_CHANGED", `artifact changed while reading: ${file}`);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
if (error instanceof PublicationError) throw error;
|
|
22
|
+
throw new PublicationError("PUBLICATION_ARTIFACT_MISSING", `artifact unreadable: ${file}`);
|
|
23
|
+
}
|
|
24
|
+
if (!/^[a-f0-9]{64}$/.test(evidence?.sha256 ?? "") || digestBytes(bytes) !== evidence.sha256) {
|
|
25
|
+
throw new PublicationError("PUBLICATION_HASH_MISMATCH", `artifact sha256 mismatch: ${file}`);
|
|
26
|
+
}
|
|
27
|
+
if (evidence.sizeBytes !== undefined && bytes.length !== evidence.sizeBytes) {
|
|
28
|
+
throw new PublicationError("PUBLICATION_SIZE_MISMATCH", `artifact size mismatch: ${file}`);
|
|
29
|
+
}
|
|
30
|
+
return { file, bytes, sha256: evidence.sha256, sizeBytes: bytes.length, identity };
|
|
31
|
+
}
|
|
32
|
+
function sameIdentity(a, b) {
|
|
33
|
+
return a.isFile() && b.isFile() && ["dev", "ino", "size", "mtimeMs", "ctimeMs"].every((key) => a[key] === b[key]);
|
|
34
|
+
}
|
|
35
|
+
export function assertArtifactUnchanged(artifact) {
|
|
36
|
+
let current;
|
|
37
|
+
try { current = statSync(artifact.file); } catch { /* refusal below */ }
|
|
38
|
+
if (!current || !sameIdentity(artifact.identity, current)) throw new PublicationError("PUBLICATION_SOURCE_CHANGED", `artifact replaced after verification: ${artifact.file}`);
|
|
39
|
+
if (digestBytes(artifact.bytes) !== artifact.sha256 || artifact.bytes.length !== artifact.sizeBytes) throw new PublicationError("PUBLICATION_HASH_MISMATCH", `verified bytes changed: ${artifact.file}`);
|
|
40
|
+
return artifact.bytes;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit, app, version, platform, configPath, releaseId }, deps = {}) {
|
|
44
|
+
let sealed;
|
|
45
|
+
try { sealed = verifySealedRelease(sealedDir); }
|
|
46
|
+
catch (error) {
|
|
47
|
+
const code = /hash mismatch/.test(error.message) ? "PUBLICATION_HASH_MISMATCH" : /size mismatch/.test(error.message) ? "PUBLICATION_SIZE_MISMATCH" : "PUBLICATION_SEAL_INVALID";
|
|
48
|
+
throw new PublicationError(code, error.message);
|
|
49
|
+
}
|
|
50
|
+
const manifest = sealed.manifest;
|
|
51
|
+
if (!/^[a-f0-9]{40}$/.test(commit ?? "") || manifest.commit !== commit
|
|
52
|
+
|| (app && manifest.app !== app) || (version && manifest.version !== version)
|
|
53
|
+
|| manifest.platform !== platform || (releaseId && manifest.releaseId !== releaseId)) throw new PublicationError("PUBLICATION_SOURCE_MISMATCH", "sealed release source or product identity mismatch");
|
|
54
|
+
const ownership = (deps.verifyOwnership ?? verifyOwnershipEvidence)(manifest.ownershipEvidence, repoRoot, appRoot);
|
|
55
|
+
if (!ownership.ok || manifest.ownershipEvidence?.commit !== commit) throw new PublicationError("PUBLICATION_OWNERSHIP_INVALID", `sealed ownership evidence rejected: ${ownership.reason ?? "commit mismatch"}`);
|
|
56
|
+
if (!manifest.checkpoints?.includes("signed") || !manifest.checkpoints?.includes("hardened")) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "signed & hardened seal checkpoints are required");
|
|
57
|
+
if (configPath && digestBytes(readFileSync(configPath)) !== manifest.signing?.configSha256) throw new PublicationError("PUBLICATION_POLICY_MISMATCH", "release config differs from sealed signing policy");
|
|
58
|
+
const receipts = manifest.signing?.receipts;
|
|
59
|
+
if (!Array.isArray(receipts) || !receipts.length) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed signing receipts are required");
|
|
60
|
+
let notarized = false;
|
|
61
|
+
let signed = false;
|
|
62
|
+
for (const receipt of receipts) {
|
|
63
|
+
const artifact = readVerifiedArtifact(path.resolve(appRoot, receipt.file), receipt);
|
|
64
|
+
let value;
|
|
65
|
+
try { value = JSON.parse(artifact.bytes); } catch { throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", "invalid sealed signing receipt"); }
|
|
66
|
+
if (value.schema !== 1) throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", "malformed sealed signing receipt");
|
|
67
|
+
if (Array.isArray(value.files)) {
|
|
68
|
+
if (!value.files.length || value.files.some((item) => !/^[a-f0-9]{64}$/.test(item.after?.sha256 ?? "") || item.timestampPresent !== true
|
|
69
|
+
|| (platform === "win" ? item.authenticode !== "Valid" || item.subject !== "CN=Damned Ventures LLC" : item.codesign !== "strict-valid" || item.identity !== value.identity))) throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", "sealed signature verification evidence rejected");
|
|
70
|
+
signed = true;
|
|
71
|
+
} else if (Array.isArray(value.embedded)) {
|
|
72
|
+
if (!value.embedded.length || !/^[a-f0-9]{64}$/.test(value.rawExe?.signedSha256 ?? "") || value.rawExe.signedSha256 !== value.rawExe.verifiedSha256
|
|
73
|
+
|| value.embedded.some((item) => item.sha256 !== value.rawExe.signedSha256)) throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", "sealed installer embedding evidence rejected");
|
|
74
|
+
} else if (!value.notarization) throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", "unrecognized sealed signing receipt");
|
|
75
|
+
if (value.notarization) {
|
|
76
|
+
if (value.notarization.status !== "Accepted" || !value.stapling?.length || value.stapling.some((item) => item.validated !== true)) throw new PublicationError("PUBLICATION_NOTARIZATION_INVALID", "notarization/stapling evidence rejected");
|
|
77
|
+
if (!value.archive?.file || !value.archive.sha256) throw new PublicationError("PUBLICATION_NOTARIZATION_INVALID", "notarization archive binding missing");
|
|
78
|
+
readVerifiedArtifact(path.resolve(appRoot, value.archive.file), value.archive);
|
|
79
|
+
notarized = true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!signed) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed native signing evidence is required");
|
|
83
|
+
if (platform === "mac" && !notarized) throw new PublicationError("PUBLICATION_NOTARIZATION_MISSING", "sealed notarization evidence is required");
|
|
84
|
+
const artifacts = new Map();
|
|
85
|
+
for (const entry of manifest.files ?? []) {
|
|
86
|
+
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");
|
|
87
|
+
artifacts.set(entry.name, readVerifiedArtifact(path.join(sealedDir, entry.name), entry));
|
|
88
|
+
}
|
|
89
|
+
if (!artifacts.size) throw new PublicationError("PUBLICATION_SEAL_INVALID", "sealed release has no artifacts");
|
|
90
|
+
return { ...sealed, artifacts };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Bounded memory for large artifacts. Transport must read back against this digest.
|
|
94
|
+
export function verifyArtifactFile(file, evidence) {
|
|
95
|
+
if (!/^[a-f0-9]{64}$/.test(evidence?.sha256 ?? "")) throw new PublicationError("PUBLICATION_SEAL_INVALID", "publication hash evidence is required");
|
|
96
|
+
const fd = openSync(file, "r");
|
|
97
|
+
try {
|
|
98
|
+
const before = fstatSync(fd), hash = createHash("sha256"), chunk = Buffer.alloc(1024 * 1024);
|
|
99
|
+
let sizeBytes = 0, count;
|
|
100
|
+
while (sizeBytes < before.size && (count = readSync(fd, chunk, 0, Math.min(chunk.length, before.size - sizeBytes), null)) > 0) {
|
|
101
|
+
hash.update(chunk.subarray(0, count)); sizeBytes += count;
|
|
102
|
+
}
|
|
103
|
+
if (!sameIdentity(before, fstatSync(fd))) throw new PublicationError("PUBLICATION_SOURCE_CHANGED", `artifact changed while hashing: ${file}`);
|
|
104
|
+
if (hash.digest("hex") !== evidence.sha256) throw new PublicationError("PUBLICATION_HASH_MISMATCH", `artifact sha256 mismatch: ${file}`);
|
|
105
|
+
if (evidence.sizeBytes !== undefined && sizeBytes !== evidence.sizeBytes) throw new PublicationError("PUBLICATION_SIZE_MISMATCH", `artifact size mismatch: ${file}`);
|
|
106
|
+
return { file, sha256: evidence.sha256, sizeBytes, identity: before };
|
|
107
|
+
} finally { closeSync(fd); }
|
|
108
|
+
}
|
package/publication-gate.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import path from "node:path";
|
|
|
10
10
|
import { spawnSync } from "./background-process.mjs";
|
|
11
11
|
import { recordPublished } from "./rightkit-sync.mjs";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { digestBytes, readVerifiedArtifact } from "./publication-artifacts.mjs";
|
|
13
14
|
|
|
14
15
|
export const GATE_SCHEMA = 1;
|
|
15
16
|
export const REQUIRED_HOSTS = Object.freeze(["darwin", "win32"]);
|
|
@@ -28,7 +29,7 @@ function defaultGit(args, cwd) {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
// Returns { ok, missing[] }. Pure apart from the injected git/readFile.
|
|
31
|
-
export function checkPublicationGate({ kind, name, version, packagePath, evidencePath, git = defaultGit, readFile = (p) => fs.readFileSync(p, "utf8") }) {
|
|
32
|
+
export function checkPublicationGate({ kind, name, version, packagePath, evidencePath, artifactBytes, git = defaultGit, readFile = (p) => fs.readFileSync(p, "utf8") }) {
|
|
32
33
|
const missing = [];
|
|
33
34
|
const head = git(["rev-parse", "HEAD"], packagePath);
|
|
34
35
|
if (!head || !SHA.test(head)) missing.push(`cannot read current commit SHA for ${packagePath}`);
|
|
@@ -64,7 +65,10 @@ export function checkPublicationGate({ kind, name, version, packagePath, evidenc
|
|
|
64
65
|
}
|
|
65
66
|
if (!/^[0-9a-f]{64}$/.test(String(evidence.artifactSha256 ?? ""))) missing.push("evidence artifactSha256 must be a 64-hex hash");
|
|
66
67
|
}
|
|
67
|
-
|
|
68
|
+
if (artifactBytes !== undefined && evidence && digestBytes(artifactBytes) !== evidence.artifactSha256) missing.push("artifact sha256 does not match evidence artifactSha256");
|
|
69
|
+
const result = { ok: missing.length === 0, missing };
|
|
70
|
+
Object.defineProperty(result, "evidence", { value: evidence });
|
|
71
|
+
return Object.freeze(result);
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
// Enforce: throws on missing evidence unless dryRun (then reports and continues).
|
|
@@ -81,7 +85,8 @@ export const NPM_KEYCHAIN_SERVICE = "npm-rightkit-publish";
|
|
|
81
85
|
|
|
82
86
|
// `right-release publish npm --package <name> [--evidence <file>] [--dry-run]` runs the gate only.
|
|
83
87
|
// Add `--publish --tarball <file>` to publish that exact gated tarball: its sha256 must equal the
|
|
84
|
-
// evidence artifactSha256
|
|
88
|
+
// evidence artifactSha256. Mandatory registry readback compares against captured bytes,
|
|
89
|
+
// so a source-path swap cannot return success or record publication. The token is read from the macOS Keychain inside this process (never
|
|
85
90
|
// printed or passed on a command line), and the registry shasum is verified after publication.
|
|
86
91
|
/** `right-release publish npm --check-auth`: prove the Keychain token authenticates (whoami +
|
|
87
92
|
* @rightkit package access) without publishing. Prints only the username and counts. */
|
|
@@ -150,17 +155,32 @@ export function publishGatedTarball({ name, version, tarball, evidencePath }, de
|
|
|
150
155
|
{ encoding: "utf8", env: { ...process.env, RIGHTKIT_NPM_TOKEN: token }, windowsHide: true });
|
|
151
156
|
const out = redact(`${result.stdout ?? ""}${result.stderr ?? ""}`);
|
|
152
157
|
if (result.error || result.status !== 0) throw new Error(`npm publish failed for ${name}@${version}:\n${out.trim()}`);
|
|
153
|
-
process.stdout.write(`[right-release]
|
|
158
|
+
process.stdout.write(`[right-release] submitted ${name}@${version}\n`);
|
|
154
159
|
} finally {
|
|
155
160
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
156
161
|
}
|
|
157
162
|
const sha1 = crypto.createHash("sha1").update(bytes).digest("hex");
|
|
163
|
+
verifyNpmRegistryBytes({ name, version, sha1 }, deps);
|
|
164
|
+
(deps.recordPublished ?? recordPublished)({ registry: "npm", name, version });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// CI and the authenticated helper share mandatory, fail-closed registry equality.
|
|
168
|
+
export function prepareNpmPublication({ name, version, tarball, evidencePath }) {
|
|
169
|
+
if (!name || !version) throw new Error("npm publication identity is required");
|
|
170
|
+
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
|
|
171
|
+
const artifact = readVerifiedArtifact(tarball, { sha256: evidence.artifactSha256 });
|
|
172
|
+
return { name, version, sha1: crypto.createHash("sha1").update(artifact.bytes).digest("hex"), sha256: artifact.sha256 };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function verifyNpmRegistryBytes({ name, version, sha1 }, deps = {}) {
|
|
176
|
+
if (!name || !version || !/^[a-f0-9]{40}$/.test(sha1 ?? "")) throw new Error("captured npm publication hash evidence is required");
|
|
177
|
+
const run = deps.spawnSync ?? spawnSync;
|
|
158
178
|
const deadline = Date.now() + (deps.verifyTimeoutMs ?? 10 * 60 * 1000);
|
|
159
179
|
for (;;) {
|
|
160
180
|
const view = run("npm", ["view", `${name}@${version}`, "dist.shasum", "--prefer-online", "--registry", "https://registry.npmjs.org/"], { encoding: "utf8", windowsHide: true });
|
|
161
181
|
const registry = (view.stdout ?? "").trim();
|
|
162
|
-
if (registry === sha1) { process.stdout.write(`[right-release] registry bytes verified for ${name}@${version} (shasum ${sha1})\n`);
|
|
163
|
-
if (registry && registry !== sha1) throw new Error(`registry shasum ${registry} != gated tarball ${sha1} for ${name}@${version}`);
|
|
182
|
+
if (!view.error && view.status === 0 && registry === sha1) { process.stdout.write(`[right-release] registry bytes verified for ${name}@${version} (shasum ${sha1})\n`); return; }
|
|
183
|
+
if (registry && registry !== sha1) throw new Error(`registry shasum ${registry} != gated tarball ${sha1} for ${name}@${version}; inspect remote package ${name}@${version}`);
|
|
164
184
|
if (Date.now() > deadline) throw new Error(`${name}@${version} not visible on the registry after publish; verify later with npm view`);
|
|
165
185
|
(deps.sleep ?? ((ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)))(15000);
|
|
166
186
|
}
|
|
@@ -174,7 +194,8 @@ function readKeychainToken(run) {
|
|
|
174
194
|
return token;
|
|
175
195
|
}
|
|
176
196
|
|
|
177
|
-
|
|
197
|
+
const realOrSelf = (file) => { try { return fs.realpathSync(file); } catch { return path.resolve(file); } };
|
|
198
|
+
if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
|
|
178
199
|
try { runNpmPreflight(process.argv.slice(2)); } catch (error) {
|
|
179
200
|
process.stderr.write(`right-release publish npm: ${error.message}\n`); process.exitCode = 1;
|
|
180
201
|
}
|
package/publish-cargo.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { spawnSync } from "./background-process.mjs";
|
|
5
4
|
import { fileURLToPath } from "node:url";
|
|
6
5
|
import { enforcePublicationGate, defaultEvidencePath } from "./publication-gate.mjs";
|
|
6
|
+
import { runCargoSync } from "./managed-cargo.mjs";
|
|
7
|
+
import { readVerifiedArtifact, assertArtifactUnchanged } from "./publication-artifacts.mjs";
|
|
8
|
+
import { verifyCargoRegistry } from "./cargo-registry-readback.mjs";
|
|
7
9
|
import { recordPublished } from "./rightkit-sync.mjs";
|
|
8
10
|
|
|
9
11
|
const FORBIDDEN_PACKAGE_PATH = /(^|\/)(?:\.env(?:\.[^/]*)?|target(?:\/.*)?|node_modules(?:\/.*)?|[^/]+\.(?:p8|p12|pem|key))$/i;
|
|
@@ -50,7 +52,15 @@ export function buildCargoPublishPlan({ crate, crateDir, dryRun, allowDirty = fa
|
|
|
50
52
|
{ label: "package contents", command: "cargo", args: packageArgs, capture: true },
|
|
51
53
|
{ label: "registry dry-run", command: "cargo", args: dryRunArgs },
|
|
52
54
|
];
|
|
53
|
-
if (!dryRun) plan.push(
|
|
55
|
+
if (!dryRun) plan.push(
|
|
56
|
+
// `publish --dry-run` leaves its archive under target/package/tmp-crate on current Cargo; package
|
|
57
|
+
// explicitly to bind target/package/<name>-<version>.crate to evidence before Cargo uploads.
|
|
58
|
+
{ label: "package archive", command: "cargo", args: ["package", "--no-verify", "-p", crate, ...(allowDirty ? ["--allow-dirty"] : [])] },
|
|
59
|
+
{ label: "package location", command: "cargo", args: ["metadata", "--format-version", "1", "--no-deps"], capture: true },
|
|
60
|
+
{ label: "archive hash check", internal: "verify-archive" },
|
|
61
|
+
{ label: "registry publish", command: "cargo", args: ["publish", "--no-verify", "-p", crate] },
|
|
62
|
+
{ label: "registry readback", internal: "registry-readback" },
|
|
63
|
+
);
|
|
54
64
|
return plan;
|
|
55
65
|
}
|
|
56
66
|
|
|
@@ -77,17 +87,22 @@ export function scanCrateForSecrets(crateDir) {
|
|
|
77
87
|
return [...new Set(violations)].sort();
|
|
78
88
|
}
|
|
79
89
|
|
|
80
|
-
export function runCargoPublish(options, execute =
|
|
90
|
+
export async function runCargoPublish(options, execute = (_command, args, settings) => runCargoSync(args, settings), deps = {}) {
|
|
81
91
|
const crateDir = path.join(options.workspaceRoot, "crates", options.crate);
|
|
82
92
|
const manifest = path.join(crateDir, "Cargo.toml");
|
|
83
93
|
if (!fs.existsSync(manifest)) throw new Error(`crate manifest not found: ${manifest}`);
|
|
84
94
|
const cargoVersion = fs.readFileSync(manifest, "utf8").match(/^version\s*=\s*"([^"]+)"/m)?.[1];
|
|
85
|
-
|
|
95
|
+
const gateOptions = {
|
|
86
96
|
kind: "cargo", name: options.crate, version: cargoVersion, packagePath: crateDir,
|
|
87
97
|
evidencePath: path.resolve(options.evidence ?? defaultEvidencePath(options.workspaceRoot, "cargo", options.crate)),
|
|
88
|
-
}
|
|
98
|
+
};
|
|
99
|
+
const gate = (deps.enforceGate ?? enforcePublicationGate)(gateOptions, { dryRun: options.dryRun });
|
|
100
|
+
const expectedSha256 = gate.evidence?.artifactSha256;
|
|
89
101
|
const plan = buildCargoPublishPlan({ ...options, crateDir });
|
|
90
102
|
|
|
103
|
+
let targetDir;
|
|
104
|
+
let artifact;
|
|
105
|
+
let publication;
|
|
91
106
|
for (const step of plan) {
|
|
92
107
|
process.stdout.write(`[right-release] cargo ${step.label}\n`);
|
|
93
108
|
if (step.internal === "secret-scan") {
|
|
@@ -95,6 +110,19 @@ export function runCargoPublish(options, execute = spawnSync) {
|
|
|
95
110
|
if (violations.length) throw new Error(`secret scan rejected: ${violations.join(", ")}`);
|
|
96
111
|
continue;
|
|
97
112
|
}
|
|
113
|
+
if (step.internal === "verify-archive") {
|
|
114
|
+
// Revalidate source/evidence after packaging; preserve that exact evidence object.
|
|
115
|
+
const current = (deps.enforceGate ?? enforcePublicationGate)(gateOptions);
|
|
116
|
+
if (current.evidence?.artifactSha256 !== expectedSha256) throw new Error("publication evidence changed during packaging");
|
|
117
|
+
artifact = readVerifiedArtifact(path.join(targetDir, "package", `${options.crate}-${cargoVersion}.crate`),
|
|
118
|
+
{ sha256: current.evidence?.artifactSha256 });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (step.internal === "registry-readback") {
|
|
122
|
+
publication = await verifyCargoRegistry({ name: options.crate, version: cargoVersion, sha256: artifact.sha256 }, deps);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (step.label === "registry publish") assertArtifactUnchanged(artifact);
|
|
98
126
|
const result = execute(step.command, step.args, {
|
|
99
127
|
cwd: options.workspaceRoot,
|
|
100
128
|
encoding: "utf8",
|
|
@@ -106,7 +134,11 @@ export function runCargoPublish(options, execute = spawnSync) {
|
|
|
106
134
|
if (step.capture && result.stderr) process.stderr.write(result.stderr);
|
|
107
135
|
throw new Error(`${step.label} failed with exit code ${result.status}`);
|
|
108
136
|
}
|
|
109
|
-
if (step.
|
|
137
|
+
if (step.label === "package location") {
|
|
138
|
+
const metadata = JSON.parse(result.stdout);
|
|
139
|
+
if (typeof metadata.target_directory !== "string" || !path.isAbsolute(metadata.target_directory)) throw new Error("invalid Cargo package target directory");
|
|
140
|
+
targetDir = metadata.target_directory;
|
|
141
|
+
} else if (step.capture) {
|
|
110
142
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
111
143
|
const files = String(result.stdout ?? "").split(/\r?\n/).filter(Boolean);
|
|
112
144
|
const violations = packageFileViolations(files);
|
|
@@ -114,6 +146,7 @@ export function runCargoPublish(options, execute = spawnSync) {
|
|
|
114
146
|
process.stdout.write(`${files.join("\n")}\n`);
|
|
115
147
|
}
|
|
116
148
|
}
|
|
149
|
+
return publication ?? { name: options.crate, version: cargoVersion, dryRun: true };
|
|
117
150
|
}
|
|
118
151
|
|
|
119
152
|
function walkFiles(root) {
|
|
@@ -136,21 +169,19 @@ function valueAfter(argv, flag) {
|
|
|
136
169
|
return index >= 0 ? argv[index + 1] : undefined;
|
|
137
170
|
}
|
|
138
171
|
|
|
139
|
-
function main() {
|
|
172
|
+
async function main() {
|
|
140
173
|
const options = parseCargoPublishArgs(process.argv.slice(2));
|
|
141
|
-
runCargoPublish(options);
|
|
174
|
+
const result = await runCargoPublish(options);
|
|
142
175
|
process.stdout.write(`[right-release] ${options.dryRun ? "validated" : "published"} ${options.crate}\n`);
|
|
143
176
|
if (!options.dryRun) {
|
|
144
|
-
|
|
145
|
-
if (version) recordPublished({ registry: "cargo", name: options.crate, version });
|
|
177
|
+
recordPublished({ registry: "cargo", name: result.name, version: result.version });
|
|
146
178
|
}
|
|
147
179
|
}
|
|
148
180
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
process.stderr.write(`right-release publish cargo: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
181
|
+
const realOrSelf = (file) => { try { return fs.realpathSync(file); } catch { return path.resolve(file); } };
|
|
182
|
+
if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
|
|
183
|
+
main().catch((error) => {
|
|
184
|
+
process.stderr.write(`right-release publish cargo: ${error.code ? `[${error.code}] ` : ""}${error.message}\n`);
|
|
154
185
|
process.exitCode = 1;
|
|
155
|
-
}
|
|
186
|
+
});
|
|
156
187
|
}
|