@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 +11 -0
- package/build-release.mjs +5 -11
- package/cli/right-release.mjs +2 -1
- package/github-release.mjs +3 -5
- package/package.json +1 -1
- package/pe-imports.mjs +3 -1
- package/publish-update.mjs +3 -4
- package/release-qa-audit.mjs +145 -0
- package/release-qa-command.mjs +4 -1
- package/rightkit-versions.json +10 -6
- package/swift-package-command.mjs +1 -1
- package/swift-windows-stage.mjs +11 -1
- package/upload-release.mjs +3 -4
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 {
|
|
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
|
|
258
|
-
//
|
|
259
|
-
|
|
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:
|
|
514
|
+
qa: qaAdmission,
|
|
521
515
|
platform,
|
|
522
516
|
cacheKey,
|
|
523
517
|
// Portable contracts sign only raw executables — installer/embedding
|
package/cli/right-release.mjs
CHANGED
|
@@ -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 (
|
|
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
|
package/github-release.mjs
CHANGED
|
@@ -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 {
|
|
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;
|
|
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
|
-
|
|
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
package/pe-imports.mjs
CHANGED
|
@@ -55,5 +55,7 @@ export function readPeImports(bytes) {
|
|
|
55
55
|
delayImports.add(cstring(name));
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
-
|
|
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
|
}
|
package/publish-update.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/release-qa-command.mjs
CHANGED
|
@@ -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];
|
package/rightkit-versions.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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) {
|
package/swift-windows-stage.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/upload-release.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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`);
|