@rightkit/release 0.6.24 → 0.6.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +11 -35
  2. package/android-dev.mjs +2 -1
  3. package/app-check.mjs +1 -2
  4. package/app-closure.mjs +3 -2
  5. package/assembled-app.mjs +11 -3
  6. package/bin/darwin/rightkit-swap +0 -0
  7. package/bin/darwin/rightkit-swap.sign.json +6 -0
  8. package/bin/rightkit-gate.mjs +83 -26
  9. package/build-helper-trace.mjs +264 -0
  10. package/build-helpers-command.mjs +184 -0
  11. package/build-launch.mjs +11 -75
  12. package/build-release.mjs +10 -11
  13. package/cargo-profile-contract.mjs +19 -29
  14. package/cargo-target-shim.mjs +31 -0
  15. package/cli/right-release.mjs +5 -7
  16. package/create-mac-updater.mjs +4 -0
  17. package/cross-targets.mjs +94 -0
  18. package/deterministic-scans.mjs +310 -20
  19. package/development.mjs +8 -8
  20. package/freshness-census.mjs +1 -1
  21. package/gate-stages.mjs +1 -1
  22. package/install-assembled-app.mjs +1 -1
  23. package/install-tauri-authenticode.mjs +3 -3
  24. package/local-publish.mjs +27 -5
  25. package/mac-package-recipe.mjs +55 -2
  26. package/managed-cargo.mjs +27 -1
  27. package/managed-roots.mjs +12 -4
  28. package/package.json +1 -5
  29. package/pipeline-admission.mjs +1 -1
  30. package/pipeline-receipts.mjs +302 -302
  31. package/provenance-verify.mjs +3 -1
  32. package/qa-build-features.mjs +11 -2
  33. package/release-qa-audit.mjs +9 -27
  34. package/release-qa-command.mjs +23 -64
  35. package/release-qa-fixtures.mjs +9 -5
  36. package/release-qa-floor.json +0 -1
  37. package/release-qa-policy.mjs +9 -10
  38. package/release-qa-receipt.mjs +6 -44
  39. package/release-source-identity.mjs +1 -12
  40. package/release-switch-registry.json +4 -1
  41. package/rightkit-registry-resolver.mjs +35 -3
  42. package/rightkit-versions.json +2 -2
  43. package/sign-windows.mjs +1 -1
  44. package/snapshot-broker.mjs +33 -8
  45. package/snapshot-metadata.mjs +32 -0
  46. package/snapshot-request.mjs +6 -3
  47. package/stage-sidecar.mjs +28 -11
  48. package/suite-signing-policy.mjs +8 -58
  49. package/templates/cargo-profiles.toml +6 -25
  50. package/test-command.mjs +40 -140
  51. package/updater-shipped-key.mjs +26 -0
  52. package/web-build.mjs +29 -4
  53. package/qa-journeys-prebuilt-run.mjs +0 -18
  54. package/qa-journeys-prebuilt.mjs +0 -148
  55. package/qa-layout.mjs +0 -229
  56. package/qa-snapshot-run.mjs +0 -53
  57. package/qa-snapshot.mjs +0 -164
  58. package/sign-qa-bundle.mjs +0 -210
  59. package/suite-signing.mjs +0 -259
@@ -1,210 +0,0 @@
1
- #!/usr/bin/env node
2
- // right-release sign-qa-bundle: QA-ONLY ad hoc signing of an already-assembled .app that lives inside a QA scratch root.
3
- // Why it exists: an assembled QA bundle (for example an Accessibility QA copy) needs a coherent ad hoc signature, but
4
- // `codesign --force --deep --sign -` routed through the signing agent (SSH or non-Aqua session) is refused by design
5
- // (macos-signing-session.mjs codesignAgentItems; rightkit-signing-agent.mjs). Those refusals stay. This module never calls the
6
- // agent: it spawns /usr/bin/codesign itself, signs nested code one item at a time, deepest first, the outer bundle last, with
7
- // identity "-" and never `--deep`. Ad hoc output is not a release artifact: it is never notarized, sealed, published or installed
8
- // under /Applications, and the receipt says so (signature "adhoc", releaseArtifact false, qaOnly true).
9
- import { existsSync, lstatSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
10
- import os from "node:os";
11
- import path from "node:path";
12
- import { pathToFileURL } from "node:url";
13
- import { spawnSync } from "./background-process.mjs";
14
- import { adhocSafeEntitlements } from "./adhoc-entitlements.mjs";
15
-
16
- export const QA_BUNDLE_SIGNING_SCHEMA = 1;
17
- export const CODESIGN = "/usr/bin/codesign";
18
- const PLUTIL = "/usr/bin/plutil";
19
- // macOS paths only: posix semantics keep the checks identical on every host that unit-tests this module.
20
- const px = path.posix;
21
-
22
- export class QaBundleSigningError extends Error {
23
- constructor(code, message, details = {}) {
24
- super(message);
25
- this.name = "QaBundleSigningError";
26
- this.code = code;
27
- Object.assign(this, details);
28
- }
29
- }
30
- const fail = (code, message, details) => { throw new QaBundleSigningError(code, message, details); };
31
-
32
- const defaultIo = { lstatSync, readdirSync, realpathSync, existsSync, mkdtempSync, writeFileSync, rmSync };
33
- const captureOptions = { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false };
34
- const outputOf = (result) => `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
35
- const inside = (parent, child) => child !== parent && child.startsWith(parent === "/" ? "/" : `${parent}/`);
36
-
37
- function checked(run, args, code, command = CODESIGN) {
38
- const result = run(command, args, captureOptions);
39
- if (!result || result.error || result.status !== 0) {
40
- fail(code, `${command} ${args[0]} failed (${result?.status ?? "unavailable"}): ${result?.error?.message || outputOf(result ?? {})}`, { status: result?.status });
41
- }
42
- return result;
43
- }
44
-
45
- function lstatOrNull(io, file) { try { return io.lstatSync(file); } catch { return null; } }
46
-
47
- function requireAbsolute(label, value) {
48
- if (typeof value !== "string" || !value.startsWith("/")) fail("QA_SIGN_PATH_NOT_ABSOLUTE", `${label} must be an absolute macOS path: ${JSON.stringify(value)}`);
49
- if (value.split("/").includes("..") || value.includes("\0")) fail("QA_SIGN_PATH_NOT_NORMAL", `${label} must not contain ".." segments: ${JSON.stringify(value)}`);
50
- return value.length > 1 ? value.replace(/\/+$/, "") : value;
51
- }
52
-
53
- /** Validate the target and return its real paths. Refusals are typed and happen before any codesign call. */
54
- export function resolveQaBundleTarget({ app, qaScratchRoot, platform = process.platform, home = os.homedir(), io = defaultIo }) {
55
- if (platform !== "darwin") fail("QA_SIGN_DARWIN_ONLY", `sign-qa-bundle runs on macOS only (platform ${platform})`);
56
- const lexicalApp = requireAbsolute("--app", app);
57
- const lexicalRoot = requireAbsolute("--qa-root", qaScratchRoot);
58
- const lexicalHome = requireAbsolute("home", home);
59
- const applications = ["/Applications", px.join(lexicalHome, "Applications")];
60
- const real = (label, file) => { try { return io.realpathSync(file); } catch { return fail("QA_SIGN_PATH_MISSING", `${label} does not exist: ${file}`); } };
61
- const realApplications = applications.flatMap((dir) => { const out = [dir]; try { out.push(io.realpathSync(dir)); } catch { /* absent */ } return out; });
62
- const inApplications = (file) => realApplications.some((dir) => file === dir || inside(dir, file));
63
- if (inApplications(lexicalApp)) fail("QA_SIGN_APPLICATIONS_REFUSED", `QA signing refuses installed apps (/Applications, ~/Applications): ${lexicalApp}`);
64
- if (px.extname(lexicalApp) !== ".app") fail("QA_SIGN_NOT_APP", `target is not a .app bundle: ${lexicalApp}`);
65
- const link = lstatOrNull(io, lexicalApp);
66
- if (!link) fail("QA_SIGN_PATH_MISSING", `--app does not exist: ${lexicalApp}`);
67
- if (link.isSymbolicLink()) fail("QA_SIGN_LINK_REFUSED", `--app is a symbolic link; pass the real bundle: ${lexicalApp}`);
68
- if (!link.isDirectory()) fail("QA_SIGN_NOT_APP", `target is not a directory bundle: ${lexicalApp}`);
69
- const realRoot = real("--qa-root", lexicalRoot);
70
- const realApp = real("--app", lexicalApp);
71
- if (realRoot === "/" || realRoot === lexicalHome || inApplications(realRoot) || realApplications.some((dir) => inside(realRoot, dir))) {
72
- fail("QA_SIGN_ROOT_REFUSED", `QA scratch root is too broad or an installed-app location: ${realRoot}`);
73
- }
74
- if (inApplications(realApp)) fail("QA_SIGN_APPLICATIONS_REFUSED", `QA signing refuses installed apps (resolved to ${realApp})`);
75
- if (!inside(realRoot, realApp)) fail("QA_SIGN_OUTSIDE_QA_ROOT", `bundle ${realApp} is not inside the QA scratch root ${realRoot}`);
76
- if (px.extname(realApp) !== ".app") fail("QA_SIGN_NOT_APP", `target resolves to a non-.app path: ${realApp}`);
77
- return { app: lexicalApp, realApp, realRoot };
78
- }
79
-
80
- /** Bundle kinds that are signed as a unit, and the directory whose files are loose Mach-O candidates. */
81
- const BUNDLE_EXTENSIONS = new Set([".app", ".xpc", ".framework"]);
82
-
83
- function readInfo(run, bundle, cache, io) {
84
- if (cache.has(bundle)) return cache.get(bundle);
85
- let info = {};
86
- for (const location of ["Contents/Info.plist", "Resources/Info.plist", "Info.plist"]) {
87
- const file = px.join(bundle, location);
88
- if (!io.existsSync(file)) continue;
89
- const result = checked(run, ["-convert", "json", "-o", "-", file], "QA_SIGN_PLIST_INVALID", PLUTIL);
90
- try { info = JSON.parse(result.stdout); } catch { fail("QA_SIGN_PLIST_INVALID", `Invalid Info.plist JSON: ${file}`); }
91
- break;
92
- }
93
- cache.set(bundle, info);
94
- return info;
95
- }
96
-
97
- /**
98
- * Nested code inside the bundle, ordered inside-out: deepest path first (stable by path), the outer bundle excluded.
99
- * Symbolic links are never entered or signed; a link that resolves outside the bundle is refused.
100
- */
101
- export function enumerateNestedCode({ realApp, run, io = defaultIo }) {
102
- const infoCache = new Map();
103
- const found = new Map();
104
- const add = (file, kind) => { if (!found.has(file)) found.set(file, kind); };
105
- const bundleOf = (dir) => px.dirname(px.dirname(dir)); // .../X.app/Contents/MacOS -> .../X.app
106
- const walk = (dir) => {
107
- for (const entry of io.readdirSync(dir, { withFileTypes: true })) {
108
- const file = px.join(dir, entry.name);
109
- if (entry.isSymbolicLink()) {
110
- let target;
111
- try { target = io.realpathSync(file); } catch { fail("QA_SIGN_LINK_REFUSED", `dangling symbolic link inside the bundle: ${file}`); }
112
- if (target !== realApp && !inside(realApp, target)) fail("QA_SIGN_LINK_REFUSED", `symbolic link escapes the bundle: ${file} -> ${target}`);
113
- continue;
114
- }
115
- const ext = px.extname(entry.name);
116
- if (entry.isDirectory()) {
117
- if (BUNDLE_EXTENSIONS.has(ext)) add(file, ext === ".framework" ? "framework" : ext === ".xpc" ? "xpc" : "app");
118
- else if (px.basename(dir) === "LoginItems" && px.basename(px.dirname(dir)) === "Library") add(file, "login-item");
119
- walk(file);
120
- } else if (entry.isFile()) {
121
- if (ext === ".dylib") add(file, "dylib");
122
- else if (px.basename(dir) === "MacOS" && px.basename(px.dirname(dir)) === "Contents") {
123
- const main = readInfo(run, bundleOf(dir), infoCache, io).CFBundleExecutable;
124
- if (typeof main !== "string" || !main) fail("QA_SIGN_PLIST_INVALID", `no CFBundleExecutable for ${bundleOf(dir)}`);
125
- if (entry.name !== main) add(file, "executable");
126
- } else if (px.basename(dir) === "Frameworks" && px.basename(px.dirname(dir)) === "Contents") add(file, "executable");
127
- else if (px.basename(dir) === "LoginItems" && px.basename(px.dirname(dir)) === "Library") add(file, "login-item");
128
- }
129
- }
130
- };
131
- walk(realApp);
132
- const depth = (file) => file.split("/").length;
133
- return [...found].map(([file, kind]) => ({ path: file, kind }))
134
- .sort((a, b) => depth(b.path) - depth(a.path) || (a.path < b.path ? -1 : 1));
135
- }
136
-
137
- /** The only codesign argv this module emits. `--deep` and any identity other than "-" are structurally impossible. */
138
- export function adhocCodesignArgs(item, entitlementsFile) {
139
- const args = ["--force", "--sign", "-"];
140
- if (entitlementsFile) args.push("--entitlements", entitlementsFile);
141
- args.push(item);
142
- if (args.includes("--deep") || args[args.indexOf("--sign") + 1] !== "-") fail("QA_SIGN_ARGV_REFUSED", "internal: ad hoc argv must never carry --deep or a real identity");
143
- return args;
144
- }
145
-
146
- export function signQaBundleAdhoc({ app, qaScratchRoot, run = spawnSync, platform = process.platform, home = os.homedir(), entitlements, io = defaultIo, out } = {}) {
147
- const target = resolveQaBundleTarget({ app, qaScratchRoot, platform, home, io });
148
- const { realApp, realRoot } = target;
149
- let scratch;
150
- try {
151
- let outerEntitlements;
152
- let entitlementKeys = [];
153
- if (entitlements !== undefined) {
154
- const source = requireAbsolute("entitlements", entitlements);
155
- const parsed = checked(run, ["-convert", "json", "-o", "-", source], "QA_SIGN_ENTITLEMENTS_INVALID", PLUTIL);
156
- let reduced;
157
- try { reduced = adhocSafeEntitlements(JSON.parse(parsed.stdout)); } catch { fail("QA_SIGN_ENTITLEMENTS_INVALID", `Invalid entitlements plist: ${source}`); }
158
- entitlementKeys = Object.keys(reduced).sort();
159
- if (entitlementKeys.length) {
160
- scratch = io.mkdtempSync(px.join(realRoot, ".sign-qa-"));
161
- outerEntitlements = px.join(scratch, "adhoc-entitlements.plist");
162
- io.writeFileSync(outerEntitlements, JSON.stringify(reduced));
163
- checked(run, ["-convert", "xml1", outerEntitlements], "QA_SIGN_ENTITLEMENTS_INVALID", PLUTIL);
164
- }
165
- }
166
- const nested = enumerateNestedCode({ realApp, run, io });
167
- const plan = [...nested, { path: realApp, kind: "bundle" }];
168
- const signed = [];
169
- for (const item of plan) {
170
- const withEntitlements = item.kind === "bundle" ? outerEntitlements : undefined;
171
- checked(run, adhocCodesignArgs(item.path, withEntitlements), "QA_SIGN_CODESIGN_FAILED");
172
- signed.push({ path: item.path, kind: item.kind });
173
- }
174
- checked(run, ["--verify", "--strict", realApp], "QA_SIGN_VERIFY_FAILED");
175
- const receipt = {
176
- schema: QA_BUNDLE_SIGNING_SCHEMA, signature: "adhoc", releaseArtifact: false, qaOnly: true,
177
- notarized: false, published: false, usedSigningAgent: false,
178
- app: realApp, qaScratchRoot: realRoot, entitlementKeys, verified: "codesign --verify --strict", signed,
179
- };
180
- if (out) {
181
- const file = requireAbsolute("--out", out);
182
- io.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
183
- }
184
- return receipt;
185
- } finally {
186
- if (scratch) { try { io.rmSync(scratch, { recursive: true, force: true }); } catch { /* best effort */ } }
187
- }
188
- }
189
-
190
- export function main(args = process.argv.slice(2), options = {}) {
191
- const take = (flag) => {
192
- const index = args.indexOf(flag);
193
- if (index < 0) return undefined;
194
- const value = args[index + 1];
195
- if (!value || value.startsWith("--")) fail("QA_SIGN_USAGE", `${flag} requires a value`);
196
- return value;
197
- };
198
- try {
199
- for (const arg of args.filter((a) => a.startsWith("--"))) {
200
- if (!["--app", "--qa-root", "--out", "--entitlements"].includes(arg)) fail("QA_SIGN_USAGE", `unknown option ${arg}`);
201
- }
202
- const receipt = signQaBundleAdhoc({ app: take("--app"), qaScratchRoot: take("--qa-root"), entitlements: take("--entitlements"), out: take("--out"), ...options });
203
- console.log(`ad hoc signed (QA only, not a release artifact) ${receipt.signed.length} item(s), outer bundle last: ${receipt.app}`);
204
- } catch (error) {
205
- console.error(`right-release sign-qa-bundle: ${error.code ? `[${error.code}] ` : ""}${error.message}`);
206
- process.exitCode = 1;
207
- }
208
- }
209
-
210
- if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) main();
package/suite-signing.mjs DELETED
@@ -1,259 +0,0 @@
1
- // The `sign` step of `tests.suites` (rightkit#1303): a gate-run test build is signed with the product's one signing identity before anything runs it,
2
- // so a QA run exercises the same code identity the release carries (macOS keys privacy grants to the designated requirement).
3
- //
4
- // { name: "sign-app", sign: { files: [{ from: "build-app", path: "aarch64-apple-darwin/debug/heardright" }, ...] } }
5
- //
6
- // It runs IN-PROCESS inside `right-release test`, in the gate's own session and outside the broker (like QA: the signing agent and the Keychain are
7
- // the session's, a broker job has neither), and it is never a carried step (build-launch.mjs refuses it). It reuses the release signers and adds no
8
- // ceremony of its own: macOS signs through signMacosEntries, which signs through runMacCodesignCommands (the signing agent) with the identity
9
- // resolveMacosDevelopmentSigning picks; Windows signs and verifies through sign-windows.mjs (signWindowsFiles, verifyWindowsFiles).
10
- //
11
- // What it does, in this order, and refuses at the first thing that is not so:
12
- // 1. the identity is derived: the product's tauri.conf.json `identifier` (mac install.bundleId must equal it); the team is SIGN_TEAM and the
13
- // Windows signer subject SIGN_SUBJECT; ad hoc signing (RIGHTKIT_DEV_SIGNING, install.signing) is refused;
14
- // 2. each `from` names a suite that ran before in this run; its carried receipt's output directory holds `path` (a path that leaves it, by `..` or
15
- // a link, is refused);
16
- // 3. the file is copied (sha256 checked) into a gate-managed directory outside the snapshot, the checkout and every broker output directory, and the
17
- // copy is signed. The source bytes are never touched;
18
- // 4. read back from the signed copy: mac `Identifier`, `TeamIdentifier` and the designated requirement, which must EQUAL the release-shaped one
19
- // (expectedDesignatedRequirement) and be neither ad hoc nor cdhash-bound; Windows Authenticode with the signer subject;
20
- // 5. only then the evidence (file, sha256 before and after, identifier, team or subject, requirement) is written, and the main binary's signed copy
21
- // becomes RIGHTKIT_QA_APP_BINARY for the suites after it.
22
- // The main binary (identifier = the bundle id) is the file named by tauri.conf.json `mainBinaryName` when it declares one, else the first file; every
23
- // other file is a sidecar whose identifier is the release identifier resolveBundleIdentifier gives it (`sign.identifiers` / `identifierTemplate`, keyed
24
- // `Contents/MacOS/<name>` as in the bundle). RIGHTKIT_QA_APP_BINARY is the main binary's signed copy (unset by a step that signs no main binary).
25
- import { createHash, randomBytes } from "node:crypto";
26
- import fs from "node:fs";
27
- import path from "node:path";
28
- import { spawnSync } from "./background-process.mjs";
29
- import { appendLedger, SCAN_TOKEN_ENV } from "./cargo-ledger.mjs";
30
- import { carriedCargoMapOf } from "./carried-cargo.mjs";
31
- import { MacosDevelopmentSigningError, assertStableDesignatedRequirement, expectedDesignatedRequirement, readDevSigningSettings, resolveMacosDevelopmentSigning } from "./development-signing.mjs";
32
- import { lookupBundlePath, resolveBundleIdentifier } from "./mac-bundle-signing.mjs";
33
- import { signMacosEntries } from "./mac-sign-core.mjs";
34
- import { runMacCodesignCommands } from "./macos-signing-session.mjs";
35
- import { resolveManagedRoots } from "./managed-roots.mjs";
36
- import { isInside, realish } from "./qa-snapshot.mjs";
37
- import { GATE_TARGET_DIRS_ENV } from "./qa-layout.mjs";
38
- import { requireSignerToken, signWindowsFiles, verifyWindowsFiles } from "./sign-windows.mjs";
39
- import { QA_APP_BINARY_ENV, QA_SIGNED_FILES_ENV, SIGN_SUBJECT, SIGN_TEAM, SuiteSigningError, assertBundleIdMatches, containedRelative, productIdentity } from "./suite-signing-policy.mjs";
40
-
41
- const refuse = (code, message, details) => { throw new SuiteSigningError(code, message, details); };
42
- const sha256 = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
43
- const output = (result) => `${result?.stdout ?? ""}\n${result?.stderr ?? ""}`;
44
-
45
- /** Where signed copies live: under the managed data root, which the signing agent admits and the gate never builds in. */
46
- export const defaultSigningDirectory = ({ env = process.env, platform = process.platform } = {}) => path.join(resolveManagedRoots({ env, platform }).dataRoot, "suite-signing");
47
-
48
- /** The Authority-independent facts `codesign -dvv` reports. */
49
- function codesignReport(file, { run, env, root }) {
50
- const result = run("codesign", ["-dvv", file], { cwd: root, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false });
51
- if (result?.error || result?.status !== 0) refuse("SIGN_READBACK_FAILED", `cannot read the signature of ${file}: ${result?.error?.message || output(result).trim() || `exit ${result?.status ?? "unavailable"}`}`);
52
- const text = output(result);
53
- return { identifier: /^Identifier=(.+)$/m.exec(text)?.[1]?.trim(), team: /^TeamIdentifier=(.+)$/m.exec(text)?.[1]?.trim(), adhoc: /^Signature=adhoc$/m.test(text) };
54
- }
55
-
56
- // The QA bundle runs under app.rightkit.qa.<id> with no embedded.provisionprofile, so a provisioning profile bound to the product id cannot apply to it, and
57
- // AMFI refuses to spawn a bundle that claims a profile-gated entitlement without one (POSIX 163, "Launchd job spawn failed"). Suite (QA) signing therefore
58
- // drops the profile-gated keys; profile-gated capabilities (iCloud/CloudKit, push) are not exercisable in Mac source QA. Release signing never goes through here.
59
- const isProfileGatedEntitlement = (key) => key === "com.apple.application-identifier" || key === "com.apple.developer.team-identifier" || key.startsWith("com.apple.developer.") || key === "aps-environment" || key === "keychain-access-groups";
60
-
61
- /** The entitlements file to sign `index` with: the original when no key is profile-gated, else a derived plist in signedDir without them. */
62
- function qaEntitlements({ file, index, signedDir, run, env, root }) {
63
- const options = { cwd: root, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false };
64
- const failed = (why) => refuse("SIGN_ENTITLEMENTS_UNREADABLE", `cannot read the entitlements ${file}: ${why}`, { file });
65
- const read = run("plutil", ["-convert", "json", "-o", "-", file], options);
66
- if (read?.error || read?.status !== 0) failed(read?.error?.message || output(read).trim() || `plutil exit ${read?.status ?? "unavailable"}`);
67
- let parsed;
68
- try { parsed = JSON.parse(read.stdout); } catch (cause) { failed(`plutil did not return JSON (${cause.message})`); }
69
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) failed("the plist is not a dictionary");
70
- const dropped = Object.keys(parsed).filter(isProfileGatedEntitlement);
71
- if (!dropped.length) return { file, dropped };
72
- const derivedDir = path.join(signedDir, "qa-entitlements");
73
- fs.mkdirSync(derivedDir, { recursive: true });
74
- const derived = path.join(derivedDir, `${index}.plist`);
75
- fs.writeFileSync(derived, JSON.stringify(Object.fromEntries(Object.entries(parsed).filter(([key]) => !isProfileGatedEntitlement(key)))));
76
- const write = run("plutil", ["-convert", "xml1", derived], options);
77
- if (write?.error || write?.status !== 0) failed(`cannot write ${derived}: ${write?.error?.message || output(write).trim() || `plutil exit ${write?.status ?? "unavailable"}`}`);
78
- return { file: derived, dropped };
79
- }
80
-
81
- /** Mac: sign each copy and read it back against the release-shaped identity. Returns one evidence record per file. */
82
- function signMac({ copies, name, config, appRoot, env, run, deps, identifier, install, signedDir, mainIndex }) {
83
- const settings = readDevSigningSettings(install, config, { root: appRoot });
84
- let signing;
85
- try { signing = (deps.resolveSigning ?? resolveMacosDevelopmentSigning)({ root: appRoot, bundle: signedDir, descriptor: { manifestPath: install.manifestPath }, config, env, run }); }
86
- catch (cause) {
87
- if (!(cause instanceof MacosDevelopmentSigningError)) throw cause;
88
- // No Developer ID identity with its private key (or an ad hoc / Apple Development request): a refusal, never a fallback.
89
- refuse("SIGN_CREDENTIAL_MISSING", `${name}: the Developer ID Application identity of team ${SIGN_TEAM} is not available (${cause.code}): ${cause.message}`, { cause, reason: cause.code });
90
- }
91
- // Developer ID of the one team, never ad hoc, never Apple Development: a different identity is a different designated requirement.
92
- if (signing.adhoc || !/^Developer ID Application:/.test(signing.identity ?? "")) refuse("SIGN_IDENTITY_REFUSED", `suite signing needs the Developer ID Application identity of team ${SIGN_TEAM}; got ${JSON.stringify(signing.identity)}. There is no ad hoc or Apple Development fallback`, { identity: signing.identity });
93
- if (signing.teamIdentifier !== SIGN_TEAM || !signing.identity.endsWith(`(${SIGN_TEAM})`)) refuse("SIGN_TEAM_MISMATCH", `suite signing resolved ${JSON.stringify(signing.identity)} (team ${signing.teamIdentifier}), not team ${SIGN_TEAM}`, { identity: signing.identity, team: signing.teamIdentifier });
94
- const entries = copies.map((copy, index) => {
95
- const relative = `Contents/MacOS/${path.basename(copy.file)}`;
96
- let id;
97
- if (index === mainIndex) id = identifier;
98
- else {
99
- try { id = resolveBundleIdentifier({ relative, identifiers: settings.identifiers, template: settings.identifierTemplate, bundleId: identifier, plain: true }); }
100
- catch (cause) { refuse("SIGN_SIDECAR_IDENTIFIER", cause.message); }
101
- }
102
- // A sidecar the release signs with no declared identifier is signed by codesign with no --identifier, which derives the file name: the same here
103
- // (nothing is passed), and the read-back expects exactly that name.
104
- const entitlements = (index === mainIndex ? lookupBundlePath(settings.entitlements, ".") : undefined) ?? lookupBundlePath(settings.entitlements, relative);
105
- return { file: copy.file, ...(id !== undefined ? { identifier: id } : {}), expectedIdentifier: id ?? path.basename(copy.file), ...(entitlements ? { entitlements } : {}) };
106
- });
107
- entries.forEach((entry, index) => {
108
- entry.entitlementsDropped = [];
109
- if (!entry.entitlements) return;
110
- const derived = qaEntitlements({ file: entry.entitlements, index, signedDir, run, env, root: appRoot });
111
- entry.entitlements = derived.file;
112
- entry.entitlementsDropped = derived.dropped;
113
- });
114
- const signOne = deps.signOne ?? ((args) => runMacCodesignCommands([args], { env, run, cwd: appRoot })[0]);
115
- const inspect = deps.inspect ?? ((command, args) => {
116
- const result = run(command, args, { cwd: appRoot, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false });
117
- if (result?.error || result?.status !== 0) refuse("SIGN_READBACK_FAILED", `${command} ${args.join(" ")} failed: ${output(result).trim() || result?.error?.message}`);
118
- return output(result);
119
- });
120
- let records;
121
- try { records = signMacosEntries(entries.map(({ expectedIdentifier: _derived, entitlementsDropped: _dropped, ...entry }) => entry), { identity: signing.identity, signOne, inspect, bundle: signedDir }); }
122
- catch (cause) { refuse("SIGN_FAILED", `${name}: ${cause.message}`, { cause }); }
123
- return records.map((record, index) => {
124
- const entry = entries[index];
125
- const report = codesignReport(record.file, { run, env, root: appRoot });
126
- if (report.adhoc) refuse("SIGN_ADHOC", `${copies[index].path}: the signed copy is ad hoc signed`);
127
- if (report.team !== SIGN_TEAM) refuse("SIGN_TEAM_MISMATCH", `${copies[index].path}: TeamIdentifier=${report.team ?? "missing"}, expected ${SIGN_TEAM}`, { team: report.team });
128
- if (report.identifier !== entry.expectedIdentifier) refuse("SIGN_IDENTIFIER_MISMATCH", `${copies[index].path}: Identifier=${report.identifier ?? "missing"}, expected ${entry.expectedIdentifier}`, { identifier: report.identifier });
129
- const expected = expectedDesignatedRequirement(entry.expectedIdentifier, SIGN_TEAM);
130
- let requirement;
131
- try { requirement = assertStableDesignatedRequirement({ root: appRoot, installed: record.file, expected, env, run }); }
132
- catch (error) { if (!(error instanceof MacosDevelopmentSigningError)) throw error; refuse("SIGN_REQUIREMENT_MISMATCH", `${copies[index].path}: ${error.message}`, { expected, actual: error.installedRequirement }); }
133
- // assertStableDesignatedRequirement is silent about an unreadable or cdhash-bound requirement (it has no grants to protect); here either is a refusal.
134
- if (requirement !== expected) refuse("SIGN_REQUIREMENT_MISMATCH", `${copies[index].path}: the designated requirement could not be read back, or is cdhash-bound, instead of ${JSON.stringify(expected)}`, { expected });
135
- return { platform: "mac", file: copies[index].path, signed: record.file, sha256Before: record.before.sha256, sha256After: record.after.sha256, identifier: entry.expectedIdentifier, team: report.team, identity: signing.identity, requirement, entitlementsDropped: entry.entitlementsDropped };
136
- });
137
- }
138
-
139
- /** Windows: sign each copy through sign-windows and check the signer subject. */
140
- function signWin({ copies, name, env, run, deps }) {
141
- let evidence;
142
- try {
143
- evidence = (deps.signWindows ?? ((files) => signWindowsFiles(files)))(copies.map((copy) => copy.file));
144
- if (!Array.isArray(evidence) || evidence.length !== copies.length) refuse("SIGN_FAILED", `${name}: the Windows signer returned ${Array.isArray(evidence) ? evidence.length : "no"} record(s) for ${copies.length} file(s)`);
145
- const verified = (deps.verifyWindows ?? ((files) => verifyWindowsFiles(files)))(copies.map((copy) => copy.file));
146
- if (!Array.isArray(verified) || verified.length !== copies.length) refuse("SIGN_FAILED", `${name}: the Windows verifier returned ${Array.isArray(verified) ? verified.length : "no"} record(s) for ${copies.length} file(s)`);
147
- verified.forEach((record, index) => { if (record.subject !== SIGN_SUBJECT) refuse("SIGN_SUBJECT_MISMATCH", `${copies[index].path}: signer subject ${JSON.stringify(record.subject)}, expected ${SIGN_SUBJECT}`, { subject: record.subject }); });
148
- evidence.forEach((record, index) => { if (record.subject !== SIGN_SUBJECT || record.authenticode !== "Valid") refuse("SIGN_SUBJECT_MISMATCH", `${copies[index].path}: signer subject ${JSON.stringify(record.subject)} / ${JSON.stringify(record.authenticode)}, expected ${SIGN_SUBJECT} / Valid`, { subject: record.subject }); });
149
- } catch (error) {
150
- if (error instanceof SuiteSigningError) throw error;
151
- if (error?.code === "SIGN_WINDOWS_TOKEN_UNAVAILABLE") refuse("SIGN_WINDOWS_TOKEN_UNAVAILABLE", `${name}: ${error.message}`, { cause: error });
152
- refuse("SIGN_FAILED", `${name}: ${error.message}`, { cause: error });
153
- }
154
- return evidence.map((record, index) => ({ platform: "win", file: copies[index].path, signed: copies[index].file, sha256Before: record.before.sha256, sha256After: record.after.sha256, subject: record.subject, authenticode: record.authenticode, timestampPresent: record.timestampPresent }));
155
- }
156
-
157
- /** The path of `rel` inside one of `dirs` (receipt output directories), the last that holds a regular file there. A link that leaves the directory is refused. */
158
- function locateSource(dirs, rel, label) {
159
- const found = [];
160
- for (const dir of dirs) {
161
- const candidate = path.join(dir, ...rel.split("/"));
162
- let info;
163
- try { info = fs.lstatSync(candidate); } catch { continue; }
164
- if (info.isSymbolicLink()) refuse("SIGN_PATH_ESCAPE", `${label}: ${candidate} is a link; a sign step signs the receipt's own file`);
165
- if (!info.isFile()) refuse("SIGN_SOURCE_INVALID", `${label}: ${candidate} is not a regular file`);
166
- if (!isInside(fs.realpathSync.native(candidate), fs.realpathSync.native(dir))) refuse("SIGN_PATH_ESCAPE", `${label}: ${candidate} resolves outside the receipt's output directory ${dir}`);
167
- found.push(candidate);
168
- }
169
- if (!found.length) refuse("SIGN_SOURCE_MISSING", `${label}: no ${rel} in the output directory of the suite's carried receipt (${dirs.join(", ") || "none recorded"})`);
170
- return found.at(-1);
171
- }
172
-
173
- /**
174
- * Run one sign step. `outputDirsOf(suite)` returns the verified carried receipts' output directories of an earlier suite of this run, or null when it
175
- * did not run. Returns `{ appBinary, evidence, evidenceFile, signedDir }`; throws SuiteSigningError (the step then sets nothing).
176
- */
177
- export function runSignStep({ name, sign, appRoot, config, env = process.env, platform = process.platform, outputDirsOf, deps = {}, log = () => {} }) {
178
- const host = platform === "darwin" ? "mac" : platform === "win32" ? "win" : null;
179
- const carried = carriedCargoMapOf(env);
180
- const record = (entry) => { if (carried?.map) try { appendLedger(carried.map.ledgerDir, env[SCAN_TOKEN_ENV], { id: `sign-${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`, mode: "signing", cwd: appRoot, argv: ["sign", name], ...entry }); } catch (error) { log(`right-release test: cannot record the signing step in the carrier's ledger: ${error.message}`); throw error; } };
181
- let signedDir = null;
182
- try {
183
- if (!host) refuse("SIGN_PLATFORM", `${name}: a sign step runs on macOS (Developer ID) or Windows (Azure Artifact Signing), not ${platform}`);
184
- // Windows signer readiness comes first: before the signing directory exists and before any copy, the Azure CLI sign-in must yield a silent token.
185
- if (host === "win") {
186
- try { (deps.signerPreflight ?? (() => requireSignerToken()))(); }
187
- catch (error) {
188
- if (error?.code === "SIGN_WINDOWS_TOKEN_UNAVAILABLE") refuse("SIGN_WINDOWS_TOKEN_UNAVAILABLE", `${name}: ${error.message}`, { cause: error });
189
- refuse("SIGN_FAILED", `${name}: the Windows signer preflight failed: ${error.message}`, { cause: error });
190
- }
191
- }
192
- // 1. The identity is derived, and ad hoc is never an option.
193
- if (env.RIGHTKIT_DEV_SIGNING !== undefined) refuse("SIGN_ADHOC_REFUSED", `${name}: RIGHTKIT_DEV_SIGNING is set; a signed suite build is never ad hoc signed`);
194
- const install = config?.development?.targets?.[host]?.install ?? {};
195
- if (install.signing !== undefined) refuse("SIGN_ADHOC_REFUSED", `${name}: development.targets.${host}.install.signing ${JSON.stringify(install.signing)} asks for ad hoc signing`);
196
- const read = (rel) => { try { return fs.readFileSync(path.resolve(appRoot, rel), "utf8"); } catch { return null; } };
197
- const { identifier, mainBinaryName } = productIdentity({ readFile: read, manifestPath: install.manifestPath });
198
- assertBundleIdMatches(config, identifier);
199
- // 2. Where each file comes from.
200
- // A broker host names a suite's outputs by its carried receipts; a direct host by the output directories the gate pinned for the stage
201
- // (RIGHTKIT_GATE_TARGET_DIRS, test-command.mjs). With neither, nothing says where a suite's files are, and nothing is signed.
202
- if (!carried?.map && !env[GATE_TARGET_DIRS_ENV]) refuse("SIGN_RECEIPT_MISSING", `${name}: no carried receipts in this environment (${carried?.error ?? "RIGHTKIT_CARRIED_CARGO_MAP is not set"}) and no gate-pinned output directories (${GATE_TARGET_DIRS_ENV}); a sign step signs a file an earlier suite built`);
203
- if (typeof outputDirsOf !== "function") refuse("SIGN_RECEIPT_MISSING", `${name}: the run has no receipt reader`);
204
- const sources = sign.files.map((item, index) => {
205
- const file = { ...item, path: containedRelative(item.path, `${name}.files[${index}].path`) };
206
- const dirs = outputDirsOf(file.from);
207
- if (!dirs) refuse("SIGN_FROM_NOT_RUN", `${name}.files[${index}]: suite ${JSON.stringify(file.from)} did not run before this step in this run`);
208
- return { ...file, dirs, source: locateSource(dirs, file.path, `${name}.files[${index}] (${file.from}:${file.path})`) };
209
- });
210
- // 3. A gate-managed copy, outside everything the build owns.
211
- const signingRoot = deps.signingDirectory ?? defaultSigningDirectory({ env, platform });
212
- signedDir = path.join(signingRoot, `${name}-${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`);
213
- // A direct host (no carrier) has no broker section: the checkout and the suites' own output directories are still forbidden.
214
- const section = carried?.map?.broker ?? {};
215
- const forbidden = [section.snapshot?.path, section.checkout, env.RK_GATE_CHECKOUT, ...sources.flatMap((source) => source.dirs)].filter((p) => typeof p === "string" && p);
216
- for (const place of forbidden) if (isInside(signedDir, place)) refuse("SIGN_COPY_PLACE", `${name}: the signing directory ${signedDir} is inside ${place} (the snapshot, the checkout or a broker output directory)`);
217
- fs.mkdirSync(signedDir, { recursive: true, mode: 0o700 });
218
- // The copies keep their relative paths under one directory, so a sidecar stays beside the main binary the way the build left them.
219
- const taken = new Set();
220
- const copies = sources.map((source) => {
221
- const file = path.join(signedDir, ...source.path.split("/"));
222
- if (taken.has(file)) refuse("SIGN_COPY_COLLISION", `${name}: two files would be copied to ${source.path}; give them different paths`);
223
- taken.add(file);
224
- fs.mkdirSync(path.dirname(file), { recursive: true });
225
- const before = sha256(source.source);
226
- fs.copyFileSync(source.source, file);
227
- fs.chmodSync(file, fs.statSync(source.source).mode & 0o777);
228
- if (sha256(file) !== before) refuse("SIGN_COPY_MISMATCH", `${name}: the copy of ${source.path} differs from its source`);
229
- return { path: source.path, from: source.from, source: source.source, file, sha256: before };
230
- });
231
- // 4. Sign the copies and read them back.
232
- const run = deps.run ?? spawnSync;
233
- // The main binary is the file named by the product's `mainBinaryName` when tauri.conf.json declares one (a step that signs only sidecars names none of
234
- // them), else the first file. Every other file is a sidecar.
235
- let mainIndex = 0;
236
- if (mainBinaryName) {
237
- const hits = copies.flatMap((copy, index) => (path.basename(copy.path).replace(/\.exe$/i, "") === mainBinaryName ? [index] : []));
238
- if (hits.length > 1) refuse("SIGN_MAIN_AMBIGUOUS", `${name}: ${hits.length} files are named ${mainBinaryName}, the product's main binary`);
239
- mainIndex = hits.length ? hits[0] : -1;
240
- }
241
- const evidence = host === "mac"
242
- ? signMac({ copies, name, config, appRoot, env, run, deps, identifier, install, signedDir, mainIndex })
243
- : signWin({ copies, name, env, run, deps });
244
- for (const copy of copies) if (sha256(copy.source) !== copy.sha256) refuse("SIGN_SOURCE_CHANGED", `${name}: the receipt's ${copy.path} changed while it was signed; a sign step never modifies its source`);
245
- // 5. Evidence first, then the variable.
246
- const evidenceFile = path.join(signedDir, "signing-evidence.json");
247
- fs.writeFileSync(evidenceFile, `${JSON.stringify({ schema: 1, step: name, ...(host === "mac" ? { identifier, team: SIGN_TEAM } : { subject: SIGN_SUBJECT }), files: evidence }, null, 2)}\n`);
248
- record({ status: "ran", step: name, signing: evidence });
249
- // Every signed file by its declared path (main binary and every sidecar), for the consumer that is handed the signed bytes.
250
- return { appBinary: mainIndex >= 0 ? copies[mainIndex].file : null, signedFiles: Object.fromEntries(copies.map((copy) => [copy.path, copy.file])), evidence, evidenceFile, signedDir };
251
- } catch (error) {
252
- // A step that failed leaves no signed copy behind.
253
- if (signedDir) { try { fs.rmSync(signedDir, { recursive: true, force: true }); } catch { /* the managed root is pruned */ } }
254
- if (error instanceof SuiteSigningError) { try { record({ status: "refused", step: name, code: error.code, message: error.message }); } catch { /* the failure is thrown either way */ } }
255
- throw error;
256
- }
257
- }
258
-
259
- export { QA_APP_BINARY_ENV, QA_SIGNED_FILES_ENV };