@rightkit/release 0.2.165 → 0.2.173
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.
- package/README.md +121 -4
- package/addon-command.mjs +6 -13
- package/android-aab-scan.mjs +265 -0
- package/android-dev.mjs +111 -0
- package/android-ndk.mjs +77 -0
- package/android-play-upload.mjs +262 -0
- package/android-release.mjs +344 -0
- package/android-test-fixtures.mjs +79 -0
- package/apple-profile-create.mjs +363 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/build-release.mjs +73 -13
- package/cargo-guard.mjs +2 -0
- package/cargo-profile-contract.mjs +183 -0
- package/cargo-target.mjs +8 -0
- package/cli/right-release.mjs +16 -4
- package/committed-source.mjs +1 -1
- package/dev-artifact-refresh.mjs +176 -0
- package/dev-artifact-support.mjs +61 -0
- package/development-signing.mjs +71 -4
- package/development.mjs +93 -47
- package/direct-bootstrap.mjs +26 -41
- package/disk-command.mjs +298 -5
- package/disk-policy.json +7 -5
- package/disk-policy.mjs +105 -16
- package/disk-stable-root.mjs +19 -16
- package/engine-locator.mjs +116 -0
- package/external-bin.mjs +133 -0
- package/github-release.mjs +2 -2
- package/github-updater-release.mjs +154 -0
- package/hardeningscan.mjs +1 -1
- package/in-place-install.mjs +56 -0
- package/install-app-windows.mjs +63 -0
- package/install-app.mjs +53 -23
- package/install-tauri-devid.mjs +172 -0
- package/inventory-check.mjs +1 -1
- package/local-publish.mjs +11 -1
- package/mac-package-recipe.mjs +304 -0
- package/mac-signing-matrix.mjs +23 -11
- package/managed-cargo.mjs +6 -0
- package/media-sidecar-catalog.mjs +45 -0
- package/media-sidecar.mjs +306 -0
- package/ownership-approval-command.mjs +10 -44
- package/ownership-approval.mjs +3 -42
- package/ownership-catalog-config.json +2 -1
- package/ownership-catalog.mjs +4 -2
- package/ownership-command.mjs +7 -9
- package/ownership-dependency-allowlist.json +25 -1
- package/ownership-evidence.mjs +8 -5
- package/ownership-git-inventory.mjs +41 -7
- package/ownership-lockfiles.mjs +91 -8
- package/ownership-mandatory-set.json +36 -16
- package/ownership-report.mjs +5 -6
- package/ownership-scan-worker.mjs +2 -2
- package/ownership-verdict.mjs +10 -7
- package/ownership.mjs +47 -29
- package/package-recipe-targets.mjs +95 -0
- package/package.json +7 -4
- package/portable-development.mjs +32 -115
- package/preflight.mjs +4 -6
- package/project-type.mjs +1 -1
- package/publication-artifacts.mjs +34 -0
- package/publish-update.mjs +72 -11
- package/qa-adoption-exemptions.json +5 -0
- package/qa-build-features.mjs +7 -0
- package/qa-contract.mjs +182 -0
- package/qa-control-scan.mjs +3 -3
- package/release-qa-command.mjs +38 -12
- package/release-qa-installed.mjs +136 -0
- package/release-qa-receipt.mjs +23 -2
- package/release.mjs +285 -14
- package/rightkit-ownership.json +537 -5
- package/rightkit-sync.mjs +27 -1
- package/rightkit-versions.json +111 -63
- package/runtime-budget-classes.json +28 -0
- package/runtime-budgets.mjs +4 -1
- package/sign-candidate.mjs +161 -0
- package/sign-dev-artifact.mjs +198 -0
- package/stage-binary.mjs +60 -0
- package/stage-sidecar-command.mjs +41 -0
- package/stage-sidecar.mjs +424 -0
- package/swap-helper-bundle.mjs +105 -0
- package/swift-mac-dmg.mjs +2 -1
- package/templates/cargo-profiles.toml +28 -0
- package/upload-release.mjs +11 -1
- package/win-package-recipe.mjs +321 -0
package/development-signing.mjs
CHANGED
|
@@ -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) && /^
|
|
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
|
-
|
|
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,23 @@ 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 {
|
|
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 { installTauriDevId, resolveTauriDevIdDescriptor, TAURI_DEVID_KIND } from "./install-tauri-devid.mjs";
|
|
17
|
+
import { androidDevelopmentNdkCommand, androidDevelopmentSerial, installAndroidDevelopment, validateAndroidDevelopment } from "./android-dev.mjs";
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
// One standard Cargo profile for every app (templates/cargo-profiles.toml): Cargo's built-in `dev`.
|
|
20
|
+
// `--profile dev` is valid and no flag also means dev.
|
|
21
|
+
export const DEVELOPMENT_PROFILE = "dev";
|
|
22
|
+
// Cargo writes the `dev` (and `test`) profile to target/<triple>/debug, never .../dev; `bench` goes to release.
|
|
23
|
+
// Every path built from a profile name must go through this.
|
|
24
|
+
export function cargoProfileDirectory(profile) {
|
|
25
|
+
if (profile === "dev" || profile === "test") return "debug";
|
|
26
|
+
if (profile === "bench") return "release";
|
|
27
|
+
return profile;
|
|
28
|
+
}
|
|
16
29
|
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
30
|
|
|
18
31
|
export function developmentEnvironment(env = process.env) {
|
|
@@ -26,6 +39,8 @@ export function developmentEnvironment(env = process.env) {
|
|
|
26
39
|
};
|
|
27
40
|
for (const key of RELEASE_ONLY_APPLE_ENV) delete result[key];
|
|
28
41
|
delete result.CARGO_INCREMENTAL;
|
|
42
|
+
// A dev build is debug-signed: a release signing-properties path never reaches it.
|
|
43
|
+
delete result.RIGHTKIT_ANDROID_SIGNING_PROPERTIES;
|
|
29
44
|
const client = managedRuntimeClient(result);
|
|
30
45
|
if (client) {
|
|
31
46
|
const { agentBin } = resolveManagedRoots({ env: result });
|
|
@@ -41,7 +56,7 @@ export function developmentEnvironment(env = process.env) {
|
|
|
41
56
|
return result;
|
|
42
57
|
}
|
|
43
58
|
|
|
44
|
-
export function validateDevelopmentConfig(config, platform) {
|
|
59
|
+
export function validateDevelopmentConfig(config, platform, { root = process.cwd() } = {}) {
|
|
45
60
|
const target = config?.development?.targets?.[platform];
|
|
46
61
|
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
62
|
for (const [index, command] of target.build.entries()) validateCommand(command, `development.targets.${platform}.build[${index}]`);
|
|
@@ -56,36 +71,64 @@ export function validateDevelopmentConfig(config, platform) {
|
|
|
56
71
|
for (const key of ["sourceDirectory", "entrypoint"]) {
|
|
57
72
|
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
73
|
}
|
|
74
|
+
} else if (target.install?.kind === TAURI_DEVID_KIND) {
|
|
75
|
+
if (platform !== "mac") throw new Error(`the ${TAURI_DEVID_KIND} development installer is for --platform mac`);
|
|
76
|
+
resolveTauriDevIdDescriptor(target.install, { appRoot: root });
|
|
77
|
+
if (!target.install.targetTriple && !target.install.bundleDirectory) throw new Error(`development.targets.${platform}.install requires targetTriple (or bundleDirectory) to find the built .app`);
|
|
78
|
+
} else if (target.install?.kind === "adb") {
|
|
79
|
+
if (platform !== "android") throw new Error("the adb development installer is for --platform android");
|
|
59
80
|
} else if (target.install) throw new Error(`unsupported development installer kind: ${target.install.kind}`);
|
|
81
|
+
validateStageEntries(target.stage, `development.targets.${platform}.stage`);
|
|
82
|
+
if (platform === "android") return { ...target, ...validateAndroidDevelopment(target, { root }) };
|
|
60
83
|
return target;
|
|
61
84
|
}
|
|
62
85
|
|
|
63
86
|
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
|
|
87
|
+
codesignEnvironment = macosCodesignEnvironment, serial }) {
|
|
88
|
+
if (!new Set(["win", "mac", "android"]).has(platform)) throw new Error("--platform must be win, mac or android");
|
|
66
89
|
assertNativeDevelopmentHost(platform);
|
|
67
|
-
const target = validateDevelopmentConfig(config, platform);
|
|
90
|
+
const target = validateDevelopmentConfig(config, platform, { root });
|
|
68
91
|
const repoRoot = git(root, ["rev-parse", "--show-toplevel"]);
|
|
69
92
|
assertPrimaryReleaseCheckout(repoRoot, env);
|
|
70
|
-
if (dryRun) return { built: false, installed: false, plan: { build: target.build, install: buildOnly ? null : target.install ?? null, profile: DEVELOPMENT_PROFILE } };
|
|
93
|
+
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
94
|
assertReleaseExecutionAllowed({ cwd: repoRoot, environment: env, command: `right-release dev --platform ${platform}` });
|
|
95
|
+
// Refuse an invalid or off-CI ad hoc opt-in before the (long) build, not after it.
|
|
96
|
+
if (!buildOnly && platform === "mac" && target.install?.kind === "tauri") requestedCiAdhocSigning({ descriptor: target.install, env });
|
|
97
|
+
// Android: the device serial is required, so a missing one is refused before the (long) build too.
|
|
98
|
+
if (!buildOnly && platform === "android" && target.install) androidDevelopmentSerial({ descriptor: target.install, serial, env });
|
|
72
99
|
const childEnv = codesignEnvironment(developmentEnvironment(env));
|
|
73
100
|
// One lease spans every managed request this run makes, so a broker cutover cannot land between them.
|
|
74
101
|
const lease = acquireManagedRunLease({ command: `right-release dev --platform ${platform}`, env });
|
|
102
|
+
let releaseLease = true;
|
|
75
103
|
try {
|
|
76
104
|
const startedMs = Date.now();
|
|
105
|
+
// Android: cargo-ndk (Cargo dev profile) fills the generated jniLibs, then the Gradle debug build packages it.
|
|
106
|
+
if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, label: "native build" }); }
|
|
77
107
|
for (const command of target.build) execute(command, { root, env: childEnv, dryRun, run, label: "build" });
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
108
|
+
// Dev and QA binaries run from one stable staged path, copied in place (<name>.previous kept), never from the
|
|
109
|
+
// broker's per-generation target folder or a hashed dev-targets folder.
|
|
110
|
+
const staged = target.stage?.length ? stageDevelopmentBinaries(target.stage, { root, platform: platform === "win" ? "win32" : process.platform }) : [];
|
|
111
|
+
for (const file of staged) console.log(`right-release dev: staged ${file}`);
|
|
112
|
+
if (buildOnly || !target.install) return { built: true, installed: false, ...(staged.length ? { staged } : {}) };
|
|
113
|
+
if (target.install.kind === TAURI_DEVID_KIND) {
|
|
114
|
+
// installTauriDevId is async: the lease is held until it settles, so a broker cutover cannot land mid-install.
|
|
115
|
+
const built = resolveTauriArtifact(target.install, { root, platform, env: childEnv });
|
|
116
|
+
releaseLease = false;
|
|
117
|
+
return installTauriDevId({ appRoot: root, config: target.install, built, env: childEnv })
|
|
118
|
+
.then((outcome) => ({ built: true, installed: true, ...(staged.length ? { staged } : {}), devid: outcome }))
|
|
119
|
+
.finally(() => lease.release());
|
|
120
|
+
}
|
|
121
|
+
const signing = install(target.install, { root, platform, config, env: childEnv, run, sinceMs: startedMs, serial });
|
|
122
|
+
return { built: true, installed: true, ...(staged.length ? { staged } : {}), ...(platform === "mac" && target.install.kind === "tauri" ? { signing } : {}) };
|
|
123
|
+
} finally { if (releaseLease) lease.release(); }
|
|
82
124
|
}
|
|
83
125
|
|
|
84
126
|
export function assertNativeDevelopmentHost(platform, host = process.platform) {
|
|
85
127
|
if ((platform === "win" && host !== "win32") || (platform === "mac" && host !== "darwin")) throw new Error(`development target ${platform} requires its native host`);
|
|
86
128
|
}
|
|
87
129
|
|
|
88
|
-
function install(descriptor, { root, platform, config, env, run, sinceMs = null }) {
|
|
130
|
+
function install(descriptor, { root, platform, config, env, run, sinceMs = null, serial }) {
|
|
131
|
+
if (descriptor.kind === "adb") return installAndroidDevelopment(descriptor, { root, env, run, serial });
|
|
89
132
|
if (descriptor.kind === "command") return execute(descriptor, { root, env, run, label: "install" });
|
|
90
133
|
if (descriptor.kind === "portable") return installPortableDevelopment(descriptor, { root, platform: platform === "win" ? "win32" : "darwin", env });
|
|
91
134
|
if (platform === "win") {
|
|
@@ -97,6 +140,9 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null
|
|
|
97
140
|
// was running, pass /R so the installer relaunches it as the user (RunAsUser, outside this process tree).
|
|
98
141
|
const running = isWindowsProcessRunning(path.basename(descriptor.expectedInstalledPath), { run });
|
|
99
142
|
if (running) console.log(`right-release dev: ${descriptor.productName} is running; the installer will close it and relaunch it`);
|
|
143
|
+
// The silent installer updates the fixed path in place. Keep the copy it replaces once, as <exe>.previous (never run).
|
|
144
|
+
const installedBefore = path.resolve(root, descriptor.expectedInstalledPath);
|
|
145
|
+
if (existsSync(installedBefore) && statSync(installedBefore).isFile()) copyFileSync(installedBefore, previousPathOf(installedBefore));
|
|
100
146
|
const result = run(app, windowsInstallerArguments({ running }), { cwd: root, env, stdio: "inherit", windowsHide: true, shell: false });
|
|
101
147
|
if (result.status !== 0) throw new Error(`development installer failed (${result.status}): ${app}`);
|
|
102
148
|
const installed = path.resolve(root, descriptor.expectedInstalledPath);
|
|
@@ -104,23 +150,27 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null
|
|
|
104
150
|
return;
|
|
105
151
|
}
|
|
106
152
|
const app = resolveTauriArtifact(descriptor, { root, platform, env });
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
153
|
+
// One fixed install path per app: macOS keys privacy grants to it. No /Applications/<name> guess.
|
|
154
|
+
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)");
|
|
155
|
+
const destination = path.resolve(descriptor.destination);
|
|
156
|
+
mkdirSync(path.dirname(destination), { recursive: true });
|
|
157
|
+
// Fixed staging name (not per-pid): the copy is signed and verified here, then renamed into place. Nothing runs from it.
|
|
158
|
+
const staging = stagingPathOf(destination);
|
|
159
|
+
const previous = previousPathOf(destination);
|
|
112
160
|
rmSync(staging, { recursive: true, force: true });
|
|
113
|
-
rmSync(backup, { recursive: true, force: true });
|
|
114
161
|
cpSync(app, staging, { recursive: true, verbatimSymlinks: true });
|
|
115
162
|
let signing;
|
|
116
163
|
let executableName;
|
|
117
164
|
try {
|
|
118
165
|
signing = signMacosDevelopmentBundle({ root, bundle: staging, descriptor, config, env, run });
|
|
166
|
+
// Same identity as the installed copy, or fail: a changed designated requirement resets every privacy grant.
|
|
167
|
+
if (!signing.adhoc && existsSync(destination)) assertStableDesignatedRequirement({ root, installed: destination, expected: signing.designatedRequirement, env, run });
|
|
119
168
|
executableName = readBundleExecutable(staging, { root, env, run });
|
|
120
169
|
} catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; }
|
|
121
|
-
swapInstalledMacosBundle({ staging, destination,
|
|
170
|
+
swapInstalledMacosBundle({ staging, destination, previous, executableName });
|
|
122
171
|
if (signing.provisioningProfile) signing.provisioningProfile = path.join(destination, "Contents", "embedded.provisionprofile");
|
|
123
|
-
console.log(`right-release dev: installed ${destination};
|
|
172
|
+
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`);
|
|
173
|
+
else console.log(`right-release dev: installed ${destination}; identity=${signing.identity}; TeamIdentifier=${signing.teamIdentifier}; profile=${signing.provisioningProfile ?? "none"}; designated=${signing.designatedRequirement}`);
|
|
124
174
|
return signing;
|
|
125
175
|
}
|
|
126
176
|
|
|
@@ -128,15 +178,15 @@ export class DevelopmentInstallError extends Error {
|
|
|
128
178
|
constructor(code, message) { super(message); this.name = "DevelopmentInstallError"; this.code = code; }
|
|
129
179
|
}
|
|
130
180
|
|
|
131
|
-
const NODE_FS = { existsSync, readFileSync, renameSync, rmSync, statSync };
|
|
181
|
+
const NODE_FS = { existsSync, lstatSync, mkdirSync, copyFileSync, readFileSync, renameSync, rmSync, statSync };
|
|
132
182
|
|
|
133
183
|
/**
|
|
134
|
-
* Move the signed staging bundle into place
|
|
184
|
+
* Move the signed staging bundle into place, keeping the previous app as `<destination>.previous`, and verify it: Info.plist exists and
|
|
135
185
|
* 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,
|
|
186
|
+
* new install, renames `.previous` back and throws a DevelopmentInstallError (code DEV_INSTALL_VERIFICATION_FAILED,
|
|
137
187
|
* DEV_INSTALL_EXECUTABLE_MISSING or DEV_INSTALL_EXECUTABLE_MISMATCH). `fs` is the file-system seam (tests inject it).
|
|
138
188
|
*/
|
|
139
|
-
export function swapInstalledMacosBundle({ staging, destination,
|
|
189
|
+
export function swapInstalledMacosBundle({ staging, destination, previous = previousPathOf(destination), executableName, fs = NODE_FS }) {
|
|
140
190
|
if (typeof executableName !== "string" || !executableName) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", "swapInstalledMacosBundle requires the staged CFBundleExecutable name");
|
|
141
191
|
const executable = path.join("Contents", "MacOS", executableName);
|
|
142
192
|
const digest = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
@@ -146,23 +196,18 @@ export function swapInstalledMacosBundle({ staging, destination, backup, executa
|
|
|
146
196
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
147
197
|
throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISSING", `staged app has no main executable ${executable}: ${staging} (${error.message})`);
|
|
148
198
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
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
|
-
}
|
|
199
|
+
// The old app becomes `<destination>.previous` (replacing any older one) and stays there for rollback; any failure renames it back.
|
|
200
|
+
swapInPlace({
|
|
201
|
+
staging, destination, previous, fs,
|
|
202
|
+
verify: () => {
|
|
203
|
+
if (!fs.existsSync(path.join(destination, "Contents", "Info.plist"))) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", `installed app verification failed: ${destination}`);
|
|
204
|
+
const installed = path.join(destination, executable);
|
|
205
|
+
let actual;
|
|
206
|
+
try { actual = digest(installed); }
|
|
207
|
+
catch { throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISSING", `installed app verification failed: main executable ${executable} is missing in ${destination}`); }
|
|
208
|
+
if (actual !== expected) throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISMATCH", `installed app verification failed: ${executable} SHA-256 ${actual} does not match the staged ${expected}`);
|
|
209
|
+
},
|
|
210
|
+
});
|
|
166
211
|
}
|
|
167
212
|
|
|
168
213
|
export function resolveTauriArtifact(descriptor, { root, platform, env = process.env, runCargo = runCargoSync }) {
|
|
@@ -223,7 +268,7 @@ function resolveBundleRoot(descriptor, { root, env, runCargo }) {
|
|
|
223
268
|
let targetDirectory;
|
|
224
269
|
try { targetDirectory = JSON.parse(metadata.stdout).target_directory; } catch { throw new Error("RightKit Cargo metadata returned invalid JSON"); }
|
|
225
270
|
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));
|
|
271
|
+
return path.join(targetDirectory, descriptor.targetTriple, cargoProfileDirectory(DEVELOPMENT_PROFILE), "bundle", platformBundleKind(descriptor));
|
|
227
272
|
}
|
|
228
273
|
|
|
229
274
|
function platformBundleKind(descriptor) { return descriptor.targetTriple.includes("apple") ? "macos" : "nsis"; }
|
|
@@ -290,16 +335,17 @@ function resolveJavaScriptEntrypoint(candidate) {
|
|
|
290
335
|
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
336
|
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
337
|
export async function main(args = process.argv.slice(2)) {
|
|
293
|
-
let platform, configPath = "right-release.config.mjs", dryRun = false, buildOnly = false;
|
|
338
|
+
let platform, configPath = "right-release.config.mjs", dryRun = false, buildOnly = false, serial;
|
|
294
339
|
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; }
|
|
340
|
+
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
341
|
if (args[i] === "--platform") platform = args[++i];
|
|
297
342
|
else if (args[i] === "--config") configPath = args[++i];
|
|
298
343
|
else if (args[i] === "--dry-run") dryRun = true;
|
|
299
344
|
else if (args[i] === "--build-only") buildOnly = true;
|
|
345
|
+
else if (args[i] === "--serial") serial = args[++i];
|
|
300
346
|
else throw new Error(`unknown dev option: ${args[i]}`);
|
|
301
347
|
}
|
|
302
|
-
if (!platform) throw new Error("right-release dev requires --platform win|mac");
|
|
348
|
+
if (!platform) throw new Error("right-release dev requires --platform win|mac|android");
|
|
303
349
|
const absoluteConfig = path.resolve(configPath);
|
|
304
350
|
const safeEnv = developmentEnvironment(process.env);
|
|
305
351
|
for (const [key, value] of Object.entries(safeEnv)) process.env[key] = value;
|
|
@@ -309,7 +355,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
309
355
|
if (pathKey) process.env[pathKey] = safeEnv[pathKey] ?? process.env[pathKey];
|
|
310
356
|
const loaded = await import(pathToFileURL(absoluteConfig));
|
|
311
357
|
const config = loaded.default ?? loaded.config ?? loaded;
|
|
312
|
-
const result = runDevelopment({ root: path.dirname(absoluteConfig), platform, config, dryRun, buildOnly });
|
|
358
|
+
const result = await runDevelopment({ root: path.dirname(absoluteConfig), platform, config, dryRun, buildOnly, serial });
|
|
313
359
|
if (dryRun) console.log(JSON.stringify(result.plan, null, 2));
|
|
314
360
|
return result;
|
|
315
361
|
}
|
package/direct-bootstrap.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"$
|
|
294
|
-
"$
|
|
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-
|
|
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 $
|
|
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 $
|
|
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 =
|
|
370
|
-
"$PriorVersion = if ($PriorTarget) { Resolve-PriorVersion (Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + ")
|
|
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 =
|
|
396
|
-
"$PriorVersion = if ($PriorTarget) { Resolve-PriorVersion (Join-Path $Current " + psQuote(executablePath.replaceAll("/", "\\")) + ")
|
|
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
|
-
"
|
|
425
|
-
"$VersionExe = Join-Path $
|
|
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
|
-
"
|
|
439
|
-
"Remove-
|
|
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 $
|
|
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
|
-
//
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|
466
|
-
" try { if ($PriorTarget -and (Test-Path $Current) -and
|
|
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
|
-
"
|
|
483
|
+
"current.previous",
|
|
499
484
|
"integration-journal.json",
|
|
500
485
|
"Rollback activation failed",
|
|
501
486
|
"Prior health was not restored",
|