@rightkit/release 0.2.165 → 0.2.172

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 (77) hide show
  1. package/README.md +121 -4
  2. package/addon-command.mjs +6 -13
  3. package/android-aab-scan.mjs +265 -0
  4. package/android-dev.mjs +111 -0
  5. package/android-ndk.mjs +77 -0
  6. package/android-play-upload.mjs +262 -0
  7. package/android-release.mjs +344 -0
  8. package/android-test-fixtures.mjs +79 -0
  9. package/apple-profile-create.mjs +363 -0
  10. package/bin/darwin/rightkit-swap +0 -0
  11. package/bin/darwin/rightkit-swap.sign.json +6 -0
  12. package/build-release.mjs +73 -13
  13. package/cargo-guard.mjs +2 -0
  14. package/cargo-profile-contract.mjs +183 -0
  15. package/cargo-target.mjs +8 -0
  16. package/cli/right-release.mjs +14 -4
  17. package/committed-source.mjs +1 -1
  18. package/dev-artifact-refresh.mjs +176 -0
  19. package/dev-artifact-support.mjs +61 -0
  20. package/development-signing.mjs +71 -4
  21. package/development.mjs +78 -46
  22. package/direct-bootstrap.mjs +26 -41
  23. package/disk-command.mjs +298 -5
  24. package/disk-policy.json +7 -5
  25. package/disk-policy.mjs +105 -16
  26. package/disk-stable-root.mjs +19 -16
  27. package/engine-locator.mjs +116 -0
  28. package/external-bin.mjs +133 -0
  29. package/github-release.mjs +2 -2
  30. package/github-updater-release.mjs +154 -0
  31. package/hardeningscan.mjs +1 -1
  32. package/in-place-install.mjs +56 -0
  33. package/install-app-windows.mjs +63 -0
  34. package/install-app.mjs +53 -23
  35. package/local-publish.mjs +11 -1
  36. package/mac-signing-matrix.mjs +23 -11
  37. package/managed-cargo.mjs +6 -0
  38. package/media-sidecar-catalog.mjs +45 -0
  39. package/media-sidecar.mjs +306 -0
  40. package/ownership-approval-command.mjs +10 -44
  41. package/ownership-approval.mjs +3 -42
  42. package/ownership-catalog-config.json +2 -1
  43. package/ownership-catalog.mjs +4 -2
  44. package/ownership-command.mjs +7 -9
  45. package/ownership-dependency-allowlist.json +25 -1
  46. package/ownership-evidence.mjs +8 -5
  47. package/ownership-git-inventory.mjs +41 -7
  48. package/ownership-lockfiles.mjs +91 -8
  49. package/ownership-mandatory-set.json +36 -16
  50. package/ownership-report.mjs +5 -6
  51. package/ownership-scan-worker.mjs +2 -2
  52. package/ownership-verdict.mjs +10 -7
  53. package/ownership.mjs +47 -29
  54. package/package.json +7 -4
  55. package/portable-development.mjs +32 -115
  56. package/preflight.mjs +4 -6
  57. package/publication-artifacts.mjs +34 -0
  58. package/publish-update.mjs +72 -11
  59. package/qa-adoption-exemptions.json +5 -0
  60. package/qa-build-features.mjs +7 -0
  61. package/qa-contract.mjs +182 -0
  62. package/qa-control-scan.mjs +3 -3
  63. package/release-qa-command.mjs +38 -12
  64. package/release-qa-installed.mjs +136 -0
  65. package/release-qa-receipt.mjs +23 -2
  66. package/release.mjs +281 -13
  67. package/rightkit-ownership.json +537 -5
  68. package/rightkit-sync.mjs +26 -0
  69. package/rightkit-versions.json +109 -62
  70. package/runtime-budget-classes.json +28 -0
  71. package/runtime-budgets.mjs +4 -1
  72. package/sign-candidate.mjs +161 -0
  73. package/sign-dev-artifact.mjs +198 -0
  74. package/stage-binary.mjs +60 -0
  75. package/swap-helper-bundle.mjs +105 -0
  76. package/templates/cargo-profiles.toml +28 -0
  77. package/upload-release.mjs +11 -1
package/ownership.mjs CHANGED
@@ -1,8 +1,7 @@
1
1
  // RightKit ownership scan. Public exports retain the original findings-array API.
2
2
  import { isBlockingFinding } from "./ownership-verdict.mjs";
3
3
  import { spawnSync } from "./background-process.mjs";
4
- import { canonicalRepositoryIdentity, loadTrustedExternal, occurrenceHash, policyEntryDigest, resolveKeyLogTrust, sha256, verifyException, verifyProtectedProvenance } from "./ownership-approval.mjs";
5
- import installedManifest from "./rightkit-ownership.json" with { type: "json" };
4
+ import { canonicalRepositoryIdentity, loadTrustedExternal, occurrenceHash, resolveKeyLogTrust, sha256, verifyProtectedProvenance } from "./ownership-approval.mjs";
6
5
  import { parse as parseToml } from "smol-toml";
7
6
  import { sourceNonCode } from "./ownership-source.mjs";
8
7
  import { OWNERSHIP_LOCKFILES, transitiveDependencies } from "./ownership-lockfiles.mjs";
@@ -40,12 +39,20 @@ function sourceMatchers(manifest) {
40
39
  const flags = [...new Set(`m${sources.map(({ re }) => re.flags.replace(/[gy]/g, "")).join("")}`)].join("");
41
40
  prefilter = new RegExp(sources.map(({ f }) => `(?:${f.pattern})`).join("|") || "(?!)", flags);
42
41
  } catch { prefilter = /[\s\S]/; }
43
- const matchers = { digest, sources, prefilter };
42
+ // `excludePaths` (exact files or directory prefixes) keeps a source rule off the paths that
43
+ // hold its own positive samples, such as the scanner's fixtures.
44
+ const excluded = new Map(sources.filter(({ f }) => f.excludePaths?.length).map(({ f }) => [f.id, f.excludePaths]));
45
+ const matchers = { digest, sources, prefilter, excluded };
44
46
  catalogCache.set(digest, matchers);
45
47
  if (catalogCache.size > 8) catalogCache.delete(catalogCache.keys().next().value);
46
48
  return matchers;
47
49
  }
48
50
 
51
+ function sourceExcluded(matchers, id, rel) {
52
+ const paths = matchers.excluded?.get(id);
53
+ return Boolean(paths?.some((p) => rel === p || rel.startsWith(p.endsWith("/") ? p : `${p}/`)));
54
+ }
55
+
49
56
  function sourceFindings(file, matchers) {
50
57
  const ext = path.posix.extname(file.rel).toLowerCase();
51
58
  const key = `${matchers.digest}:${ext}:${file.cacheKey ?? inputDigest(file)}`;
@@ -194,7 +201,7 @@ function fileInventory(appRoot, trust, pathPrefix = "", repositoryRoot = appRoot
194
201
  const inventory = gitListedInventory(appRoot, {
195
202
  repositoryRoot,
196
203
  protectedPaths: [...protectedPaths],
197
- relevant: (rel) => ["Cargo.toml", "package.json", "Package.swift", "Package.resolved", ...OWNERSHIP_LOCKFILES].includes(path.posix.basename(rel)) || CODE_EXT.has(path.posix.extname(rel).toLowerCase()),
204
+ relevant: (rel) => ["Cargo.toml", "package.json", "Package.swift", "Package.resolved", "pnpm-workspace.yaml", ...OWNERSHIP_LOCKFILES].includes(path.posix.basename(rel)) || CODE_EXT.has(path.posix.extname(rel).toLowerCase()),
198
205
  maxFileBytes: (rel) => CODE_EXT.has(path.posix.extname(rel).toLowerCase()) || protectedPaths.has(rel) ? 2 * 1024 * 1024 : 64 * 1024 * 1024,
199
206
  });
200
207
  for (const item of inventory.coverage) if (item.reason === "file-over-size-limit") item.reason = protectedPaths.has(item.path) ? "protected-provenance-file-over-2MiB" : CODE_EXT.has(path.posix.extname(item.path).toLowerCase()) ? "source-file-over-2MiB" : "manifest-file-over-64MiB";
@@ -214,7 +221,7 @@ function committedFileInventory(appRoot, trust, pathPrefix, committed) {
214
221
  if (entry.mode === "120000") { coverage.push({ path: rel, status: "unscanned", reason: "committed-symlink" }); continue; }
215
222
  if (entry.mode === "160000" || entry.type === "commit") { coverage.push({ path: rel, status: "unscanned", reason: "committed-gitlink" }); continue; }
216
223
  if (entry.type !== "blob") { coverage.push({ path: rel, status: "unscanned", reason: "committed-unsupported-entry" }); continue; }
217
- const relevant = protectedPaths.has(entry.rel) || ["Cargo.toml", "package.json", "Package.swift", "Package.resolved", "ownership-exceptions.json", ...OWNERSHIP_LOCKFILES].includes(path.posix.basename(rel)) || CODE_EXT.has(path.posix.extname(rel).toLowerCase());
224
+ const relevant = protectedPaths.has(entry.rel) || ["Cargo.toml", "package.json", "Package.swift", "Package.resolved", "ownership-exceptions.json", "pnpm-workspace.yaml", ...OWNERSHIP_LOCKFILES].includes(path.posix.basename(rel)) || CODE_EXT.has(path.posix.extname(rel).toLowerCase());
218
225
  const bytes = relevant ? entry.bytes : undefined;
219
226
  if (relevant && !bytes) coverage.push({ path: rel, status: "unscanned", reason: "committed-blob-unavailable" });
220
227
  const isExceptionFile = path.posix.basename(rel) === "ownership-exceptions.json";
@@ -579,7 +586,7 @@ function scanInternal(appRoot, manifest, { inventory = fileInventory(appRoot), t
579
586
  }
580
587
  if (size > 2 * 1024 * 1024) { coverage.push({ path: rel, status: "unscanned", reason: "source-file-over-2MiB" }); continue; }
581
588
  contentDigests.push({ path: rel, digest: inputDigest(file) });
582
- for (const finding of sourceFindings(file, matchers)) findings.push({ ...finding, file: rel });
589
+ for (const finding of sourceFindings(file, matchers)) if (!sourceExcluded(matchers, finding.fingerprint, rel)) findings.push({ ...finding, file: rel });
583
590
  }
584
591
  if (pathPrefix) {
585
592
  const prefix = (p) => p === "." ? pathPrefix.slice(0, -1) : `${pathPrefix}${p}`;
@@ -675,31 +682,42 @@ function enrollmentPending(trust, state) {
675
682
  function findingPolicy(c, f) {
676
683
  return { tier: f.tier ?? c.tier ?? "block", ...(f.policy ? { policy: f.policy } : {}) };
677
684
  }
685
+ /**
686
+ * Plain exception entries (Orthic-Labs/rightkit#262), reviewed through git, no signatures:
687
+ * { capability, package (alias: crate), reason, owner, date }, optional narrowing { fingerprint, path }.
688
+ * An entry marks every finding of that capability/package (and fingerprint/path when given) `excepted`;
689
+ * an entry that matches no finding is reported as a stale warning; a malformed or legacy signed entry is a problem.
690
+ */
691
+ export function validateExceptionEntry(entry) {
692
+ const problems = [];
693
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return ["not an object"];
694
+ if (entry.payload || entry.signature !== undefined || entry.schema !== undefined) return ["legacy signed exception record; replace it with a plain entry {capability, package, reason, owner, date}"];
695
+ for (const key of ["capability", "reason", "owner"]) if (typeof entry[key] !== "string" || !entry[key].trim()) problems.push(`missing ${key}`);
696
+ const owner = entry.package ?? entry.crate;
697
+ if (typeof owner !== "string" || !owner.trim()) problems.push("missing package (crate or package)");
698
+ if (typeof entry.date !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(entry.date) || Number.isNaN(Date.parse(`${entry.date}T00:00:00Z`))) problems.push("date must be YYYY-MM-DD");
699
+ for (const key of ["fingerprint", "path"]) if (entry[key] !== undefined && (typeof entry[key] !== "string" || !entry[key])) problems.push(`${key} must be a non-empty string when present`);
700
+ return problems;
701
+ }
702
+ export function exceptionMatches(entry, finding) {
703
+ const owner = entry.package ?? entry.crate;
704
+ if (finding.capability !== entry.capability || finding.package !== owner) return false;
705
+ if (entry.fingerprint !== undefined && finding.fingerprint !== entry.fingerprint) return false;
706
+ if (entry.path !== undefined && !(finding.file === entry.path || finding.file?.startsWith(`${entry.path.replace(/\/$/, "")}/`))) return false;
707
+ return true;
708
+ }
678
709
  export function evaluate(findings, exceptions, options = {}) {
679
- const today = options.today ?? new Date().toISOString().slice(0, 10), problems = [], covered = [], violations = [], warnings = [];
680
- const metadata = options.metadata ?? findings.metadata ?? {};
681
- const catalogDigest = metadata.catalogDigest ?? options.catalogDigest ?? "";
682
- const identity = options.repositoryIdentity ?? metadata.repositoryIdentity ?? "";
710
+ const problems = [], covered = [], violations = [], warnings = [];
683
711
  for (const e of exceptions) {
684
- const p = e?.payload ?? e ?? {};
685
- const where = `exception #${e?.index} (${p.fingerprint ?? "?"})`;
686
- const missing = ["fingerprint", "occurrences", "policyDigest", "minimumScannerVersion", "minimumCatalogVersion", "issued", "reason", "owner", "expires", "requestId"].filter((k) => !p[k] || (Array.isArray(p[k]) && !p[k].length));
687
- if (missing.length) { problems.push(`${where}: missing ${missing.join(", ")}`); continue; }
688
- const approvedBy = e.approvedBy ?? p.approvedBy;
689
- if (approvedBy !== undefined && approvedBy !== "Adrian") { problems.push(`${where}: approvedBy must be Adrian`); continue; }
690
- if (!/^\d{4}-\d{2}-\d{2}$/.test(p.expires) || Number.isNaN(Date.parse(`${p.expires}T00:00:00Z`)) || p.expires < today) { problems.push(`${where}: invalid or expired expires date`); continue; }
691
- if (!p.trackingRef || typeof p.trackingRef !== "object" || !p.trackingRef.verified || !/^https:\/\//.test(p.trackingRef.url ?? "") || !p.trackingRef.id) { problems.push(`${where}: tracking must be a verified RightKit request reference`); continue; }
692
- let policyDigest;
693
- try { policyDigest = policyEntryDigest(options.manifest ?? installedManifest, p.fingerprint); } catch (error) { problems.push(`${where}: ${error.message}`); continue; }
694
- const { index, ...record } = e;
695
- const verification = verifyException(record, { repositoryIdentity: identity, catalogDigest, policyDigest, findings, trust: options.trust, today, scannerVersion: options.scannerVersion ?? metadata.scannerVersion ?? SCANNER_VERSION, catalogVersion: options.catalogVersion ?? metadata.catalogVersion });
696
- if (!verification?.ok) { problems.push(`${where}: signature verification failed${verification?.problems?.length ? ` (${verification.problems.join("; ")})` : ""}`); continue; }
697
- const matching = findings.filter((f) => f.fingerprint === p.fingerprint && p.occurrences.some((item) => item.path === f.file && item.hash === occurrenceHash(f)));
698
- if (!matching.length) { problems.push(`${where}: stale, matches no signed occurrence`); continue; }
699
- for (const f of matching) covered.push({ ...f, occurrenceHash: occurrenceHash(f), exception: e });
712
+ const where = `exception #${e?.index} (${e?.capability ?? "?"}/${e?.package ?? e?.crate ?? "?"})`;
713
+ const invalid = validateExceptionEntry(e);
714
+ if (invalid.length) { problems.push(`${where}: ${invalid.join("; ")}`); continue; }
715
+ const matching = findings.filter((f) => exceptionMatches(e, f));
716
+ if (!matching.length) { warnings.push(`${where}: stale, matches no current finding (remove it from the exceptions file)`); continue; }
717
+ for (const f of matching) if (!covered.some((x) => x.occurrenceHash === occurrenceHash(f))) covered.push({ ...f, occurrenceHash: occurrenceHash(f), exception: e });
700
718
  }
701
719
  const pending = enrollmentPending(options.trust, options.trustEnrollment);
702
- if (pending) warnings.push("approval enrollment pending; detected findings remain open backlog requests until distinct Windows & macOS keys are enrolled");
720
+ if (pending) warnings.push("approval enrollment pending (warning only): exceptions are plain git-reviewed entries and no scan result depends on enrolled approval keys");
703
721
  for (const f of findings) if (!covered.some((x) => x.occurrenceHash === occurrenceHash(f))) violations.push({ ...f, occurrenceHash: occurrenceHash(f) });
704
722
  return { violations, covered, problems, warnings, enrollmentPending: pending };
705
723
  }
@@ -744,7 +762,7 @@ function checkAppInternal(appRoot, manifest, options, readAuthority) {
744
762
  const result = evaluate(scanned.findings, loadExceptions(appRoot), { ...options, manifest, trust, trustEnrollment: enrollment, metadata });
745
763
  const coverage = [...new Map(metadata.coverage.map((x) => [`${x.path}\0${x.reason}`, x])).values()];
746
764
  const rows = capabilityTable(manifest, scanned.deps, { ...result, coverage });
747
- return { ok: !result.enrollmentPending && !result.violations.some(isBlockingFinding) && !result.problems.length && !coverage.length, rows, ...result, findings: scanned.findings, coverage, metadata };
765
+ return { ok: !result.violations.some(isBlockingFinding) && !result.problems.length && !coverage.length, rows, ...result, findings: scanned.findings, coverage, metadata };
748
766
  }
749
767
 
750
768
  function checkCommitted(appRoot, manifest, options, acquire, readAuthority = readCommittedKeyAuthority) {
@@ -812,7 +830,7 @@ function checkCommitted(appRoot, manifest, options, acquire, readAuthority = rea
812
830
  : trustState?.pending !== undefined || trustState?.enrolled !== undefined ? trustState : undefined, metadata });
813
831
  const coverage = [...new Map(metadata.coverage.map((x) => [`${x.path}\0${x.reason}`, x])).values()];
814
832
  const rows = capabilityTable(manifest, scanned.deps, { ...result, coverage });
815
- return { ok: !result.enrollmentPending && !result.violations.some(isBlockingFinding) && !result.problems.length && !coverage.length, rows, ...result, findings: scanned.findings, coverage, metadata };
833
+ return { ok: !result.violations.some(isBlockingFinding) && !result.problems.length && !coverage.length, rows, ...result, findings: scanned.findings, coverage, metadata };
816
834
  }
817
835
 
818
836
  /** Protected production scan: acquire and inspect exact HEAD blobs without reading checkout contents. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rightkit/release",
3
- "version": "0.2.165",
3
+ "version": "0.2.172",
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",
@@ -18,10 +18,13 @@
18
18
  },
19
19
  "files": [
20
20
  "cli",
21
+ "bin",
22
+ "templates",
21
23
  "*.mjs",
22
24
  "*.json",
23
25
  "*.sh",
24
26
  "msix-repair-guard.ps1",
27
+ "bin/darwin/rightkit-swap",
25
28
  "*.py",
26
29
  "LICENSE-MIT",
27
30
  "LICENSE-APACHE",
@@ -35,14 +38,14 @@
35
38
  },
36
39
  "repository": {
37
40
  "type": "git",
38
- "url": "git+https://github.com/Orthic-Labs/rightkit.git",
41
+ "url": "git+https://github.com/bogusyogi/rightkit.git",
39
42
  "directory": "packages/release"
40
43
  },
41
44
  "dependencies": {
42
- "smol-toml": "1.9.0"
45
+ "smol-toml": "1.9.1"
43
46
  },
44
47
  "scripts": {
45
- "test": "node --test --test-concurrency=1 --test-force-exit *.test.mjs",
48
+ "test": "node --import ./test-support/broker-guard.mjs --test --test-concurrency=1 --test-force-exit *.test.mjs",
46
49
  "test:registry-parity": "node registry-parity.mjs --allow-unpublished",
47
50
  "test:signer:windows": "node sign-release-manifest.integration.mjs",
48
51
  "doctor:all": "node --test right-suite-contract.test.mjs",
@@ -1,16 +1,19 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
- import { spawnSync } from "./background-process.mjs";
4
+ import { previousPathOf, stagingPathOf, swapInPlace } from "./in-place-install.mjs";
5
5
 
6
6
  const CHUNK_BYTES = 1024 * 1024;
7
7
 
8
- /** Install an unsigned development payload through an owned, versioned `current` pointer. */
8
+ /**
9
+ * Install an unsigned development payload in place at the fixed path `<installRoot>/current` (a real directory).
10
+ * No versions/ folder, no digest folder, no symlink or junction. The previous copy is kept once, as
11
+ * `<installRoot>/current.previous` (rename it back to roll back); nothing ever runs from it. A legacy `current`
12
+ * symlink or junction from the old layout is moved aside to `.previous` the same way.
13
+ */
9
14
  export function installPortableDevelopment(descriptor, { root, platform = process.platform, env = process.env } = {}) {
10
- const emit = (event, fields = {}) => process.stdout.write(`${JSON.stringify({ event: `DEV_INSTALL_${event}`, ...fields })}\n`);
11
- let backup = null;
12
- let switched = false;
13
- let versionRoot;
15
+ const emit = (event, fields = {}) => process.stdout.write(`${JSON.stringify({ event: `DEV_INSTALL_${event}`, ...fields })}
16
+ `);
14
17
  let digest;
15
18
  let current;
16
19
  try {
@@ -27,110 +30,40 @@ export function installPortableDevelopment(descriptor, { root, platform = proces
27
30
  const sourceEntrypoint = resolveContained(source, descriptor.entrypoint, "entrypoint");
28
31
  const sourceEntrypointInfo = fs.lstatSync(sourceEntrypoint);
29
32
  if (!sourceEntrypointInfo.isFile() || sourceEntrypointInfo.isSymbolicLink()) throw new Error("development entrypoint must be a regular file inside sourceDirectory");
30
- const sourceDigest = digestTree(source);
31
- digest = sourceDigest;
32
- versionRoot = path.join(installRoot, "versions", `dev-${digest}`);
33
+ digest = digestTree(source);
33
34
  current = path.join(installRoot, "current");
34
- emit("STAGE", { sourceDirectory: source, versionRoot, digest });
35
-
36
- fs.mkdirSync(path.dirname(versionRoot), { recursive: true });
37
- if (exists(versionRoot)) {
38
- if (digestTree(versionRoot) !== digest) throw new Error(`development version digest collision: ${versionRoot}`);
39
- } else {
40
- const staging = path.join(path.dirname(versionRoot), `.stage-${process.pid}-${randomSuffix()}`);
41
- try {
42
- copyTree(source, staging);
43
- if (digestTree(staging) !== digest) throw new Error("staged development payload did not match source digest");
44
- fs.renameSync(staging, versionRoot);
45
- } catch (error) {
46
- removeIfExists(staging);
47
- throw error;
48
- }
49
- }
35
+ const staging = stagingPathOf(current);
36
+ const previous = previousPathOf(current);
37
+ emit("STAGE", { sourceDirectory: source, current, staging, digest });
38
+ if (exists(current) && !fs.lstatSync(current).isDirectory() && !fs.lstatSync(current).isSymbolicLink()) throw new Error(`development current is not a directory; refusing to replace: ${current}`);
50
39
 
51
- validateCurrent(current, installRoot, platform);
52
- if (exists(current)) {
53
- backup = path.join(installRoot, `.current-backup-${process.pid}-${randomSuffix()}`);
54
- fs.renameSync(current, backup);
55
- }
40
+ fs.mkdirSync(installRoot, { recursive: true });
41
+ removeIfExists(staging);
56
42
  try {
57
- fs.symlinkSync(versionRoot, current, platform === "win32" ? "junction" : "dir");
58
- switched = true;
59
- emit("ACTIVATE", { current, versionRoot });
60
- const activeRoot = fs.realpathSync(current);
61
- if (!samePath(activeRoot, fs.realpathSync(versionRoot), platform)) throw new Error("development current pointer did not resolve to staged version");
62
- const entrypoint = resolveContained(versionRoot, descriptor.entrypoint, "entrypoint");
63
- const entryDigest = hashFile(entrypoint);
64
- if (entryDigest !== hashFile(path.join(source, descriptor.entrypoint))) throw new Error("development entrypoint readback did not match source");
65
- if (backup) removeIfExists(backup);
66
- emit("PRUNE", pruneDevelopmentVersions(installRoot, versionRoot, { platform }));
67
- emit("SUCCEEDED", { installRoot, current, versionRoot, digest, entrypoint: descriptor.entrypoint, entrypointDigest: entryDigest });
68
- return { installRoot, current, versionRoot, digest, entrypoint: path.join(current, descriptor.entrypoint), entrypointDigest: entryDigest };
43
+ copyTree(source, staging);
44
+ if (digestTree(staging) !== digest) throw new Error("staged development payload did not match source digest");
69
45
  } catch (error) {
70
- if (switched) removeIfExists(current);
71
- if (backup && exists(backup)) fs.renameSync(backup, current);
46
+ removeIfExists(staging);
72
47
  throw error;
73
48
  }
49
+ const sourceEntryDigest = hashFile(sourceEntrypoint);
50
+ let entryDigest;
51
+ swapInPlace({
52
+ staging, destination: current, previous,
53
+ verify: (destination) => {
54
+ entryDigest = hashFile(resolveContained(destination, descriptor.entrypoint, "entrypoint"));
55
+ if (entryDigest !== sourceEntryDigest) throw new Error("development entrypoint readback did not match source");
56
+ },
57
+ });
58
+ emit("ACTIVATE", { current, previous });
59
+ emit("SUCCEEDED", { installRoot, current, previous, digest, entrypoint: descriptor.entrypoint, entrypointDigest: entryDigest });
60
+ return { installRoot, current, previous, versionRoot: current, digest, entrypoint: path.join(current, descriptor.entrypoint), entrypointDigest: entryDigest };
74
61
  } catch (error) {
75
- emit("FAILED", { installRoot: descriptor?.installRoot, versionRoot, digest, message: error?.message ?? String(error) });
62
+ emit("FAILED", { installRoot: descriptor?.installRoot, current, digest, message: error?.message ?? String(error) });
76
63
  throw error;
77
64
  }
78
65
  }
79
66
 
80
- /**
81
- * Development installs keep only the active version: dev builds are rebuilt from source, so there is
82
- * no rollback copy. A version a running process still uses is skipped and removed by the next
83
- * install. Never throws: a prune problem must not fail an install that already activated.
84
- */
85
- export function pruneDevelopmentVersions(installRoot, keepRoot, { platform = process.platform, runningExecutables = null } = {}) {
86
- const versionsRoot = path.join(installRoot, "versions");
87
- const removed = []; const skipped = []; let reclaimedBytes = 0;
88
- let running = null;
89
- const inUse = (dir) => {
90
- if (platform === "win32") return false; // Windows: a loaded image or DLL makes the rename below fail.
91
- running ??= runningExecutables ?? listRunningExecutables();
92
- return running.some((exe) => isWithin(dir, exe, platform));
93
- };
94
- const candidates = [];
95
- for (const [dir, pattern] of [[versionsRoot, /^(dev-[0-9a-f]+|\.stage-.+|\.prune-.+)$/], [installRoot, /^\.(current-backup|prune)-.+$/]]) {
96
- let names = [];
97
- try { names = fs.readdirSync(dir); } catch { continue; }
98
- for (const name of names) if (pattern.test(name)) candidates.push(path.join(dir, name));
99
- }
100
- for (const candidate of candidates) {
101
- if (samePath(candidate, keepRoot, platform)) continue;
102
- if (path.basename(candidate).includes(`-${process.pid}-`)) continue; // this process's own stage/backup
103
- try {
104
- if (inUse(candidate)) { skipped.push({ path: candidate, reason: "in-use" }); continue; }
105
- const bytes = treeBytes(candidate);
106
- // Rename first: on Windows this fails while any file inside is loaded, so a running version is skipped whole.
107
- const doomed = path.basename(candidate).startsWith(".prune-") ? candidate : path.join(path.dirname(candidate), `.prune-${randomSuffix()}`);
108
- if (doomed !== candidate) fs.renameSync(candidate, doomed);
109
- fs.rmSync(doomed, { recursive: true, force: true });
110
- removed.push(candidate); reclaimedBytes += bytes;
111
- } catch (error) {
112
- skipped.push({ path: candidate, reason: error?.code ?? error?.message ?? String(error) });
113
- }
114
- }
115
- return { kept: keepRoot, removed: removed.length, skipped, reclaimedBytes };
116
- }
117
-
118
- function listRunningExecutables() {
119
- const result = spawnSync("ps", ["-axo", "comm="], { encoding: "utf8" });
120
- return result.status === 0 ? result.stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => path.isAbsolute(line)) : [];
121
- }
122
-
123
- function treeBytes(target) {
124
- let total = 0;
125
- const stat = fs.lstatSync(target);
126
- if (!stat.isDirectory()) return stat.size;
127
- for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
128
- const child = path.join(target, entry.name);
129
- total += entry.isDirectory() && !entry.isSymbolicLink() ? treeBytes(child) : fs.lstatSync(child).size;
130
- }
131
- return total;
132
- }
133
-
134
67
  function validateDescriptor(descriptor, root, platform) {
135
68
  if (!descriptor || descriptor.kind !== "portable" || descriptor.activation?.kind !== "current") throw new Error("unsupported portable development descriptor");
136
69
  if (typeof root !== "string" || !path.isAbsolute(root)) throw new Error("repository root must be absolute");
@@ -214,24 +147,8 @@ function copyTree(source, target) {
214
147
  }
215
148
  }
216
149
 
217
- function validateCurrent(current, installRoot, platform) {
218
- if (!exists(current)) return;
219
- const stat = fs.lstatSync(current);
220
- if (!stat.isSymbolicLink()) throw new Error(`development current pointer is not a symlink or junction; refusing to replace: ${current}`);
221
- let target;
222
- try { target = fs.realpathSync(current); } catch (error) { throw new Error(`development current pointer is dangling or unreadable: ${current}`, { cause: error }); }
223
- const versionsRoot = path.join(installRoot, "versions");
224
- const versionName = path.basename(target);
225
- if (!isWithin(fs.realpathSync(installRoot), target, platform)
226
- || !samePath(path.dirname(target), fs.realpathSync(versionsRoot), platform)
227
- || !versionName || versionName === "." || versionName === "..") {
228
- throw new Error(`development current pointer does not resolve to an owned version: ${current}`);
229
- }
230
- }
231
-
232
150
  function removeIfExists(target) {
233
151
  if (!exists(target)) return;
234
152
  fs.rmSync(target, { recursive: true, force: true });
235
153
  }
236
154
 
237
- function randomSuffix() { return `${Date.now()}-${Math.random().toString(16).slice(2)}`; }
package/preflight.mjs CHANGED
@@ -341,13 +341,11 @@ export function ownershipCheck(repoRoot, options = {}) {
341
341
  try { checked = (options.evidenceFactory ?? createOwnershipEvidence)(repoRoot, options.scanRoot ?? repoRoot); }
342
342
  catch (error) { return fail("ownership", `ownership evidence failed: ${error.message}`, "restore protected RightKit ownership trust and catalog"); }
343
343
  const { result, evidence, floor } = checked;
344
- if (checked.enrollmentPending) {
345
- const diagnostics = [...(result.problems ?? []), ...(result.violations ?? []).map((entry) => `${entry.fingerprint} at ${entry.file}${entry.line ? `:${entry.line}` : ""}`)];
346
- return { ...warn("ownership", `${checked.warning}${diagnostics.length ? `; ${diagnostics.length} diagnostic finding/problem(s) retained` : ""}`, null), evidence, diagnostics, result, floor };
347
- }
348
344
  if (checked.ok) {
349
345
  const excepted = result.covered.length;
350
- return { ...ok("ownership", excepted ? `no unapproved copies (${excepted} signed findings)` : "no copies of RightKit-owned capabilities"), evidence };
346
+ const detail = excepted ? `no unapproved copies (${excepted} excepted findings)` : "no copies of RightKit-owned capabilities";
347
+ // Pending approval enrollment is a warning only (#262); real findings still fail below.
348
+ return checked.enrollmentPending ? { ...warn("ownership", `${detail}; ${checked.warning}`, null), evidence, result, floor } : { ...ok("ownership", detail), evidence };
351
349
  }
352
350
  const first = result.violations[0];
353
351
  const detail = !floor.ok
@@ -355,7 +353,7 @@ export function ownershipCheck(repoRoot, options = {}) {
355
353
  : first
356
354
  ? `${result.violations.length} own cop${result.violations.length === 1 ? "y" : "ies"} of RightKit capabilities, e.g. ${first.fingerprint} at ${first.file}${first.line ? `:${first.line}` : ""}`
357
355
  : result.problems[0] ?? result.coverage[0]?.reason ?? "ownership scan incomplete";
358
- return { ...fail("ownership", detail, "run: right-release ownership (move to RightKit, or request a signed, dated exception)"), evidence };
356
+ return { ...fail("ownership", detail, "run: right-release ownership (move to RightKit, or add a dated entry to the repo exceptions file)"), evidence };
359
357
  }
360
358
 
361
359
  export function formatPreflight(checks) {
@@ -3,6 +3,7 @@ import { readFileSync, statSync, openSync, fstatSync, readSync, closeSync } from
3
3
  import path from "node:path";
4
4
  import { verifySealedRelease } from "./release-state.mjs";
5
5
  import { verifyOwnershipEvidence } from "./ownership-evidence.mjs";
6
+ import { validateMediaSidecarReceipt } from "./media-sidecar.mjs";
6
7
 
7
8
  export class PublicationError extends Error {
8
9
  constructor(code, message) { super(`[${code}] ${message}`); this.name = "PublicationError"; this.code = code; }
@@ -55,15 +56,25 @@ export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit,
55
56
  if (!ownership.ok || manifest.ownershipEvidence?.commit !== commit) throw new PublicationError("PUBLICATION_OWNERSHIP_INVALID", `sealed ownership evidence rejected: ${ownership.reason ?? "commit mismatch"}`);
56
57
  if (!manifest.checkpoints?.includes("signed") || !manifest.checkpoints?.includes("hardened")) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "signed & hardened seal checkpoints are required");
57
58
  if (configPath && digestBytes(readFileSync(configPath)) !== manifest.signing?.configSha256) throw new PublicationError("PUBLICATION_POLICY_MISMATCH", "release config differs from sealed signing policy");
59
+ if (manifest.mediaSidecars !== undefined) {
60
+ try { validateMediaSidecarReceipt(manifest.mediaSidecars); }
61
+ catch (error) { throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", error.message); }
62
+ }
58
63
  const receipts = manifest.signing?.receipts;
59
64
  if (!Array.isArray(receipts) || !receipts.length) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed signing receipts are required");
60
65
  let notarized = false;
61
66
  let signed = false;
67
+ let androidVerified = false;
62
68
  for (const receipt of receipts) {
63
69
  const artifact = readVerifiedArtifact(path.resolve(appRoot, receipt.file), receipt);
64
70
  let value;
65
71
  try { value = JSON.parse(artifact.bytes); } catch { throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", "invalid sealed signing receipt"); }
66
72
  if (value.schema !== 1) throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", "malformed sealed signing receipt");
73
+ if (value.contract !== undefined) {
74
+ verifyAndroidPublicationReceipt(value, manifest, platform);
75
+ androidVerified = true; signed = true;
76
+ continue;
77
+ }
67
78
  if (Array.isArray(value.files)) {
68
79
  if (!value.files.length || value.files.some((item) => !/^[a-f0-9]{64}$/.test(item.after?.sha256 ?? "") || item.timestampPresent !== true
69
80
  || (platform === "win" ? item.authenticode !== "Valid" || item.subject !== "CN=Damned Ventures LLC" : item.codesign !== "strict-valid" || item.identity !== value.identity))) throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", "sealed signature verification evidence rejected");
@@ -81,6 +92,7 @@ export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit,
81
92
  }
82
93
  if (!signed) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed native signing evidence is required");
83
94
  if (platform === "mac" && !notarized) throw new PublicationError("PUBLICATION_NOTARIZATION_MISSING", "sealed notarization evidence is required");
95
+ if (platform === "android" && !androidVerified) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed android AAB upload-key receipt is required");
84
96
  const artifacts = new Map();
85
97
  for (const entry of manifest.files ?? []) {
86
98
  if (typeof entry.name !== "string" || path.basename(entry.name) !== entry.name || artifacts.has(entry.name)) throw new PublicationError("PUBLICATION_SEAL_INVALID", "unsafe or duplicate sealed artifact name");
@@ -90,6 +102,28 @@ export function verifyPublicationRelease({ sealedDir, repoRoot, appRoot, commit,
90
102
  return { ...sealed, artifacts };
91
103
  }
92
104
 
105
+ // Android AAB receipt (android-release.mjs: contract "android-aab-upload-key-v1"). The receipt was already read from the
106
+ // manifest's signing.receipts and hash-verified by the caller; here it must describe exactly the sealed "aab" file and be
107
+ // signed by the pinned upload certificate. Fails closed with a specific message on any mismatch.
108
+ const ANDROID_RECEIPT_CONTRACT = "android-aab-upload-key-v1";
109
+ function verifyAndroidPublicationReceipt(value, manifest, platform) {
110
+ const invalid = (message) => { throw new PublicationError("PUBLICATION_SIGNATURE_INVALID", message); };
111
+ if (value.contract !== ANDROID_RECEIPT_CONTRACT) invalid(`unrecognized signing receipt contract: ${JSON.stringify(value.contract)}`);
112
+ if (platform !== "android" || manifest.platform !== "android") invalid("android AAB receipt in a non-android release");
113
+ const signer = value.signer?.certSha256;
114
+ if (!/^[a-f0-9]{64}$/.test(signer ?? "")) invalid("android AAB receipt has no signer certificate sha256");
115
+ if (value.signer.debugCertificate === true || /Android Debug/i.test(String(value.signer.subject ?? ""))) invalid("android AAB receipt is signed with the Android debug certificate");
116
+ const pinned = manifest.signing?.uploadCertSha256;
117
+ if (!/^[a-f0-9]{64}$/.test(pinned ?? "")) invalid("sealed signing policy has no pinned android upload certificate");
118
+ if (signer !== pinned) invalid("android AAB signer certificate is not the pinned upload certificate");
119
+ const aabs = (manifest.files ?? []).filter((entry) => entry.role === "aab");
120
+ if (aabs.length !== 1) invalid(aabs.length ? "sealed release has more than one aab entry" : "sealed release has no aab entry");
121
+ const [aab] = aabs;
122
+ if (!/^[a-f0-9]{64}$/.test(value.file?.sha256 ?? "") || value.file.sha256 !== aab.sha256) invalid("android AAB receipt sha256 differs from the sealed aab entry");
123
+ if (!Number.isInteger(value.file.sizeBytes) || value.file.sizeBytes !== aab.sizeBytes) invalid("android AAB receipt size differs from the sealed aab entry");
124
+ if (value.file.name !== aab.name) invalid("android AAB receipt names a different file than the sealed aab entry");
125
+ }
126
+
93
127
  // Bounded memory for large artifacts. Transport must read back against this digest.
94
128
  export function verifyArtifactFile(file, evidence) {
95
129
  if (!/^[a-f0-9]{64}$/.test(evidence?.sha256 ?? "")) throw new PublicationError("PUBLICATION_SEAL_INVALID", "publication hash evidence is required");
@@ -14,6 +14,7 @@ import { verifyPublicationRelease, PublicationError, assertArtifactUnchanged, di
14
14
  import { uploadVerifiedR2Artifact } from "./r2-verified-upload.mjs";
15
15
  import { verifyPlatformTrust } from "./github-release.mjs";
16
16
  import { admitInstalledQa } from "./release-qa-audit.mjs";
17
+ import { assertAssetName, assertGitHubRepository, assertUpdaterKeyId, assertUpdaterPlatform, buildLatestJson, githubAssetUrl, githubTag, publishGitHubUpdaterRelease } from "./github-updater-release.mjs";
17
18
 
18
19
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
19
20
  const API_BASE = process.env.RIGHTAPPS_API_URL || "https://api.spoares.com";
@@ -44,9 +45,18 @@ if (!["win", "mac"].includes(platform)) throw new PublicationError("PUBLICATION_
44
45
  const configPath = path.resolve(configName);
45
46
  const root = path.dirname(configPath);
46
47
  const config = (await import(pathToFileURL(configPath))).default;
47
- if (config?.distribution?.provider !== "r2-rightapps") {
48
- fail("release config distribution.provider must be r2-rightapps");
48
+ const provider = config?.distribution?.provider;
49
+ if (provider !== "r2-rightapps" && provider !== "github-releases") {
50
+ fail("release config distribution.provider must be r2-rightapps or github-releases");
49
51
  }
52
+ // github-releases: the same sealed, suite-key-signed updater artifacts, published as release assets plus a Tauri v2
53
+ // static latest.json (github-updater-release.mjs). Nothing below forks the r2 signing or platforms code.
54
+ const githubLane = provider === "github-releases";
55
+ let githubRepo = "";
56
+ if (githubLane) {
57
+ try { githubRepo = assertGitHubRepository(config.distribution.repository); } catch (error) { fail(error.message); }
58
+ }
59
+ const githubKey = (item) => item.key ?? path.basename(item.file);
50
60
  const target = config?.targets?.[platform];
51
61
  const artifacts = target?.updater?.artifacts ?? [];
52
62
  const installers = target?.installer?.artifacts ?? [];
@@ -59,19 +69,24 @@ if (!dryRun || releaseId) {
59
69
  const commit = git(["rev-parse", "HEAD"]);
60
70
  assertCleanSource({ status: git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]), commandId: "right-release publish-update" });
61
71
  sealed = verifyPublicationRelease({ sealedDir: path.join(repoRoot, ".right-release", "sealed", releaseId, platform === "win" ? "windows" : "mac"), repoRoot, appRoot: root, commit, app: config.app, version: versionForStorage, platform, configPath, releaseId });
62
- const routes = sealed.manifest.routes?.[tier] ?? [];
63
- const configured = [...(tier === "patch" ? installers.map((item) => ({ ...item, role: "installer" })) : []), ...artifacts.map((item) => ({ ...item, key: tier === "patch" ? patchInstallerKey(item) : item.key, role: "updater" }))];
72
+ const routes = sealed.manifest.routes?.[githubLane ? "update" : tier] ?? [];
73
+ const configured = githubLane
74
+ ? artifacts.map((item) => ({ ...item, key: githubKey(item), role: "updater" }))
75
+ : [...(tier === "patch" ? installers.map((item) => ({ ...item, role: "installer" })) : []), ...artifacts.map((item) => ({ ...item, key: tier === "patch" ? patchInstallerKey(item) : item.key, role: "updater" }))];
64
76
  for (const item of configured) {
65
- if (!routes.some((route) => route.key === item.key && route.role === item.role && route.bucket === (tier === "patch" ? "public" : "private") && route.name === path.basename(item.file) && (!item.signature || route.signature === path.basename(item.signature)))) throw new PublicationError("PUBLICATION_ROUTE_INVALID", `config route is not sealed: ${item.key}`);
77
+ if (!routes.some((route) => route.key === item.key && route.role === item.role && route.bucket === (tier === "patch" && !githubLane ? "public" : "private") && route.name === path.basename(item.file) && (!item.signature || route.signature === path.basename(item.signature)))) throw new PublicationError("PUBLICATION_ROUTE_INVALID", `config route is not sealed: ${item.key}`);
66
78
  }
67
79
  if (!configured.length) throw new PublicationError("PUBLICATION_ROUTE_INVALID", "seal has no configured upload route");
68
80
  const installer = sealed.manifest.files.find((item) => item.role.split("+").includes("installer"));
69
- if (!installer) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed installer required for platform trust verification");
70
- verifyPlatformTrust({ sealed, installer: path.join(sealed.sealedDir, installer.name) });
81
+ if (!installer && !githubLane) throw new PublicationError("PUBLICATION_SIGNATURE_MISSING", "sealed installer required for platform trust verification");
82
+ // A github-releases app may ship only updater bundles and portable zips; their trust is the sealed signing
83
+ // receipts plus the updater signature, so platform trust runs only when a sealed installer exists.
84
+ if (installer) verifyPlatformTrust({ sealed, installer: path.join(sealed.sealedDir, installer.name) });
85
+ else console.log("no sealed installer; skipping installer platform trust check (github-releases updater-only seal)");
71
86
  for (const artifact of sealed.artifacts.values()) assertArtifactUnchanged(artifact);
72
87
  // Hardening step 4: installed QA is recorded, not enforced (R2 patch + update tiers); a missing receipt warns and is written to state.
73
88
  if (!dryRun) {
74
- admitInstalledQa({ repoRoot, manifest: sealed.manifest, platform, configName: path.relative(process.cwd(), configPath) || configName, releaseConfigSha256: digestBytes(readFileSync(configPath)) }, { provider: "r2" });
89
+ admitInstalledQa({ repoRoot, manifest: sealed.manifest, platform, configName: path.relative(process.cwd(), configPath) || configName, releaseConfigSha256: digestBytes(readFileSync(configPath)) }, { provider: githubLane ? "github-releases" : "r2" });
75
90
  }
76
91
  }
77
92
  const artifactFile = (declared) => {
@@ -98,12 +113,12 @@ function git(args) {
98
113
  return result.stdout.trim();
99
114
  }
100
115
 
101
- if (tier === "patch" && !installers.length) fail(`${config?.app ?? "app"} ${platform} patch has no installer.artifacts`);
102
- if (tier === "update" && !artifacts.length) fail(`${config?.app ?? "app"} ${platform} update has no updater.artifacts`);
116
+ if (!githubLane && tier === "patch" && !installers.length) fail(`${config?.app ?? "app"} ${platform} patch has no installer.artifacts`);
117
+ if ((githubLane || tier === "update") && !artifacts.length) fail(`${config?.app ?? "app"} ${platform} update has no updater.artifacts`);
103
118
 
104
119
  if (sealed) for (const artifact of artifacts) signatureFor(artifact);
105
120
  let releaseToken = null;
106
- if (!dryRun && (tier === "update" || artifacts.length > 0)) {
121
+ if (!githubLane && !dryRun && (tier === "update" || artifacts.length > 0)) {
107
122
  try {
108
123
  releaseToken = await loadReleaseToken();
109
124
  } catch (error) {
@@ -111,6 +126,52 @@ if (!dryRun && (tier === "update" || artifacts.length > 0)) {
111
126
  }
112
127
  }
113
128
 
129
+ if (githubLane) {
130
+ const version = config.version || target.updater.version;
131
+ if (!version) fail(`${config.app} config must expose version or targets.${platform}.updater.version`);
132
+ try {
133
+ const tag = githubTag(version);
134
+ const notes = process.env.RIGHT_RELEASE_NOTES || `${config.app} ${version}`;
135
+ const platforms = {};
136
+ const assets = new Map();
137
+ for (const artifact of artifacts) {
138
+ assertUpdaterPlatform(artifact.platform);
139
+ const file = artifactFile(artifact.file);
140
+ const name = assertAssetName(path.basename(file));
141
+ // Same signature source and process as the r2 lane: the sealed minisign .sig written by sign-updater.
142
+ const signature = dryRun ? `<signature:${artifact.signature ?? "msix-authenticode"}>` : signatureFor(artifact);
143
+ if (!dryRun && !appInstallerKind) assertUpdaterKeyId(signature, config.distribution.updaterKeyId, `signature of ${name}`);
144
+ platforms[artifact.platform] = { signature, url: githubAssetUrl(githubRepo, tag, name) };
145
+ assets.set(file, { file, evidence: dryRun ? undefined : verifiedArtifact(file) });
146
+ if (!appInstallerKind) {
147
+ const sigFile = artifactFile(artifact.signature);
148
+ assets.set(sigFile, { file: sigFile, evidence: dryRun ? undefined : verifiedArtifact(artifact.signature) });
149
+ }
150
+ }
151
+ for (const asset of assets.values()) if (asset.evidence) assertArtifactUnchanged(asset.evidence);
152
+ const latest = buildLatestJson({ version, notes, pubDate: new Date().toISOString(), platforms });
153
+ for (const asset of assets.values()) console.log(`${dryRun ? "[dry-run] " : ""}UPLOAD ${githubRepo}@${tag}/${path.basename(asset.file)}`);
154
+ console.log(`${dryRun ? "[dry-run] " : ""}UPLOAD ${githubRepo}@${tag}/latest.json`);
155
+ console.log(JSON.stringify(latest));
156
+ const stateDir = path.join(root, ".right-release", "state", releaseId || "dry-run", platform === "win" ? "windows" : "mac", "github-updater");
157
+ const result = publishGitHubUpdaterRelease({
158
+ repo: githubRepo,
159
+ tag,
160
+ title: `${config.app} ${version}`,
161
+ notes,
162
+ assets: [...assets.values()].map((asset) => ({ file: asset.file, evidence: asset.evidence })),
163
+ latest,
164
+ stateDir,
165
+ dryRun,
166
+ });
167
+ console.log(`publish-update: github-releases ${result.status} ${tag}${result.latestJsonUrl ? ` ${result.latestJsonUrl}` : ""}`);
168
+ } catch (error) {
169
+ fail(error.message);
170
+ }
171
+ if (!dryRun) recordPublishedVersion({ app: config.app, version: versionForStorage, platform, configPath, releaseId });
172
+ process.exit(0);
173
+ }
174
+
114
175
  if (tier === "patch") {
115
176
  for (const installer of installers) {
116
177
  assertCurrentKey(installer.key, "installer");
@@ -0,0 +1,5 @@
1
+ {
2
+ "schema": 1,
3
+ "description": "Repositories that are not apps and so need no RightKit QA lane. RightKit-owned: a change needs a RightKit release. Never list an app here; there is no per-app opt-out. The RightKit source tree itself is recognised by packages/release/package.json (name @rightkit/release) and needs no entry.",
4
+ "nonAppRepositories": []
5
+ }