@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.
Files changed (86) hide show
  1. package/README.md +121 -4
  2. package/addon-command.mjs +6 -13
  3. package/android-aab-scan.mjs +265 -0
  4. package/android-dev.mjs +111 -0
  5. package/android-ndk.mjs +77 -0
  6. package/android-play-upload.mjs +262 -0
  7. package/android-release.mjs +344 -0
  8. package/android-test-fixtures.mjs +79 -0
  9. package/apple-profile-create.mjs +363 -0
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/darwin/rightkit-swap.sign.json +6 -0
  12. package/build-release.mjs +73 -13
  13. package/cargo-guard.mjs +2 -0
  14. package/cargo-profile-contract.mjs +183 -0
  15. package/cargo-target.mjs +8 -0
  16. package/cli/right-release.mjs +16 -4
  17. package/committed-source.mjs +1 -1
  18. package/dev-artifact-refresh.mjs +176 -0
  19. package/dev-artifact-support.mjs +61 -0
  20. package/development-signing.mjs +71 -4
  21. package/development.mjs +93 -47
  22. package/direct-bootstrap.mjs +26 -41
  23. package/disk-command.mjs +298 -5
  24. package/disk-policy.json +7 -5
  25. package/disk-policy.mjs +105 -16
  26. package/disk-stable-root.mjs +19 -16
  27. package/engine-locator.mjs +116 -0
  28. package/external-bin.mjs +133 -0
  29. package/github-release.mjs +2 -2
  30. package/github-updater-release.mjs +154 -0
  31. package/hardeningscan.mjs +1 -1
  32. package/in-place-install.mjs +56 -0
  33. package/install-app-windows.mjs +63 -0
  34. package/install-app.mjs +53 -23
  35. package/install-tauri-devid.mjs +172 -0
  36. package/inventory-check.mjs +1 -1
  37. package/local-publish.mjs +11 -1
  38. package/mac-package-recipe.mjs +304 -0
  39. package/mac-signing-matrix.mjs +23 -11
  40. package/managed-cargo.mjs +6 -0
  41. package/media-sidecar-catalog.mjs +45 -0
  42. package/media-sidecar.mjs +306 -0
  43. package/ownership-approval-command.mjs +10 -44
  44. package/ownership-approval.mjs +3 -42
  45. package/ownership-catalog-config.json +2 -1
  46. package/ownership-catalog.mjs +4 -2
  47. package/ownership-command.mjs +7 -9
  48. package/ownership-dependency-allowlist.json +25 -1
  49. package/ownership-evidence.mjs +8 -5
  50. package/ownership-git-inventory.mjs +41 -7
  51. package/ownership-lockfiles.mjs +91 -8
  52. package/ownership-mandatory-set.json +36 -16
  53. package/ownership-report.mjs +5 -6
  54. package/ownership-scan-worker.mjs +2 -2
  55. package/ownership-verdict.mjs +10 -7
  56. package/ownership.mjs +47 -29
  57. package/package-recipe-targets.mjs +95 -0
  58. package/package.json +7 -4
  59. package/portable-development.mjs +32 -115
  60. package/preflight.mjs +4 -6
  61. package/project-type.mjs +1 -1
  62. package/publication-artifacts.mjs +34 -0
  63. package/publish-update.mjs +72 -11
  64. package/qa-adoption-exemptions.json +5 -0
  65. package/qa-build-features.mjs +7 -0
  66. package/qa-contract.mjs +182 -0
  67. package/qa-control-scan.mjs +3 -3
  68. package/release-qa-command.mjs +38 -12
  69. package/release-qa-installed.mjs +136 -0
  70. package/release-qa-receipt.mjs +23 -2
  71. package/release.mjs +285 -14
  72. package/rightkit-ownership.json +537 -5
  73. package/rightkit-sync.mjs +27 -1
  74. package/rightkit-versions.json +111 -63
  75. package/runtime-budget-classes.json +28 -0
  76. package/runtime-budgets.mjs +4 -1
  77. package/sign-candidate.mjs +161 -0
  78. package/sign-dev-artifact.mjs +198 -0
  79. package/stage-binary.mjs +60 -0
  80. package/stage-sidecar-command.mjs +41 -0
  81. package/stage-sidecar.mjs +424 -0
  82. package/swap-helper-bundle.mjs +105 -0
  83. package/swift-mac-dmg.mjs +2 -1
  84. package/templates/cargo-profiles.toml +28 -0
  85. package/upload-release.mjs +11 -1
  86. package/win-package-recipe.mjs +321 -0
@@ -14,6 +14,7 @@ import { verifyPublicationRelease, PublicationError, assertArtifactUnchanged, di
14
14
  import { uploadVerifiedR2Artifact } from "./r2-verified-upload.mjs";
15
15
  import { verifyPlatformTrust } from "./github-release.mjs";
16
16
  import { admitInstalledQa } from "./release-qa-audit.mjs";
17
+ import { assertAssetName, assertGitHubRepository, assertUpdaterKeyId, assertUpdaterPlatform, buildLatestJson, githubAssetUrl, githubTag, publishGitHubUpdaterRelease } from "./github-updater-release.mjs";
17
18
 
18
19
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
19
20
  const API_BASE = process.env.RIGHTAPPS_API_URL || "https://api.spoares.com";
@@ -44,9 +45,18 @@ if (!["win", "mac"].includes(platform)) throw new PublicationError("PUBLICATION_
44
45
  const configPath = path.resolve(configName);
45
46
  const root = path.dirname(configPath);
46
47
  const config = (await import(pathToFileURL(configPath))).default;
47
- if (config?.distribution?.provider !== "r2-rightapps") {
48
- fail("release config distribution.provider must be r2-rightapps");
48
+ const provider = config?.distribution?.provider;
49
+ if (provider !== "r2-rightapps" && provider !== "github-releases") {
50
+ fail("release config distribution.provider must be r2-rightapps or github-releases");
49
51
  }
52
+ // github-releases: the same sealed, suite-key-signed updater artifacts, published as release assets plus a Tauri v2
53
+ // static latest.json (github-updater-release.mjs). Nothing below forks the r2 signing or platforms code.
54
+ const githubLane = provider === "github-releases";
55
+ let githubRepo = "";
56
+ if (githubLane) {
57
+ try { githubRepo = assertGitHubRepository(config.distribution.repository); } catch (error) { fail(error.message); }
58
+ }
59
+ const githubKey = (item) => item.key ?? path.basename(item.file);
50
60
  const target = config?.targets?.[platform];
51
61
  const artifacts = target?.updater?.artifacts ?? [];
52
62
  const installers = target?.installer?.artifacts ?? [];
@@ -59,19 +69,24 @@ if (!dryRun || releaseId) {
59
69
  const commit = git(["rev-parse", "HEAD"]);
60
70
  assertCleanSource({ status: git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]), commandId: "right-release publish-update" });
61
71
  sealed = verifyPublicationRelease({ sealedDir: path.join(repoRoot, ".right-release", "sealed", releaseId, platform === "win" ? "windows" : "mac"), repoRoot, appRoot: root, commit, app: config.app, version: versionForStorage, platform, configPath, releaseId });
62
- const routes = sealed.manifest.routes?.[tier] ?? [];
63
- const configured = [...(tier === "patch" ? installers.map((item) => ({ ...item, role: "installer" })) : []), ...artifacts.map((item) => ({ ...item, key: tier === "patch" ? patchInstallerKey(item) : item.key, role: "updater" }))];
72
+ const routes = sealed.manifest.routes?.[githubLane ? "update" : tier] ?? [];
73
+ const configured = githubLane
74
+ ? artifacts.map((item) => ({ ...item, key: githubKey(item), role: "updater" }))
75
+ : [...(tier === "patch" ? installers.map((item) => ({ ...item, role: "installer" })) : []), ...artifacts.map((item) => ({ ...item, key: tier === "patch" ? patchInstallerKey(item) : item.key, role: "updater" }))];
64
76
  for (const item of configured) {
65
- if (!routes.some((route) => route.key === item.key && route.role === item.role && route.bucket === (tier === "patch" ? "public" : "private") && route.name === path.basename(item.file) && (!item.signature || route.signature === path.basename(item.signature)))) throw new PublicationError("PUBLICATION_ROUTE_INVALID", `config route is not sealed: ${item.key}`);
77
+ if (!routes.some((route) => route.key === item.key && route.role === item.role && route.bucket === (tier === "patch" && !githubLane ? "public" : "private") && route.name === path.basename(item.file) && (!item.signature || route.signature === path.basename(item.signature)))) throw new PublicationError("PUBLICATION_ROUTE_INVALID", `config route is not sealed: ${item.key}`);
66
78
  }
67
79
  if (!configured.length) throw new PublicationError("PUBLICATION_ROUTE_INVALID", "seal has no configured upload route");
68
80
  const installer = sealed.manifest.files.find((item) => item.role.split("+").includes("installer"));
69
- if (!installer) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed installer required for platform trust verification");
70
- verifyPlatformTrust({ sealed, installer: path.join(sealed.sealedDir, installer.name) });
81
+ if (!installer && !githubLane) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed installer required for platform trust verification");
82
+ // A github-releases app may ship only updater bundles and portable zips; their trust is the sealed signing
83
+ // receipts plus the updater signature, so platform trust runs only when a sealed installer exists.
84
+ if (installer) verifyPlatformTrust({ sealed, installer: path.join(sealed.sealedDir, installer.name) });
85
+ else console.log("no sealed installer; skipping installer platform trust check (github-releases updater-only seal)");
71
86
  for (const artifact of sealed.artifacts.values()) assertArtifactUnchanged(artifact);
72
87
  // Hardening step 4: installed QA is recorded, not enforced (R2 patch + update tiers); a missing receipt warns and is written to state.
73
88
  if (!dryRun) {
74
- admitInstalledQa({ repoRoot, manifest: sealed.manifest, platform, configName: path.relative(process.cwd(), configPath) || configName, releaseConfigSha256: digestBytes(readFileSync(configPath)) }, { provider: "r2" });
89
+ admitInstalledQa({ repoRoot, manifest: sealed.manifest, platform, configName: path.relative(process.cwd(), configPath) || configName, releaseConfigSha256: digestBytes(readFileSync(configPath)) }, { provider: githubLane ? "github-releases" : "r2" });
75
90
  }
76
91
  }
77
92
  const artifactFile = (declared) => {
@@ -98,12 +113,12 @@ function git(args) {
98
113
  return result.stdout.trim();
99
114
  }
100
115
 
101
- if (tier === "patch" && !installers.length) fail(`${config?.app ?? "app"} ${platform} patch has no installer.artifacts`);
102
- if (tier === "update" && !artifacts.length) fail(`${config?.app ?? "app"} ${platform} update has no updater.artifacts`);
116
+ if (!githubLane && tier === "patch" && !installers.length) fail(`${config?.app ?? "app"} ${platform} patch has no installer.artifacts`);
117
+ if ((githubLane || tier === "update") && !artifacts.length) fail(`${config?.app ?? "app"} ${platform} update has no updater.artifacts`);
103
118
 
104
119
  if (sealed) for (const artifact of artifacts) signatureFor(artifact);
105
120
  let releaseToken = null;
106
- if (!dryRun && (tier === "update" || artifacts.length > 0)) {
121
+ if (!githubLane && !dryRun && (tier === "update" || artifacts.length > 0)) {
107
122
  try {
108
123
  releaseToken = await loadReleaseToken();
109
124
  } catch (error) {
@@ -111,6 +126,52 @@ if (!dryRun && (tier === "update" || artifacts.length > 0)) {
111
126
  }
112
127
  }
113
128
 
129
+ if (githubLane) {
130
+ const version = config.version || target.updater.version;
131
+ if (!version) fail(`${config.app} config must expose version or targets.${platform}.updater.version`);
132
+ try {
133
+ const tag = githubTag(version);
134
+ const notes = process.env.RIGHT_RELEASE_NOTES || `${config.app} ${version}`;
135
+ const platforms = {};
136
+ const assets = new Map();
137
+ for (const artifact of artifacts) {
138
+ assertUpdaterPlatform(artifact.platform);
139
+ const file = artifactFile(artifact.file);
140
+ const name = assertAssetName(path.basename(file));
141
+ // Same signature source and process as the r2 lane: the sealed minisign .sig written by sign-updater.
142
+ const signature = dryRun ? `<signature:${artifact.signature ?? "msix-authenticode"}>` : signatureFor(artifact);
143
+ if (!dryRun && !appInstallerKind) assertUpdaterKeyId(signature, config.distribution.updaterKeyId, `signature of ${name}`);
144
+ platforms[artifact.platform] = { signature, url: githubAssetUrl(githubRepo, tag, name) };
145
+ assets.set(file, { file, evidence: dryRun ? undefined : verifiedArtifact(file) });
146
+ if (!appInstallerKind) {
147
+ const sigFile = artifactFile(artifact.signature);
148
+ assets.set(sigFile, { file: sigFile, evidence: dryRun ? undefined : verifiedArtifact(artifact.signature) });
149
+ }
150
+ }
151
+ for (const asset of assets.values()) if (asset.evidence) assertArtifactUnchanged(asset.evidence);
152
+ const latest = buildLatestJson({ version, notes, pubDate: new Date().toISOString(), platforms });
153
+ for (const asset of assets.values()) console.log(`${dryRun ? "[dry-run] " : ""}UPLOAD ${githubRepo}@${tag}/${path.basename(asset.file)}`);
154
+ console.log(`${dryRun ? "[dry-run] " : ""}UPLOAD ${githubRepo}@${tag}/latest.json`);
155
+ console.log(JSON.stringify(latest));
156
+ const stateDir = path.join(root, ".right-release", "state", releaseId || "dry-run", platform === "win" ? "windows" : "mac", "github-updater");
157
+ const result = publishGitHubUpdaterRelease({
158
+ repo: githubRepo,
159
+ tag,
160
+ title: `${config.app} ${version}`,
161
+ notes,
162
+ assets: [...assets.values()].map((asset) => ({ file: asset.file, evidence: asset.evidence })),
163
+ latest,
164
+ stateDir,
165
+ dryRun,
166
+ });
167
+ console.log(`publish-update: github-releases ${result.status} ${tag}${result.latestJsonUrl ? ` ${result.latestJsonUrl}` : ""}`);
168
+ } catch (error) {
169
+ fail(error.message);
170
+ }
171
+ if (!dryRun) recordPublishedVersion({ app: config.app, version: versionForStorage, platform, configPath, releaseId });
172
+ process.exit(0);
173
+ }
174
+
114
175
  if (tier === "patch") {
115
176
  for (const installer of installers) {
116
177
  assertCurrentKey(installer.key, "installer");
@@ -0,0 +1,5 @@
1
+ {
2
+ "schema": 1,
3
+ "description": "Repositories that are not apps and so need no RightKit QA lane. RightKit-owned: a change needs a RightKit release. Never list an app here; there is no per-app opt-out. The RightKit source tree itself is recognised by packages/release/package.json (name @rightkit/release) and needs no entry.",
4
+ "nonAppRepositories": []
5
+ }
@@ -16,6 +16,9 @@ export const FORBIDDEN_FEATURES = Object.freeze(["devtools", "content-devtools",
16
16
  "qa-shot", "qa-tools", "dev-control", "bench-native",
17
17
  // D4 (M21/M23): rightkit-daemon `test-mode` compiles the override selectors; never in a release build.
18
18
  "test-mode"]);
19
+ // rightkit-qa 0.6.0 moved the embedded control server (the former rightkit-qa-driver `qa-control`) behind its `control`
20
+ // feature. It emits the same QA_CONTROL_MARKER, so the binary layer refuses it unchanged; this is the source layer.
21
+ export const QUALIFIED_FORBIDDEN_FEATURES = Object.freeze(["rightkit-qa/control", "rightkit-qa?/control"]);
19
22
  // The only forbidden features an allow-listed app may enable: rightkit-tauri-shell's `content-devtools` (and the Tauri
20
23
  // `devtools` it switches on). See devtools-policy.mjs; every other app is refused them.
21
24
  const DEVTOOLS_FEATURES = Object.freeze(["devtools", "content-devtools"]);
@@ -31,6 +34,8 @@ export function isForbiddenFeature(item, { allowDevtools = false } = {}) {
31
34
  const text = String(item).trim();
32
35
  const name = featureName(text);
33
36
  if (allowDevtools && DEVTOOLS_FEATURES.includes(name)) return false;
37
+ // `control` is too generic to forbid by bare name; rightkit-qa's dev-only server feature is refused when qualified.
38
+ if (QUALIFIED_FORBIDDEN_FEATURES.includes(text.replace(/\s+/g, ""))) return true;
34
39
  return text === `dep:${FORBIDDEN_DEPENDENCY}` || FORBIDDEN_FEATURES.includes(name);
35
40
  }
36
41
 
@@ -163,6 +168,8 @@ export function cargoManifestIssues(manifestText, label = "Cargo.toml", { allowD
163
168
  const control = dependencies.get(name);
164
169
  if (control && !control.optional && control.features.includes("qa-control")) issues.push(`${label}: dependency \`${name}\` lists \`qa-control\` unconditionally`);
165
170
  }
171
+ const qa = dependencies.get("rightkit-qa");
172
+ if (qa && !qa.optional && qa.features.includes("control")) issues.push(`${label}: dependency \`rightkit-qa\` lists \`control\` unconditionally`);
166
173
  // The crate was renamed from rightkit-shell; apps still on the old published name must stay covered.
167
174
  for (const name of ["rightkit-tauri-shell", "rightkit-shell"]) {
168
175
  const shell = dependencies.get(name);
package/qa-contract.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { spawnSync } from "./background-process.mjs";
4
+ import { breakingBump, loadRightkitVersions } from "./rightkit-drift.mjs";
3
5
 
4
6
  export function validateQaBackdoorContract(root, packageScripts = {}) {
5
7
  if (!existsSync(path.join(root, "right-qa.config.mjs"))) return [];
@@ -59,3 +61,183 @@ function readRustSources(root) {
59
61
  visit(root);
60
62
  return files.sort().map((file) => readFileSync(file, "utf8")).join("\n");
61
63
  }
64
+
65
+ // ---------------------------------------------------------------- QA adoption (mandatory for every app)
66
+ //
67
+ // RightKit QA is a mandatory component: every Orthic Labs app must carry a recognised QA lane. Recognised lanes:
68
+ // - the native wdio lane (`right-qa.config.mjs`, validated by validateQaBackdoorContract above),
69
+ // - `@rightkit/qa` in any non-node_modules package.json (dependencies or devDependencies), and/or
70
+ // - `rightkit-qa` in any Cargo.toml (dependencies, dev-dependencies or build-dependencies).
71
+ // `@rightkit/qa-chrome` is the web-UI companion; its presence is not enforced (a UI cannot be detected reliably)
72
+ // but its pin is lag-checked like the others.
73
+ // A pin behind the latest published version (rightkit-versions.json) is a warning; a breaking-bump lag (the
74
+ // `breakingBump` rule rightkit-drift uses) refuses. The only exemption is for repositories that are not apps
75
+ // (qa-adoption-exemptions.json, RightKit-owned: changing it takes a RightKit release). No per-app opt-out.
76
+
77
+ export const QA_EXEMPTIONS_FILE = "qa-adoption-exemptions.json";
78
+ const NPM_QA = "@rightkit/qa";
79
+ const NPM_QA_CHROME = "@rightkit/qa-chrome";
80
+ const CARGO_QA = "rightkit-qa";
81
+ const NPM_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
82
+ const SKIP_DIRS = /^(node_modules(\..*)?|target|vendor|dist|build|\.git|\.cache|\.right-release)$/;
83
+ const MAX_DEPTH = 6;
84
+ const EXEMPTION_KINDS = ["library", "tooling"];
85
+
86
+ const numeric = (value) => String(value).split("-")[0].split(".").map((part) => Number.parseInt(part, 10) || 0);
87
+ const newer = (candidate, current) => { const a = numeric(candidate); const b = numeric(current); for (let i = 0; i < 3; i += 1) if (a[i] !== b[i]) return a[i] > b[i]; return false; };
88
+ const latestFor = (versions, ecosystem, name) => versions.registry
89
+ ? versions.registry[ecosystem]?.[name]
90
+ : ecosystem === "npm" ? versions.stagedNpm?.[name] ?? versions.npm?.[name] : versions.stagedCargo?.[name] ?? versions.cargo?.[name];
91
+
92
+ /** package.json and Cargo.toml files in the app tree, skipping node_modules (any suffix), target, vendor, dist and the RightKit tree. */
93
+ export function qaManifests(appRoot, { readdir = readdirSync } = {}) {
94
+ const found = [];
95
+ const visit = (dir, depth) => {
96
+ let entries;
97
+ try { entries = readdir(dir, { withFileTypes: true }); } catch { return; }
98
+ for (const entry of entries) {
99
+ const relative = path.relative(appRoot, path.join(dir, entry.name)).split(path.sep).join("/");
100
+ if (entry.isDirectory()) {
101
+ if (depth < MAX_DEPTH && !SKIP_DIRS.test(entry.name) && relative !== "rightkit" && relative !== "tools/rightkit") visit(path.join(dir, entry.name), depth + 1);
102
+ } else if (entry.name === "package.json" || entry.name === "Cargo.toml") found.push(relative);
103
+ }
104
+ };
105
+ visit(appRoot, 0);
106
+ return found.sort();
107
+ }
108
+
109
+ /** `[dependencies]`-style section header that holds dependencies (including target-specific ones). */
110
+ const DEPENDENCY_HEADER = /^\[\s*(?:target\.[^\]]+\.)?(?:dev-|build-)?dependencies\s*\]/;
111
+ const DEPENDENCY_TABLE_HEADER = /^\[\s*(?:target\.[^\]]+\.)?(?:dev-|build-)?dependencies\.([A-Za-z0-9_-]+)\s*\]/;
112
+ const WORKSPACE_DEPENDENCY = /^\[\s*workspace\.dependencies\s*\]/;
113
+
114
+ /** `rightkit-qa` declarations in a Cargo.toml: [{ version | null }]. Path, git and workspace deps carry no version. */
115
+ export function cargoQaPins(text, crate = CARGO_QA) {
116
+ const pins = [];
117
+ let section = null;
118
+ let table = null;
119
+ const push = (body) => {
120
+ const version = (body.match(/\bversion\s*=\s*"[=^~<>\s]*(\d+\.\d+\.\d+)/) ?? body.match(/^"[=^~<>\s]*(\d+\.\d+\.\d+)/))?.[1] ?? null;
121
+ pins.push({ version: /\bpath\s*=|\bgit\s*=/.test(body) ? null : version });
122
+ };
123
+ for (const raw of text.split(/\r?\n/)) {
124
+ const line = raw.replace(/\s+#.*$/, "").trim();
125
+ if (!line || line.startsWith("#")) continue;
126
+ if (line.startsWith("[")) {
127
+ table = line.match(DEPENDENCY_TABLE_HEADER);
128
+ if (table && table[1] === crate) { section = "table"; pins.push({ version: null, open: true }); continue; }
129
+ section = DEPENDENCY_HEADER.test(line) || WORKSPACE_DEPENDENCY.test(line) ? "deps" : null;
130
+ continue;
131
+ }
132
+ if (section === "table") {
133
+ const open = pins.at(-1);
134
+ const version = line.match(/^version\s*=\s*"[=^~<>\s]*(\d+\.\d+\.\d+)/)?.[1];
135
+ if (version) open.version = version;
136
+ if (/^(path|git)\s*=/.test(line)) open.local = true;
137
+ continue;
138
+ }
139
+ if (section === "deps") {
140
+ const match = line.match(new RegExp(`^${crate}(?:\\.[A-Za-z-]+)?\\s*=\\s*(.*)$`));
141
+ if (match) push(match[1]);
142
+ }
143
+ }
144
+ return pins.map(({ version, local }) => ({ version: local ? null : version }));
145
+ }
146
+
147
+ /** `@rightkit/qa` style declarations in one parsed package.json. */
148
+ export function npmQaPins(manifest, name) {
149
+ const pins = [];
150
+ for (const section of NPM_SECTIONS) {
151
+ const spec = manifest?.[section]?.[name];
152
+ if (typeof spec !== "string") continue;
153
+ pins.push({ version: spec.match(/^[\^~=]?(\d+\.\d+\.\d+)$/)?.[1] ?? null });
154
+ }
155
+ return pins;
156
+ }
157
+
158
+ /** Normalise a git remote to `host/owner/repo` lowercase, so ssh and https spellings of one repository agree. */
159
+ export function normalizeRepositoryIdentity(url) {
160
+ const text = String(url ?? "").trim().replace(/\.git$/i, "").replace(/\/+$/, "");
161
+ const ssh = text.match(/^(?:ssh:\/\/)?git@([^:/]+)[:/](.+)$/);
162
+ if (ssh) return `${ssh[1]}/${ssh[2]}`.toLowerCase();
163
+ return text.replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]+@/, "").toLowerCase();
164
+ }
165
+
166
+ function originRemote(root) {
167
+ const result = spawnSync("git", ["-C", root, "remote", "get-url", "origin"], { encoding: "utf8", windowsHide: true });
168
+ return result.status === 0 ? String(result.stdout).trim() : null;
169
+ }
170
+
171
+ function loadExemptions() {
172
+ try { return JSON.parse(readFileSync(new URL(`./${QA_EXEMPTIONS_FILE}`, import.meta.url), "utf8")); } catch { return { nonAppRepositories: [] }; }
173
+ }
174
+
175
+ /**
176
+ * The exemption for a repository that is not an app, or null. Matched by git `origin` identity against the
177
+ * RightKit-owned list; the RightKit source tree itself is recognised by its release package, with no list entry.
178
+ */
179
+ export function qaExemption({ appRoot, repoRoot }, { exemptions = loadExemptions(), remote = originRemote, exists = existsSync, readFile = readFileSync } = {}) {
180
+ for (const root of [appRoot, repoRoot].filter(Boolean)) {
181
+ try {
182
+ const manifest = path.join(root, "packages", "release", "package.json");
183
+ if (exists(manifest) && JSON.parse(readFile(manifest, "utf8")).name === "@rightkit/release") return { kind: "tooling", reason: "the RightKit source tree is not an app" };
184
+ } catch { /* not the RightKit tree */ }
185
+ }
186
+ for (const entry of exemptions.nonAppRepositories ?? []) {
187
+ if (!EXEMPTION_KINDS.includes(entry?.kind) || !entry.reason || !entry.repositoryIdentity) continue;
188
+ for (const root of [appRoot, repoRoot].filter(Boolean)) {
189
+ const origin = remote(root);
190
+ if (origin && normalizeRepositoryIdentity(origin) === normalizeRepositoryIdentity(entry.repositoryIdentity)) return { kind: entry.kind, reason: entry.reason };
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+
196
+
197
+ /** Pure evaluation: { exempt, lane, refusals[], warnings[] }. */
198
+ export function evaluateQaAdoption({ appRoot, repoRoot, app }, deps = {}) {
199
+ const { readFile = readFileSync, exists = existsSync, versions = loadRightkitVersions() } = deps;
200
+ const exempt = qaExemption({ appRoot, repoRoot }, deps);
201
+ if (exempt) return { exempt, lane: null, refusals: [], warnings: [] };
202
+ const files = deps.manifests ?? qaManifests(appRoot, deps);
203
+ const pins = [];
204
+ const unreadable = [];
205
+ for (const file of files) {
206
+ let text;
207
+ try { text = readFile(path.join(appRoot, file), "utf8"); } catch (error) { unreadable.push(`${file}: ${error.message}`); continue; }
208
+ if (file.endsWith("package.json")) {
209
+ let manifest;
210
+ try { manifest = JSON.parse(text); } catch { unreadable.push(`${file}: not valid JSON`); continue; }
211
+ for (const name of [NPM_QA, NPM_QA_CHROME]) for (const pin of npmQaPins(manifest, name)) pins.push({ ecosystem: "npm", name, file, ...pin });
212
+ } else {
213
+ for (const pin of cargoQaPins(text)) pins.push({ ecosystem: "cargo", name: CARGO_QA, file, ...pin });
214
+ }
215
+ }
216
+ const label = app ?? "this app";
217
+ const adopted = [...new Set(pins.filter((pin) => pin.name === NPM_QA || pin.name === CARGO_QA).map((pin) => pin.name))];
218
+ const lane = exists(path.join(appRoot, "right-qa.config.mjs")) ? ["native wdio lane (right-qa.config.mjs)", ...adopted] : adopted;
219
+ const refusals = [];
220
+ const warnings = [];
221
+ if (lane.length === 0) {
222
+ refusals.push(`RightKit QA is mandatory for every Orthic Labs app, and ${label} has no QA lane: no right-qa.config.mjs, no ${NPM_QA} in a package.json, no ${CARGO_QA} in a Cargo.toml${unreadable.length ? ` (unreadable: ${unreadable.join("; ")})` : ""}. Adopt it: add ${NPM_QA} to devDependencies (plus ${NPM_QA_CHROME} for a web UI) and/or ${CARGO_QA} to Cargo.toml [dev-dependencies], add the rightkit-qa floor run (smoke.launch, journey), see docs/ADOPTION.md, then run: node rightkit-drift.mjs --apply. Only a repository that is not an app is exempt (${QA_EXEMPTIONS_FILE}); an app cannot opt out`);
223
+ }
224
+ const lagging = pins
225
+ .map((pin) => ({ ...pin, latest: pin.version && latestFor(versions, pin.ecosystem, pin.name) }))
226
+ .filter((pin) => pin.latest && newer(pin.latest, pin.version))
227
+ .map((pin) => ({ ...pin, breaking: breakingBump(pin.version, pin.latest) }));
228
+ const describe = (list) => list.map((pin) => `${pin.name} ${pin.version} -> ${pin.latest}${pin.breaking ? " (breaking)" : ""} (${pin.file})`).join("; ");
229
+ const breaking = lagging.filter((pin) => pin.breaking);
230
+ const soft = lagging.filter((pin) => !pin.breaking);
231
+ if (breaking.length) refusals.push(`${label} runs RightKit QA behind a breaking version: ${describe(breaking)}. Move the QA pins forward and adapt the QA setup: node rightkit-drift.mjs --apply --all`);
232
+ if (soft.length) warnings.push(`${label} runs RightKit QA behind the latest published version: ${describe(soft)}. Update with: node rightkit-drift.mjs --apply`);
233
+ return { exempt: null, lane, refusals, warnings };
234
+ }
235
+
236
+ /** Release gate: throws when the app has no QA lane or lags a breaking version; warnings go to `warn`. */
237
+ export function assertQaAdoption(opts, deps = {}) {
238
+ const { warn = (line) => console.error(line) } = deps;
239
+ const result = evaluateQaAdoption(opts, deps);
240
+ for (const line of result.warnings) warn(`[right-release] QA warning: ${line}`);
241
+ if (result.refusals.length) throw new Error(`QA adoption contract failed:\n- ${result.refusals.join("\n- ")}`);
242
+ return result;
243
+ }
@@ -6,7 +6,7 @@
6
6
  // (signing set, staged payloads, hardening targets, sealed files). The development lane (`development.mjs`)
7
7
  // never calls it: dev builds are the QA builds. The source-level layer is `qa-build-features.mjs`.
8
8
  //
9
- // qa-control-server `rightkit-qa-driver` feature `qa-control`: every binary with the embedded server carries
9
+ // qa-control-server `rightkit-qa` feature `control` (and the `rightkit-qa-driver` shim feature `qa-control`): every binary with the embedded server carries
10
10
  // QA_CONTROL_MARKER (a `#[used]` static in `embedded/mod.rs`, also read by `Control::build`).
11
11
  // wdio-webdriver `tauri-plugin-wdio-webdriver` (HeardRight/MailRight/ViewRight `qa-native`): needs 2 of 3
12
12
  // independent signatures. WEAKER than the marker: the plugin source was not on disk when
@@ -20,7 +20,7 @@ import { closeSync, lstatSync, openSync, readFileSync, readSync, readdirSync } f
20
20
  import path from "node:path";
21
21
  import { inflateRawSync } from "node:zlib";
22
22
 
23
- // Keep identical to `QA_CONTROL_MARKER` in crates/rightkit-qa-driver/src/embedded/mod.rs.
23
+ // Keep identical to `QA_CONTROL_MARKER` in crates/rightkit-qa/src/embedded/mod.rs.
24
24
  export const QA_CONTROL_MARKER = "RIGHTKIT-QA-CONTROL-SERVER-PRESENT";
25
25
  // Keep identical to `DEV_INSPECT_MARKER` in crates/rightkit-tauri-shell/src/devtools.rs (a `#[used]` static compiled only
26
26
  // with rightkit-tauri-shell's `dev-inspect` feature and read by the process guard). No allow-list lifts this rule.
@@ -31,7 +31,7 @@ const CHUNK = 4 * 1024 * 1024;
31
31
  // Each signature is matched as Latin-1/UTF-8 bytes and as UTF-16LE (Windows wide strings). `min` signatures of a
32
32
  // rule must be present for it to fire; a signature counts once however often or in whichever encoding it occurs.
33
33
  export const QA_RULES = Object.freeze([
34
- { id: "qa-control-server", min: 1, label: "RightKit QA control server (rightkit-qa-driver qa-control)",
34
+ { id: "qa-control-server", min: 1, label: "RightKit QA control server (rightkit-qa control, or rightkit-qa-driver qa-control)",
35
35
  signatures: [{ id: "marker", text: QA_CONTROL_MARKER }] },
36
36
  { id: "shell-dev-inspect", min: 1, marker: DEV_INSPECT_MARKER, label: "rightkit-tauri-shell developer inspection (rightkit-tauri-shell dev-inspect)",
37
37
  signatures: [{ id: "marker", text: DEV_INSPECT_MARKER }] },
@@ -7,7 +7,8 @@
7
7
  //
8
8
  // The release config declares the app's QA plan:
9
9
  // qa: { version: "1", config: "rightkit-qa.toml", runner?: "<path to rightkit-qa>",
10
- // args?: { source?: [..], installed?: [..] }, // {sealedDir} {releaseId} expand
10
+ // args?: { source?: [..], installed?: [..] }, // {sealedDir} {releaseId} expand; installed also {installedApp} {installedDir} {qaRoot}
11
+ // installed?: { installDir?: "<abs dir>" }, // opt in: mount the sealed DMG (mac) / run the sealed NSIS installer (win, needs RIGHT_RELEASE_QA_ALLOW_INSTALL=1 off CI), see release-qa-installed.mjs
11
12
  // checks: { "smoke.launch": ["app.launch"], journey: ["journey.*"] } } // floor id -> evidence check names
12
13
  // Every id in release-qa-floor.json must be mapped; skipped/failed/missing required checks fail the receipt.
13
14
  import { createHash } from "node:crypto";
@@ -20,6 +21,7 @@ import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
20
21
  import { assertCleanSource } from "./source-gate.mjs";
21
22
  import { verifySealedRelease } from "./release-state.mjs";
22
23
  import { auditMain } from "./release-qa-audit.mjs";
24
+ import { prepareInstalledArtifacts } from "./release-qa-installed.mjs";
23
25
  import { darwinKeyFile, PROTECTED_SECRETS } from "./protected-credentials.mjs";
24
26
  import {
25
27
  buildQaReceipt, currentPolicyBundleDigest, dependencyClosure, ensureQaReceiptKey, evaluateRequiredChecks, loadQaFloor,
@@ -53,7 +55,7 @@ export function evidencePathFromOutput(stdout) {
53
55
  }
54
56
 
55
57
  /** Run the QA plan through the runner, then seal a receipt. `deps` are test seams; production uses the defaults. */
56
- export async function runQa({ stage, platform, releaseIdArg, configPath, deps = {} }) {
58
+ export async function runQa({ stage, platform, releaseIdArg, configPath, carryTo = null, deps = {} }) {
57
59
  const gitRunner = deps.gitRunner ?? spawnSync;
58
60
  const appRoot = path.dirname(configPath);
59
61
  const repoRoot = String(gitRunner("git", ["-C", appRoot, "rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true }).stdout ?? "").trim();
@@ -86,12 +88,28 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, deps =
86
88
  const runnerSha = sha256File(runnerFile);
87
89
  const qaConfig = path.resolve(appRoot, config.qa.config);
88
90
  if (!existsSync(qaConfig)) throw new Error(`qa.config does not exist: ${qaConfig}`);
89
- const expand = (value) => String(value).replaceAll("{sealedDir}", sealed?.sealedDir ?? "").replaceAll("{releaseId}", releaseId);
90
- const args = ["run", "--config", qaConfig, ...((config.qa.args?.[stage] ?? []).map(expand))];
91
- const run = (deps.spawnRunner ?? spawnSync)(runnerFile, args, {
92
- cwd: appRoot, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024,
93
- env: { ...process.env, RIGHT_RELEASE_QA_STAGE: stage, RIGHT_RELEASE_QA_COMMIT: source.commit, RIGHT_RELEASE_QA_RELEASE_ID: releaseId, ...(sealed ? { RIGHT_RELEASE_QA_SEALED_DIR: sealed.sealedDir } : {}) },
94
- });
91
+ // #184: opt in with `qa.installed: { installDir? }`. Mounts the sealed DMG (mac) or runs the sealed NSIS installer into a
92
+ // throwaway directory (win) and hands the runner the opened app; always cleaned up, even when the runner throws.
93
+ let installedQa = null;
94
+ if (stage === "installed" && config.qa.installed && config.qa.installed !== false) {
95
+ installedQa = (deps.prepareInstalled ?? prepareInstalledArtifacts)({
96
+ sealedDir: sealed.sealedDir, manifest: sealed.manifest, platform,
97
+ installDir: typeof config.qa.installed === "object" ? config.qa.installed.installDir ?? null : null,
98
+ deps: { log: (event) => { if (!deps.quiet) console.error(`[qa] ${JSON.stringify(event)}`); } },
99
+ });
100
+ }
101
+ let run;
102
+ try {
103
+ const expand = (value) => String(value).replaceAll("{sealedDir}", sealed?.sealedDir ?? "").replaceAll("{releaseId}", releaseId)
104
+ .replaceAll("{installedApp}", installedQa?.installedApp ?? "").replaceAll("{installedDir}", installedQa?.installedDir ?? "").replaceAll("{qaRoot}", installedQa?.qaRoot ?? "");
105
+ const args = ["run", "--config", qaConfig, ...((config.qa.args?.[stage] ?? []).map(expand))];
106
+ run = (deps.spawnRunner ?? spawnSync)(runnerFile, args, {
107
+ cwd: appRoot, encoding: "utf8", windowsHide: true, maxBuffer: 64 * 1024 * 1024,
108
+ env: { ...process.env, RIGHT_RELEASE_QA_STAGE: stage, RIGHT_RELEASE_QA_COMMIT: source.commit, RIGHT_RELEASE_QA_RELEASE_ID: releaseId, ...(sealed ? { RIGHT_RELEASE_QA_SEALED_DIR: sealed.sealedDir } : {}), ...(installedQa?.env ?? {}) },
109
+ });
110
+ } finally {
111
+ installedQa?.cleanup();
112
+ }
95
113
  if (!deps.quiet) { process.stdout.write(String(run.stdout ?? "")); process.stderr.write(String(run.stderr ?? "")); }
96
114
  const evidenceFile = evidencePathFromOutput(run.stdout);
97
115
  let evidence = null, evidenceSha256 = null;
@@ -112,31 +130,39 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, deps =
112
130
  }), key);
113
131
  const file = qaReceiptPath({ repoRoot, releaseId, platform, stage });
114
132
  writeQaReceipt(file, receipt);
115
- return { receipt, file };
133
+ // #183: copy the sealed receipt next to the candidate handoff so the signing job (a different runner) can read it.
134
+ let carriedFile = null;
135
+ if (carryTo) {
136
+ carriedFile = path.join(path.resolve(carryTo), `${stage}.receipt.json`);
137
+ writeQaReceipt(carriedFile, receipt);
138
+ }
139
+ return { receipt, file, carriedFile };
116
140
  }
117
141
 
118
142
  function usage(code) {
119
- console.log("usage: right-release qa --stage source|installed --platform mac|win [--release <sealed-id>] [--config right-release.config.mjs]\n right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>]");
143
+ console.log("usage: right-release qa --stage source|installed --platform mac|win [--release <sealed-id>] [--config right-release.config.mjs] [--carry-to <dir>]\n right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>]");
120
144
  process.exit(code);
121
145
  }
122
146
 
123
147
  export async function main(args) {
124
148
  if (args[0] === "audit") process.exit(auditMain(args.slice(1)));
125
- let stage = "", platform = process.platform === "win32" ? "win" : "mac", releaseId = "", configName = "right-release.config.mjs";
149
+ let stage = "", platform = process.platform === "win32" ? "win" : "mac", releaseId = "", configName = "right-release.config.mjs", carryTo = null;
126
150
  for (let index = 0; index < args.length; index += 1) {
127
151
  const arg = args[index];
128
152
  if (arg === "--stage") stage = args[++index];
129
153
  else if (arg === "--platform") platform = args[++index];
130
154
  else if (arg === "--release") releaseId = args[++index];
131
155
  else if (arg === "--config") configName = args[++index];
156
+ else if (arg === "--carry-to") carryTo = args[++index];
132
157
  else if (arg === "-h" || arg === "--help") usage(0);
133
158
  else { console.error(`right-release qa: unknown argument: ${arg}`); process.exit(2); }
134
159
  }
135
160
  if (!QA_STAGES.includes(stage)) { console.error("right-release qa: --stage must be source or installed"); process.exit(2); }
136
161
  if (platform !== "win" && platform !== "mac") { console.error("right-release qa: --platform must be win or mac"); process.exit(2); }
137
162
  try {
138
- const { receipt, file } = await runQa({ stage, platform, releaseIdArg: releaseId, configPath: path.resolve(configName) });
163
+ const { receipt, file, carriedFile } = await runQa({ stage, platform, releaseIdArg: releaseId, configPath: path.resolve(configName), carryTo });
139
164
  console.log(`right-release qa: ${stage} receipt ${receipt.status} ${receipt.digest.slice(0, 16)} -> ${file}`);
165
+ if (carriedFile) console.log(`right-release qa: carried copy -> ${carriedFile}`);
140
166
  for (const item of receipt.required) console.log(` ${item.id}: ${item.status}`);
141
167
  process.exit(receipt.status === "passed" ? 0 : 1);
142
168
  } catch (error) {
@@ -0,0 +1,136 @@
1
+ // Installed QA stage artifact preparation (Orthic-Labs/rightkit#184).
2
+ //
3
+ // `right-release qa --stage installed` used to hand the sealed directory to the QA runner and nothing more:
4
+ // install-app is Mac-only and nothing mounted or ran the sealed artifacts. This module opens the exact sealed
5
+ // bytes the receipt will name, without touching real user data:
6
+ //
7
+ // macOS mounts the sealed DMG read-only and not browsable (`hdiutil attach -readonly -nobrowse -noautoopen`)
8
+ // at a throwaway mount root under the OS temp directory, and exposes the .app inside the mount.
9
+ // Nothing is copied to /Applications and no user data directory is read or written.
10
+ // Windows runs the sealed NSIS installer silently (`/S /D=<throwaway dir>`; /D is the NSIS install-dir
11
+ // override and must be last) into a directory under the OS temp directory. Running an installer registers
12
+ // an uninstall entry for the current user, so it needs an explicit opt-in on a VM or throwaway host
13
+ // (`RIGHT_RELEASE_QA_ALLOW_INSTALL=1`) or GitHub-hosted CI. It is refused when an install directory
14
+ // outside the throwaway root is requested without that opt-in. Cleanup runs the installed uninstaller
15
+ // silently and removes the directory.
16
+ //
17
+ // Both paths also give the runner a throwaway QA data root (`RIGHT_RELEASE_QA_ROOT`) so the app under QA never
18
+ // has to use the real per-user data location. The caller owns `cleanup()`; it never throws.
19
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import { spawnSync as defaultSpawnSync } from "./background-process.mjs";
23
+ import { detachWithRetrySync } from "./hdiutil-detach.mjs";
24
+ import { mountPointsFromAttach } from "./install-app.mjs";
25
+
26
+ export const QA_ALLOW_INSTALL_ENV = "RIGHT_RELEASE_QA_ALLOW_INSTALL";
27
+
28
+ export class InstalledQaError extends Error {
29
+ constructor(code, message) {
30
+ super(message);
31
+ this.name = "InstalledQaError";
32
+ this.code = code;
33
+ }
34
+ }
35
+
36
+ const lower = (name) => String(name).toLowerCase();
37
+
38
+ /** The one sealed DMG (macOS) or NSIS setup executable (Windows). Anything else is ambiguous and refused. */
39
+ export function selectSealedInstaller({ files, platform }) {
40
+ const names = (files ?? []).map((file) => file.name);
41
+ const pick = platform === "win"
42
+ ? names.filter((name) => lower(name).endsWith(".exe") && lower(name).includes("setup"))
43
+ : names.filter((name) => lower(name).endsWith(".dmg"));
44
+ if (pick.length !== 1) {
45
+ throw new InstalledQaError("INSTALLED_QA_ARTIFACT", `installed QA needs exactly one sealed ${platform === "win" ? "NSIS setup .exe" : ".dmg"}; found ${pick.length}${pick.length ? ` (${pick.join(", ")})` : ""}`);
46
+ }
47
+ return pick[0];
48
+ }
49
+
50
+ /** Windows installers write an uninstall entry: only on CI, or when the operator opted in on a VM / throwaway host. */
51
+ export function assertWindowsInstallAllowed(env = process.env) {
52
+ const ci = env.GITHUB_ACTIONS === "true" && env.RUNNER_ENVIRONMENT === "github-hosted";
53
+ if (ci || env[QA_ALLOW_INSTALL_ENV] === "1") return;
54
+ throw new InstalledQaError("INSTALLED_QA_INSTALL_NOT_ALLOWED", `running the sealed NSIS installer registers the app for the current user; run it on a VM or throwaway host and set ${QA_ALLOW_INSTALL_ENV}=1 (never on a machine holding real app data)`);
55
+ }
56
+
57
+ const describe = (result) => `${result?.stderr ?? ""}${result?.stdout ?? ""}`.trim() || result?.error?.message || `exit ${result?.status}`;
58
+
59
+ function prepareMac({ sealedDir, name, tmpdir, spawn, log }) {
60
+ const dmg = path.join(sealedDir, name);
61
+ const mountRoot = mkdtempSync(path.join(tmpdir, "right-qa-dmg-"));
62
+ let mountPoint = null;
63
+ const cleanup = () => {
64
+ if (mountPoint) {
65
+ const out = detachWithRetrySync({ mount: mountPoint, log, detach: (args) => spawn("/usr/bin/hdiutil", args, { encoding: "utf8", windowsHide: true }) });
66
+ if (!out.ok) log({ event: "installed-qa-detach-failed", mount: mountPoint });
67
+ mountPoint = null;
68
+ }
69
+ try { rmSync(mountRoot, { recursive: true, force: true }); } catch { /* best effort */ }
70
+ };
71
+ const attached = spawn("/usr/bin/hdiutil", ["attach", "-nobrowse", "-readonly", "-noautoopen", "-mountrandom", mountRoot, "-plist", dmg], { encoding: "utf8", windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
72
+ if (attached.status !== 0) { cleanup(); throw new InstalledQaError("INSTALLED_QA_MOUNT", `hdiutil attach failed for ${name}: ${describe(attached)}`); }
73
+ let mounts;
74
+ try { mounts = mountPointsFromAttach(attached.stdout); } catch (error) { cleanup(); throw new InstalledQaError("INSTALLED_QA_MOUNT", `cannot read hdiutil attach output for ${name}: ${error.message}`); }
75
+ if (!mounts.length) { cleanup(); throw new InstalledQaError("INSTALLED_QA_MOUNT", `${name} attached but exposed no mount point`); }
76
+ mountPoint = mounts[0];
77
+ const apps = [];
78
+ for (const mount of mounts) for (const entry of readdirSync(mount)) if (entry.endsWith(".app")) apps.push(path.join(mount, entry));
79
+ if (apps.length !== 1) { cleanup(); throw new InstalledQaError("INSTALLED_QA_APP", `expected exactly one .app in ${name}; found ${apps.length}`); }
80
+ log({ event: "installed-qa-mounted", dmg: name, mount: mountPoint, app: apps[0] });
81
+ return { kind: "dmg", installedApp: apps[0], installedDir: mountPoint, cleanup };
82
+ }
83
+
84
+ function prepareWindows({ sealedDir, name, tmpdir, spawn, log, env, installDir }) {
85
+ assertWindowsInstallAllowed(env);
86
+ const installer = path.join(sealedDir, name);
87
+ const root = mkdtempSync(path.join(tmpdir, "right-qa-nsis-"));
88
+ const target = installDir ?? path.join(root, "app");
89
+ if (!path.isAbsolute(target)) { rmSync(root, { recursive: true, force: true }); throw new InstalledQaError("INSTALLED_QA_INSTALL_DIR", `install directory must be absolute: ${target}`); }
90
+ if (installDir && !path.resolve(installDir).toLowerCase().startsWith(path.resolve(tmpdir).toLowerCase() + path.sep) && env[QA_ALLOW_INSTALL_ENV] !== "1") {
91
+ rmSync(root, { recursive: true, force: true });
92
+ throw new InstalledQaError("INSTALLED_QA_INSTALL_DIR", `install directory ${installDir} is outside the throwaway root and ${QA_ALLOW_INSTALL_ENV}=1 is not set`);
93
+ }
94
+ const cleanup = () => {
95
+ try {
96
+ const uninstaller = path.join(target, "uninstall.exe");
97
+ // `_?=<dir>` keeps the uninstaller in place and synchronous so the directory can be removed afterwards.
98
+ if (existsSync(uninstaller)) spawn(uninstaller, ["/S", `_?=${target}`], { encoding: "utf8", windowsHide: true, shell: false });
99
+ } catch { /* best effort */ }
100
+ try { rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ }
101
+ if (installDir) { try { rmSync(target, { recursive: true, force: true }); } catch { /* best effort */ } }
102
+ };
103
+ mkdirSync(target, { recursive: true });
104
+ // NSIS: /S silent, /D=<dir> install directory; /D must be the last argument, unquoted.
105
+ const result = spawn(installer, ["/S", `/D=${target}`], { encoding: "utf8", windowsHide: true, shell: false });
106
+ if (result.status !== 0) { cleanup(); throw new InstalledQaError("INSTALLED_QA_INSTALL", `sealed installer ${name} failed: ${describe(result)}`); }
107
+ const exes = existsSync(target) ? readdirSync(target).filter((entry) => lower(entry).endsWith(".exe") && lower(entry) !== "uninstall.exe") : [];
108
+ log({ event: "installed-qa-installed", installer: name, dir: target, executables: exes });
109
+ return { kind: "nsis", installedApp: exes.length === 1 ? path.join(target, exes[0]) : null, installedDir: target, cleanup };
110
+ }
111
+
112
+ /**
113
+ * Open the sealed installer for the runner. Returns `{ kind, installedApp, installedDir, qaRoot, env, cleanup }`.
114
+ * `env` is merged into the runner environment; `expand` placeholders are `{installedApp}`, `{installedDir}`, `{qaRoot}`.
115
+ */
116
+ export function prepareInstalledArtifacts({ sealedDir, manifest, platform, installDir = null, deps = {} }) {
117
+ const { spawn = defaultSpawnSync, tmpdir = os.tmpdir(), env = process.env, log = () => {}, hostPlatform = process.platform } = deps;
118
+ if (platform === "mac" && hostPlatform !== "darwin") throw new InstalledQaError("INSTALLED_QA_HOST", "mounting the sealed DMG needs macOS");
119
+ if (platform === "win" && hostPlatform !== "win32") throw new InstalledQaError("INSTALLED_QA_HOST", "running the sealed NSIS installer needs Windows");
120
+ const name = selectSealedInstaller({ files: manifest.files, platform });
121
+ const opened = platform === "win"
122
+ ? prepareWindows({ sealedDir, name, tmpdir, spawn, log, env, installDir })
123
+ : prepareMac({ sealedDir, name, tmpdir, spawn, log });
124
+ const qaRoot = mkdtempSync(path.join(tmpdir, "right-qa-root-"));
125
+ const cleanup = () => { opened.cleanup(); try { rmSync(qaRoot, { recursive: true, force: true }); } catch { /* best effort */ } };
126
+ return {
127
+ kind: opened.kind, installedApp: opened.installedApp, installedDir: opened.installedDir, qaRoot, cleanup,
128
+ env: {
129
+ RIGHT_RELEASE_QA_INSTALLED_KIND: opened.kind,
130
+ RIGHT_RELEASE_QA_INSTALLED_ARTIFACT: name,
131
+ RIGHT_RELEASE_QA_INSTALLED_DIR: opened.installedDir,
132
+ ...(opened.installedApp ? { RIGHT_RELEASE_QA_INSTALLED_APP: opened.installedApp } : {}),
133
+ RIGHT_RELEASE_QA_ROOT: qaRoot,
134
+ },
135
+ };
136
+ }