@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
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,
@@ -12,16 +13,15 @@ import {
12
13
  } from "node:fs";
13
14
  import path from "node:path";
14
15
  import os from "node:os";
16
+ import { spawnSync } from "./background-process.mjs";
15
17
 
16
18
  export const ARTIFACT_MARKER_SCHEMA = 1;
17
- export const DEFAULT_MANAGED_ROOT = process.platform === "win32"
18
- ? "D:\\.rightkit-managed"
19
- : "/Volumes/D/.rightkit-managed";
19
+ export const DEFAULT_MANAGED_ROOT = resolveManagedRoots().dataRoot;
20
20
 
21
21
  const SAFE_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/;
22
22
 
23
23
  export function resolveManagedRoot({ env = process.env, exists = existsSync } = {}) {
24
- if (env.RIGHTKIT_MANAGED_ROOT) return path.resolve(env.RIGHTKIT_MANAGED_ROOT);
24
+ if (env.RIGHTKIT_DATA_ROOT || env.RIGHTKIT_MANAGED_ROOT || env.RIGHTKIT_EXTERNAL_ROOT) return path.resolve(resolveManagedRoots({ env }).dataRoot);
25
25
  // Workstation default only when its drive exists; hosted CI runners fall back to RUNNER_TEMP, then the system temp dir.
26
26
  const volume = process.platform === "win32" ? "D:\\" : "/Volumes/D";
27
27
  if (exists(volume)) return path.resolve(DEFAULT_MANAGED_ROOT);
@@ -59,8 +59,11 @@ export function publishedPointerPath({ app, managedRoot } = {}) {
59
59
  * Create or refresh the lease marker before a build writes managed artifacts.
60
60
  * Marker fields intentionally remain the small cross-process contract.
61
61
  */
62
- export function beginArtifactLease({ app, version, managedRoot, now = Date.now(), ownerPid = process.pid, leaseMs = 2 * 60 * 60 * 1000 } = {}) {
63
- 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) {
64
67
  throw new TypeError("artifact lease requires finite now/leaseMs and positive ownerPid");
65
68
  }
66
69
  const root = resolveArtifactRoot({ app, managedRoot });
@@ -72,14 +75,24 @@ export function beginArtifactLease({ app, version, managedRoot, now = Date.now()
72
75
  ensureDirectory(directory);
73
76
  const file = markerPath({ app, version, managedRoot });
74
77
  assertSafeMutation(file);
78
+ const identity = processIdentity(ownerPid);
79
+ let reclaimed;
75
80
  if (existsSync(file)) {
76
81
  const previous = readArtifactMarker(file);
77
- 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) {
78
86
  throw new Error(`artifact lease is held by PID ${previous.ownerPid}: ${app}@${version}`);
79
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 };
80
91
  }
81
- 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 };
82
94
  writeJson(file, marker);
95
+ if (reclaimed) log(reclaimed);
83
96
  return marker;
84
97
  } finally {
85
98
  lock.release();
@@ -112,6 +125,8 @@ export function readArtifactMarker(file) {
112
125
  if (!Number.isFinite(marker[key])) throw new Error(`artifact marker ${key} must be numeric: ${file}`);
113
126
  }
114
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}`);
115
130
  return marker;
116
131
  }
117
132
 
@@ -316,11 +331,33 @@ function safeRemove(target) {
316
331
  rmSync(target, { recursive: true, force: true });
317
332
  }
318
333
 
319
- function isActiveLease(marker, now) {
320
- 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;
321
346
  return Number(marker.leaseUntil) > Number(now);
322
347
  }
323
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
+
324
361
  function acquireRetentionLock(root, now) {
325
362
  ensureDirectory(root);
326
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
@@ -46,7 +46,7 @@ import { macosCodesignEnvironment } from "./codesign-environment.mjs";
46
46
  // and test-only edits and would force a cold Rust rebuild of every app each time.
47
47
  // Cached target directories must not outlive changes to these signing helpers.
48
48
 
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", "rightkit-signing-agent.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"];
50
50
  const PIPELINE_FINGERPRINT = createHash("sha256")
51
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"))
52
52
  .digest("hex")
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>");
@@ -42,6 +42,7 @@ try {
42
42
  if (githubBuildCommands.has(first)
43
43
  || first?.startsWith("-")
44
44
  || (first === "addon" && args[1] === "build")
45
+ || (first === "dmg" && args[1] === "build")
45
46
  || (first === "publish" && ["cargo", "swift"].includes(args[1]))) {
46
47
  assertReleaseExecutionAllowed({ command: `right-release ${args.join(" ")}`.trim() });
47
48
  }
@@ -63,6 +64,8 @@ if (first === "--version" || first === "-v") {
63
64
  } else {
64
65
  run("release.mjs", ["--doctor", ...args.slice(1)]);
65
66
  }
67
+ } else if (first === "hooks") {
68
+ run("ownership-hooks.mjs", args.slice(1));
66
69
  } else if (first === "suite-doctor") {
67
70
  runTest(
68
71
  ["--test", path.join(packageRoot, "right-suite-contract.test.mjs")],
@@ -70,6 +73,10 @@ if (first === "--version" || first === "-v") {
70
73
  );
71
74
  } else if (first === "cache") {
72
75
  run("cache-command.mjs", args.slice(1));
76
+ } else if (first === "install-app") {
77
+ run("install-app.mjs", args.slice(1));
78
+ } else if (first === "dmg") {
79
+ run("dmg-build.mjs", args.slice(1));
73
80
  } else if (first === "apple") {
74
81
  run("apple-profile-renew.mjs", args.slice(1));
75
82
  } else if (first === "publish") {
@@ -83,6 +90,12 @@ if (first === "--version" || first === "-v") {
83
90
  } else {
84
91
  run("upload-release.mjs", rest);
85
92
  }
93
+ } else if (first === "inventory") {
94
+ if (args[1] !== "check") {
95
+ console.error("right-release inventory: expected check");
96
+ process.exit(2);
97
+ }
98
+ run("inventory-check.mjs", args.slice(2));
86
99
  } else if (first === "model") {
87
100
  const rest = args.slice(1);
88
101
  if (rest[0] !== "promote") {
@@ -155,12 +168,20 @@ function printHelp() {
155
168
  console.log(`right-release <command> [options]
156
169
 
157
170
  Commands:
171
+ dmg build --app <.app> --out <dmg> --volname <name> [--background <png>]
172
+ [--icon-size N] [--app-position x,y] [--applications-position x,y] [--window w,h]
173
+ Build branded UDZO image without Finder
174
+ install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id <id>] [--dest /Applications]
175
+ [--relaunch|--no-relaunch] [--json]
176
+ Verify a signed DMG, quit, replace & relaunch hidden; no UI, no osascript (macOS)
158
177
  build [--platform mac|win] [--skip-checks] [--resume-from <phase>]
159
178
  Resume macOS at prepackage, sign, package, post-sign, or harden
160
179
  dev --platform win|mac [--config <path>] [--dry-run] [--build-only]
161
180
  Incremental unsigned build & optional local install
162
181
  upload --release <id> --platform mac|win --tier patch|update
163
182
  Upload one sealed release; never builds
183
+ publish-update --release <id> --platform mac|win --tier patch|update
184
+ Upload sealed bytes to R2 & register RightApps
164
185
  release Compatibility alias for build
165
186
  publish Compatibility alias for upload
166
187
  publish npm --package @rightkit/<name> [--dry-run] Gate preflight for npm publish
@@ -169,6 +190,8 @@ Commands:
169
190
  (npm-rightkit-publish) and verify registry bytes
170
191
  publish cargo --crate <name> [--dry-run] Test, inspect, scan, and publish one crates.io package
171
192
  publish swift [--repo <git-url>] [--dry-run] Test, tag, resolve a fresh exact-pin consumer, and publish RightKitSwift to its git repo
193
+ inventory check [--json] [--warn-only] [--md <file>] [--no-cache]
194
+ Registry, account, dependency, app & hygiene drift (public endpoints)
172
195
  model promote --authority <app-key> --config <file> [--dry-run]
173
196
  Sign and promote one runtime/model artifact
174
197
  addon doctor|build|upload|adopt <options> Manage a signed headless add-on
@@ -179,10 +202,13 @@ Commands:
179
202
  doctor --all Verify all Right Suite app release contracts
180
203
  suite-doctor Verify all local Right Suite repositories
181
204
  ownership [--app <dir>] [--json] Fail on app copies of RightKit-owned capabilities
205
+ ownership --staged [--app <dir>] [--json] Pre-commit gate: scan the staged index for block-tier findings
206
+ hooks install [--app <dir>] Install the advisory pre-commit ownership gate (pinned @rightkit/release)
182
207
  ownership enroll [--root] [--name <device>] [--root-fingerprint <sha256>] [--app <dir>]
183
208
  Enroll one native key or create a signed-device request
184
209
  ownership approve-key <fingerprint|request.json> Approve one pending device key from an active host
185
210
  ownership revoke-key <fingerprint> Revoke one active device key
211
+ ownership trust-sync Advance this host's protected trust to the committed key log
186
212
  ownership-approval --draft <input.json> [--out <draft.json>]
187
213
  Draft an exception for native owner approval
188
214
  ownership-approval --verify <signed.json> --context <scan.json>