@rightkit/release 0.3.0 → 0.4.0

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 (66) hide show
  1. package/README.md +25 -4
  2. package/addon-command.mjs +3 -4
  3. package/android-play-upload.mjs +2 -1
  4. package/apple-profile-renew.mjs +11 -3
  5. package/artifact-storage.mjs +35 -2
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +1 -1
  8. package/build-release.mjs +138 -47
  9. package/cli/right-release.mjs +11 -2
  10. package/codesign-environment.mjs +2 -2
  11. package/content-digest.mjs +209 -0
  12. package/development.mjs +17 -6
  13. package/direct-bootstrap.mjs +5 -6
  14. package/disk-command.mjs +1 -0
  15. package/disk-policy.json +6 -2
  16. package/disk-stable-root.mjs +13 -9
  17. package/github-updater-release.mjs +70 -7
  18. package/in-place-install.mjs +17 -14
  19. package/install-app-windows.mjs +24 -7
  20. package/install-app.mjs +21 -40
  21. package/ios-asc-credentials.mjs +163 -0
  22. package/ios-build-context.mjs +129 -0
  23. package/ios-build-upload.mjs +282 -0
  24. package/ios-credentials-command.mjs +70 -0
  25. package/ios-dev.mjs +248 -0
  26. package/ios-host.mjs +76 -0
  27. package/ios-lock.mjs +75 -0
  28. package/ios-package-recipe.mjs +707 -0
  29. package/ios-testflight.mjs +266 -0
  30. package/ios-upload.mjs +313 -0
  31. package/local-publish.mjs +92 -37
  32. package/managed-cargo.mjs +8 -0
  33. package/mirror-root-artifact.mjs +5 -1
  34. package/native-cargo-layout.mjs +47 -4
  35. package/ownership-command.mjs +2 -1
  36. package/ownership-dependency-allowlist.json +1 -1
  37. package/ownership-evidence.mjs +30 -18
  38. package/ownership-key-command.mjs +69 -2
  39. package/ownership-mandatory-set.mjs +66 -16
  40. package/ownership-repositories.json +1 -1
  41. package/ownership-scope.mjs +94 -0
  42. package/ownership.mjs +49 -7
  43. package/package-recipe-targets.mjs +41 -1
  44. package/package.json +1 -1
  45. package/preflight.mjs +14 -7
  46. package/project-type.mjs +3 -1
  47. package/protected-credentials.mjs +28 -1
  48. package/publication-artifacts.mjs +30 -5
  49. package/publication-gate.mjs +15 -4
  50. package/publish-cargo.mjs +109 -22
  51. package/publish-update.mjs +15 -2
  52. package/qa-build-features.mjs +16 -13
  53. package/qa-contract.mjs +30 -2
  54. package/qa-control-scan.mjs +4 -0
  55. package/release-invocation.mjs +118 -8
  56. package/release-switch-registry.json +126 -27
  57. package/release-switch-scan.mjs +45 -17
  58. package/release.mjs +69 -17
  59. package/rightkit-ownership.json +1 -1
  60. package/rightkit-versions.json +379 -189
  61. package/runtime-budgets.mjs +120 -16
  62. package/sign-updater.mjs +56 -59
  63. package/source-gate.mjs +8 -1
  64. package/stage-sidecar.mjs +160 -32
  65. package/target-distribution.mjs +5 -0
  66. package/upload-release.mjs +7 -2
package/README.md CHANGED
@@ -56,7 +56,7 @@ Every Right app has absolutely NO access to DevTools; the single exception is Vi
56
56
 
57
57
  - `name` (required, unique): environment variable, argument flag, settings key or `VITE_*` name.
58
58
  - `class` (required): `S1`..`S6`. `channel` (required): `env`, `argv`, `settings`, `preference` or `frontend`.
59
- - S1-S4 need `compiledOut`: `feature:<name>`, `cfg:test` or `cfg:debug_assertions` (the read exists only there). S5 needs `userGesture: true`. S6 needs `valueType` of `bool`, `int` or `enum`.
59
+ - S1-S4 need `compiledOut`: `feature:<name>`, `cfg:test`, `cfg:debug_assertions` or, for Swift, `swift:DEBUG` (the read sits inside `#if DEBUG`; the read exists only there). The source walk skips test directories, including Swift bundles named `Tests`, `UITests`, `<App>Tests` and `<App>UITests`. S5 needs `userGesture: true`. S6 needs `valueType` of `bool`, `int` or `enum`.
60
60
  - `anchor` and `note` are informational. Fixtures: `fixtures/release-switches/` (`app/` is a clean example).
61
61
 
62
62
  ## Commands
@@ -164,6 +164,10 @@ app in `notarize.app`. Declare the file in `updater.artifacts` as before. See `d
164
164
 
165
165
  Audit ad hoc or on a weekly schedule with `right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>]`. It reads the app repo's sealed manifests and state and lists source and installed QA status per release and platform (passed, missing, rejected, not-uploaded). It exits 0 when every uploaded build has passing QA and 1 when any uploaded build lacks it. An installed receipt can only chain to a build sealed with a passing source receipt, so a build sealed with `qa.status: "missing"` needs source QA and a rebuild before installed QA can pass.
166
166
 
167
+ ## Local publish (`publish npm|cargo ... --local`, Mac or Windows)
168
+
169
+ `right-release publish cargo --crate <name> --local` (and the batch form `--crates a,b,c | --crates-file <file>`) publishes from the Mac **or from Windows** under the committed local-build grant (`.rightkit-local-build.json`, `allow: "build-test-publish"`, naming this repository); `publish npm --package @rightkit/<name> --local` stays Mac only (the npm token comes from the macOS Keychain and `@rightkit/release` ships a signed helper), and `--local` refuses any other host (Linux) and any host without the grant. The gate is the same on both hosts: the unit's own `ci/gate.config.json` commands run with zero failures and no skipped evidence, the artifact sha256 is recorded, and the existing `enforcePublicationGate` and publisher run, including the registry readback. The evidence carries one receipt for the publishing host: `hostPolicy: "darwin-only"` with a `darwin` run on the Mac, `hostPolicy: "win32-only"` (plus a `hostPolicyReason`) with a `win32` run on Windows; the gate requires that host's receipt and `isDarwinOnlyPolicy`, `isWin32OnlyPolicy` and `singleHostOfPolicy` in `publication-gate.mjs` are the one definition. A crate whose `ci/gate.config.json` `os` list lacks the publishing host (`macos` or `windows`) is refused. Windows publishes with the credential Cargo stores itself (`~/.cargo/credentials.toml`): no `--token` is passed, read or logged. A runtime-budget crate needs the measurement of the publishing host only, under the same evidence policy that the budget file may not contradict.
170
+
167
171
  ## Local app install (macOS)
168
172
 
169
173
  Agents must not run `osascript`, `open`, `screencapture`, or `notifyutil -p` directly. Install a signed build through:
@@ -344,6 +348,23 @@ The Android lane is native Kotlin with Gradle plus Rust `.so` libraries per ABI
344
348
  - Secrets: the keystore passwords and the Play service-account JSON live in the OS credential store (macOS Keychain `rightkit-android-<name>`; a protected token file on Windows and Linux, so store the JSON as one line) or in a 0600 file outside the repository, selected by `{ credential: "<name>" }` or `{ file: "<path>" }`. They never appear in the environment, on argv, in config, or in a log; key material found in the environment refuses the command.
345
349
  - Dev: a debug build installed by serial (never "the only device") with a package ending in `.dev`, `.qa`, `.test` or `.debug`; release tasks and signing properties are refused.
346
350
 
351
+ ## iOS (archive, export, TestFlight, simulator and device install)
352
+
353
+ One declarative recipe for every iOS app (bogusyogi/rightkit#349); full reference and example config in `docs/IOS-PACKAGE-RECIPE.md`. Same split as Android: `build` never uploads, `upload` never builds, and the upload is a dry run unless `--apply`.
354
+
355
+ | Step | Command | Config |
356
+ |---|---|---|
357
+ | Build | `right-release build --platform ios` | `targets.ios`: `iosPackage` (`project`, `projectSpec?`, `scheme`, `teamId`, `bundleId`, `archive`, `ipa`, `extraBuildSettings?`, `prepare?`, `cleanup?`, `checks?`) and `artifacts` listing the IPA |
358
+ | Upload | `right-release upload --platform ios --release <sealed-id> [--apply]` | top-level `distribution: { provider: "app-store-connect", appId, bundleId, track: "testflight", groups (required), submitExternalBetaReview? (default false), credentials? }` |
359
+ | Credentials, once | `right-release ios-credentials store --key-id <KEYID> --issuer <uuid>` (`.p8` on stdin), `ios-credentials check` | Keychain service names, optional |
360
+ | Dev | `right-release dev --platform ios [--serial <device id>]` | `development.targets.ios`: `project`, `projectSpec?`, `scheme`, `teamId?`, `install: { kind: "simulator" \| "device", device, bundleId, ... }` |
361
+
362
+ - Build: `xcodegen generate` (required when `projectSpec` is named; a committed xcodeproj is never a fallback), `xcodebuild archive` with automatic signing, `-allowProvisioningUpdates`, the configured team and a date build number `YYYYMMDDNN` (the marketing version stays as the app sets it), then the archive checks (one version across the app, appex and Watch plists, QA/release-switch scan, forbidden strings), `xcodebuild -exportArchive`, the IPA checks (entitlements, distribution profile, code signature) and a receipt over the final IPA bytes. A failure deletes the archive, the IPA and the receipt. macOS only, in the GUI signing session; on Windows and Linux only config validation and `--dry-run` run.
363
+ - Credentials (D4): the App Store Connect `.p8`, key id and issuer id are read from the macOS login Keychain (`security find-generic-password -s <service> -a <account> -w`; defaults `rightkit-asc-api-key`, `rightkit-asc-key-id`, `rightkit-asc-issuer-id`). A key path, key id, issuer or key in config, and key material in the environment, are refused; the ASC environment variables are refused on managed hosts. Key material never reaches a log.
364
+ - Upload: the sealed IPA is uploaded in process through the App Store Connect build-upload resources (`buildUploads`, `buildUploadFiles`, pre-signed part uploads, commit, poll to `COMPLETE`), then the exact build is assigned to the TestFlight beta groups (in-process ES256 JWT). Idempotent calls are retried with bounded backoff (the create POSTs never), an interrupted upload for the same version and build is resumed or finished, pre-signed URLs are limited to `*.blobstore.apple.com` and may not carry credential headers, and the IPA is streamed in ranges. A per-release lock, a GUI-session check before the Keychain read and marker identity checks guard a rerun. No altool and no file ever holds the key. No App Store submission. Apps must git-ignore `.right-release/` (see `docs/IOS-PACKAGE-RECIPE.md`).
365
+ - Dev: simulator (`simctl boot/install/launch`) or physical device (`devicectl install` and `process launch`), chosen by id, built Debug with generated commands.
366
+
367
+
347
368
  ## Bundled FFmpeg decode sidecar (Ember and other media apps)
348
369
 
349
370
  An app that decodes video through `rightkit-ffmpeg` ships the verified LGPL `ffmpeg` and `ffprobe` programs as a Tauri `externalBin` sidecar. right-release adds no new bundling mechanism: Tauri bundles the files (mac `Contents/MacOS`, Windows next to the app EXE), and right-release plans them from the catalog the app compiled against, ships the LGPL text and source offer, signs the binaries with the app, and seals their hashes. Set `targets.mac.mediaSidecar` and `targets.win.mediaSidecar`:
@@ -378,10 +399,10 @@ Any app that ships more than the ffmpeg sidecar (MailRight: `mailright-engine`,
378
399
 
379
400
  `stage-sidecar` (the `sidecars` list in `right-release.config.mjs`, receipt `stage-sidecar.json`) covers these cases without an app script. Every download is https and digest-pinned, nothing runs through a shell, and a failed check stages nothing.
380
401
 
381
- - **`cmake` (Orthic-Labs/rightkit#489):** `{ kind: "cmake", name: "whisper-cli", repo: "https://github.com/ggml-org/whisper.cpp", commit: "<40 hex>", target?: "whisper-cli", output?: "bin/whisper-cli", cmakeArgs?: ["-DBUILD_SHARED_LIBS=OFF"], platformArgs?: { mac: ["-DGGML_METAL=ON"], win: [...] }, generator?, smoke?: ["--help"], sha256?, dest?, clean?, platforms? }`. It fetches exactly that commit into the fixed `.right-release/cmake/<name>/src` (`git init`, `fetch --depth 1 origin <commit>`, detach; a checkout already at the commit is reused, one with local edits or at another commit is refused with `STAGE_SIDECAR_SOURCE_MISMATCH`), configures and builds `target` in `.right-release/cmake/<name>/build` (`CMAKE_OSX_ARCHITECTURES` is set from the target; `universal-apple-darwin` builds `arm64;x86_64` and checks `lipo -archs`), finds the executable (`output`, then `Release/` layouts, `.exe` on Windows), optionally pins its sha256, stages it as `<name>-<triple>[.exe]` and smoke-runs it when the target can run on this host. `cmakeArgs` and `platformArgs` accept only `-DNAME=value` definitions.
382
- - **`prebuilt` (#490):** verifies a host-built binary already staged at `<stageDir>/<name>-<triple>` (or copied from `from`) against an app manifest, never building or downloading: `manifest: { file, entry: "binaries.{platform}", sha256?: "sha256", approvalSha256?: "approval_sha256", approvalAlgorithm?: "approval_hash_algorithm" }` (`{platform}` is `macos-aarch64`, `windows-x86_64`, ...). The raw sha256 must match; when an approval hash is named it must match too, with `raw-sha256` or `macho-codesign-strip-linkedit-v1` (`macho-approval-hash.mjs`: the sha256 of a thin Mach-O with its code signature removed and `__LINKEDIT` normalised, so a signed and an unsigned copy of one build agree; macOS only, `codesign --remove-signature` on a private copy). A missing file, entry or digest fails closed.
402
+ - **`cmake` (Orthic-Labs/rightkit#489):** `{ kind: "cmake", name: "whisper-cli", repo: "https://github.com/ggml-org/whisper.cpp", commit: "<40 hex>", target?: "whisper-cli", output?: "bin/whisper-cli", cmakeArgs?: ["-DBUILD_SHARED_LIBS=OFF"], platformArgs?: { mac: ["-DGGML_METAL=ON"], win: [...] }, generator?, smoke?: ["--help"], sha256?, dest?, clean?, platforms? }`. It fetches exactly that commit into the fixed `.right-release/cmake/<name>/src` (`git init`, `fetch --depth 1 origin <commit>`, detach; a checkout already at the commit is reused, one with local edits or at another commit is refused with `STAGE_SIDECAR_SOURCE_MISMATCH`), configures and builds `target` in `.right-release/cmake/<name>/build` (`CMAKE_OSX_ARCHITECTURES` is set from the target; `universal-apple-darwin` builds `arm64;x86_64` and checks `lipo -archs`), finds the executable (`output`, then `Release/` layouts, `.exe` on Windows), optionally pins its sha256, stages it as `<name>-<triple>[.exe]` and smoke-runs it when the target can run on this host. `cmakeArgs` and `platformArgs` accept only `-DNAME=value` definitions. Toolchain controls (#691, all optional, defaults unchanged): `cmakeBin: "<name or path>"` runs that cmake (a bare name is looked up on PATH, a path with separators is resolved against the app root); `generator` is per entry and `platformGenerators: { win: "Visual Studio 17 2022" }` overrides it for one platform (`-G`); `architecture: "x64"` (any `-A` platform: `x64`, `Win32`, `ARM64`) adds `-A <arch>` after `-G`. With no `cmakeBin`, a Windows host uses `cmake` from PATH (`where cmake`) when there is one, else the Visual Studio-bundled cmake (`vswhere.exe` under `%ProgramFiles(x86)%\Microsoft Visual Studio\Installer`, `-latest -products * -requires Microsoft.VisualStudio.Component.VC.CMake.Project -property installationPath`, then `Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe`), else plain `cmake`. The receipt row carries `cmakeBin` (when not plain `cmake`) and `generator`. There is no environment-variable override: the staging recipe has none for any other tool.
403
+ - **`prebuilt` (#490):** verifies a host-built binary already staged at `<stageDir>/<name>-<triple>` (or copied from `from`) against an app manifest, never building or downloading: `manifest: { file, entry: "binaries.{platform}", sha256?: "sha256", approvalSha256?: "approval_sha256", approvalAlgorithm?: "approval_hash_algorithm" }` (`{platform}` is `macos-aarch64`, `windows-x86_64`, ...). The raw sha256 must match; when an approval hash is named it must match too, with `raw-sha256` or `macho-codesign-strip-linkedit-v1` (`macho-approval-hash.mjs`: the sha256 of a thin Mach-O with its code signature removed and `__LINKEDIT` normalised, so a signed and an unsigned copy of one build agree; macOS only, `codesign --remove-signature` on a private copy). A missing file, entry or digest fails closed. With `from: { aarch64: "<path>", x86_64: "<path>" }` the entry also works on `universal-apple-darwin` (#692): each arch is verified against its own manifest entry (`macos-aarch64`, `macos-x86_64`; the approval algorithm default is the macOS one), then both are lipo-merged by the same helper `ffmpeg`, `onnxruntime` and `cmake` use into `<name>-universal-apple-darwin` (both archs required, `STAGE_SIDECAR_NOT_UNIVERSAL` otherwise; nothing is merged after a failed slice). With `from` omitted the inputs are the staged `<name>-aarch64-apple-darwin` and `<name>-x86_64-apple-darwin`; a plain string `from` is refused for universal. On a thin target an object `from` picks the entry for that arch. The row carries `platform: "macos-universal"` and per-arch `slices`.
383
404
  - **`remote` archives (#491):** `extract: "<member file name>"` (with `memberSha256?`, `executable?`) unpacks one member of a zip or tgz after the archive digest passes, so a Windows zip needs no `command` entry.
384
- - **`ffmpeg` mixed variants, fetch and probes (#491):** `variants: { ffmpeg: "export-ass" }` lets one entry take the export-ass ffmpeg with the decode ffprobe (a tool not named uses `variant`). `sources: { ffmpeg: { url, sha256, member }, ffprobe: {...} }` downloads the bytes the catalog only pins: the archive is digest-checked, the member is unpacked and its sha256 must equal the APPROVED catalog row's content digest, so a swapped archive can never stage. `probe: true` runs `-version` and `-encoders` on a staged binary this host can run: the configuration must say `--disable-gpl` and `--disable-nonfree` and must not enable GPL, nonfree, libx264, libx265, libxvid or libfdk-aac, and ffmpeg may not list those encoders (`STAGE_SIDECAR_FFMPEG_PROBE_FAILED`; another architecture or OS is recorded as `skipped-cross-target`). The catalog has no Windows `export-ass` row yet (only `export-ass-zimg`); that row belongs to the rightkit-ffmpeg crate.
405
+ - **`ffmpeg` mixed variants, fetch and probes (#491):** `variants: { ffmpeg: "export-ass" }` lets one entry take the export-ass ffmpeg with the decode ffprobe (a tool not named uses `variant`). `sources: { ffmpeg: { url, sha256, member }, ffprobe: {...} }` downloads the bytes the catalog only pins: the archive is digest-checked, the member is unpacked and its sha256 must equal the APPROVED catalog row's content digest, so a swapped archive can never stage. A source may instead be a local path (#692): `sources: { ffmpeg: { path: "../vendor/ffmpeg-lgpl", member?: "ffmpeg" } }` (also per arch: `{ aarch64: { path }, x86_64: { path } }`). `path` is relative to the app root and may step out of it; it names the binary itself or a directory holding `<tool>[.exe]` or `bin/<tool>[.exe]` (`member` renames the file). Nothing is downloaded and there is no archive digest, but the file must still equal the APPROVED catalog row content digest (`STAGE_SIDECAR_HASH_MISMATCH` otherwise); notices, probe and universal lipo are unchanged; a missing file is `STAGE_SIDECAR_BINARY_NOT_FOUND`. `probe: true` runs `-version` and `-encoders` on a staged binary this host can run: the configuration must say `--disable-gpl` and `--disable-nonfree` and must not enable GPL, nonfree, libx264, libx265, libxvid or libfdk-aac, and ffmpeg may not list those encoders (`STAGE_SIDECAR_FFMPEG_PROBE_FAILED`; another architecture or OS is recorded as `skipped-cross-target`). The catalog has no Windows `export-ass` row yet (only `export-ass-zimg`); that row belongs to the rightkit-ffmpeg crate.
385
406
  - **`universal-apple-darwin` for `ffmpeg` and `onnxruntime` (#492):** each arch row is staged thin under `.right-release/slices/<triple>/` and the files are lipo-merged into `<stageDir>/<tool>-universal-apple-darwin` (onnxruntime: into `resourceDir`), requiring both archs and every row approved (onnxruntime also accepts `pinned`). For `ffmpeg`, `{arch}`, `{triple}` and `{stageDir}` in the `stage` command's arguments name the slice (it runs once per slice), `sources` need the per-arch form `{ ffmpeg: { aarch64: {...}, x86_64: {...} } }`, one source offer lists both slices, and the receipt rows carry `slices`. A merged file identical to the staged one is not rewritten.
386
407
 
387
408
  ## Declared Rust audit, dist-scan and the Windows Swift toolchain
package/addon-command.mjs CHANGED
@@ -172,11 +172,10 @@ async function adopt(options) {
172
172
  }
173
173
  }
174
174
  copyFileSync(path.join(source, "addon-manifest.json"), path.join(stage, "addon-manifest.json"));
175
- // Directory rename is atomic on one filesystem. The previous add-on stays beside the fixed path as
176
- // `<destination>.previous` (one copy, rename it back to roll back; never run from it) and is restored if
177
- // replacement fails.
175
+ // Directory rename is atomic on one filesystem. No `<destination>.previous` is retained (D01): the
176
+ // verified staged copy replaces the fixed path and any stale `.previous` is removed.
178
177
  mkdirSync(output, { recursive: true });
179
- swapInPlace({ staging: stage, destination });
178
+ swapInPlace({ staging: stage, destination, previous: null });
180
179
  if (temporary) rmSync(temporary, { recursive: true, force: true });
181
180
  const repoRoot = git(path.dirname(lockPath), ["rev-parse", "--show-toplevel"]);
182
181
  const receipt = path.join(repoRoot, ".right-release", "addons", "adoptions", `${manifest.addon}-${manifest.platform}.json`);
@@ -27,6 +27,7 @@ import { pathToFileURL } from "node:url";
27
27
  import { spawnSync } from "./background-process.mjs";
28
28
  import { describeAabProblems, scanAabBuffer } from "./android-aab-scan.mjs";
29
29
  import { sealedManifestAllowsContentDevtools } from "./devtools-policy.mjs";
30
+ import { targetDistribution } from "./target-distribution.mjs";
30
31
  import { AndroidReleaseError, createAndroidCredentialStore, resolveAndroidSecret, validateSecretSource } from "./android-release.mjs";
31
32
  import { assertArtifactUnchanged, digestBytes, verifyPublicationRelease } from "./publication-artifacts.mjs";
32
33
  import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
@@ -199,7 +200,7 @@ export async function authorizePlayUpload({ releaseId, configName = "right-relea
199
200
  if (!existsSync(configPath)) refuse("PLAY_CONFIG_INVALID", `release config is required: ${configName}`);
200
201
  // The config is a module that runs on import: loaded only after the tree is proven clean.
201
202
  const config = (await import(`${pathToFileURL(configPath).href}?upload=${Date.now()}`)).default;
202
- const distribution = validatePlayDistribution(config?.distribution);
203
+ const distribution = validatePlayDistribution(targetDistribution(config, "android"));
203
204
  const sealedDir = path.join(repoRoot, ".right-release", "sealed", releaseId, "android");
204
205
  const sealed = verifyPublicationRelease({ sealedDir, repoRoot, appRoot: path.dirname(configPath), commit, app: config.app, version: config.version, platform: "android", configPath, releaseId });
205
206
  const entries = sealed.manifest.files.filter((entry) => entry.role === "aab");
@@ -43,12 +43,20 @@ export function appleProfileAuth(env = process.env, { config = {}, root = proces
43
43
  // Only this in-process signer reads key material. Never send it to a subprocess,
44
44
  // include it in diagnostics, or expose it in returned renewal plans.
45
45
  export function signAppleApiJwt({ keyPath, keyId, issuer }, now = new Date()) {
46
+ let key;
47
+ try { key = createPrivateKey(readFileSync(keyPath)); } catch { fail("APPLE_PROFILE_KEY_INVALID", "Unable to sign API JWT with configured .p8 key."); }
48
+ return signAppleApiJwtWithKey(key, { keyId, issuer }, now);
49
+ }
50
+
51
+ // The same ES256 App Store Connect JWT from a key that is already in memory (a KeyObject, for example one read from the
52
+ // macOS Keychain by ios-asc-credentials.mjs). No file is read. lifetimeSeconds is at most Apple's 1200 second limit.
53
+ export function signAppleApiJwtWithKey(key, { keyId, issuer }, now = new Date(), { lifetimeSeconds = 600 } = {}) {
46
54
  try {
47
- const key = createPrivateKey(readFileSync(keyPath));
48
- if (key.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") throw new Error();
55
+ if (!Number.isInteger(lifetimeSeconds) || lifetimeSeconds < 1 || lifetimeSeconds > 1200) throw new Error();
56
+ if (key?.asymmetricKeyType !== "ec" || key.asymmetricKeyDetails?.namedCurve !== "prime256v1") throw new Error();
49
57
  const iat = Math.floor(now.getTime() / 1000);
50
58
  const encoded = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
51
- const payload = `${encoded({ alg: "ES256", kid: keyId, typ: "JWT" })}.${encoded({ iss: issuer, iat, exp: iat + 600, aud: "appstoreconnect-v1" })}`;
59
+ const payload = `${encoded({ alg: "ES256", kid: keyId, typ: "JWT" })}.${encoded({ iss: issuer, iat, exp: iat + lifetimeSeconds, aud: "appstoreconnect-v1" })}`;
52
60
  return `${payload}.${sign("sha256", Buffer.from(payload), { key, dsaEncoding: "ieee-p1363" }).toString("base64url")}`;
53
61
  } catch { fail("APPLE_PROFILE_KEY_INVALID", "Unable to sign API JWT with configured .p8 key."); }
54
62
  }
@@ -6,6 +6,7 @@ import {
6
6
  mkdirSync,
7
7
  readFileSync,
8
8
  readdirSync,
9
+ realpathSync,
9
10
  renameSync,
10
11
  rmSync,
11
12
  statSync,
@@ -20,6 +21,21 @@ export const DEFAULT_MANAGED_ROOT = resolveManagedRoots().dataRoot;
20
21
 
21
22
  const SAFE_COMPONENT = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,159}$/;
22
23
 
24
+ /**
25
+ * The configured root, canonicalised once: an owner may move the managed root to another volume and leave a symlink at the
26
+ * configured path (2026-10-18: /Volumes/D/.rightkit-managed -> /Volumes/E/.rightkit-managed). Every path below it is still checked
27
+ * for symlinks by its consumer (ensurePrivateDirectory refuses symlink ancestors); only the root itself is resolved here.
28
+ * Used only where a strict ancestor walk needs it (codesign-environment.mjs): resolveManagedRoot stays lexical, because the
29
+ * Mac signing agent's allowed roots are recorded in that lexical spelling.
30
+ */
31
+ export function canonicalManagedRoot(root, { realpath = realpathSync.native } = {}) {
32
+ const resolved = path.resolve(root);
33
+ try { return realpath(resolved); } catch (error) {
34
+ if (error?.code === "ENOENT") return resolved;
35
+ throw error;
36
+ }
37
+ }
38
+
23
39
  export function resolveManagedRoot({ env = process.env, exists = existsSync } = {}) {
24
40
  if (env.RIGHTKIT_DATA_ROOT || env.RIGHTKIT_MANAGED_ROOT || env.RIGHTKIT_EXTERNAL_ROOT) return path.resolve(resolveManagedRoots({ env }).dataRoot);
25
41
  // Workstation default only when its drive exists; hosted CI runners fall back to RUNNER_TEMP, then the system temp dir.
@@ -310,15 +326,32 @@ function ensureDirectory(directory) {
310
326
  mkdirSync(directory, { recursive: true });
311
327
  }
312
328
 
313
- function assertSafeMutation(target) {
329
+ /**
330
+ * The managed roots an owner configures (env overrides and the workstation default), as spelled. Only these exact paths may
331
+ * themselves be symlinks (2026-10-18: /Volumes/D/.rightkit-managed -> /Volumes/E/.rightkit-managed); every path below them,
332
+ * and every other ancestor, is still refused when it is a symlink.
333
+ */
334
+ export function configuredManagedRootSpellings({ env = process.env } = {}) {
335
+ const spellings = [DEFAULT_MANAGED_ROOT, env.RIGHTKIT_DATA_ROOT, env.RIGHTKIT_MANAGED_ROOT, env.RIGHTKIT_EXTERNAL_ROOT]
336
+ .filter((value) => typeof value === "string" && value.length > 0)
337
+ .map((value) => path.resolve(value));
338
+ return new Set(process.platform === "win32" ? spellings.map((value) => value.toLowerCase()) : spellings);
339
+ }
340
+
341
+ function isConfiguredManagedRoot(candidate, roots) {
342
+ return roots.has(process.platform === "win32" ? path.resolve(candidate).toLowerCase() : path.resolve(candidate));
343
+ }
344
+
345
+ export function assertSafeMutation(target, { env = process.env } = {}) {
314
346
  const absolute = path.resolve(target);
315
347
  const root = path.parse(absolute).root;
316
348
  const relative = path.relative(root, absolute);
349
+ const managedRoots = configuredManagedRootSpellings({ env });
317
350
  let current = root;
318
351
  for (const segment of relative ? relative.split(path.sep) : []) {
319
352
  current = path.join(current, segment);
320
353
  try {
321
- if (lstatSync(current).isSymbolicLink()) throw new Error(`refusing filesystem mutation through symlink: ${current}`);
354
+ if (lstatSync(current).isSymbolicLink() && !isConfiguredManagedRoot(current, managedRoots)) throw new Error(`refusing filesystem mutation through symlink: ${current}`);
322
355
  } catch (error) {
323
356
  if (error.code === "ENOENT") continue;
324
357
  throw error;
Binary file
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schema": 1,
3
- "sha256": "cbd04b2fa9af4c416c9d4983e2c63097dc0ca5f987d15eb272123df2de3d22cd",
3
+ "sha256": "a41ce13fd3bac31db0230d70bed20db0ed9bf554bdae2255bdf2064589b48f29",
4
4
  "identity": "Developer ID Application: Adrian D'souza (6KLGD3LLKF)",
5
5
  "codesign": "strict-valid"
6
6
  }