@rightkit/release 0.6.3 → 0.6.4
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 +37 -5
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +4 -4
- package/cli/right-release.mjs +3 -2
- package/deterministic-scans.mjs +21 -10
- package/development.mjs +3 -2
- package/hosted-workflow-policy.mjs +111 -0
- package/ios-dev.mjs +2 -2
- package/ios-host.mjs +7 -7
- package/ios-package-recipe.mjs +2 -2
- package/local-publish.mjs +1 -1
- package/local-qualify.mjs +3 -3
- package/local-runtime.mjs +1 -1
- package/package.json +1 -1
- package/pipeline-admission.mjs +5 -4
- package/pipeline-receipts.mjs +302 -302
- package/pipeline-scan.mjs +3 -3
- package/pipeline-tools.mjs +175 -171
- package/prebuild-gates.mjs +187 -0
- package/progress-control.mjs +1 -1
- package/publish-update.mjs +4 -3
- package/qa-admission-policy.json +5 -0
- package/release-config.mjs +353 -330
- package/release-qa-audit.mjs +37 -11
- package/release-qa-command.mjs +46 -5
- package/release-qa-fixtures.mjs +127 -0
- package/release-qa-floor.json +6 -1
- package/release-qa-hardening.mjs +173 -0
- package/release-qa-policy.mjs +168 -0
- package/release-qa-receipt.mjs +21 -1
- package/release-switch-scan.mjs +137 -6
- package/release.mjs +244 -197
- package/rightapps-register.mjs +3 -1
- package/rightkit-drift.mjs +1 -1
- package/rightkit-ownership.json +15 -5
- package/rightkit-versions.json +43 -32
- package/stage-sidecar.mjs +17 -15
- package/suite-api-literal.mjs +109 -0
- package/suite-api.generated.mjs +78 -0
- package/swap-helper-bundle.mjs +2 -2
- package/win-package-recipe.mjs +7 -4
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// QA admission policy (ADR-0001 item 10): the per-app warn -> refuse switch and the approved rightkit-qa runner
|
|
2
|
+
// digests, recorded in `qa-admission-policy.json` next to this module.
|
|
3
|
+
//
|
|
4
|
+
// { "schema": 1,
|
|
5
|
+
// "apps": { "<app>": { "mode": "warn" | "refuse", "since": "YYYY-MM-DD", "set_by": "<name>" } },
|
|
6
|
+
// "approvedRunners": [ { "sha256": "<64 hex>", "version": "<rightkit-qa version>", "note": "..." } ] }
|
|
7
|
+
//
|
|
8
|
+
// An app with no entry is in `warn` mode (default). In `refuse` mode `admit()` throws instead of warning.
|
|
9
|
+
//
|
|
10
|
+
// Only the owner (`adrian`) may move an app back from `refuse` to `warn` (or delete its entry). What is checkable
|
|
11
|
+
// here, and enforced:
|
|
12
|
+
// - every write through `setAdmissionMode` goes through `checkPolicyTransition`, which refuses a downgrade whose
|
|
13
|
+
// new entry is not `set_by: "adrian"`;
|
|
14
|
+
// - every load compares the working file with the copy committed at `baselineRef` (default HEAD) and refuses a
|
|
15
|
+
// hand-edited downgrade the same way, so an uncommitted edit cannot quietly switch an app off refuse;
|
|
16
|
+
// - an unreadable or malformed policy file is a hard error, never a silent fall back to warn.
|
|
17
|
+
// What is NOT checkable from this file: `set_by` is a recorded claim, not an authenticated identity, and a
|
|
18
|
+
// downgrade that was already committed is the new baseline. The file must therefore be owner-protected in the
|
|
19
|
+
// repository (CODEOWNERS on this path plus branch protection, and the file belongs in the protected policy bundle,
|
|
20
|
+
// see ownership-policy-bundle.mjs). CI should set RIGHT_RELEASE_QA_POLICY_BASELINE_REF=origin/main so the baseline
|
|
21
|
+
// is the protected branch rather than the local HEAD.
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
26
|
+
import { spawnSync } from "./background-process.mjs";
|
|
27
|
+
import { canonicalJson } from "./ownership-approval.mjs";
|
|
28
|
+
|
|
29
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const HEX64 = /^[a-f0-9]{64}$/;
|
|
31
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
32
|
+
|
|
33
|
+
export const QA_ADMISSION_POLICY_FILE = "qa-admission-policy.json";
|
|
34
|
+
export const QA_POLICY_BASELINE_REF_ENV = "RIGHT_RELEASE_QA_POLICY_BASELINE_REF";
|
|
35
|
+
export const ADMISSION_MODES = Object.freeze(["warn", "refuse"]);
|
|
36
|
+
/** The only `set_by` that may move an app from refuse back to warn. */
|
|
37
|
+
export const POLICY_OWNER = "adrian";
|
|
38
|
+
|
|
39
|
+
export class QaPolicyError extends Error {
|
|
40
|
+
constructor(message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "QaPolicyError";
|
|
43
|
+
this.code = "QA_POLICY_INVALID";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function validateAdmissionPolicy(policy) {
|
|
48
|
+
const problems = [];
|
|
49
|
+
if (!policy || typeof policy !== "object" || Array.isArray(policy)) return ["policy must be an object"];
|
|
50
|
+
if (policy.schema !== 1) problems.push("policy schema must be 1");
|
|
51
|
+
if (!policy.apps || typeof policy.apps !== "object" || Array.isArray(policy.apps)) problems.push("policy apps must be an object");
|
|
52
|
+
else {
|
|
53
|
+
for (const [app, entry] of Object.entries(policy.apps)) {
|
|
54
|
+
if (!app.trim()) problems.push("policy app name is empty");
|
|
55
|
+
if (!entry || typeof entry !== "object") { problems.push(`policy app ${app} must be an object`); continue; }
|
|
56
|
+
if (!ADMISSION_MODES.includes(entry.mode)) problems.push(`policy app ${app} mode must be warn or refuse`);
|
|
57
|
+
if (typeof entry.since !== "string" || !DATE.test(entry.since)) problems.push(`policy app ${app} since must be YYYY-MM-DD`);
|
|
58
|
+
if (typeof entry.set_by !== "string" || !entry.set_by.trim()) problems.push(`policy app ${app} set_by is required`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!Array.isArray(policy.approvedRunners)) problems.push("policy approvedRunners must be an array");
|
|
62
|
+
else {
|
|
63
|
+
for (const runner of policy.approvedRunners) {
|
|
64
|
+
if (!runner || typeof runner.sha256 !== "string" || !HEX64.test(runner.sha256)) problems.push("approvedRunners entries need a lowercase 64-hex sha256");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return problems;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Problems when `next` weakens `previous`: a refuse entry that is gone or now warn without `set_by: "adrian"`. */
|
|
71
|
+
export function checkPolicyTransition(previous, next) {
|
|
72
|
+
const problems = [];
|
|
73
|
+
for (const [app, before] of Object.entries(previous?.apps ?? {})) {
|
|
74
|
+
if (before?.mode !== "refuse") continue;
|
|
75
|
+
const after = next?.apps?.[app];
|
|
76
|
+
if (!after) problems.push(`${app}: the refuse entry was removed; only ${POLICY_OWNER} may move an app back to warn (set mode warn with set_by "${POLICY_OWNER}")`);
|
|
77
|
+
else if (after.mode !== "refuse" && after.set_by !== POLICY_OWNER) problems.push(`${app}: refuse -> warn requires set_by "${POLICY_OWNER}", got ${JSON.stringify(after.set_by)}`);
|
|
78
|
+
}
|
|
79
|
+
return problems;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const digestOf = (policy) => createHash("sha256").update(canonicalJson(policy)).digest("hex");
|
|
83
|
+
|
|
84
|
+
function parsePolicyText(text, label) {
|
|
85
|
+
let policy;
|
|
86
|
+
try { policy = JSON.parse(text); } catch (error) { throw new QaPolicyError(`QA admission policy is unreadable (${label}): ${error.message}`); }
|
|
87
|
+
const problems = validateAdmissionPolicy(policy);
|
|
88
|
+
if (problems.length) throw new QaPolicyError(`QA admission policy is invalid (${label}): ${problems.join("; ")}`);
|
|
89
|
+
return policy;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function gitBaselineText({ dir, ref, runner = spawnSync }) {
|
|
93
|
+
try {
|
|
94
|
+
const result = runner("git", ["-C", dir, "show", `${ref}:./${QA_ADMISSION_POLICY_FILE}`], { encoding: "utf8", windowsHide: true });
|
|
95
|
+
return result?.status === 0 ? String(result.stdout) : null;
|
|
96
|
+
} catch { return null; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Load the policy. `baseline` (policy text) overrides the git lookup; `baseline: false` disables the comparison.
|
|
101
|
+
* Returns { policy, digest, baseline: "supplied" | "git" | "none" }.
|
|
102
|
+
*/
|
|
103
|
+
export function loadAdmissionPolicy({ dir = here, baseline, baselineRef, env = process.env, gitRunner } = {}) {
|
|
104
|
+
const file = path.join(dir, QA_ADMISSION_POLICY_FILE);
|
|
105
|
+
if (!existsSync(file)) throw new QaPolicyError(`QA admission policy is missing (${QA_ADMISSION_POLICY_FILE}); it ships with @rightkit/release and is never optional`);
|
|
106
|
+
const policy = parsePolicyText(readFileSync(file, "utf8"), QA_ADMISSION_POLICY_FILE);
|
|
107
|
+
let source = "none";
|
|
108
|
+
let baselineText = null;
|
|
109
|
+
if (typeof baseline === "string") { baselineText = baseline; source = "supplied"; }
|
|
110
|
+
else if (baseline !== false) {
|
|
111
|
+
baselineText = gitBaselineText({ dir, ref: baselineRef ?? env?.[QA_POLICY_BASELINE_REF_ENV] ?? "HEAD", runner: gitRunner });
|
|
112
|
+
if (baselineText !== null) source = "git";
|
|
113
|
+
}
|
|
114
|
+
if (baselineText !== null) {
|
|
115
|
+
const problems = checkPolicyTransition(parsePolicyText(baselineText, "baseline"), policy);
|
|
116
|
+
if (problems.length) throw new QaPolicyError(`QA admission policy weakens its baseline: ${problems.join("; ")}`);
|
|
117
|
+
}
|
|
118
|
+
return { policy, digest: digestOf(policy), baseline: source };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The mode for an app; an app with no entry is `warn`. */
|
|
122
|
+
export function admissionModeFor(policy, app) {
|
|
123
|
+
const entry = policy?.apps?.[app];
|
|
124
|
+
return entry?.mode === "refuse" ? { mode: "refuse", since: entry.since, set_by: entry.set_by } : { mode: "warn", since: entry?.since ?? null, set_by: entry?.set_by ?? null };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const approvedRunnerDigests = (policy) => (policy?.approvedRunners ?? []).map((runner) => runner.sha256);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Record an app's mode. A downgrade (refuse -> warn) is refused unless `setBy` is the owner. Writes atomically.
|
|
131
|
+
* The file is read without the git baseline here (the transition rule is applied directly to what is on disk).
|
|
132
|
+
*/
|
|
133
|
+
export function setAdmissionMode({ app, mode, setBy, dir = here, now = () => new Date() }) {
|
|
134
|
+
if (!app || !String(app).trim()) throw new QaPolicyError("app is required");
|
|
135
|
+
if (!ADMISSION_MODES.includes(mode)) throw new QaPolicyError("mode must be warn or refuse");
|
|
136
|
+
if (!setBy || !String(setBy).trim()) throw new QaPolicyError("set_by is required");
|
|
137
|
+
const file = path.join(dir, QA_ADMISSION_POLICY_FILE);
|
|
138
|
+
const current = parsePolicyText(readFileSync(file, "utf8"), QA_ADMISSION_POLICY_FILE);
|
|
139
|
+
const next = { ...current, apps: { ...current.apps, [app]: { mode, since: now().toISOString().slice(0, 10), set_by: String(setBy) } } };
|
|
140
|
+
const problems = checkPolicyTransition(current, next);
|
|
141
|
+
if (problems.length) throw new QaPolicyError(`QA admission policy change refused: ${problems.join("; ")}`);
|
|
142
|
+
const sorted = { ...next, apps: Object.fromEntries(Object.entries(next.apps).sort(([a], [b]) => a.localeCompare(b))) };
|
|
143
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
144
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
145
|
+
writeFileSync(temp, `${JSON.stringify(sorted, null, 2)}\n`);
|
|
146
|
+
renameSync(temp, file);
|
|
147
|
+
return sorted.apps[app];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// `node release-qa-policy.mjs show | set --app <app> --mode warn|refuse --set-by <name>`
|
|
151
|
+
export function main(args, { stdout = (text) => console.log(text), stderr = (text) => console.error(text) } = {}) {
|
|
152
|
+
const value = (flag) => { const index = args.indexOf(flag); return index >= 0 ? args[index + 1] : undefined; };
|
|
153
|
+
try {
|
|
154
|
+
if (args[0] === "show") { stdout(JSON.stringify(loadAdmissionPolicy().policy, null, 2)); return 0; }
|
|
155
|
+
if (args[0] === "set") {
|
|
156
|
+
const entry = setAdmissionMode({ app: value("--app"), mode: value("--mode"), setBy: value("--set-by") });
|
|
157
|
+
stdout(`qa admission: ${value("--app")} -> ${entry.mode} (since ${entry.since}, set_by ${entry.set_by})`);
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
stderr("usage: release-qa-policy.mjs show | set --app <app> --mode warn|refuse --set-by <name>");
|
|
161
|
+
return 2;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
stderr(`release-qa-policy: ${error.message}`);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exit(main(process.argv.slice(2)));
|
package/release-qa-receipt.mjs
CHANGED
|
@@ -85,6 +85,17 @@ export function validateQaFloor(floor) {
|
|
|
85
85
|
const required = floor.stages?.[stage]?.required;
|
|
86
86
|
if (!Array.isArray(required) || required.length === 0 || required.some((id) => typeof id !== "string" || !id)) problems.push(`floor stage ${stage} must list required check ids`);
|
|
87
87
|
}
|
|
88
|
+
// `journeys` maps one floor id to "every release-critical journey required on the platform passed", proven by a
|
|
89
|
+
// rightkit.journeys.receipt/1 validated through rightkit-qa (release-qa-hardening.mjs).
|
|
90
|
+
const journeys = floor.journeys;
|
|
91
|
+
if (journeys !== undefined) {
|
|
92
|
+
if (!journeys || typeof journeys !== "object" || Array.isArray(journeys)) problems.push("floor journeys must be an object");
|
|
93
|
+
else {
|
|
94
|
+
if (typeof journeys.floorId !== "string" || !QA_STAGES.every((stage) => floor.stages?.[stage]?.required?.includes?.(journeys.floorId))) problems.push("floor journeys.floorId must be a required id of every stage");
|
|
95
|
+
if (journeys.receiptSchema !== "rightkit.journeys.receipt/1") problems.push("floor journeys.receiptSchema must be rightkit.journeys.receipt/1");
|
|
96
|
+
if (journeys.rule !== "all-release-critical-passed") problems.push("floor journeys.rule must be all-release-critical-passed");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
88
99
|
return problems;
|
|
89
100
|
}
|
|
90
101
|
|
|
@@ -95,6 +106,11 @@ export function validateAppQaConfig(qa, floor, stage) {
|
|
|
95
106
|
if (typeof qa.version !== "string" && typeof qa.version !== "number") problems.push("qa.version is required");
|
|
96
107
|
if (typeof qa.config !== "string" || !qa.config) problems.push("qa.config (rightkit-qa.toml path) is required");
|
|
97
108
|
for (const id of floor.stages[stage].required) {
|
|
109
|
+
// The journeys receipt proves this id; no evidence-check mapping is needed (or used) for it.
|
|
110
|
+
if (floor.journeys?.floorId === id && qa.journeys) {
|
|
111
|
+
if (typeof qa.journeys !== "object" || Array.isArray(qa.journeys)) problems.push("qa.journeys must be an object { manifest?, receipt?, args? }");
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
98
114
|
const patterns = qa.checks?.[id];
|
|
99
115
|
if (!Array.isArray(patterns) || patterns.length === 0 || patterns.some((p) => typeof p !== "string" || !p)) problems.push(`qa.checks must map required floor check "${id}" to evidence check names`);
|
|
100
116
|
}
|
|
@@ -179,7 +195,7 @@ export function receiptDigest(receipt) {
|
|
|
179
195
|
|
|
180
196
|
export function buildQaReceipt({
|
|
181
197
|
stage, app, platform, releaseId, source, closure, releaseConfigSha256, qa, floor, floorDigest, policyBundleDigest,
|
|
182
|
-
runner, evidence, evidenceSha256, required, artifacts = [], sourceReceiptDigest = null, now = new Date(),
|
|
198
|
+
runner, evidence, evidenceSha256, required, artifacts = [], sourceReceiptDigest = null, now = new Date(), journeys = null, execution = null,
|
|
183
199
|
}) {
|
|
184
200
|
const evidenceOk = evidence?.status === "passed" && Boolean(evidence?.finishedAt);
|
|
185
201
|
const status = evidenceOk && required.every((item) => item.status === "passed") ? "passed" : "failed";
|
|
@@ -195,6 +211,8 @@ export function buildQaReceipt({
|
|
|
195
211
|
evidence: { sha256: evidenceSha256, runId: evidence?.runId ?? null, status: evidence?.status ?? null, schemaVersion: evidence?.schemaVersion ?? null },
|
|
196
212
|
required, checks: (evidence?.checks ?? []).map((check) => ({ name: String(check.name), status: String(check.status) })),
|
|
197
213
|
artifacts, sourceReceiptDigest, status, createdAt: now.toISOString(),
|
|
214
|
+
...(journeys ? { journeys } : {}),
|
|
215
|
+
...(execution ? { execution } : {}),
|
|
198
216
|
};
|
|
199
217
|
}
|
|
200
218
|
|
|
@@ -272,6 +290,7 @@ export function assertSourceQaAdmission({ repoRoot, releaseId, platform, configN
|
|
|
272
290
|
if (carried && (receipt.releaseId !== releaseId || receipt.platform !== platform)) {
|
|
273
291
|
throw new QaAdmissionError(`source QA receipt rejected: carried receipt is for ${receipt.releaseId} (${receipt.platform}), not ${releaseId} (${platform})`, { command });
|
|
274
292
|
}
|
|
293
|
+
opts.afterVerify?.(receipt); // hardening hook (release-qa-hardening.mjs); throws QaAdmissionError
|
|
275
294
|
return { digest: receipt.digest, floorDigest: receipt.floor.digest };
|
|
276
295
|
}
|
|
277
296
|
|
|
@@ -293,5 +312,6 @@ export function assertInstalledQaAdmission({ repoRoot, manifest, platform, confi
|
|
|
293
312
|
});
|
|
294
313
|
if (!verdict.ok) throw new QaAdmissionError(`installed QA receipt rejected: ${verdict.reason}`, { command });
|
|
295
314
|
if (receipt.commit !== manifest.commit) throw new QaAdmissionError("installed QA receipt commit differs from the sealed release commit", { command });
|
|
315
|
+
opts.afterVerify?.(receipt);
|
|
296
316
|
return { digest: receipt.digest };
|
|
297
317
|
}
|
package/release-switch-scan.mjs
CHANGED
|
@@ -370,7 +370,7 @@ const DYNAMIC_READS = [
|
|
|
370
370
|
// Builtins every JS runtime sets; they select nothing and are not part of the inventory.
|
|
371
371
|
const JS_BUILTINS = new Set(["MODE", "DEV", "PROD", "BASE_URL", "SSR", "NODE_ENV"]);
|
|
372
372
|
|
|
373
|
-
function* walkSource(root) {
|
|
373
|
+
function* walkSource(root, skipAbs = new Set()) {
|
|
374
374
|
const stack = [root];
|
|
375
375
|
while (stack.length) {
|
|
376
376
|
const dir = stack.pop();
|
|
@@ -379,7 +379,7 @@ function* walkSource(root) {
|
|
|
379
379
|
for (const entry of entries) {
|
|
380
380
|
const full = path.join(dir, entry.name);
|
|
381
381
|
if (entry.isSymbolicLink()) continue;
|
|
382
|
-
if (entry.isDirectory()) { if (!skipDir(entry.name)) stack.push(full); continue; }
|
|
382
|
+
if (entry.isDirectory()) { if (!skipDir(entry.name) && !skipAbs.has(full)) stack.push(full); continue; }
|
|
383
383
|
if (entry.isFile() && SOURCE_EXT.has(path.extname(entry.name)) && !TEST_FILE.test(entry.name)) yield full;
|
|
384
384
|
}
|
|
385
385
|
}
|
|
@@ -388,7 +388,7 @@ function* walkSource(root) {
|
|
|
388
388
|
function lineOf(text, index) { let n = 1; for (let i = 0; i < index; i += 1) if (text.charCodeAt(i) === 10) n += 1; return n; }
|
|
389
389
|
|
|
390
390
|
// inventoryText: contents of switches.json. Returns findings only.
|
|
391
|
-
export function sourceSwitchFindings({ sourceRoot, sourceRoots, inventoryText, inventoryLabel = "switches.json", extraDeny = [] }) {
|
|
391
|
+
export function sourceSwitchFindings({ sourceRoot, sourceRoots, inventoryText, inventoryLabel = "switches.json", extraDeny = [], skipRoots = [] }) {
|
|
392
392
|
const { doc, issues } = parseInventory(inventoryText, inventoryLabel);
|
|
393
393
|
const findings = [...issues];
|
|
394
394
|
if (!doc || !Array.isArray(doc.switches)) return findings;
|
|
@@ -398,7 +398,9 @@ export function sourceSwitchFindings({ sourceRoot, sourceRoots, inventoryText, i
|
|
|
398
398
|
const denyFlags = [...REGISTRY.denyNames, ...extraDeny.map((n) => ({ name: typeof n === "string" ? n : n.name }))].map((d) => d.name).filter((n) => n.startsWith("-"));
|
|
399
399
|
// `sourceRoots` (absolute directories) narrows the walk to what a target compiles; paths stay relative to `sourceRoot`.
|
|
400
400
|
const walkRoots = Array.isArray(sourceRoots) && sourceRoots.length ? sourceRoots : [sourceRoot];
|
|
401
|
-
|
|
401
|
+
// `skipRoots` (paths relative to `sourceRoot`, validated by `validateNonShipped`) are directories that never ship; the walk does not enter them.
|
|
402
|
+
const skipAbs = new Set((skipRoots ?? []).map((dir) => path.resolve(sourceRoot, dir)));
|
|
403
|
+
for (const file of walkRoots.flatMap((walkRoot) => [...walkSource(walkRoot, skipAbs)])) {
|
|
402
404
|
let text;
|
|
403
405
|
try { text = readFileSync(file, "utf8"); } catch { continue; }
|
|
404
406
|
const rel = path.relative(sourceRoot, file);
|
|
@@ -527,7 +529,7 @@ export function scanSwitchPaths(paths, { denyMap = denyNamesFor() } = {}) {
|
|
|
527
529
|
// Every layer is optional input; a layer given no input is skipped, except that `requireBinary` fails a scan that
|
|
528
530
|
// found no native binary. `bypass: ["feature-closure"]` exists only for the negative fixtures that prove the binary
|
|
529
531
|
// layer independently; release.mjs never passes it.
|
|
530
|
-
export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, sourceRoots, inventoryText, binaryPaths = [], appForbid = [], requireBinary = true, bypass = [], remoteHost, remoteLoopback, devtools } = {}) {
|
|
532
|
+
export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, sourceRoots, skipRoots, inventoryText, binaryPaths = [], appForbid = [], requireBinary = true, bypass = [], remoteHost, remoteLoopback, devtools } = {}) {
|
|
531
533
|
const findings = [];
|
|
532
534
|
const bypassed = new Set(bypass);
|
|
533
535
|
const denyMap = denyNamesFor({ appForbid: [...appForbid, ...(inventoryText != null ? inventoryDenyNames(inventoryText) : [])] });
|
|
@@ -539,7 +541,7 @@ export function releaseSwitchFindings({ treeText, commands = [], sourceRoot, sou
|
|
|
539
541
|
}
|
|
540
542
|
for (const argv of commands) findings.push(...commandSwitchFindings(argv, { allowDevtools: devtools?.mode === "content-only" }));
|
|
541
543
|
}
|
|
542
|
-
if (sourceRoot && inventoryText != null) findings.push(...sourceSwitchFindings({ sourceRoot, sourceRoots, inventoryText, extraDeny: appForbid }));
|
|
544
|
+
if (sourceRoot && inventoryText != null) findings.push(...sourceSwitchFindings({ sourceRoot, sourceRoots, skipRoots, inventoryText, extraDeny: appForbid }));
|
|
543
545
|
let receipt = { files: [], binaries: 0 };
|
|
544
546
|
if (binaryPaths.length) {
|
|
545
547
|
const scanned = scanSwitchPaths(binaryPaths, { denyMap });
|
|
@@ -571,3 +573,132 @@ export function localCrateDirsFromTree(treeText, root) {
|
|
|
571
573
|
}
|
|
572
574
|
return [...dirs].sort();
|
|
573
575
|
}
|
|
576
|
+
|
|
577
|
+
const NON_SHIPPED_SKIP = new Set(["node_modules", "target", ".git"]);
|
|
578
|
+
function hasPackageJson(dir, depth = 0) {
|
|
579
|
+
if (depth > 6) return false;
|
|
580
|
+
let entries;
|
|
581
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return false; }
|
|
582
|
+
for (const entry of entries) {
|
|
583
|
+
if (entry.isFile() && entry.name === "package.json") return true;
|
|
584
|
+
if (entry.isDirectory() && !entry.isSymbolicLink() && !NON_SHIPPED_SKIP.has(entry.name) && hasPackageJson(path.join(dir, entry.name), depth + 1)) return true;
|
|
585
|
+
}
|
|
586
|
+
return false;
|
|
587
|
+
}
|
|
588
|
+
const within = (outer, inner) => inner === outer || inner.startsWith(outer + path.sep);
|
|
589
|
+
|
|
590
|
+
const readJsonSafe = (file) => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } };
|
|
591
|
+
const readTextSafe = (file) => { try { return readFileSync(file, "utf8"); } catch { return ""; } };
|
|
592
|
+
const BUILD_HOOKS = ["beforeBuildCommand", "beforeDevCommand", "beforeBundleCommand"];
|
|
593
|
+
|
|
594
|
+
// Existing paths under `base` named by shell command strings (`node ./scripts/x.mjs && pnpm run y`): each path-like token, and
|
|
595
|
+
// the body of every package.json script reached through `run <name>` / `pnpm <name>`.
|
|
596
|
+
function commandRefs(base, commands, scripts) {
|
|
597
|
+
const refs = new Set();
|
|
598
|
+
const seen = new Set();
|
|
599
|
+
const visit = (command, depth) => {
|
|
600
|
+
for (const raw of String(command).split(/\s+|&&|\|\||[;|]/)) {
|
|
601
|
+
const tokens = [raw.replace(/^['"]+|['"]+$/g, "")];
|
|
602
|
+
if (raw.includes("=")) tokens.push(raw.slice(raw.indexOf("=") + 1).replace(/^['"]+|['"]+$/g, ""));
|
|
603
|
+
for (const token of tokens) {
|
|
604
|
+
if (!token || token.startsWith("-") || /^[a-z][a-z0-9+.-]*:\/\//i.test(token)) continue;
|
|
605
|
+
if (!/[\\/]|\.[A-Za-z0-9]+$/.test(token)) continue;
|
|
606
|
+
const abs = path.resolve(base, token);
|
|
607
|
+
if (abs !== base && within(base, abs) && existsSync(abs)) refs.add(abs);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (depth >= 4) return;
|
|
611
|
+
for (const m of String(command).matchAll(/\b(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?([A-Za-z0-9:_.-]+)/g)) {
|
|
612
|
+
const name = m[1];
|
|
613
|
+
if (Object.hasOwn(scripts, name) && !seen.has(name)) { seen.add(name); visit(scripts[name], depth + 1); }
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
commands.forEach((c) => visit(c, 0));
|
|
617
|
+
return refs;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// The standard root-level frontend (package.json at the app root): src/, public/ and every directory index.html, vite.config.*
|
|
621
|
+
// and tsconfig.json name. Quoted strings in vite.config are read as paths when they exist under the root (root, publicDir, build input).
|
|
622
|
+
function rootFrontendDirs(base) {
|
|
623
|
+
const dirs = new Set();
|
|
624
|
+
const add = (rel) => {
|
|
625
|
+
const abs = path.resolve(base, rel);
|
|
626
|
+
if (abs !== base && within(base, abs) && existsSync(abs)) dirs.add(abs);
|
|
627
|
+
};
|
|
628
|
+
add("src"); add("public");
|
|
629
|
+
for (const m of readTextSafe(path.join(base, "index.html")).matchAll(/\b(?:src|href)\s*=\s*["']([^"'#?]+)["']/g)) if (!/^[a-z][a-z0-9+.-]*:|^\/\//i.test(m[1])) add(m[1].replace(/^\//, ""));
|
|
630
|
+
for (const entry of readdirSafe(base)) {
|
|
631
|
+
if (!/^vite\.config\./.test(entry)) continue;
|
|
632
|
+
for (const m of readTextSafe(path.join(base, entry)).matchAll(/["'`]([^"'`\n]+)["'`]/g)) add(m[1].replace(/^\//, ""));
|
|
633
|
+
}
|
|
634
|
+
const ts = readTextSafe(path.join(base, "tsconfig.json"));
|
|
635
|
+
for (const m of ts.matchAll(/"(?:include|files)"\s*:\s*\[([^\]]*)\]/g)) {
|
|
636
|
+
for (const q of m[1].matchAll(/"([^"]+)"/g)) add(q[1].split(/[*?{[]/)[0].replace(/\/$/, "") || ".");
|
|
637
|
+
}
|
|
638
|
+
return dirs;
|
|
639
|
+
}
|
|
640
|
+
function readdirSafe(dir) { try { return readdirSync(dir); } catch { return []; } }
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* `releaseSwitches.nonShipped: [{ dir, reason }]`: app directories (tooling, bakeoffs, scripts) the desktop source walk skips.
|
|
644
|
+
* An exclusion list: whatever is not declared is still scanned. Each entry is refused (`inventory-invalid`) when the dir is missing,
|
|
645
|
+
* absolute, escapes the root, is a symlink or the root itself, has an empty reason, contains or sits inside a local crate of the release
|
|
646
|
+
* cargo tree, contains or sits inside `src-tauri` or the Tauri `frontendDist` bundle, or contains or sits inside a package that has a
|
|
647
|
+
* `package.json` (a frontend build input), overlaps the root frontend (`src/`, `public/`, directories named by `index.html`, `vite.config.*` or
|
|
648
|
+
* `tsconfig.json`, when the root has a `package.json`), or contains a file named by a `src-tauri/tauri.conf.json` `before*Command` or a root
|
|
649
|
+
* `package.json` `build*` script or by the release recipe's own commands (`recipeCommands`; followed through `run <script>`), which run during the release build.
|
|
650
|
+
* Returns { findings, roots } where `roots` are the validated root-relative dirs, empty if any entry was refused.
|
|
651
|
+
*/
|
|
652
|
+
export function validateNonShipped({ root, treeText, nonShipped, recipeCommands = [] }) {
|
|
653
|
+
const findings = [];
|
|
654
|
+
const roots = [];
|
|
655
|
+
const bad = (label, why) => findings.push({ layer: "inventory", rule: "inventory-invalid", file: `releaseSwitches.nonShipped${label}`, reason: `inventory-invalid: releaseSwitches.nonShipped${label}: ${why}` });
|
|
656
|
+
if (nonShipped === undefined) return { findings, roots };
|
|
657
|
+
if (!Array.isArray(nonShipped)) { bad("", "must be an array of { dir, reason }"); return { findings, roots }; }
|
|
658
|
+
const base = path.resolve(root);
|
|
659
|
+
const crates = localCrateDirsFromTree(treeText, base).map((dir) => (dir === base ? path.join(base, "src") : dir));
|
|
660
|
+
const protectedDirs = [path.join(base, "src-tauri"), ...crates];
|
|
661
|
+
try {
|
|
662
|
+
const conf = JSON.parse(readFileSync(path.join(base, "src-tauri", "tauri.conf.json"), "utf8")).build ?? {};
|
|
663
|
+
const dist = conf.frontendDist ?? conf.distDir;
|
|
664
|
+
if (typeof dist === "string" && !/^[a-z][a-z0-9+.-]*:\/\//i.test(dist)) protectedDirs.push(path.resolve(base, "src-tauri", dist));
|
|
665
|
+
} catch { /* no readable tauri.conf.json: nothing extra to protect */ }
|
|
666
|
+
const pkg = readJsonSafe(path.join(base, "package.json"));
|
|
667
|
+
const frontendDirs = pkg ? rootFrontendDirs(base) : new Set();
|
|
668
|
+
const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
|
|
669
|
+
const hookObj = readJsonSafe(path.join(base, "src-tauri", "tauri.conf.json"))?.build ?? {};
|
|
670
|
+
const commands = [
|
|
671
|
+
...BUILD_HOOKS.map((k) => hookObj[k]).map((v) => (v && typeof v === "object" ? v.script : v)).filter((v) => typeof v === "string"),
|
|
672
|
+
...Object.entries(scripts).filter(([name, v]) => /^build/.test(name) && typeof v === "string").map(([, v]) => v),
|
|
673
|
+
// The release recipe's own commands (targets.<os>.prePackage, package, postSign, packageHook, finalizer): `release.mjs` passes its `closureCommands`.
|
|
674
|
+
...(Array.isArray(recipeCommands) ? recipeCommands : []).map((c) => (Array.isArray(c) ? c.join(" ") : c)).filter((c) => typeof c === "string"),
|
|
675
|
+
];
|
|
676
|
+
const buildRefs = commandRefs(base, commands, scripts);
|
|
677
|
+
nonShipped.forEach((entry, i) => {
|
|
678
|
+
const label = `[${i}]`;
|
|
679
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) { bad(label, "must be { dir, reason }"); return; }
|
|
680
|
+
const { dir, reason } = entry;
|
|
681
|
+
if (typeof reason !== "string" || reason.trim() === "") bad(label, "reason is required and must say why the directory never ships");
|
|
682
|
+
if (typeof dir !== "string" || dir.trim() === "") { bad(label, "dir is required"); return; }
|
|
683
|
+
if (path.isAbsolute(dir) || path.win32.isAbsolute(dir)) { bad(label, `dir \`${dir}\` must be relative to the app root, not absolute`); return; }
|
|
684
|
+
const abs = path.resolve(base, dir);
|
|
685
|
+
if (abs === base) { bad(label, `dir \`${dir}\` is the app root itself`); return; }
|
|
686
|
+
if (!within(base, abs)) { bad(label, `dir \`${dir}\` escapes the app root`); return; }
|
|
687
|
+
let st;
|
|
688
|
+
try { st = lstatSync(abs); } catch { bad(label, `dir \`${dir}\` does not exist under the app root`); return; }
|
|
689
|
+
if (st.isSymbolicLink() || !st.isDirectory()) { bad(label, `dir \`${dir}\` must be a real directory`); return; }
|
|
690
|
+
for (const guarded of protectedDirs) {
|
|
691
|
+
if (within(abs, guarded) || within(guarded, abs)) { bad(label, `dir \`${dir}\` overlaps shipped code (${path.relative(base, guarded) || "."}: a local crate of the release cargo tree, src-tauri or the frontend bundle); it cannot be declared non-shipped`); return; }
|
|
692
|
+
}
|
|
693
|
+
if (hasPackageJson(abs)) { bad(label, `dir \`${dir}\` contains a package.json (a possible frontend build input); it cannot be declared non-shipped`); return; }
|
|
694
|
+
for (let up = path.dirname(abs); within(base, up) && up !== base; up = path.dirname(up)) {
|
|
695
|
+
if (existsSync(path.join(up, "package.json"))) { bad(label, `dir \`${dir}\` sits inside a package (${path.relative(base, up)}/package.json: a possible frontend build input); it cannot be declared non-shipped`); return; }
|
|
696
|
+
}
|
|
697
|
+
const guardedBy = [...frontendDirs].find((g) => within(abs, g) || within(g, abs));
|
|
698
|
+
if (guardedBy) { bad(label, `dir \`${dir}\` overlaps the app's root frontend (${path.relative(base, guardedBy)}: src/, public/ or a directory named by index.html, vite.config or tsconfig); it cannot be declared non-shipped`); return; }
|
|
699
|
+
const buildRef = [...buildRefs].find((r) => within(abs, r) || within(r, abs));
|
|
700
|
+
if (buildRef) { bad(label, `dir \`${dir}\` contains or sits inside a build-time input (${path.relative(base, buildRef).split(path.sep).join("/")}: named by a tauri.conf.json before*Command or a package.json build script, which runs during the release build); it cannot be declared non-shipped`); return; }
|
|
701
|
+
if (typeof reason === "string" && reason.trim() !== "") roots.push(path.relative(base, abs));
|
|
702
|
+
});
|
|
703
|
+
return { findings, roots: findings.length ? [] : roots };
|
|
704
|
+
}
|