@rightkit/release 0.2.150 → 0.2.153

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE-APACHE +201 -0
  2. package/LICENSE-MIT +21 -0
  3. package/README.md +119 -8
  4. package/build-release.mjs +16 -2
  5. package/cli/right-release.mjs +18 -5
  6. package/committed-source.mjs +19 -1
  7. package/crate-graph.mjs +121 -0
  8. package/development-signing.mjs +8 -0
  9. package/development.mjs +46 -13
  10. package/devtools-policy.json +5 -0
  11. package/devtools-policy.mjs +119 -0
  12. package/direct-bootstrap.mjs +9 -3
  13. package/dmg-build.mjs +19 -5
  14. package/dmg-layout.mjs +2 -2
  15. package/dmg-spec.mjs +97 -0
  16. package/execution-policy.mjs +17 -1
  17. package/github-release.mjs +25 -7
  18. package/github-upload-gate.mjs +84 -0
  19. package/hardeningscan.mjs +3 -0
  20. package/inventory-check.mjs +4 -4
  21. package/mac-bundle-signing.mjs +1 -1
  22. package/mac-sign-core.mjs +97 -0
  23. package/mac-signing-matrix.mjs +186 -0
  24. package/macos-notarization.mjs +18 -2
  25. package/msix-appinstaller.mjs +4 -1
  26. package/msix-config.mjs +5 -1
  27. package/msix-repair-guard.mjs +24 -0
  28. package/msix-repair-guard.ps1 +356 -0
  29. package/notarize-macos.mjs +4 -3
  30. package/ownership-catalog-config.json +6 -7
  31. package/ownership-command.mjs +69 -8
  32. package/ownership-evidence.mjs +1 -1
  33. package/ownership-key-authority.mjs +1 -1
  34. package/ownership-key-command.mjs +15 -3
  35. package/ownership-key-elevation.mjs +11 -2
  36. package/ownership-mandatory-set.json +420 -0
  37. package/ownership-mandatory-set.mjs +273 -0
  38. package/ownership.mjs +2 -2
  39. package/package.json +15 -3
  40. package/packaged-app-identity.mjs +88 -0
  41. package/preflight.mjs +25 -3
  42. package/prune-r2.mjs +28 -3
  43. package/publication-gate.mjs +49 -10
  44. package/publish-cargo.mjs +21 -1
  45. package/publish-swift.mjs +78 -141
  46. package/publish-update.mjs +24 -2
  47. package/qa-build-features.mjs +216 -0
  48. package/qa-control-scan.mjs +435 -0
  49. package/registry-parity.mjs +18 -1
  50. package/release-invocation.mjs +2 -1
  51. package/release-state.mjs +9 -0
  52. package/release-switch-registry.json +33 -0
  53. package/release-switch-scan.mjs +452 -0
  54. package/release.mjs +324 -26
  55. package/renamed-away.mjs +29 -0
  56. package/rightkit-ownership.json +682 -709
  57. package/rightkit-sync.mjs +8 -4
  58. package/rightkit-version-policy.mjs +18 -0
  59. package/rightkit-versions.json +212 -127
  60. package/runtime-budget-classes.json +4 -0
  61. package/runtime-budgets.mjs +870 -0
  62. package/runtime-bundle-scan.mjs +61 -0
  63. package/sign-macos.mjs +28 -30
  64. package/sign-updater.mjs +5 -2
  65. package/swift-mac-dmg.mjs +7 -2
  66. package/swift-package-command.mjs +1 -1
  67. package/upload-release.mjs +10 -37
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}`);
@@ -0,0 +1,216 @@
1
+ // Source-level layer of "no test-automation surface ships": a release or update build must not enable the cargo
2
+ // features that compile one in. The binary layer (`qa-control-scan.mjs`) cannot see every surface (a stripped
3
+ // binary, compressed installers), so right-release also refuses, before it builds anything:
4
+ // - a build command (prePackage, package, native package hook, finalizer) that enables one of the forbidden
5
+ // features, directly (`--features`, `-F`, `-f`, `--features=`, `--all-features`) or through a package.json
6
+ // script it runs (`pnpm run <script>`, followed up to three levels);
7
+ // - an app Cargo.toml whose DEFAULT feature closure reaches one of them, or that lists `devtools` on `tauri`,
8
+ // (`devtools` and rightkit-tauri-shell's `content-devtools` are accepted only for an allow-listed app, see devtools-policy.mjs)
9
+ // `qa-control` on `rightkit-qa-driver`, `dev-inspect` on `rightkit-tauri-shell`, or `tauri-plugin-wdio-webdriver` as a non-optional dependency.
10
+ // Dev builds are unaffected: `development.mjs` never calls this.
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import path from "node:path";
13
+
14
+ export const FORBIDDEN_FEATURES = Object.freeze(["devtools", "content-devtools", "qa-native", "qa-control", "dev-native", "dev-inspect",
15
+ // Per-app test-automation features (M20 owner gap): HeardRight `qa-shot`/`qa-tools`, ViewRight `dev-control`/`bench-native`.
16
+ "qa-shot", "qa-tools", "dev-control", "bench-native",
17
+ // D4 (M21/M23): rightkit-daemon `test-mode` compiles the override selectors; never in a release build.
18
+ "test-mode"]);
19
+ // The only forbidden features an allow-listed app may enable: rightkit-tauri-shell's `content-devtools` (and the Tauri
20
+ // `devtools` it switches on). See devtools-policy.mjs; every other app is refused them.
21
+ const DEVTOOLS_FEATURES = Object.freeze(["devtools", "content-devtools"]);
22
+ const FORBIDDEN_DEPENDENCY = "tauri-plugin-wdio-webdriver";
23
+ const MANAGERS = new Set(["pnpm", "npm", "yarn", "bun", "npx"]);
24
+
25
+ // "dep:foo" -> "foo"; "tauri/devtools" and "tauri?/devtools" -> "devtools".
26
+ function featureName(item) {
27
+ const bare = String(item).trim().replace(/^dep:/, "");
28
+ return bare.includes("/") ? bare.slice(bare.lastIndexOf("/") + 1) : bare;
29
+ }
30
+ export function isForbiddenFeature(item, { allowDevtools = false } = {}) {
31
+ const text = String(item).trim();
32
+ const name = featureName(text);
33
+ if (allowDevtools && DEVTOOLS_FEATURES.includes(name)) return false;
34
+ return text === `dep:${FORBIDDEN_DEPENDENCY}` || FORBIDDEN_FEATURES.includes(name);
35
+ }
36
+
37
+ export function tokenize(text) {
38
+ return (String(text).match(/"[^"]*"|'[^']*'|[^\s"']+/g) ?? []).map((t) => t.replace(/^["']|["']$/g, ""));
39
+ }
40
+
41
+ // Feature names a token list enables, plus whether it asks for every feature.
42
+ export function featuresInTokens(tokens) {
43
+ const enabled = []; let allFeatures = false;
44
+ const take = (value) => { for (const part of String(value).split(/[,\s]+/)) if (part) enabled.push(part); };
45
+ for (let i = 0; i < tokens.length; i += 1) {
46
+ const t = tokens[i];
47
+ if (t === "--all-features") allFeatures = true;
48
+ else if (t === "--features" || t === "-F" || t === "-f" || t === "--feature") { if (i + 1 < tokens.length) take(tokens[i + 1]); }
49
+ else if (t.startsWith("--features=")) take(t.slice("--features=".length));
50
+ else if (/^-F.+/.test(t)) take(t.slice(2));
51
+ }
52
+ return { enabled, allFeatures };
53
+ }
54
+
55
+ export function scriptTarget(tokens) {
56
+ const first = tokens.findIndex((t) => MANAGERS.has(path.basename(t).replace(/\.(cmd|exe)$/i, "")));
57
+ if (first < 0) return null;
58
+ let i = first + 1;
59
+ while (i < tokens.length && tokens[i].startsWith("-")) i += 1;
60
+ if (["run", "run-script"].includes(tokens[i])) { i += 1; while (i < tokens.length && tokens[i].startsWith("-")) i += 1; }
61
+ return tokens[i] ?? null;
62
+ }
63
+
64
+ // Issues (strings) for one command object { cmd, args, env? } or a shell string.
65
+ export function commandFeatureIssues(command, { label = "command", scripts = {}, depth = 0, seen = new Set(), allowDevtools = false } = {}) {
66
+ const parts = typeof command === "string" ? [command] : [command?.cmd, ...(command?.args ?? [])].filter((v) => v !== undefined).map(String);
67
+ // Two views: every argument whole (`--features` followed by "a b qa-native"), and every argument re-tokenised as
68
+ // shell text (`sh -c "cargo build --features qa-native"`).
69
+ const views = [parts, parts.flatMap((p) => tokenize(p))];
70
+ const issues = new Set();
71
+ for (const tokens of views) {
72
+ const { enabled, allFeatures } = featuresInTokens(tokens);
73
+ if (allFeatures) issues.add(`${label}: --all-features enables every feature including ${FORBIDDEN_FEATURES.join(", ")}`);
74
+ for (const feature of enabled) if (isForbiddenFeature(feature, { allowDevtools })) issues.add(`${label}: enables feature \`${feature}\``);
75
+ }
76
+ if (command && typeof command === "object") {
77
+ for (const [key, value] of Object.entries(command.env ?? {})) {
78
+ if (/FEATURES$/i.test(key)) for (const feature of String(value).split(/[,\s]+/)) if (feature && isForbiddenFeature(feature, { allowDevtools })) issues.add(`${label}: env ${key} enables feature \`${feature}\``);
79
+ }
80
+ }
81
+ const script = views.map(scriptTarget).find(Boolean);
82
+ if (script && depth < 3 && Object.hasOwn(scripts, script) && !seen.has(script)) {
83
+ for (const issue of commandFeatureIssues(String(scripts[script]), { label: `${label} -> package script \`${script}\``, scripts, depth: depth + 1, seen: new Set([...seen, script]), allowDevtools })) issues.add(issue);
84
+ }
85
+ return [...issues];
86
+ }
87
+
88
+ // ---- Cargo.toml ---------------------------------------------------------------------------------------------
89
+ function stripComments(text) {
90
+ let out = ""; let quote = null;
91
+ for (let i = 0; i < text.length; i += 1) {
92
+ const c = text[i];
93
+ if (quote) { out += c; if (c === quote && text[i - 1] !== "\\") quote = null; continue; }
94
+ if (c === '"' || c === "'") { quote = c; out += c; continue; }
95
+ if (c === "#") { while (i < text.length && text[i] !== "\n") i += 1; out += "\n"; continue; }
96
+ out += c;
97
+ }
98
+ return out;
99
+ }
100
+
101
+ function sectionsOf(text) {
102
+ const sections = []; let current = { name: "", body: "" };
103
+ for (const line of text.split(/\r?\n/)) {
104
+ const header = line.match(/^\s*\[\s*([^\[\]]+?)\s*\]\s*$/);
105
+ if (header) { sections.push(current); current = { name: header[1].replace(/\s+/g, ""), body: "" }; }
106
+ else current.body += `${line}\n`;
107
+ }
108
+ sections.push(current);
109
+ return sections;
110
+ }
111
+
112
+ function balanced(text, from, open, close) {
113
+ let depth = 0; let quote = null;
114
+ for (let i = from; i < text.length; i += 1) {
115
+ const c = text[i];
116
+ if (quote) { if (c === quote && text[i - 1] !== "\\") quote = null; continue; }
117
+ if (c === '"' || c === "'") quote = c;
118
+ else if (c === open) depth += 1;
119
+ else if (c === close) { depth -= 1; if (depth === 0) return text.slice(from, i + 1); }
120
+ }
121
+ return text.slice(from);
122
+ }
123
+
124
+ const strings = (text) => [...String(text).matchAll(/"([^"]*)"|'([^']*)'/g)].map((m) => m[1] ?? m[2]);
125
+ const unquote = (key) => key.replace(/^["']|["']$/g, "");
126
+
127
+ export function parseCargoFeaturesAndDependencies(manifestText) {
128
+ const sections = sectionsOf(stripComments(manifestText));
129
+ const features = new Map(); const dependencies = new Map();
130
+ for (const { name, body } of sections) {
131
+ if (name === "features") {
132
+ for (const m of body.matchAll(/^\s*([A-Za-z0-9_\-"']+)\s*=\s*\[/gm)) {
133
+ features.set(unquote(m[1]), strings(balanced(body, m.index + m[0].length - 1, "[", "]")));
134
+ }
135
+ } else if (/^(?:target\.[^\n]+\.)?dependencies$/.test(name)) {
136
+ for (const m of body.matchAll(/^\s*([A-Za-z0-9_\-"']+)\s*=\s*(\{)?/gm)) {
137
+ const depName = unquote(m[1]);
138
+ const spec = m[2] ? balanced(body, m.index + m[0].length - 1, "{", "}") : "";
139
+ dependencies.set(depName, dependencyInfo(spec));
140
+ }
141
+ } else {
142
+ const table = name.match(/^(?:target\.[^\n]+\.)?dependencies\.([A-Za-z0-9_\-]+)$/);
143
+ if (table) dependencies.set(table[1], dependencyInfo(body));
144
+ }
145
+ }
146
+ return { features, dependencies };
147
+ }
148
+ function dependencyInfo(spec) {
149
+ const list = spec.match(/features\s*=\s*(\[)/);
150
+ return {
151
+ optional: /optional\s*=\s*true/.test(spec),
152
+ features: list ? strings(balanced(spec, list.index + list[0].length - 1, "[", "]")) : [],
153
+ };
154
+ }
155
+
156
+ export function cargoManifestIssues(manifestText, label = "Cargo.toml", { allowDevtools = false } = {}) {
157
+ const { features, dependencies } = parseCargoFeaturesAndDependencies(manifestText);
158
+ const issues = [];
159
+ const tauri = dependencies.get("tauri");
160
+ if (!allowDevtools && tauri?.features.includes("devtools")) issues.push(`${label}: dependency \`tauri\` lists feature \`devtools\` unconditionally (inspector ships in release builds)`);
161
+ // The crate was renamed from rightkit-control; apps still on the old published name must stay covered.
162
+ for (const name of ["rightkit-qa-driver", "rightkit-control"]) {
163
+ const control = dependencies.get(name);
164
+ if (control && !control.optional && control.features.includes("qa-control")) issues.push(`${label}: dependency \`${name}\` lists \`qa-control\` unconditionally`);
165
+ }
166
+ // The crate was renamed from rightkit-shell; apps still on the old published name must stay covered.
167
+ for (const name of ["rightkit-tauri-shell", "rightkit-shell"]) {
168
+ const shell = dependencies.get(name);
169
+ if (shell && !shell.optional && shell.features.includes("dev-inspect")) issues.push(`${label}: dependency \`${name}\` lists \`dev-inspect\` unconditionally (developer inspection ships in release builds)`);
170
+ }
171
+ const service = dependencies.get("rightkit-daemon");
172
+ if (service && !service.optional && service.features.includes("test-mode")) issues.push(`${label}: dependency \`rightkit-daemon\` lists \`test-mode\` unconditionally (test-mode capability ships in release builds)`);
173
+ const wdio = dependencies.get(FORBIDDEN_DEPENDENCY);
174
+ if (wdio && !wdio.optional) issues.push(`${label}: dependency \`${FORBIDDEN_DEPENDENCY}\` is not optional`);
175
+ const reached = new Set(); const stack = [...(features.get("default") ?? [])];
176
+ while (stack.length) {
177
+ const item = stack.pop();
178
+ if (reached.has(item)) continue;
179
+ reached.add(item);
180
+ if (isForbiddenFeature(item, { allowDevtools })) issues.push(`${label}: default feature set reaches \`${item}\``);
181
+ if (features.has(item)) stack.push(...features.get(item));
182
+ }
183
+ return issues;
184
+ }
185
+
186
+ // commands: [{ label, command }]. Reads package.json scripts and the app Cargo manifests under `root`.
187
+ // `devtools`: the allowance resolved by devtools-policy.mjs (mode "content-only") for an allow-listed app, else undefined.
188
+ // Single manifest resolver shared by the closure gate (release-switch-scan.mjs) and the default-feature check below:
189
+ // targets.<os>.cargoManifest -> releaseSwitches.manifest -> src-tauri/Cargo.toml -> Cargo.toml. `rel` is the first
190
+ // candidate that exists (undefined when none does); `declared` are the configured paths.
191
+ export function resolveCargoManifestPath({ root, cargoManifest, manifest } = {}) {
192
+ const declared = [cargoManifest, manifest].filter(Boolean);
193
+ const candidates = [...declared, path.join("src-tauri", "Cargo.toml"), "Cargo.toml"];
194
+ return { rel: candidates.find((c) => existsSync(path.join(root, c))), declared, candidates };
195
+ }
196
+
197
+ export function buildFeatureIssues({ root, commands, devtools, cargoManifest, manifest }) {
198
+ const allowDevtools = devtools?.mode === "content-only";
199
+ let scripts = {};
200
+ try { scripts = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).scripts ?? {}; } catch { /* no package.json */ }
201
+ const issues = [];
202
+ for (const { label, command } of commands) if (command) issues.push(...commandFeatureIssues(command, { label, scripts, allowDevtools }));
203
+ const { rel } = resolveCargoManifestPath({ root, cargoManifest, manifest });
204
+ if (rel) issues.push(...cargoManifestIssues(readFileSync(path.join(root, rel), "utf8"), rel, { allowDevtools }));
205
+ return issues;
206
+ }
207
+
208
+ export function assertNoForbiddenBuildFeatures({ root, commands, tier = "release", devtools, cargoManifest, manifest }) {
209
+ const issues = buildFeatureIssues({ root, commands, devtools, cargoManifest, manifest });
210
+ if (issues.length === 0) return;
211
+ throw new Error(
212
+ `${tier} build refused: it enables a test-automation or debugging feature (${FORBIDDEN_FEATURES.join(", ")}); none may ship:\n- ${issues.join("\n- ")}\n`
213
+ + "Remove the feature from the release build command, the app's default features and its tauri dependency; dev builds (`right-release dev`) may keep it. "
214
+ + "No app may ship devtools unless it is on the allow-list in devtools-policy.json and declares `devtools: \"content-only\"` (rightkit-tauri-shell DevToolsPolicy::ContentOnly).",
215
+ );
216
+ }