@rightkit/release 0.6.12 → 0.6.14
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 +49 -1
- package/adapter-env.mjs +165 -0
- package/android-ndk.mjs +5 -1
- package/android-release.mjs +73 -8
- package/app-check.mjs +22 -8
- package/app-closure.mjs +214 -0
- package/assemble-app-command.mjs +24 -7
- package/atomic-write.mjs +57 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +1190 -335
- package/broker-client.mjs +106 -0
- package/broker-exception.mjs +211 -0
- package/broker-handshake.mjs +160 -0
- package/broker-pins.mjs +27 -8
- package/broker-wire.mjs +262 -0
- package/build-launch.mjs +364 -12
- package/build-ownership.mjs +117 -49
- package/build-release.mjs +3 -2
- package/cache-command.mjs +7 -2
- package/candidate-build-receipt.mjs +96 -0
- package/candidate-receipt-command.mjs +29 -0
- package/cargo-contract.mjs +4 -3
- package/cargo-ledger.mjs +1 -1
- package/cargo-license-notices.mjs +2 -2
- package/cargo-shapes.mjs +332 -0
- package/cargo-target-shim.mjs +44 -5
- package/cargo-target.mjs +5 -0
- package/carried-adapter-run.mjs +31 -0
- package/carried-cargo-run.mjs +9 -0
- package/carried-cargo.mjs +237 -0
- package/carrier-census.mjs +124 -0
- package/class-a-launch-guard.mjs +266 -33
- package/cli/right-release.mjs +2 -0
- package/clippy-route.mjs +23 -11
- package/compile-inputs.mjs +9 -12
- package/control-plane.mjs +18 -0
- package/deps-cargo-lock.mjs +208 -0
- package/deps.mjs +5 -0
- package/deterministic-scans.mjs +273 -44
- package/development-signing.mjs +14 -1
- package/development.mjs +109 -24
- package/direct-origin.mjs +107 -0
- package/disk-command.mjs +1 -0
- package/disk-policy.mjs +18 -3
- package/freshness-census.mjs +206 -58
- package/gate-executor.mjs +30 -12
- package/gate-stages.mjs +93 -16
- package/github-release.mjs +2 -1
- package/github-upload-gate.mjs +2 -1
- package/install-tauri-authenticode.mjs +346 -0
- package/ios-dev.mjs +77 -23
- package/ios-package-recipe.mjs +100 -41
- package/linux-artifacts.mjs +17 -2
- package/linux-qualifier.mjs +83 -0
- package/local-publish.mjs +36 -14
- package/local-runtime.mjs +2 -2
- package/lock-identity.mjs +138 -0
- package/lsclean.sh +0 -0
- package/managed-cargo.mjs +151 -44
- package/managed-roots.mjs +130 -12
- package/media-sidecar-catalog.mjs +1 -1
- package/native-adapters.mjs +411 -0
- package/native-helpers.mjs +26 -18
- package/native-tool-planning.mjs +94 -0
- package/ownership-mark.mjs +182 -0
- package/pack-digest.mjs +122 -0
- package/package.json +12 -12
- package/platform-dir.mjs +14 -0
- package/pnpm-adapter-grammar.mjs +226 -0
- package/progress-control.mjs +7 -2
- package/provenance-real-receipt.pins.json +7 -0
- package/provenance-verify.mjs +262 -1
- package/publication-outcomes.mjs +21 -0
- package/publish-update.mjs +2 -1
- package/qa-journeys-prebuilt-run.mjs +18 -0
- package/qa-journeys-prebuilt.mjs +148 -0
- package/qa-snapshot-run.mjs +53 -0
- package/qa-snapshot.mjs +164 -0
- package/qualification-verify.mjs +144 -0
- package/qualify-reuse.mjs +38 -0
- package/recipe-build.mjs +16 -3
- package/release-qa-audit.mjs +2 -2
- package/release-qa-command.mjs +2 -1
- package/release-qa-receipt.mjs +3 -2
- package/release-state.mjs +3 -2
- package/release.mjs +14 -3
- package/rightkit-drift.mjs +27 -5
- package/rightkit-versions.json +15 -10
- package/rustright-names.mjs +32 -0
- package/scan-tool-enrolment.mjs +167 -0
- package/sign-windows.mjs +212 -112
- package/signing-files.mjs +35 -0
- package/signing-identity.mjs +7 -0
- package/snapshot-broker.mjs +528 -57
- package/snapshot-check.mjs +95 -11
- package/snapshot-metadata.mjs +123 -0
- package/snapshot-request.mjs +389 -96
- package/stage-binary.mjs +22 -4
- package/stage-commands.mjs +56 -13
- package/stage-sidecar.mjs +93 -21
- package/suite-signing-policy.mjs +657 -0
- package/suite-signing.mjs +223 -0
- package/swift-scratch.mjs +25 -0
- package/tauri-locked.mjs +25 -0
- package/test-command.mjs +164 -29
- package/unit-gate.mjs +201 -31
- package/upload-large.mjs +0 -0
- package/web-build.mjs +2 -2
- package/win-package-recipe.mjs +26 -2
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// The `sign` step of `tests.suites` (rightkit#1303): a gate-run test build is signed with the product's one signing identity before anything runs it,
|
|
2
|
+
// so a QA run exercises the same code identity the release carries (macOS keys privacy grants to the designated requirement).
|
|
3
|
+
//
|
|
4
|
+
// { name: "sign-app", sign: { files: [{ from: "build-app", path: "aarch64-apple-darwin/debug/heardright" }, ...] } }
|
|
5
|
+
//
|
|
6
|
+
// It runs IN-PROCESS inside `right-release test`, in the gate's own session and outside the broker (like QA: the signing agent and the Keychain are
|
|
7
|
+
// the session's, a broker job has neither), and it is never a carried step (build-launch.mjs refuses it). It reuses the release signers and adds no
|
|
8
|
+
// ceremony of its own: macOS signs through signMacosEntries, which signs through runMacCodesignCommands (the signing agent) with the identity
|
|
9
|
+
// resolveMacosDevelopmentSigning picks; Windows signs and verifies through sign-windows.mjs (signWindowsFiles, verifyWindowsFiles).
|
|
10
|
+
//
|
|
11
|
+
// What it does, in this order, and refuses at the first thing that is not so:
|
|
12
|
+
// 1. the identity is derived: the product's tauri.conf.json `identifier` (mac install.bundleId must equal it); the team is SIGN_TEAM and the
|
|
13
|
+
// Windows signer subject SIGN_SUBJECT; ad hoc signing (RIGHTKIT_DEV_SIGNING, install.signing) is refused;
|
|
14
|
+
// 2. each `from` names a suite that ran before in this run; its carried receipt's output directory holds `path` (a path that leaves it, by `..` or
|
|
15
|
+
// a link, is refused);
|
|
16
|
+
// 3. the file is copied (sha256 checked) into a gate-managed directory outside the snapshot, the checkout and every broker output directory, and the
|
|
17
|
+
// copy is signed. The source bytes are never touched;
|
|
18
|
+
// 4. read back from the signed copy: mac `Identifier`, `TeamIdentifier` and the designated requirement, which must EQUAL the release-shaped one
|
|
19
|
+
// (expectedDesignatedRequirement) and be neither ad hoc nor cdhash-bound; Windows Authenticode with the signer subject;
|
|
20
|
+
// 5. only then the evidence (file, sha256 before and after, identifier, team or subject, requirement) is written, and the main binary's signed copy
|
|
21
|
+
// becomes RIGHTKIT_QA_APP_BINARY for the suites after it.
|
|
22
|
+
// The main binary (identifier = the bundle id) is the file named by tauri.conf.json `mainBinaryName` when it declares one, else the first file; every
|
|
23
|
+
// other file is a sidecar whose identifier is the release identifier resolveBundleIdentifier gives it (`sign.identifiers` / `identifierTemplate`, keyed
|
|
24
|
+
// `Contents/MacOS/<name>` as in the bundle). RIGHTKIT_QA_APP_BINARY is the main binary's signed copy (unset by a step that signs no main binary).
|
|
25
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { spawnSync } from "./background-process.mjs";
|
|
29
|
+
import { appendLedger, SCAN_TOKEN_ENV } from "./cargo-ledger.mjs";
|
|
30
|
+
import { carriedCargoMapOf } from "./carried-cargo.mjs";
|
|
31
|
+
import { MacosDevelopmentSigningError, assertStableDesignatedRequirement, expectedDesignatedRequirement, readDevSigningSettings, resolveMacosDevelopmentSigning } from "./development-signing.mjs";
|
|
32
|
+
import { lookupBundlePath, resolveBundleIdentifier } from "./mac-bundle-signing.mjs";
|
|
33
|
+
import { signMacosEntries } from "./mac-sign-core.mjs";
|
|
34
|
+
import { runMacCodesignCommands } from "./macos-signing-session.mjs";
|
|
35
|
+
import { resolveManagedRoots } from "./managed-roots.mjs";
|
|
36
|
+
import { isInside, realish } from "./qa-snapshot.mjs";
|
|
37
|
+
import { requireSignerToken, signWindowsFiles, verifyWindowsFiles } from "./sign-windows.mjs";
|
|
38
|
+
import { QA_APP_BINARY_ENV, QA_SIGNED_FILES_ENV, SIGN_SUBJECT, SIGN_TEAM, SuiteSigningError, assertBundleIdMatches, containedRelative, productIdentity } from "./suite-signing-policy.mjs";
|
|
39
|
+
|
|
40
|
+
const refuse = (code, message, details) => { throw new SuiteSigningError(code, message, details); };
|
|
41
|
+
const sha256 = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
42
|
+
const output = (result) => `${result?.stdout ?? ""}\n${result?.stderr ?? ""}`;
|
|
43
|
+
|
|
44
|
+
/** Where signed copies live: under the managed data root, which the signing agent admits and the gate never builds in. */
|
|
45
|
+
export const defaultSigningDirectory = ({ env = process.env, platform = process.platform } = {}) => path.join(resolveManagedRoots({ env, platform }).dataRoot, "suite-signing");
|
|
46
|
+
|
|
47
|
+
/** The Authority-independent facts `codesign -dvv` reports. */
|
|
48
|
+
function codesignReport(file, { run, env, root }) {
|
|
49
|
+
const result = run("codesign", ["-dvv", file], { cwd: root, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false });
|
|
50
|
+
if (result?.error || result?.status !== 0) refuse("SIGN_READBACK_FAILED", `cannot read the signature of ${file}: ${result?.error?.message || output(result).trim() || `exit ${result?.status ?? "unavailable"}`}`);
|
|
51
|
+
const text = output(result);
|
|
52
|
+
return { identifier: /^Identifier=(.+)$/m.exec(text)?.[1]?.trim(), team: /^TeamIdentifier=(.+)$/m.exec(text)?.[1]?.trim(), adhoc: /^Signature=adhoc$/m.test(text) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Mac: sign each copy and read it back against the release-shaped identity. Returns one evidence record per file. */
|
|
56
|
+
function signMac({ copies, name, config, appRoot, env, run, deps, identifier, install, signedDir, mainIndex }) {
|
|
57
|
+
const settings = readDevSigningSettings(install, config, { root: appRoot });
|
|
58
|
+
let signing;
|
|
59
|
+
try { signing = (deps.resolveSigning ?? resolveMacosDevelopmentSigning)({ root: appRoot, bundle: signedDir, descriptor: { manifestPath: install.manifestPath }, config, env, run }); }
|
|
60
|
+
catch (cause) {
|
|
61
|
+
if (!(cause instanceof MacosDevelopmentSigningError)) throw cause;
|
|
62
|
+
// No Developer ID identity with its private key (or an ad hoc / Apple Development request): a refusal, never a fallback.
|
|
63
|
+
refuse("SIGN_CREDENTIAL_MISSING", `${name}: the Developer ID Application identity of team ${SIGN_TEAM} is not available (${cause.code}): ${cause.message}`, { cause, reason: cause.code });
|
|
64
|
+
}
|
|
65
|
+
// Developer ID of the one team, never ad hoc, never Apple Development: a different identity is a different designated requirement.
|
|
66
|
+
if (signing.adhoc || !/^Developer ID Application:/.test(signing.identity ?? "")) refuse("SIGN_IDENTITY_REFUSED", `suite signing needs the Developer ID Application identity of team ${SIGN_TEAM}; got ${JSON.stringify(signing.identity)}. There is no ad hoc or Apple Development fallback`, { identity: signing.identity });
|
|
67
|
+
if (signing.teamIdentifier !== SIGN_TEAM || !signing.identity.endsWith(`(${SIGN_TEAM})`)) refuse("SIGN_TEAM_MISMATCH", `suite signing resolved ${JSON.stringify(signing.identity)} (team ${signing.teamIdentifier}), not team ${SIGN_TEAM}`, { identity: signing.identity, team: signing.teamIdentifier });
|
|
68
|
+
const entries = copies.map((copy, index) => {
|
|
69
|
+
const relative = `Contents/MacOS/${path.basename(copy.file)}`;
|
|
70
|
+
let id;
|
|
71
|
+
if (index === mainIndex) id = identifier;
|
|
72
|
+
else {
|
|
73
|
+
try { id = resolveBundleIdentifier({ relative, identifiers: settings.identifiers, template: settings.identifierTemplate, bundleId: identifier, plain: true }); }
|
|
74
|
+
catch (cause) { refuse("SIGN_SIDECAR_IDENTIFIER", cause.message); }
|
|
75
|
+
}
|
|
76
|
+
// A sidecar the release signs with no declared identifier is signed by codesign with no --identifier, which derives the file name: the same here
|
|
77
|
+
// (nothing is passed), and the read-back expects exactly that name.
|
|
78
|
+
const entitlements = (index === mainIndex ? lookupBundlePath(settings.entitlements, ".") : undefined) ?? lookupBundlePath(settings.entitlements, relative);
|
|
79
|
+
return { file: copy.file, ...(id !== undefined ? { identifier: id } : {}), expectedIdentifier: id ?? path.basename(copy.file), ...(entitlements ? { entitlements } : {}) };
|
|
80
|
+
});
|
|
81
|
+
const signOne = deps.signOne ?? ((args) => runMacCodesignCommands([args], { env, run, cwd: appRoot })[0]);
|
|
82
|
+
const inspect = deps.inspect ?? ((command, args) => {
|
|
83
|
+
const result = run(command, args, { cwd: appRoot, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true, shell: false });
|
|
84
|
+
if (result?.error || result?.status !== 0) refuse("SIGN_READBACK_FAILED", `${command} ${args.join(" ")} failed: ${output(result).trim() || result?.error?.message}`);
|
|
85
|
+
return output(result);
|
|
86
|
+
});
|
|
87
|
+
let records;
|
|
88
|
+
try { records = signMacosEntries(entries.map(({ expectedIdentifier: _derived, ...entry }) => entry), { identity: signing.identity, signOne, inspect, bundle: signedDir }); }
|
|
89
|
+
catch (cause) { refuse("SIGN_FAILED", `${name}: ${cause.message}`, { cause }); }
|
|
90
|
+
return records.map((record, index) => {
|
|
91
|
+
const entry = entries[index];
|
|
92
|
+
const report = codesignReport(record.file, { run, env, root: appRoot });
|
|
93
|
+
if (report.adhoc) refuse("SIGN_ADHOC", `${copies[index].path}: the signed copy is ad hoc signed`);
|
|
94
|
+
if (report.team !== SIGN_TEAM) refuse("SIGN_TEAM_MISMATCH", `${copies[index].path}: TeamIdentifier=${report.team ?? "missing"}, expected ${SIGN_TEAM}`, { team: report.team });
|
|
95
|
+
if (report.identifier !== entry.expectedIdentifier) refuse("SIGN_IDENTIFIER_MISMATCH", `${copies[index].path}: Identifier=${report.identifier ?? "missing"}, expected ${entry.expectedIdentifier}`, { identifier: report.identifier });
|
|
96
|
+
const expected = expectedDesignatedRequirement(entry.expectedIdentifier, SIGN_TEAM);
|
|
97
|
+
let requirement;
|
|
98
|
+
try { requirement = assertStableDesignatedRequirement({ root: appRoot, installed: record.file, expected, env, run }); }
|
|
99
|
+
catch (error) { if (!(error instanceof MacosDevelopmentSigningError)) throw error; refuse("SIGN_REQUIREMENT_MISMATCH", `${copies[index].path}: ${error.message}`, { expected, actual: error.installedRequirement }); }
|
|
100
|
+
// assertStableDesignatedRequirement is silent about an unreadable or cdhash-bound requirement (it has no grants to protect); here either is a refusal.
|
|
101
|
+
if (requirement !== expected) refuse("SIGN_REQUIREMENT_MISMATCH", `${copies[index].path}: the designated requirement could not be read back, or is cdhash-bound, instead of ${JSON.stringify(expected)}`, { expected });
|
|
102
|
+
return { platform: "mac", file: copies[index].path, signed: record.file, sha256Before: record.before.sha256, sha256After: record.after.sha256, identifier: entry.expectedIdentifier, team: report.team, identity: signing.identity, requirement };
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Windows: sign each copy through sign-windows and check the signer subject. */
|
|
107
|
+
function signWin({ copies, name, env, run, deps }) {
|
|
108
|
+
let evidence;
|
|
109
|
+
try {
|
|
110
|
+
evidence = (deps.signWindows ?? ((files) => signWindowsFiles(files)))(copies.map((copy) => copy.file));
|
|
111
|
+
if (!Array.isArray(evidence) || evidence.length !== copies.length) refuse("SIGN_FAILED", `${name}: the Windows signer returned ${Array.isArray(evidence) ? evidence.length : "no"} record(s) for ${copies.length} file(s)`);
|
|
112
|
+
const verified = (deps.verifyWindows ?? ((files) => verifyWindowsFiles(files)))(copies.map((copy) => copy.file));
|
|
113
|
+
if (!Array.isArray(verified) || verified.length !== copies.length) refuse("SIGN_FAILED", `${name}: the Windows verifier returned ${Array.isArray(verified) ? verified.length : "no"} record(s) for ${copies.length} file(s)`);
|
|
114
|
+
verified.forEach((record, index) => { if (record.subject !== SIGN_SUBJECT) refuse("SIGN_SUBJECT_MISMATCH", `${copies[index].path}: signer subject ${JSON.stringify(record.subject)}, expected ${SIGN_SUBJECT}`, { subject: record.subject }); });
|
|
115
|
+
evidence.forEach((record, index) => { if (record.subject !== SIGN_SUBJECT || record.authenticode !== "Valid") refuse("SIGN_SUBJECT_MISMATCH", `${copies[index].path}: signer subject ${JSON.stringify(record.subject)} / ${JSON.stringify(record.authenticode)}, expected ${SIGN_SUBJECT} / Valid`, { subject: record.subject }); });
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof SuiteSigningError) throw error;
|
|
118
|
+
if (error?.code === "SIGN_WINDOWS_TOKEN_UNAVAILABLE") refuse("SIGN_WINDOWS_TOKEN_UNAVAILABLE", `${name}: ${error.message}`, { cause: error });
|
|
119
|
+
refuse("SIGN_FAILED", `${name}: ${error.message}`, { cause: error });
|
|
120
|
+
}
|
|
121
|
+
return evidence.map((record, index) => ({ platform: "win", file: copies[index].path, signed: copies[index].file, sha256Before: record.before.sha256, sha256After: record.after.sha256, subject: record.subject, authenticode: record.authenticode, timestampPresent: record.timestampPresent }));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The path of `rel` inside one of `dirs` (receipt output directories), the last that holds a regular file there. A link that leaves the directory is refused. */
|
|
125
|
+
function locateSource(dirs, rel, label) {
|
|
126
|
+
const found = [];
|
|
127
|
+
for (const dir of dirs) {
|
|
128
|
+
const candidate = path.join(dir, ...rel.split("/"));
|
|
129
|
+
let info;
|
|
130
|
+
try { info = fs.lstatSync(candidate); } catch { continue; }
|
|
131
|
+
if (info.isSymbolicLink()) refuse("SIGN_PATH_ESCAPE", `${label}: ${candidate} is a link; a sign step signs the receipt's own file`);
|
|
132
|
+
if (!info.isFile()) refuse("SIGN_SOURCE_INVALID", `${label}: ${candidate} is not a regular file`);
|
|
133
|
+
if (!isInside(fs.realpathSync.native(candidate), fs.realpathSync.native(dir))) refuse("SIGN_PATH_ESCAPE", `${label}: ${candidate} resolves outside the receipt's output directory ${dir}`);
|
|
134
|
+
found.push(candidate);
|
|
135
|
+
}
|
|
136
|
+
if (!found.length) refuse("SIGN_SOURCE_MISSING", `${label}: no ${rel} in the output directory of the suite's carried receipt (${dirs.join(", ") || "none recorded"})`);
|
|
137
|
+
return found.at(-1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Run one sign step. `outputDirsOf(suite)` returns the verified carried receipts' output directories of an earlier suite of this run, or null when it
|
|
142
|
+
* did not run. Returns `{ appBinary, evidence, evidenceFile, signedDir }`; throws SuiteSigningError (the step then sets nothing).
|
|
143
|
+
*/
|
|
144
|
+
export function runSignStep({ name, sign, appRoot, config, env = process.env, platform = process.platform, outputDirsOf, deps = {}, log = () => {} }) {
|
|
145
|
+
const host = platform === "darwin" ? "mac" : platform === "win32" ? "win" : null;
|
|
146
|
+
const carried = carriedCargoMapOf(env);
|
|
147
|
+
const record = (entry) => { if (carried?.map) try { appendLedger(carried.map.ledgerDir, env[SCAN_TOKEN_ENV], { id: `sign-${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`, mode: "signing", cwd: appRoot, argv: ["sign", name], ...entry }); } catch (error) { log(`right-release test: cannot record the signing step in the carrier's ledger: ${error.message}`); throw error; } };
|
|
148
|
+
let signedDir = null;
|
|
149
|
+
try {
|
|
150
|
+
if (!host) refuse("SIGN_PLATFORM", `${name}: a sign step runs on macOS (Developer ID) or Windows (Azure Artifact Signing), not ${platform}`);
|
|
151
|
+
// Windows signer readiness comes first: before the signing directory exists and before any copy, the Azure CLI sign-in must yield a silent token.
|
|
152
|
+
if (host === "win") {
|
|
153
|
+
try { (deps.signerPreflight ?? (() => requireSignerToken()))(); }
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (error?.code === "SIGN_WINDOWS_TOKEN_UNAVAILABLE") refuse("SIGN_WINDOWS_TOKEN_UNAVAILABLE", `${name}: ${error.message}`, { cause: error });
|
|
156
|
+
refuse("SIGN_FAILED", `${name}: the Windows signer preflight failed: ${error.message}`, { cause: error });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// 1. The identity is derived, and ad hoc is never an option.
|
|
160
|
+
if (env.RIGHTKIT_DEV_SIGNING !== undefined) refuse("SIGN_ADHOC_REFUSED", `${name}: RIGHTKIT_DEV_SIGNING is set; a signed suite build is never ad hoc signed`);
|
|
161
|
+
const install = config?.development?.targets?.[host]?.install ?? {};
|
|
162
|
+
if (install.signing !== undefined) refuse("SIGN_ADHOC_REFUSED", `${name}: development.targets.${host}.install.signing ${JSON.stringify(install.signing)} asks for ad hoc signing`);
|
|
163
|
+
const read = (rel) => { try { return fs.readFileSync(path.resolve(appRoot, rel), "utf8"); } catch { return null; } };
|
|
164
|
+
const { identifier, mainBinaryName } = productIdentity({ readFile: read, manifestPath: install.manifestPath });
|
|
165
|
+
assertBundleIdMatches(config, identifier);
|
|
166
|
+
// 2. Where each file comes from.
|
|
167
|
+
if (!carried?.map) refuse("SIGN_RECEIPT_MISSING", `${name}: no carried receipts in this environment (${carried?.error ?? "RIGHTKIT_CARRIED_CARGO_MAP is not set"}); a sign step signs a file named by an earlier suite's signed receipt`);
|
|
168
|
+
if (typeof outputDirsOf !== "function") refuse("SIGN_RECEIPT_MISSING", `${name}: the run has no receipt reader`);
|
|
169
|
+
const sources = sign.files.map((item, index) => {
|
|
170
|
+
const file = { ...item, path: containedRelative(item.path, `${name}.files[${index}].path`) };
|
|
171
|
+
const dirs = outputDirsOf(file.from);
|
|
172
|
+
if (!dirs) refuse("SIGN_FROM_NOT_RUN", `${name}.files[${index}]: suite ${JSON.stringify(file.from)} did not run before this step in this run`);
|
|
173
|
+
return { ...file, dirs, source: locateSource(dirs, file.path, `${name}.files[${index}] (${file.from}:${file.path})`) };
|
|
174
|
+
});
|
|
175
|
+
// 3. A gate-managed copy, outside everything the build owns.
|
|
176
|
+
const signingRoot = deps.signingDirectory ?? defaultSigningDirectory({ env, platform });
|
|
177
|
+
signedDir = path.join(signingRoot, `${name}-${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`);
|
|
178
|
+
const section = carried.map.broker ?? {};
|
|
179
|
+
const forbidden = [section.snapshot?.path, section.checkout, env.RK_GATE_CHECKOUT, ...sources.flatMap((source) => source.dirs)].filter((p) => typeof p === "string" && p);
|
|
180
|
+
for (const place of forbidden) if (isInside(signedDir, place)) refuse("SIGN_COPY_PLACE", `${name}: the signing directory ${signedDir} is inside ${place} (the snapshot, the checkout or a broker output directory)`);
|
|
181
|
+
fs.mkdirSync(signedDir, { recursive: true, mode: 0o700 });
|
|
182
|
+
// The copies keep their relative paths under one directory, so a sidecar stays beside the main binary the way the build left them.
|
|
183
|
+
const taken = new Set();
|
|
184
|
+
const copies = sources.map((source) => {
|
|
185
|
+
const file = path.join(signedDir, ...source.path.split("/"));
|
|
186
|
+
if (taken.has(file)) refuse("SIGN_COPY_COLLISION", `${name}: two files would be copied to ${source.path}; give them different paths`);
|
|
187
|
+
taken.add(file);
|
|
188
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
189
|
+
const before = sha256(source.source);
|
|
190
|
+
fs.copyFileSync(source.source, file);
|
|
191
|
+
fs.chmodSync(file, fs.statSync(source.source).mode & 0o777);
|
|
192
|
+
if (sha256(file) !== before) refuse("SIGN_COPY_MISMATCH", `${name}: the copy of ${source.path} differs from its source`);
|
|
193
|
+
return { path: source.path, from: source.from, source: source.source, file, sha256: before };
|
|
194
|
+
});
|
|
195
|
+
// 4. Sign the copies and read them back.
|
|
196
|
+
const run = deps.run ?? spawnSync;
|
|
197
|
+
// The main binary is the file named by the product's `mainBinaryName` when tauri.conf.json declares one (a step that signs only sidecars names none of
|
|
198
|
+
// them), else the first file. Every other file is a sidecar.
|
|
199
|
+
let mainIndex = 0;
|
|
200
|
+
if (mainBinaryName) {
|
|
201
|
+
const hits = copies.flatMap((copy, index) => (path.basename(copy.path).replace(/\.exe$/i, "") === mainBinaryName ? [index] : []));
|
|
202
|
+
if (hits.length > 1) refuse("SIGN_MAIN_AMBIGUOUS", `${name}: ${hits.length} files are named ${mainBinaryName}, the product's main binary`);
|
|
203
|
+
mainIndex = hits.length ? hits[0] : -1;
|
|
204
|
+
}
|
|
205
|
+
const evidence = host === "mac"
|
|
206
|
+
? signMac({ copies, name, config, appRoot, env, run, deps, identifier, install, signedDir, mainIndex })
|
|
207
|
+
: signWin({ copies, name, env, run, deps });
|
|
208
|
+
for (const copy of copies) if (sha256(copy.source) !== copy.sha256) refuse("SIGN_SOURCE_CHANGED", `${name}: the receipt's ${copy.path} changed while it was signed; a sign step never modifies its source`);
|
|
209
|
+
// 5. Evidence first, then the variable.
|
|
210
|
+
const evidenceFile = path.join(signedDir, "signing-evidence.json");
|
|
211
|
+
fs.writeFileSync(evidenceFile, `${JSON.stringify({ schema: 1, step: name, ...(host === "mac" ? { identifier, team: SIGN_TEAM } : { subject: SIGN_SUBJECT }), files: evidence }, null, 2)}\n`);
|
|
212
|
+
record({ status: "ran", step: name, signing: evidence });
|
|
213
|
+
// Every signed file by its declared path (main binary and every sidecar), for the consumer that is handed the signed bytes.
|
|
214
|
+
return { appBinary: mainIndex >= 0 ? copies[mainIndex].file : null, signedFiles: Object.fromEntries(copies.map((copy) => [copy.path, copy.file])), evidence, evidenceFile, signedDir };
|
|
215
|
+
} catch (error) {
|
|
216
|
+
// A step that failed leaves no signed copy behind.
|
|
217
|
+
if (signedDir) { try { fs.rmSync(signedDir, { recursive: true, force: true }); } catch { /* the managed root is pruned */ } }
|
|
218
|
+
if (error instanceof SuiteSigningError) { try { record({ status: "refused", step: name, code: error.code, message: error.message }); } catch { /* the failure is thrown either way */ } }
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export { QA_APP_BINARY_ENV, QA_SIGNED_FILES_ENV };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// GB-67: SwiftPM's build state belongs to the broker. For a snapshot-bound `swift build` through the registered swift adapter RustRight appends ONE
|
|
2
|
+
// `--scratch-path <outputDir>/swiftpm/<sha256 of the package's snapshot-relative path>` (the same directory for `build` and `build --show-bin-path` of one
|
|
3
|
+
// package, on every run and platform) and never creates a second `.build`. A caller-supplied `--scratch-path`, `-scratch-path` or `--build-path` (also `=`-spelled) is
|
|
4
|
+
// refused at admission with RK_ADAPTER_ARGV_DENIED and a message naming the canonical route. The gate therefore sends neither, refuses them before anything is sent
|
|
5
|
+
// with the same explanation, and strips nothing silently: the product location is read from `build --show-bin-path`.
|
|
6
|
+
const SCRATCH_OPTIONS = ["--scratch-path", "-scratch-path", "--build-path", "-build-path"];
|
|
7
|
+
export const SWIFT_SCRATCH_REFUSED = "RK_SWIFT_SCRATCH_OWNED";
|
|
8
|
+
|
|
9
|
+
/** The caller-supplied scratch or build-path option in a swift argv (before any `--`), or null. */
|
|
10
|
+
export function swiftScratchOption(words) {
|
|
11
|
+
const end = words.indexOf("--");
|
|
12
|
+
for (const word of end < 0 ? words : words.slice(0, end)) {
|
|
13
|
+
const w = String(word);
|
|
14
|
+
const name = w.includes("=") ? w.slice(0, w.indexOf("=")) : w;
|
|
15
|
+
if (SCRATCH_OPTIONS.includes(name)) return w;
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `{ code, message }` when a swift argv names a scratch or build path the broker owns; null otherwise. */
|
|
21
|
+
export function swiftScratchRefusal(words) {
|
|
22
|
+
const option = swiftScratchOption(words);
|
|
23
|
+
if (option === null) return null;
|
|
24
|
+
return { code: SWIFT_SCRATCH_REFUSED, message: `\`swift ${words.join(" ")}\` passes ${option.split("=")[0]}, but the broker owns SwiftPM's build state (it appends the one managed --scratch-path and never creates a second .build); send \`build\` and \`build --show-bin-path\` without it and read the product location from --show-bin-path; nothing is stripped or rewritten, so nothing was sent` };
|
|
25
|
+
}
|
package/tauri-locked.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The Tauri CLI builds and runs Cargo itself, and an unlocked build can rewrite Cargo.lock inside the snapshot of a commit-only build.
|
|
2
|
+
// RustRight refuses a grant-minted `cargo build` or `cargo run` that carries neither `--locked` nor `--frozen` (rightkit#1301), so in a
|
|
3
|
+
// snapshot stage with a carrier every `tauri build` and `tauri dev` step gets `--locked` forwarded to Cargo.
|
|
4
|
+
//
|
|
5
|
+
// tauri-cli 2.12.4 (confirmed from its source by the coordinator; the exact forwarding form is PENDING RustRight's confirmation):
|
|
6
|
+
// tauri build -- --locked puts --locked on `cargo build` (before --bins), both universal-mac builds included;
|
|
7
|
+
// tauri dev -- --locked puts it on `cargo run`, before the trailing `--` that run_dev always appends;
|
|
8
|
+
// tauri dev -- --locked -- <appArgs> when app arguments are passed, --locked stays before the second `--`.
|
|
9
|
+
// So `--locked` goes right after the first `--` (added with its own `--` when the step has none). A step that already names `--locked`
|
|
10
|
+
// or `--frozen` among the Cargo arguments is left as it is.
|
|
11
|
+
import { carriedCargoMapOf } from "./carried-cargo.mjs";
|
|
12
|
+
|
|
13
|
+
/** `args` of a step that may run the Tauri CLI, with `--locked` forwarded to Cargo in a carried stage; unchanged otherwise. */
|
|
14
|
+
export function lockedTauriArgs(args, env = process.env) {
|
|
15
|
+
const list = args.map(String);
|
|
16
|
+
if (!carriedCargoMapOf(env)?.map) return args;
|
|
17
|
+
const at = list.indexOf("tauri");
|
|
18
|
+
if (at < 0 || !["build", "dev"].includes(list[at + 1])) return args;
|
|
19
|
+
const separator = list.indexOf("--", at + 2);
|
|
20
|
+
if (separator < 0) return [...list, "--", "--locked"];
|
|
21
|
+
const own = list.slice(separator + 1);
|
|
22
|
+
const end = own.indexOf("--");
|
|
23
|
+
if ((end < 0 ? own : own.slice(0, end)).some((w) => w === "--locked" || w === "--frozen")) return args;
|
|
24
|
+
return [...list.slice(0, separator + 1), "--locked", ...own];
|
|
25
|
+
}
|
package/test-command.mjs
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
// // Expands to journeys-1, journeys-2, ...: the listed journeys run `chunk` at a time in separate processes.
|
|
14
14
|
// { name: "journeys", node: "scripts/qa-browser-functional.mjs", args: ["--keep-going"],
|
|
15
15
|
// journeys: { file: "scripts/qa-e2e-journeys.json", key: "journeys", chunk: 12, flag: "--journey" } },
|
|
16
|
+
// // A sign step (suite-signing.mjs, rightkit#1303): signs what an EARLIER suite built, in-process, with the product's one identity, then
|
|
17
|
+
// // sets RIGHTKIT_QA_APP_BINARY to the signed copy of the main binary for the suites after it. It is never carried and has no cmd or node.
|
|
18
|
+
// { name: "sign-app", sign: { files: [{ from: "build-app", path: "aarch64-apple-darwin/debug/heardright" }] } },
|
|
16
19
|
// ],
|
|
17
20
|
// }
|
|
18
21
|
//
|
|
@@ -21,13 +24,18 @@
|
|
|
21
24
|
// from the app root. `--suite journeys` selects every chunk of that group. `--runner vitest -- run --project unit` runs
|
|
22
25
|
// the runner once with the arguments after `--` (the watch or one-off case); `-- <args>` after `--suite` is allowed when
|
|
23
26
|
// exactly one suite is selected. Exit code: 0 when every selected suite passed, else the failing suite's exit code.
|
|
24
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
25
28
|
import { buildLaunch } from "./build-launch.mjs";
|
|
26
29
|
import path from "node:path";
|
|
27
30
|
import { pathToFileURL } from "node:url";
|
|
28
31
|
import { spawn } from "./background-process.mjs";
|
|
32
|
+
import { carriedCargoMapOf, carriedLedgerIds, carriedOutputDirsSince } from "./carried-cargo.mjs";
|
|
29
33
|
import { CommandSpecError, loadAppConfig, resolveInvocation, validateStep } from "./command-spec.mjs";
|
|
30
34
|
import { defaultReleaseConfigFile } from "./release-config.mjs";
|
|
35
|
+
import { fsListRustFiles } from "./signing-files.mjs";
|
|
36
|
+
import { journeysCapabilityRefusal, journeysRunRefusal, qaPrebuiltCapability } from "./qa-journeys-prebuilt.mjs";
|
|
37
|
+
import { SIGN_STEP_KEYS, QA_APP_BINARY_ENV, QA_SIGNING_EVIDENCE_ENV, QA_SIGNED_FILES_ENV, SuiteSigningError, assertPlanSigningPolicy, assertSuiteSigningPolicy, validateSignSpec } from "./suite-signing-policy.mjs";
|
|
38
|
+
import { runSignStep } from "./suite-signing.mjs";
|
|
31
39
|
|
|
32
40
|
const fail = (message) => { throw new CommandSpecError(message); };
|
|
33
41
|
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -55,7 +63,7 @@ export function parseTestArgs(argv) {
|
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
function mergeRunner(runner, entry) {
|
|
58
|
-
const { name: _name, runner: _runner, journeys: _journeys, ...own } = entry;
|
|
66
|
+
const { name: _name, runner: _runner, journeys: _journeys, requires: _requires, ...own } = entry;
|
|
59
67
|
return {
|
|
60
68
|
...runner,
|
|
61
69
|
...own,
|
|
@@ -110,9 +118,25 @@ export function planTests(config, { suites = [], runner, passthrough = [], appRo
|
|
|
110
118
|
if (!Array.isArray(tests.suites) || tests.suites.length === 0) fail("tests.suites must be a non-empty list");
|
|
111
119
|
const planned = [];
|
|
112
120
|
const seen = new Set();
|
|
121
|
+
const declared = new Set();
|
|
113
122
|
tests.suites.forEach((entry, index) => {
|
|
114
123
|
const label = `tests.suites[${index}]`;
|
|
115
|
-
|
|
124
|
+
// A sign step is data, not a command: `{ name, sign: { files: [{ from, path }] } }`, judged by the policy and run in-process (runSuites).
|
|
125
|
+
if (isObject(entry) && entry.sign !== undefined) {
|
|
126
|
+
const extra = Object.keys(entry).filter((key) => !SIGN_STEP_KEYS.includes(key));
|
|
127
|
+
if (extra.length) throw new SuiteSigningError("SIGN_STEP_SHAPE", `${label} is a sign step and has unknown key(s): ${extra.join(", ")} (allowed: ${SIGN_STEP_KEYS.join(", ")}); it has no cmd, node, args or env`);
|
|
128
|
+
if (typeof entry.name !== "string" || !NAME.test(entry.name)) fail(`${label}.name must be set (letters, digits, . _ -)`);
|
|
129
|
+
if (seen.has(entry.name) || declared.has(entry.name)) fail(`${label}: duplicate suite name ${entry.name}`);
|
|
130
|
+
const sign = validateSignSpec(entry.sign, `${label}.sign`, declared);
|
|
131
|
+
seen.add(entry.name); declared.add(entry.name);
|
|
132
|
+
// A sign step needs the suites whose outputs it signs (GB-44): if one of them failed or was skipped, there is nothing to sign.
|
|
133
|
+
planned.push({ name: entry.name, group: entry.name, sign, step: null, extraArgs: [], requires: [...new Set(sign.files.map((file) => file.from))] });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
validateStep(entry, label, ["name", "runner", "journeys", "requires"]);
|
|
137
|
+
// `requires` (GB-44): the earlier suites (by name or group) whose success this suite's inputs depend on; it is skipped, with the reason recorded,
|
|
138
|
+
// when one of them failed or was skipped, while every independent suite still runs.
|
|
139
|
+
if (entry.requires !== undefined && (!Array.isArray(entry.requires) || entry.requires.some((r) => typeof r !== "string" || !declared.has(r)))) fail(`${label}.requires must be a list of earlier suite names`);
|
|
116
140
|
if (typeof entry.name !== "string" || !NAME.test(entry.name)) fail(`${label}.name must be set (letters, digits, . _ -)`);
|
|
117
141
|
if (entry.runner !== undefined && !runners[entry.runner]) fail(`${label}.runner names an undeclared runner: ${entry.runner}`);
|
|
118
142
|
const base = mergeRunner(entry.runner === undefined ? undefined : runners[entry.runner], entry);
|
|
@@ -120,10 +144,12 @@ export function planTests(config, { suites = [], runner, passthrough = [], appRo
|
|
|
120
144
|
const variants = entry.journeys === undefined
|
|
121
145
|
? [{ name: entry.name, extra: [] }]
|
|
122
146
|
: expandJourneys(entry.journeys, label, appRoot).map((extra, chunk) => ({ name: `${entry.name}-${chunk + 1}`, extra }));
|
|
147
|
+
if (declared.has(entry.name)) fail(`${label}: duplicate suite name ${entry.name}`);
|
|
148
|
+
declared.add(entry.name);
|
|
123
149
|
for (const variant of variants) {
|
|
124
150
|
if (seen.has(variant.name)) fail(`${label}: duplicate suite name ${variant.name}`);
|
|
125
151
|
seen.add(variant.name);
|
|
126
|
-
planned.push({ name: variant.name, group: entry.name, step: { ...base, args: [...base.args, ...variant.extra] }, extraArgs: [] });
|
|
152
|
+
planned.push({ name: variant.name, group: entry.name, step: { ...base, args: [...base.args, ...variant.extra] }, extraArgs: [], ...(Array.isArray(entry.requires) ? { requires: [...entry.requires] } : {}) });
|
|
127
153
|
}
|
|
128
154
|
});
|
|
129
155
|
let selected = planned;
|
|
@@ -134,11 +160,21 @@ export function planTests(config, { suites = [], runner, passthrough = [], appRo
|
|
|
134
160
|
}
|
|
135
161
|
if (passthrough.length) {
|
|
136
162
|
if (selected.length !== 1) fail("arguments after -- need exactly one selected suite (or use --runner)");
|
|
163
|
+
if (selected[0].sign) fail("arguments after -- cannot go to a sign step");
|
|
137
164
|
selected = [{ ...selected[0], extraArgs: [...passthrough] }];
|
|
138
165
|
}
|
|
139
166
|
return selected;
|
|
140
167
|
}
|
|
141
168
|
|
|
169
|
+
/** The refusal code of a planned `rightkit-qa journeys run` suite (GB-79). */
|
|
170
|
+
export const QA_JOURNEYS_UNSUPPORTED = "RK_QA_JOURNEYS_PREBUILT_UNSUPPORTED";
|
|
171
|
+
/** True when a suite step is `rightkit-qa journeys run ...` (by command or by node script name, extra args included). */
|
|
172
|
+
export function qaJourneysRunStep(step, extraArgs = []) {
|
|
173
|
+
const words = [step.cmd ?? step.node, ...(step.args ?? []), ...extraArgs].filter((w) => typeof w === "string").map((w) => w.replaceAll("\\", "/"));
|
|
174
|
+
const at = words.findIndex((w) => /(^|\/)rightkit-qa(\.exe)?$/.test(w));
|
|
175
|
+
return at >= 0 && words[at + 1] === "journeys" && words[at + 2] === "run";
|
|
176
|
+
}
|
|
177
|
+
|
|
142
178
|
export function eventPrefix(config) {
|
|
143
179
|
const prefix = config?.tests?.eventPrefix ?? "test";
|
|
144
180
|
if (typeof prefix !== "string" || !PREFIX.test(prefix)) fail("tests.eventPrefix must be lower-case letters, digits and underscores");
|
|
@@ -146,31 +182,130 @@ export function eventPrefix(config) {
|
|
|
146
182
|
}
|
|
147
183
|
|
|
148
184
|
/**
|
|
149
|
-
* Run the planned suites in order
|
|
150
|
-
*
|
|
185
|
+
* Run the planned suites in order, collecting every reachable failure (GB-44): a failed suite does not stop an independent one, a suite that requires a
|
|
186
|
+
* failed or skipped one is skipped with its reason, and the run ends non-zero. `spawnFn` and `log` are injectable.
|
|
187
|
+
* Resolves `{ exitCode, results: [{ suite, exitCode, durationMs, skipped?, reason?, error? }] }`; a signal (cancellation) rejects.
|
|
188
|
+
* A `sign` step (suite-signing.mjs) runs IN THIS PROCESS, never through `door` (it is no command and never a broker job); it signs what an earlier
|
|
189
|
+
* suite built, named by that suite's verified carried receipts, and only a success sets RIGHTKIT_QA_APP_BINARY for the suites after it.
|
|
151
190
|
*/
|
|
152
|
-
export async function runSuites(plan, { appRoot, prefix = "test", env = process.env, spawnFn = spawn, log = console.log, door = buildLaunch } = {}) {
|
|
191
|
+
export async function runSuites(plan, { appRoot, prefix = "test", env = process.env, spawnFn = spawn, log = console.log, errLog = console.error, door = buildLaunch, config = null, platform = process.platform, signDeps = {}, signStep = runSignStep, snapshotStage = Boolean(carriedCargoMapOf(env)?.map), qaCapability = (qa) => qaPrebuiltCapability(qa, { env }) } = {}) {
|
|
153
192
|
const results = [];
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
193
|
+
// One signing policy, enforced here whatever the config's format (the route probe reads JSON configs only; a legacy .mjs config is judged now): an
|
|
194
|
+
// unsigned or ad hoc product build, or an identifier override, is refused before any suite runs (rightkit#1303). Mac and Dell only.
|
|
195
|
+
try {
|
|
196
|
+
const readRel = (rel) => { try { return readFileSync(path.resolve(appRoot, rel), "utf8"); } catch { return null; } };
|
|
197
|
+
const listFiles = fsListRustFiles(appRoot);
|
|
198
|
+
assertSuiteSigningPolicy({ config, platform, env, to: "test", readFile: readRel, listFiles });
|
|
199
|
+
// And the plan that actually runs: runner mode, a selected subset and `-- <args>` passthrough are judged too (rightkit#1303 H1).
|
|
200
|
+
assertPlanSigningPolicy({ config, plan, platform, env, readFile: readRel, listFiles });
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (!(error instanceof SuiteSigningError)) throw error;
|
|
203
|
+
errLog(`right-release test: [${error.code}] ${error.message}`);
|
|
204
|
+
return { exitCode: 1, results };
|
|
205
|
+
}
|
|
206
|
+
// GB-79 (GATE test stage/E1/E6/E11): `rightkit-qa journeys run` spawns its own broker build, which a gate-carried snapshot stage cannot allow. When the installed rightkit-qa
|
|
207
|
+
// advertises `journeys-prebuilt-input` the door carries the build itself, signs the products and hands QA the verified listing and attestation (qa-journeys-prebuilt.mjs).
|
|
208
|
+
// Without the capability it is a PLANNING failure naming it, before any suite runs; nothing is silently skipped and an empty journey set is never green. A step that names
|
|
209
|
+
// its own listing, attestation or plan flag is refused too (an input the gate did not verify). Only a gate-carried snapshot stage (a carrier map in the environment) has the
|
|
210
|
+
// one-build rule; a direct host starts it as it always did.
|
|
211
|
+
const journeys = snapshotStage ? plan.filter((item) => !item.sign && item.step && qaJourneysRunStep(item.step, item.extraArgs)) : [];
|
|
212
|
+
const unsupported = [];
|
|
213
|
+
for (const item of journeys) {
|
|
214
|
+
const argv = [item.step.cmd ?? item.step.node, ...(item.step.args ?? []), ...(item.extraArgs ?? [])].filter((w) => typeof w === "string");
|
|
215
|
+
const at = argv.findIndex((w) => /(^|[\\/])rightkit-qa(\.exe)?$/.test(w));
|
|
216
|
+
const own = journeysRunRefusal(argv.slice(at + 1));
|
|
217
|
+
if (own) { errLog(`right-release test: [${QA_JOURNEYS_UNSUPPORTED}] ${item.name}: ${own}. Nothing ran`); unsupported.push(item); continue; }
|
|
218
|
+
if (!qaCapability(argv[at], item.step)) { errLog(`right-release test: [${QA_JOURNEYS_UNSUPPORTED}] ${journeysCapabilityRefusal(item.name)}`); unsupported.push(item); }
|
|
219
|
+
}
|
|
220
|
+
if (unsupported.length) return { exitCode: 1, results };
|
|
221
|
+
// The environment later suites get: the caller's, plus RIGHTKIT_QA_APP_BINARY and RIGHTKIT_QA_SIGNING_EVIDENCE once a sign step succeeded.
|
|
222
|
+
let suiteEnv = env;
|
|
223
|
+
// The verified carried receipts' output directories of each suite that passed, by name and by group (a sign step reads them).
|
|
224
|
+
const outputs = new Map();
|
|
225
|
+
const signedDirs = [];
|
|
226
|
+
// GB-44 (BR29): every reachable failure is collected. A failed or errored suite does not stop its independent siblings; only a suite that
|
|
227
|
+
// REQUIRES a failed or skipped one (a sign step needs the suites it signs; a suite after a sign step needs every earlier sign step unless it declares its own `requires`; an explicit
|
|
228
|
+
// `requires`) is skipped, with the reason recorded. Cancellation (a signal) still stops the whole run, and nothing is signed or launched from a failed build.
|
|
229
|
+
const status = new Map(); // suite name and group -> "passed" | "failed" | "skipped"
|
|
230
|
+
const signsSoFar = [];
|
|
231
|
+
// What a suite waits for. A sign step waits for the suites it signs. Any other suite positioned after a sign step is by default a CONSUMER of its
|
|
232
|
+
// signed output (a QA runner, a launcher: nothing in its command line shows what its script reads), so it waits for every earlier sign step too;
|
|
233
|
+
// a suite that declares `requires` (even an empty list) states its own dependencies instead and so keeps running for its diagnostics.
|
|
234
|
+
// Fail closed: nothing that may consume signed bytes runs after a sign step that failed or was skipped.
|
|
235
|
+
const waitsFor = (item) => (item.sign || Array.isArray(item.requires) ? (item.requires ?? []) : signsSoFar);
|
|
236
|
+
const record = (item, outcome) => { for (const key of new Set([item.name, item.group])) status.set(key, outcome); };
|
|
237
|
+
const firstFailure = () => results.find((r) => r.exitCode !== 0 && r.skipped !== true)?.exitCode ?? (results.some((r) => r.skipped) ? 1 : 0);
|
|
238
|
+
try {
|
|
239
|
+
for (const item of plan) {
|
|
240
|
+
const unmet = waitsFor(item).filter((name) => status.has(name) && status.get(name) !== "passed");
|
|
241
|
+
if (unmet.length) {
|
|
242
|
+
const reason = `${[...new Set(unmet)].map((n) => JSON.stringify(n)).join(", ")} did not pass, and ${item.name} ${item.sign ? "signs what it builds" : "needs what it produces"}`;
|
|
243
|
+
errLog(`right-release test: ${item.name}: skipped: ${reason}`);
|
|
244
|
+
log(JSON.stringify({ event: `${prefix}_suite_skipped`, suite: item.name, reason }));
|
|
245
|
+
results.push({ suite: item.name, exitCode: null, skipped: true, reason, durationMs: 0 });
|
|
246
|
+
record(item, "skipped");
|
|
247
|
+
if (item.sign) signsSoFar.push(item.name);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
log(JSON.stringify({ event: `${prefix}_suite_started`, suite: item.name }));
|
|
251
|
+
const started = Date.now();
|
|
252
|
+
if (item.sign) {
|
|
253
|
+
let exitCode = 0;
|
|
254
|
+
try {
|
|
255
|
+
const signed = signStep({ name: item.name, sign: item.sign, appRoot, config, env: suiteEnv, platform, outputDirsOf: (suite) => outputs.get(suite) ?? null, deps: signDeps, log: errLog });
|
|
256
|
+
signedDirs.push(signed.signedDir);
|
|
257
|
+
// The variable is set only now, after every file was signed and read back.
|
|
258
|
+
if (signed.appBinary) suiteEnv = { ...suiteEnv, [QA_APP_BINARY_ENV]: signed.appBinary };
|
|
259
|
+
// The evidence file lives in the signed directory, kept until this run ends: a consumer checks what it was handed against it.
|
|
260
|
+
suiteEnv = { ...suiteEnv, [QA_SIGNING_EVIDENCE_ENV]: signed.evidenceFile, [QA_SIGNED_FILES_ENV]: JSON.stringify(signed.signedFiles ?? {}) };
|
|
261
|
+
log(JSON.stringify({ event: `${prefix}_suite_signed`, suite: item.name, appBinary: signed.appBinary, signing: signed.evidence }));
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (!(error instanceof SuiteSigningError)) throw error;
|
|
264
|
+
errLog(`right-release test: ${item.name}: [${error.code}] ${error.message}`);
|
|
265
|
+
exitCode = 1;
|
|
266
|
+
}
|
|
267
|
+
const durationMs = Date.now() - started;
|
|
268
|
+
log(JSON.stringify({ event: `${prefix}_suite_completed`, suite: item.name, exitCode, durationMs }));
|
|
269
|
+
results.push({ suite: item.name, exitCode, durationMs });
|
|
270
|
+
record(item, exitCode === 0 ? "passed" : "failed");
|
|
271
|
+
signsSoFar.push(item.name);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const invocation = resolveInvocation(item.step, { appRoot, baseEnv: suiteEnv, extraArgs: item.extraArgs });
|
|
275
|
+
const before = carriedLedgerIds(env);
|
|
276
|
+
let exitCode; let failure = null;
|
|
277
|
+
try {
|
|
278
|
+
exitCode = await new Promise((resolve, reject) => {
|
|
279
|
+
// A test suite goes through the one door (build-launch.mjs): a broker job on a managed host, which builds it under the app's claim.
|
|
280
|
+
const target = door(invocation.cmd, invocation.args, { env: invocation.env, stepEnv: item.step.env, cwd: invocation.cwd, exists: existsSync });
|
|
281
|
+
const child = spawnFn(target.command, target.args, { cwd: invocation.cwd, env: target.env ? { ...invocation.env, ...target.env } : invocation.env, stdio: "inherit" });
|
|
282
|
+
child.once("error", (error) => reject(Object.assign(error, { spawnFailure: true })));
|
|
283
|
+
child.once("exit", (code, signal) => {
|
|
284
|
+
if (signal) reject(Object.assign(new Error(`${item.name} stopped by ${signal}`), { cancelled: true }));
|
|
285
|
+
else resolve(code ?? 1);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
} catch (error) {
|
|
289
|
+
// A suite that cannot start (a refused launch, a spawn error) is a failed suite; a signal is a cancellation and ends the run.
|
|
290
|
+
if (error?.cancelled) throw error;
|
|
291
|
+
failure = error; exitCode = 1;
|
|
292
|
+
errLog(`right-release test: ${item.name}: ${error?.code ? `[${error.code}] ` : ""}${error?.message ?? error}`);
|
|
293
|
+
}
|
|
294
|
+
const durationMs = Date.now() - started;
|
|
295
|
+
// Only a suite that passed hands its receipts to a sign step.
|
|
296
|
+
if (exitCode === 0) {
|
|
297
|
+
const dirs = carriedOutputDirsSince(env, before);
|
|
298
|
+
for (const key of new Set([item.name, item.group])) outputs.set(key, [...new Set([...(outputs.get(key) ?? []), ...dirs])]);
|
|
299
|
+
}
|
|
300
|
+
log(JSON.stringify({ event: `${prefix}_suite_completed`, suite: item.name, exitCode, durationMs }));
|
|
301
|
+
results.push({ suite: item.name, exitCode, durationMs, ...(failure ? { error: String(failure.message ?? failure) } : {}) });
|
|
302
|
+
record(item, exitCode === 0 ? "passed" : "failed");
|
|
303
|
+
}
|
|
304
|
+
return { exitCode: results.some((r) => r.skipped !== true && r.exitCode !== 0) || results.some((r) => r.skipped) ? firstFailure() : 0, results };
|
|
305
|
+
} finally {
|
|
306
|
+
// The signed copies live only as long as the run that signed them.
|
|
307
|
+
for (const dir of signedDirs) { try { rmSync(dir, { recursive: true, force: true }); } catch { /* the managed root is pruned */ } }
|
|
172
308
|
}
|
|
173
|
-
return { exitCode: 0, results };
|
|
174
309
|
}
|
|
175
310
|
|
|
176
311
|
export async function main(argv = process.argv.slice(2)) {
|
|
@@ -187,12 +322,12 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
187
322
|
}
|
|
188
323
|
const plan = planTests(config, { suites: opts.suites, runner: opts.runner, passthrough: opts.passthrough, appRoot });
|
|
189
324
|
if (opts.list) {
|
|
190
|
-
const view = plan.map((item) => ({ suite: item.name, ...resolveInvocation(item.step, { appRoot, baseEnv: {}, extraArgs: item.extraArgs }), env: undefined }));
|
|
325
|
+
const view = plan.map((item) => (item.sign ? { suite: item.name, sign: item.sign } : { suite: item.name, ...resolveInvocation(item.step, { appRoot, baseEnv: {}, extraArgs: item.extraArgs }), env: undefined }));
|
|
191
326
|
if (opts.json) console.log(JSON.stringify(view, null, 2));
|
|
192
|
-
else for (const item of view) console.log(`${item.suite}: ${[item.cmd, ...item.args].join(" ")}`);
|
|
327
|
+
else for (const item of view) console.log(item.sign ? `${item.suite}: sign ${item.sign.files.map((file) => `${file.from}:${file.path}`).join(", ")}` : `${item.suite}: ${[item.cmd, ...item.args].join(" ")}`);
|
|
193
328
|
return 0;
|
|
194
329
|
}
|
|
195
|
-
const { exitCode } = await runSuites(plan, { appRoot, prefix: eventPrefix(config) });
|
|
330
|
+
const { exitCode } = await runSuites(plan, { appRoot, prefix: eventPrefix(config), config });
|
|
196
331
|
return exitCode;
|
|
197
332
|
}
|
|
198
333
|
|