@rightkit/release 0.6.6 → 0.6.7
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 +122 -9
- package/admission-files.mjs +3 -2
- package/advisory-db.json +4 -0
- package/advisory-db.mjs +124 -0
- package/advisory-exceptions.mjs +13 -10
- package/app-check.mjs +257 -38
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-agent-hook.mjs +9 -0
- package/bin/rightkit-gate.mjs +495 -344
- package/build-release.mjs +13 -6
- package/cli/right-release.mjs +29 -6
- package/compile-inputs.mjs +66 -0
- package/consumer-contracts.json +1044 -0
- package/consumer-contracts.mjs +627 -0
- package/consumer-input-contracts.mjs +422 -0
- package/consumer-policy.mjs +588 -0
- package/consumer-source-contracts.mjs +469 -0
- package/deny-default.toml +42 -0
- package/deterministic-scans.mjs +240 -29
- package/disk-command.mjs +270 -30
- package/disk-policy.json +50 -10
- package/disk-policy.mjs +482 -35
- package/disk-report.mjs +129 -0
- package/door-policy.mjs +228 -0
- package/gate-executor.mjs +30 -0
- package/gate-stages.mjs +110 -0
- package/github-updater-release.mjs +48 -8
- package/init-command.mjs +41 -0
- package/legion-semantic-contracts.mjs +428 -0
- package/ownership-class-rules.mjs +277 -0
- package/ownership-key-authority.mjs +3 -1
- package/ownership-mandatory-set.mjs +3 -2
- package/package.json +10 -3
- package/publish-cargo.mjs +5 -2
- package/publish-update.mjs +14 -11
- package/release-config.mjs +51 -6
- package/rightkit-versions.json +172 -167
- package/shell-contract-bridge.json +251 -0
- package/shell-contract.mjs +1069 -0
- package/sign-updater.mjs +24 -4
- package/source-closure-rules.mjs +204 -0
- package/stage-sidecar-command.mjs +2 -1
- package/stage-sidecar.mjs +437 -114
- package/suite-api.generated.mjs +96 -6
- package/suite-trust-roots.mjs +198 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// Ownership class rules for the app check (plan items 0.5 and 0.6). Code is placed by class, never by consumer count:
|
|
2
|
+
// foundation a row of ownership-mandatory-set.json for the app's profile: the app must use its owner;
|
|
3
|
+
// add-on any other catalogued capability: RightKit-owned from its first consumer;
|
|
4
|
+
// app-specific product domain logic and presentation only.
|
|
5
|
+
// Non-product code stays in an app only under an approved, expiring entry of .rightkit/ownership-exceptions.json that names the
|
|
6
|
+
// RightKit issue that moves it. These two rules run wherever the app check runs (`rightkit-gate check`, `right-release check`, the
|
|
7
|
+
// pre-push hook, the start of every checked right-release command and RightKit's own preflight):
|
|
8
|
+
//
|
|
9
|
+
// mandatory-foundation OW-05 an app whose profile requires a foundation owner the app does not declare as a dependency (missing,
|
|
10
|
+
// or dev-only), or that selects an unknown or unratified profile. An app is a repository whose root
|
|
11
|
+
// holds its declaration, rightkit-mandatory.json, or that ships a desktop app: a root
|
|
12
|
+
// right-release.config.(mjs|json), or one beside a src-tauri/tauri.conf.json; when that directory
|
|
13
|
+
// holds its own declaration (RightKit's desktop-app-template), its rulings are checked here and its
|
|
14
|
+
// owners when the directory is checked as its own root (no TOML parser is needed before install). An app without the
|
|
15
|
+
// declaration is a finding, so a missing declaration is never an exemption. A row the app declares not applicable is
|
|
16
|
+
// excused only by a ratified ruling: appAmendments.<app>.notApplicable.<row> for the `app` the
|
|
17
|
+
// declaration names and its profile, with the reason copied verbatim; any other not-applicable row is
|
|
18
|
+
// a finding and the row still applies (rightkit#1140). Owner-pending rows never fail. Below-required versions
|
|
19
|
+
// and path/git overrides stay with `right-release ownership-mandatory --enforce` (versions move with
|
|
20
|
+
// every publish). Findings carry an identity, so an app may ratchet them in its baseline.
|
|
21
|
+
// ownership-exceptions OW-06 every committed .rightkit/ownership-exceptions.json: { schema?: 1, generated?, exceptions: [...] };
|
|
22
|
+
// each entry holds only the known keys, with capability, package (or crate), reason, owner,
|
|
23
|
+
// approvedBy, a real `date` not later than today, a real `expires` not before `date`, and `issue`, a
|
|
24
|
+
// RightKit issue (`<owner>/rightkit#<n>` or https://github.com/<owner>/rightkit/issues/<n>). Schema
|
|
25
|
+
// findings carry an identity (an adoption ratchet, bounded by the baseline's 90-day sunset); an entry
|
|
26
|
+
// past its `expires` date is a finding with no identity, so no baseline can silence an expiry. Each
|
|
27
|
+
// entry must also match a ratified ruling, appAmendments.<app>.exceptions in ownership-mandatory-set.json
|
|
28
|
+
// for the `app` the root rightkit-mandatory.json names: same capability, package (or crate), path when
|
|
29
|
+
// the ruling names one, and RightKit issue, expiring no later than the ruling. `approvedBy` alone is
|
|
30
|
+
// text the app writes; an entry no ruling covers is a finding.
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
|
|
34
|
+
const readText = (root, rel) => { try { return fs.readFileSync(path.join(root, rel), "utf8"); } catch { return null; } };
|
|
35
|
+
const readJson = (root, rel) => { const text = readText(root, rel); try { return text === null ? null : JSON.parse(text); } catch { return null; } };
|
|
36
|
+
|
|
37
|
+
// The mandatory-set evaluator parses Cargo.toml (smol-toml). It is loaded only when a checked tree is an app, so a copy of the check
|
|
38
|
+
// without it still runs every other rule; an app whose check cannot load it fails the rule ("the rule itself failed").
|
|
39
|
+
let mandatorySet = null;
|
|
40
|
+
let mandatorySetError = null;
|
|
41
|
+
try { mandatorySet = await import("./ownership-mandatory-set.mjs"); } catch (error) { mandatorySetError = error; }
|
|
42
|
+
|
|
43
|
+
export const APP_MARKERS = Object.freeze(["rightkit-mandatory.json"]);
|
|
44
|
+
export const APP_DECLARATION_FILE = "rightkit-mandatory.json";
|
|
45
|
+
const RELEASE_CONFIG = /^(?:(.+)\/)?right-release\.config\.(?:mjs|json)$/;
|
|
46
|
+
const SKIPPED_TREE = /(^|\/)(node_modules|vendor|target)\//;
|
|
47
|
+
const MANDATORY_DATA = new URL("./ownership-mandatory-set.json", import.meta.url);
|
|
48
|
+
/** The mandatory-set data, read as JSON (no TOML parser needed), or null when it cannot be read. */
|
|
49
|
+
export function mandatoryData() { try { return JSON.parse(fs.readFileSync(MANDATORY_DATA, "utf8")); } catch { return null; } }
|
|
50
|
+
export const OWNERSHIP_LEDGER_FILE = ".rightkit/ownership-exceptions.json";
|
|
51
|
+
export const LEDGER_TOP_KEYS = Object.freeze(["schema", "generated", "exceptions"]);
|
|
52
|
+
export const LEDGER_ENTRY_KEYS = Object.freeze(["capability", "package", "crate", "path", "fingerprint", "reason", "owner", "approvedBy", "date", "expires", "issue"]);
|
|
53
|
+
const RIGHTKIT_ISSUE = /^(?:https:\/\/github\.com\/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/rightkit\/issues\/([1-9]\d*)|([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))\/rightkit#([1-9]\d*))$/i;
|
|
54
|
+
const todayOf = (now) => (now instanceof Date ? now : new Date()).toISOString().slice(0, 10);
|
|
55
|
+
|
|
56
|
+
/** True when `text` is a real calendar date written YYYY-MM-DD (UTC). */
|
|
57
|
+
export function isRealDate(text) {
|
|
58
|
+
if (typeof text !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(text)) return false;
|
|
59
|
+
const parsed = new Date(`${text}T00:00:00Z`);
|
|
60
|
+
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === text;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The RightKit issue an `issue` field names, as `<owner>/rightkit#<n>`, or null when it names no RightKit issue. */
|
|
64
|
+
export function rightkitIssue(text) {
|
|
65
|
+
const m = typeof text === "string" ? RIGHTKIT_ISSUE.exec(text.trim()) : null;
|
|
66
|
+
return m ? `${m[1] ?? m[3]}/rightkit#${m[2] ?? m[4]}` : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const nonEmpty = (value) => typeof value === "string" && value.trim().length > 0;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate one ledger entry. Returns { problems: [string], expired: boolean }; `expired` is set only for an otherwise dated entry
|
|
73
|
+
* whose `expires` is before `today` (YYYY-MM-DD, UTC).
|
|
74
|
+
*/
|
|
75
|
+
export function validateLedgerEntry(entry, { today = todayOf() } = {}) {
|
|
76
|
+
const problems = [];
|
|
77
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return { problems: ["is not an object"], expired: false };
|
|
78
|
+
const unknown = Object.keys(entry).filter((key) => !LEDGER_ENTRY_KEYS.includes(key));
|
|
79
|
+
if (unknown.length) problems.push(`unknown key(s) ${unknown.join(", ")} (allowed: ${LEDGER_ENTRY_KEYS.join(", ")})`);
|
|
80
|
+
for (const key of ["capability", "reason", "owner", "approvedBy"]) if (!nonEmpty(entry[key])) problems.push(`missing ${key}`);
|
|
81
|
+
if (entry.package !== undefined && entry.crate !== undefined) problems.push("names both package and crate; keep one");
|
|
82
|
+
else if (!nonEmpty(entry.package ?? entry.crate)) problems.push("missing package (or crate)");
|
|
83
|
+
for (const key of ["path", "fingerprint"]) if (entry[key] !== undefined && !nonEmpty(entry[key])) problems.push(`${key} must be a non-empty string when present`);
|
|
84
|
+
const dated = isRealDate(entry.date);
|
|
85
|
+
if (!dated) problems.push("date must be a real date, YYYY-MM-DD (UTC)");
|
|
86
|
+
else if (entry.date > today) problems.push(`date ${entry.date} is later than today (${today}, UTC)`);
|
|
87
|
+
const expiring = isRealDate(entry.expires);
|
|
88
|
+
if (!expiring) problems.push("expires must be a real date, YYYY-MM-DD (UTC): every exception expires");
|
|
89
|
+
else if (dated && entry.expires < entry.date) problems.push(`expires ${entry.expires} is before date ${entry.date}`);
|
|
90
|
+
if (entry.issue === undefined) problems.push("missing issue: the RightKit issue that moves this code into RightKit");
|
|
91
|
+
else if (!rightkitIssue(entry.issue)) problems.push(`issue ${JSON.stringify(entry.issue)} is not a RightKit issue (<owner>/rightkit#<n> or https://github.com/<owner>/rightkit/issues/<n>)`);
|
|
92
|
+
return { problems, expired: expiring && entry.expires < today };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Validate one ledger document (parsed JSON, or null when unparseable). Returns [{ problem, fix, subject?, expired? }]:
|
|
97
|
+
* a schema finding carries a `subject`; an expired entry carries `expired: true` and no subject.
|
|
98
|
+
*/
|
|
99
|
+
export function validateLedger(doc, { today = todayOf() } = {}) {
|
|
100
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) return [{ problem: "is not a JSON object { exceptions: [...] }", fix: "write { \"exceptions\": [] } and add approved, expiring entries", subject: "document" }];
|
|
101
|
+
const out = [];
|
|
102
|
+
const unknown = Object.keys(doc).filter((key) => !LEDGER_TOP_KEYS.includes(key));
|
|
103
|
+
if (unknown.length) out.push({ problem: `unknown top-level key(s) ${unknown.join(", ")} (allowed: ${LEDGER_TOP_KEYS.join(", ")})`, fix: "remove them", subject: "document" });
|
|
104
|
+
if (doc.schema !== undefined && doc.schema !== 1) out.push({ problem: `schema ${JSON.stringify(doc.schema)} is not 1`, fix: "set \"schema\": 1 or drop it", subject: "document" });
|
|
105
|
+
if (!Array.isArray(doc.exceptions)) { out.push({ problem: "exceptions must be an array", fix: "write \"exceptions\": []", subject: "document" }); return out; }
|
|
106
|
+
doc.exceptions.forEach((entry, index) => {
|
|
107
|
+
const label = entry && typeof entry === "object" && !Array.isArray(entry) ? `${entry.capability ?? "?"}/${entry.package ?? entry.crate ?? "?"}${entry.path ? ` ${entry.path}` : ""}` : "?";
|
|
108
|
+
const { problems, expired } = validateLedgerEntry(entry, { today });
|
|
109
|
+
if (problems.length) out.push({ problem: `entry #${index} (${label}): ${problems.join("; ")}`, fix: "every exception needs capability, package, reason, owner, approvedBy, date, expires and the RightKit issue that moves the code", subject: `#${index} ${label}` });
|
|
110
|
+
if (expired) out.push({ problem: `entry #${index} (${label}) expired on ${entry.expires} (today ${today}, UTC); an expired exception is a failure and no baseline lists it`, fix: `move the code into RightKit (${rightkitIssue(entry.issue) ?? "its RightKit issue"}) and remove the entry, or record a fresh approval with a new expires date`, expired: true });
|
|
111
|
+
});
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The ratified ruling that covers a ledger entry, or null. `rulings` is appAmendments.<app>.exceptions: [{ capability, package | crate,
|
|
117
|
+
* path?, issue, expires }]. A ruling with no `path` covers every path of its capability and package.
|
|
118
|
+
*/
|
|
119
|
+
export function rulingFor(entry, rulings) {
|
|
120
|
+
if (!entry || typeof entry !== "object" || !Array.isArray(rulings)) return null;
|
|
121
|
+
const issue = rightkitIssue(entry.issue);
|
|
122
|
+
return rulings.find((r) => r && r.capability === entry.capability && (r.package ?? r.crate) === (entry.package ?? entry.crate)
|
|
123
|
+
&& (r.path === undefined || r.path === entry.path) && issue !== null && rightkitIssue(r.issue)?.toLowerCase() === issue.toLowerCase()
|
|
124
|
+
&& isRealDate(r.expires) && isRealDate(entry.expires) && entry.expires <= r.expires) ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The declaration as ownership-mandatory-set.mjs reads it (profile and not-applicable rows of 10+ characters), without its TOML parser. */
|
|
128
|
+
function declarationOf(appRoot, data) {
|
|
129
|
+
const doc = readJson(appRoot, data.appDeclarationFile) ?? {};
|
|
130
|
+
const notApplicable = {};
|
|
131
|
+
for (const [id, reason] of Object.entries(doc.notApplicable ?? {})) if (typeof reason === "string" && reason.trim().length >= 10) notApplicable[id] = reason.trim();
|
|
132
|
+
return { profile: doc.profile ?? data.defaultProfile, notApplicable, records: {}, problems: [] };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The `app` a root declaration names, or null. */
|
|
136
|
+
export function declaredApp(root) {
|
|
137
|
+
const doc = readJson(root, APP_DECLARATION_FILE);
|
|
138
|
+
return doc && typeof doc === "object" && typeof doc.app === "string" && doc.app.trim() ? doc.app.trim() : null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The ownership-exceptions rule: every committed ledger in the tree. */
|
|
142
|
+
export function ownershipExceptions({ root, files, now }, { data = mandatoryData() } = {}) {
|
|
143
|
+
const today = todayOf(now);
|
|
144
|
+
const app = declaredApp(root);
|
|
145
|
+
const amendment = app && data?.appAmendments ? data.appAmendments[app] : undefined;
|
|
146
|
+
const rulings = amendment && Array.isArray(amendment.exceptions) ? amendment.exceptions : [];
|
|
147
|
+
const ledgers = (files ?? []).filter((file) => file === OWNERSHIP_LEDGER_FILE || file.endsWith(`/${OWNERSHIP_LEDGER_FILE}`)).sort();
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const file of ledgers) {
|
|
150
|
+
const text = readText(root, file);
|
|
151
|
+
let doc = null;
|
|
152
|
+
try { doc = JSON.parse(text); } catch (error) {
|
|
153
|
+
out.push({ file, subject: "document", problem: `${file} is not valid JSON (${error.message})`, fix: "repair the JSON" });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
for (const finding of validateLedger(doc, { today })) {
|
|
157
|
+
const { subject, expired, ...rest } = finding;
|
|
158
|
+
out.push({ ...rest, problem: `${file}: ${rest.problem}`, ...(expired ? {} : { file, subject }) });
|
|
159
|
+
}
|
|
160
|
+
(Array.isArray(doc?.exceptions) ? doc.exceptions : []).forEach((entry, index) => {
|
|
161
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry) || rulingFor(entry, rulings)) return;
|
|
162
|
+
const label = `${entry.capability ?? "?"}/${entry.package ?? entry.crate ?? "?"}${entry.path ? ` ${entry.path}` : ""}`;
|
|
163
|
+
out.push({
|
|
164
|
+
file, subject: `#${index} ${label} ruling`,
|
|
165
|
+
problem: `${file}: entry #${index} (${label}) matches no ratified ruling: ${app ? `appAmendments.${app}.exceptions in ownership-mandatory-set.json has none with this capability, ${entry.crate !== undefined ? "crate" : "package"}, path, issue and an expiry no earlier than ${entry.expires}` : `${APP_DECLARATION_FILE} names no "app", so no ruling can apply`}`,
|
|
166
|
+
fix: `ratify the exception in RightKit (appAmendments.<app>.exceptions, with its issue and expiry) and name the app in ${APP_DECLARATION_FILE}, or move the code into RightKit and remove the entry`,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The marker files that make the checked root an app, in APP_MARKERS order. */
|
|
174
|
+
export function appMarkers(root) {
|
|
175
|
+
return APP_MARKERS.filter((name) => readText(root, name) !== null);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The release configs that make a tree an app even without its declaration: a root right-release.config.(mjs|json), or one whose
|
|
180
|
+
* directory also holds src-tauri/tauri.conf.json (a packaged desktop app).
|
|
181
|
+
*/
|
|
182
|
+
export function releaseAppMarkers(files) {
|
|
183
|
+
const tracked = new Set(files ?? []);
|
|
184
|
+
return [...tracked].filter((file) => {
|
|
185
|
+
const m = RELEASE_CONFIG.exec(file);
|
|
186
|
+
if (!m || SKIPPED_TREE.test(file)) return false;
|
|
187
|
+
return m[1] === undefined || tracked.has(`${m[1]}/src-tauri/tauri.conf.json`);
|
|
188
|
+
}).sort();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Split declared not-applicable rows into those a ratified ruling covers and those it does not. */
|
|
192
|
+
export function ratifiedNotApplicable(declared, { app, profile, data }) {
|
|
193
|
+
const amendment = app && data?.appAmendments ? data.appAmendments[app] : undefined;
|
|
194
|
+
const rulings = amendment && amendment.profile === profile && amendment.notApplicable && typeof amendment.notApplicable === "object" ? amendment.notApplicable : {};
|
|
195
|
+
const ratified = {}, unratified = {};
|
|
196
|
+
for (const [id, reason] of Object.entries(declared ?? {})) {
|
|
197
|
+
if (typeof rulings[id] === "string" && rulings[id].trim() === reason.trim()) ratified[id] = reason;
|
|
198
|
+
else unratified[id] = reason;
|
|
199
|
+
}
|
|
200
|
+
return { ratified, unratified, ruled: Boolean(amendment) };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The findings of one app root (`dir` relative to the checked root, "" for the root itself). `owners: false` runs only the checks
|
|
205
|
+
* that need no TOML parser (the declaration parses; every not-applicable row is ratified): a nested app's foundation owners are
|
|
206
|
+
* checked when its own directory is checked, so RightKit's preflight, which runs before any install, can still see the rulings.
|
|
207
|
+
*/
|
|
208
|
+
function checkAppRoot({ root, dir, evaluator, data, owners = true }) {
|
|
209
|
+
const appRoot = dir ? path.join(root, dir) : root;
|
|
210
|
+
const declarationFile = data.appDeclarationFile;
|
|
211
|
+
const file = dir ? `${dir}/${declarationFile}` : declarationFile;
|
|
212
|
+
const out = [];
|
|
213
|
+
if (readJson(appRoot, declarationFile) === null) {
|
|
214
|
+
out.push({ file, subject: "declaration", problem: `${file} is not valid JSON, so the app's profile and not-applicable rows cannot be read`, fix: `repair ${file}` });
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
const declaration = (evaluator ?? { loadAppDeclaration: declarationOf }).loadAppDeclaration(appRoot, data);
|
|
218
|
+
const app = declaredApp(appRoot);
|
|
219
|
+
const { ratified, unratified, ruled } = ratifiedNotApplicable(declaration.notApplicable, { app, profile: declaration.profile, data });
|
|
220
|
+
for (const [row, reason] of Object.entries(unratified)) out.push({
|
|
221
|
+
file, subject: `notApplicable:${row}`,
|
|
222
|
+
problem: `notApplicable.${row} ("${reason.slice(0, 80)}${reason.length > 80 ? "..." : ""}") is not a ratified ruling: ${!app ? `${file} names no "app"` : !ruled ? `appAmendments has no entry for "${app}"` : `appAmendments.${app} (profile ${data.appAmendments[app].profile}) has no ruling for ${row} with this reason verbatim for profile ${declaration.profile}`}; the row still applies`,
|
|
223
|
+
fix: `ratify the ruling in RightKit (appAmendments.${app ?? "<app>"}.notApplicable.${row}, rightkit#1140) and copy its reason verbatim, or adopt the row's owner`,
|
|
224
|
+
});
|
|
225
|
+
if (!owners) return out;
|
|
226
|
+
const report = evaluator.checkApp(appRoot, { data, declaration: { ...declaration, notApplicable: ratified } });
|
|
227
|
+
for (const owner of report.owners) {
|
|
228
|
+
if (owner.status === "unknown-profile" || owner.status === "unratified") {
|
|
229
|
+
out.push({ file, subject: owner.owner, problem: `profile "${report.profile}" is ${owner.status === "unknown-profile" ? "not a profile of ownership-mandatory-set.json" : "not ratified (or has no rows)"}, so no foundation set can be checked`, fix: `select a ratified profile (${Object.entries(data.profiles).filter(([, p]) => p.ratified !== false).map(([name]) => name).join(", ")}) in ${file}` });
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (owner.status !== "missing") continue;
|
|
233
|
+
const devOnly = owner.evidence.some((line) => line.includes("dev-dependencies"));
|
|
234
|
+
out.push({
|
|
235
|
+
file, subject: owner.owner,
|
|
236
|
+
problem: `profile "${report.profile}" requires the foundation owner ${owner.owner} (${owner.ecosystem}, row${owner.rows.length > 1 ? "s" : ""} ${owner.rows.join(", ")}) and the app does not use it${devOnly ? " (declared only as a dev dependency)" : ""}`,
|
|
237
|
+
fix: `depend on ${owner.owner} >= ${owner.required} from the registry and use it, or, when the row truly does not apply, declare it under notApplicable in ${file} with the reason RightKit ratified for the app`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The mandatory-foundation rule. The root declaration makes the whole tree one app. Without it, every release config that ships a
|
|
245
|
+
* desktop app (releaseAppMarkers) is an app: one whose own directory holds a declaration is checked from there; the others are one
|
|
246
|
+
* finding, the missing declaration (no profile is chosen for the app, so none of its rows can be excused or checked).
|
|
247
|
+
*/
|
|
248
|
+
export function mandatoryFoundation({ root, files }, { evaluator = mandatorySet } = {}) {
|
|
249
|
+
const markers = appMarkers(root);
|
|
250
|
+
const releaseMarkers = markers.length ? [] : releaseAppMarkers(files);
|
|
251
|
+
if (!markers.length && !releaseMarkers.length) return [];
|
|
252
|
+
const out = [];
|
|
253
|
+
const declared = [], undeclared = [];
|
|
254
|
+
for (const marker of releaseMarkers) {
|
|
255
|
+
const dir = marker.includes("/") ? marker.slice(0, marker.lastIndexOf("/")) : "";
|
|
256
|
+
(dir && readText(root, `${dir}/${APP_DECLARATION_FILE}`) !== null ? declared : undeclared).push(dir || marker);
|
|
257
|
+
}
|
|
258
|
+
if (undeclared.length) out.push({
|
|
259
|
+
file: APP_DECLARATION_FILE, subject: "declaration",
|
|
260
|
+
problem: `${undeclared.join(", ")} ships a desktop app, and the repository has no ${APP_DECLARATION_FILE}: an app without its declaration is never exempt from the mandatory foundation set`,
|
|
261
|
+
fix: `commit ${APP_DECLARATION_FILE} at the root (or beside the app's release config) with the app's "app" name, its "profile" and only the not-applicable rows ratified for it in appAmendments`,
|
|
262
|
+
});
|
|
263
|
+
if (!markers.length && !declared.length) return out;
|
|
264
|
+
if (markers.length) {
|
|
265
|
+
if (!evaluator) throw new Error(`ownership-mandatory-set.mjs could not be loaded: ${mandatorySetError?.message ?? "unknown error"}`);
|
|
266
|
+
return checkAppRoot({ root, dir: "", evaluator, data: evaluator.loadData() });
|
|
267
|
+
}
|
|
268
|
+
const data = mandatoryData();
|
|
269
|
+
if (!data) throw new Error("ownership-mandatory-set.json could not be read");
|
|
270
|
+
for (const dir of declared) out.push(...checkAppRoot({ root, dir, evaluator: null, data, owners: false }));
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export const OWNERSHIP_CLASS_RULES = Object.freeze([
|
|
275
|
+
{ id: "mandatory-foundation", gap: "OW-05", run: (ctx) => mandatoryFoundation(ctx) },
|
|
276
|
+
{ id: "ownership-exceptions", gap: "OW-06", run: ownershipExceptions },
|
|
277
|
+
]);
|
|
@@ -118,8 +118,10 @@ function persistCheckpoint(trust, bytes, file, audit) {
|
|
|
118
118
|
export function readCommittedKeyAuthority(trust) { return readAuthority(trust); }
|
|
119
119
|
function readAuthority(trust, { file, audit = auditPath } = {}) {
|
|
120
120
|
try {
|
|
121
|
-
|
|
121
|
+
// The authority's own shape is judged before anything host-specific: a malformed authority is "malformed" on every
|
|
122
|
+
// platform, not "unsupported platform" on the hosts that have no protected trust location (fixture scan-error-key-authority-malformed).
|
|
122
123
|
const expected = validateAuthority(trust?.keyAuthority);
|
|
124
|
+
file ??= trustedPath();
|
|
123
125
|
const top = String(git(expected.checkoutPath, ["rev-parse", "--show-toplevel"])).trim();
|
|
124
126
|
const normalizedTop = process.platform === "win32" ? path.resolve(top).toLowerCase() : path.resolve(top);
|
|
125
127
|
const normalizedExpected = process.platform === "win32" ? expected.checkoutPath.toLowerCase() : expected.checkoutPath;
|
|
@@ -301,10 +301,11 @@ export function pendingAppliesTo(row, active) {
|
|
|
301
301
|
return row.profile === "all" || (Array.isArray(row.profile) ? row.profile.includes(active) : row.profile === active);
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
|
|
304
|
+
// `declaration` replaces the one read from root (the app check passes the declaration with its unratified not-applicable rows removed).
|
|
305
|
+
export function checkApp(root, { data = loadData(), profile, amendments = [], declaration: override = null } = {}) {
|
|
305
306
|
const name = path.basename(root);
|
|
306
307
|
if (!existsSync(root) || !statSync(root).isDirectory()) return { app: name, root, scanned: false, profile: profile ?? data.defaultProfile, owners: [], pending: [], notApplicable: [], problems: ["app root missing"], violations: 0 };
|
|
307
|
-
const declaration = loadAppDeclaration(root, data);
|
|
308
|
+
const declaration = override ?? loadAppDeclaration(root, data);
|
|
308
309
|
const active = profile ?? declaration.profile;
|
|
309
310
|
const problems = [...declaration.problems];
|
|
310
311
|
if (!data.profiles[active]) problems.push(`unknown profile "${active}"`);
|
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/release",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.7",
|
|
4
4
|
"description": "Portable release CLI/SDK with fail-closed public GitHub Releases and private R2 + RightApps distribution.",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"right-release": "cli/right-release.mjs",
|
|
9
|
-
"rightkit-gate": "bin/rightkit-gate.mjs"
|
|
9
|
+
"rightkit-gate": "bin/rightkit-gate.mjs",
|
|
10
|
+
"rightkit-agent-hook": "bin/rightkit-agent-hook.mjs"
|
|
10
11
|
},
|
|
11
12
|
"exports": {
|
|
13
|
+
"./bin/rightkit-agent-hook.mjs": "./bin/rightkit-agent-hook.mjs",
|
|
12
14
|
"./bin/rightkit-gate.mjs": "./bin/rightkit-gate.mjs",
|
|
13
15
|
"./cargo-contract.mjs": "./cargo-contract.mjs",
|
|
14
16
|
"./cargo-target.mjs": "./cargo-target.mjs",
|
|
@@ -51,7 +53,11 @@
|
|
|
51
53
|
],
|
|
52
54
|
"rightkit-gate": [
|
|
53
55
|
"--version"
|
|
54
|
-
]
|
|
56
|
+
],
|
|
57
|
+
"rightkit-agent-hook": {
|
|
58
|
+
"args": [],
|
|
59
|
+
"output": "{}"
|
|
60
|
+
}
|
|
55
61
|
}
|
|
56
62
|
}
|
|
57
63
|
},
|
|
@@ -61,6 +67,7 @@
|
|
|
61
67
|
"templates",
|
|
62
68
|
"*.mjs",
|
|
63
69
|
"*.json",
|
|
70
|
+
"deny-default.toml",
|
|
64
71
|
"*.sh",
|
|
65
72
|
"msix-repair-guard.ps1",
|
|
66
73
|
"bin/darwin/rightkit-swap",
|
package/publish-cargo.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { formatPreflight, PREFLIGHT_FAIL } from "./preflight.mjs";
|
|
|
16
16
|
import { cargoManifestState } from "./cargo-manifest-text.mjs";
|
|
17
17
|
|
|
18
18
|
const FORBIDDEN_PACKAGE_PATH = /(^|\/)(?:\.env(?:\.[^/]*)?|target(?:\/.*)?|node_modules(?:\/.*)?|[^/]+\.(?:p8|p12|pem|key))$/i;
|
|
19
|
-
const SECRET_PATTERNS = [
|
|
19
|
+
export const SECRET_PATTERNS = [
|
|
20
20
|
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
|
|
21
21
|
/\bAKIA[0-9A-Z]{16}\b/,
|
|
22
22
|
/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/,
|
|
@@ -360,7 +360,10 @@ function scanRepositoryForSecrets(dir, repoRoot) {
|
|
|
360
360
|
violations.push(`${relative}: refused by name (an .env file, a key or certificate file, or build output); it is never committed and cannot be recorded`);
|
|
361
361
|
continue;
|
|
362
362
|
}
|
|
363
|
-
|
|
363
|
+
// A file that vanished between the walk and the read was a tool's temporary file (pnpm writes `_tmp_<pid>_*` next to a
|
|
364
|
+
// lockfile it reads), never committed content.
|
|
365
|
+
let bytes;
|
|
366
|
+
try { bytes = fs.readFileSync(file); } catch (e) { if (e.code === "ENOENT") continue; throw e; }
|
|
364
367
|
violations.push(...(ENV_TEMPLATE.test(inDir) ? envTemplateViolations(relative, file, bytes, records, repoRoot) : fileViolations(relative, bytes, records)));
|
|
365
368
|
}
|
|
366
369
|
return [...new Set(violations)].sort();
|
package/publish-update.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { uploadVerifiedR2Artifact } from "./r2-verified-upload.mjs";
|
|
|
16
16
|
import { verifyPlatformTrust } from "./github-release.mjs";
|
|
17
17
|
import { admitInstalledQa } from "./release-qa-audit.mjs";
|
|
18
18
|
import { RIGHT_SUITE_API_ORIGIN, RIGHT_SUITE_GATED_ARTIFACT_HOST, RIGHT_SUITE_PUBLIC_INSTALLER_BASE } from "./suite-api.generated.mjs";
|
|
19
|
-
import { assertAssetName, assertGitHubRepository, assertUpdaterKeyId, assertUpdaterPlatform, assertUpdaterSignature, buildLatestJson, githubAssetUrl, githubTag, publishGitHubUpdaterRelease } from "./github-updater-release.mjs";
|
|
19
|
+
import { assertAssetName, assertGitHubRepository, assertUpdaterKeyId, assertUpdaterPlatform, assertUpdaterSignature, buildLatestJson, githubAssetUrl, githubTag, publishGitHubUpdaterRelease, resolveUpdaterTrust } from "./github-updater-release.mjs";
|
|
20
20
|
|
|
21
21
|
const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
|
|
22
22
|
const API_BASE = process.env.RIGHTAPPS_API_URL || RIGHT_SUITE_API_ORIGIN;
|
|
@@ -119,18 +119,21 @@ function git(args) {
|
|
|
119
119
|
if (!githubLane && tier === "patch" && !installers.length) fail(`${config?.app ?? "app"} ${platform} patch has no installer.artifacts`);
|
|
120
120
|
if ((githubLane || tier === "update") && !artifacts.length) fail(`${config?.app ?? "app"} ${platform} update has no updater.artifacts`);
|
|
121
121
|
|
|
122
|
-
// Cryptographic gate before any upload: every sealed minisign signature must verify over the
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// (suite key already pinned per-app as tauri plugins.updater.pubkey).
|
|
122
|
+
// Cryptographic gate before any upload: every sealed minisign signature must verify over the exact final artifact
|
|
123
|
+
// bytes against the updater trust root. A Right Suite app takes that key, and its key id, from the Right Suite contract
|
|
124
|
+
// (a declared key must be the contract's) and is always pinned to the id; any other app must declare
|
|
125
|
+
// distribution.updaterPublicKey, and without one a sealed (real) publish fails closed.
|
|
127
126
|
const updaterPublicKey = config.distribution?.updaterPublicKey ?? config.distribution?.updaterPubkey ?? target?.updater?.pubkey ?? target?.updater?.publicKey;
|
|
127
|
+
const trust = sealed && artifacts.length && !appInstallerKind
|
|
128
|
+
? resolveUpdaterTrust({ app: config.app, configuredPublicKey: updaterPublicKey, configuredKeyId: config.distribution.updaterKeyId })
|
|
129
|
+
: null;
|
|
128
130
|
if (sealed) for (const artifact of artifacts) {
|
|
129
131
|
const signature = signatureFor(artifact);
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
assertUpdaterKeyId(signature,
|
|
132
|
+
if (trust) {
|
|
133
|
+
const label = `signature of ${path.basename(artifact.file)}`;
|
|
134
|
+
// The id comes first: a signature by another key would otherwise fail verification without saying whose it is.
|
|
135
|
+
assertUpdaterKeyId(signature, trust.keyId, label, { app: config.app });
|
|
136
|
+
assertUpdaterSignature(assertArtifactUnchanged(verifiedArtifact(artifact.file)), signature, trust.publicKey, label);
|
|
134
137
|
}
|
|
135
138
|
}
|
|
136
139
|
let releaseToken = null;
|
|
@@ -156,7 +159,7 @@ if (githubLane) {
|
|
|
156
159
|
const name = assertAssetName(path.basename(file));
|
|
157
160
|
// Same signature source and process as the r2 lane: the sealed minisign .sig written by sign-updater.
|
|
158
161
|
const signature = dryRun ? `<signature:${artifact.signature ?? "msix-authenticode"}>` : signatureFor(artifact);
|
|
159
|
-
if (!dryRun && !appInstallerKind) assertUpdaterKeyId(signature,
|
|
162
|
+
if (!dryRun && !appInstallerKind) assertUpdaterKeyId(signature, trust.keyId, `signature of ${name}`, { app: config.app });
|
|
160
163
|
platforms[artifact.platform] = { signature, url: githubAssetUrl(githubRepo, tag, name) };
|
|
161
164
|
assets.set(file, { file, evidence: dryRun ? undefined : verifiedArtifact(file) });
|
|
162
165
|
if (!appInstallerKind) {
|
package/release-config.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import { pathToFileURL } from "node:url";
|
|
|
29
29
|
import { parse as parseToml } from "smol-toml";
|
|
30
30
|
import { collectConfigCommands, formatFindings, validateConfigPipeline } from "./pipeline-config.mjs";
|
|
31
31
|
import { loadGeneratorRegistry } from "./pipeline-generators.mjs";
|
|
32
|
+
import { RIGHT_SUITE_APP_KEYS, RIGHT_SUITE_UPDATER_PUBLIC_KEYS } from "./suite-api.generated.mjs";
|
|
32
33
|
|
|
33
34
|
export const RELEASE_CONFIG_FILE = "right-release.config.json";
|
|
34
35
|
export const LEGACY_RELEASE_CONFIG_FILE = "right-release.config.mjs";
|
|
@@ -267,16 +268,31 @@ export function validateReleaseConfigShape(config, { file = RELEASE_CONFIG_FILE,
|
|
|
267
268
|
return config;
|
|
268
269
|
}
|
|
269
270
|
|
|
271
|
+
// The same rule as UPDATER_KEY_ID_RE in github-updater-release.mjs, kept here because that module's import graph loads the release state
|
|
272
|
+
// machine, which this loader must not depend on.
|
|
273
|
+
const UPDATER_KEY_ID = /^[0-9A-F]{16}$/;
|
|
274
|
+
// A typed template ({ "$fromJson": ... }) is a string only after expansion, so the raw-phase check leaves it to the final one.
|
|
275
|
+
const isTemplateNode = (value) => isObject(value) && Object.keys(value).some((key) => key.startsWith("$"));
|
|
276
|
+
const isBlank = (value) => value === undefined || value === null || (typeof value === "string" && !value.trim());
|
|
277
|
+
|
|
270
278
|
/**
|
|
271
279
|
* Field-level refusals that need no other part of the config, so a caller (the loader, the doctor) can list them next to every other
|
|
272
|
-
* finding: `[{ name, code, pointer, detail }]`.
|
|
273
|
-
*
|
|
274
|
-
*
|
|
280
|
+
* finding: `[{ name, code, pointer, detail }]`.
|
|
281
|
+
*
|
|
282
|
+
* `targets.<t>.updater.keyFile`: updater minisign keys come from the OS credential provider (`protected-credentials.mjs`, secret
|
|
283
|
+
* "updater-signing-key", namespace `rightsuite-updater-key`; `sign-updater.mjs` refuses `--key-file`), so a config that names a key
|
|
284
|
+
* file could only fail mid-signing. It is refused up front instead, and never forwarded.
|
|
285
|
+
*
|
|
286
|
+
* `distribution.updaterKeyId` must be 16 upper-case hex digits and, for a Right Suite app, a key the Right Suite contract accepts; a
|
|
287
|
+
* Right Suite app must not declare an updater public key of its own (`distribution.updaterPublicKey`, `updaterPubkey`,
|
|
288
|
+
* `targets.<t>.updater.pubkey` or `publicKey`) unless it is exactly a contract key's text. Publish and seal refuse the same things
|
|
289
|
+
* (`resolveUpdaterTrust`); here they are found before a build starts.
|
|
275
290
|
*/
|
|
276
291
|
export function releaseConfigFieldProblems(config) {
|
|
277
292
|
const problems = [];
|
|
278
|
-
if (!isObject(config)
|
|
279
|
-
|
|
293
|
+
if (!isObject(config)) return problems;
|
|
294
|
+
const targets = isObject(config.targets) ? Object.entries(config.targets) : [];
|
|
295
|
+
for (const [name, target] of targets) {
|
|
280
296
|
if (!isObject(target) || !isObject(target.updater) || !Object.prototype.hasOwnProperty.call(target.updater, "keyFile")) continue;
|
|
281
297
|
problems.push({
|
|
282
298
|
name: "updater-key-file",
|
|
@@ -285,6 +301,35 @@ export function releaseConfigFieldProblems(config) {
|
|
|
285
301
|
detail: `targets.${name}.updater.keyFile is not supported: updater signing keys come from the OS credential provider (rightkit-credentials, namespace rightsuite-updater-key; see protected-credentials.mjs and sign-updater.mjs), never from a key file. Remove updater.keyFile from the release config.`,
|
|
286
302
|
});
|
|
287
303
|
}
|
|
304
|
+
const suiteApp = typeof config.app === "string" && RIGHT_SUITE_APP_KEYS.includes(config.app.toLowerCase());
|
|
305
|
+
const distribution = isObject(config.distribution) ? config.distribution : {};
|
|
306
|
+
const keyId = distribution.updaterKeyId;
|
|
307
|
+
if (keyId !== undefined && !isTemplateNode(keyId)) {
|
|
308
|
+
const accepted = RIGHT_SUITE_UPDATER_PUBLIC_KEYS.map((key) => key.kid);
|
|
309
|
+
const detail = typeof keyId !== "string" || !UPDATER_KEY_ID.test(keyId)
|
|
310
|
+
? `distribution.updaterKeyId must be the 16 upper-case hex digits of a minisign key id, got ${JSON.stringify(keyId)}.`
|
|
311
|
+
: suiteApp && !accepted.includes(keyId)
|
|
312
|
+
? `distribution.updaterKeyId ${keyId} is not an updater key in the Right Suite contract (accepted: ${accepted.join(", ")}). Remove it to sign with the contract's signing key.`
|
|
313
|
+
: null;
|
|
314
|
+
if (detail) problems.push({ name: "updater-key-id", code: "RELEASE_CONFIG_UPDATER_KEY_ID", pointer: "/distribution/updaterKeyId", detail });
|
|
315
|
+
}
|
|
316
|
+
if (suiteApp) {
|
|
317
|
+
const contractTexts = RIGHT_SUITE_UPDATER_PUBLIC_KEYS.map((key) => key.publicKey);
|
|
318
|
+
const declared = (pointer, label, value) => {
|
|
319
|
+
if (isBlank(value) || isTemplateNode(value) || contractTexts.includes(value)) return;
|
|
320
|
+
problems.push({
|
|
321
|
+
name: "updater-public-key",
|
|
322
|
+
code: "RELEASE_CONFIG_UPDATER_PUBLIC_KEY",
|
|
323
|
+
pointer,
|
|
324
|
+
detail: `${label} is not a Right Suite contract key: ${config.app} is a suite app and takes its updater keys from the contract (contracts/right-suite-api.v1.json), so remove ${label}.`,
|
|
325
|
+
});
|
|
326
|
+
};
|
|
327
|
+
for (const field of ["updaterPublicKey", "updaterPubkey"]) declared(`/distribution/${field}`, `distribution.${field}`, distribution[field]);
|
|
328
|
+
for (const [name, target] of targets) {
|
|
329
|
+
if (!isObject(target) || !isObject(target.updater)) continue;
|
|
330
|
+
for (const field of ["pubkey", "publicKey"]) declared(`/targets/${pointerKey(name)}/updater/${field}`, `targets.${name}.updater.${field}`, target.updater[field]);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
288
333
|
return problems;
|
|
289
334
|
}
|
|
290
335
|
|
|
@@ -297,7 +342,7 @@ function packageScripts(appRoot, readText) {
|
|
|
297
342
|
|
|
298
343
|
/**
|
|
299
344
|
* Load, validate and expand a release config. Returns `{ config, configPath, appRoot, sha256, inputs, lineOf }`. Throws ReleaseConfigError
|
|
300
|
-
* (RELEASE_CONFIG_LEGACY_EXECUTABLE | _NOT_JSON | _NOT_FOUND | _INVALID_JSON | _SCHEMA | _KEY | _UPDATER_KEY_FILE | _TEMPLATE | _M24). `validatePipeline: false`
|
|
345
|
+
* (RELEASE_CONFIG_LEGACY_EXECUTABLE | _NOT_JSON | _NOT_FOUND | _INVALID_JSON | _SCHEMA | _KEY | _UPDATER_KEY_FILE | _UPDATER_KEY_ID | _UPDATER_PUBLIC_KEY | _TEMPLATE | _M24). `validatePipeline: false`
|
|
301
346
|
* skips only the M24 command admission (the pipeline scanner reports those findings itself); nothing else is optional.
|
|
302
347
|
* File access, the generator registry, the environment and the home directory are injectable for tests.
|
|
303
348
|
*/
|