@rightkit/release 0.6.5 → 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.
Files changed (73) hide show
  1. package/README.md +196 -11
  2. package/admission-files.mjs +3 -2
  3. package/advisory-db.json +4 -0
  4. package/advisory-db.mjs +124 -0
  5. package/advisory-exceptions.mjs +13 -10
  6. package/android-play-upload.mjs +4 -4
  7. package/app-check.mjs +702 -26
  8. package/apple-profile-create.mjs +4 -3
  9. package/apple-profile-renew.mjs +5 -4
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/rightkit-agent-hook.mjs +9 -0
  12. package/bin/rightkit-gate.mjs +671 -133
  13. package/binary-license-notices.mjs +2 -1
  14. package/build-release.mjs +20 -12
  15. package/cache-command.mjs +4 -3
  16. package/cargo-license-notices.mjs +2 -1
  17. package/cargo-manifest-text.mjs +25 -0
  18. package/cargo-profile-contract.mjs +26 -13
  19. package/cli/right-release.mjs +37 -7
  20. package/command-spec.mjs +4 -5
  21. package/compile-inputs.mjs +66 -0
  22. package/consumer-contracts.json +1044 -0
  23. package/consumer-contracts.mjs +627 -0
  24. package/consumer-input-contracts.mjs +422 -0
  25. package/consumer-policy.mjs +588 -0
  26. package/consumer-source-contracts.mjs +469 -0
  27. package/deny-default.toml +42 -0
  28. package/deps.mjs +5 -4
  29. package/deterministic-scans.mjs +513 -54
  30. package/development.mjs +5 -5
  31. package/disk-command.mjs +270 -30
  32. package/disk-policy.json +50 -10
  33. package/disk-policy.mjs +482 -35
  34. package/disk-report.mjs +129 -0
  35. package/door-policy.mjs +228 -0
  36. package/gate-executor.mjs +30 -0
  37. package/gate-stages.mjs +110 -0
  38. package/github-release.mjs +5 -3
  39. package/github-updater-release.mjs +48 -8
  40. package/github-upload-gate.mjs +6 -6
  41. package/init-command.mjs +175 -8
  42. package/ios-upload.mjs +4 -4
  43. package/legion-semantic-contracts.mjs +428 -0
  44. package/mirror-root-artifact.mjs +3 -4
  45. package/npm-license-notices.mjs +2 -1
  46. package/outdated-command.mjs +91 -0
  47. package/ownership-class-rules.mjs +277 -0
  48. package/ownership-key-authority.mjs +3 -1
  49. package/ownership-mandatory-set.mjs +3 -2
  50. package/package.json +46 -3
  51. package/pipeline-admission.mjs +12 -0
  52. package/publish-cargo.mjs +219 -4
  53. package/publish-update.mjs +19 -14
  54. package/release-config.mjs +87 -11
  55. package/release-dependencies-command.mjs +4 -3
  56. package/release-qa-audit.mjs +12 -10
  57. package/release-qa-command.mjs +42 -7
  58. package/release-qa-hardening.mjs +10 -3
  59. package/release-qa-policy.mjs +214 -47
  60. package/release.mjs +4 -4
  61. package/rightkit-registry-resolver.mjs +5 -2
  62. package/rightkit-versions.json +178 -170
  63. package/shell-contract-bridge.json +251 -0
  64. package/shell-contract.mjs +1069 -0
  65. package/sign-updater.mjs +24 -4
  66. package/source-closure-rules.mjs +204 -0
  67. package/stage-sidecar-command.mjs +7 -6
  68. package/stage-sidecar.mjs +437 -114
  69. package/suite-api.generated.mjs +96 -6
  70. package/suite-trust-roots.mjs +198 -0
  71. package/test-command.mjs +3 -2
  72. package/upload-release.mjs +3 -2
  73. package/web-build.mjs +2 -1
@@ -8,12 +8,12 @@
8
8
  // - sealed ownership evidence that still matches the committed source;
9
9
  // - an explicit distribution.provider of github-releases and a distribution.repository, with any --repo
10
10
  // argument required to equal it (the origin remote is never consulted).
11
- // Order matters: the release config is a module that runs on import, so it is loaded only after the tree is
12
- // proven clean and the sealed commit equals HEAD.
11
+ // Order matters: a legacy release config is a module that runs on import (a JSON config is data), so the config is
12
+ // loaded only after the tree is proven clean and the sealed commit equals HEAD.
13
13
  import { existsSync } from "node:fs";
14
14
  import path from "node:path";
15
- import { pathToFileURL } from "node:url";
16
15
  import { spawnSync } from "./background-process.mjs";
16
+ import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
17
17
  import { verifyOwnershipEvidence } from "./ownership-evidence.mjs";
18
18
  import { assertPrimaryReleaseCheckout } from "./release-invocation.mjs";
19
19
  import { verifySealedRelease } from "./release-state.mjs";
@@ -48,9 +48,9 @@ export async function authorizeGitHubUpload({
48
48
  tier,
49
49
  releaseId,
50
50
  platform,
51
- configName = "right-release.config.mjs",
52
- repo = "",
53
51
  cwd = process.cwd(),
52
+ configName = defaultReleaseConfigFile(cwd),
53
+ repo = "",
54
54
  }) {
55
55
  assertUploadTier(tier);
56
56
  if (!releaseId) throw new Error("--release is required");
@@ -69,7 +69,7 @@ export async function authorizeGitHubUpload({
69
69
 
70
70
  const configPath = path.resolve(cwd, configName);
71
71
  if (!existsSync(configPath)) throw new Error(`release config is required: ${configName}`);
72
- const releaseConfig = (await import(`${pathToFileURL(configPath).href}?upload=${Date.now()}`)).default;
72
+ const releaseConfig = await importReleaseConfig(configPath, { query: `upload=${Date.now()}` });
73
73
  const ownership = verifyOwnershipEvidence(sealed.manifest.ownershipEvidence, repoRoot, path.dirname(configPath));
74
74
  if (!ownership.ok) throw new Error(`sealed ownership evidence rejected: ${ownership.reason}`);
75
75
  const configuredRepo = resolveGitHubDistribution(releaseConfig, repo);
package/init-command.mjs CHANGED
@@ -3,9 +3,11 @@
3
3
  // (bogusyogi/rightkit#385): renames the desktop app template into a real app, in place. Ports PostRight's
4
4
  // crates/tools/src/init.rs. The template identity (`RightApp`, `rightapp`, `RIGHTAPP`, `app.rightsuite.rightapp`)
5
5
  // is rewritten in text files; nothing else is touched and no gate or build is run.
6
- import { readdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
7
8
  import path from "node:path";
8
9
  import { fileURLToPath } from "node:url";
10
+ import { isJudgedNotice } from "./consumer-contracts.mjs";
9
11
 
10
12
  export const SKIP_DIRS = new Set(["node_modules", "target", ".git", "dist", "gen"]);
11
13
  export const TEXT_EXT = new Set(["json", "toml", "mjs", "ts", "tsx", "rs", "md", "html", "css", "yaml", "yml", "sh"]);
@@ -50,6 +52,60 @@ export function resolveIdentity({ name, id, slug }) {
50
52
  return { name, id, slug: resolvedSlug, warnings };
51
53
  }
52
54
 
55
+ // Cargo writes Cargo.lock with its [[package]] tables sorted by name (then version) and each `dependencies` list sorted by name, and
56
+ // `cargo --locked` refuses a lock that differs from what it would write. Renaming the template's own crates moves them in that order,
57
+ // so the lock is re-sorted after the text rename; without it every `--locked` script fails on the first build of the new app.
58
+ const LOCK_FILE = "Cargo.lock";
59
+ const byteOrder = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
60
+ const semverParts = (version) => {
61
+ const [core, pre] = String(version ?? "").split("+")[0].split(/-(.*)/s);
62
+ return { nums: core.split(".").map((part) => Number.parseInt(part, 10) || 0), pre: pre ?? null };
63
+ };
64
+ function compareSemver(a, b) {
65
+ const x = semverParts(a);
66
+ const y = semverParts(b);
67
+ for (let i = 0; i < 3; i += 1) if (x.nums[i] !== y.nums[i]) return (x.nums[i] ?? 0) - (y.nums[i] ?? 0);
68
+ if (x.pre === y.pre) return 0;
69
+ if (x.pre === null) return 1;
70
+ if (y.pre === null) return -1;
71
+ return byteOrder(x.pre, y.pre);
72
+ }
73
+ const lockField = (block, key) => new RegExp(`^${key} = "([^"]*)"$`, "m").exec(block)?.[1] ?? "";
74
+ /** `"name"`, `"name version"` or `"name version (source)"`: the name and version of one `dependencies` entry. */
75
+ const lockDependency = (line) => {
76
+ const [name = "", version = ""] = line.trim().replace(/^"|",?$/g, "").split(" ");
77
+ return { name, version };
78
+ };
79
+
80
+ /** Sort one lock's [[package]] tables and their `dependencies` lists the way cargo writes them; everything else is kept byte for byte. */
81
+ export function sortCargoLock(text) {
82
+ const crlf = text.includes("\r\n");
83
+ const lf = crlf ? text.replace(/\r\n/g, "\n") : text;
84
+ const first = /^\[\[package\]\]$/m.exec(lf);
85
+ if (!first) return text;
86
+ const head = lf.slice(0, first.index);
87
+ let body = lf.slice(first.index);
88
+ let tail = "";
89
+ const other = /^\[(?!\[package\]\]$)/m.exec(body);
90
+ if (other) {
91
+ tail = body.slice(other.index);
92
+ body = body.slice(0, other.index);
93
+ }
94
+ const blocks = body.replace(/\n+$/, "").split(/\n\n(?=\[\[package\]\]$)/m).map((block) => block.replace(
95
+ /^dependencies = \[\n([\s\S]*?)\n\]$/m,
96
+ (_, list) => `dependencies = [\n${list.split("\n").map((line) => ({ line, ...lockDependency(line) }))
97
+ .sort((a, b) => byteOrder(a.name, b.name) || compareSemver(a.version, b.version)).map((entry) => entry.line).join("\n")}\n]`,
98
+ ));
99
+ blocks.sort((a, b) => byteOrder(lockField(a, "name"), lockField(b, "name")) || compareSemver(lockField(a, "version"), lockField(b, "version")));
100
+ const out = `${head}${blocks.join("\n\n")}\n${tail ? `\n${tail}` : ""}`;
101
+ return crlf ? out.replace(/\n/g, "\r\n") : out;
102
+ }
103
+
104
+ /** Rename the template identity in a Cargo.lock and restore cargo's order, so `cargo --locked` accepts the renamed lock. */
105
+ export function reidentifyCargoLock(text, identity) {
106
+ return sortCargoLock(renameText(text, identity));
107
+ }
108
+
53
109
  export function walkTextFiles(dir, out = []) {
54
110
  let entries;
55
111
  try {
@@ -61,26 +117,77 @@ export function walkTextFiles(dir, out = []) {
61
117
  const full = path.join(dir, entry.name);
62
118
  if (entry.isDirectory()) {
63
119
  if (!SKIP_DIRS.has(entry.name)) walkTextFiles(full, out);
64
- } else if (entry.isFile() && TEXT_EXT.has(path.extname(entry.name).slice(1))) {
120
+ } else if (entry.isFile() && (TEXT_EXT.has(path.extname(entry.name).slice(1)) || entry.name === LOCK_FILE)) {
65
121
  out.push(full);
66
122
  }
67
123
  }
68
124
  return out;
69
125
  }
70
126
 
127
+ // `rightapps` is not the template identity: it is the RightApps distribution service (the `r2-rightapps` provider), which every app
128
+ // keeps. A spelling followed by a whole-word `s` is therefore left alone; `rightapp-engine`, `rightapp_protocol`, `RIGHTAPP_HOME` and
129
+ // the rest of the identity still rename.
130
+ const spelling = (word) => new RegExp(`${word}(?![sS](?![A-Za-z0-9]))`, "g");
131
+ const UPPER_WORD = spelling(UPPER);
132
+ const TITLE_WORD = spelling(TITLE);
133
+ const LOWER_WORD = spelling(LOWER);
134
+
71
135
  /** Rewrite the template identity in one string; the bundle id first, then the three spellings. */
72
136
  export function renameText(text, { name, id, slug }) {
73
137
  return text
74
138
  .replaceAll(TEMPLATE_ID, id)
75
- .replaceAll(UPPER, slug.toUpperCase())
76
- .replaceAll(TITLE, name)
77
- .replaceAll(LOWER, slug);
139
+ .replace(UPPER_WORD, () => slug.toUpperCase())
140
+ .replace(TITLE_WORD, () => name)
141
+ .replace(LOWER_WORD, () => slug);
142
+ }
143
+
144
+ export const LEGAL_MANIFEST = "legal/legal-manifest.json";
145
+ const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
146
+
147
+ /**
148
+ * Recompute the `sha256` of every document and appendix of a legal manifest from the bytes `read(relativePath)` returns (paths are
149
+ * relative to the manifest's directory; `read` returns Buffer, string or null for a missing file). Returns `{ text, changed, stale,
150
+ * material }`: the manifest text with the new digests (2-space JSON, as `legal-snapshot` writes it), the entries whose digest moved,
151
+ * and which of those are material agreements. Material documents feed the acceptance digest in src/generated/legal-contract.json, which
152
+ * only `legal-snapshot` can recompute, so the caller warns about them.
153
+ */
154
+ export function rehashLegalManifest(text, read) {
155
+ const manifest = JSON.parse(text);
156
+ const changed = [];
157
+ const material = [];
158
+ for (const entry of [...(manifest.documents ?? []), ...(manifest.appendices ?? [])]) {
159
+ const bytes = typeof entry?.path === "string" ? read(entry.path) : null;
160
+ if (bytes === null || bytes === undefined) continue;
161
+ const digest = sha256Hex(typeof bytes === "string" ? Buffer.from(bytes) : bytes);
162
+ if (entry.sha256 === digest) continue;
163
+ entry.sha256 = digest;
164
+ changed.push(entry.path);
165
+ if (entry.material === true) material.push(entry.path);
166
+ }
167
+ return { text: changed.length ? `${JSON.stringify(manifest, null, 2)}\n` : text, changed, material };
168
+ }
169
+
170
+ /**
171
+ * Rename the template under `root`. Returns the identity, the changed file paths (relative, sorted) and `warnings`.
172
+ * The rename changes the bytes of text the legal manifest hashes (the privacy notice and the npm appendix carry the product name), so the
173
+ * manifest's digests are recomputed from the renamed bytes in the same pass.
174
+ */
175
+ /** The lock a notice field names (`prefix` holds the name or path), resolved from `dir` upward to `root` as CA-14 resolves it. */
176
+ function resolveLockUpward(root, dir, prefix) {
177
+ const ref = /^`?([^`\s(]+)/.exec(prefix)[1];
178
+ for (let at = path.resolve(dir); ; at = path.dirname(at)) {
179
+ const candidate = path.resolve(at, ...ref.split("/"));
180
+ if (existsSync(candidate)) return candidate;
181
+ if (at === path.resolve(root) || at === path.dirname(at)) return null;
182
+ }
78
183
  }
79
184
 
80
- /** Rename the template under `root`. Returns the identity and the changed file paths (relative, sorted). */
81
185
  export function initTemplate(root, options) {
82
186
  const identity = resolveIdentity(options);
83
187
  const changed = [];
188
+ const warnings = [...identity.warnings];
189
+ const renamed = new Map();
190
+ const lockDigestsBefore = new Map();
84
191
  for (const file of walkTextFiles(root)) {
85
192
  if (path.basename(file) === SELF) continue;
86
193
  let before;
@@ -89,20 +196,80 @@ export function initTemplate(root, options) {
89
196
  } catch {
90
197
  continue;
91
198
  }
92
- const after = renameText(before, identity);
199
+ const after = path.basename(file) === LOCK_FILE ? reidentifyCargoLock(before, identity) : renameText(before, identity);
93
200
  if (after === before) continue;
201
+ if (path.basename(file) === LOCK_FILE) lockDigestsBefore.set(path.resolve(file), createHash("sha256").update(before).digest("hex"));
202
+ renamed.set(path.resolve(file), after);
94
203
  if (!options.dryRun) writeFileSync(file, after);
95
204
  changed.push(path.relative(root, file).split(path.sep).join("/"));
96
205
  }
97
- return { ...identity, changed: changed.sort() };
206
+ // The rename changes Cargo.lock's bytes (the app's own crates), so every notice that records the lock's digest
207
+ // (`Cargo.lock sha256 <hex>` / `sha256=<hex>`, checked by notice-lock-digest CA-14) would go stale: carry the new digest.
208
+ // Only the provenance field moves: `<lock> sha256 <hex>` or `<lock> sha256=<hex>` (optionally backticked, optionally with a
209
+ // parenthesised label) in a notice CA-14 judges (isJudgedNotice), where <lock> resolves from the notice's directory upward to
210
+ // this lock, as CA-14 resolves it, and <hex> is its old digest. The same digest, or the same formatted text, anywhere else
211
+ // (another input's field, archived reports and evidence, a source literal, a notice in a history tree) is left alone.
212
+ const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
213
+ const pairs = [...lockDigestsBefore].map(([file, old]) => {
214
+ const names = [...new Set([path.basename(file), path.relative(root, file).split(path.sep).join("/")])].map(escapeRe).join("|");
215
+ const field = new RegExp(`(\`?(?:${names})\`?(?:[ \\t]*\\([^)\\n]*\\))?[ \\t]+sha256(?:=|[ \\t]+)\`?)${old}(?![0-9a-f])`, "g");
216
+ return { lock: file, field, now: createHash("sha256").update(renamed.get(file)).digest("hex") };
217
+ });
218
+ if (pairs.length) {
219
+ for (const file of walkTextFiles(root)) {
220
+ if (path.basename(file) === SELF || path.basename(file) === LOCK_FILE) continue;
221
+ if (!isJudgedNotice(path.relative(root, file).split(path.sep).join("/"))) continue;
222
+ const before = renamed.get(path.resolve(file)) ?? readFileSync(file, "utf8");
223
+ let after = before;
224
+ for (const { lock, field, now } of pairs) {
225
+ after = after.replace(field, (whole, prefix) => (resolveLockUpward(root, path.dirname(file), prefix) === lock ? `${prefix}${now}` : whole));
226
+ }
227
+ if (after === before) continue;
228
+ renamed.set(path.resolve(file), after);
229
+ if (!options.dryRun) writeFileSync(file, after);
230
+ const relative = path.relative(root, file).split(path.sep).join("/");
231
+ if (!changed.includes(relative)) changed.push(relative);
232
+ }
233
+ }
234
+ const manifestFile = path.join(root, ...LEGAL_MANIFEST.split("/"));
235
+ if (existsSync(manifestFile)) {
236
+ const legalDir = path.dirname(manifestFile);
237
+ const read = (relative) => {
238
+ const target = path.resolve(legalDir, relative);
239
+ if (renamed.has(target)) return renamed.get(target);
240
+ try { return readFileSync(target); } catch { return null; }
241
+ };
242
+ const rehashed = rehashLegalManifest(renamed.get(path.resolve(manifestFile)) ?? readFileSync(manifestFile, "utf8"), read);
243
+ if (rehashed.changed.length) {
244
+ if (!options.dryRun) writeFileSync(manifestFile, rehashed.text);
245
+ if (!changed.includes(LEGAL_MANIFEST)) changed.push(LEGAL_MANIFEST);
246
+ }
247
+ for (const material of rehashed.material) {
248
+ warnings.push(`the rename changed the material legal document legal/${material}, which feeds src/generated/legal-contract.json; run \`pnpm exec legal-snapshot\` to regenerate the acceptance contract`);
249
+ }
250
+ }
251
+ return { ...identity, changed: changed.sort(), warnings };
98
252
  }
99
253
 
254
+ /** What a person does after init: the steps no command can do for them, in order. */
255
+ export const NEXT_STEPS = Object.freeze([
256
+ "pnpm install (installs the pinned packages and, through `prepare`, the pre-push hook)",
257
+ "right-release doctor (the build's own checks for this app, as rows)",
258
+ "set the app's public legal host in legal-snapshot.config.json, replace legal/PRODUCT_SCHEDULE.md and legal/PRIVACY_NOTICE.md with reviewed text, then `pnpm exec legal-snapshot`",
259
+ "register the repository on each host: add it to packages/release/ownership-repositories.json in RightKit (and its byte copy under packages/build-control-policy), then run `right-release ownership trust-refresh` on the Mac and the Dell (a person with administrator rights; an agent cannot)",
260
+ "README.md in the template: \"Create a new app (checklist)\"",
261
+ ]);
262
+
100
263
  export function runInitCommand(argv, io = { stdout: console.log, stderr: console.error }) {
101
264
  const opts = parseInitArgs(argv);
102
265
  const result = initTemplate(path.resolve(opts.dir), opts);
103
266
  for (const warning of result.warnings) io.stderr(`warning: ${warning}`);
104
267
  io.stdout(`right-release init${opts.dryRun ? " --dry-run" : ""}: ${result.changed.length} file(s) ${opts.dryRun ? "would change" : "changed"}`);
105
268
  for (const file of result.changed) io.stdout(` ${file}`);
269
+ if (!opts.dryRun) {
270
+ io.stdout("next:");
271
+ for (const step of NEXT_STEPS) io.stdout(` ${step}`);
272
+ }
106
273
  return result;
107
274
  }
108
275
 
package/ios-upload.mjs CHANGED
@@ -21,8 +21,8 @@
21
21
  // TestFlight steps use the same in-memory ES256 JWT. No file, temp or not, ever holds the key; no altool, no Transporter.
22
22
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
23
23
  import path from "node:path";
24
- import { pathToFileURL } from "node:url";
25
24
  import { spawnSync } from "./background-process.mjs";
25
+ import { defaultReleaseConfigFile, importReleaseConfig } from "./release-config.mjs";
26
26
  import { assertGuiSigningSession, assertIosHost, IosHostError } from "./ios-host.mjs";
27
27
  import { acquireFileLock } from "./ios-lock.mjs";
28
28
  import {
@@ -136,7 +136,7 @@ const git = (cwd, args) => {
136
136
  };
137
137
 
138
138
  /** Local checks only; no network, no secret. The sealed release is the only input: build-free by construction. */
139
- export async function authorizeIosUpload({ releaseId, configName = "right-release.config.mjs", cwd = process.cwd() }) {
139
+ export async function authorizeIosUpload({ releaseId, cwd = process.cwd(), configName = defaultReleaseConfigFile(cwd) }) {
140
140
  if (!RELEASE_ID.test(releaseId ?? "")) refuse("IOS_UPLOAD_RELEASE_REQUIRED", "--release <sealed-id> is required");
141
141
  const repoRoot = git(cwd, ["rev-parse", "--show-toplevel"]);
142
142
  assertPrimaryReleaseCheckout(repoRoot);
@@ -144,8 +144,8 @@ export async function authorizeIosUpload({ releaseId, configName = "right-releas
144
144
  assertCleanSource({ status: git(repoRoot, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]), commandId: "right-release upload" });
145
145
  const configPath = path.resolve(cwd, configName);
146
146
  if (!existsSync(configPath)) refuse("IOS_UPLOAD_CONFIG_INVALID", `release config is required: ${configName}`);
147
- // The config is a module that runs on import: loaded only after the tree is proven clean.
148
- const config = (await import(`${pathToFileURL(configPath).href}?upload=${Date.now()}`)).default;
147
+ // A legacy config is a module that runs on import (a JSON config is data): loaded only after the tree is proven clean.
148
+ const config = await importReleaseConfig(configPath, { query: `upload=${Date.now()}` });
149
149
  const distribution = validateIosDistribution(targetDistribution(config, "ios"));
150
150
  const sealedDir = path.join(repoRoot, ".right-release", "sealed", releaseId, "ios");
151
151
  const appRoot = path.dirname(configPath);