@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.
Files changed (81) hide show
  1. package/README.md +62 -0
  2. package/addon-command.mjs +53 -34
  3. package/apple-profile-renew.mjs +30 -12
  4. package/artifact-storage.mjs +47 -10
  5. package/background-process.mjs +18 -0
  6. package/build-release.mjs +1 -1
  7. package/cache-command.mjs +4 -3
  8. package/cargo-contract.mjs +1 -1
  9. package/cargo-guard.mjs +4 -3
  10. package/cargo-registry-readback.mjs +51 -0
  11. package/cargo-target.mjs +3 -1
  12. package/cli/right-release.mjs +26 -0
  13. package/codesign-environment.mjs +26 -2
  14. package/codesign-shim.mjs +2 -1
  15. package/committed-source.mjs +3 -3
  16. package/development.mjs +7 -7
  17. package/direct-bootstrap.mjs +19 -9
  18. package/dmg-build.mjs +117 -0
  19. package/dmg-layout.mjs +123 -0
  20. package/github-release.mjs +74 -37
  21. package/heavy-command.mjs +3 -2
  22. package/install-app.mjs +358 -0
  23. package/inventory-check.mjs +980 -0
  24. package/macos-signing-session.mjs +3 -0
  25. package/managed-cargo.mjs +23 -19
  26. package/managed-host-secrets.mjs +30 -0
  27. package/managed-roots.mjs +39 -0
  28. package/model-promote.mjs +31 -37
  29. package/notary-auth.mjs +4 -0
  30. package/osascript-guard.mjs +24 -0
  31. package/ownership-approval-command.mjs +15 -4
  32. package/ownership-approval.mjs +114 -49
  33. package/ownership-availability.mjs +25 -0
  34. package/ownership-catalog-config.json +18 -0
  35. package/ownership-catalog.mjs +94 -0
  36. package/ownership-command.mjs +17 -10
  37. package/ownership-declaration.json +15 -0
  38. package/ownership-default-policy.json +1 -11
  39. package/ownership-evidence.mjs +10 -8
  40. package/ownership-git-inventory.mjs +29 -1
  41. package/ownership-helper-paths.json +2 -2
  42. package/ownership-hooks.mjs +107 -0
  43. package/ownership-internal-owners.json +44 -0
  44. package/ownership-key-authority.mjs +112 -7
  45. package/ownership-key-command.mjs +76 -19
  46. package/ownership-key-trust.mjs +97 -9
  47. package/ownership-lockfiles.mjs +311 -0
  48. package/ownership-report-command.mjs +12 -3
  49. package/ownership-report.mjs +20 -17
  50. package/ownership-repositories.json +19 -0
  51. package/ownership-scan-worker.mjs +96 -11
  52. package/ownership-source.mjs +39 -0
  53. package/ownership-staged.mjs +193 -0
  54. package/ownership-verdict.mjs +74 -0
  55. package/ownership.mjs +215 -107
  56. package/package.json +1 -1
  57. package/protected-credentials.mjs +111 -0
  58. package/prune-r2.mjs +4 -3
  59. package/pub-upload.mjs +3 -85
  60. package/publication-artifacts.mjs +108 -0
  61. package/publication-gate.mjs +28 -7
  62. package/publish-cargo.mjs +47 -16
  63. package/publish-swift.mjs +46 -17
  64. package/publish-update.mjs +61 -39
  65. package/r2-verified-upload.mjs +41 -0
  66. package/registry-parity.mjs +5 -3
  67. package/release-state.mjs +1 -0
  68. package/release-token.mjs +2 -0
  69. package/rightkit-drift.mjs +3 -2
  70. package/rightkit-ownership.json +2028 -139
  71. package/rightkit-signing-agent.mjs +5 -3
  72. package/rightkit-sync.mjs +6 -2
  73. package/rightkit-versions.json +213 -103
  74. package/runtime-artifact-manifest.mjs +20 -6
  75. package/sign-updater.mjs +5 -0
  76. package/signing-agent-client.mjs +2 -1
  77. package/signing-agent-install.mjs +3 -2
  78. package/standalone-clone-verify.mjs +3 -2
  79. package/tauri-bundle-marker.mjs +3 -2
  80. package/upload-large.mjs +36 -82
  81. 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 { accessSync, statSync } from "node:fs";
3
- import path from "node:path";
4
- import { spawnSync } from "./background-process.mjs";
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
+ }
@@ -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
- return Object.freeze({ ok: missing.length === 0, missing });
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, the token is read from the macOS Keychain inside this process (never
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] published ${name}@${version}\n`);
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`); (deps.recordPublished ?? recordPublished)({ registry: "npm", name, version }); return; }
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
- if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
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({ label: "registry publish", command: "cargo", args: ["publish", "-p", crate] });
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 = spawnSync) {
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
- enforcePublicationGate({
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
- }, { dryRun: options.dryRun });
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.capture) {
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
- const version = fs.readFileSync(path.join(options.workspaceRoot, "crates", options.crate, "Cargo.toml"), "utf8").match(/^version\s*=\s*"([^"]+)"/m)?.[1];
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
- if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url))) {
150
- try {
151
- main();
152
- } catch (error) {
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
  }