@rightkit/release 0.2.151 → 0.2.153

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.
Files changed (65) hide show
  1. package/LICENSE-APACHE +201 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +117 -8
  4. package/build-release.mjs +7 -2
  5. package/cli/right-release.mjs +18 -5
  6. package/committed-source.mjs +19 -1
  7. package/crate-graph.mjs +121 -0
  8. package/development-signing.mjs +8 -0
  9. package/development.mjs +46 -13
  10. package/devtools-policy.json +5 -0
  11. package/devtools-policy.mjs +119 -0
  12. package/direct-bootstrap.mjs +9 -3
  13. package/dmg-build.mjs +19 -5
  14. package/dmg-layout.mjs +2 -2
  15. package/dmg-spec.mjs +97 -0
  16. package/execution-policy.mjs +17 -1
  17. package/github-release.mjs +25 -7
  18. package/github-upload-gate.mjs +84 -0
  19. package/hardeningscan.mjs +3 -0
  20. package/inventory-check.mjs +4 -4
  21. package/mac-bundle-signing.mjs +1 -1
  22. package/mac-sign-core.mjs +97 -0
  23. package/mac-signing-matrix.mjs +186 -0
  24. package/msix-appinstaller.mjs +4 -1
  25. package/msix-config.mjs +5 -1
  26. package/msix-repair-guard.mjs +24 -0
  27. package/msix-repair-guard.ps1 +356 -0
  28. package/ownership-catalog-config.json +6 -7
  29. package/ownership-command.mjs +69 -8
  30. package/ownership-evidence.mjs +1 -1
  31. package/ownership-key-authority.mjs +1 -1
  32. package/ownership-key-command.mjs +15 -3
  33. package/ownership-key-elevation.mjs +11 -2
  34. package/ownership-mandatory-set.json +420 -0
  35. package/ownership-mandatory-set.mjs +273 -0
  36. package/ownership.mjs +2 -2
  37. package/package.json +15 -3
  38. package/packaged-app-identity.mjs +88 -0
  39. package/preflight.mjs +25 -3
  40. package/prune-r2.mjs +28 -3
  41. package/publication-gate.mjs +49 -10
  42. package/publish-cargo.mjs +21 -1
  43. package/publish-swift.mjs +78 -141
  44. package/publish-update.mjs +24 -2
  45. package/qa-build-features.mjs +216 -0
  46. package/qa-control-scan.mjs +435 -0
  47. package/registry-parity.mjs +18 -1
  48. package/release-invocation.mjs +2 -1
  49. package/release-state.mjs +9 -0
  50. package/release-switch-registry.json +33 -0
  51. package/release-switch-scan.mjs +452 -0
  52. package/release.mjs +320 -26
  53. package/renamed-away.mjs +29 -0
  54. package/rightkit-ownership.json +682 -709
  55. package/rightkit-sync.mjs +8 -4
  56. package/rightkit-version-policy.mjs +18 -0
  57. package/rightkit-versions.json +211 -127
  58. package/runtime-budget-classes.json +4 -0
  59. package/runtime-budgets.mjs +870 -0
  60. package/runtime-bundle-scan.mjs +61 -0
  61. package/sign-macos.mjs +28 -30
  62. package/sign-updater.mjs +5 -2
  63. package/swift-mac-dmg.mjs +7 -2
  64. package/swift-package-command.mjs +1 -1
  65. package/upload-release.mjs +10 -37
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ // right-release ownership-mandatory: Table 1 mandatory-owner-set check (plan task 5.1).
3
+ //
4
+ // A registered app must declare every Table 1 owner (rows M1-M23) at or above the required version,
5
+ // from the registry. Report mode (default) prints per-app findings and exits 0; --enforce exits 1
6
+ // on any missing owner, below-required version, or path/git/patch override ("not-on-registry").
7
+ // Mode switching of the ownership gate itself (Report -> Enforce) is a separate step; this check
8
+ // is read-only and never changes ownership mode.
9
+ //
10
+ // right-release ownership-mandatory [--workspace <dir>] [--app <name|dir>]... [--profile desktop|apple]
11
+ // [--amendment v2.1] [--enforce] [--json]
12
+ //
13
+ // App-side declaration (optional): <app>/rightkit-mandatory.json
14
+ // { "profile": "desktop", "notApplicable": { "M16": "reason text" } }
15
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
16
+ import path from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { parse as parseToml } from "smol-toml";
19
+
20
+ const packageRoot = path.dirname(fileURLToPath(import.meta.url));
21
+ export const DATA_FILE = "ownership-mandatory-set.json";
22
+ export const loadData = () => JSON.parse(readFileSync(path.join(packageRoot, DATA_FILE), "utf8"));
23
+
24
+ const SKIP_DIRS = new Set(["node_modules", "target", ".git", "rk-snapshot", "vendor", "dist", "build", "examples", "fixtures", "benchmarks", ".pnpm-store"]);
25
+ const MAX_MANIFESTS = 2000;
26
+
27
+ // ---- versions -------------------------------------------------------------------------------
28
+ /** Lower bound of the first comparator in a Cargo/npm requirement, or null when none can be proven. */
29
+ export function lowerBound(spec) {
30
+ if (typeof spec !== "string") return null;
31
+ let s = spec.trim().replace(/^npm:(?:@[^@/]+\/)?[^@]+@/, "");
32
+ const m = /(\d+)(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?/.exec(s);
33
+ if (!m || /^(\*|latest|x|)$/i.test(s)) return null;
34
+ const n = (v) => (v === undefined || /[xX*]/.test(v) ? 0 : Number(v));
35
+ return { major: Number(m[1]), minor: n(m[2]), patch: n(m[3]), pre: m[4] ?? null };
36
+ }
37
+ export const parseVersion = lowerBound;
38
+ export function compareVersions(a, b) {
39
+ for (const k of ["major", "minor", "patch"]) if (a[k] !== b[k]) return a[k] < b[k] ? -1 : 1;
40
+ if (a.pre === b.pre) return 0;
41
+ if (a.pre === null) return 1;
42
+ if (b.pre === null) return -1;
43
+ return a.pre < b.pre ? -1 : 1;
44
+ }
45
+ const show = (v) => `${v.major}.${v.minor}.${v.patch}${v.pre ? `-${v.pre}` : ""}`;
46
+
47
+ // ---- manifest discovery & parsing ---------------------------------------------------------
48
+ export function findManifests(root) {
49
+ const found = [];
50
+ const walk = (dir, depth) => {
51
+ if (found.length >= MAX_MANIFESTS || depth > 8) return;
52
+ let entries;
53
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
54
+ for (const e of entries) {
55
+ if (e.isSymbolicLink()) continue;
56
+ const full = path.join(dir, e.name);
57
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) walk(full, depth + 1); }
58
+ else if (e.name === "Cargo.toml" || e.name === "package.json") found.push(full);
59
+ }
60
+ };
61
+ walk(root, 0);
62
+ return found.sort();
63
+ }
64
+
65
+ const isNonRegistry = (v) => v && typeof v === "object" && ("path" in v || "git" in v);
66
+
67
+ /** Collect dependency facts: { owner, ecosystem, kind: "registry"|"override", dev, spec, via, file }. */
68
+ export function readManifest(file, root) {
69
+ const rel = path.relative(root, file).split(path.sep).join("/");
70
+ const text = readFileSync(file, "utf8");
71
+ const facts = [];
72
+ let workspaceDeps;
73
+ if (path.basename(file) === "Cargo.toml") {
74
+ let doc;
75
+ try { doc = parseToml(text); } catch (error) { return { rel, facts, error: `unparseable Cargo.toml: ${error.message}` }; }
76
+ workspaceDeps = doc.workspace?.dependencies;
77
+ const wsTable = workspaceDeps ?? {};
78
+ const sections = (table, dev) => {
79
+ for (const [section, isDev] of [["dependencies", dev], ["build-dependencies", dev], ["dev-dependencies", true]]) {
80
+ for (const [key, raw] of Object.entries(table?.[section] ?? {})) add(key, raw, isDev);
81
+ }
82
+ };
83
+ const add = (key, raw, dev) => {
84
+ let value = raw;
85
+ if (value && typeof value === "object" && value.workspace === true && wsTable[key] !== undefined) value = { ...(typeof wsTable[key] === "object" ? wsTable[key] : { version: wsTable[key] }), ...value };
86
+ const name = typeof value === "object" && typeof value.package === "string" ? value.package : key;
87
+ const spec = typeof value === "string" ? value : value?.version;
88
+ const unresolved = raw && typeof raw === "object" && raw.workspace === true && wsTable[key] === undefined;
89
+ facts.push({ owner: name, ecosystem: "cargo", kind: isNonRegistry(value) ? "override" : "registry", dev, spec: spec ?? null, via: isNonRegistry(value) ? ("path" in value ? "path dependency" : "git dependency") : "dependency", file: rel, ...(unresolved ? { inheritsWorkspace: key } : {}) });
90
+ };
91
+ sections(doc, false);
92
+ for (const target of Object.values(doc.target ?? {})) sections(target, false);
93
+ for (const [key, raw] of Object.entries(wsTable)) {
94
+ // workspace.dependencies only counts through members that opt in (above); record path/git overrides here.
95
+ if (isNonRegistry(raw)) facts.push({ owner: typeof raw.package === "string" ? raw.package : key, ecosystem: "cargo", kind: "override", dev: false, spec: raw.version ?? null, via: "workspace.dependencies path/git", file: rel, declarationOnly: true });
96
+ }
97
+ for (const [registry, table] of Object.entries(doc.patch ?? {})) {
98
+ for (const [key, raw] of Object.entries(table ?? {})) {
99
+ if (isNonRegistry(raw)) facts.push({ owner: typeof raw.package === "string" ? raw.package : key, ecosystem: "cargo", kind: "override", dev: false, spec: null, via: `[patch.${registry}]`, file: rel, patch: true });
100
+ }
101
+ }
102
+ } else {
103
+ let doc;
104
+ try { doc = JSON.parse(text); } catch (error) { return { rel, facts, error: `unparseable package.json: ${error.message}` }; }
105
+ for (const [section, dev] of [["dependencies", false], ["optionalDependencies", false], ["peerDependencies", false], ["devDependencies", true]]) {
106
+ for (const [name, spec] of Object.entries(doc[section] ?? {})) {
107
+ const alias = typeof spec === "string" ? /^npm:((?:@[^@/]+\/)?[^@]+)@/.exec(spec) : null;
108
+ const owner = alias ? alias[1] : name;
109
+ const local = typeof spec === "string" && /^(workspace:|file:|link:|portal:|git\+|git:|github:|https?:)/.test(spec);
110
+ facts.push({ owner, ecosystem: "npm", kind: local ? "override" : "registry", dev, spec: typeof spec === "string" ? spec : null, via: local ? `${spec.split(":")[0]}: specifier` : "dependency", file: rel });
111
+ }
112
+ }
113
+ for (const table of [doc.pnpm?.overrides, doc.overrides, doc.resolutions]) {
114
+ for (const [key, spec] of Object.entries(table ?? {})) {
115
+ if (typeof spec === "string" && /^(workspace:|file:|link:|portal:|git\+|github:)/.test(spec)) {
116
+ facts.push({ owner: key.replace(/@[^@/]*$/, ""), ecosystem: "npm", kind: "override", dev: false, spec, via: "package override", file: rel, patch: true });
117
+ }
118
+ }
119
+ }
120
+ }
121
+ return { rel, facts, workspaceDeps };
122
+ }
123
+
124
+ /** A member's `{ workspace = true }` names a table in an ancestor manifest's [workspace.dependencies]; resolve it across files. */
125
+ export function resolveWorkspaceInheritance(manifests) {
126
+ const roots = manifests.filter((m) => m.workspaceDeps && Object.keys(m.workspaceDeps).length);
127
+ for (const m of manifests) {
128
+ for (const fact of m.facts) {
129
+ if (!fact.inheritsWorkspace) continue;
130
+ const dir = path.posix.dirname(m.rel);
131
+ const owner = roots
132
+ .filter((r) => r.workspaceDeps[fact.inheritsWorkspace] !== undefined)
133
+ .filter((r) => { const rd = path.posix.dirname(r.rel); return rd === "." || dir === rd || dir.startsWith(`${rd}/`); })
134
+ .sort((a, b) => path.posix.dirname(b.rel).length - path.posix.dirname(a.rel).length)[0];
135
+ if (!owner) continue;
136
+ const declared = owner.workspaceDeps[fact.inheritsWorkspace];
137
+ const table = declared && typeof declared === "object" ? declared : { version: declared };
138
+ fact.spec = table.version ?? null;
139
+ fact.kind = isNonRegistry(table) ? "override" : "registry";
140
+ if (isNonRegistry(table)) fact.via = "path" in table ? "path dependency" : "git dependency";
141
+ if (typeof table.package === "string") fact.owner = table.package;
142
+ }
143
+ }
144
+ }
145
+
146
+ // ---- evaluation --------------------------------------------------------------------------
147
+ /** Merge row owners into one requirement per (ecosystem,name): max min, any allowDev, union of rows. */
148
+ function requirements(rows) {
149
+ const map = new Map();
150
+ for (const row of rows) for (const o of row.owners) {
151
+ const key = `${o.ecosystem}:${o.name}`;
152
+ const cur = map.get(key) ?? { ...o, rows: [] };
153
+ if (compareVersions(lowerBound(o.min), lowerBound(cur.min)) > 0) cur.min = o.min;
154
+ cur.allowDev = Boolean(cur.allowDev || o.allowDev);
155
+ cur.rows.push(row.id);
156
+ map.set(key, cur);
157
+ }
158
+ return map;
159
+ }
160
+
161
+ function judge(req, facts) {
162
+ const mine = facts.filter((f) => f.owner === req.name && f.ecosystem === req.ecosystem);
163
+ const overrides = mine.filter((f) => f.kind === "override");
164
+ const counted = mine.filter((f) => f.kind === "registry" && (req.allowDev || !f.dev));
165
+ const devOnly = !counted.length && mine.some((f) => f.kind === "registry" && f.dev);
166
+ const min = lowerBound(req.min);
167
+ const base = { owner: req.name, ecosystem: req.ecosystem, rows: [...new Set(req.rows)], required: req.min };
168
+ const bounds = counted.map((f) => ({ f, v: lowerBound(f.spec) }));
169
+ const best = bounds.filter((b) => b.v).sort((a, b) => compareVersions(b.v, a.v))[0];
170
+ const declaredAt = best ? show(best.v) : null;
171
+ const registryExempt = req.registry === false;
172
+ if (overrides.length && !registryExempt) {
173
+ return { ...base, status: "not-on-registry", declared: declaredAt, evidence: overrides.map((f) => `${f.file}: ${f.via}${f.spec ? ` (${f.spec})` : ""}`) };
174
+ }
175
+ if (!counted.length && !(registryExempt && overrides.length)) {
176
+ return { ...base, status: "missing", declared: null, evidence: devOnly ? ["declared only under dev-dependencies"] : [] };
177
+ }
178
+ if (!counted.length) return { ...base, status: "ok", declared: "path/git (registry not required)", evidence: overrides.map((f) => `${f.file}: ${f.via}`) };
179
+ if (!best) return { ...base, status: "below-required", declared: null, evidence: counted.map((f) => `${f.file}: version not provable from ${JSON.stringify(f.spec)}`) };
180
+ if (compareVersions(best.v, min) < 0) return { ...base, status: "below-required", declared: declaredAt, evidence: [`${best.f.file}: ${best.f.spec} < ${req.min}`] };
181
+ return { ...base, status: "ok", declared: declaredAt, evidence: [] };
182
+ }
183
+
184
+ export function loadAppDeclaration(root, data) {
185
+ const file = path.join(root, data.appDeclarationFile);
186
+ if (!existsSync(file)) return { profile: data.defaultProfile, notApplicable: {}, problems: [] };
187
+ const problems = [];
188
+ let doc = {};
189
+ try { doc = JSON.parse(readFileSync(file, "utf8")); } catch (error) { problems.push(`${data.appDeclarationFile} unparseable: ${error.message}`); }
190
+ const notApplicable = {};
191
+ for (const [id, reason] of Object.entries(doc.notApplicable ?? {})) {
192
+ if (typeof reason === "string" && reason.trim().length >= 10) notApplicable[id] = reason.trim();
193
+ else problems.push(`${id}: N/A declaration needs a reason (>= 10 chars); ignored`);
194
+ }
195
+ return { profile: doc.profile ?? data.defaultProfile, notApplicable, problems };
196
+ }
197
+
198
+ export function checkApp(root, { data = loadData(), profile, amendments = [] } = {}) {
199
+ const name = path.basename(root);
200
+ 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 };
201
+ const declaration = loadAppDeclaration(root, data);
202
+ const active = profile ?? declaration.profile;
203
+ const problems = [...declaration.problems];
204
+ if (!data.profiles[active]) problems.push(`unknown profile "${active}"`);
205
+ const rows = data.rows.filter((r) => r.profile === active).map((r) => ({ ...r, owners: [...r.owners] }));
206
+ for (const key of amendments) {
207
+ const amendment = data.amendments?.[key];
208
+ if (!amendment) { problems.push(`unknown amendment "${key}"`); continue; }
209
+ for (const extra of amendment.rows.filter((r) => r.profile === active)) {
210
+ const row = rows.find((r) => r.id === extra.id);
211
+ if (row) row.owners.push(...extra.owners);
212
+ }
213
+ }
214
+ const notApplicable = Object.entries(declaration.notApplicable).map(([id, reason]) => ({ row: id, reason }));
215
+ const applicable = rows.filter((r) => !(r.id in declaration.notApplicable));
216
+ const pending = data.pendingRows.filter((r) => r.profile === active).map((r) => ({ row: r.id, name: r.name, status: "owner-pending", note: r.ownerPending }));
217
+ const manifests = findManifests(root).map((f) => readManifest(f, root));
218
+ resolveWorkspaceInheritance(manifests);
219
+ for (const m of manifests) if (m.error) problems.push(`${m.rel}: ${m.error}`);
220
+ const facts = manifests.flatMap((m) => m.facts);
221
+ const owners = [...requirements(applicable).values()].map((req) => judge(req, facts));
222
+ // A profile that is unknown, marked unratified, or has no rows checks nothing; an app may not select it, so it fails rather than passing empty.
223
+ const profileStatus = !data.profiles[active] ? "unknown-profile" : (data.profiles[active].ratified === false || !data.rows.some((r) => r.profile === active)) ? "unratified" : null;
224
+ if (profileStatus) {
225
+ problems.push(`profile "${active}" is ${profileStatus === "unratified" ? "unratified or has no rows" : "unknown"}; no mandatory set is checked`);
226
+ owners.push({ owner: `profile:${active}`, ecosystem: "profile", rows: [], required: "ratified profile", status: profileStatus, declared: null, evidence: ["an app may not select a profile that is not ratified"] });
227
+ }
228
+ const violations = owners.filter((o) => o.status !== "ok").length;
229
+ return { app: name, root, scanned: true, profile: active, manifests: manifests.length, owners, pending, notApplicable, problems, violations };
230
+ }
231
+
232
+ export function checkApps(roots, options = {}) {
233
+ const data = options.data ?? loadData();
234
+ const apps = roots.map((r) => checkApp(r, { ...options, data }));
235
+ return { schema: 1, mode: options.enforce ? "enforce" : "report", amendments: options.amendments ?? [], apps, failed: apps.some((a) => !a.scanned || a.violations > 0) };
236
+ }
237
+
238
+ export function renderText(report) {
239
+ const out = [`ownership-mandatory (${report.mode}) — Table 1 mandatory owner set`];
240
+ for (const app of report.apps) {
241
+ const count = (s) => app.owners.filter((o) => o.status === s).length;
242
+ out.push("", `${app.app} [${app.profile}] ${app.scanned ? `ok=${count("ok")} missing=${count("missing")} below-required=${count("below-required")} not-on-registry=${count("not-on-registry")}` : "UNSCANNED"}`
243
+ + (app.pending.length ? ` pending=${app.pending.map((p) => p.row).join(",")}` : "") + (app.notApplicable.length ? ` n/a=${app.notApplicable.map((n) => n.row).join(",")}` : ""));
244
+ for (const o of app.owners.filter((x) => x.status !== "ok")) out.push(` ${o.status.padEnd(15)} ${o.owner} (${o.rows.join(",")}) requires ${o.required}${o.declared ? `, declared ${o.declared}` : ""}${o.evidence.length ? ` — ${o.evidence.join("; ")}` : ""}`);
245
+ for (const n of app.notApplicable) out.push(` n/a ${n.row}: ${n.reason}`);
246
+ for (const p of app.problems) out.push(` problem ${p}`);
247
+ }
248
+ out.push("", `${report.apps.filter((a) => !a.scanned || a.violations).length}/${report.apps.length} apps fail the mandatory set${report.mode === "enforce" ? "" : " (report mode: exit 0)"}`);
249
+ return `${out.join("\n")}\n`;
250
+ }
251
+
252
+ const flags = (args, name) => args.flatMap((a, i) => (a === name ? [args[i + 1]] : []));
253
+ export async function main(args, io = {}) {
254
+ const stdout = io.stdout ?? process.stdout, stderr = io.stderr ?? process.stderr;
255
+ const data = loadData();
256
+ const workspace = path.resolve(flags(args, "--workspace")[0] ?? path.resolve(packageRoot, "../../.."));
257
+ const requested = flags(args, "--app");
258
+ const roots = (requested.length ? requested : data.defaultApps).map((a) => (path.isAbsolute(a) || a.includes(path.sep) ? path.resolve(a) : path.join(workspace, a)));
259
+ const amendments = flags(args, "--amendment").map((a) => a.replace(/^v?/, "v"));
260
+ const profile = flags(args, "--profile")[0];
261
+ if (profile && !data.profiles[profile]) { stderr.write(`[right-release] ownership-mandatory: unknown profile "${profile}"\n`); return 2; }
262
+ const unknownAmendment = amendments.find((a) => !data.amendments?.[a]);
263
+ if (unknownAmendment) { stderr.write(`[right-release] ownership-mandatory: unknown amendment "${unknownAmendment}"\n`); return 2; }
264
+ const report = checkApps(roots, { data, profile, amendments, enforce: args.includes("--enforce") });
265
+ stdout.write(args.includes("--json") ? `${JSON.stringify(report, null, 2)}\n` : renderText(report));
266
+ return args.includes("--enforce") && report.failed ? 1 : 0;
267
+ }
268
+
269
+ const realOrSelf = (file) => { try { return realpathSync(file); } catch { return path.resolve(file); } };
270
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
271
+ try { process.exitCode = await main(process.argv.slice(2)); }
272
+ catch (error) { console.error(`[right-release] ownership-mandatory: ${error.message}`); process.exitCode = 1; }
273
+ }
package/ownership.mjs CHANGED
@@ -480,7 +480,7 @@ function scanRepositoryRoot(appRoot) {
480
480
  if (!rootPath || !path.isAbsolute(rootPath)) return { root: path.resolve(appRoot), prefix: "", pathBase: "scan-root", commit };
481
481
  // Git prints the resolved root (macOS /private/var/...); resolve the app root the same way so a
482
482
  // symlinked spelling (/var, /tmp) is not mistaken for a path outside the repository.
483
- const real = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
483
+ const real = (p) => { try { return realpathSync.native(p); } catch { return path.resolve(p); } };
484
484
  const root = real(rootPath), app = real(appRoot);
485
485
  const relative = path.relative(root, app);
486
486
  if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return { root: app, prefix: "", pathBase: "scan-root", commit };
@@ -758,7 +758,7 @@ function checkCommitted(appRoot, manifest, options, acquire, readAuthority = rea
758
758
  if ((options.expectedCommit && committed.commit !== options.expectedCommit) || (options.expectedTree && committed.tree !== options.expectedTree)) throw new Error("committed ownership: source handle does not match expected commit/tree");
759
759
  // Compare real paths: Git reports the resolved root (macOS /private/var/...), while callers
760
760
  // may pass a symlinked spelling of the same directory (/var/..., /tmp/...).
761
- const real = (p) => { try { return realpathSync(p); } catch { return path.resolve(p); } };
761
+ const real = (p) => { try { return realpathSync.native(p); } catch { return path.resolve(p); } };
762
762
  const relativeRoot = path.relative(real(committed.repositoryRoot), real(appRoot));
763
763
  if (relativeRoot === ".." || relativeRoot.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRoot)) throw new Error("committed ownership: scan root is outside committed repository");
764
764
  const pathPrefix = relativeRoot ? `${relativeRoot.split(path.sep).join("/")}/` : "";
package/package.json CHANGED
@@ -1,18 +1,30 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.151",
3
+ "version": "0.2.153",
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
9
  },
10
+ "rightkit": {
11
+ "publishCheck": {
12
+ "binSmoke": {
13
+ "right-release": [
14
+ "--version"
15
+ ]
16
+ }
17
+ }
18
+ },
10
19
  "files": [
11
20
  "cli",
12
21
  "*.mjs",
13
22
  "*.json",
14
23
  "*.sh",
24
+ "msix-repair-guard.ps1",
15
25
  "*.py",
26
+ "LICENSE-MIT",
27
+ "LICENSE-APACHE",
16
28
  "!*.test.mjs",
17
29
  "!standalone-clone-evidence.json"
18
30
  ],
@@ -23,11 +35,11 @@
23
35
  },
24
36
  "repository": {
25
37
  "type": "git",
26
- "url": "git+https://github.com/bogusyogi/rightkit.git",
38
+ "url": "git+https://github.com/Orthic-Labs/rightkit.git",
27
39
  "directory": "packages/release"
28
40
  },
29
41
  "dependencies": {
30
- "smol-toml": "1.7.0"
42
+ "smol-toml": "1.9.0"
31
43
  },
32
44
  "scripts": {
33
45
  "test": "node --test --test-concurrency=1 --test-force-exit *.test.mjs",
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // Packaged macOS app identity assertions (M19 owner gap 3): codesign Identifier,
3
+ // TeamIdentifier, hardened runtime, no ad-hoc signature, expected cdhash, and
4
+ // lipo architectures. Asserts only what the caller declares; it never signs.
5
+ // Usage: right-release identity-scan --target <path> [--identifier <id>] [--team <OU>]
6
+ // [--cdhash <hex>] [--arch <arch>]... [--allow-adhoc]
7
+ import { spawnSync } from "./background-process.mjs";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ /** Parse `codesign -dv --verbose=4` output (stderr text) into fields. */
11
+ export function parseCodesignDisplay(text) {
12
+ const out = { authorities: [] };
13
+ for (const line of String(text).split(/\r?\n/)) {
14
+ const m = /^([A-Za-z][A-Za-z0-9 ]*)=(.*)$/.exec(line.trim());
15
+ if (!m) continue;
16
+ const [, key, value] = m;
17
+ if (key === "Authority") out.authorities.push(value);
18
+ else if (key === "Identifier") out.identifier = value;
19
+ else if (key === "TeamIdentifier") out.team = value;
20
+ else if (key === "CDHash") out.cdhash = value.toLowerCase();
21
+ else if (key === "Signature") out.signature = value;
22
+ }
23
+ const flags = /flags=0x[0-9a-f]+\(([^)]*)\)/i.exec(String(text));
24
+ out.flags = flags ? flags[1].split(",").map((s) => s.trim()).filter(Boolean) : [];
25
+ out.adhoc = out.signature === "adhoc" || out.flags.includes("adhoc");
26
+ out.runtime = out.flags.includes("runtime");
27
+ return out;
28
+ }
29
+
30
+ /** Parse `lipo -archs` output. */
31
+ export function parseLipoArchs(text) {
32
+ return String(text).trim().split(/\s+/).filter(Boolean);
33
+ }
34
+
35
+ /** Return the list of failures for the declared expectations (empty = pass). */
36
+ export function assertPackagedIdentity(display, archs, expected = {}) {
37
+ const failures = [];
38
+ if (!expected.allowAdhoc && display.adhoc) failures.push("signature is ad-hoc; a release must be Developer ID signed");
39
+ if (expected.identifier && display.identifier !== expected.identifier) {
40
+ failures.push(`Identifier must be ${expected.identifier} (got ${display.identifier ?? "none"})`);
41
+ }
42
+ if (expected.team && display.team !== expected.team) {
43
+ failures.push(`TeamIdentifier must be ${expected.team} (got ${display.team ?? "none"})`);
44
+ }
45
+ if (expected.cdhash && display.cdhash !== String(expected.cdhash).toLowerCase()) {
46
+ failures.push(`CDHash must be ${expected.cdhash} (got ${display.cdhash ?? "none"})`);
47
+ }
48
+ if (!expected.allowAdhoc && !display.runtime) failures.push("hardened runtime flag is missing");
49
+ for (const arch of expected.archs ?? []) {
50
+ if (!archs.includes(arch)) failures.push(`missing architecture ${arch} (has ${archs.join(",") || "none"})`);
51
+ }
52
+ return failures;
53
+ }
54
+
55
+ function capture(cmd, args) {
56
+ const r = spawnSync(cmd, args, { encoding: "utf8" });
57
+ return { status: r.status ?? 1, output: `${r.stdout ?? ""}${r.stderr ?? ""}` };
58
+ }
59
+
60
+ function main(argv) {
61
+ const expected = { archs: [] };
62
+ let target = "";
63
+ for (let i = 0; i < argv.length; i++) {
64
+ const a = argv[i];
65
+ if (a === "--target") target = argv[++i] ?? "";
66
+ else if (a === "--identifier") expected.identifier = argv[++i];
67
+ else if (a === "--team") expected.team = argv[++i];
68
+ else if (a === "--cdhash") expected.cdhash = argv[++i];
69
+ else if (a === "--arch") expected.archs.push(argv[++i]);
70
+ else if (a === "--allow-adhoc") expected.allowAdhoc = true;
71
+ else { console.error(`identity-scan: unknown argument ${a}`); return 2; }
72
+ }
73
+ if (!target) { console.error("usage: right-release identity-scan --target <path> [--identifier <id>] [--team <OU>] [--cdhash <hex>] [--arch <arch>]... [--allow-adhoc]"); return 2; }
74
+ const display = capture("codesign", ["-dv", "--verbose=4", target]);
75
+ if (display.status !== 0) { console.error(`identity-scan: codesign could not read ${target}:\n${display.output}`); return 1; }
76
+ let archs = [];
77
+ if (expected.archs.length) {
78
+ const lipo = capture("lipo", ["-archs", target]);
79
+ if (lipo.status !== 0) { console.error(`identity-scan: lipo failed:\n${lipo.output}`); return 1; }
80
+ archs = parseLipoArchs(lipo.output);
81
+ }
82
+ const failures = assertPackagedIdentity(parseCodesignDisplay(display.output), archs, expected);
83
+ for (const f of failures) console.error(`identity-scan: ${f}`);
84
+ if (!failures.length) console.log(`identity-scan: ${target} ok`);
85
+ return failures.length ? 1 : 0;
86
+ }
87
+
88
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) process.exit(main(process.argv.slice(2)));
package/preflight.mjs CHANGED
@@ -23,6 +23,7 @@ import path from "node:path";
23
23
  import { spawnSync } from "./background-process.mjs";
24
24
  import { brokerManagedHost } from "./cargo-contract.mjs";
25
25
  import { createOwnershipEvidence } from "./ownership-evidence.mjs";
26
+ import { appRuntimeBudgetAdoptionCheck } from "./runtime-budgets.mjs";
26
27
  import { isSwiftProject } from "./project-type.mjs";
27
28
 
28
29
  export const PREFLIGHT_OK = "ok";
@@ -137,6 +138,17 @@ export function nextFreeVersion(sealedDir, app, currentVersion, { readdir = read
137
138
  return { taken: [...taken], collision: true, suggestion };
138
139
  }
139
140
 
141
+ /** The path itself, or its nearest existing ancestor (df cannot measure a directory that does not exist yet). */
142
+ export function nearestExisting(target) {
143
+ let current = path.resolve(target);
144
+ for (;;) {
145
+ if (existsSync(current)) return current;
146
+ const parent = path.dirname(current);
147
+ if (parent === current) return null;
148
+ current = parent;
149
+ }
150
+ }
151
+
140
152
  function freeDiskGb(targetPath) {
141
153
  if (process.platform === "win32") {
142
154
  const drive = path.parse(path.resolve(targetPath)).root.replace(/\\$/, "");
@@ -168,18 +180,20 @@ export function collectPreflight({
168
180
  platform,
169
181
  appRoot,
170
182
  repoRoot,
183
+ env = process.env,
171
184
  app,
172
185
  version,
173
186
  configPath,
174
187
  cargoLockPaths = [],
175
188
  nativeLayout,
176
189
  targetLink,
177
- minFreeGb = 25,
178
- env = process.env,
190
+ minFreeGb = Number.isFinite(Number(env.RIGHT_RELEASE_MIN_FREE_GB)) && Number(env.RIGHT_RELEASE_MIN_FREE_GB) > 0 ? Number(env.RIGHT_RELEASE_MIN_FREE_GB) : 25,
179
191
  versionProbe = commandVersion,
180
192
  isBrokerManagedHost = brokerManagedHost,
181
193
  ownership = true,
182
194
  ownershipEvidenceFactory = createOwnershipEvidence,
195
+ runtimeBudgetAdoption = appRuntimeBudgetAdoptionCheck,
196
+ freeDisk = freeDiskGb,
183
197
  projectType,
184
198
  msix = false,
185
199
  findSdkTool = findWindowsSdkTool,
@@ -288,7 +302,9 @@ export function collectPreflight({
288
302
  }
289
303
 
290
304
  if (appRoot) {
291
- const free = freeDiskGb(appRoot);
305
+ // The floor protects the volume builds write to: the cargo target directory when the layout names one
306
+ // (builds go there, often on a different drive from the app), else the app root.
307
+ const free = freeDisk(nearestExisting(nativeLayout?.targetDir ?? appRoot) ?? appRoot);
292
308
  if (free === null) {
293
309
  checks.push(warn("disk", "could not determine free disk space", null));
294
310
  } else {
@@ -304,6 +320,12 @@ export function collectPreflight({
304
320
  }
305
321
  }
306
322
 
323
+ // Held-resource crates are proven at adoption: an app that links one needs its own installed measurement.
324
+ if (appRoot && !swift) {
325
+ const adoption = runtimeBudgetAdoption({ appRoot, app, version, cargoLockPaths });
326
+ if (adoption) checks.push(adoption);
327
+ }
328
+
307
329
  if (repoRoot && ownership) {
308
330
  // An app living inside the RightKit workspace repository is scoped to its own directory.
309
331
  const workspace = existsSync(path.join(repoRoot, "tools", "rightkit", "packages", "release"));
package/prune-r2.mjs CHANGED
@@ -3,16 +3,41 @@ import { resolveCloudflareApiToken } from "./protected-credentials.mjs";
3
3
  const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID || "03ae77ccd7a07bcbb2dcfde47fa7ba3a";
4
4
  const BUCKETS = { public: "rightapps-downloads", private: "rightapps-updates" };
5
5
 
6
- export async function pruneReleaseObjects({ app, platform, keep, dryRun = false, env = process.env, credentialStore }) {
6
+ // Versioned install sources (`<app>/installers/<platform>/versions/<version>/<file>`) back MSIX App Installer
7
+ // repair: Windows re-adds a package from the URI it was installed from, so the newest `previous` versions
8
+ // beyond the explicitly kept keys survive pruning.
9
+ const VERSIONED = (app, platform) => new RegExp(`^${app.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/installers/${platform}/versions/([^/]+)/`);
10
+ const versionParts = (v) => v.split(/[.+-]/).map((x) => (/^\d+$/.test(x) ? Number(x) : -1));
11
+ export function compareVersions(a, b) {
12
+ const x = versionParts(a), y = versionParts(b);
13
+ for (let i = 0; i < Math.max(x.length, y.length); i++) { const d = (x[i] ?? 0) - (y[i] ?? 0); if (d) return d; }
14
+ return 0;
15
+ }
16
+ export function previousVersionKeys({ app, platform, keys, keep, previous = 1 }) {
17
+ const re = VERSIONED(app, platform);
18
+ const kept = new Set([...keep].map((key) => re.exec(key)?.[1]).filter(Boolean));
19
+ const others = new Map();
20
+ for (const key of keys) {
21
+ const version = re.exec(key)?.[1];
22
+ if (!version || kept.has(version)) continue;
23
+ others.set(version, [...(others.get(version) ?? []), key]);
24
+ }
25
+ const newest = [...others.keys()].sort((a, b) => compareVersions(b, a)).slice(0, previous);
26
+ return new Set(newest.flatMap((version) => others.get(version)));
27
+ }
28
+
29
+ export async function pruneReleaseObjects({ app, platform, keep, dryRun = false, env = process.env, credentialStore, retainPreviousVersions = 0 }) {
7
30
  if (!new Set(["mac", "windows"]).has(platform)) throw new Error(`invalid R2 prune platform: ${platform}`);
8
31
  const token = resolveCloudflareApiToken({ env, credentialStore, required: !dryRun });
9
32
  for (const [alias, bucket] of Object.entries(BUCKETS)) {
10
- const retained = new Set(keep[alias] ?? []);
33
+ let retained = new Set(keep[alias] ?? []);
11
34
  if (dryRun) {
12
35
  console.log(`[dry-run] PRUNE ${bucket}/${app}/${platform} keep=${[...retained].sort().join(",") || "<none>"}`);
13
36
  continue;
14
37
  }
15
- for (const key of await listObjects(bucket, app, token)) {
38
+ const listed = await listObjects(bucket, app, token);
39
+ if (retainPreviousVersions > 0) retained = new Set([...retained, ...previousVersionKeys({ app, platform, keys: listed, keep: retained, previous: retainPreviousVersions })]);
40
+ for (const key of listed) {
16
41
  if (!isReleaseObject(app, platform, key) || retained.has(key)) continue;
17
42
  console.log(`DELETE ${bucket}/${key}`);
18
43
  await deleteObject(bucket, key, token);
@@ -17,6 +17,9 @@ export const REQUIRED_HOSTS = Object.freeze(["darwin", "win32"]);
17
17
  // Swift packages (RightKitSwift) are Apple-only; Windows cannot run them.
18
18
  export const requiredHosts = (kind) => (kind === "swift" ? Object.freeze(["darwin"]) : REQUIRED_HOSTS);
19
19
  const SHA = /^[0-9a-f]{40}$/;
20
+ // The one definition of a recorded darwin-only host policy (release evidence and the runtime budget gate share it).
21
+ export const isDarwinOnlyPolicy = (source) => source?.hostPolicy === "darwin-only"
22
+ && typeof source.hostPolicyReason === "string" && source.hostPolicyReason.trim() !== "";
20
23
 
21
24
  export function defaultEvidencePath(workspaceRoot, kind, name) {
22
25
  return path.join(workspaceRoot, "release-evidence", "gate", `${kind}-${name.replace(/^@/, "").replace("/", "-")}.json`);
@@ -28,6 +31,40 @@ function defaultGit(args, cwd) {
28
31
  return (result.stdout ?? "").trim();
29
32
  }
30
33
 
34
+ // Explains a receipt commit that is not the publishing HEAD. The gate still refuses it (exact-commit rule,
35
+ // docs/RIGHTKIT-SHARED-PACKAGES.md "Publication gate" rule 1); this only says whether the receipt is an
36
+ // ancestor with an unchanged unit path (re-qualify by re-running on HEAD) or unrelated/changed.
37
+ function describeCommitMismatch(evidenceCommit, head, packagePath, git) {
38
+ const known = git(["cat-file", "-t", evidenceCommit], packagePath);
39
+ if (known !== "commit") return "receipt commit is not a commit object in this repository";
40
+ // `merge-base --is-ancestor` prints nothing: "" means ancestor, null (non-zero exit) means not.
41
+ const ancestor = git(["merge-base", "--is-ancestor", evidenceCommit, head], packagePath) !== null;
42
+ const relation = ancestor ? git(["rev-list", "--count", `${evidenceCommit}..${head}`], packagePath) : null;
43
+ const changed = git(["diff", "--name-only", evidenceCommit, head, "--", "."], packagePath);
44
+ const parts = [];
45
+ parts.push(ancestor ? `receipt commit is an ancestor, ${relation ?? "?"} commit(s) behind HEAD` : "receipt commit is not an ancestor of HEAD");
46
+ if (changed === null) parts.push("cannot diff the unit path");
47
+ else parts.push(changed === "" ? "unit path unchanged since the receipt (re-run the host runs on HEAD to re-qualify)" : `unit path changed since the receipt: ${changed.split("\n").slice(0, 5).join(", ")}`);
48
+ return parts.join("; ");
49
+ }
50
+
51
+ // Names of the fields of one host run that do not qualify (empty when the run qualifies). Every run binds to
52
+ // the same unit, commit and artifact: commit, host arch, feature set and artifactSha256 equal to the evidence.
53
+ function invalidRunFields(run, evidence) {
54
+ const bad = [];
55
+ const text = (value) => typeof value === "string" && value.trim() !== "";
56
+ if (run.commit !== evidence.commit) bad.push(`commit (must equal ${evidence.commit})`);
57
+ if (!text(run.host?.arch)) bad.push("host.arch");
58
+ if (!(Array.isArray(run.features) && run.features.length > 0 && run.features.every(text))) bad.push("features");
59
+ if (run.artifactSha256 !== evidence.artifactSha256) bad.push("artifactSha256 (must equal the evidence hash)");
60
+ if (!text(run.command)) bad.push("command");
61
+ if (!text(run.operator)) bad.push("operator");
62
+ if (!text(run.timestamp)) bad.push("timestamp");
63
+ if (!(Number.isInteger(run.passed) && run.passed > 0)) bad.push("passed (must be > 0)");
64
+ if (run.failed !== 0) bad.push("failed (must be 0)");
65
+ return bad;
66
+ }
67
+
31
68
  // Returns { ok, missing[] }. Pure apart from the injected git/readFile.
32
69
  export function checkPublicationGate({ kind, name, version, packagePath, evidencePath, artifactBytes, git = defaultGit, readFile = (p) => fs.readFileSync(p, "utf8") }) {
33
70
  const missing = [];
@@ -43,25 +80,27 @@ export function checkPublicationGate({ kind, name, version, packagePath, evidenc
43
80
  if (evidence) {
44
81
  if (evidence.schema !== GATE_SCHEMA) missing.push(`evidence schema must be ${GATE_SCHEMA}`);
45
82
  if (evidence.kind !== kind || evidence.name !== name) missing.push(`evidence is for ${evidence.kind}:${evidence.name}, expected ${kind}:${name}`);
46
- if (version && evidence.version !== version) missing.push(`evidence version ${evidence.version} != ${version}`);
83
+ // A missing publishing version must refuse: a skipped comparison would qualify any evidence version.
84
+ if (typeof version !== "string" || version.trim() === "") missing.push("publishing version is unknown (manifest has no literal version), cannot bind the receipt to it");
85
+ else if (evidence.version !== version) missing.push(`evidence version ${evidence.version} != ${version}`);
47
86
  if (!SHA.test(String(evidence.commit ?? ""))) missing.push("evidence commit is not a full 40-character SHA");
48
- else if (head && evidence.commit !== head) missing.push(`evidence commit ${evidence.commit} != current HEAD ${head}`);
87
+ else if (head && evidence.commit !== head) {
88
+ missing.push(`evidence commit ${evidence.commit} != current HEAD ${head} (${describeCommitMismatch(evidence.commit, head, packagePath, git)})`);
89
+ }
49
90
  const runs = Array.isArray(evidence.runs) ? evidence.runs : [];
50
91
  // npm packages and crates may ship on macOS evidence first when Adrian decides so; the decision must be
51
92
  // recorded in the evidence (hostPolicy "darwin-only" + a non-empty hostPolicyReason).
52
93
  // Crates may ship Mac-first too (Adrian 2026-10-07): the Windows run follows; the reason is recorded.
53
- const darwinOnly = (kind === "npm" || kind === "cargo") && evidence.hostPolicy === "darwin-only"
54
- && typeof evidence.hostPolicyReason === "string" && evidence.hostPolicyReason.trim() !== "";
94
+ const darwinOnly = (kind === "npm" || kind === "cargo") && isDarwinOnlyPolicy(evidence);
55
95
  for (const host of darwinOnly ? ["darwin"] : requiredHosts(kind)) {
56
96
  const label = host === "darwin" ? "macOS" : "Windows";
57
97
  const matching = runs.filter((run) => run?.host?.os === host);
58
98
  if (!matching.length) { missing.push(`no ${label} host run receipt`); continue; }
59
- const good = matching.some((run) => run.commit === evidence.commit
60
- && Number.isInteger(run.passed) && run.passed > 0 && run.failed === 0
61
- && typeof run.command === "string" && run.command.trim()
62
- && typeof run.operator === "string" && run.operator.trim()
63
- && typeof run.timestamp === "string" && run.timestamp.trim());
64
- if (!good) missing.push(`${label} run receipt must name commit ${evidence.commit}, command, operator, timestamp, passed>0 and failed=0`);
99
+ // Every run on the host must qualify: a failing run is not masked by a passing run beside it.
100
+ matching.forEach((run, index) => {
101
+ const fields = invalidRunFields(run, evidence);
102
+ if (fields.length) missing.push(`${label} run receipt ${index} invalid: ${fields.join(", ")}`);
103
+ });
65
104
  }
66
105
  if (!/^[0-9a-f]{64}$/.test(String(evidence.artifactSha256 ?? ""))) missing.push("evidence artifactSha256 must be a 64-hex hash");
67
106
  }