@rightkit/release 0.2.172 → 0.2.174
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 +10 -4
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +1 -1
- package/cli/right-release.mjs +7 -3
- package/development-signing.mjs +135 -4
- package/development.mjs +27 -12
- package/in-place-install.mjs +32 -7
- package/install-app-windows.mjs +108 -17
- package/install-app.mjs +32 -8
- package/install-tauri-devid.mjs +231 -0
- package/inventory-check.mjs +1 -1
- package/mac-bundle-signing.mjs +143 -1
- package/mac-package-recipe.mjs +304 -0
- package/mac-signing-matrix.mjs +73 -6
- package/macos-signing-session.mjs +12 -2
- package/media-sidecar.mjs +1 -1
- package/package-recipe-targets.mjs +245 -0
- package/package.json +1 -1
- package/portable-development.mjs +9 -9
- package/project-type.mjs +1 -1
- package/qa-build-features.mjs +7 -1
- package/recipe-build.mjs +346 -0
- package/release.mjs +27 -9
- package/rightkit-signing-agent.mjs +10 -4
- package/rightkit-sync.mjs +1 -1
- package/rightkit-versions.json +11 -6
- package/runtime-budgets.mjs +14 -5
- package/stage-binary.mjs +1 -1
- package/stage-sidecar-command.mjs +73 -0
- package/stage-sidecar.mjs +839 -0
- package/swift-mac-dmg.mjs +2 -1
- package/win-package-recipe.mjs +321 -0
package/README.md
CHANGED
|
@@ -100,13 +100,13 @@ The shipped `bin/darwin/rightkit-swap` is Developer ID signed. It is added by `r
|
|
|
100
100
|
|
|
101
101
|
Development builds (from `@rightkit/ci` `devArtifact`, Developer ID-signed but not notarized) install with `--dev` and exactly one of `--dmg`, `--zip <path.zip>` (extracted with `ditto -x -k`), or `--app <path.app>`: `right-release install-app --dev --zip dev-mac.zip`. `--dev` still requires `codesign --verify --deep --strict`, a Team ID, no Team ID change, and a bundle-id match; it skips only `spctl` and `stapler`, removes `com.apple.quarantine` from the new bundle only, prints `DEV BUILD (not notarized)`, and sets `dev: true` on every JSON event.
|
|
102
102
|
|
|
103
|
-
## One fixed path, one identity
|
|
103
|
+
## One fixed path, one identity; release keeps one `.previous`, dev keeps none (every app)
|
|
104
104
|
|
|
105
|
-
macOS and Windows tie privacy grants (mic, screen, Files and Folders, Removable Volumes, Full Disk Access) to the path an app runs from and to its code identity. So every app, helper and dev or QA binary installs and updates IN PLACE at ONE fixed path under ONE identity; there is no per-version, per-digest, per-pid or per-build install folder or bundle id.
|
|
105
|
+
macOS and Windows tie privacy grants (mic, screen, Files and Folders, Removable Volumes, Full Disk Access) to the path an app runs from and to its code identity. So every app, helper and dev or QA binary installs and updates IN PLACE at ONE fixed path under ONE identity; there is no per-version, per-digest, per-pid or per-build install folder or bundle id. A RELEASE install may keep one rollback `<name>.previous` beside the fixed path (rename it back). A DEV or QA install keeps NO old copy of any kind (Adrian, bogusyogi/rightkit#348: "when installing dev, old copy must never be backedup"; reinstall from an older DMG): it replaces the path in place through a short-lived staging name and removes any stale `.previous`, and a failed dev install leaves the path absent or partial with a clear error. Nothing ever runs from `.previous` or from a staging copy.
|
|
106
106
|
|
|
107
|
-
- `install-app` (macOS) renames the replaced app to `<App>.app.previous` (never the Trash); `install-app --installer <Setup.exe> --dest <folder> --exe <App.exe
|
|
107
|
+
- `install-app` (macOS) renames the replaced app to `<App>.app.previous` (never the Trash) for a release install only; `install-app --dev` removes the old app and keeps nothing. `install-app --installer <Setup.exe> --dest <folder> --exe <App.exe> [--payload <file>]... [--payload-manifest <json>]` runs the NSIS installer in place. It NEVER copies or restores the whole install folder, because that folder can hold user data (CodeRight keeps `coderight.db`, its WAL, state and models there). Its backup and rollback are payload-only, from an explicit allowlist: the `--exe` file plus `--payload` entries and a `--payload-manifest` JSON of relative file paths (files, never directories); with no `--payload` it backs up and rolls back nothing. `right-release dev` (mac) installs to `install.destination` (required) with no `.previous` and fails if its designated requirement differs from the installed copy; Windows tauri dev runs the silent installer without copying `<exe>.previous`; a portable dev payload installs as `<installRoot>/current` with no `current.previous` (data beside `current` is never touched); the Windows direct installer installs to `current` with `current.previous`.
|
|
108
108
|
- `right-release dev` (mac) signs with the same Developer ID Application identity as release (an app that needs a provisioning profile uses a Developer ID profile); Apple Development certificates are refused with `DEV_MAC_SIGNING_DEVELOPER_ID_REQUIRED`.
|
|
109
|
-
- **Cargo target folders are not stable paths.** The broker's target tree is `<managed>/rightkit-build-control/ws/<hash>/g/<generation>/target/...` and the generation changes; `cargo-guard` uses a hashed `dev-targets/<project>-<hash>` folder. They are build output only, so nothing may run from them. Dev and QA binaries run from a fixed staged path: list them in `development.targets.<platform>.stage` (`[{ source } | { manifestPath, bin, targetTriple?, profile?, name? }]`) and `right-release dev` copies each in place to `<app root>/.rightkit-dev-bin/<triple-or-host>/<name>` (
|
|
109
|
+
- **Cargo target folders are not stable paths.** The broker's target tree is `<managed>/rightkit-build-control/ws/<hash>/g/<generation>/target/...` and the generation changes; `cargo-guard` uses a hashed `dev-targets/<project>-<hash>` folder. They are build output only, so nothing may run from them. Dev and QA binaries run from a fixed staged path: list them in `development.targets.<platform>.stage` (`[{ source } | { manifestPath, bin, targetTriple?, profile?, name? }]`) and `right-release dev` copies each in place to `<app root>/.rightkit-dev-bin/<triple-or-host>/<name>` (no backup of the old copy); `rightkit-qa` launches from its `.rightkit-qa-bin` stash on every path.
|
|
110
110
|
|
|
111
111
|
## Runtime budget gate (crates)
|
|
112
112
|
|
|
@@ -320,3 +320,9 @@ No agent may write to the internal drive (C: on Windows, the internal disk on ma
|
|
|
320
320
|
- `smol-toml` is pinned exactly. A consumer that depends on a different `smol-toml` version gets a second copy.
|
|
321
321
|
- Apps pin this package at different versions; `docs/ADOPTION.md` lists each pin and marks the stale ones.
|
|
322
322
|
- Prepublish runs the test suite, the Windows signer integration test and the registry parity check.
|
|
323
|
+
|
|
324
|
+
## Post-sign hook chains
|
|
325
|
+
|
|
326
|
+
`target.postSign` accepts either one command object or an array of command objects. An array runs in order, for example `create-mac-updater` followed by `lsclean`, and stops at the first failing command, so the release fails closed. Every command in the array is scanned for forbidden build features and release switches, exactly like a single command. A single object behaves as before.
|
|
327
|
+
|
|
328
|
+
Each command may set `timeoutMs`. When it expires the release kills the whole process tree (`taskkill /T /F` on Windows, the process group elsewhere) and exits non-zero, so a hung child such as Tauri leaves no orphan.
|
package/bin/darwin/rightkit-swap
CHANGED
|
Binary file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
|
-
"sha256": "
|
|
3
|
+
"sha256": "f52367947184283bf188d04e414743879c842ac359ed0a97acacecf23383b1ce",
|
|
4
4
|
"identity": "Developer ID Application: Adrian D'souza (6KLGD3LLKF)",
|
|
5
5
|
"codesign": "strict-valid"
|
|
6
6
|
}
|
package/cli/right-release.mjs
CHANGED
|
@@ -21,6 +21,8 @@ const commands = new Map([
|
|
|
21
21
|
["sign-dev-artifact", "sign-dev-artifact.mjs"],
|
|
22
22
|
["sign-candidate", "sign-candidate.mjs"],
|
|
23
23
|
["engine-locate", "engine-locator.mjs"],
|
|
24
|
+
["stage-sidecar", "stage-sidecar-command.mjs"],
|
|
25
|
+
["recipe-build", "recipe-build.mjs"],
|
|
24
26
|
["signing-agent", "signing-agent-command.mjs"],
|
|
25
27
|
["create-mac-updater", "create-mac-updater.mjs"],
|
|
26
28
|
["mirror-root-artifact", "mirror-root-artifact.mjs"],
|
|
@@ -181,15 +183,17 @@ function printHelp() {
|
|
|
181
183
|
console.log(`right-release <command> [options]
|
|
182
184
|
|
|
183
185
|
Commands:
|
|
186
|
+
stage-sidecar [--config <file>] [--target <triple>] [--json] Stage declarative sidecars into src-tauri/binaries (Tauri externalBin naming)
|
|
187
|
+
recipe-build --plan <json> | --plan-file <path> [--app-root <dir>] Run a package recipe: prepare steps, the Tauri build, postBuild steps (argv, no shell, fail closed)
|
|
184
188
|
dmg build --app <.app> --out <dmg> --volname <name> [--background <png>] [--spec <appdmg.json>]
|
|
185
189
|
[--icon-size N] [--app-position x,y] [--applications-position x,y] [--window w,h] [--window-position x,y]
|
|
186
190
|
Build branded UDZO image without Finder
|
|
187
191
|
install-app --dmg <path.dmg> [--app-name Pulse.app] [--bundle-id <id>] [--dest /Applications]
|
|
188
192
|
[--relaunch|--no-relaunch] [--json]
|
|
189
193
|
Verify a signed DMG, quit, replace & relaunch hidden; no UI, no osascript (macOS)
|
|
190
|
-
|
|
191
|
-
install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--json]
|
|
192
|
-
Windows: run the installer in place at the fixed folder,
|
|
194
|
+
A release install keeps the replaced app beside the fixed path as <App>.app.previous; --dev keeps NO old copy
|
|
195
|
+
install-app --installer <Setup.exe> --dest <fixed install folder> --exe <App.exe> [--payload <file>]... [--payload-manifest <json>] [--json]
|
|
196
|
+
Windows: run the installer in place at the fixed folder; payload-only rollback (never the whole folder, user data beside the app is never copied)
|
|
193
197
|
build [--platform mac|win] [--skip-checks] [--resume-from <phase>]
|
|
194
198
|
Resume macOS at prepackage, sign, package, post-sign, or harden
|
|
195
199
|
dev --platform win|mac [--config <path>] [--dry-run] [--build-only]
|
package/development-signing.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { spawnSync } from "./background-process.mjs";
|
|
6
6
|
import { runMacCodesignCommands } from "./macos-signing-session.mjs";
|
|
7
|
+
import { bundleRelativePath, compileBundlePathMap, globToRegExp, isGlobPattern, lookupBundlePath, resolveBundleIdentifier, validateBundleRelativeKey, validateIdentifierTemplate, validateSigningIdentifier } from "./mac-bundle-signing.mjs";
|
|
7
8
|
|
|
8
9
|
export class MacosDevelopmentSigningError extends Error {
|
|
9
10
|
constructor(code, message, details = {}) {
|
|
@@ -100,7 +101,8 @@ export function resolveMacosDevelopmentSigning({ root, bundle, descriptor = {},
|
|
|
100
101
|
if (existsSync(file)) mac = { ...mac, ...JSON.parse(readFileSync(file, "utf8")).bundle?.macOS };
|
|
101
102
|
}
|
|
102
103
|
const sign = config.targets?.mac?.sign ?? {};
|
|
103
|
-
|
|
104
|
+
// `sign.entitlements` is the app-level file when a string; an object is the per-path map (readDevSigningSettings).
|
|
105
|
+
const entitlements = typeof sign.entitlements === "string" && sign.entitlements ? path.resolve(root, sign.entitlements) : mac.entitlements ? path.resolve(tauriRoot, mac.entitlements) : null;
|
|
104
106
|
if (entitlements && !existsSync(entitlements)) throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", `Configured entitlements file is missing: ${entitlements}`);
|
|
105
107
|
// CI-only ad hoc: no Keychain, no provisioning profile (a profile authorizes restricted entitlements that an ad hoc signature cannot carry).
|
|
106
108
|
if (ciAdhoc) return { identity: "-", certificateSha1: "-", teamIdentifier: null, entitlements, provisioningProfile: null, mode: CI_ADHOC_SIGNING, adhoc: true };
|
|
@@ -158,9 +160,128 @@ function macho(file) {
|
|
|
158
160
|
} finally { closeSync(fd); }
|
|
159
161
|
}
|
|
160
162
|
|
|
163
|
+
const invalidConfig = (message) => { throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", message); };
|
|
164
|
+
const ASSERT_KEYS = new Set(["path", "identifier", "teamIdentifier", "authority"]);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Declarative per-binary signing settings of a dev install. Everything is optional; with none declared the result is
|
|
168
|
+
* empty and signing is exactly what it was before these keys existed.
|
|
169
|
+
* install.identifiers / sign.identifiers { path-or-glob: "--identifier value" } ("." is the bundle)
|
|
170
|
+
* install.identifierTemplate / sign.identifierTemplate default identifier for remaining plain Mach-O files
|
|
171
|
+
* install.entitlements / sign.entitlements (object) { path-or-glob: plist path } (a string sign.entitlements is the app file)
|
|
172
|
+
* install.timestamp true adds --timestamp (a secure timestamp contacts Apple's server)
|
|
173
|
+
* install.assert [{ path-or-glob, identifier?, teamIdentifier?, authority? }]
|
|
174
|
+
* install.assertTeamSignedNested true: every Mach-O in the bundle carries the app's TeamIdentifier
|
|
175
|
+
* install.* entries win over sign.* entries (so one declaration in `sign` keeps dev and release identifiers identical).
|
|
176
|
+
* `root` resolves entitlement plist paths and checks they exist; without it only the shape is validated.
|
|
177
|
+
*/
|
|
178
|
+
export function readDevSigningSettings(descriptor = {}, config = {}, { root, exists = existsSync } = {}) {
|
|
179
|
+
const sign = config?.targets?.mac?.sign ?? {};
|
|
180
|
+
const identifier = (raw, label) => validateSigningIdentifier(raw, label, invalidConfig);
|
|
181
|
+
const identifiers = [...compileBundlePathMap(descriptor.identifiers, "install.identifiers", { reject: invalidConfig, value: identifier }),
|
|
182
|
+
...compileBundlePathMap(sign.identifiers, "sign.identifiers", { reject: invalidConfig, value: identifier })];
|
|
183
|
+
const templateSource = descriptor.identifierTemplate !== undefined ? ["install.identifierTemplate", descriptor.identifierTemplate] : sign.identifierTemplate !== undefined ? ["sign.identifierTemplate", sign.identifierTemplate] : null;
|
|
184
|
+
const identifierTemplate = templateSource ? validateIdentifierTemplate(templateSource[1], templateSource[0], invalidConfig) : undefined;
|
|
185
|
+
const plistPath = (raw, label) => {
|
|
186
|
+
if (typeof raw !== "string" || !raw.trim()) invalidConfig(`${label} must be an entitlements plist path`);
|
|
187
|
+
if (!root) return raw;
|
|
188
|
+
const file = path.resolve(root, raw);
|
|
189
|
+
if (!exists(file)) invalidConfig(`${label} entitlements file not found: ${file}`);
|
|
190
|
+
return file;
|
|
191
|
+
};
|
|
192
|
+
const signMap = typeof sign.entitlements === "object" && sign.entitlements !== null && !Array.isArray(sign.entitlements) ? sign.entitlements : undefined;
|
|
193
|
+
const entitlements = [...compileBundlePathMap(descriptor.entitlements, "install.entitlements", { reject: invalidConfig, value: plistPath }),
|
|
194
|
+
...compileBundlePathMap(signMap, "sign.entitlements", { reject: invalidConfig, value: plistPath })];
|
|
195
|
+
if (descriptor.timestamp !== undefined && typeof descriptor.timestamp !== "boolean") invalidConfig("install.timestamp must be true or false");
|
|
196
|
+
if (descriptor.assertTeamSignedNested !== undefined && typeof descriptor.assertTeamSignedNested !== "boolean") invalidConfig("install.assertTeamSignedNested must be true or false");
|
|
197
|
+
const rawAssertions = descriptor.assert ?? [];
|
|
198
|
+
if (!Array.isArray(rawAssertions)) invalidConfig("install.assert must be an array of { path, identifier?, teamIdentifier?, authority? }");
|
|
199
|
+
const assertions = rawAssertions.map((item, index) => {
|
|
200
|
+
const label = `install.assert[${index}]`;
|
|
201
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) invalidConfig(`${label} must be an object`);
|
|
202
|
+
for (const key of Object.keys(item)) if (!ASSERT_KEYS.has(key)) invalidConfig(`${label}.${key} is not a known key (path, identifier, teamIdentifier, authority)`);
|
|
203
|
+
validateBundleRelativeKey(item.path, `${label}.path`, invalidConfig);
|
|
204
|
+
const checks = ["identifier", "teamIdentifier", "authority"].filter((key) => item[key] !== undefined);
|
|
205
|
+
if (!checks.length) invalidConfig(`${label} needs at least one of identifier, teamIdentifier, authority`);
|
|
206
|
+
for (const key of checks) if (typeof item[key] !== "string" || !item[key].trim()) invalidConfig(`${label}.${key} must be a non-empty string`);
|
|
207
|
+
return { path: item.path, ...Object.fromEntries(checks.map((key) => [key, item[key]])) };
|
|
208
|
+
});
|
|
209
|
+
return { identifiers, identifierTemplate, entitlements, timestamp: descriptor.timestamp === true, assertions, assertTeamSignedNested: descriptor.assertTeamSignedNested === true };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function listBundleFiles(bundle) {
|
|
213
|
+
const out = [];
|
|
214
|
+
const walk = (directory) => {
|
|
215
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
216
|
+
const file = path.join(directory, entry.name);
|
|
217
|
+
if (entry.isSymbolicLink()) continue;
|
|
218
|
+
if (entry.isDirectory()) walk(file);
|
|
219
|
+
else if (entry.isFile()) out.push(bundleRelativePath(bundle, file));
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
walk(bundle);
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Post-sign identity assertions, read from `codesign -dvv` (stdout and stderr: codesign reports on stderr). Fails closed with
|
|
228
|
+
* DEV_MAC_SIGNING_ASSERT_FAILED listing every mismatch. A glob that matches no file is a failure, so a typo cannot pass.
|
|
229
|
+
* `assertTeamSignedNested` additionally requires every Mach-O in the bundle (dylibs included) to be signed by
|
|
230
|
+
* `teamIdentifier`, not ad hoc. Used on the signed staging copy and again on the installed copy (a failure there rolls back).
|
|
231
|
+
* Injectable: run, listFiles(bundle) -> bundle-relative file paths, isMachO(file), join.
|
|
232
|
+
*/
|
|
233
|
+
export function assertSignedIdentities({ root, bundle, settings, teamIdentifier, identity, env = process.env, run = spawnSync, listFiles = listBundleFiles, isMachO = macho, join = path.join, label = "signed bundle" }) {
|
|
234
|
+
const { assertions = [], assertTeamSignedNested = false } = settings ?? {};
|
|
235
|
+
if (!assertions.length && !assertTeamSignedNested) return { checked: 0 };
|
|
236
|
+
const reports = new Map();
|
|
237
|
+
const report = (file) => {
|
|
238
|
+
if (!reports.has(file)) {
|
|
239
|
+
const result = run("codesign", ["-dvv", file], captureOptions(root, env));
|
|
240
|
+
if (result.error || result.status !== 0) {
|
|
241
|
+
throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_ASSERT_FAILED", `${label}: cannot inspect the signing identity of ${file}: ${result.error?.message || output(result) || `exit ${result.status ?? "unavailable"}`}`, { identity });
|
|
242
|
+
}
|
|
243
|
+
const text = output(result);
|
|
244
|
+
reports.set(file, { identifier: /^Identifier=(.+)$/m.exec(text)?.[1]?.trim(), team: /^TeamIdentifier=(.+)$/m.exec(text)?.[1]?.trim(), authorities: [...text.matchAll(/^Authority=(.+)$/gm)].map((match) => match[1].trim()), adhoc: /^Signature=adhoc$/m.test(text) });
|
|
245
|
+
}
|
|
246
|
+
return reports.get(file);
|
|
247
|
+
};
|
|
248
|
+
const absolute = (relative) => (relative === "." ? bundle : join(bundle, ...relative.split("/")));
|
|
249
|
+
let files;
|
|
250
|
+
const listed = () => (files ??= listFiles(bundle));
|
|
251
|
+
const problems = [];
|
|
252
|
+
let checked = 0;
|
|
253
|
+
for (const item of assertions) {
|
|
254
|
+
const targets = isGlobPattern(item.path) ? listed().filter((relative) => globToRegExp(item.path).test(relative)) : [item.path];
|
|
255
|
+
if (!targets.length) { problems.push(`${item.path}: matches no file in the bundle`); continue; }
|
|
256
|
+
for (const relative of targets) {
|
|
257
|
+
const actual = report(absolute(relative));
|
|
258
|
+
checked += 1;
|
|
259
|
+
if (item.identifier !== undefined && actual.identifier !== item.identifier) problems.push(`${relative}: Identifier=${actual.identifier ?? "missing"}, expected ${item.identifier}`);
|
|
260
|
+
if (item.teamIdentifier !== undefined && actual.team !== item.teamIdentifier) problems.push(`${relative}: TeamIdentifier=${actual.team ?? "missing"}, expected ${item.teamIdentifier}`);
|
|
261
|
+
if (item.authority !== undefined && !actual.authorities.includes(item.authority)) problems.push(`${relative}: Authority does not include ${JSON.stringify(item.authority)} (found ${actual.authorities.map((name) => JSON.stringify(name)).join(", ") || "none"})`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (assertTeamSignedNested) {
|
|
265
|
+
if (!teamIdentifier) problems.push("assertTeamSignedNested: the expected TeamIdentifier is unknown (ad hoc signing has none)");
|
|
266
|
+
else {
|
|
267
|
+
for (const relative of [".", ...listed().filter((entry) => isMachO(absolute(entry)))]) {
|
|
268
|
+
const actual = report(absolute(relative));
|
|
269
|
+
checked += 1;
|
|
270
|
+
if (actual.adhoc || actual.team !== teamIdentifier) problems.push(`${relative}: ${actual.adhoc ? "ad hoc signed" : `TeamIdentifier=${actual.team ?? "missing"}`}, expected TeamIdentifier=${teamIdentifier}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (problems.length) {
|
|
275
|
+
throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_ASSERT_FAILED", `${label}: signing identity assertions failed (${problems.length}): ${problems.slice(0, 20).join("; ")}${problems.length > 20 ? "; ..." : ""}. Refusing dev installation.`, { identity, problems });
|
|
276
|
+
}
|
|
277
|
+
return { checked };
|
|
278
|
+
}
|
|
279
|
+
|
|
161
280
|
export function signMacosDevelopmentBundle(options) {
|
|
162
281
|
const { root, bundle, env = process.env, run = spawnSync } = options;
|
|
163
282
|
const context = { root, env, run };
|
|
283
|
+
// Declared per-binary settings are validated before any Keychain or codesign call.
|
|
284
|
+
const settings = readDevSigningSettings(options.descriptor, options.config, { root });
|
|
164
285
|
const signing = resolveMacosDevelopmentSigning({ ...options, env, run });
|
|
165
286
|
const { identity, certificateSha1, teamIdentifier, entitlements, provisioningProfile } = signing;
|
|
166
287
|
const embedded = path.join(bundle, "Contents", "embedded.provisionprofile");
|
|
@@ -218,12 +339,20 @@ export function signMacosDevelopmentBundle(options) {
|
|
|
218
339
|
const commands = [];
|
|
219
340
|
for (const target of ordered) {
|
|
220
341
|
// Bind the exact profile-authorized certificate even when names collide in Keychain.
|
|
221
|
-
const
|
|
342
|
+
const relative = bundleRelativePath(bundle, target.file);
|
|
343
|
+
let identifier;
|
|
344
|
+
try {
|
|
345
|
+
identifier = resolveBundleIdentifier({ relative, identifiers: settings.identifiers, template: settings.identifierTemplate, bundleId: options.descriptor?.bundleId ?? appInfo.CFBundleIdentifier, plain: !target.info });
|
|
346
|
+
} catch (cause) { throw new MacosDevelopmentSigningError("DEV_MAC_SIGNING_CONFIG_INVALID", cause.message, { cause }); }
|
|
347
|
+
// --timestamp only when declared (install.timestamp): it needs Apple's timestamp server, and dev builds otherwise sign offline.
|
|
348
|
+
const args = ["--force", "--options", "runtime", ...(settings.timestamp ? ["--timestamp"] : []), ...(identifier ? ["--identifier", identifier] : []), "--sign", certificateSha1];
|
|
222
349
|
const parent = target.file === bundle;
|
|
350
|
+
const mappedEntitlements = lookupBundlePath(settings.entitlements, relative || ".");
|
|
223
351
|
const profiledHelper = target.info && existsSync(path.join(target.file, "Contents/embedded.provisionprofile")) && target.info.CFBundleIdentifier === appInfo.CFBundleIdentifier;
|
|
224
352
|
if (profiledHelper && !parent && provisioningProfile) copyFileSync(embedded, path.join(target.file, "Contents/embedded.provisionprofile"));
|
|
225
353
|
const variant = !parent && entitlementVariants.find((item) => path.basename(target.file).replace(/\.(app|xpc|appex|bundle)$/, "").toLowerCase().endsWith(item.suffix));
|
|
226
|
-
if (
|
|
354
|
+
if (mappedEntitlements) args.push("--entitlements", entitlementFile(mappedEntitlements));
|
|
355
|
+
else if (variant) args.push("--entitlements", entitlementFile(variant.file));
|
|
227
356
|
else if (entitlements && (parent || profiledHelper)) args.push("--entitlements", entitlementFile(entitlements));
|
|
228
357
|
else {
|
|
229
358
|
const existing = run("codesign", ["--display", "--entitlements", ":-", target.file], captureOptions(root, env));
|
|
@@ -248,7 +377,9 @@ export function signMacosDevelopmentBundle(options) {
|
|
|
248
377
|
const requirement = output(checked("codesign", ["--display", "-r-", bundle], context, "DEV_MAC_SIGNATURE_INVALID", identity));
|
|
249
378
|
const designated = /^designated => (.+)$/m.exec(requirement)?.[1]?.trim();
|
|
250
379
|
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
|
-
|
|
380
|
+
// Declared identity assertions (install.assert, install.assertTeamSignedNested) on the signed staging copy, before any install.
|
|
381
|
+
const asserted = assertSignedIdentities({ root, bundle, settings, teamIdentifier, identity, env, run, label: "signed staging copy", ...(options.listFiles ? { listFiles: options.listFiles } : {}), ...(options.isMachO ? { isMachO: options.isMachO } : {}) });
|
|
382
|
+
return { ...signing, provisioningProfile: provisioningProfile ? embedded : null, verified: "strict-deep", designatedRequirement: designated, signedTargets: ordered.length, ...(asserted.checked ? { assertedIdentities: asserted.checked } : {}) };
|
|
252
383
|
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
|
253
384
|
}
|
|
254
385
|
|
package/development.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import { previousPathOf, stagingPathOf, swapInPlace } from "./in-place-install.m
|
|
|
13
13
|
import { stageDevelopmentBinaries, validateStageEntries } from "./stage-binary.mjs";
|
|
14
14
|
import { assertStableDesignatedRequirement, readBundleExecutable, requestedCiAdhocSigning, signMacosDevelopmentBundle } from "./development-signing.mjs";
|
|
15
15
|
import { macosCodesignEnvironment } from "./codesign-environment.mjs";
|
|
16
|
+
import { installTauriDevId, resolveTauriDevIdDescriptor, TAURI_DEVID_KIND } from "./install-tauri-devid.mjs";
|
|
16
17
|
import { androidDevelopmentNdkCommand, androidDevelopmentSerial, installAndroidDevelopment, validateAndroidDevelopment } from "./android-dev.mjs";
|
|
17
18
|
|
|
18
19
|
// One standard Cargo profile for every app (templates/cargo-profiles.toml): Cargo's built-in `dev`.
|
|
@@ -70,6 +71,10 @@ export function validateDevelopmentConfig(config, platform, { root = process.cwd
|
|
|
70
71
|
for (const key of ["sourceDirectory", "entrypoint"]) {
|
|
71
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`);
|
|
72
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`);
|
|
73
78
|
} else if (target.install?.kind === "adb") {
|
|
74
79
|
if (platform !== "android") throw new Error("the adb development installer is for --platform android");
|
|
75
80
|
} else if (target.install) throw new Error(`unsupported development installer kind: ${target.install.kind}`);
|
|
@@ -94,19 +99,28 @@ export function runDevelopment({ root, platform, config, buildOnly = false, dryR
|
|
|
94
99
|
const childEnv = codesignEnvironment(developmentEnvironment(env));
|
|
95
100
|
// One lease spans every managed request this run makes, so a broker cutover cannot land between them.
|
|
96
101
|
const lease = acquireManagedRunLease({ command: `right-release dev --platform ${platform}`, env });
|
|
102
|
+
let releaseLease = true;
|
|
97
103
|
try {
|
|
98
104
|
const startedMs = Date.now();
|
|
99
105
|
// Android: cargo-ndk (Cargo dev profile) fills the generated jniLibs, then the Gradle debug build packages it.
|
|
100
106
|
if (target.ndk) { rmSync(target.ndk.jniLibs, { recursive: true, force: true }); execute(androidDevelopmentNdkCommand(target.ndk), { root, env: childEnv, dryRun, run, label: "native build" }); }
|
|
101
107
|
for (const command of target.build) execute(command, { root, env: childEnv, dryRun, run, label: "build" });
|
|
102
|
-
// Dev and QA binaries run from one stable staged path, copied in place (
|
|
108
|
+
// Dev and QA binaries run from one stable staged path, copied in place (no backup of the old copy), never from the
|
|
103
109
|
// broker's per-generation target folder or a hashed dev-targets folder.
|
|
104
110
|
const staged = target.stage?.length ? stageDevelopmentBinaries(target.stage, { root, platform: platform === "win" ? "win32" : process.platform }) : [];
|
|
105
111
|
for (const file of staged) console.log(`right-release dev: staged ${file}`);
|
|
106
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, releaseConfig: config, built, env: childEnv })
|
|
118
|
+
.then((outcome) => ({ built: true, installed: true, ...(staged.length ? { staged } : {}), devid: outcome }))
|
|
119
|
+
.finally(() => lease.release());
|
|
120
|
+
}
|
|
107
121
|
const signing = install(target.install, { root, platform, config, env: childEnv, run, sinceMs: startedMs, serial });
|
|
108
122
|
return { built: true, installed: true, ...(staged.length ? { staged } : {}), ...(platform === "mac" && target.install.kind === "tauri" ? { signing } : {}) };
|
|
109
|
-
} finally { lease.release(); }
|
|
123
|
+
} finally { if (releaseLease) lease.release(); }
|
|
110
124
|
}
|
|
111
125
|
|
|
112
126
|
export function assertNativeDevelopmentHost(platform, host = process.platform) {
|
|
@@ -126,9 +140,10 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
|
|
|
126
140
|
// was running, pass /R so the installer relaunches it as the user (RunAsUser, outside this process tree).
|
|
127
141
|
const running = isWindowsProcessRunning(path.basename(descriptor.expectedInstalledPath), { run });
|
|
128
142
|
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.
|
|
143
|
+
// The silent installer updates the fixed path in place. NO backup of the old copy (Orthic-Labs/rightkit#348): a stale
|
|
144
|
+
// <exe>.previous left by an older RightKit is removed, nothing is copied, and a failed install is reinstalled from an older build.
|
|
130
145
|
const installedBefore = path.resolve(root, descriptor.expectedInstalledPath);
|
|
131
|
-
|
|
146
|
+
rmSync(previousPathOf(installedBefore), { recursive: true, force: true });
|
|
132
147
|
const result = run(app, windowsInstallerArguments({ running }), { cwd: root, env, stdio: "inherit", windowsHide: true, shell: false });
|
|
133
148
|
if (result.status !== 0) throw new Error(`development installer failed (${result.status}): ${app}`);
|
|
134
149
|
const installed = path.resolve(root, descriptor.expectedInstalledPath);
|
|
@@ -142,7 +157,6 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
|
|
|
142
157
|
mkdirSync(path.dirname(destination), { recursive: true });
|
|
143
158
|
// Fixed staging name (not per-pid): the copy is signed and verified here, then renamed into place. Nothing runs from it.
|
|
144
159
|
const staging = stagingPathOf(destination);
|
|
145
|
-
const previous = previousPathOf(destination);
|
|
146
160
|
rmSync(staging, { recursive: true, force: true });
|
|
147
161
|
cpSync(app, staging, { recursive: true, verbatimSymlinks: true });
|
|
148
162
|
let signing;
|
|
@@ -153,7 +167,7 @@ function install(descriptor, { root, platform, config, env, run, sinceMs = null,
|
|
|
153
167
|
if (!signing.adhoc && existsSync(destination)) assertStableDesignatedRequirement({ root, installed: destination, expected: signing.designatedRequirement, env, run });
|
|
154
168
|
executableName = readBundleExecutable(staging, { root, env, run });
|
|
155
169
|
} catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; }
|
|
156
|
-
swapInstalledMacosBundle({ staging, destination,
|
|
170
|
+
swapInstalledMacosBundle({ staging, destination, executableName });
|
|
157
171
|
if (signing.provisioningProfile) signing.provisioningProfile = path.join(destination, "Contents", "embedded.provisionprofile");
|
|
158
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`);
|
|
159
173
|
else console.log(`right-release dev: installed ${destination}; identity=${signing.identity}; TeamIdentifier=${signing.teamIdentifier}; profile=${signing.provisioningProfile ?? "none"}; designated=${signing.designatedRequirement}`);
|
|
@@ -167,12 +181,13 @@ export class DevelopmentInstallError extends Error {
|
|
|
167
181
|
const NODE_FS = { existsSync, lstatSync, mkdirSync, copyFileSync, readFileSync, renameSync, rmSync, statSync };
|
|
168
182
|
|
|
169
183
|
/**
|
|
170
|
-
* Move the signed staging bundle into place
|
|
184
|
+
* Move the signed staging bundle into place IN PLACE with NO backup (Orthic-Labs/rightkit#348: the old app is removed,
|
|
185
|
+
* never kept as `<destination>.previous`; a stale `.previous` is removed too), and verify it: Info.plist exists and
|
|
171
186
|
* 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
|
|
172
|
-
* new install,
|
|
187
|
+
* new (absent or partial) install, does NOT restore the old app (reinstall from an older DMG), and throws a DevelopmentInstallError (code DEV_INSTALL_VERIFICATION_FAILED,
|
|
173
188
|
* DEV_INSTALL_EXECUTABLE_MISSING or DEV_INSTALL_EXECUTABLE_MISMATCH). `fs` is the file-system seam (tests inject it).
|
|
174
189
|
*/
|
|
175
|
-
export function swapInstalledMacosBundle({ staging, destination,
|
|
190
|
+
export function swapInstalledMacosBundle({ staging, destination, executableName, fs = NODE_FS }) {
|
|
176
191
|
if (typeof executableName !== "string" || !executableName) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", "swapInstalledMacosBundle requires the staged CFBundleExecutable name");
|
|
177
192
|
const executable = path.join("Contents", "MacOS", executableName);
|
|
178
193
|
const digest = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
@@ -182,9 +197,9 @@ export function swapInstalledMacosBundle({ staging, destination, previous = prev
|
|
|
182
197
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
183
198
|
throw new DevelopmentInstallError("DEV_INSTALL_EXECUTABLE_MISSING", `staged app has no main executable ${executable}: ${staging} (${error.message})`);
|
|
184
199
|
}
|
|
185
|
-
// The old app
|
|
200
|
+
// The old app is removed and the staged one renamed over it; no old copy survives, even on failure.
|
|
186
201
|
swapInPlace({
|
|
187
|
-
staging, destination, previous, fs,
|
|
202
|
+
staging, destination, previous: null, fs,
|
|
188
203
|
verify: () => {
|
|
189
204
|
if (!fs.existsSync(path.join(destination, "Contents", "Info.plist"))) throw new DevelopmentInstallError("DEV_INSTALL_VERIFICATION_FAILED", `installed app verification failed: ${destination}`);
|
|
190
205
|
const installed = path.join(destination, executable);
|
|
@@ -341,7 +356,7 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
341
356
|
if (pathKey) process.env[pathKey] = safeEnv[pathKey] ?? process.env[pathKey];
|
|
342
357
|
const loaded = await import(pathToFileURL(absoluteConfig));
|
|
343
358
|
const config = loaded.default ?? loaded.config ?? loaded;
|
|
344
|
-
const result = runDevelopment({ root: path.dirname(absoluteConfig), platform, config, dryRun, buildOnly, serial });
|
|
359
|
+
const result = await runDevelopment({ root: path.dirname(absoluteConfig), platform, config, dryRun, buildOnly, serial });
|
|
345
360
|
if (dryRun) console.log(JSON.stringify(result.plan, null, 2));
|
|
346
361
|
return result;
|
|
347
362
|
}
|
package/in-place-install.mjs
CHANGED
|
@@ -4,8 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
/**
|
|
5
5
|
* The one install rule for every app, helper and dev or QA binary (Adrian, 2026-10-17: "No new id, no new folder"):
|
|
6
6
|
* macOS and Windows tie privacy grants to the path and the code identity, so an install or update replaces the
|
|
7
|
-
* copy at ONE fixed path.
|
|
8
|
-
*
|
|
7
|
+
* copy at ONE fixed path. Release installs may keep a persistent `<name>.previous` beside it (rename it back).
|
|
8
|
+
* Dev and QA installs NEVER keep an old copy (Orthic-Labs/rightkit#348: "when installing dev, old copy must never be
|
|
9
|
+
* backedup"): they pass `previous: null` to `swapInPlace`, or use `replaceFileInPlace`, which has no backup at all.
|
|
10
|
+
* Nothing ever runs from `.previous` or from the staging copy.
|
|
9
11
|
*/
|
|
10
12
|
export const PREVIOUS_SUFFIX = ".previous";
|
|
11
13
|
export const STAGING_SUFFIX = ".staging";
|
|
@@ -20,12 +22,19 @@ const exists = (fs, file) => {
|
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
/**
|
|
23
|
-
* Swap `staging` into `destination
|
|
24
|
-
*
|
|
25
|
-
* path
|
|
25
|
+
* Swap `staging` into `destination`, then `verify(destination)` runs.
|
|
26
|
+
*
|
|
27
|
+
* With a `previous` path (release): the old destination becomes `previous` (replacing any older `.previous`). Any
|
|
28
|
+
* failure removes the new copy and renames `previous` back, so the fixed path is never left empty. Renames only.
|
|
26
29
|
* @returns {{destination: string, previous: string|null}} previous is null when nothing was installed before.
|
|
30
|
+
*
|
|
31
|
+
* With `previous: null` (dev and QA, NO backup of any kind): the old destination is removed and `staging` is renamed
|
|
32
|
+
* over the fixed path; a stale `<destination>.previous` left by an older RightKit is removed too. Any failure removes
|
|
33
|
+
* the new (absent or partial) copy and the staging copy and rethrows; the old copy is NOT restored, the user reinstalls
|
|
34
|
+
* from an older build. `previous` is always null in the result.
|
|
27
35
|
*/
|
|
28
36
|
export function swapInPlace({ staging, destination, previous = previousPathOf(destination), verify = () => {}, fs = nodeFs }) {
|
|
37
|
+
if (previous === null) return replaceWithoutBackup({ staging, destination, verify, fs });
|
|
29
38
|
if (previous === destination || staging === destination || staging === previous) throw new Error("swapInPlace needs three distinct paths");
|
|
30
39
|
let movedAside = false;
|
|
31
40
|
try {
|
|
@@ -45,12 +54,28 @@ export function swapInPlace({ staging, destination, previous = previousPathOf(de
|
|
|
45
54
|
return { destination, previous: movedAside ? previous : null };
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
|
|
57
|
+
function replaceWithoutBackup({ staging, destination, verify, fs }) {
|
|
58
|
+
if (staging === destination) throw new Error("swapInPlace needs distinct staging and destination paths");
|
|
59
|
+
try {
|
|
60
|
+
if (exists(fs, destination)) fs.rmSync(destination, { recursive: true, force: true });
|
|
61
|
+
fs.renameSync(staging, destination);
|
|
62
|
+
verify(destination);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
try { fs.rmSync(destination, { recursive: true, force: true }); } catch { /* keep the original error */ }
|
|
65
|
+
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
// No old copy may survive a dev install, including one an older RightKit left behind.
|
|
69
|
+
try { fs.rmSync(previousPathOf(destination), { recursive: true, force: true }); } catch { /* best effort */ }
|
|
70
|
+
return { destination, previous: null };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Copy one dev or QA file to its fixed path in place: temp sibling, then rename. NO backup of the old file. Returns the path. */
|
|
49
74
|
export function replaceFileInPlace(from, destination, { fs = nodeFs } = {}) {
|
|
50
75
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
51
76
|
const staging = stagingPathOf(destination);
|
|
52
77
|
fs.rmSync(staging, { force: true });
|
|
53
78
|
fs.copyFileSync(from, staging);
|
|
54
|
-
swapInPlace({ staging, destination });
|
|
79
|
+
swapInPlace({ staging, destination, previous: null, fs });
|
|
55
80
|
return destination;
|
|
56
81
|
}
|