@rightkit/release 0.2.151 → 0.2.154

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 (70) 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/deps.mjs +0 -0
  9. package/development-signing.mjs +8 -0
  10. package/development.mjs +46 -13
  11. package/devtools-policy.json +5 -0
  12. package/devtools-policy.mjs +119 -0
  13. package/direct-bootstrap.mjs +9 -3
  14. package/dmg-build.mjs +19 -5
  15. package/dmg-layout.mjs +2 -2
  16. package/dmg-spec.mjs +97 -0
  17. package/execution-policy.mjs +17 -1
  18. package/github-release.mjs +25 -7
  19. package/github-upload-gate.mjs +84 -0
  20. package/hardeningscan.mjs +3 -0
  21. package/inventory-check.mjs +4 -4
  22. package/lsclean.sh +0 -0
  23. package/mac-bundle-signing.mjs +1 -1
  24. package/mac-sign-core.mjs +97 -0
  25. package/mac-signing-matrix.mjs +186 -0
  26. package/msix-appinstaller.mjs +4 -1
  27. package/msix-build.mjs +3 -6
  28. package/msix-config.mjs +5 -1
  29. package/msix-repair-guard.mjs +24 -0
  30. package/msix-repair-guard.ps1 +356 -0
  31. package/msix-signer.mjs +211 -0
  32. package/ownership-catalog-config.json +6 -7
  33. package/ownership-command.mjs +69 -8
  34. package/ownership-evidence.mjs +1 -1
  35. package/ownership-key-authority.mjs +1 -1
  36. package/ownership-key-command.mjs +15 -3
  37. package/ownership-key-elevation.mjs +11 -2
  38. package/ownership-mandatory-set.json +420 -0
  39. package/ownership-mandatory-set.mjs +273 -0
  40. package/ownership.mjs +2 -2
  41. package/package.json +25 -11
  42. package/packaged-app-identity.mjs +88 -0
  43. package/preflight.mjs +25 -3
  44. package/prune-r2.mjs +28 -3
  45. package/publication-gate.mjs +49 -10
  46. package/publish-cargo.mjs +21 -1
  47. package/publish-swift.mjs +78 -141
  48. package/publish-update.mjs +24 -2
  49. package/qa-build-features.mjs +216 -0
  50. package/qa-control-scan.mjs +435 -0
  51. package/registry-parity.mjs +18 -1
  52. package/release-invocation.mjs +2 -1
  53. package/release-state.mjs +9 -0
  54. package/release-switch-registry.json +33 -0
  55. package/release-switch-scan.mjs +452 -0
  56. package/release.mjs +320 -26
  57. package/renamed-away.mjs +29 -0
  58. package/rightkit-ownership.json +682 -709
  59. package/rightkit-sync.mjs +8 -4
  60. package/rightkit-version-policy.mjs +18 -0
  61. package/rightkit-versions.json +211 -127
  62. package/runtime-budget-classes.json +4 -0
  63. package/runtime-budgets.mjs +870 -0
  64. package/runtime-bundle-scan.mjs +61 -0
  65. package/sign-macos.mjs +28 -30
  66. package/sign-updater.mjs +5 -2
  67. package/swift-mac-dmg.mjs +7 -2
  68. package/swift-package-command.mjs +1 -1
  69. package/upload-large.mjs +0 -0
  70. package/upload-release.mjs +10 -37
@@ -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
  }
package/publish-cargo.mjs CHANGED
@@ -7,6 +7,9 @@ import { runCargoSync } from "./managed-cargo.mjs";
7
7
  import { readVerifiedArtifact, assertArtifactUnchanged } from "./publication-artifacts.mjs";
8
8
  import { verifyCargoRegistry } from "./cargo-registry-readback.mjs";
9
9
  import { recordPublished } from "./rightkit-sync.mjs";
10
+ import { readCargoPackage } from "./crate-graph.mjs";
11
+ import { runtimeBudgetCheck, RUNTIME_BUDGET_CODE } from "./runtime-budgets.mjs";
12
+ import { formatPreflight, PREFLIGHT_FAIL } from "./preflight.mjs";
10
13
 
11
14
  const FORBIDDEN_PACKAGE_PATH = /(^|\/)(?:\.env(?:\.[^/]*)?|target(?:\/.*)?|node_modules(?:\/.*)?|[^/]+\.(?:p8|p12|pem|key))$/i;
12
15
  const SECRET_PATTERNS = [
@@ -46,6 +49,9 @@ export function buildCargoPublishPlan({ crate, crateDir, dryRun, allowDirty = fa
46
49
  if (allowDirty) packageArgs.push("--allow-dirty");
47
50
  const plan = [
48
51
  { label: "secret scan", internal: "secret-scan", crateDir },
52
+ // Runtime-budget crates (runtime-budgets.mjs) must carry a measured runtime-budget.json for this
53
+ // version on every required host. Dry-run reports the check; a real publish refuses it.
54
+ { label: "runtime budget", internal: "runtime-budget", crate, crateDir },
49
55
  { label: "format", command: "cargo", args: ["fmt", "-p", crate, "--check"] },
50
56
  { label: "tests", command: "cargo", args: ["test", "-p", crate, "--", "--test-threads=1"] },
51
57
  { label: "clippy", command: "cargo", args: ["clippy", "-p", crate, "--all-targets", "--", "-D", "warnings"] },
@@ -91,7 +97,11 @@ export async function runCargoPublish(options, execute = (_command, args, settin
91
97
  const crateDir = path.join(options.workspaceRoot, "crates", options.crate);
92
98
  const manifest = path.join(crateDir, "Cargo.toml");
93
99
  if (!fs.existsSync(manifest)) throw new Error(`crate manifest not found: ${manifest}`);
94
- const cargoVersion = fs.readFileSync(manifest, "utf8").match(/^version\s*=\s*"([^"]+)"/m)?.[1];
100
+ // Parsed, not pattern-matched: `version.workspace = true` resolves through the workspace root, and a
101
+ // manifest whose [package].name is not the requested crate is refused before anything else runs.
102
+ const pkg = readCargoPackage(crateDir);
103
+ if (pkg.name !== options.crate) throw new Error(`Cargo.toml [package].name ${JSON.stringify(pkg.name)} differs from --crate ${options.crate}`);
104
+ const cargoVersion = pkg.version ?? undefined;
95
105
  const gateOptions = {
96
106
  kind: "cargo", name: options.crate, version: cargoVersion, packagePath: crateDir,
97
107
  evidencePath: path.resolve(options.evidence ?? defaultEvidencePath(options.workspaceRoot, "cargo", options.crate)),
@@ -110,6 +120,16 @@ export async function runCargoPublish(options, execute = (_command, args, settin
110
120
  if (violations.length) throw new Error(`secret scan rejected: ${violations.join(", ")}`);
111
121
  continue;
112
122
  }
123
+ if (step.internal === "runtime-budget") {
124
+ const check = runtimeBudgetCheck({ crate: options.crate, crateDir, version: cargoVersion, evidence: gate.evidence ?? undefined });
125
+ if (check) process.stdout.write(`${formatPreflight([check])}\n`);
126
+ if (check?.status === PREFLIGHT_FAIL && !options.dryRun) {
127
+ const error = new Error(`runtime budget refused: ${check.detail}. Fix: ${check.fix}`);
128
+ error.code = RUNTIME_BUDGET_CODE;
129
+ throw error;
130
+ }
131
+ continue;
132
+ }
113
133
  if (step.internal === "verify-archive") {
114
134
  // Revalidate source/evidence after packaging; preserve that exact evidence object.
115
135
  const current = (deps.enforceGate ?? enforcePublicationGate)(gateOptions);
package/publish-swift.mjs CHANGED
@@ -1,129 +1,73 @@
1
- // Publish RightKitSwift by the ONE Swift distribution contract: a git repository pinned by URL +
2
- // exact version tag (docs/RIGHTKIT-SHARED-PACKAGES.md, Swift section). Gates, in order:
3
- // secret scan -> swift test -> release build -> mirror tree into a git checkout + tag ->
4
- // FRESH CONSUMER RESOLUTION (a throwaway app pins the tag with `exact:` and runs
5
- // `swift package resolve`) -> push. A dry-run performs everything except the push, so a green
6
- // dry-run proves the mirror layout, the tag, and clean consumer resolution -- not just that tests passed.
7
- // swift-tools is macOS/Linux only, so the plan builder, arg parser, and version check are
8
- // pure and unit-tested on any OS (publish-swift.test.mjs); the swift steps run where swift exists.
1
+ // Publish RightKitSwift by the ONE Swift distribution contract: a plain semver git tag on this
2
+ // repository (github.com/Orthic-Labs/rightkit), resolved by apps through the root Package.swift
3
+ // (docs/RIGHTKIT-SHARED-PACKAGES.md, Swift section). There is no mirror and no second repository.
4
+ //
5
+ // `right-release publish swift [--dry-run] [--evidence <file>] [--workspace <dir>]` verifies, in order:
6
+ // 1. VERSION (swift/RightKitSwift/VERSION) is plain semver (X.Y.Z, no "v", no prerelease);
7
+ // 2. the checkout is on main, has a clean tree, and HEAD is reachable from origin/main;
8
+ // 3. the root Package.swift and the nested manifest agree (scripts/swift-root-manifest.test.mjs);
9
+ // 4. the tag <VERSION> exists neither locally nor on origin;
10
+ // 5. the publication gate evidence is bound to HEAD and to the package tree at HEAD;
11
+ // then creates the tag on HEAD and pushes it:
12
+ // git tag <VERSION> <HEAD sha>
13
+ // git push origin refs/tags/<VERSION>:refs/tags/<VERSION>
14
+ // A dry-run performs every check and prints those commands without running them.
9
15
  import fs from "node:fs";
10
- import os from "node:os";
11
16
  import path from "node:path";
12
17
  import { fileURLToPath } from "node:url";
13
18
  import { spawnSync } from "./background-process.mjs";
14
19
  import { digestBytes } from "./publication-artifacts.mjs";
15
- import { scanCrateForSecrets } from "./publish-cargo.mjs";
16
20
  import { enforcePublicationGate, defaultEvidencePath } from "./publication-gate.mjs";
17
21
 
18
- export const DEFAULT_SWIFT_GIT_URL = "https://github.com/bogusyogi/rightkit-swift.git";
19
- // The mirror must reproduce the verified git subtree exactly (its tree id is
20
- // compared), so only untracked build state is excluded. Tracked Xcode scheme
21
- // metadata under .swiftpm ships with the package; SwiftPM consumers ignore it.
22
- const MIRROR_EXCLUDES = new Set([".build", ".git", "Package.resolved", ".DS_Store"]);
22
+ export const SWIFT_DISTRIBUTION_URL = "https://github.com/Orthic-Labs/rightkit";
23
+ const DRIFT_TEST = path.join("scripts", "swift-root-manifest.test.mjs");
23
24
 
24
- export function parseSwiftPublishArgs(argv, env = process.env) {
25
- const allowed = new Set(["--repo", "--dry-run", "--allow-dirty", "--evidence", "--workspace"]);
26
- let repo = env.RIGHTKIT_SWIFT_GIT_URL ?? DEFAULT_SWIFT_GIT_URL;
25
+ export function parseSwiftPublishArgs(argv) {
26
+ const allowed = new Set(["--dry-run", "--evidence", "--workspace"]);
27
27
  let dryRun = false;
28
- let allowDirty = false;
29
28
  let evidence;
30
29
  let workspace;
31
30
  for (let index = 0; index < argv.length; index += 1) {
32
31
  const arg = argv[index];
33
32
  if (!allowed.has(arg)) throw new Error(`unexpected argument: ${arg}`);
34
- if (arg === "--dry-run") dryRun = true;
35
- else if (arg === "--allow-dirty") allowDirty = true;
36
- else {
37
- const value = argv[(index += 1)];
38
- if (value === undefined) throw new Error(`${arg} needs a value`);
39
- if (arg === "--evidence") evidence = value;
40
- else if (arg === "--workspace") workspace = value;
41
- else repo = value;
42
- }
33
+ if (arg === "--dry-run") { dryRun = true; continue; }
34
+ const value = argv[(index += 1)];
35
+ if (value === undefined) throw new Error(`${arg} needs a value`);
36
+ if (arg === "--evidence") evidence = value;
37
+ else workspace = value;
43
38
  }
44
- if (!/^(https:\/\/|git@|ssh:\/\/|file:\/\/)/.test(repo)) throw new Error(`invalid swift git url: ${repo}`);
45
- if (allowDirty && !dryRun) throw new Error("--allow-dirty is only valid with --dry-run");
46
- return { repo, dryRun, allowDirty, evidence, workspace };
39
+ return { dryRun, evidence, workspace };
47
40
  }
48
41
 
49
- // The VERSION file is the single source of truth; Package.swift must name the package and the
50
- // version must be a clean semver so the registry archive is reproducible.
42
+ // VERSION is the single source of truth; the tag is exactly its text.
51
43
  export function readPackageIdentity(packageDir) {
52
44
  const manifest = path.join(packageDir, "Package.swift");
53
45
  if (!fs.existsSync(manifest)) throw new Error(`Package.swift not found: ${manifest}`);
54
46
  const versionFile = path.join(packageDir, "VERSION");
55
47
  if (!fs.existsSync(versionFile)) throw new Error(`VERSION not found: ${versionFile}`);
56
48
  const version = fs.readFileSync(versionFile, "utf8").trim();
57
- if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
58
- throw new Error(`VERSION is not clean semver: "${version}"`);
59
- }
49
+ if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error(`VERSION is not plain semver (X.Y.Z): "${version}"`);
60
50
  const nameMatch = fs.readFileSync(manifest, "utf8").match(/name:\s*"([^"]+)"/);
61
51
  if (!nameMatch) throw new Error("could not read package name from Package.swift");
62
52
  return { name: nameMatch[1], version };
63
53
  }
64
54
 
65
- export function buildSwiftPublishPlan({ packageDir, name, version, repo, dryRun }) {
66
- const plan = [
67
- { label: "secret scan", internal: "secret-scan" },
68
- { label: "tests", command: "swift", args: ["test", "--package-path", packageDir] },
69
- { label: "release build", command: "swift", args: ["build", "-c", "release", "--package-path", packageDir] },
70
- // Dry-run mirrors into a fresh local repo; a real publish clones the distribution repo first.
71
- { label: "mirror and tag", internal: "mirror", tag: version, repo: dryRun ? null : repo },
72
- { label: "fresh consumer resolution (exact pin)", internal: "consume", tag: version, name },
55
+ // The exact commands a real publish runs after every check passes.
56
+ export function swiftTagCommands(version, sha) {
57
+ return [
58
+ ["git", ["tag", version, sha]],
59
+ ["git", ["push", "origin", `refs/tags/${version}:refs/tags/${version}`]],
73
60
  ];
74
- if (!dryRun) plan.push({ label: "git push (branch and tag)", internal: "push", tag: version, repo });
75
- return plan;
76
61
  }
77
62
 
78
- function git(args, cwd, execute = spawnSync) {
63
+ function git(args, cwd, execute) {
79
64
  const result = execute("git", args, { cwd, encoding: "utf8", windowsHide: true });
80
65
  if (result.error) throw result.error;
81
66
  if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${(result.stderr ?? "").trim()}`);
82
67
  return (result.stdout ?? "").trim();
83
68
  }
84
69
 
85
- function copyTree(from, to) {
86
- fs.mkdirSync(to, { recursive: true });
87
- for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
88
- if (MIRROR_EXCLUDES.has(entry.name)) continue;
89
- const source = path.join(from, entry.name);
90
- const target = path.join(to, entry.name);
91
- if (entry.isDirectory()) copyTree(source, target);
92
- else if (entry.isFile()) { fs.copyFileSync(source, target); fs.chmodSync(target, fs.statSync(source).mode & 0o777); }
93
- else throw new Error(`unsupported Swift source entry: ${entry.name}`);
94
- }
95
- }
96
-
97
- function mirrorAndTag(step, packageDir, work, execute) {
98
- const mirror = path.join(work, "rightkit-swift"); // SwiftPM names the package by repo directory
99
- if (step.repo) git(["clone", step.repo, mirror], work, execute);
100
- else { fs.mkdirSync(mirror, { recursive: true }); git(["init", "-q", "-b", "main"], mirror, execute); }
101
- for (const entry of fs.readdirSync(mirror)) if (entry !== ".git") fs.rmSync(path.join(mirror, entry), { recursive: true, force: true });
102
- copyTree(packageDir, mirror);
103
- git(["add", "-A"], mirror, execute);
104
- git(["-c", "user.email=release@rightsuite.invalid", "-c", "user.name=right-release", "commit", "-q", "--allow-empty", "-m", `RightKitSwift ${step.tag}`], mirror, execute);
105
- if (git(["tag", "-l", step.tag], mirror, execute) !== "") throw new Error(`tag ${step.tag} already exists: bump VERSION`);
106
- git(["tag", step.tag], mirror, execute);
107
- return mirror;
108
- }
109
-
110
- function consumeFresh(step, mirror, work, execute) {
111
- const app = path.join(work, "consumer");
112
- fs.mkdirSync(path.join(app, "Sources", "App"), { recursive: true });
113
- fs.writeFileSync(path.join(app, "Package.swift"), [
114
- "// swift-tools-version: 6.0", "import PackageDescription", "let package = Package(",
115
- ' name: "App", platforms: [.iOS(.v17), .macOS(.v14)],',
116
- ` dependencies: [.package(url: "file://${mirror}", exact: "${step.tag}")],`,
117
- ' targets: [.target(name: "App", dependencies: [.product(name: "RightKitStore", package: "rightkit-swift")])]', ")", "",
118
- ].join("\n"));
119
- fs.writeFileSync(path.join(app, "Sources", "App", "App.swift"), "import RightKitStore\npublic let ok = true\n");
120
- const result = execute("swift", ["package", "resolve"], { cwd: app, encoding: "utf8", windowsHide: true, stdio: "inherit" });
121
- if (result.error || result.status !== 0) throw new Error("fresh consumer resolution failed");
122
- const pins = fs.readFileSync(path.join(app, "Package.resolved"), "utf8");
123
- if (!pins.includes(`"version" : "${step.tag}"`)) throw new Error(`consumer did not resolve exact tag ${step.tag}`);
124
- }
125
-
126
- // Evidence binds this exact git-archive command, rooted at the package tree.
70
+ // Evidence binds this exact git-archive of the package tree at HEAD.
127
71
  export function verifySwiftSourceArchive(bytes, evidence) {
128
72
  if (!/^[a-f0-9]{64}$/.test(evidence?.artifactSha256 ?? "")) throw new Error("Swift source archive hash evidence is required");
129
73
  if (digestBytes(bytes) !== evidence.artifactSha256) throw new Error("Swift source archive sha256 does not match evidence artifactSha256");
@@ -133,70 +77,63 @@ export function verifySwiftSourceArchive(bytes, evidence) {
133
77
  export function runSwiftPublish(options, execute = spawnSync) {
134
78
  const packageDir = options.packageDir;
135
79
  const { name, version } = readPackageIdentity(packageDir);
80
+ const toplevel = git(["rev-parse", "--show-toplevel"], packageDir, execute);
81
+ const out = (message) => process.stdout.write(`[right-release] swift ${message}\n`);
82
+
83
+ const branch = git(["symbolic-ref", "--short", "-q", "HEAD"], toplevel, execute);
84
+ if (branch !== "main") throw new Error(`publish swift runs only on main (current: ${branch || "detached HEAD"})`);
85
+ if (git(["status", "--porcelain", "--untracked-files=normal"], toplevel, execute) !== "") throw new Error("working tree is not clean: commit or remove changes before tagging");
86
+ git(["fetch", "--quiet", "origin", "main"], toplevel, execute);
87
+ const sha = git(["rev-parse", "HEAD"], toplevel, execute);
88
+ const ancestor = execute("git", ["merge-base", "--is-ancestor", sha, "origin/main"], { cwd: toplevel, encoding: "utf8", windowsHide: true });
89
+ if (ancestor.error) throw ancestor.error;
90
+ if (ancestor.status !== 0) throw new Error(`HEAD ${sha} is not on origin/main: push it and let CI pass first`);
91
+ out("branch, clean tree, HEAD on origin/main: ok");
92
+
93
+ const drift = execute(process.execPath, ["--test", DRIFT_TEST], { cwd: toplevel, encoding: "utf8", windowsHide: true });
94
+ if (drift.error) throw drift.error;
95
+ if (drift.status !== 0) throw new Error(`root Package.swift drifted from swift/RightKitSwift/Package.swift:\n${drift.stdout ?? ""}${drift.stderr ?? ""}`);
96
+ out("root manifest matches nested manifest: ok");
97
+
98
+ if (git(["tag", "-l", version], toplevel, execute) !== "") throw new Error(`tag ${version} already exists locally: bump VERSION`);
99
+ if (git(["ls-remote", "--tags", "origin", `refs/tags/${version}`], toplevel, execute) !== "") throw new Error(`tag ${version} already exists on origin: bump VERSION`);
100
+ out(`tag ${version} is free: ok`);
101
+
136
102
  const workspaceRoot = path.resolve(options.workspace ?? path.join(packageDir, "..", ".."));
137
103
  const gate = enforcePublicationGate({
138
104
  kind: "swift", name, version, packagePath: packageDir,
139
105
  evidencePath: path.resolve(options.evidence ?? defaultEvidencePath(workspaceRoot, "swift", name)),
140
106
  }, { dryRun: options.dryRun });
141
- const prefix = git(["rev-parse", "--show-prefix"], packageDir, execute).replace(/\/$/, "");
142
- const sourceCommit = gate.evidence?.commit ?? "HEAD";
143
- const tree = git(["rev-parse", prefix ? `${sourceCommit}:${prefix}` : `${sourceCommit}^{tree}`], packageDir, execute);
144
- // `git archive` filters by the current subdirectory, so archiving the package
145
- // subtree from inside the package yields an empty tar; run it at the toplevel.
146
- const toplevel = git(["rev-parse", "--show-toplevel"], packageDir, execute) || packageDir;
147
- const archive = execute("git", ["archive", "--format=tar", "--mtime=1970-01-01T00:00:00Z", tree], { cwd: toplevel, windowsHide: true, maxBuffer: 128 * 1024 * 1024 });
148
- if (archive.error || archive.status !== 0 || !archive.stdout?.length) throw new Error("Swift source archive creation failed");
149
- const bytes = Buffer.from(archive.stdout);
150
- if (gate.ok || !options.dryRun) verifySwiftSourceArchive(bytes, gate.evidence);
151
- const plan = buildSwiftPublishPlan({ ...options, packageDir, name, version });
152
- const work = fs.mkdtempSync(path.join(os.tmpdir(), "rightkit-swift-publish-"));
153
- let mirror = null;
154
- let sealedCommit;
155
- const snapshot = path.join(work, "source");
156
- fs.mkdirSync(snapshot);
157
- try {
158
- const extracted = execute("tar", ["-xf", "-", "-C", snapshot], { input: bytes, windowsHide: true });
159
- if (extracted.error || extracted.status !== 0) throw new Error("Swift source archive extraction failed");
160
- const identity = readPackageIdentity(snapshot);
161
- if (identity.name !== name || identity.version !== version) throw new Error("Swift source archive identity mismatch");
162
- for (const step of plan) {
163
- process.stdout.write(`[right-release] swift ${step.label}\n`);
164
- if (step.internal === "secret-scan") {
165
- const violations = scanCrateForSecrets(snapshot);
166
- if (violations.length) throw new Error(`secret scan rejected: ${violations.join(", ")}`);
167
- continue;
168
- }
169
- if (step.internal === "mirror") { mirror = mirrorAndTag(step, snapshot, work, execute);
170
- if (git(["rev-parse", `${step.tag}^{tree}`], mirror, execute) !== tree) throw new Error("Swift mirror differs from verified source archive");
171
- sealedCommit = git(["rev-parse", step.tag], mirror, execute);
172
- continue; }
173
- if (step.internal === "consume") { consumeFresh(step, mirror, work, execute); continue; }
174
- if (step.internal === "push") {
175
- if (git(["rev-parse", `${sealedCommit}^{tree}`], mirror, execute) !== tree || git(["rev-parse", step.tag], mirror, execute) !== sealedCommit) throw new Error("Swift publication tag changed after verification");
176
- const branch = git(["symbolic-ref", "--short", "HEAD"], mirror, execute);
177
- git(["push", "--atomic", "origin", `${sealedCommit}:refs/heads/${branch}`, `${sealedCommit}:refs/tags/${step.tag}`], mirror, execute);
178
- continue;
179
- }
180
- const result = execute(step.command, step.args.map((arg) => arg === packageDir ? snapshot : arg), { cwd: snapshot, encoding: "utf8", windowsHide: true, stdio: "inherit" });
181
- if (result.error) {
182
- if (result.error.code === "ENOENT") {
183
- throw new Error(`${step.command} not found -- run publish swift on macOS/Linux with the Swift toolchain installed`);
184
- }
185
- throw result.error;
186
- }
187
- if (result.status !== 0) throw new Error(`${step.label} failed with exit code ${result.status}`);
188
- }
189
- } finally {
190
- fs.rmSync(work, { recursive: true, force: true });
107
+ if (gate.ok) {
108
+ if (gate.evidence?.commit !== sha) throw new Error(`publication evidence commit ${gate.evidence?.commit} is not HEAD ${sha}`);
109
+ const prefix = git(["rev-parse", "--show-prefix"], packageDir, execute).replace(/\/$/, "");
110
+ const tree = git(["rev-parse", prefix ? `${sha}:${prefix}` : `${sha}^{tree}`], packageDir, execute);
111
+ // `git archive` filters by the current subdirectory, so archive the tree id from the toplevel.
112
+ const archive = execute("git", ["archive", "--format=tar", "--mtime=1970-01-01T00:00:00Z", tree], { cwd: toplevel, windowsHide: true, maxBuffer: 128 * 1024 * 1024 });
113
+ if (archive.error || archive.status !== 0 || !archive.stdout?.length) throw new Error("Swift package tree archive creation failed");
114
+ verifySwiftSourceArchive(Buffer.from(archive.stdout), gate.evidence);
115
+ }
116
+
117
+ const commands = swiftTagCommands(version, sha);
118
+ if (options.dryRun) {
119
+ for (const [command, args] of commands) out(`dry-run would run: ${command} ${args.join(" ")}`);
120
+ return { name, version, commit: sha, published: false };
191
121
  }
192
- return { name, version, published: !options.dryRun };
122
+ const [tagCommand, pushCommand] = commands;
123
+ git(tagCommand[1], toplevel, execute);
124
+ try { git(pushCommand[1], toplevel, execute); }
125
+ catch (error) { git(["tag", "-d", version], toplevel, execute); throw error; }
126
+ const remote = git(["ls-remote", "--tags", "origin", `refs/tags/${version}`], toplevel, execute);
127
+ if (!remote.startsWith(sha)) throw new Error(`origin does not show tag ${version} at ${sha}`);
128
+ out(`tagged ${version} at ${sha} on ${SWIFT_DISTRIBUTION_URL}`);
129
+ return { name, version, commit: sha, published: true };
193
130
  }
194
131
 
195
132
  const realOrSelf = (file) => { try { return fs.realpathSync(file); } catch { return path.resolve(file); } };
196
133
  if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
197
134
  try {
198
135
  const options = parseSwiftPublishArgs(process.argv.slice(2));
199
- const packageDir = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/(\w:)/, "$1")), "../../swift/RightKitSwift");
136
+ const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../swift/RightKitSwift");
200
137
  runSwiftPublish({ ...options, packageDir });
201
138
  } catch (error) {
202
139
  process.stderr.write(`${error.message}\n`);
@@ -114,6 +114,7 @@ if (!dryRun && (tier === "update" || artifacts.length > 0)) {
114
114
  if (tier === "patch") {
115
115
  for (const installer of installers) {
116
116
  assertCurrentKey(installer.key, "installer");
117
+ assertVersionedKey(installer.versionedKey);
117
118
  artifactFile(installer.file);
118
119
  }
119
120
  const platforms = {};
@@ -161,6 +162,13 @@ if (tier === "patch") {
161
162
  ...metadata,
162
163
  updaterSignature: null,
163
164
  });
165
+ // The versioned copy is the App Installer install source: Windows repairs by re-adding from the URI the
166
+ // package came from, so it must outlive the version that replaces it.
167
+ if (installer.versionedKey && !uploadedKeys.has(installer.versionedKey)) {
168
+ console.log(`${dryRun ? "[dry-run] " : ""}UPLOAD public/${installer.versionedKey}`);
169
+ if (!dryRun) uploadVerifiedR2Artifact(verifiedArtifact(file), installer.versionedKey, "public", { cwd: root });
170
+ uploadedKeys.add(installer.versionedKey);
171
+ }
164
172
  }
165
173
  if (!artifacts.length) {
166
174
  console.log(`${dryRun ? "[dry-run] " : ""}NO patch updater artifacts; skipping patch manifest registration`);
@@ -169,9 +177,10 @@ if (tier === "patch") {
169
177
  platform: platform === "mac" ? "mac" : "windows",
170
178
  dryRun,
171
179
  keep: {
172
- public: installers.map((artifact) => artifact.key),
180
+ public: installerKeepKeys(),
173
181
  private: target.updater.artifacts.map((artifact) => artifact.key),
174
182
  },
183
+ retainPreviousVersions: installers.some((artifact) => artifact.versionedKey) ? 1 : 0,
175
184
  });
176
185
  if (!dryRun) recordPublishedVersion({ app: config.app, version: versionForStorage, platform, configPath, releaseId });
177
186
  process.exit(0);
@@ -207,9 +216,10 @@ if (tier === "patch") {
207
216
  platform: platform === "mac" ? "mac" : "windows",
208
217
  dryRun,
209
218
  keep: {
210
- public: [...installers.map((artifact) => artifact.key), ...registrations.keys()],
219
+ public: [...installerKeepKeys(), ...registrations.keys()],
211
220
  private: [],
212
221
  },
222
+ retainPreviousVersions: installers.some((artifact) => artifact.versionedKey) ? 1 : 0,
213
223
  });
214
224
  if (!dryRun) recordPublishedVersion({ app: config.app, version: versionForStorage, platform, configPath, releaseId });
215
225
  process.exit(0);
@@ -311,6 +321,18 @@ function assertCurrentKey(key, kind) {
311
321
  }
312
322
  }
313
323
 
324
+ // Current + versioned installer keys: both stay public so the old install source is never deleted.
325
+ function installerKeepKeys() {
326
+ return installers.flatMap((artifact) => [artifact.key, ...(artifact.versionedKey ? [artifact.versionedKey] : [])]);
327
+ }
328
+
329
+ function assertVersionedKey(key) {
330
+ if (key === undefined) return;
331
+ if (!/\/installers\/(?:mac|windows)\/versions\/[^/]+\/[^/]+$/.test(key)) {
332
+ fail(`installer versionedKey must be <app>/installers/<os>/versions/<version>/<file>, got: ${key}`);
333
+ }
334
+ }
335
+
314
336
  function patchInstallerKey(artifact) {
315
337
  if (artifact.patchKey) return artifact.patchKey;
316
338
  if (!artifact.key.includes("/updates/")) fail(`patch updater key must be convertible to installers/current, got: ${artifact.key}`);