@rightkit/release 0.2.144 → 0.2.146

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 CHANGED
@@ -25,6 +25,15 @@ Signing and publication rules are in the workspace file `docs/rules/release-sign
25
25
  | Checks | `deps`, `hardening`, `harden`, `hardeningscan`, `cargo-target` |
26
26
  | Ownership | `ownership`, `ownership-approval`, `ownership-report` |
27
27
 
28
+ ## QA receipts (warn-and-record)
29
+
30
+ `right-release qa --stage source|installed --platform mac|win` still runs `rightkit-qa` and writes a signed receipt, and receipt verification is unchanged. A missing or invalid receipt no longer blocks a release. `build`, `upload`, `publish-update` and `github` print `[right-release] QA warning: ...` to stderr, continue, and record the outcome:
31
+
32
+ - Source stage: sealed into the manifest as `qa: { status: "passed", sourceReceiptDigest, floorDigest }` or `qa: { status: "missing" | "rejected", reason }`.
33
+ - Installed stage: written to `.right-release/state/<releaseId>/<platformDir>/qa-installed.json` as `{ status, reason?, receiptDigest?, checkedAt, provider }`. It is written when publication starts, so a later upload failure still leaves a record.
34
+
35
+ Audit ad hoc or on a weekly schedule with `right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>]`. It reads the app repo's sealed manifests and state and lists source and installed QA status per release and platform (passed, missing, rejected, not-uploaded). It exits 0 when every uploaded build has passing QA and 1 when any uploaded build lacks it. An installed receipt can only chain to a build sealed with a passing source receipt, so a build sealed with `qa.status: "missing"` needs source QA and a rebuild before installed QA can pass.
36
+
28
37
  ## Local app install (macOS)
29
38
 
30
39
  Agents must not run `osascript`, `open`, `screencapture`, or `notifyutil -p` directly. Install a signed build through:
@@ -66,6 +75,8 @@ Swift (SwiftPM) apps are a first-class project type. `projectType: "swiftpm"` (c
66
75
 
67
76
  `updater.kind: "appinstaller"` (default remains the Tauri minisign flow) generates `msix.appInstaller.out` (https `uri`/`msixUri`); trust is the MSIX Authenticode signature, so no `.sig` is produced and `publish-update` records the sealed package digest.
68
77
 
78
+ Never cancel an MSIX deployment from app or updater code. Cancelling `AddPackageAsync` while it is in `RegistrationChanged` leaves the package `Modified, NeedsRemediation` (0x80073CF8, AppX event 751); the app then cannot activate, so the OnLaunch feed check never runs and cannot self-heal. Measured recovery (SwiftRight SliceApp R5 W08, 2026-10-10): `Add-AppxPackage -AppInstallerFile <feed uri>` restores launch by deploying the feed's newer version, with user data intact; `-RegisterByFamilyName`, `Reset-AppxPackage` and relaunching do not. Recovery when the feed offers no newer version is unverified, so publish the next version to the feed before directing a user to that command.
79
+
69
80
  macOS Swift apps use `right-release swift-package mac-app` (Info.plist from config, resources, frameworks, entitlements on the bundle only; nested code signed inside-out before the app).
70
81
 
71
82
  Build a SwiftPM app with `right-release build --platform mac|win` (add `--config <path>` when the config is not `right-release.config.mjs`). Build is tier-neutral and never uploads, so it rejects `--tier` and takes no `--upload`/`--no-upload`; the old `release --tier patch --no-upload` form no longer works. Pick the tier only at upload: `right-release upload --release <sealed-id> --platform mac|win --tier patch|update`.
package/build-release.mjs CHANGED
@@ -24,7 +24,7 @@ import { spawn, spawnSync } from "./background-process.mjs";
24
24
  import { fileURLToPath, pathToFileURL } from "node:url";
25
25
  import { assertPrimaryReleaseCheckout, dirtyBuildInputs, resolveReleaseBuildInputs } from "./release-invocation.mjs";
26
26
  import { resolveNativeCargoLayout } from "./native-cargo-layout.mjs";
27
- import { assertSourceQaAdmission } from "./release-qa-receipt.mjs";
27
+ import { admitSourceQa } from "./release-qa-audit.mjs";
28
28
  import { commandOutputPortable, releaseEnvironment, resolveReleaseLayout, runBuildStateMachine, verifySealedRelease, watchProgress } from "./release-state.mjs";
29
29
  import { acquireCacheLease, acquireSuiteBuildSlot, applyCachePrune, assertWriteVolumeFloors, inspectCache, inspectWriteVolumes, markCacheEntrySuccessful, planCachePrune, readCachePolicy, resolveCacheLayout, resolveSharedCacheIdentity, resolveSharedCacheRoot } from "./cache-policy.mjs";
30
30
  import { createTargetBridge } from "./target-bridge.mjs";
@@ -254,15 +254,9 @@ try {
254
254
  for (const check of preflight) {
255
255
  if (check.status !== "ok") console.error(`[right-release] preflight ${check.name}: ${check.detail}`);
256
256
  }
257
- // Hardening step 4: source QA admits the candidate build/sign. The receipt digest is sealed into the manifest so
258
- // upload/publication can require installed QA chained to it. Dev lanes (install-app --dev) never reach this file.
259
- let qaAdmission;
260
- try {
261
- qaAdmission = assertSourceQaAdmission({ repoRoot: layout.repoRoot, releaseId, platform, configName: path.relative(process.cwd(), configPath) || path.basename(configPath), releaseConfigSha256: hashFile(configPath) });
262
- } catch (error) {
263
- if (error?.code !== "QA_ADMISSION_REFUSED") throw error;
264
- fail(error.message);
265
- }
257
+ // Hardening step 4: source QA is recorded, not enforced. A missing or rejected receipt warns loudly and the outcome
258
+ // is sealed into the manifest (`qa.status`) for `right-release qa audit`. Dev lanes (install-app --dev) never reach this file.
259
+ const qaAdmission = admitSourceQa({ repoRoot: layout.repoRoot, releaseId, platform, configName: path.relative(process.cwd(), configPath) || path.basename(configPath), releaseConfigSha256: hashFile(configPath) });
266
260
 
267
261
  // A tool being INSTALLED is not the same as it being the one PATH picks. The
268
262
  // vendored-OpenSSL build shells out to `perl`, and under Git Bash/MSYS that
@@ -517,7 +511,7 @@ function sealRelease({ configRoot, managedCargoTarget, nativeLayout, sealedDir,
517
511
  version: config.version,
518
512
  commit,
519
513
  ownershipEvidence,
520
- qa: { sourceReceiptDigest: qaAdmission.digest, floorDigest: qaAdmission.floorDigest },
514
+ qa: qaAdmission,
521
515
  platform,
522
516
  cacheKey,
523
517
  // Portable contracts sign only raw executables — installer/embedding
@@ -184,7 +184,8 @@ Commands:
184
184
  Stage SwiftPM app; build+sign MSIX (Windows) or assemble+sign+notarize .app (macOS); never uploads
185
185
  upload --release <id> --platform mac|win --tier patch|update
186
186
  Upload one sealed release; never builds
187
- qa --stage source|installed --platform mac|win [--release <id>] Run rightkit-qa and write the sealed QA receipt (required to build/upload)
187
+ qa --stage source|installed --platform mac|win [--release <id>] Run rightkit-qa and write the sealed QA receipt (a missing receipt warns and is recorded; it never blocks)
188
+ qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>] List source/installed QA status per release; exit 1 if an uploaded build lacks passing QA
188
189
  publish-update --release <id> --platform mac|win --tier patch|update
189
190
  Upload sealed bytes to R2 & register RightApps
190
191
  release Compatibility alias for build
@@ -7,7 +7,7 @@ import { readVerifiedArtifact, assertArtifactUnchanged, digestBytes, verifyArtif
7
7
  import { spawnSync } from "./background-process.mjs";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { verifySealedRelease } from "./release-state.mjs";
10
- import { assertInstalledQaAdmission } from "./release-qa-receipt.mjs";
10
+ import { admitInstalledQa } from "./release-qa-audit.mjs";
11
11
  import { addonManifestSha256, validateAddonConfig, validateAddonManifest } from "./addon-contract.mjs";
12
12
  import { RIGHTRELEASE_MANIFEST_SIGNER, verifyManifestCatalogSignature } from "./sign-release-manifest.mjs";
13
13
 
@@ -395,13 +395,11 @@ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(
395
395
  plan = prepareGitHubAddonRelease({ repoRoot, config, configRoot: path.dirname(configPath), ...options });
396
396
  } else {
397
397
  options.repo ||= repositoryFromRemote(repoRoot);
398
- // Hardening step 4: the direct GitHub lane is a publication path too; it needs installed QA like `upload` does.
398
+ // Hardening step 4: the direct GitHub lane is a publication path too; installed QA is recorded like `upload` does, never enforced.
399
399
  if (!options.dryRun) {
400
400
  const sealed = verifySealedRelease(path.join(repoRoot, ".right-release", "sealed", options.releaseId, options.platform === "win" ? "windows" : "mac"));
401
401
  const configPath = path.resolve("right-release.config.mjs");
402
- try {
403
- assertInstalledQaAdmission({ repoRoot, manifest: sealed.manifest, platform: options.platform, configName: "right-release.config.mjs", releaseConfigSha256: existsSync(configPath) ? createHash("sha256").update(readFileSync(configPath)).digest("hex") : undefined });
404
- } catch (error) { if (error?.code !== "QA_ADMISSION_REFUSED") throw error; console.error(`right-release github: ${error.message}`); process.exit(1); }
402
+ admitInstalledQa({ repoRoot, manifest: sealed.manifest, platform: options.platform, configName: "right-release.config.mjs", releaseConfigSha256: existsSync(configPath) ? createHash("sha256").update(readFileSync(configPath)).digest("hex") : undefined }, { provider: "github-releases" });
405
403
  }
406
404
  plan = prepareGitHubRelease({ repoRoot, ...options });
407
405
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.144",
3
+ "version": "0.2.146",
4
4
  "description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "type": "module",
package/pe-imports.mjs CHANGED
@@ -55,5 +55,7 @@ export function readPeImports(bytes) {
55
55
  delayImports.add(cstring(name));
56
56
  }
57
57
  }
58
- return { machine, architecture: PE_MACHINES[machine] ?? `0x${machine.toString(16)}`, imports: [...imports], delayImports: [...delayImports] };
58
+ // Optional header Subsystem is at offset 68 in both PE32 and PE32+ (2 = WINDOWS_GUI, 3 = WINDOWS_CUI).
59
+ const subsystem = buf.readUInt16LE(optional + 68);
60
+ return { machine, architecture: PE_MACHINES[machine] ?? `0x${machine.toString(16)}`, subsystem, imports: [...imports], delayImports: [...delayImports] };
59
61
  }
@@ -13,7 +13,7 @@ import { assertCleanSource } from "./source-gate.mjs";
13
13
  import { verifyPublicationRelease, PublicationError, assertArtifactUnchanged, digestBytes } from "./publication-artifacts.mjs";
14
14
  import { uploadVerifiedR2Artifact } from "./r2-verified-upload.mjs";
15
15
  import { verifyPlatformTrust } from "./github-release.mjs";
16
- import { assertInstalledQaAdmission } from "./release-qa-receipt.mjs";
16
+ import { admitInstalledQa } from "./release-qa-audit.mjs";
17
17
 
18
18
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
19
19
  const API_BASE = process.env.RIGHTAPPS_API_URL || "https://api.spoares.com";
@@ -69,10 +69,9 @@ if (!dryRun || releaseId) {
69
69
  if (!installer) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed installer required for platform trust verification");
70
70
  verifyPlatformTrust({ sealed, installer: path.join(sealed.sealedDir, installer.name) });
71
71
  for (const artifact of sealed.artifacts.values()) assertArtifactUnchanged(artifact);
72
- // Hardening step 4: installed QA on these exact artifact hashes and commit must have passed (R2 patch + update tiers).
72
+ // Hardening step 4: installed QA is recorded, not enforced (R2 patch + update tiers); a missing receipt warns and is written to state.
73
73
  if (!dryRun) {
74
- try { assertInstalledQaAdmission({ repoRoot, manifest: sealed.manifest, platform, configName: path.relative(process.cwd(), configPath) || configName, releaseConfigSha256: digestBytes(readFileSync(configPath)) }); }
75
- catch (error) { if (error?.code !== "QA_ADMISSION_REFUSED") throw error; fail(error.message); }
74
+ admitInstalledQa({ repoRoot, manifest: sealed.manifest, platform, configName: path.relative(process.cwd(), configPath) || configName, releaseConfigSha256: digestBytes(readFileSync(configPath)) }, { provider: "r2" });
76
75
  }
77
76
  }
78
77
  const artifactFile = (declared) => {
@@ -0,0 +1,145 @@
1
+ // Warn-and-record QA admission plus the ad hoc `right-release qa audit` scan.
2
+ //
3
+ // A missing or invalid QA receipt never blocks a release. The gates in release-qa-receipt.mjs still verify
4
+ // receipts exactly as before; this module turns a refusal into a loud stderr warning and a durable record
5
+ // (the sealed manifest `qa` field for the source stage, `state/<releaseId>/<platformDir>/qa-installed.json`
6
+ // for the installed stage). `qa audit` reads those records so a weekly run can flag released builds
7
+ // that lack passing QA.
8
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { spawnSync } from "./background-process.mjs";
11
+ import { assertInstalledQaAdmission, assertSourceQaAdmission, qaReceiptPath, readQaReceipt } from "./release-qa-receipt.mjs";
12
+
13
+ const platformDirOf = (platform) => (platform === "win" ? "windows" : platform);
14
+
15
+ /** "missing" when no receipt exists; "rejected" for anything else the gate refused (bad receipt, no key, wrong commit). */
16
+ function refusalStatus(error) {
17
+ return /^no (?:source|installed) QA receipt/.test(error.message) ? "missing" : "rejected";
18
+ }
19
+ const refusalReason = (error) => String(error.message).split("\n run:")[0];
20
+
21
+ export function formatQaWarning({ status, reason, command }) {
22
+ return `[right-release] QA warning: ${reason}; release continues and is recorded as QA ${status} for audit.${command ? ` Run: ${command}` : ""}`;
23
+ }
24
+
25
+ function admit(check, { warn = (line) => console.error(line) } = {}) {
26
+ try { return { status: "passed", result: check() }; }
27
+ catch (error) {
28
+ if (error?.code !== "QA_ADMISSION_REFUSED") throw error;
29
+ const outcome = { status: refusalStatus(error), reason: refusalReason(error), command: error.command ?? null };
30
+ warn(formatQaWarning(outcome));
31
+ return outcome;
32
+ }
33
+ }
34
+
35
+ /** Build stage. Returns the `qa` block sealed into the release manifest. */
36
+ export function admitSourceQa(opts, hooks) {
37
+ const outcome = admit(() => assertSourceQaAdmission(opts), hooks);
38
+ if (outcome.status === "passed") return { status: "passed", sourceReceiptDigest: outcome.result.digest, floorDigest: outcome.result.floorDigest };
39
+ return { status: outcome.status, reason: outcome.reason };
40
+ }
41
+
42
+ export const installedQaRecordPath = ({ repoRoot, releaseId, platform }) =>
43
+ path.join(repoRoot, ".right-release", "state", releaseId, platformDirOf(platform), "qa-installed.json");
44
+
45
+ /** Publication stage (upload, publish-update, github). Records the outcome durably and never blocks. */
46
+ export function admitInstalledQa(opts, { provider, now = () => new Date(), ...hooks } = {}) {
47
+ const outcome = admit(() => assertInstalledQaAdmission(opts), hooks);
48
+ const record = {
49
+ status: outcome.status,
50
+ ...(outcome.status === "passed" ? { receiptDigest: outcome.result.digest } : { reason: outcome.reason }),
51
+ checkedAt: now().toISOString(),
52
+ provider,
53
+ };
54
+ const file = installedQaRecordPath({ repoRoot: opts.repoRoot, releaseId: opts.manifest.releaseId, platform: opts.platform });
55
+ try {
56
+ mkdirSync(path.dirname(file), { recursive: true });
57
+ writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`);
58
+ } catch (error) {
59
+ (hooks.warn ?? ((line) => console.error(line)))(`[right-release] QA warning: could not record installed QA status at ${file} (${error.message}); release continues.`);
60
+ }
61
+ return record;
62
+ }
63
+
64
+ const readJson = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
65
+ const subdirs = (dir) => (existsSync(dir) ? readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : []);
66
+
67
+ function sourceStatus(manifest) {
68
+ const qa = manifest.qa;
69
+ if (qa?.status === "passed" || (!qa?.status && qa?.sourceReceiptDigest)) return { status: "passed" };
70
+ if (qa?.status) return { status: qa.status, ...(qa.reason ? { reason: qa.reason } : {}) };
71
+ return { status: "missing", reason: "sealed manifest records no source QA" };
72
+ }
73
+
74
+ function installedStatus({ repoRoot, releaseId, platformDir }) {
75
+ const stateDir = path.join(repoRoot, ".right-release", "state", releaseId, platformDir);
76
+ const record = readJson(path.join(stateDir, "qa-installed.json"));
77
+ const published = existsSync(stateDir) && readdirSync(stateDir).some((name) => /^verified-.*\.json$/.test(name));
78
+ if (record) return { uploaded: true, status: record.status, ...(record.reason ? { reason: record.reason } : {}), ...(record.checkedAt ? { checkedAt: record.checkedAt } : {}) };
79
+ if (!published) return { uploaded: false, status: "not-uploaded" };
80
+ // Uploaded before outcomes were recorded: fall back to the receipt file the old gate required.
81
+ const receipt = readQaReceipt(qaReceiptPath({ repoRoot, releaseId, platform: platformDir === "windows" ? "win" : platformDir, stage: "installed" }));
82
+ if (receipt?.status === "passed") return { uploaded: true, status: "passed", reason: "legacy upload; receipt file present, not re-verified" };
83
+ return { uploaded: true, status: "missing", reason: "uploaded with no recorded installed QA outcome" };
84
+ }
85
+
86
+ /** Scan the app repo's sealed manifests and state. Returns rows plus `ok` (false when any uploaded build lacks passing QA). */
87
+ export function auditQa({ repoRoot, since } = {}) {
88
+ const sealedRoot = path.join(repoRoot, ".right-release", "sealed");
89
+ const rows = [];
90
+ for (const releaseId of subdirs(sealedRoot).sort()) {
91
+ for (const platformDir of subdirs(path.join(sealedRoot, releaseId)).sort()) {
92
+ const manifest = readJson(path.join(sealedRoot, releaseId, platformDir, "release-manifest.json"));
93
+ if (!manifest) continue;
94
+ if (since && String(manifest.sealedAt ?? "") < since) continue;
95
+ const installed = installedStatus({ repoRoot, releaseId, platformDir });
96
+ const source = sourceStatus(manifest);
97
+ const { uploaded, ...installedRest } = installed;
98
+ rows.push({
99
+ releaseId, platform: platformDir, version: manifest.version ?? null, sealedAt: manifest.sealedAt ?? null, uploaded,
100
+ source, installed: installedRest,
101
+ ok: !uploaded || (source.status === "passed" && installedRest.status === "passed"),
102
+ });
103
+ }
104
+ }
105
+ return { repoRoot, since: since ?? null, rows, ok: rows.every((row) => row.ok) };
106
+ }
107
+
108
+ export function formatAuditTable(report) {
109
+ if (!report.rows.length) return "right-release qa audit: no sealed releases found";
110
+ const header = ["release", "platform", "source", "installed", "result"];
111
+ const body = report.rows.map((row) => [row.releaseId, row.platform, row.source.status, row.installed.status, row.ok ? (row.uploaded ? "ok" : "ok (not uploaded)") : "FLAG"]);
112
+ const widths = header.map((_, column) => Math.max(header[column].length, ...body.map((line) => line[column].length)));
113
+ const line = (cells) => cells.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd();
114
+ const flagged = report.rows.filter((row) => !row.ok);
115
+ return [
116
+ line(header), ...body.map(line),
117
+ "",
118
+ flagged.length
119
+ ? `${flagged.length} uploaded build(s) lack passing QA:\n${flagged.map((row) => ` ${row.releaseId} ${row.platform}: ${[row.source.status !== "passed" && `source ${row.source.status}${row.source.reason ? ` (${row.source.reason})` : ""}`, row.installed.status !== "passed" && `installed ${row.installed.status}${row.installed.reason ? ` (${row.installed.reason})` : ""}`].filter(Boolean).join("; ")}`).join("\n")}`
120
+ : "all uploaded builds have passing QA",
121
+ ].join("\n");
122
+ }
123
+
124
+ function repoRootOf(dir) {
125
+ const result = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { encoding: "utf8", windowsHide: true });
126
+ const top = result.status === 0 ? String(result.stdout ?? "").trim() : "";
127
+ return top && existsSync(path.join(top, ".right-release")) ? top : path.resolve(dir);
128
+ }
129
+
130
+ export function auditMain(args, { stdout = (text) => console.log(text), stderr = (text) => console.error(text) } = {}) {
131
+ let app = process.cwd(), json = false, since;
132
+ for (let index = 0; index < args.length; index += 1) {
133
+ const arg = args[index];
134
+ if (arg === "--app") app = args[++index];
135
+ else if (arg === "--json") json = true;
136
+ else if (arg === "--since") since = args[++index];
137
+ else if (arg === "-h" || arg === "--help") { stdout("usage: right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>]"); return 0; }
138
+ else { stderr(`right-release qa audit: unknown argument: ${arg}`); return 2; }
139
+ }
140
+ if (!app) { stderr("right-release qa audit: --app requires a directory"); return 2; }
141
+ if (since !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(since)) { stderr("right-release qa audit: --since must be YYYY-MM-DD"); return 2; }
142
+ const report = auditQa({ repoRoot: repoRootOf(app), since });
143
+ stdout(json ? JSON.stringify(report, null, 2) : formatAuditTable(report));
144
+ return report.ok ? 0 : 1;
145
+ }
@@ -3,6 +3,7 @@
3
3
  //
4
4
  // right-release qa --stage source --platform mac|win [--config right-release.config.mjs]
5
5
  // right-release qa --stage installed --platform mac|win --release <sealed-id> [--config right-release.config.mjs]
6
+ // right-release qa audit [--app <dir>] [--json] [--since <YYYY-MM-DD>] (release-qa-audit.mjs; exit 1 when an uploaded build lacks passing QA)
6
7
  //
7
8
  // The release config declares the app's QA plan:
8
9
  // qa: { version: "1", config: "rightkit-qa.toml", runner?: "<path to rightkit-qa>",
@@ -18,6 +19,7 @@ import { spawnSync } from "./background-process.mjs";
18
19
  import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
19
20
  import { assertCleanSource } from "./source-gate.mjs";
20
21
  import { verifySealedRelease } from "./release-state.mjs";
22
+ import { auditMain } from "./release-qa-audit.mjs";
21
23
  import { darwinKeyFile, PROTECTED_SECRETS } from "./protected-credentials.mjs";
22
24
  import {
23
25
  buildQaReceipt, currentPolicyBundleDigest, dependencyClosure, ensureQaReceiptKey, evaluateRequiredChecks, loadQaFloor,
@@ -114,11 +116,12 @@ export async function runQa({ stage, platform, releaseIdArg, configPath, deps =
114
116
  }
115
117
 
116
118
  function usage(code) {
117
- console.log("usage: right-release qa --stage source|installed --platform mac|win [--release <sealed-id>] [--config right-release.config.mjs]");
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>]");
118
120
  process.exit(code);
119
121
  }
120
122
 
121
123
  export async function main(args) {
124
+ if (args[0] === "audit") process.exit(auditMain(args.slice(1)));
122
125
  let stage = "", platform = process.platform === "win32" ? "win" : "mac", releaseId = "", configName = "right-release.config.mjs";
123
126
  for (let index = 0; index < args.length; index += 1) {
124
127
  const arg = args[index];
@@ -29,7 +29,7 @@
29
29
  "@rightkit/platform-ui": "0.2.0",
30
30
  "@rightkit/qa": "0.4.0",
31
31
  "@rightkit/qa-chrome": "0.1.3",
32
- "@rightkit/release": "0.2.144",
32
+ "@rightkit/release": "0.2.146",
33
33
  "@rightkit/settings-ui": "0.3.0",
34
34
  "@rightkit/shell": "0.1.0",
35
35
  "@rightkit/tauri": "0.2.0",
@@ -176,7 +176,9 @@
176
176
  "0.2.140",
177
177
  "0.2.141",
178
178
  "0.2.142",
179
- "0.2.143"
179
+ "0.2.143",
180
+ "0.2.144",
181
+ "0.2.145"
180
182
  ],
181
183
  "@rightkit/qa": [
182
184
  "0.1.0",
@@ -276,7 +278,7 @@
276
278
  "rightkit-browser": "0.2.3",
277
279
  "rightkit-cache": "0.1.0",
278
280
  "rightkit-contracts": "0.1.2",
279
- "rightkit-control": "0.1.9",
281
+ "rightkit-control": "0.1.11",
280
282
  "rightkit-crash": "0.2.0",
281
283
  "rightkit-db": "0.3.2",
282
284
  "rightkit-embed": "0.3.1",
@@ -333,7 +335,7 @@
333
335
  "rightkit-audio": "0.2.0",
334
336
  "rightkit-browser": "0.2.2",
335
337
  "rightkit-cache": "0.1.0",
336
- "rightkit-control": "0.1.9",
338
+ "rightkit-control": "0.1.11",
337
339
  "rightkit-crash": "0.2.0",
338
340
  "rightkit-db": "0.3.2",
339
341
  "rightkit-embed": "0.3.1",
@@ -388,7 +390,7 @@
388
390
  "@rightkit/platform-ui": "0.2.0",
389
391
  "@rightkit/qa": "0.4.0",
390
392
  "@rightkit/qa-chrome": "0.1.3",
391
- "@rightkit/release": "0.2.144",
393
+ "@rightkit/release": "0.2.146",
392
394
  "@rightkit/settings-ui": "0.3.0",
393
395
  "@rightkit/shell": "0.1.0",
394
396
  "@rightkit/tauri": "0.2.0",
@@ -504,7 +506,9 @@
504
506
  "rightkit-control": [
505
507
  "0.1.6",
506
508
  "0.1.7",
507
- "0.1.8"
509
+ "0.1.8",
510
+ "0.1.9",
511
+ "0.1.10"
508
512
  ],
509
513
  "rightkit-secrets": [
510
514
  "0.1.0",
@@ -29,7 +29,7 @@ export function windowsStageOptions({ config, base }) {
29
29
  const w = config.windows;
30
30
  if (!w?.exe || !w?.out) throw fail("SWIFT_PACKAGE_CONFIG", "windows.exe and windows.out are required");
31
31
  return { exe: rel(base, w.exe), out: rel(base, w.stageDir ?? `${w.out}.stage`), searchDirs: relList(base, w.searchDirs),
32
- include: relList(base, w.include), systemDlls: w.systemDlls, dependencies: w.dependencies, architecture: w.architecture };
32
+ include: relList(base, w.include), systemDlls: w.systemDlls, dependencies: w.dependencies, architecture: w.architecture, subsystem: w.subsystem };
33
33
  }
34
34
 
35
35
  export function msixOptions({ config, base }, staged) {
@@ -41,7 +41,9 @@ function findInDirs(name, dirs) {
41
41
  * systemDlls[] (extra allowed), dependencies[{ name, provides[] }], architecture }
42
42
  * Returns a manifest; throws SWIFT_STAGE_UNRESOLVED listing every missing DLL.
43
43
  */
44
- export function stageSwiftWindows({ exe, out, searchDirs = [], include = [], systemDlls = [], dependencies = [], architecture, clean = true } = {}) {
44
+ const PE_SUBSYSTEMS = { gui: 2, console: 3 };
45
+
46
+ export function stageSwiftWindows({ exe, out, searchDirs = [], include = [], systemDlls = [], dependencies = [], architecture, subsystem, clean = true } = {}) {
45
47
  if (!exe || !out) throw fail("SWIFT_STAGE_OPTIONS", "exe and out are required");
46
48
  exe = path.resolve(exe); out = path.resolve(out);
47
49
  if (!/\.exe$/i.test(exe) || !existsSync(exe) || !statSync(exe).isFile()) throw fail("SWIFT_STAGE_OPTIONS", `exe must be an existing .exe: ${exe}`);
@@ -71,6 +73,14 @@ export function stageSwiftWindows({ exe, out, searchDirs = [], include = [], sys
71
73
  const info = readPeImports(readFileSync(item.source));
72
74
  if (!["x64", "arm64"].includes(info.architecture)) throw fail("SWIFT_STAGE_ARCH", `${item.source} is ${info.architecture}; only x64 and arm64 are supported`);
73
75
  arch ??= info.architecture;
76
+ if (item.from === "<root>" && subsystem) {
77
+ const want = PE_SUBSYSTEMS[subsystem];
78
+ if (!want) throw fail("SWIFT_STAGE_OPTIONS", `windows.subsystem must be "gui" or "console", got ${JSON.stringify(subsystem)}`);
79
+ if (info.subsystem !== want) {
80
+ throw fail("SWIFT_STAGE_SUBSYSTEM", `${item.source} has PE subsystem ${info.subsystem}, expected ${want} (${subsystem})` +
81
+ (subsystem === "gui" ? "; link with -Xlinker /SUBSYSTEM:WINDOWS -Xlinker /ENTRY:mainCRTStartup so launching it opens no console window" : ""));
82
+ }
83
+ }
74
84
  if (info.architecture !== arch) throw fail("SWIFT_STAGE_ARCH", `${item.source} is ${info.architecture} but package architecture is ${arch}`);
75
85
  for (const dep of [...info.imports, ...info.delayImports]) {
76
86
  const dk = lower(dep);
@@ -10,7 +10,7 @@ import { verifySealedRelease } from "./release-state.mjs";
10
10
  import { assertCleanSource } from "./source-gate.mjs";
11
11
  import { verifyOwnershipEvidence } from "./ownership-evidence.mjs";
12
12
  import { recordPublishedVersion } from "./artifact-storage.mjs";
13
- import { assertInstalledQaAdmission } from "./release-qa-receipt.mjs";
13
+ import { admitInstalledQa } from "./release-qa-audit.mjs";
14
14
 
15
15
  const args = process.argv.slice(2);
16
16
  let platform = process.platform === "win32" ? "win" : process.platform === "darwin" ? "mac" : process.platform;
@@ -60,10 +60,9 @@ if (!configuredRepo) fail("release config distribution.repository is required fo
60
60
  if (repo && repo !== configuredRepo) fail(`--repo must match configured distribution.repository (${configuredRepo})`);
61
61
  repo = configuredRepo;
62
62
 
63
- // Hardening step 4: installed QA on these exact artifact hashes and commit must have passed before any upload.
63
+ // Hardening step 4: installed QA is recorded, not enforced; a missing receipt warns and the outcome is written to state.
64
64
  if (!dryRun) {
65
- try { assertInstalledQaAdmission({ repoRoot, manifest: sealed.manifest, platform, configName: path.relative(process.cwd(), configPath) || configName, releaseConfigSha256: createHash("sha256").update(readFileSync(configPath)).digest("hex") }); }
66
- catch (error) { if (error?.code !== "QA_ADMISSION_REFUSED") throw error; fail(error.message); }
65
+ admitInstalledQa({ repoRoot, manifest: sealed.manifest, platform, configName: path.relative(process.cwd(), configPath) || configName, releaseConfigSha256: createHash("sha256").update(readFileSync(configPath)).digest("hex") }, { provider: "github-releases" });
67
66
  }
68
67
  const stateRoot = path.join(repoRoot, ".right-release", "state", releaseId, platformDir);
69
68
  const verifiedMarker = path.join(stateRoot, `verified-${tier}-github-releases.json`);