@rightkit/release 0.2.134 → 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 (86) 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 +53 -12
  5. package/background-process.mjs +18 -0
  6. package/build-release.mjs +4 -3
  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-argv.mjs +64 -0
  14. package/codesign-environment.mjs +58 -0
  15. package/codesign-shim.mjs +51 -0
  16. package/committed-source.mjs +3 -3
  17. package/development.mjs +11 -9
  18. package/direct-bootstrap.mjs +19 -9
  19. package/dmg-build.mjs +117 -0
  20. package/dmg-layout.mjs +123 -0
  21. package/github-release.mjs +74 -37
  22. package/heavy-command.mjs +3 -2
  23. package/install-app.mjs +358 -0
  24. package/inventory-check.mjs +980 -0
  25. package/macos-notarization.mjs +24 -8
  26. package/macos-signing-session.mjs +50 -2
  27. package/managed-cargo.mjs +23 -19
  28. package/managed-host-secrets.mjs +30 -0
  29. package/managed-roots.mjs +39 -0
  30. package/model-promote.mjs +31 -37
  31. package/notarize-macos.mjs +5 -5
  32. package/notary-auth.mjs +4 -0
  33. package/osascript-guard.mjs +24 -0
  34. package/ownership-approval-command.mjs +15 -4
  35. package/ownership-approval.mjs +114 -49
  36. package/ownership-availability.mjs +25 -0
  37. package/ownership-catalog-config.json +18 -0
  38. package/ownership-catalog.mjs +94 -0
  39. package/ownership-command.mjs +17 -10
  40. package/ownership-declaration.json +15 -0
  41. package/ownership-default-policy.json +1 -11
  42. package/ownership-evidence.mjs +10 -8
  43. package/ownership-git-inventory.mjs +29 -1
  44. package/ownership-helper-paths.json +4 -4
  45. package/ownership-hooks.mjs +107 -0
  46. package/ownership-internal-owners.json +44 -0
  47. package/ownership-key-authority.mjs +112 -7
  48. package/ownership-key-command.mjs +76 -19
  49. package/ownership-key-trust.mjs +97 -9
  50. package/ownership-lockfiles.mjs +311 -0
  51. package/ownership-report-command.mjs +12 -3
  52. package/ownership-report.mjs +20 -17
  53. package/ownership-repositories.json +19 -0
  54. package/ownership-scan-worker.mjs +96 -11
  55. package/ownership-source.mjs +39 -0
  56. package/ownership-staged.mjs +193 -0
  57. package/ownership-verdict.mjs +74 -0
  58. package/ownership.mjs +215 -107
  59. package/package.json +1 -1
  60. package/protected-credentials.mjs +111 -0
  61. package/prune-r2.mjs +4 -3
  62. package/pub-upload.mjs +3 -85
  63. package/publication-artifacts.mjs +108 -0
  64. package/publication-gate.mjs +28 -7
  65. package/publish-cargo.mjs +47 -16
  66. package/publish-swift.mjs +46 -17
  67. package/publish-update.mjs +61 -39
  68. package/r2-verified-upload.mjs +41 -0
  69. package/registry-parity.mjs +5 -3
  70. package/release-state.mjs +1 -0
  71. package/release-token.mjs +2 -0
  72. package/release.mjs +2 -1
  73. package/rightkit-drift.mjs +3 -2
  74. package/rightkit-ownership.json +2028 -139
  75. package/rightkit-signing-agent.mjs +161 -19
  76. package/rightkit-sync.mjs +6 -2
  77. package/rightkit-versions.json +220 -106
  78. package/runtime-artifact-manifest.mjs +20 -6
  79. package/sign-updater.mjs +5 -0
  80. package/signing-agent-client.mjs +78 -22
  81. package/signing-agent-command.mjs +5 -4
  82. package/signing-agent-install.mjs +31 -4
  83. package/standalone-clone-verify.mjs +3 -2
  84. package/tauri-bundle-marker.mjs +3 -2
  85. package/upload-large.mjs +36 -82
  86. package/verifier.mjs +5 -1
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # @rightkit/release
2
+
3
+ Portable release CLI and SDK for Right Suite products: builds, signs, and publishes to public GitHub Releases or to private R2 with RightApps distribution. It also runs the ownership catalog and lockfile checks and the release gates.
4
+
5
+ | Item | Value |
6
+ |---|---|
7
+ | Where it runs | Build and release tooling. Native signing runs on the build host. |
8
+ | Rust pair | None. The Rust build broker files in this package (`managed-cargo.mjs`, `managed-roots.mjs`) are moving to the separate RustRight product. Do not extend them here. |
9
+ | Version | see INDEX.md |
10
+ | License | MIT OR Apache-2.0 |
11
+
12
+ Signing and publication rules are in the workspace file `docs/rules/release-signing.md`. Public releases use RightKit CI. Private builds run on the native host.
13
+
14
+ ## Commands
15
+
16
+ `right-release` maps these subcommands to the modules in this package:
17
+
18
+ | Group | Subcommands |
19
+ |---|---|
20
+ | Build | `release`, `build`, `package` |
21
+ | Install | `install-app` |
22
+ | Publish | `publish`, `upload`, `publish-update`, `github`, `mirror-root-artifact` |
23
+ | Signing | `sign-windows`, `sign-updater`, `signing-agent`, `create-mac-updater` |
24
+ | Checks | `deps`, `hardening`, `harden`, `hardeningscan`, `cargo-target` |
25
+ | Ownership | `ownership`, `ownership-approval`, `ownership-report` |
26
+
27
+ ## Local app install (macOS)
28
+
29
+ Agents must not run `osascript`, `open`, `screencapture`, or `notifyutil -p` directly. Install a signed build through:
30
+
31
+ ```sh
32
+ right-release install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id dev.orthic.pulse] [--dest /Applications] [--relaunch|--no-relaunch] [--json]
33
+ ```
34
+
35
+ It mounts the image read-only and hidden (`-nobrowse`), verifies the app (`codesign --verify --deep --strict`, `spctl --assess`, `stapler validate`, bundle id, and Team ID against the installed copy; a Team ID change is refused) before touching the installed copy, quits the running instance with SIGTERM (15 s, then SIGKILL), moves the old bundle to `~/.Trash/<App> <timestamp>.app`, copies the new one with `ditto` (restoring the old bundle on failure), re-verifies, and relaunches only if it was running or `--relaunch` is given, using `open -g -j -b` (background, hidden, never activated). `--json` emits one lifecycle event per line (`start`, `verified`, `quit`, `replaced`, `relaunched`, `done`/`failed`). Win32 exits with "not implemented on win32". Exit codes: 0 ok, 1 failed, 2 usage, 3 unsupported platform.
36
+
37
+ ## Entry points
38
+
39
+ Bin: `right-release` (`cli/right-release.mjs`). The package has no `exports` map. The CLI is the supported surface.
40
+
41
+ ## Dependencies
42
+
43
+ | Dependency | Range | Note |
44
+ |---|---|---|
45
+ | `smol-toml` | `1.7.0` (exact) | Used to parse lock and manifest files |
46
+
47
+ Peer dependencies: none.
48
+
49
+ ## Example
50
+
51
+ Illustrative. Dependency checks, as described in the shared-packages doc:
52
+
53
+ ```sh
54
+ pnpm add -D @rightkit/release
55
+ pnpm exec right-release deps --check
56
+ ```
57
+
58
+ ## Known issues
59
+
60
+ - `smol-toml` is pinned exactly. A consumer that adds another `smol-toml` gets a second copy (npm audit 2026-10-08, release finding).
61
+ - Eleven apps pin this package at different versions, from 0.2.109 to 0.2.135 (npm audit 2026-10-08).
62
+ - Prepublish runs the test suite, the Windows signer integration test and the registry parity check.
package/addon-command.mjs CHANGED
@@ -7,28 +7,35 @@ import { spawnSync } from "./background-process.mjs";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  import { addonManifestSha256, addonRoutes, canonicalAddonManifest, createAddonManifest, downloadImmutableAddOn, immutableAddonManifestUrl, validateAddonConfig, validateAddonManifest } from "./addon-contract.mjs";
9
9
  import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
10
+ import { readVerifiedArtifact } from "./publication-artifacts.mjs";
11
+ import { resolveCloudflareApiToken } from "./protected-credentials.mjs";
12
+ import { uploadVerifiedR2Artifact } from "./r2-verified-upload.mjs";
10
13
  import { assertCleanSource } from "./source-gate.mjs";
11
14
 
12
15
  const ROOT = path.dirname(fileURLToPath(import.meta.url));
13
16
  const SIGN_WINDOWS = path.join(ROOT, "sign-windows.mjs");
14
17
  const HARDEN = path.join(ROOT, "hardeningscan.mjs");
15
- const [operation, ...args] = process.argv.slice(2);
16
- const options = parse(args);
17
- if (!new Set(["doctor", "build", "upload", "adopt"]).has(operation)) usage(2);
18
- if (!new Set(["mac", "win"]).has(options.platform)) fail("--platform must be mac or win");
18
+ async function main() {
19
+ const [operation, ...args] = process.argv.slice(2);
20
+ const options = parse(args);
21
+ if (!new Set(["doctor", "build", "upload", "adopt"]).has(operation)) usage(2);
22
+ if (!new Set(["mac", "win"]).has(options.platform)) fail("--platform must be mac or win");
23
+
24
+ if (operation === "adopt") await adopt(options);
25
+ else {
26
+ if (!options.config) fail("--config is required");
27
+ const configPath = path.resolve(options.config);
28
+ const root = path.dirname(configPath);
29
+ const repoRoot = git(root, ["rev-parse", "--show-toplevel"]);
30
+ assertPrimaryReleaseCheckout(repoRoot);
31
+ const config = (await import(`${pathToFileURL(configPath).href}?addon=${Date.now()}`)).default;
32
+ if (operation === "doctor") doctor({ config, configPath, root, repoRoot, options });
33
+ else if (operation === "build") await build({ config, configPath, root, repoRoot, options });
34
+ else await upload({ config, root, repoRoot, options });
35
+ }
19
36
 
20
- if (operation === "adopt") await adopt(options);
21
- else {
22
- if (!options.config) fail("--config is required");
23
- const configPath = path.resolve(options.config);
24
- const root = path.dirname(configPath);
25
- const repoRoot = git(root, ["rev-parse", "--show-toplevel"]);
26
- assertPrimaryReleaseCheckout(repoRoot);
27
- const config = (await import(`${pathToFileURL(configPath).href}?addon=${Date.now()}`)).default;
28
- if (operation === "doctor") doctor({ config, configPath, root, repoRoot, options });
29
- else if (operation === "build") await build({ config, configPath, root, repoRoot, options });
30
- else await upload({ config, root, repoRoot, options });
31
37
  }
38
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
32
39
 
33
40
  function doctor({ config, configPath, root, repoRoot, options }) {
34
41
  validateAddonConfig(config, { root, platform: options.platform, allowMissingExecutables: true });
@@ -84,33 +91,45 @@ async function upload({ config, root, repoRoot, options }) {
84
91
  if (!new Set(["patch", "update"]).has(options.tier)) fail("add-on upload requires --tier patch|update");
85
92
  const commit = git(repoRoot, ["rev-parse", "HEAD"]);
86
93
  const sealed = path.join(repoRoot, ".right-release", "addons", config.addon, config.version, commit.slice(0, 8), options.platform);
87
- const manifest = JSON.parse(readFileSync(path.join(sealed, "addon-manifest.json"), "utf8"));
88
- validateAddonManifest(manifest);
89
- const routes = addonRoutes(manifest);
94
+ const routes = publishR2Addon({ manifestPath: path.join(sealed, "addon-manifest.json"), repoRoot, config, commit, platform: options.platform, dryRun: options.dryRun });
90
95
  if (options.dryRun) {
91
96
  for (const route of routes) console.log(`would upload ${route.key}${route.manifest ? " (last)" : ""}`);
92
97
  return;
93
98
  }
94
- if (!process.env.CLOUDFLARE_API_TOKEN) fail("CLOUDFLARE_API_TOKEN is required before R2 mutation");
95
- for (const route of routes) {
96
- const source = route.manifest ? path.join(sealed, "addon-manifest.json") : path.join(sealed, route.name);
97
- await putImmutable({ source, route, repoRoot });
98
- }
99
99
  console.log(`right-release addon upload: verified ${config.addon} ${options.platform} tier=${options.tier}`);
100
100
  }
101
101
 
102
- async function putImmutable({ source, route, repoRoot }) {
102
+ export function publishR2Addon({ manifestPath, repoRoot, config, commit, platform, dryRun = false }, { run = spawnSync, env = process.env, credentialStore } = {}) {
103
+ // Credential resolution precedes any read or mutation; dry runs only enforce managed-host refusal.
104
+ const token = resolveCloudflareApiToken({ env, credentialStore, required: !dryRun });
105
+ const manifest = validateAddonManifest(JSON.parse(readFileSync(manifestPath, "utf8")));
106
+ if (manifest.addon !== config.addon || manifest.version !== config.version || manifest.platform !== platform || manifest.commit !== commit) throw new Error("sealed add-on identity mismatch");
107
+ const routes = addonRoutes(manifest);
108
+ const artifacts = new Map(routes.map((route) => {
109
+ const source = path.join(path.dirname(manifestPath), route.name);
110
+ const entry = manifest.files.find((file) => file.name === route.name);
111
+ return [route.key, readVerifiedArtifact(source, { sha256: route.sha256, sizeBytes: entry?.sizeBytes })];
112
+ }));
113
+ if (dryRun) return routes;
114
+ // The token reaches only the Wrangler child environment, never the caller's env object.
115
+ const uploadEnv = { ...env, CLOUDFLARE_API_TOKEN: token };
116
+ for (const route of routes) putImmutable({ artifact: artifacts.get(route.key), route, repoRoot, run, env: uploadEnv });
117
+ return routes;
118
+ }
119
+
120
+ function putImmutable({ artifact, route, repoRoot, run, env }) {
103
121
  const target = `rightapps-downloads/${route.key}`;
104
- const temp = path.join(os.tmpdir(), `right-addon-r2-${process.pid}-${createHash("sha256").update(route.key).digest("hex").slice(0, 12)}`);
105
- const existing = wrangler(["r2", "object", "get", target, "--file", temp, "--remote"], repoRoot);
106
- if (existing.status === 0) {
107
- const actual = sha256(temp); rmSync(temp, { force: true });
108
- if (actual !== route.sha256) fail(`immutable object exists with different bytes: ${route.key}`);
109
- return;
110
- }
111
- if (!/not found|does not exist|404/i.test(`${existing.stdout}\n${existing.stderr}`)) fail(`cannot inspect immutable object: ${route.key}`);
112
- const uploaded = wrangler(["r2", "object", "put", target, "--file", source, "--remote"], repoRoot);
113
- if (uploaded.status !== 0) fail(`immutable upload failed: ${route.key}`);
122
+ const temporary = mkdtempSync(path.join(os.tmpdir(), "right-addon-r2-"));
123
+ const temp = path.join(temporary, "object");
124
+ try {
125
+ const existing = run("pnpm", ["dlx", "wrangler@4", "r2", "object", "get", target, "--file", temp, "--remote"], { cwd: repoRoot, env, encoding: "utf8", windowsHide: true, shell: process.platform === "win32" });
126
+ if (existing.status === 0) {
127
+ if (sha256(temp) !== route.sha256) throw new Error(`immutable object exists with different bytes; remove remote key ${target}`);
128
+ return;
129
+ }
130
+ if (!/not found|does not exist|404/i.test(`${existing.stdout}\n${existing.stderr}`)) throw new Error(`cannot inspect immutable object: ${route.key}`);
131
+ uploadVerifiedR2Artifact(artifact, route.key, "public", { cwd: repoRoot, run, env });
132
+ } finally { rmSync(temporary, { recursive: true, force: true }); }
114
133
  }
115
134
 
116
135
  async function adopt(options) {
@@ -1,9 +1,10 @@
1
1
  import { createHash, createPrivateKey, randomUUID, sign, X509Certificate } from "node:crypto";
2
- import { constants, copyFileSync, existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { constants, copyFileSync, existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { pathToFileURL } from "node:url";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { spawnSync } from "./background-process.mjs";
6
- import { notarytoolAuthArgs } from "./notary-auth.mjs";
6
+ import { isManagedHost, refuseManagedEnvSecret } from "./managed-host-secrets.mjs";
7
+ import os from "node:os";
7
8
 
8
9
  const ASC = "https://api.appstoreconnect.apple.com";
9
10
  export class AppleProfileRenewError extends Error {
@@ -14,13 +15,29 @@ const sha1 = (bytes) => createHash("sha1").update(bytes).digest("hex").toUpperCa
14
15
  const serial = (value) => String(value ?? "").replace(/[^a-f0-9]/gi, "").replace(/^0+/, "").toUpperCase();
15
16
  const query = (endpoint, params) => `${endpoint}?${new URLSearchParams(params)}`;
16
17
 
17
- export function appleProfileAuth(env = process.env) {
18
- if (!env.APPLE_API_ISSUER?.trim()) fail("APPLE_PROFILE_ISSUER_MISSING", "Set APPLE_API_ISSUER using existing notarization environment.");
19
- if (!env.APPLE_API_KEY?.trim() || !env.APPLE_API_KEY_PATH?.trim()) fail("APPLE_PROFILE_KEY_MISSING", "Set APPLE_API_KEY & APPLE_API_KEY_PATH using existing notarization environment.");
20
- const args = notarytoolAuthArgs({ env });
21
- const auth = { keyPath: args[1], keyId: args[3], issuer: args[5] };
22
- if (!existsSync(auth.keyPath) || !statSync(auth.keyPath).isFile()) fail("APPLE_PROFILE_KEY_MISSING", "Configured App Store Connect API key file is missing.");
23
- return auth;
18
+ const PEM_MARKER = "-----BEGIN";
19
+ const trimmed = (value) => String(value ?? "").trim();
20
+ const expandHome = (value) => value === "~" ? os.homedir() : value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value;
21
+
22
+ // Key ID and issuer are identifiers: the release config is read first, then the
23
+ // APPLE_API_KEY / APPLE_API_ISSUER environment on every host. The .p8 is a file:
24
+ // the config path is read first, then APPLE_API_KEY_PATH. No Keychain entry is
25
+ // needed. Managed hosts refuse any Apple environment value that carries inline
26
+ // PEM key material, because key material must never ride in the environment.
27
+ export function appleProfileAuth(env = process.env, { config = {}, root = process.cwd() } = {}) {
28
+ for (const name of ["APPLE_API_KEY_PATH", "APPLE_API_KEY", "APPLE_API_ISSUER"]) {
29
+ if (isManagedHost(env) && trimmed(env[name]).includes(PEM_MARKER)) refuseManagedEnvSecret(name);
30
+ }
31
+ const asc = config.appStoreConnect ?? {};
32
+ const keyId = trimmed(asc.keyId) || trimmed(env.APPLE_API_KEY);
33
+ const issuer = trimmed(asc.issuer) || trimmed(env.APPLE_API_ISSUER);
34
+ if (!issuer) fail("APPLE_PROFILE_ISSUER_MISSING", "Set appStoreConnect.issuer in the release config or APPLE_API_ISSUER.");
35
+ if (!keyId) fail("APPLE_PROFILE_KEY_MISSING", "Set appStoreConnect.keyId in the release config or APPLE_API_KEY.");
36
+ const configuredPath = trimmed(asc.keyPath);
37
+ const keyPath = configuredPath ? path.resolve(root, expandHome(configuredPath)) : expandHome(trimmed(env.APPLE_API_KEY_PATH));
38
+ if (!keyPath) fail("APPLE_PROFILE_KEY_MISSING", "Set appStoreConnect.keyPath in the release config or APPLE_API_KEY_PATH.");
39
+ if (!existsSync(keyPath) || !statSync(keyPath).isFile()) fail("APPLE_PROFILE_KEY_MISSING", "Configured App Store Connect API key file is missing.");
40
+ return { keyPath, keyId, issuer };
24
41
  }
25
42
 
26
43
  // Only this in-process signer reads key material. Never send it to a subprocess,
@@ -124,7 +141,7 @@ export async function renewAppleProfile({ root, config = {}, profilePath, dryRun
124
141
  const previous = decodeAppleProfile(file, { run });
125
142
  const info = describeAppleProfile(previous);
126
143
  const certificate = findAppleSigningCertificate(info, { env, run, now });
127
- const auth = appleProfileAuth(env);
144
+ const auth = appleProfileAuth(env, { config, root });
128
145
  const token = await jwtSigner(auth, now);
129
146
  const calls = [];
130
147
  const request = async (method, endpoint, body) => {
@@ -215,6 +232,7 @@ export async function main(args = process.argv.slice(2), options = {}) {
215
232
  return renewAppleProfile({ ...options, root: path.dirname(absolute), config: loaded.default ?? loaded.config ?? loaded, profilePath, dryRun });
216
233
  }
217
234
 
218
- if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
235
+ const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
236
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
219
237
  main().catch((error) => { console.error(`right-release apple profile renew: [${error.code ?? "APPLE_PROFILE_FAILED"}] ${error instanceof AppleProfileRenewError ? error.message : "Renewal failed; check app configuration & local file access."}`); process.exitCode = 1; });
220
238
  }
@@ -1,3 +1,4 @@
1
+ import { resolveManagedRoots } from './managed-roots.mjs';
1
2
  import {
2
3
  existsSync,
3
4
  copyFileSync,
@@ -11,17 +12,20 @@ import {
11
12
  writeFileSync,
12
13
  } from "node:fs";
13
14
  import path from "node:path";
15
+ import os from "node:os";
16
+ import { spawnSync } from "./background-process.mjs";
14
17
 
15
18
  export const ARTIFACT_MARKER_SCHEMA = 1;
16
- export const DEFAULT_MANAGED_ROOT = process.platform === "win32"
17
- ? "D:\\.rightkit-managed"
18
- : "/Volumes/D/.rightkit-managed";
19
+ export const DEFAULT_MANAGED_ROOT = resolveManagedRoots().dataRoot;
19
20
 
20
21
  const SAFE_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/;
21
22
 
22
- export function resolveManagedRoot({ env = process.env } = {}) {
23
- const value = env.RIGHTKIT_MANAGED_ROOT || DEFAULT_MANAGED_ROOT;
24
- return path.resolve(value);
23
+ export function resolveManagedRoot({ env = process.env, exists = existsSync } = {}) {
24
+ if (env.RIGHTKIT_DATA_ROOT || env.RIGHTKIT_MANAGED_ROOT || env.RIGHTKIT_EXTERNAL_ROOT) return path.resolve(resolveManagedRoots({ env }).dataRoot);
25
+ // Workstation default only when its drive exists; hosted CI runners fall back to RUNNER_TEMP, then the system temp dir.
26
+ const volume = process.platform === "win32" ? "D:\\" : "/Volumes/D";
27
+ if (exists(volume)) return path.resolve(DEFAULT_MANAGED_ROOT);
28
+ return path.join(env.RUNNER_TEMP && exists(env.RUNNER_TEMP) ? env.RUNNER_TEMP : os.tmpdir(), "rightkit-managed");
25
29
  }
26
30
 
27
31
  export function resolveArtifactRoot({ app, managedRoot } = {}) {
@@ -55,8 +59,11 @@ export function publishedPointerPath({ app, managedRoot } = {}) {
55
59
  * Create or refresh the lease marker before a build writes managed artifacts.
56
60
  * Marker fields intentionally remain the small cross-process contract.
57
61
  */
58
- export function beginArtifactLease({ app, version, managedRoot, now = Date.now(), ownerPid = process.pid, leaseMs = 2 * 60 * 60 * 1000 } = {}) {
59
- if (!Number.isFinite(now) || !Number.isSafeInteger(ownerPid) || ownerPid < 1 || !Number.isFinite(leaseMs) || leaseMs <= 0) {
62
+ export function beginArtifactLease({ app, version, managedRoot, now = Date.now(), ownerPid = process.pid,
63
+ ownerHost = os.hostname(), processIdentity = artifactProcessIdentity,
64
+ log = (event) => console.error(JSON.stringify(event)), leaseMs = 2 * 60 * 60 * 1000 } = {}) {
65
+ if (!Number.isFinite(now) || !Number.isSafeInteger(ownerPid) || ownerPid < 1 || !Number.isFinite(leaseMs) || leaseMs <= 0
66
+ || typeof ownerHost !== "string" || !ownerHost) {
60
67
  throw new TypeError("artifact lease requires finite now/leaseMs and positive ownerPid");
61
68
  }
62
69
  const root = resolveArtifactRoot({ app, managedRoot });
@@ -68,14 +75,24 @@ export function beginArtifactLease({ app, version, managedRoot, now = Date.now()
68
75
  ensureDirectory(directory);
69
76
  const file = markerPath({ app, version, managedRoot });
70
77
  assertSafeMutation(file);
78
+ const identity = processIdentity(ownerPid);
79
+ let reclaimed;
71
80
  if (existsSync(file)) {
72
81
  const previous = readArtifactMarker(file);
73
- if (isActiveLease(previous, now) && previous.ownerPid !== ownerPid) {
82
+ const state = leaseOwnerState(previous, { host: ownerHost, processIdentity });
83
+ const sameOwner = previous.ownerPid === ownerPid && (!previous.ownerHost || previous.ownerHost === ownerHost)
84
+ && (!previous.ownerStartTime || !identity.startTime || previous.ownerStartTime === identity.startTime);
85
+ if (state !== "dead-pid" && state !== "pid-reused" && isActiveLease(previous, now, { host: ownerHost, processIdentity }) && !sameOwner) {
74
86
  throw new Error(`artifact lease is held by PID ${previous.ownerPid}: ${app}@${version}`);
75
87
  }
88
+ if (state === "dead-pid" || state === "pid-reused") reclaimed = { event: "artifact-lease-reclaimed", app, version,
89
+ reason: state, ownerPid: previous.ownerPid, ownerHost: previous.ownerHost ?? ownerHost,
90
+ ownerStartTime: previous.ownerStartTime ?? null, newOwnerPid: ownerPid, timestamp: now };
76
91
  }
77
- const marker = { schemaVersion: ARTIFACT_MARKER_SCHEMA, app, version, createdAt: now, ownerPid, leaseUntil: now + leaseMs };
92
+ const marker = { schemaVersion: ARTIFACT_MARKER_SCHEMA, app, version, createdAt: now, ownerPid,
93
+ ownerHost, ownerStartTime: identity.startTime ?? null, leaseUntil: now + leaseMs };
78
94
  writeJson(file, marker);
95
+ if (reclaimed) log(reclaimed);
79
96
  return marker;
80
97
  } finally {
81
98
  lock.release();
@@ -108,6 +125,8 @@ export function readArtifactMarker(file) {
108
125
  if (!Number.isFinite(marker[key])) throw new Error(`artifact marker ${key} must be numeric: ${file}`);
109
126
  }
110
127
  if (marker.completedAt != null && !Number.isFinite(marker.completedAt)) throw new Error(`artifact marker completedAt must be numeric: ${file}`);
128
+ if (marker.ownerHost != null && (typeof marker.ownerHost !== "string" || !marker.ownerHost)) throw new Error(`artifact marker ownerHost must be a nonempty string: ${file}`);
129
+ if (marker.ownerStartTime != null && (typeof marker.ownerStartTime !== "string" || !marker.ownerStartTime)) throw new Error(`artifact marker ownerStartTime must be a nonempty string: ${file}`);
111
130
  return marker;
112
131
  }
113
132
 
@@ -312,11 +331,33 @@ function safeRemove(target) {
312
331
  rmSync(target, { recursive: true, force: true });
313
332
  }
314
333
 
315
- function isActiveLease(marker, now) {
316
- if (Number(marker.ownerPid) > 0 && processAlive(marker.ownerPid)) return true;
334
+ function leaseOwnerState(marker, { host = os.hostname(), processIdentity = artifactProcessIdentity } = {}) {
335
+ if (Number(marker.ownerPid) <= 0 || (marker.ownerHost && marker.ownerHost !== host)) return "unknown";
336
+ const identity = processIdentity(marker.ownerPid);
337
+ if (!identity.alive) return "dead-pid";
338
+ if (marker.ownerStartTime && identity.startTime && marker.ownerStartTime !== identity.startTime) return "pid-reused";
339
+ return "live";
340
+ }
341
+
342
+ function isActiveLease(marker, now, options) {
343
+ const state = leaseOwnerState(marker, options);
344
+ if (state === "live") return true;
345
+ if (state === "dead-pid" || state === "pid-reused") return false;
317
346
  return Number(marker.leaseUntil) > Number(now);
318
347
  }
319
348
 
349
+ /** Start identity is queried on the owning host; denied inspection stays conservative. */
350
+ export function artifactProcessIdentity(pid, { platform = process.platform, run = spawnSync, alive = processAlive } = {}) {
351
+ if (!alive(pid)) return { alive: false, startTime: null };
352
+ const result = platform === "win32"
353
+ ? run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command",
354
+ `(Get-Process -Id ${Number(pid)} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`], { encoding: "utf8", timeout: 3000, shell: false })
355
+ : run("/bin/ps", ["-p", String(pid), "-o", "lstart="], {
356
+ encoding: "utf8", timeout: 1000, shell: false, env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
357
+ });
358
+ return { alive: true, startTime: result.status === 0 && result.stdout?.trim() || null };
359
+ }
360
+
320
361
  function acquireRetentionLock(root, now) {
321
362
  ensureDirectory(root);
322
363
  const directory = path.join(root, ".retention-lock");
@@ -1,6 +1,16 @@
1
1
  import childProcess from "node:child_process";
2
2
  import { promisify } from "node:util";
3
3
 
4
+ // Windows PowerShell's inbox ACL cmdlets can fail while importing legacy
5
+ // module type data when inherited PSModulePath entries are present.
6
+ export function windowsPowerShellEnvironment(overrides = {}, base = process.env) {
7
+ const environment = { ...base, ...overrides };
8
+ for (const key of Object.keys(environment)) {
9
+ if (key.toLowerCase() === "psmodulepath") delete environment[key];
10
+ }
11
+ return environment;
12
+ }
13
+
4
14
  // Release tooling has no interactive console children. Keep this policy at
5
15
  // every Node launch boundary, including fixtures & nested release commands.
6
16
  function hiddenArguments(args, options) {
@@ -17,6 +27,14 @@ export function spawnSync(command, args, options) {
17
27
  return childProcess.spawnSync(command, ...hiddenArguments(args, options));
18
28
  }
19
29
 
30
+ // Native ownership ceremonies must be able to show their Windows Hello UI.
31
+ // Keep this exception explicit so ordinary release subprocesses stay hidden.
32
+ export function spawnSyncInteractive(command, args, options) {
33
+ return childProcess.spawnSync(command, ...(Array.isArray(args)
34
+ ? [args, { ...options, shell: false, windowsHide: false }]
35
+ : [[], { ...(args ?? options), shell: false, windowsHide: false }]));
36
+ }
37
+
20
38
  export function execFileSync(file, args, options) {
21
39
  return childProcess.execFileSync(file, ...hiddenArguments(args, options));
22
40
  }
package/build-release.mjs CHANGED
@@ -39,13 +39,14 @@ import { acquireHeavyWorkSlot, heavyCommandEnvironment, summarizePreBrokerWaits,
39
39
  import { processGroupCpuSeconds } from "./process-liveness.mjs";
40
40
  import { cancelAndReapManagedRequest, firstManagedRequestId, releaseProgressLimits } from "./progress-control.mjs";
41
41
  import { beginArtifactLease, completeArtifactLease, materializeArtifacts, retainArtifacts, resolveArtifactDir, resolveManagedRoot } from "./artifact-storage.mjs";
42
+ import { macosCodesignEnvironment } from "./codesign-environment.mjs";
42
43
 
43
44
  // Fingerprint of the pipeline code that can change the bytes we ship or the way
44
45
  // they are signed — deliberately NOT the package version, which moves for docs
45
46
  // and test-only edits and would force a cold Rust rebuild of every app each time.
46
47
  // Cached target directories must not outlive changes to these signing helpers.
47
48
 
48
- const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "sign-macos.mjs", "macos-signing-session.mjs", "notarize-macos.mjs", "macos-notarization.mjs", "signing-receipts.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs"];
49
+ const PIPELINE_FINGERPRINT_SOURCES = ["release.mjs", "sign-windows.mjs", "sign-macos.mjs", "macos-signing-session.mjs", "codesign-environment.mjs", "codesign-shim.mjs", "codesign-argv.mjs", "osascript-guard.mjs", "dmg-build.mjs", "dmg-layout.mjs", "rightkit-signing-agent.mjs", "notarize-macos.mjs", "macos-notarization.mjs", "signing-receipts.mjs", "tauri-bundle-marker.mjs", "nsis-payload.mjs"];
49
50
  const PIPELINE_FINGERPRINT = createHash("sha256")
50
51
  .update(PIPELINE_FINGERPRINT_SOURCES.map((file) => `${file}:${createHash("sha256").update(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), file))).digest("hex")}`).join("\n"))
51
52
  .digest("hex")
@@ -251,7 +252,7 @@ try {
251
252
  // resolves to MSYS Perl, which cannot configure OpenSSL. Put the real Windows
252
253
  // Perl first so the build behaves the same from any shell.
253
254
  const pathPrefix = buildPathPrefix({ platform });
254
- const env = {
255
+ const env = macosCodesignEnvironment({
255
256
  ...heavyCommandEnvironment(process.env),
256
257
  ...releaseEnvironment({ root: vaultRoot, cacheRoot: sharedCacheRoot, platform, architecture: cacheIdentity.architecture, app: config.app, cacheKey, kind: "release", appRoot, mode: cacheMode }),
257
258
  ...(pathPrefix ? { PATH: `${pathPrefix}${path.delimiter}${process.env.PATH ?? ""}` } : {}),
@@ -259,7 +260,7 @@ try {
259
260
  RIGHT_RELEASE_APP_ROOT: layout.appRoot,
260
261
  RIGHTKIT_MANAGED_ROOT: resolveManagedRoot({ env: process.env }),
261
262
  RIGHT_RELEASE_ARTIFACT_DIR: resolveArtifactDir({ app: config.app, version: config.version, managedRoot: resolveManagedRoot({ env: process.env }) }),
262
- };
263
+ });
263
264
  if (managedCargoTarget) {
264
265
  for (const name of ["CARGO_HOME", "CARGO_TARGET_DIR", "RUSTC_WRAPPER", "SCCACHE_DIR", "SCCACHE_BASEDIRS", "SCCACHE_CACHE_SIZE", "RUSTFLAGS"]) {
265
266
  delete env[name];
package/cache-command.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { pathToFileURL } from "node:url";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import {
6
6
  applyCachePrune,
7
7
  inspectCache,
@@ -87,6 +87,7 @@ function rejectUnknown(args, allowed) { for (const arg of args) if (!allowed.has
87
87
  function platformFrom(env) { return env.RIGHT_RELEASE_PLATFORM || (process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform); }
88
88
  function findRepoRoot(start) { let current = path.resolve(start); for (;;) { if (existsSync(path.join(current, ".git"))) return current; const parent = path.dirname(current); if (parent === current) throw new Error(`cache migrate could not find Git repository for ${start}`); current = parent; } }
89
89
 
90
- if (import.meta.url === pathToFileURL(process.argv[1]).href) {
90
+ const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
91
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
91
92
  runCacheCommand().catch((error) => { console.error(error.message); process.exitCode = 2; });
92
93
  }
@@ -21,7 +21,7 @@ export function managedCargoShimOnPath(env = process.env, platform = process.pla
21
21
  for (const extension of extensions) {
22
22
  const candidate = pathApi.join(directory, `cargo${extension.toLowerCase()}`);
23
23
  if (!exists(candidate)) continue;
24
- return /(?:^|\/)(?:\.rightkit-managed|rightkitmanagedagent)\/agent-bin\/cargo(?:\.[^/]*)?$/i
24
+ return /(?:^|\/)(?:\.rightkit-managed|rightkitmanagedagent|rightkit\/build-control)\/agent-bin\/cargo(?:\.[^/]*)?$/i
25
25
  .test(candidate.replaceAll("\\", "/"));
26
26
  }
27
27
  }
package/cargo-guard.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from "./background-process.mjs";
3
3
  import { createHash } from "node:crypto";
4
- import { existsSync, readFileSync } from "node:fs";
4
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
@@ -136,7 +136,7 @@ export function resolveRealRustc({ env = process.env, run = spawnSync } = {}) {
136
136
  export function resolveManagedCargoShim({ env = process.env, platform = process.platform, exists = existsSync } = {}) {
137
137
  const win = platform === "win32" || platform === "win";
138
138
  const delimiter = win ? ";" : ":";
139
- const agentBin = /(?:^|[\\/])(?:\.rightkit-managed|rightkitmanagedagent)[\\/]agent-bin[\\/]?$/i;
139
+ const agentBin = /(?:^|[\\/])(?:\.rightkit-managed|rightkitmanagedagent|rightkit[\\/]build-control)[\\/]agent-bin[\\/]?$/i;
140
140
  for (const directory of String(env.PATH ?? "").split(delimiter).filter(Boolean)) {
141
141
  if (!agentBin.test(directory)) continue;
142
142
  for (const name of win ? ["cargo.exe", "cargo.cmd"] : ["cargo"]) {
@@ -254,7 +254,8 @@ export async function runCargoGuard(args, { env = process.env, policyOptions = {
254
254
  return runLight(cargo, args, env);
255
255
  }
256
256
 
257
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
257
+ const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
258
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
258
259
  const args = process.argv.slice(2);
259
260
  if (args[0] === "--check-hook-base64") {
260
261
  try { checkHookInput(Buffer.from(args[1] ?? "", "base64url").toString("utf8")); }
@@ -0,0 +1,51 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+ import { PublicationError } from "./publication-artifacts.mjs";
3
+
4
+ // https://doc.rust-lang.org/cargo/reference/registry-index.html#index-files
5
+ export function cargoIndexUrl(name) {
6
+ if (!/^[a-z0-9_-]+$/i.test(name)) throw new Error(`invalid crate name: ${name}`);
7
+ const lower = name.toLowerCase();
8
+ const prefix = lower.length === 1 ? "1" : lower.length === 2 ? "2"
9
+ : lower.length === 3 ? `3/${lower[0]}` : `${lower.slice(0, 2)}/${lower.slice(2, 4)}`;
10
+ return `https://index.crates.io/${prefix}/${lower}`;
11
+ }
12
+
13
+ export async function verifyCargoRegistry({ name, version, sha256 }, deps = {}) {
14
+ const fetchImpl = deps.fetch ?? fetch;
15
+ const wait = deps.sleep ?? sleep;
16
+ const now = deps.now ?? (() => performance.now());
17
+ const timeoutMs = deps.timeoutMs ?? 300_000;
18
+ const deadline = now() + timeoutMs;
19
+ const url = cargoIndexUrl(name);
20
+ let delayMs = 1_000;
21
+ while (now() < deadline) {
22
+ let entry;
23
+ try {
24
+ const response = await fetchImpl(url, {
25
+ headers: { Accept: "text/plain", "Cache-Control": "no-cache", "User-Agent": "rightkit-release" },
26
+ signal: AbortSignal.timeout(Math.max(1, Math.ceil(Math.min(30_000, deadline - now())))), redirect: "error",
27
+ });
28
+ if (response.ok) {
29
+ entry = (await response.text()).split(/\r?\n/).filter((line) => line.trim())
30
+ .map((line) => JSON.parse(line)).find((record) => record.name === name && record.vers === version);
31
+ }
32
+ } catch {
33
+ // Index propagation, HTTP errors & transient transport failures share this bounded retry.
34
+ }
35
+ if (entry) {
36
+ if (entry.cksum !== sha256) {
37
+ const error = new PublicationError("PUBLICATION_REGISTRY_MISMATCH",
38
+ `${name}@${version}: verified sha256 ${sha256}, registry sha256 ${entry.cksum ?? "<missing>"}. Operator must yank this version with cargo yank --vers ${version} ${name}; no automatic yank was performed.`);
39
+ error.expectedSha256 = sha256;
40
+ error.registrySha256 = entry.cksum;
41
+ throw error;
42
+ }
43
+ return { name, version, sha256 };
44
+ }
45
+ const remainingMs = deadline - now();
46
+ if (remainingMs <= 0) break;
47
+ await wait(Math.min(delayMs, remainingMs));
48
+ delayMs = Math.min(delayMs * 2, 15_000);
49
+ }
50
+ throw new PublicationError("PUBLICATION_REGISTRY_TIMEOUT", `${name}@${version} checksum was not visible at ${url} within ${timeoutMs}ms`);
51
+ }
package/cargo-target.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  // (see cargo-contract.mjs) remains a separate, cache-policy-only decision:
10
10
  // whether Cache V2's lease/slot/prune/floor machinery should run at all, not
11
11
  // where the target directory lives.
12
+ import { realpathSync } from "node:fs";
12
13
  import { execFileSync } from "./background-process.mjs";
13
14
  import { runCargoSync } from "./managed-cargo.mjs";
14
15
  import path from "node:path";
@@ -38,7 +39,8 @@ export function resolveTargetRoot(manifestPath, options = {}) {
38
39
  return path.resolve(directory);
39
40
  }
40
41
 
41
- if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
42
+ const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
43
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
42
44
  const manifestPath = process.argv[2];
43
45
  if (!manifestPath) {
44
46
  console.error("usage: cargo-target.mjs <path-to-Cargo.toml>");