@rightkit/release 0.2.164 → 0.2.172

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 (77) hide show
  1. package/README.md +121 -4
  2. package/addon-command.mjs +6 -13
  3. package/android-aab-scan.mjs +265 -0
  4. package/android-dev.mjs +111 -0
  5. package/android-ndk.mjs +77 -0
  6. package/android-play-upload.mjs +262 -0
  7. package/android-release.mjs +344 -0
  8. package/android-test-fixtures.mjs +79 -0
  9. package/apple-profile-create.mjs +363 -0
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/darwin/rightkit-swap.sign.json +6 -0
  12. package/build-release.mjs +73 -13
  13. package/cargo-guard.mjs +2 -0
  14. package/cargo-profile-contract.mjs +183 -0
  15. package/cargo-target.mjs +8 -0
  16. package/cli/right-release.mjs +14 -4
  17. package/committed-source.mjs +1 -1
  18. package/dev-artifact-refresh.mjs +176 -0
  19. package/dev-artifact-support.mjs +61 -0
  20. package/development-signing.mjs +71 -4
  21. package/development.mjs +78 -46
  22. package/direct-bootstrap.mjs +26 -41
  23. package/disk-command.mjs +315 -13
  24. package/disk-policy.json +7 -5
  25. package/disk-policy.mjs +141 -22
  26. package/disk-stable-root.mjs +19 -16
  27. package/engine-locator.mjs +116 -0
  28. package/external-bin.mjs +133 -0
  29. package/github-release.mjs +2 -2
  30. package/github-updater-release.mjs +154 -0
  31. package/hardeningscan.mjs +1 -1
  32. package/in-place-install.mjs +56 -0
  33. package/install-app-windows.mjs +63 -0
  34. package/install-app.mjs +53 -23
  35. package/local-publish.mjs +11 -1
  36. package/mac-signing-matrix.mjs +23 -11
  37. package/managed-cargo.mjs +6 -0
  38. package/media-sidecar-catalog.mjs +45 -0
  39. package/media-sidecar.mjs +306 -0
  40. package/ownership-approval-command.mjs +10 -44
  41. package/ownership-approval.mjs +3 -42
  42. package/ownership-catalog-config.json +2 -1
  43. package/ownership-catalog.mjs +4 -2
  44. package/ownership-command.mjs +7 -9
  45. package/ownership-dependency-allowlist.json +25 -1
  46. package/ownership-evidence.mjs +8 -5
  47. package/ownership-git-inventory.mjs +41 -7
  48. package/ownership-lockfiles.mjs +91 -8
  49. package/ownership-mandatory-set.json +36 -16
  50. package/ownership-report.mjs +5 -6
  51. package/ownership-scan-worker.mjs +2 -2
  52. package/ownership-verdict.mjs +10 -7
  53. package/ownership.mjs +47 -29
  54. package/package.json +7 -4
  55. package/portable-development.mjs +32 -115
  56. package/preflight.mjs +4 -6
  57. package/publication-artifacts.mjs +34 -0
  58. package/publish-update.mjs +72 -11
  59. package/qa-adoption-exemptions.json +5 -0
  60. package/qa-build-features.mjs +7 -0
  61. package/qa-contract.mjs +182 -0
  62. package/qa-control-scan.mjs +3 -3
  63. package/release-qa-command.mjs +38 -12
  64. package/release-qa-installed.mjs +136 -0
  65. package/release-qa-receipt.mjs +23 -2
  66. package/release.mjs +283 -14
  67. package/rightkit-ownership.json +537 -5
  68. package/rightkit-sync.mjs +26 -0
  69. package/rightkit-versions.json +110 -62
  70. package/runtime-budget-classes.json +28 -0
  71. package/runtime-budgets.mjs +4 -1
  72. package/sign-candidate.mjs +161 -0
  73. package/sign-dev-artifact.mjs +198 -0
  74. package/stage-binary.mjs +60 -0
  75. package/swap-helper-bundle.mjs +105 -0
  76. package/templates/cargo-profiles.toml +28 -0
  77. package/upload-release.mjs +11 -1
@@ -68,8 +68,31 @@ function decodeProfile(file, context) {
68
68
  }
69
69
  }
70
70
 
71
+ /** The only supported value of `install.signing` / RIGHTKIT_DEV_SIGNING: ad hoc signing on an ephemeral GitHub-hosted runner. */
72
+ export const CI_ADHOC_SIGNING = "ci-adhoc";
73
+ /** Environment spelling of the same opt-in (the generated rust-hybrid candidate and dev-qualification jobs set it). */
74
+ export const CI_ADHOC_SIGNING_ENV = "RIGHTKIT_DEV_SIGNING";
75
+
76
+ /**
77
+ * Explicit opt-in to CI-only ad hoc signing: `development.targets.mac.install.signing: "ci-adhoc"` or
78
+ * RIGHTKIT_DEV_SIGNING=ci-adhoc. Never inferred from a missing identity. Any other value is refused so a typo cannot silently
79
+ * fall back to the default; the opt-in is refused on every host that is not a GitHub-hosted Actions runner.
80
+ */
81
+ export function requestedCiAdhocSigning({ descriptor = {}, env = process.env }) {
82
+ const fromConfig = descriptor.signing;
83
+ const fromEnv = typeof env[CI_ADHOC_SIGNING_ENV] === "string" ? env[CI_ADHOC_SIGNING_ENV].trim() : "";
84
+ for (const [value, source] of [[fromConfig, "install.signing"], [fromEnv || undefined, CI_ADHOC_SIGNING_ENV]]) {
85
+ if (value !== undefined && value !== CI_ADHOC_SIGNING) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_MODE_INVALID", `${source} must be ${JSON.stringify(CI_ADHOC_SIGNING)} or unset, got ${JSON.stringify(value)}.`);
86
+ }
87
+ if (fromConfig === undefined && !fromEnv) return false;
88
+ const onRunner = env.GITHUB_ACTIONS === "true" && env.CI === "true" && env.RUNNER_ENVIRONMENT === "github-hosted";
89
+ if (!onRunner) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CI_ADHOC_REFUSED", `Ad hoc development signing (${CI_ADHOC_SIGNING}) is allowed only on a GitHub-hosted Actions runner (GITHUB_ACTIONS=true, CI=true, RUNNER_ENVIRONMENT=github-hosted). Ad hoc signatures change on every build, so on a developer machine they re-trigger macOS TCC prompts; use a Keychain Developer ID identity there.`);
90
+ return true;
91
+ }
92
+
71
93
  export function resolveMacosDevelopmentSigning({ root, bundle, descriptor = {}, config = {}, env = process.env, run = spawnSync }) {
72
94
  const context = { root, env, run };
95
+ const ciAdhoc = requestedCiAdhocSigning({ descriptor, env });
73
96
  const tauriRoot = path.dirname(path.resolve(root, descriptor.manifestPath ?? "src-tauri/Cargo.toml"));
74
97
  let mac = {};
75
98
  for (const name of ["tauri.conf.json", "tauri.macos.conf.json"]) {
@@ -79,6 +102,8 @@ export function resolveMacosDevelopmentSigning({ root, bundle, descriptor = {},
79
102
  const sign = config.targets?.mac?.sign ?? {};
80
103
  const entitlements = sign.entitlements ? path.resolve(root, sign.entitlements) : mac.entitlements ? path.resolve(tauriRoot, mac.entitlements) : null;
81
104
  if (entitlements && !existsSync(entitlements)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", `Configured entitlements file is missing: ${entitlements}`);
105
+ // CI-only ad hoc: no Keychain, no provisioning profile (a profile authorizes restricted entitlements that an ad hoc signature cannot carry).
106
+ if (ciAdhoc) return { identity: "-", certificateSha1: "-", teamIdentifier: null, entitlements, provisioningProfile: null, mode: CI_ADHOC_SIGNING, adhoc: true };
82
107
  const tauriProfile = mac.provisioningProfile ?? mac.files?.["embedded.provisionprofile"] ?? mac.files?.["/embedded.provisionprofile"];
83
108
  const configuredProfile = sign.provisioningProfile ? path.resolve(root, sign.provisioningProfile) : tauriProfile ? path.resolve(tauriRoot, tauriProfile) : null;
84
109
  const embedded = path.join(bundle, "Contents", "embedded.provisionprofile");
@@ -95,7 +120,7 @@ export function resolveMacosDevelopmentSigning({ root, bundle, descriptor = {},
95
120
  const found = checked("security", ["find-identity", "-v", "-p", "codesigning"], context, "DEV_MAC_SIGNING_IDENTITY_MISSING", expected);
96
121
  const identities = [...String(found.stdout ?? "").matchAll(/([A-Fa-f0-9]{40})\s+"([^"\n]+)"/g)].map((match) => ({ sha1: match[1].toUpperCase(), name: match[2] }));
97
122
  const matchesRequest = (item, requested) => item.name === requested || item.sha1 === requested?.toUpperCase();
98
- const profiled = (info) => identities.filter((item) => info.certificates.some((cert) => cert.sha1 === item.sha1) && /^(Apple Development|Mac Developer|Developer ID Application):/.test(item.name));
123
+ const profiled = (info) => identities.filter((item) => info.certificates.some((cert) => cert.sha1 === item.sha1) && /^Developer ID Application:/.test(item.name));
99
124
  // A configured profile can go stale when its certificate is renewed: prefer a sibling profile for the
100
125
  // same app identifier that authorizes a certificate whose private key is in this Keychain.
101
126
  if (profileInfo && !profiled(profileInfo).length && configuredProfile) {
@@ -110,6 +135,9 @@ export function resolveMacosDevelopmentSigning({ root, bundle, descriptor = {},
110
135
  : identities.filter((item) => item.name.startsWith("Developer ID Application:") && item.name.endsWith(`(${releaseTeam})`));
111
136
  if (requestedIdentity) candidates = candidates.filter((item) => matchesRequest(item, requestedIdentity));
112
137
  else if (profileInfo && configuredIdentity && candidates.some((item) => matchesRequest(item, configuredIdentity))) candidates = candidates.filter((item) => matchesRequest(item, configuredIdentity));
138
+ // One identity for dev and release (no new id): Developer ID Application only. An Apple Development or Mac Developer certificate gives a different
139
+ // designated requirement, so macOS would drop the app's privacy grants when dev and release alternate. Never a fallback.
140
+ if (!candidates.length) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_DEVELOPER_ID_REQUIRED", `Signing identity ${JSON.stringify(expected)}: no Developer ID Application identity with its private key is in Keychain${profileInfo ? ` (and authorized by ${provisioningProfile})` : ""}. Dev builds sign with the same Developer ID Application identity as release. Install that identity${profileInfo ? ", and use a Developer ID provisioning profile (not Apple Development) if the app's entitlements need one" : ""}; there is no fallback to Apple Development or ad hoc signing.`, { identity: expected });
113
141
  if (candidates.length !== 1) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_IDENTITY_MISSING", `Signing identity ${JSON.stringify(expected)}: ${candidates.length ? "multiple matching identities" : "no valid matching identity/private key"} in Keychain${provisioningProfile ? ` for ${provisioningProfile}` : ""}. ${candidates.length ? "Set APPLE_SIGNING_IDENTITY or APPLE_DEVELOPER_ID to one matching identity." : "Confirm the profile-authorized certificate and its private key are available in Keychain Access in a logged-in GUI session."}`, { identity: expected });
114
142
  return { identity: candidates[0].name, certificateSha1: candidates[0].sha1, teamIdentifier: profileInfo?.team ?? releaseTeam, entitlements, provisioningProfile };
115
143
  }
@@ -153,6 +181,20 @@ export function signMacosDevelopmentBundle(options) {
153
181
  .filter((name) => name.startsWith(`${path.basename(entitlements, path.extname(entitlements))}-`) && name.endsWith(".plist"))
154
182
  .map((name) => ({ file: path.join(path.dirname(entitlements), name), suffix: path.basename(name, ".plist").slice(path.basename(entitlements, path.extname(entitlements)).length + 1).toLowerCase() }))
155
183
  .sort((a, b) => b.suffix.length - a.suffix.length) : [];
184
+ // Ad hoc signatures cannot carry profile-restricted entitlements (iCloud, keychain-access-groups, application-identifier):
185
+ // AMFI would kill the app at launch. Ad hoc mode therefore applies only the hardened-runtime relaxations and audio input.
186
+ const safeFiles = new Map();
187
+ const entitlementFile = (file) => {
188
+ if (!signing.adhoc) return file;
189
+ if (!safeFiles.has(file)) {
190
+ const safe = Object.fromEntries(Object.entries(readPlist(file, context)).filter(([key]) => key.startsWith("com.apple.security.cs.") || key === "com.apple.security.device.audio-input"));
191
+ const out = path.join(scratch, `adhoc-${safeFiles.size}.plist`);
192
+ writeFileSync(out, JSON.stringify(safe));
193
+ checked("plutil", ["-convert", "xml1", out], context, "DEV_MAC_SIGNING_CONFIG_INVALID", identity);
194
+ safeFiles.set(file, out);
195
+ }
196
+ return safeFiles.get(file);
197
+ };
156
198
  const targets = [];
157
199
  const bundleExecutables = new Set();
158
200
  const visit = (directory, isBundle = false) => {
@@ -181,8 +223,8 @@ export function signMacosDevelopmentBundle(options) {
181
223
  const profiledHelper = target.info && existsSync(path.join(target.file, "Contents/embedded.provisionprofile")) && target.info.CFBundleIdentifier === appInfo.CFBundleIdentifier;
182
224
  if (profiledHelper && !parent && provisioningProfile) copyFileSync(embedded, path.join(target.file, "Contents/embedded.provisionprofile"));
183
225
  const variant = !parent && entitlementVariants.find((item) => path.basename(target.file).replace(/\.(app|xpc|appex|bundle)$/, "").toLowerCase().endsWith(item.suffix));
184
- if (variant) args.push("--entitlements", variant.file);
185
- else if (entitlements && (parent || profiledHelper)) args.push("--entitlements", entitlements);
226
+ if (variant) args.push("--entitlements", entitlementFile(variant.file));
227
+ else if (entitlements && (parent || profiledHelper)) args.push("--entitlements", entitlementFile(entitlements));
186
228
  else {
187
229
  const existing = run("codesign", ["--display", "--entitlements", ":-", target.file], captureOptions(root, env));
188
230
  if (existing.status === 0 && /<plist|<dict/.test(String(existing.stdout ?? ""))) args.push("--preserve-metadata=entitlements");
@@ -194,8 +236,33 @@ export function signMacosDevelopmentBundle(options) {
194
236
  for (const [index, args] of commands.entries()) checked("codesign", args, { ...context, run: () => signed[index] }, "DEV_MAC_SIGNING_FAILED", identity);
195
237
  checked("codesign", ["--verify", "--strict", "--deep", bundle], context, "DEV_MAC_SIGNATURE_INVALID", identity);
196
238
  const details = output(checked("codesign", ["--display", "--verbose=4", bundle], context, "DEV_MAC_SIGNATURE_INVALID", identity));
239
+ if (signing.adhoc) {
240
+ // No TeamIdentifier or identity-based designated requirement to check: an ephemeral runner has no TCC state to keep stable.
241
+ if (!/^Signature=adhoc$/m.test(details)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNATURE_INVALID", `CI ad hoc signing: expected Signature=adhoc on ${bundle}. Refusing dev installation.`, { identity });
242
+ return { ...signing, provisioningProfile: null, verified: "strict-deep", designatedRequirement: null, signedTargets: ordered.length, releaseArtifact: false,
243
+ receipt: { signing: CI_ADHOC_SIGNING, signature: "adhoc", releaseArtifact: false, note: "Ad hoc signed on a GitHub-hosted CI runner for candidate qualification only; never a release artifact. Release signing happens in the protected sign job." } };
244
+ }
197
245
  const actualTeam = /^TeamIdentifier=(.+)$/m.exec(details)?.[1]?.trim();
198
246
  if (actualTeam !== teamIdentifier || /^Signature=adhoc$/m.test(details)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_TEAM_MISMATCH", `Signing identity ${JSON.stringify(identity)}: expected TeamIdentifier=${teamIdentifier}, found ${actualTeam ?? "missing"}. Refusing dev installation.`, { identity, expectedTeam: teamIdentifier, actualTeam });
199
- return { ...signing, provisioningProfile: provisioningProfile ? embedded : null, verified: "strict-deep", signedTargets: ordered.length };
247
+ // Fail closed unless the designated requirement is identity-based: an ad hoc or cdhash requirement changes every build, so macOS TCC re-prompts.
248
+ const requirement = output(checked("codesign", ["--display", "-r-", bundle], context, "DEV_MAC_SIGNATURE_INVALID", identity));
249
+ const designated = /^designated => (.+)$/m.exec(requirement)?.[1]?.trim();
250
+ if (!designated || /\bcdhash\b/.test(designated)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_UNSTABLE_REQUIREMENT", `Signing identity ${JSON.stringify(identity)}: designated requirement ${designated ? JSON.stringify(designated) : "is missing"} is not stable across rebuilds (cdhash-bound). Refusing dev installation.`, { identity, designatedRequirement: designated });
251
+ return { ...signing, provisioningProfile: provisioningProfile ? embedded : null, verified: "strict-deep", designatedRequirement: designated, signedTargets: ordered.length };
200
252
  } finally { rmSync(scratch, { recursive: true, force: true }); }
201
253
  }
254
+
255
+ /**
256
+ * One stable designated requirement across dev installs. macOS keys privacy grants (TCC) to the code identity, so a dev
257
+ * build whose designated requirement differs from the installed copy's would silently drop every grant. Fails with
258
+ * DEV_MAC_SIGNING_REQUIREMENT_CHANGED. An installed copy that is unsigned, ad hoc or cdhash-bound has no stable
259
+ * grants to keep, so it does not block the move to the stable identity.
260
+ */
261
+ export function assertStableDesignatedRequirement({ root, installed, expected, env = process.env, run = spawnSync }) {
262
+ const result = run("codesign", ["--display", "-r-", installed], captureOptions(root, env));
263
+ if (result.error || result.status !== 0) return null;
264
+ const current = /^designated => (.+)$/m.exec(output(result))?.[1]?.trim();
265
+ if (!current || /\bcdhash\b/.test(current)) return null;
266
+ if (current !== expected) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_REQUIREMENT_CHANGED", `Installed ${installed} has designated requirement ${JSON.stringify(current)} but this dev build is signed with ${JSON.stringify(expected)}. Installing it would reset the app's macOS privacy grants. Sign with the same identity as the installed copy (the one stable Developer ID) or remove the installed app deliberately.`, { designatedRequirement: expected, installedRequirement: current });
267
+ return current;
268
+ }
package/development.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { discoverBrokerSocket, resolveManagedRoots } from './managed-roots.mjs';
2
2
  import { spawnSync } from "./background-process.mjs";
3
3
  import { createHash } from "node:crypto";
4
- import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
4
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, lstatSync, readdirSync, realpathSync, renameSync, rmSync, statSync } from "node:fs";
5
5
  import path from "node:path";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
7
7
  import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
@@ -9,10 +9,22 @@ import { assertReleaseExecutionAllowed } from "./execution-policy.mjs";
9
9
  import { acquireManagedRunLease, managedRuntimeClient, managedRuntimeCommand, runCargoSync } from "./managed-cargo.mjs";
10
10
  import { BUNDLE_MARKERS, bundleMarkerBuffer, readTauriBundleMarkers } from "./tauri-bundle-marker.mjs";
11
11
  import { installPortableDevelopment } from "./portable-development.mjs";
12
- import { readBundleExecutable, signMacosDevelopmentBundle } from "./development-signing.mjs";
12
+ import { previousPathOf, stagingPathOf, swapInPlace } from "./in-place-install.mjs";
13
+ import { stageDevelopmentBinaries, validateStageEntries } from "./stage-binary.mjs";
14
+ import { assertStableDesignatedRequirement, readBundleExecutable, requestedCiAdhocSigning, signMacosDevelopmentBundle } from "./development-signing.mjs";
13
15
  import { macosCodesignEnvironment } from "./codesign-environment.mjs";
16
+ import { androidDevelopmentNdkCommand, androidDevelopmentSerial, installAndroidDevelopment, validateAndroidDevelopment } from "./android-dev.mjs";
14
17
 
15
- export const DEVELOPMENT_PROFILE = "release-iterate";
18
+ // One standard Cargo profile for every app (templates/cargo-profiles.toml): Cargo's built-in `dev`.
19
+ // `--profile dev` is valid and no flag also means dev.
20
+ export const DEVELOPMENT_PROFILE = "dev";
21
+ // Cargo writes the `dev` (and `test`) profile to target/<triple>/debug, never .../dev; `bench` goes to release.
22
+ // Every path built from a profile name must go through this.
23
+ export function cargoProfileDirectory(profile) {
24
+ if (profile === "dev" || profile === "test") return "debug";
25
+ if (profile === "bench") return "release";
26
+ return profile;
27
+ }
16
28
  const RELEASE_ONLY_APPLE_ENV = ["APPLE_ID", "APPLE_PASSWORD", "APPLE_API_KEY", "APPLE_API_ISSUER", "APPLE_API_KEY_PATH", "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD"];
17
29
 
18
30
  export function developmentEnvironment(env = process.env) {
@@ -26,6 +38,8 @@ export function developmentEnvironment(env = process.env) {
26
38
  };
27
39
  for (const key of RELEASE_ONLY_APPLE_ENV) delete result[key];
28
40
  delete result.CARGO_INCREMENTAL;
41
+ // A dev build is debug-signed: a release signing-properties path never reaches it.
42
+ delete result.RIGHTKIT_ANDROID_SIGNING_PROPERTIES;
29
43
  const client = managedRuntimeClient(result);
30
44
  if (client) {
31
45
  const { agentBin } = resolveManagedRoots({ env: result });
@@ -41,7 +55,7 @@ export function developmentEnvironment(env = process.env) {
41
55
  return result;
42
56
  }
43
57
 
44
- export function validateDevelopmentConfig(config, platform) {
58
+ export function validateDevelopmentConfig(config, platform, { root = process.cwd() } = {}) {
45
59
  const target = config?.development?.targets?.[platform];
46
60
  if (!target || !Array.isArray(target.build) || target.build.length === 0) throw new Error(`development.targets.${platform}.build must be a non-empty command array`);
47
61
  for (const [index, command] of target.build.entries()) validateCommand(command, `development.targets.${platform}.build[${index}]`);
@@ -56,28 +70,42 @@ export function validateDevelopmentConfig(config, platform) {
56
70
  for (const key of ["sourceDirectory", "entrypoint"]) {
57
71
  if (typeof descriptor[key] !== "string" || !descriptor[key] || path.isAbsolute(descriptor[key]) || path.win32.isAbsolute(descriptor[key]) || descriptor[key].split(/[\\/]/).includes("..")) throw new Error(`portable development ${key} must be a contained relative path`);
58
72
  }
73
+ } else if (target.install?.kind === "adb") {
74
+ if (platform !== "android") throw new Error("the adb development installer is for --platform android");
59
75
  } else if (target.install) throw new Error(`unsupported development installer kind: ${target.install.kind}`);
76
+ validateStageEntries(target.stage, `development.targets.${platform}.stage`);
77
+ if (platform === "android") return { ...target, ...validateAndroidDevelopment(target, { root }) };
60
78
  return target;
61
79
  }
62
80
 
63
81
  export function runDevelopment({ root, platform, config, buildOnly = false, dryRun = false, env = process.env, run = spawnSync,
64
- codesignEnvironment = macosCodesignEnvironment }) {
65
- if (!new Set(["win", "mac"]).has(platform)) throw new Error("--platform must be win or mac");
82
+ codesignEnvironment = macosCodesignEnvironment, serial }) {
83
+ if (!new Set(["win", "mac", "android"]).has(platform)) throw new Error("--platform must be win, mac or android");
66
84
  assertNativeDevelopmentHost(platform);
67
- const target = validateDevelopmentConfig(config, platform);
85
+ const target = validateDevelopmentConfig(config, platform, { root });
68
86
  const repoRoot = git(root, ["rev-parse", "--show-toplevel"]);
69
87
  assertPrimaryReleaseCheckout(repoRoot, env);
70
- if (dryRun) return { built: false, installed: false, plan: { build: target.build, install: buildOnly ? null : target.install ?? null, profile: DEVELOPMENT_PROFILE } };
88
+ if (dryRun) return { built: false, installed: false, plan: { ...(target.ndk ? { ndk: androidDevelopmentNdkCommand(target.ndk) } : {}), build: target.build, install: buildOnly ? null : target.install ?? null, profile: DEVELOPMENT_PROFILE } };
71
89
  assertReleaseExecutionAllowed({ cwd: repoRoot, environment: env, command: `right-release dev --platform ${platform}` });
90
+ // Refuse an invalid or off-CI ad hoc opt-in before the (long) build, not after it.
91
+ if (!buildOnly && platform === "mac" && target.install?.kind === "tauri") requestedCiAdhocSigning({ descriptor: target.install, env });
92
+ // Android: the device serial is required, so a missing one is refused before the (long) build too.
93
+ if (!buildOnly && platform === "android" && target.install) androidDevelopmentSerial({ descriptor: target.install, serial, env });
72
94
  const childEnv = codesignEnvironment(developmentEnvironment(env));
73
95
  // One lease spans every managed request this run makes, so a broker cutover cannot land between them.
74
96
  const lease = acquireManagedRunLease({ command: `right-release dev --platform ${platform}`, env });
75
97
  try {
76
98
  const startedMs = Date.now();
99
+ // Android: cargo-ndk (Cargo dev profile) fills the generated jniLibs, then the Gradle debug build packages it.
100
+ if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, label: "native build" }); }
77
101
  for (const command of target.build) execute(command, { root, env: childEnv, dryRun, run, label: "build" });
78
- if (buildOnly || !target.install) return { built: true, installed: false };
79
- const signing = install(target.install, { root, platform, config, env: childEnv, run, sinceMs: startedMs });
80
- return { built: true, installed: true, ...(platform === "mac" && target.install.kind === "tauri" ? { signing } : {}) };
102
+ // Dev and QA binaries run from one stable staged path, copied in place (<name>.previous kept), never from the
103
+ // broker's per-generation target folder or a hashed dev-targets folder.
104
+ const staged = target.stage?.length ? stageDevelopmentBinaries(target.stage, { root, platform: platform === "win" ? "win32" : process.platform }) : [];
105
+ for (const file of staged) console.log(`right-release dev: staged ${file}`);
106
+ if (buildOnly || !target.install) return { built: true, installed: false, ...(staged.length ? { staged } : {}) };
107
+ const signing = install(target.install, { root, platform, config, env: childEnv, run, sinceMs: startedMs, serial });
108
+ return { built: true, installed: true, ...(staged.length ? { staged } : {}), ...(platform === "mac" && target.install.kind === "tauri" ? { signing } : {}) };
81
109
  } finally { lease.release(); }
82
110
  }
83
111
 
@@ -85,7 +113,8 @@ export function assertNativeDevelopmentHost(platform, host = process.platform) {
85
113
  if ((platform === "win" && host !== "win32") || (platform === "mac" && host !== "darwin")) throw new Error(`development target ${platform} requires its native host`);
86
114
  }
87
115
 
88
- function install(descriptor, { root, platform, config, env, run, sinceMs = null }) {
116
+ function install(descriptor, { root, platform, config, env, run, sinceMs = null, serial }) {
117
+ if (descriptor.kind === "adb") return installAndroidDevelopment(descriptor, { root, env, run, serial });
89
118
  if (descriptor.kind === "command") return execute(descriptor, { root, env, run, label: "install" });
90
119
  if (descriptor.kind === "portable") return installPortableDevelopment(descriptor, { root, platform: platform === "win" ? "win32" : "darwin", env });
91
120
  if (platform === "win") {
@@ -97,6 +126,9 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null
97
126
  // was running, pass /R so the installer relaunches it as the user (RunAsUser, outside this process tree).
98
127
  const running = isWindowsProcessRunning(path.basename(descriptor.expectedInstalledPath), { run });
99
128
  if (running) console.log(`right-release dev: ${descriptor.productName} is running; the installer will close it and relaunch it`);
129
+ // The silent installer updates the fixed path in place. Keep the copy it replaces once, as <exe>.previous (never run).
130
+ const installedBefore = path.resolve(root, descriptor.expectedInstalledPath);
131
+ if (existsSync(installedBefore) && statSync(installedBefore).isFile()) copyFileSync(installedBefore, previousPathOf(installedBefore));
100
132
  const result = run(app, windowsInstallerArguments({ running }), { cwd: root, env, stdio: "inherit", windowsHide: true, shell: false });
101
133
  if (result.status !== 0) throw new Error(`development installer failed (${result.status}): ${app}`);
102
134
  const installed = path.resolve(root, descriptor.expectedInstalledPath);
@@ -104,23 +136,27 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null
104
136
  return;
105
137
  }
106
138
  const app = resolveTauriArtifact(descriptor, { root, platform, env });
107
- const destination = path.resolve(descriptor.destination ?? path.join("/Applications", path.basename(app)));
108
- const destinationRoot = path.dirname(destination);
109
- mkdirSync(destinationRoot, { recursive: true });
110
- const staging = `${destination}.rightkit-dev-${process.pid}`;
111
- const backup = `${destination}.rightkit-backup-${process.pid}`;
139
+ // One fixed install path per app: macOS keys privacy grants to it. No /Applications/<name> guess.
140
+ if (!descriptor.destination) throw new DevelopmentInstallError("DEV_INSTALL_DESTINATION_REQUIRED", "development.targets.mac.install.destination is required: the one fixed path this app installs to (for example /Applications/<Product>.app)");
141
+ const destination = path.resolve(descriptor.destination);
142
+ mkdirSync(path.dirname(destination), { recursive: true });
143
+ // Fixed staging name (not per-pid): the copy is signed and verified here, then renamed into place. Nothing runs from it.
144
+ const staging = stagingPathOf(destination);
145
+ const previous = previousPathOf(destination);
112
146
  rmSync(staging, { recursive: true, force: true });
113
- rmSync(backup, { recursive: true, force: true });
114
147
  cpSync(app, staging, { recursive: true, verbatimSymlinks: true });
115
148
  let signing;
116
149
  let executableName;
117
150
  try {
118
151
  signing = signMacosDevelopmentBundle({ root, bundle: staging, descriptor, config, env, run });
152
+ // Same identity as the installed copy, or fail: a changed designated requirement resets every privacy grant.
153
+ if (!signing.adhoc && existsSync(destination)) assertStableDesignatedRequirement({ root, installed: destination, expected: signing.designatedRequirement, env, run });
119
154
  executableName = readBundleExecutable(staging, { root, env, run });
120
155
  } catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; }
121
- swapInstalledMacosBundle({ staging, destination, backup, executableName });
156
+ swapInstalledMacosBundle({ staging, destination, previous, executableName });
122
157
  if (signing.provisioningProfile) signing.provisioningProfile = path.join(destination, "Contents", "embedded.provisionprofile");
123
- console.log(`right-release dev: installed ${destination}; identity=${signing.identity}; TeamIdentifier=${signing.teamIdentifier}; profile=${signing.provisioningProfile ?? "none"}`);
158
+ if (signing.adhoc) console.log(`right-release dev: installed ${destination}; signing=ci-adhoc (ad hoc signed on a CI runner; NOT a release artifact); signature=adhoc; TeamIdentifier=none`);
159
+ else console.log(`right-release dev: installed ${destination}; identity=${signing.identity}; TeamIdentifier=${signing.teamIdentifier}; profile=${signing.provisioningProfile ?? "none"}; designated=${signing.designatedRequirement}`);
124
160
  return signing;
125
161
  }
126
162
 
@@ -128,15 +164,15 @@ export class DevelopmentInstallError extends Error {
128
164
  constructor(code, message) { super(message); this.name = "DevelopmentInstallError"; this.code = code; }
129
165
  }
130
166
 
131
- const NODE_FS = { existsSync, readFileSync, renameSync, rmSync, statSync };
167
+ const NODE_FS = { existsSync, lstatSync, mkdirSync, copyFileSync, readFileSync, renameSync, rmSync, statSync };
132
168
 
133
169
  /**
134
- * Move the signed staging bundle into place and verify it before the previous app's backup is removed: Info.plist exists and
170
+ * Move the signed staging bundle into place, keeping the previous app as `<destination>.previous`, and verify it: Info.plist exists and
135
171
  * the installed main executable (`executableName`, the staged CFBundleExecutable read through plutil by the caller) exists with the same SHA-256 as the staged one. Any failure removes the
136
- * new install, restores the backup and throws a DevelopmentInstallError (code DEV_INSTALL_VERIFICATION_FAILED,
172
+ * new install, renames `.previous` back and throws a DevelopmentInstallError (code DEV_INSTALL_VERIFICATION_FAILED,
137
173
  * DEV_INSTALL_EXECUTABLE_MISSING or DEV_INSTALL_EXECUTABLE_MISMATCH). `fs` is the file-system seam (tests inject it).
138
174
  */
139
- export function swapInstalledMacosBundle({ staging, destination, backup, executableName, fs = NODE_FS }) {
175
+ export function swapInstalledMacosBundle({ staging, destination, previous = previousPathOf(destination), executableName, fs = NODE_FS }) {
140
176
  if (typeof executableName !== "string" || !executableName) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", "swapInstalledMacosBundle requires the staged CFBundleExecutable name");
141
177
  const executable = path.join("Contents", "MacOS", executableName);
142
178
  const digest = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
@@ -146,23 +182,18 @@ export function swapInstalledMacosBundle({ staging, destination, backup, executa
146
182
  fs.rmSync(staging, { recursive: true, force: true });
147
183
  throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISSING", `staged app has no main executable ${executable}: ${staging} (${error.message})`);
148
184
  }
149
- let backedUp = false;
150
- try {
151
- if (fs.existsSync(destination)) { fs.renameSync(destination, backup); backedUp = true; }
152
- fs.renameSync(staging, destination);
153
- if (!fs.existsSync(path.join(destination, "Contents", "Info.plist"))) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", `installed app verification failed: ${destination}`);
154
- const installed = path.join(destination, executable);
155
- let actual;
156
- try { actual = digest(installed); }
157
- catch { throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISSING", `installed app verification failed: main executable ${executable} is missing in ${destination}`); }
158
- if (actual !== expected) throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISMATCH", `installed app verification failed: ${executable} SHA-256 ${actual} does not match the staged ${expected}`);
159
- if (backedUp) fs.rmSync(backup, { recursive: true, force: true });
160
- } catch (error) {
161
- fs.rmSync(destination, { recursive: true, force: true });
162
- if (backedUp && fs.existsSync(backup)) fs.renameSync(backup, destination);
163
- fs.rmSync(staging, { recursive: true, force: true });
164
- throw error;
165
- }
185
+ // The old app becomes `<destination>.previous` (replacing any older one) and stays there for rollback; any failure renames it back.
186
+ swapInPlace({
187
+ staging, destination, previous, fs,
188
+ verify: () => {
189
+ if (!fs.existsSync(path.join(destination, "Contents", "Info.plist"))) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", `installed app verification failed: ${destination}`);
190
+ const installed = path.join(destination, executable);
191
+ let actual;
192
+ try { actual = digest(installed); }
193
+ catch { throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISSING", `installed app verification failed: main executable ${executable} is missing in ${destination}`); }
194
+ if (actual !== expected) throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISMATCH", `installed app verification failed: ${executable} SHA-256 ${actual} does not match the staged ${expected}`);
195
+ },
196
+ });
166
197
  }
167
198
 
168
199
  export function resolveTauriArtifact(descriptor, { root, platform, env = process.env, runCargo = runCargoSync }) {
@@ -223,7 +254,7 @@ function resolveBundleRoot(descriptor, { root, env, runCargo }) {
223
254
  let targetDirectory;
224
255
  try { targetDirectory = JSON.parse(metadata.stdout).target_directory; } catch { throw new Error("RightKit Cargo metadata returned invalid JSON"); }
225
256
  if (!targetDirectory || !path.isAbsolute(targetDirectory)) throw new Error("Cargo metadata omitted absolute target_directory");
226
- return path.join(targetDirectory, descriptor.targetTriple, DEVELOPMENT_PROFILE, "bundle", platformBundleKind(descriptor));
257
+ return path.join(targetDirectory, descriptor.targetTriple, cargoProfileDirectory(DEVELOPMENT_PROFILE), "bundle", platformBundleKind(descriptor));
227
258
  }
228
259
 
229
260
  function platformBundleKind(descriptor) { return descriptor.targetTriple.includes("apple") ? "macos" : "nsis"; }
@@ -290,16 +321,17 @@ function resolveJavaScriptEntrypoint(candidate) {
290
321
  function git(cwd, args) { const result = spawnSync("git", args, { cwd, encoding: "utf8", windowsHide: true }); if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed`); return result.stdout.trim(); }
291
322
  function inside(root, child) { const rel = path.relative(path.resolve(root), path.resolve(child)); return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)); }
292
323
  export async function main(args = process.argv.slice(2)) {
293
- let platform, configPath = "right-release.config.mjs", dryRun = false, buildOnly = false;
324
+ let platform, configPath = "right-release.config.mjs", dryRun = false, buildOnly = false, serial;
294
325
  for (let i = 0; i < args.length; i++) {
295
- if (args[i] === "-h" || args[i] === "--help") { console.log("usage: right-release dev --platform win|mac [--config right-release.config.mjs] [--dry-run] [--build-only]"); return; }
326
+ if (args[i] === "-h" || args[i] === "--help") { console.log("usage: right-release dev --platform win|mac|android [--config right-release.config.mjs] [--dry-run] [--build-only] [--serial <adb serial>]"); return; }
296
327
  if (args[i] === "--platform") platform = args[++i];
297
328
  else if (args[i] === "--config") configPath = args[++i];
298
329
  else if (args[i] === "--dry-run") dryRun = true;
299
330
  else if (args[i] === "--build-only") buildOnly = true;
331
+ else if (args[i] === "--serial") serial = args[++i];
300
332
  else throw new Error(`unknown dev option: ${args[i]}`);
301
333
  }
302
- if (!platform) throw new Error("right-release dev requires --platform win|mac");
334
+ if (!platform) throw new Error("right-release dev requires --platform win|mac|android");
303
335
  const absoluteConfig = path.resolve(configPath);
304
336
  const safeEnv = developmentEnvironment(process.env);
305
337
  for (const [key, value] of Object.entries(safeEnv)) process.env[key] = value;
@@ -309,7 +341,7 @@ export async function main(args = process.argv.slice(2)) {
309
341
  if (pathKey) process.env[pathKey] = safeEnv[pathKey] ?? process.env[pathKey];
310
342
  const loaded = await import(pathToFileURL(absoluteConfig));
311
343
  const config = loaded.default ?? loaded.config ?? loaded;
312
- const result = runDevelopment({ root: path.dirname(absoluteConfig), platform, config, dryRun, buildOnly });
344
+ const result = runDevelopment({ root: path.dirname(absoluteConfig), platform, config, dryRun, buildOnly, serial });
313
345
  if (dryRun) console.log(JSON.stringify(result.plan, null, 2));
314
346
  return result;
315
347
  }
@@ -288,13 +288,14 @@ export function renderPowerShellBootstrap({
288
288
  "$Contract = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(" + psQuote(contractB64) + ")) | ConvertFrom-Json",
289
289
  "$AcceptedSigners = @{}; $Contract.acceptedManifestSigners.PSObject.Properties | ForEach-Object { $AcceptedSigners[$_.Name] = $_.Value }",
290
290
  "$InstallRoot = Join-Path $env:LOCALAPPDATA " + psQuote(installRootSubdir),
291
- "$VersionsRoot = Join-Path $InstallRoot 'versions'",
291
+ // In place at one fixed path: no versions/ folder, no per-version, per-digest or per-run directory, no junction.
292
+ // `current` is the real install; `current.staging` is written and verified but never run; `current.previous` is the one rollback copy.
292
293
  "$Current = Join-Path $InstallRoot 'current'",
293
- "$PreviousLink = Join-Path $InstallRoot '.current-previous'",
294
- "$NextLink = Join-Path $InstallRoot '.current-next'",
294
+ "$Staging = Join-Path $InstallRoot 'current.staging'",
295
+ "$Previous = Join-Path $InstallRoot 'current.previous'",
295
296
  "$Journal = Join-Path $InstallRoot 'integration-journal.json'",
296
297
  "function Expand-BootstrapArgs($Values, $ActiveVersionRoot, $ActiveVersion) { foreach ($Value in $Values) { ([string]$Value).Replace('{current}', $Current).Replace('{installRoot}', $InstallRoot).Replace('{versionRoot}', $ActiveVersionRoot).Replace('{version}', $ActiveVersion) } }",
297
- "function Remove-StableJunction([string]$Path) { $Item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue; if ($null -eq $Item) { return }; if ($Item.LinkType -ne 'Junction') { throw ('Stable pointer path is not a junction: ' + $Path) }; [IO.Directory]::Delete($Path) }",
298
+ "function Remove-Retired([string]$Path) { $Item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue; if ($null -eq $Item) { return }; if ($Item.LinkType -eq 'Junction') { [IO.Directory]::Delete($Path) } else { Remove-Item -LiteralPath $Path -Recurse -Force } }",
298
299
  "function Resolve-HealthPath($Value, [string]$JsonPath) { $CurrentValue = $Value; foreach ($Segment in $JsonPath.Split('.')) { if ($Segment -match '^\\d+$') { $Index = [int]$Segment; if ($null -eq $CurrentValue -or $Index -ge @($CurrentValue).Count) { throw ('Health path is missing: ' + $JsonPath) }; $CurrentValue = @($CurrentValue)[$Index] } else { $Property = $CurrentValue.PSObject.Properties[$Segment]; if ($null -eq $Property) { throw ('Health path is missing: ' + $JsonPath) }; $CurrentValue = $Property.Value } }; return $CurrentValue }",
299
300
  "function Assert-Health($Health, [string]$ActiveVersion) { foreach ($Assertion in $Contract.healthAssertions) { $Actual = Resolve-HealthPath $Health ([string]$Assertion.path); if ($Assertion.PSObject.Properties.Name -contains 'equals') { $Expected = $Assertion.equals; if ($Expected -is [string]) { $Expected = $Expected.Replace('{version}', $ActiveVersion).Replace('{current}', $Current).Replace('{installRoot}', $InstallRoot) }; if ($Actual -ne $Expected) { throw ('Health assertion failed: ' + $Assertion.path) } } elseif ($Assertion.nonempty -eq $true) { if ($null -eq $Actual -or ([string]$Actual).Trim().Length -eq 0) { throw ('Health assertion failed: ' + $Assertion.path) } } elseif (@($Actual).Count -lt [int]$Assertion.minCount) { throw ('Health assertion failed: ' + $Assertion.path) } } }",
300
301
  "function Resolve-PriorVersion($PriorExePath, $PriorLeaf) { if ($PriorExePath -and (Test-Path -LiteralPath $PriorExePath -PathType Leaf)) { $Info = (Get-Item -LiteralPath $PriorExePath).VersionInfo; $Candidate = [string]$Info.ProductVersion; if ([string]::IsNullOrWhiteSpace($Candidate)) { $Candidate = [string]$Info.FileVersion }; $Match = [regex]::Match($Candidate, '(\\d+\\.\\d+\\.\\d+)'); if ($Match.Success) { return $Match.Groups[1].Value } }; if ($PriorLeaf) { $LeafMatch = [regex]::Match([string]$PriorLeaf, '^(\\d+\\.\\d+\\.\\d+)(?:-|$)'); if ($LeafMatch.Success) { return $LeafMatch.Groups[1].Value } }; return $null }",
@@ -347,7 +348,7 @@ function Invoke-BoundedExe($Path, [string[]]$Arguments, [int]$TimeoutSeconds) {
347
348
  return $Stdout
348
349
  }`,
349
350
  ...(allowLocalReleaseRoot ? [
350
- "New-Item -ItemType Directory -Force -Path $VersionsRoot | Out-Null",
351
+ "New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null",
351
352
  "$LocalReleaseRoot = $null; if ($ReleaseRoot) { $LocalReleaseRoot = [IO.Path]::GetFullPath($ReleaseRoot); if (-not (Test-Path -LiteralPath $LocalReleaseRoot -PathType Container)) { throw 'Local release root is missing' } }",
352
353
  "if (-not $Version -and -not $LocalReleaseRoot) { $Version = ((Invoke-RestMethod " + psQuote("https://api.github.com/repos/" + repository + "/releases/latest") + ").tag_name -replace '^v','') }",
353
354
  "if ($Version -and $Version -notmatch '^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$') { throw 'Invalid stable release version' }",
@@ -363,11 +364,11 @@ function Invoke-BoundedExe($Path, [string[]]$Arguments, [int]$TimeoutSeconds) {
363
364
  "Receive-ReleaseFile 'release-manifest.json' $ManifestPath",
364
365
  "Receive-ReleaseFile 'release-manifest.cat' $SignaturePath",
365
366
  ] : [
366
- "New-Item -ItemType Directory -Force -Path $VersionsRoot | Out-Null",
367
+ "New-Item -ItemType Directory -Force -Path $InstallRoot | Out-Null",
367
368
  "if (-not $Version) { $Version = ((Invoke-RestMethod " + psQuote("https://api.github.com/repos/" + repository + "/releases/latest") + ").tag_name -replace '^v','') }",
368
369
  "if ($Version -notmatch '^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$') { throw 'Invalid stable release version' }",
369
- "$PriorTarget = $null; if (Test-Path $Current) { $PriorTarget = (Get-Item $Current).Target }",
370
- "$PriorVersion = if ($PriorTarget) { Resolve-PriorVersion (Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + ") (Split-Path ([string]$PriorTarget) -Leaf) } else { $null }",
370
+ "$PriorTarget = $null; if (Test-Path $Current) { $PriorTarget = $Current }",
371
+ "$PriorVersion = if ($PriorTarget) { Resolve-PriorVersion (Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + ") $null } else { $null }",
371
372
  "if ($PriorVersion -and -not $AllowDowngrade -and ([version]$Version -lt [version]$PriorVersion)) { throw 'Downgrade requires -AllowDowngrade' }",
372
373
  "$ReleaseBase = " + psQuote("https://github.com/" + repository + "/releases/download/v") + " + $Version",
373
374
  "$Work = Join-Path ([IO.Path]::GetTempPath()) (" + psQuote(product + "-install-") + " + [guid]::NewGuid())",
@@ -392,8 +393,8 @@ function Invoke-BoundedExe($Path, [string[]]$Arguments, [int]$TimeoutSeconds) {
392
393
  "if (-not $Version) { $Version = [string]$Manifest.version }",
393
394
  "if ($Version -notmatch '^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$') { throw 'Invalid stable release version' }",
394
395
  "$ReleaseBase = " + psQuote("https://github.com/" + repository + "/releases/download/v") + " + $Version",
395
- "$PriorTarget = $null; if (Test-Path $Current) { $PriorTarget = (Get-Item $Current).Target }",
396
- "$PriorVersion = if ($PriorTarget) { Resolve-PriorVersion (Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + ") (Split-Path ([string]$PriorTarget) -Leaf) } else { $null }",
396
+ "$PriorTarget = $null; if (Test-Path $Current) { $PriorTarget = $Current }",
397
+ "$PriorVersion = if ($PriorTarget) { Resolve-PriorVersion (Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + ") $null } else { $null }",
397
398
  "if ($PriorVersion -and -not $AllowDowngrade -and ([version]$Version -lt [version]$PriorVersion)) { throw 'Downgrade requires -AllowDowngrade' }",
398
399
  ] : []),
399
400
  "if ($Manifest.signingKeyId -ne $VerifiedKeyId -or $Manifest.signatureAlgorithm -ne 'authenticode-catalog-sha256' -or $Manifest.signatureProvider -ne 'windows-authenticode-catalog' -or $Manifest.signatureProviderVersion -ne 1) { throw 'Release manifest signer identity mismatch' }",
@@ -421,8 +422,8 @@ function Invoke-BoundedExe($Path, [string[]]$Arguments, [int]$TimeoutSeconds) {
421
422
  "$Stage = Join-Path $Work 'stage'; New-Item -ItemType Directory -Force -Path $Stage | Out-Null",
422
423
  "Add-Type -AssemblyName System.IO.Compression.FileSystem; $Zip = [IO.Compression.ZipFile]::OpenRead($Archive); try { foreach ($Entry in $Zip.Entries) { $Dest = [IO.Path]::GetFullPath((Join-Path $Stage $Entry.FullName)); if (-not $Dest.StartsWith(([IO.Path]::GetFullPath($Stage) + [IO.Path]::DirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase)) { throw 'Archive path escapes staging root' } } } finally { $Zip.Dispose() }",
423
424
  "Expand-Archive -Path $Archive -DestinationPath $Stage -Force",
424
- "$VersionRoot = Join-Path $VersionsRoot ($Version + '-' + $Asset.sha256.Substring(0,12) + '-' + [guid]::NewGuid()); Move-Item $Stage $VersionRoot",
425
- "$VersionExe = Join-Path $VersionRoot ($Asset.executablePath -replace '/', [IO.Path]::DirectorySeparatorChar); if (-not (Test-Path $VersionExe)) { throw 'Release executable missing' }",
425
+ "Remove-Retired $Staging; Move-Item $Stage $Staging; $VersionRoot = $Current",
426
+ "$VersionExe = Join-Path $Staging ($Asset.executablePath -replace '/', [IO.Path]::DirectorySeparatorChar); if (-not (Test-Path $VersionExe)) { throw 'Release executable missing' }",
426
427
  "$Authenticode = Get-AuthenticodeSignature $VersionExe; if ($Asset.nativeSignaturePolicy -eq 'authenticode-valid' -and $Authenticode.Status -ne 'Valid') { throw 'Native signature invalid' }",
427
428
  "$ActivationArgsTemplate = " + psArray(activationArgs),
428
429
  "$StatusArgsTemplate = " + psArray(statusArgs),
@@ -435,44 +436,28 @@ function Invoke-BoundedExe($Path, [string[]]$Arguments, [int]$TimeoutSeconds) {
435
436
  "$PriorCliCapable = $true; if ($Contract.priorExecutableMinVersion) { $PriorCliCapable = ($null -ne $PriorVersion -and ([version]$PriorVersion -ge [version]$Contract.priorExecutableMinVersion)) }",
436
437
  "$PriorStatus = $null; if ($PriorTarget -and $PriorCliCapable) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); try { $PriorStatus = ((Invoke-BoundedExe $PriorExe $PriorStatusArgs 60) | Out-String).Trim() } catch {} }",
437
438
  "@{ schemaVersion=1; priorTarget=$PriorTarget; priorVersion=$PriorVersion; targetVersion=$Version; priorStatus=$PriorStatus } | ConvertTo-Json -Depth 8 | Set-Content -Encoding UTF8 $Journal",
438
- "if ($PreflightArgs.Count) { Invoke-BoundedExe $VersionExe $PreflightArgs 120 | Out-Null; if ($LASTEXITCODE -ne 0) { throw 'Activation preflight failed' } }",
439
- "Remove-StableJunction $NextLink; Remove-StableJunction $PreviousLink",
440
- "New-Item -ItemType Junction -Path $NextLink -Target $VersionRoot | Out-Null",
439
+ "$PriorWasJunction = $false; $CurrentItem = Get-Item -LiteralPath $Current -Force -ErrorAction SilentlyContinue; if ($CurrentItem -and $CurrentItem.LinkType -eq 'Junction') { $PriorWasJunction = $true }",
440
+ "Remove-Retired $Previous",
441
441
  "$Switched = $false; $PriorDetached = $false",
442
442
  "try {",
443
- " if (Test-Path $Current) { Rename-Item -LiteralPath $Current -NewName (Split-Path -Leaf $PreviousLink); $PriorDetached = $true }; Rename-Item -LiteralPath $NextLink -NewName (Split-Path -Leaf $Current); $Switched = $true",
443
+ " if (Test-Path $Current) { Rename-Item -LiteralPath $Current -NewName (Split-Path -Leaf $Previous); $PriorDetached = $true }; Rename-Item -LiteralPath $Staging -NewName (Split-Path -Leaf $Current); $Switched = $true",
444
444
  " $StableExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")),
445
+ // Nothing runs from a new path: the preflight, activation and health probes all run at the fixed `current` path, and a failure renames `current.previous` back.
446
+ " if ($PreflightArgs.Count) { Invoke-BoundedExe $StableExe $PreflightArgs 120 | Out-Null; if ($LASTEXITCODE -ne 0) { throw 'Activation preflight failed' } }",
445
447
  " $ActivationArgs = @(Expand-BootstrapArgs $ActivationArgsTemplate $VersionRoot $Version); $StatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate $VersionRoot $Version)",
446
448
  " Invoke-BoundedExe $StableExe $ActivationArgs 240 | Out-Null; if ($LASTEXITCODE -ne 0) { throw 'Activation failed' }",
447
449
  " $HealthText = ((Invoke-BoundedExe $StableExe $StatusArgs 120) | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Health command failed' }; $Health = $HealthText | ConvertFrom-Json",
448
450
  " Assert-Health $Health $Version",
449
451
  " $StableBin = Split-Path $StableExe -Parent; $InstallRootResolved = [IO.Path]::GetFullPath($InstallRoot).TrimEnd([IO.Path]::DirectorySeparatorChar); $UserPath = [Environment]::GetEnvironmentVariable('Path','User'); $Kept = @($UserPath -split ';' | Where-Object { $Entry = $_.Trim(); $Entry -ne '' -and -not ($Entry.TrimEnd([IO.Path]::DirectorySeparatorChar).StartsWith(($InstallRootResolved + [IO.Path]::DirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase) -and -not $Entry.TrimEnd([IO.Path]::DirectorySeparatorChar).Equals($StableBin.TrimEnd([IO.Path]::DirectorySeparatorChar), [StringComparison]::OrdinalIgnoreCase)) }); $NewPath = ((@($StableBin) + $Kept) -join ';'); if ($NewPath -ne $UserPath) { [Environment]::SetEnvironmentVariable('Path', $NewPath, 'User') }",
450
- // Journal cleanup must succeed before discarding the prior pointer. If
451
- // cleanup fails, catch still has a rollback anchor to restore.
452
- // Version directories are compared by NAME, never by full path, here and in the rollback below.
453
- // A junction's stored target and the paths this script builds can spell one directory two ways
454
- // (a Windows short name such as RUNNER~1, another junction on the way, a different root form),
455
- // and a full-path comparison that misses would prune the prior version, skip the rollback
456
- // activation, or remove the version the stable pointer is still on. Every version lives
457
- // directly under the versions root, so its name identifies it.
458
- // Retention: after a healthy activation keep only the new version and the prior target (the
459
- // app's first-start probation may still roll back to it, then deletes it). Every other version
460
- // is removed; one a running process still holds fails the rename and is retried next install.
461
- " $KeepVersions = @([IO.Path]::GetFileName(([string]$VersionRoot).TrimEnd([IO.Path]::DirectorySeparatorChar))); if ($PriorTarget) { $KeepVersions += [IO.Path]::GetFileName(([string]$PriorTarget).TrimEnd([IO.Path]::DirectorySeparatorChar)) }; Get-ChildItem -LiteralPath $VersionsRoot -Directory -Force -ErrorAction SilentlyContinue | Where-Object { $Candidate = $_.Name; -not ($KeepVersions | Where-Object { $_.Equals($Candidate, [StringComparison]::OrdinalIgnoreCase) }) } | ForEach-Object { try { $Doomed = Join-Path $VersionsRoot ('.prune-' + [guid]::NewGuid()); Rename-Item -LiteralPath $_.FullName -NewName (Split-Path -Leaf $Doomed) -ErrorAction Stop; Remove-Item -LiteralPath $Doomed -Recurse -Force -ErrorAction SilentlyContinue } catch { } }",
462
- " Remove-Item -Force $Journal; Remove-StableJunction $PreviousLink; Write-Output (" + psQuote(product + " ") + " + $Version + ' installed')",
452
+ // Legacy cleanup only: the old layout's versions/ folder and its pointer junctions are removed once nothing needs them
453
+ // (a legacy `current` junction that was just moved to current.previous keeps its versions/ target until the next install).
454
+ " $LegacyVersions = Join-Path $InstallRoot 'versions'; if (-not $PriorWasJunction -and (Test-Path -LiteralPath $LegacyVersions)) { try { Remove-Item -LiteralPath $LegacyVersions -Recurse -Force -ErrorAction SilentlyContinue } catch { } }; foreach ($Legacy in @('.current-previous', '.current-next')) { try { Remove-Retired (Join-Path $InstallRoot $Legacy) } catch { } }",
455
+ " Remove-Item -Force $Journal; Write-Output (" + psQuote(product + " ") + " + $Version + ' installed')",
463
456
  "} catch {",
464
457
  " $InstallError = $_; $RollbackErrors = New-Object 'System.Collections.Generic.List[string]'",
465
- " try { if ($Switched -or $PriorDetached) { Remove-StableJunction $Current }; if ($PriorDetached -and (Test-Path $PreviousLink)) { Rename-Item -LiteralPath $PreviousLink -NewName (Split-Path -Leaf $Current) } } catch { $RollbackErrors.Add($_.Exception.Message) }",
466
- " try { if ($PriorTarget -and (Test-Path $Current) -and $PriorCliCapable -and ([IO.Path]::GetFileName(([string](Get-Item -LiteralPath $Current).Target).TrimEnd([IO.Path]::DirectorySeparatorChar)) -eq [IO.Path]::GetFileName(([string]$PriorTarget).TrimEnd([IO.Path]::DirectorySeparatorChar)))) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorActivationArgs = @(Expand-BootstrapArgs $ActivationArgsTemplate ([string]$PriorTarget) $PriorVersion); $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); Invoke-BoundedExe $PriorExe $PriorActivationArgs 240 | Out-Null; if ($LASTEXITCODE -ne 0) { throw 'Rollback activation failed' }; $PriorHealthText = ((Invoke-BoundedExe $PriorExe $PriorStatusArgs 120) | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Rollback health failed' }; $PriorHealth = $PriorHealthText | ConvertFrom-Json; try { Assert-Health $PriorHealth $PriorVersion } catch { throw ('Prior health was not restored: ' + $_.Exception.Message) } } } catch { $RollbackErrors.Add($_.Exception.Message) }",
467
- String.raw` try {
468
- $CandidatePath = [IO.Path]::GetFullPath($VersionRoot).TrimEnd([IO.Path]::DirectorySeparatorChar)
469
- $VersionsPrefix = [IO.Path]::GetFullPath($VersionsRoot).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
470
- $ActiveTarget = if (Test-Path -LiteralPath $Current) { [string](Get-Item -LiteralPath $Current).Target } else { $null }
471
- if ($ActiveTarget -and -not [IO.Path]::IsPathRooted($ActiveTarget)) { $ActiveTarget = Join-Path (Split-Path -Parent $Current) $ActiveTarget }
472
- if (-not $CandidatePath.StartsWith($VersionsPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'Failed version cleanup escapes versions root' }
473
- if ($ActiveTarget -and [IO.Path]::GetFileName([IO.Path]::GetFullPath($ActiveTarget).TrimEnd([IO.Path]::DirectorySeparatorChar)).Equals([IO.Path]::GetFileName($CandidatePath), [StringComparison]::OrdinalIgnoreCase)) { throw 'Failed version is still active; preserved for recovery' }
474
- if (Test-Path -LiteralPath $CandidatePath) { Remove-Item -LiteralPath $CandidatePath -Recurse -Force }
475
- } catch { $RollbackErrors.Add($_.Exception.Message) }
458
+ " try { if ($Switched) { Remove-Retired $Current }; if ($PriorDetached -and (Test-Path -LiteralPath $Previous)) { Rename-Item -LiteralPath $Previous -NewName (Split-Path -Leaf $Current) } } catch { $RollbackErrors.Add($_.Exception.Message) }",
459
+ " try { if ($PriorTarget -and $PriorDetached -and (Test-Path -LiteralPath $Current) -and -not (Test-Path -LiteralPath $Previous) -and $PriorCliCapable) { $PriorExe = Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + "; $PriorActivationArgs = @(Expand-BootstrapArgs $ActivationArgsTemplate ([string]$PriorTarget) $PriorVersion); $PriorStatusArgs = @(Expand-BootstrapArgs $StatusArgsTemplate ([string]$PriorTarget) $PriorVersion); Invoke-BoundedExe $PriorExe $PriorActivationArgs 240 | Out-Null; if ($LASTEXITCODE -ne 0) { throw 'Rollback activation failed' }; $PriorHealthText = ((Invoke-BoundedExe $PriorExe $PriorStatusArgs 120) | Out-String).Trim(); if ($LASTEXITCODE -ne 0) { throw 'Rollback health failed' }; $PriorHealth = $PriorHealthText | ConvertFrom-Json; try { Assert-Health $PriorHealth $PriorVersion } catch { throw ('Prior health was not restored: ' + $_.Exception.Message) } } } catch { $RollbackErrors.Add($_.Exception.Message) }",
460
+ String.raw` try { if (Test-Path -LiteralPath $Staging) { Remove-Retired $Staging } } catch { $RollbackErrors.Add($_.Exception.Message) }
476
461
  if ($RollbackErrors.Count) {
477
462
  $InstallError.Exception.Data['RollbackErrors'] = $RollbackErrors.ToArray()
478
463
  Write-Warning ('Rollback/cleanup also failed: ' + ($RollbackErrors -join '; '))
@@ -495,7 +480,7 @@ export function validatePowerShellBootstrap(script, expected = {}) {
495
480
  "checksums.json is not manifest-bound",
496
481
  "Provenance checksum mismatch",
497
482
  "SBOM checksum mismatch",
498
- "New-Item -ItemType Junction",
483
+ "current.previous",
499
484
  "integration-journal.json",
500
485
  "Rollback activation failed",
501
486
  "Prior health was not restored",