@rightkit/release 0.3.0 → 0.4.1

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 (83) hide show
  1. package/README.md +74 -10
  2. package/addon-command.mjs +3 -4
  3. package/adoption-evidence.mjs +650 -0
  4. package/android-play-upload.mjs +2 -1
  5. package/apple-profile-create.mjs +113 -10
  6. package/apple-profile-renew.mjs +11 -3
  7. package/artifact-storage.mjs +35 -2
  8. package/bin/darwin/rightkit-swap +0 -0
  9. package/binary-license-notices.mjs +161 -0
  10. package/build-release.mjs +138 -47
  11. package/cargo-license-notices.mjs +111 -0
  12. package/cli/right-release.mjs +21 -2
  13. package/codesign-environment.mjs +2 -2
  14. package/content-digest.mjs +209 -0
  15. package/development.mjs +17 -6
  16. package/direct-bootstrap.mjs +5 -6
  17. package/disk-command.mjs +1 -0
  18. package/disk-policy.json +6 -2
  19. package/disk-stable-root.mjs +13 -9
  20. package/github-updater-release.mjs +70 -7
  21. package/in-place-install.mjs +17 -14
  22. package/install-app-windows.mjs +24 -7
  23. package/install-app.mjs +21 -40
  24. package/ios-asc-credentials.mjs +163 -0
  25. package/ios-build-context.mjs +129 -0
  26. package/ios-build-upload.mjs +282 -0
  27. package/ios-credentials-command.mjs +70 -0
  28. package/ios-dev.mjs +248 -0
  29. package/ios-host.mjs +76 -0
  30. package/ios-lock.mjs +75 -0
  31. package/ios-package-recipe.mjs +707 -0
  32. package/ios-testflight.mjs +266 -0
  33. package/ios-upload.mjs +313 -0
  34. package/local-publish.mjs +222 -53
  35. package/managed-cargo.mjs +8 -0
  36. package/mirror-root-artifact.mjs +5 -1
  37. package/native-cargo-layout.mjs +47 -4
  38. package/ownership-command.mjs +2 -1
  39. package/ownership-dependency-allowlist.json +3 -1
  40. package/ownership-evidence.mjs +30 -18
  41. package/ownership-key-command.mjs +69 -2
  42. package/ownership-mandatory-set.json +117 -67
  43. package/ownership-mandatory-set.mjs +141 -21
  44. package/ownership-repositories.json +1 -1
  45. package/ownership-scope.mjs +94 -0
  46. package/ownership.mjs +49 -7
  47. package/package-recipe-targets.mjs +41 -1
  48. package/package.json +1 -1
  49. package/pipeline-admission.mjs +375 -0
  50. package/pipeline-config.mjs +100 -0
  51. package/pipeline-generators.json +4 -0
  52. package/pipeline-generators.mjs +137 -0
  53. package/pipeline-scan.mjs +285 -0
  54. package/pipeline-tools.mjs +171 -0
  55. package/preflight.mjs +14 -7
  56. package/production-mode.mjs +42 -0
  57. package/project-type.mjs +3 -1
  58. package/protected-credentials.mjs +28 -1
  59. package/publication-artifacts.mjs +30 -5
  60. package/publication-gate.mjs +94 -32
  61. package/publish-cargo.mjs +117 -25
  62. package/publish-swift.mjs +12 -3
  63. package/publish-update.mjs +15 -2
  64. package/qa-build-features.mjs +16 -13
  65. package/qa-contract.mjs +30 -2
  66. package/qa-control-scan.mjs +8 -0
  67. package/release-config.mjs +330 -0
  68. package/release-invocation.mjs +118 -8
  69. package/release-switch-registry.json +126 -27
  70. package/release-switch-scan.mjs +88 -21
  71. package/release.mjs +82 -20
  72. package/rightkit-ownership.json +1 -1
  73. package/rightkit-sync.mjs +8 -0
  74. package/rightkit-versions.json +428 -194
  75. package/runtime-budget-crates.mjs +14 -0
  76. package/runtime-budgets.mjs +248 -47
  77. package/sign-updater.mjs +56 -59
  78. package/source-gate.mjs +8 -1
  79. package/stage-sidecar.mjs +207 -40
  80. package/swap-helper-bundle.mjs +7 -3
  81. package/target-distribution.mjs +5 -0
  82. package/upload-release.mjs +7 -2
  83. package/bin/darwin/rightkit-swap.sign.json +0 -6
package/publish-swift.mjs CHANGED
@@ -1,5 +1,5 @@
1
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
2
+ // repository (the checkout's own origin, github.com/bogusyogi/rightkit), resolved by apps through the root Package.swift
3
3
  // (docs/RIGHTKIT-SHARED-PACKAGES.md, Swift section). There is no mirror and no second repository.
4
4
  //
5
5
  // `right-release publish swift [--dry-run] [--evidence <file>] [--workspace <dir>]` verifies, in order:
@@ -19,7 +19,14 @@ import { spawnSync } from "./background-process.mjs";
19
19
  import { digestBytes } from "./publication-artifacts.mjs";
20
20
  import { enforcePublicationGate, defaultEvidencePath } from "./publication-gate.mjs";
21
21
 
22
- export const SWIFT_DISTRIBUTION_URL = "https://github.com/Orthic-Labs/rightkit";
22
+ // The distribution URL is whatever repository this checkout's origin names (GITHUB_REPOSITORY when origin does not
23
+ // parse); it is never a constant, so a repository move cannot leave a stale name in the success message.
24
+ export function swiftDistributionUrl(origin, environment = process.env) {
25
+ const fromOrigin = /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?\/?$/i.exec(String(origin ?? "").trim())?.[1];
26
+ const fromEnvironment = /^[\w.-]+\/[\w.-]+$/.test(environment?.GITHUB_REPOSITORY ?? "") ? environment.GITHUB_REPOSITORY : null;
27
+ const repository = fromOrigin ?? fromEnvironment;
28
+ return repository ? `https://github.com/${repository}` : String(origin ?? "").trim();
29
+ }
23
30
  const DRIFT_TEST = path.join("scripts", "swift-root-manifest.test.mjs");
24
31
 
25
32
  export function parseSwiftPublishArgs(argv) {
@@ -125,7 +132,9 @@ export function runSwiftPublish(options, execute = spawnSync) {
125
132
  catch (error) { git(["tag", "-d", version], toplevel, execute); throw error; }
126
133
  const remote = git(["ls-remote", "--tags", "origin", `refs/tags/${version}`], toplevel, execute);
127
134
  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}`);
135
+ let origin = "";
136
+ try { origin = git(["config", "--get", "remote.origin.url"], toplevel, execute); } catch { /* the tag is already verified on origin; the name is cosmetic */ }
137
+ out(`tagged ${version} at ${sha} on ${swiftDistributionUrl(origin, options.environment)}`);
129
138
  return { name, version, commit: sha, published: true };
130
139
  }
131
140
 
@@ -14,7 +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
+ import { assertAssetName, assertGitHubRepository, assertUpdaterKeyId, assertUpdaterPlatform, assertUpdaterSignature, buildLatestJson, githubAssetUrl, githubTag, publishGitHubUpdaterRelease } from "./github-updater-release.mjs";
18
18
 
19
19
  const TOOL_ROOT = path.dirname(fileURLToPath(import.meta.url));
20
20
  const API_BASE = process.env.RIGHTAPPS_API_URL || "https://api.spoares.com";
@@ -116,7 +116,20 @@ function git(args) {
116
116
  if (!githubLane && tier === "patch" && !installers.length) fail(`${config?.app ?? "app"} ${platform} patch has no installer.artifacts`);
117
117
  if ((githubLane || tier === "update") && !artifacts.length) fail(`${config?.app ?? "app"} ${platform} update has no updater.artifacts`);
118
118
 
119
- if (sealed) for (const artifact of artifacts) signatureFor(artifact);
119
+ // Cryptographic gate before any upload: every sealed minisign signature must verify over the
120
+ // exact final artifact bytes against the declared public key. No declared key means the
121
+ // signature cannot be verified, so a sealed (real) publish fails closed; key-id pinning is
122
+ // supplementary only. Integrator owns declaring distribution.updaterPublicKey in app configs
123
+ // (suite key already pinned per-app as tauri plugins.updater.pubkey).
124
+ const updaterPublicKey = config.distribution?.updaterPublicKey ?? config.distribution?.updaterPubkey ?? target?.updater?.pubkey ?? target?.updater?.publicKey;
125
+ if (sealed) for (const artifact of artifacts) {
126
+ const signature = signatureFor(artifact);
127
+ if (!appInstallerKind) {
128
+ if (!updaterPublicKey) throw new PublicationError("PUBLICATION_SIGNATURE_UNVERIFIABLE", `no updater public key declared (distribution.updaterPublicKey); refusing to upload ${path.basename(artifact.file)} with an unverified signature`);
129
+ assertUpdaterSignature(assertArtifactUnchanged(verifiedArtifact(artifact.file)), signature, updaterPublicKey, `signature of ${path.basename(artifact.file)}`);
130
+ assertUpdaterKeyId(signature, config.distribution.updaterKeyId, `signature of ${path.basename(artifact.file)}`);
131
+ }
132
+ }
120
133
  let releaseToken = null;
121
134
  if (!githubLane && !dryRun && (tier === "update" || artifacts.length > 0)) {
122
135
  try {
@@ -12,17 +12,10 @@ import { existsSync, readFileSync } from "node:fs";
12
12
  import path from "node:path";
13
13
  import { expandRecipeBuildCommands } from "./recipe-build.mjs";
14
14
 
15
- export const FORBIDDEN_FEATURES = Object.freeze(["devtools", "content-devtools", "qa-native", "qa-control", "dev-native", "dev-inspect",
16
- // Per-app test-automation features (M20 owner gap): HeardRight `qa-shot`/`qa-tools`, ViewRight `dev-control`/`bench-native`.
17
- "qa-shot", "qa-tools", "dev-control", "bench-native",
18
- // D4 (M21/M23): rightkit-daemon `test-mode` compiles the override selectors; never in a release build.
19
- "test-mode"]);
20
- // rightkit-qa 0.6.0 moved the embedded control server (the former rightkit-qa-driver `qa-control`) behind its `control`
21
- // feature. It emits the same QA_CONTROL_MARKER, so the binary layer refuses it unchanged; this is the source layer.
22
- export const QUALIFIED_FORBIDDEN_FEATURES = Object.freeze(["rightkit-qa/control", "rightkit-qa?/control"]);
23
- // The only forbidden features an allow-listed app may enable: rightkit-tauri-shell's `content-devtools` (and the Tauri
24
- // `devtools` it switches on). See devtools-policy.mjs; every other app is refused them.
25
- const DEVTOOLS_FEATURES = Object.freeze(["devtools", "content-devtools"]);
15
+ const featureRegistry = JSON.parse(readFileSync(new URL("./release-switch-registry.json", import.meta.url), "utf8"));
16
+ const DEVTOOLS_FEATURES = Object.freeze(featureRegistry.conditionalForbiddenFeatures["content-only"]);
17
+ export const FORBIDDEN_FEATURES = Object.freeze([...featureRegistry.forbiddenFeatures.filter((name) => !name.includes("/")), ...DEVTOOLS_FEATURES]);
18
+ export const QUALIFIED_FORBIDDEN_FEATURES = Object.freeze(featureRegistry.forbiddenFeatures.filter((name) => name.includes("/")));
26
19
  const FORBIDDEN_DEPENDENCY = "tauri-plugin-wdio-webdriver";
27
20
  const MANAGERS = new Set(["pnpm", "npm", "yarn", "bun", "npx"]);
28
21
 
@@ -36,7 +29,7 @@ export function isForbiddenFeature(item, { allowDevtools = false } = {}) {
36
29
  const name = featureName(text);
37
30
  if (allowDevtools && DEVTOOLS_FEATURES.includes(name)) return false;
38
31
  // `control` is too generic to forbid by bare name; rightkit-qa's dev-only server feature is refused when qualified.
39
- if (QUALIFIED_FORBIDDEN_FEATURES.includes(text.replace(/\s+/g, ""))) return true;
32
+ if (QUALIFIED_FORBIDDEN_FEATURES.includes(text.replace(/\s+/g, "").replace("?/", "/"))) return true;
40
33
  return text === `dep:${FORBIDDEN_DEPENDENCY}` || FORBIDDEN_FEATURES.includes(name);
41
34
  }
42
35
 
@@ -154,6 +147,7 @@ export function parseCargoFeaturesAndDependencies(manifestText) {
154
147
  function dependencyInfo(spec) {
155
148
  const list = spec.match(/features\s*=\s*(\[)/);
156
149
  return {
150
+ package: spec.match(/package\s*=\s*["']([^"']+)["']/)?.[1],
157
151
  optional: /optional\s*=\s*true/.test(spec),
158
152
  features: list ? strings(balanced(spec, list.index + list[0].length - 1, "[", "]")) : [],
159
153
  };
@@ -162,6 +156,15 @@ function dependencyInfo(spec) {
162
156
  export function cargoManifestIssues(manifestText, label = "Cargo.toml", { allowDevtools = false } = {}) {
163
157
  const { features, dependencies } = parseCargoFeaturesAndDependencies(manifestText);
164
158
  const issues = [];
159
+ const canonicalFeature = (item) => {
160
+ const match = /^([^/]+?)(\?)?\/(.+)$/.exec(item);
161
+ return match ? `${dependencies.get(match[1])?.package ?? match[1]}/${match[3]}` : item;
162
+ };
163
+ for (const [alias, dep] of dependencies) {
164
+ if (dep.package && dep.package !== alias && !dep.optional) for (const feature of dep.features) {
165
+ if (isForbiddenFeature(`${dep.package ?? alias}/${feature}`, { allowDevtools })) issues.push(`${label}: dependency \`${alias}\` enables forbidden feature \`${dep.package ?? alias}/${feature}\``);
166
+ }
167
+ }
165
168
  const tauri = dependencies.get("tauri");
166
169
  if (!allowDevtools && tauri?.features.includes("devtools")) issues.push(`${label}: dependency \`tauri\` lists feature \`devtools\` unconditionally (inspector ships in release builds)`);
167
170
  // The crate was renamed from rightkit-control; apps still on the old published name must stay covered.
@@ -185,7 +188,7 @@ export function cargoManifestIssues(manifestText, label = "Cargo.toml", { allowD
185
188
  const item = stack.pop();
186
189
  if (reached.has(item)) continue;
187
190
  reached.add(item);
188
- if (isForbiddenFeature(item, { allowDevtools })) issues.push(`${label}: default feature set reaches \`${item}\``);
191
+ if (isForbiddenFeature(canonicalFeature(item), { allowDevtools })) issues.push(`${label}: default feature set reaches \`${item}\``);
189
192
  if (features.has(item)) stack.push(...features.get(item));
190
193
  }
191
194
  return issues;
package/qa-contract.mjs CHANGED
@@ -106,6 +106,33 @@ export function qaManifests(appRoot, { readdir = readdirSync } = {}) {
106
106
  return found.sort();
107
107
  }
108
108
 
109
+ /**
110
+ * Native Apple UI-test targets under `appRoot`: an XcodeGen `project.yml` target of `type: bundle.ui-testing`,
111
+ * or an `.xcodeproj` whose `project.pbxproj` declares `com.apple.product-type.bundle.ui-testing`. An XCUITest
112
+ * target is the QA lane of a native iOS or macOS app, which has no package.json or Cargo.toml to pin RightKit QA in.
113
+ */
114
+ export function nativeAppleUiTestLanes(appRoot, { readdir = readdirSync, readFile = readFileSync } = {}) {
115
+ const lanes = [];
116
+ const read = (file) => { try { return readFile(file, "utf8"); } catch { return ""; } };
117
+ const visit = (dir, depth) => {
118
+ let entries;
119
+ try { entries = readdir(dir, { withFileTypes: true }); } catch { return; }
120
+ for (const entry of entries) {
121
+ const full = path.join(dir, entry.name);
122
+ const relative = path.relative(appRoot, full).split(path.sep).join("/");
123
+ if (entry.isDirectory()) {
124
+ if (entry.name.endsWith(".xcodeproj")) {
125
+ if (read(path.join(full, "project.pbxproj")).includes("com.apple.product-type.bundle.ui-testing")) lanes.push(`native XCUITest lane (${relative})`);
126
+ } else if (depth < MAX_DEPTH && !SKIP_DIRS.test(entry.name) && relative !== "rightkit" && relative !== "tools/rightkit") visit(full, depth + 1);
127
+ } else if (entry.name === "project.yml" && /^\s*type:\s*bundle\.ui-testing\s*$/m.test(read(full))) {
128
+ lanes.push(`native XCUITest lane (${relative})`);
129
+ }
130
+ }
131
+ };
132
+ visit(appRoot, 0);
133
+ return [...new Set(lanes)].sort();
134
+ }
135
+
109
136
  /** `[dependencies]`-style section header that holds dependencies (including target-specific ones). */
110
137
  const DEPENDENCY_HEADER = /^\[\s*(?:target\.[^\]]+\.)?(?:dev-|build-)?dependencies\s*\]/;
111
138
  const DEPENDENCY_TABLE_HEADER = /^\[\s*(?:target\.[^\]]+\.)?(?:dev-|build-)?dependencies\.([A-Za-z0-9_-]+)\s*\]/;
@@ -215,11 +242,12 @@ export function evaluateQaAdoption({ appRoot, repoRoot, app }, deps = {}) {
215
242
  }
216
243
  const label = app ?? "this app";
217
244
  const adopted = [...new Set(pins.filter((pin) => pin.name === NPM_QA || pin.name === CARGO_QA).map((pin) => pin.name))];
218
- const lane = exists(path.join(appRoot, "right-qa.config.mjs")) ? ["native wdio lane (right-qa.config.mjs)", ...adopted] : adopted;
245
+ const nativeApple = deps.nativeAppleLanes ?? nativeAppleUiTestLanes(appRoot, deps);
246
+ const lane = [...(exists(path.join(appRoot, "right-qa.config.mjs")) ? ["native wdio lane (right-qa.config.mjs)"] : []), ...nativeApple, ...adopted];
219
247
  const refusals = [];
220
248
  const warnings = [];
221
249
  if (lane.length === 0) {
222
- refusals.push(`RightKit QA is mandatory for every Orthic Labs app, and ${label} has no QA lane: no right-qa.config.mjs, no ${NPM_QA} in a package.json, no ${CARGO_QA} in a Cargo.toml${unreadable.length ? ` (unreadable: ${unreadable.join("; ")})` : ""}. Adopt it: add ${NPM_QA} to devDependencies (plus ${NPM_QA_CHROME} for a web UI) and/or ${CARGO_QA} to Cargo.toml [dev-dependencies], add the rightkit-qa floor run (smoke.launch, journey), see docs/ADOPTION.md, then run: node rightkit-drift.mjs --apply. Only a repository that is not an app is exempt (${QA_EXEMPTIONS_FILE}); an app cannot opt out`);
250
+ refusals.push(`RightKit QA is mandatory for every Orthic Labs app, and ${label} has no QA lane: no right-qa.config.mjs, no XCUITest target, no ${NPM_QA} in a package.json, no ${CARGO_QA} in a Cargo.toml${unreadable.length ? ` (unreadable: ${unreadable.join("; ")})` : ""}. Adopt it: add ${NPM_QA} to devDependencies (plus ${NPM_QA_CHROME} for a web UI) and/or ${CARGO_QA} to Cargo.toml [dev-dependencies], add the rightkit-qa floor run (smoke.launch, journey), see docs/ADOPTION.md, then run: node rightkit-drift.mjs --apply. Only a repository that is not an app is exempt (${QA_EXEMPTIONS_FILE}); an app cannot opt out`);
223
251
  }
224
252
  const lagging = pins
225
253
  .map((pin) => ({ ...pin, latest: pin.version && latestFor(versions, pin.ecosystem, pin.name) }))
@@ -379,6 +379,10 @@ export function assertNoQaControl(paths, label = "release", { requireBinary = fa
379
379
  // string that survives minification (an identifier that is also a string literal, a file/chunk name, a protocol or
380
380
  // a build-flag name). Source maps are scanned too: a map that names qaMock.ts proves the module was bundled.
381
381
  export const WEB_MOCK_MARKERS = Object.freeze([
382
+ { id: "platform-qa-selector", pattern: /__RIGHTKIT_QA_PLATFORM__/ },
383
+ { id: "platform-qa-environment", pattern: /\bRIGHTKIT_QA_PLATFORM\b/ },
384
+ { id: "platform-qa-entry", pattern: /(?:bootstrapQaPlatform|resolveQaPlatform|configureQaPlatform|@rightkit\/platform-ui\/qa)/ },
385
+
382
386
  { id: "browser-qa-fake", pattern: /browser-qa-fake/ },
383
387
  { id: "browser-mock-api", pattern: /browser-mock-api/ },
384
388
  { id: "browser-qa-mock-scheme", pattern: /browser-qa-mock:/ },
@@ -387,6 +391,10 @@ export const WEB_MOCK_MARKERS = Object.freeze([
387
391
  { id: "isBrowserQaMode", pattern: /isBrowserQaMode/ },
388
392
  { id: "MockDataSource", pattern: /MockDataSource/ },
389
393
  { id: "qa-build-flag", pattern: /\bVITE_[A-Z0-9_]*_QA\b/ },
394
+
395
+ // @rightkit/license/testing (M12): its sentinel value, and the subpath a bundle that kept the import still names.
396
+ { id: "license-testing-sentinel", pattern: /\bRIGHTKIT_LICENSE_TESTING_DO_NOT_SHIP\b/ },
397
+ { id: "license-testing-entry", pattern: /@rightkit\/license\/testing/ },
390
398
  ]);
391
399
  const WEB_EXTENSIONS = /\.(?:m?js|cjs|html?|css|map)$/i;
392
400
 
@@ -0,0 +1,330 @@
1
+ // The release config contract (M24): `right-release.config.json`, a finite, versioned JSON schema read by dev, build, doctor, install
2
+ // and upload alike. It is data. RightKit never imports, evaluates or converts an executable (`.mjs`, `.js`, `.cjs`, `.ts`) config: the
3
+ // file name is judged BEFORE anything is opened or imported, including for `--dry-run`, so a legacy config cannot run a single line.
4
+ //
5
+ // Load order (build, dev, upload): resolve the primary checkout and the config path first, then `loadReleaseConfigSync` parses the JSON
6
+ // strictly (no comments, no trailing commas, no duplicate keys), checks `schema: 1` and the finite top-level key set, expands the
7
+ // typed templates below, and runs the M24 pipeline validation (`pipeline-config.mjs`) so a command that is not an admitted RightKit
8
+ // invocation stops the run before the #340 preflight and before any recipe executes. A hash alone is never trusted: each effect
9
+ // boundary (build, sign, upload) loads and validates again.
10
+ //
11
+ // Typed templates (the only computation a config may express):
12
+ // { "$fromJson": { "file": "src-tauri/tauri.conf.json", "pointer": "/version" } } a string read from an app-relative JSON file
13
+ // { "$fromToml": { "file": "src-tauri/Cargo.toml", "path": "package.version" } } a string read from an app-relative TOML file
14
+ // { "$json": <value> } JSON.stringify(<value>), for a JSON argument
15
+ // "{{app}}" "{{version}}" "{{home}}" "{{localAppData}}" replaced inside any string value
16
+ // Anything else with braces is left alone, and an unknown {{token}} is an error.
17
+ import { createHash } from "node:crypto";
18
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { parse as parseToml } from "smol-toml";
22
+ import { collectConfigCommands, formatFindings, validateConfigPipeline } from "./pipeline-config.mjs";
23
+ import { loadGeneratorRegistry } from "./pipeline-generators.mjs";
24
+
25
+ export const RELEASE_CONFIG_FILE = "right-release.config.json";
26
+ export const LEGACY_RELEASE_CONFIG_FILE = "right-release.config.mjs";
27
+ export const RELEASE_CONFIG_SCHEMA = 1;
28
+ export const MAX_CONFIG_BYTES = 1024 * 1024;
29
+ export const LEGACY_EXECUTABLE_EXTENSIONS = Object.freeze([".mjs", ".js", ".cjs", ".ts", ".mts", ".cts", ".jsx", ".tsx"]);
30
+
31
+ /** The complete set of top-level keys. A key outside it is refused: a new capability is a RightKit change, not an app extension. */
32
+ export const TOP_LEVEL_KEYS = Object.freeze([
33
+ "$schema", "$comment", "schema", "app", "version", "channel", "distribution", "packageManager", "workdir", "targets", "development",
34
+ "deps", "checks", "buildInputs", "hostedWorkflows", "devtools", "releaseSwitches", "qa", "nativeAssembly", "legal", "web", "tests",
35
+ "sidecars", "releaseDependencies", "appStoreConnect", "storage", "cargoProfileExempt", "runtimeArtifacts",
36
+ ]);
37
+ export const TEMPLATE_TOKENS = Object.freeze(["app", "version", "home", "localAppData"]);
38
+
39
+ export class ReleaseConfigError extends Error {
40
+ constructor(code, message, details = {}) {
41
+ super(message);
42
+ this.name = "ReleaseConfigError";
43
+ this.code = code;
44
+ Object.assign(this, details);
45
+ }
46
+ }
47
+ const fail = (code, message, details) => { throw new ReleaseConfigError(code, message, details); };
48
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
49
+
50
+ /**
51
+ * Judge a config file NAME. Throws for an executable config and for anything that is not `.json`. It touches no file: call it before
52
+ * opening, importing or even resolving a repository, so a legacy config is refused on every route (dry runs included).
53
+ */
54
+ export function assertReleaseConfigFileName(file) {
55
+ if (typeof file !== "string" || !file.trim()) fail("RELEASE_CONFIG_NOT_JSON", "a release config path is required (right-release.config.json)");
56
+ const extension = path.extname(path.basename(file)).toLowerCase();
57
+ if (LEGACY_EXECUTABLE_EXTENSIONS.includes(extension)) {
58
+ fail("RELEASE_CONFIG_LEGACY_EXECUTABLE", `${file} is an executable release config (${extension}). RightKit never imports, evaluates or converts one; write ${RELEASE_CONFIG_FILE} (finite JSON schema, see the README section "Release config (JSON)") and delete the module.`, { file });
59
+ }
60
+ if (extension !== ".json") fail("RELEASE_CONFIG_NOT_JSON", `${file} is not a JSON release config; the only config file is ${RELEASE_CONFIG_FILE}`, { file });
61
+ return file;
62
+ }
63
+
64
+ /** A hint for a missing config: a legacy sibling is named (never opened). */
65
+ function missingConfigMessage(file) {
66
+ const legacy = path.join(path.dirname(file), LEGACY_RELEASE_CONFIG_FILE);
67
+ return existsSync(legacy)
68
+ ? `release config not found: ${file}. A legacy executable config exists at ${legacy}; it is never imported or converted. Write ${RELEASE_CONFIG_FILE}.`
69
+ : `release config not found: ${file}`;
70
+ }
71
+
72
+ // ---- strict JSON ----------------------------------------------------------------------------------------------------------------
73
+ const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
74
+ const NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
75
+ const pointerKey = (key) => key.replaceAll("~", "~0").replaceAll("/", "~1");
76
+
77
+ /** Parse JSON strictly and remember where every value starts: `{ value, offsets }` (offsets: JSON pointer -> character offset). */
78
+ export function parseStrictJson(text) {
79
+ const offsets = new Map();
80
+ let pos = 0;
81
+ const error = (message) => { throw Object.assign(new SyntaxError(message), { offset: pos }); };
82
+ const skip = () => { while (pos < text.length && (text[pos] === " " || text[pos] === "\t" || text[pos] === "\n" || text[pos] === "\r")) pos += 1; };
83
+ const string = () => {
84
+ const start = pos;
85
+ pos += 1;
86
+ while (pos < text.length && text[pos] !== '"') {
87
+ if (text.charCodeAt(pos) < 0x20) error("a control character inside a string");
88
+ pos += text[pos] === "\\" ? 2 : 1;
89
+ }
90
+ if (text[pos] !== '"') error("an unterminated string");
91
+ pos += 1;
92
+ try { return JSON.parse(text.slice(start, pos)); } catch { pos = start; return error("an invalid string escape"); }
93
+ };
94
+ const literal = (word, result) => { if (!text.startsWith(word, pos)) error(`unexpected token`); pos += word.length; return result; };
95
+ const value = (pointer, depth) => {
96
+ if (depth > 64) error("nesting deeper than 64 levels");
97
+ skip();
98
+ offsets.set(pointer, pos);
99
+ const ch = text[pos];
100
+ if (ch === "{") return object(pointer, depth);
101
+ if (ch === "[") return array(pointer, depth);
102
+ if (ch === '"') return string();
103
+ if (ch === "t") return literal("true", true);
104
+ if (ch === "f") return literal("false", false);
105
+ if (ch === "n") return literal("null", null);
106
+ NUMBER.lastIndex = pos;
107
+ const match = NUMBER.exec(text);
108
+ if (!match || match.index !== pos) error("unexpected token");
109
+ pos += match[0].length;
110
+ return Number(match[0]);
111
+ };
112
+ const object = (pointer, depth) => {
113
+ const out = {};
114
+ pos += 1;
115
+ skip();
116
+ if (text[pos] === "}") { pos += 1; return out; }
117
+ for (;;) {
118
+ skip();
119
+ if (text[pos] !== '"') error("expected a string key");
120
+ const keyStart = pos;
121
+ const key = string();
122
+ if (FORBIDDEN_KEYS.has(key)) { pos = keyStart; error(`the key "${key}" is not allowed`); }
123
+ if (Object.prototype.hasOwnProperty.call(out, key)) { pos = keyStart; error(`duplicate key "${key}"`); }
124
+ skip();
125
+ if (text[pos] !== ":") error("expected ':'");
126
+ pos += 1;
127
+ out[key] = value(`${pointer}/${pointerKey(key)}`, depth + 1);
128
+ skip();
129
+ if (text[pos] === ",") { pos += 1; continue; }
130
+ if (text[pos] === "}") { pos += 1; return out; }
131
+ error("expected ',' or '}'");
132
+ }
133
+ };
134
+ const array = (pointer, depth) => {
135
+ const out = [];
136
+ pos += 1;
137
+ skip();
138
+ if (text[pos] === "]") { pos += 1; return out; }
139
+ for (;;) {
140
+ out.push(value(`${pointer}/${out.length}`, depth + 1));
141
+ skip();
142
+ if (text[pos] === ",") { pos += 1; continue; }
143
+ if (text[pos] === "]") { pos += 1; return out; }
144
+ error("expected ',' or ']'");
145
+ }
146
+ };
147
+ const result = value("", 0);
148
+ skip();
149
+ if (pos !== text.length) error("unexpected content after the JSON value");
150
+ return { value: result, offsets };
151
+ }
152
+
153
+ /** 1-based line of a JSON pointer in `text` (the nearest recorded ancestor when the exact pointer is not a source position). */
154
+ export function lineOfPointer(text, offsets, pointer) {
155
+ let current = pointer;
156
+ while (current !== "" && !offsets.has(current)) current = current.slice(0, current.lastIndexOf("/"));
157
+ const offset = offsets.get(current) ?? 0;
158
+ let line = 1;
159
+ for (let i = 0; i < offset && i < text.length; i += 1) if (text[i] === "\n") line += 1;
160
+ return line;
161
+ }
162
+
163
+ // ---- templates ------------------------------------------------------------------------------------------------------------------
164
+ function readInside(appRoot, relative, label, { readBytes, lstat }) {
165
+ if (typeof relative !== "string" || !relative.trim() || path.isAbsolute(relative) || path.win32.isAbsolute(relative) || /^[A-Za-z]:/.test(relative) || relative.replaceAll("\\", "/").split("/").includes("..")) {
166
+ fail("RELEASE_CONFIG_TEMPLATE", `${label}: file must be a relative path inside the app: ${JSON.stringify(relative)}`);
167
+ }
168
+ const absolute = path.resolve(appRoot, relative);
169
+ let info;
170
+ try { info = lstat(absolute); } catch { fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${relative} does not exist`); }
171
+ if (info.isSymbolicLink() || !info.isFile()) fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${relative} must be a regular file (no symlink)`);
172
+ if (info.size > MAX_CONFIG_BYTES) fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${relative} is larger than ${MAX_CONFIG_BYTES} bytes`);
173
+ const bytes = readBytes(absolute);
174
+ return { bytes, sha256: createHash("sha256").update(bytes).digest("hex"), relative: relative.replaceAll("\\", "/") };
175
+ }
176
+
177
+ function jsonPointerGet(document, pointer, label) {
178
+ if (pointer === "") return document;
179
+ if (typeof pointer !== "string" || !pointer.startsWith("/")) fail("RELEASE_CONFIG_TEMPLATE", `${label}: pointer must be a JSON pointer such as /version`);
180
+ let current = document;
181
+ for (const raw of pointer.slice(1).split("/")) {
182
+ const key = raw.replaceAll("~1", "/").replaceAll("~0", "~");
183
+ if (!(isObject(current) || Array.isArray(current)) || !Object.prototype.hasOwnProperty.call(current, key)) fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${pointer} is not in the file`);
184
+ current = current[key];
185
+ }
186
+ return current;
187
+ }
188
+
189
+ function scalarString(value, label) {
190
+ if (typeof value === "string") return value;
191
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
192
+ return fail("RELEASE_CONFIG_TEMPLATE", `${label}: the referenced value must be a string or a number`);
193
+ }
194
+
195
+ /** Replace the typed templates in `node`. `context` carries the template values, the app root and the injectable file access. */
196
+ function expand(node, context, label) {
197
+ if (typeof node === "string") {
198
+ return node.replace(/\{\{([A-Za-z][A-Za-z0-9]*)\}\}/g, (_, token) => {
199
+ if (!TEMPLATE_TOKENS.includes(token)) fail("RELEASE_CONFIG_TEMPLATE", `${label}: unknown template token {{${token}}} (allowed: ${TEMPLATE_TOKENS.map((t) => `{{${t}}}`).join(", ")})`);
200
+ const resolved = context.values[token];
201
+ if (typeof resolved !== "string" || resolved === "") fail("RELEASE_CONFIG_TEMPLATE", `${label}: {{${token}}} is not available${token === "localAppData" ? " (LOCALAPPDATA is not set)" : token === "version" ? " here (version cannot refer to itself)" : ""}`);
202
+ return resolved;
203
+ });
204
+ }
205
+ if (Array.isArray(node)) return node.map((item, index) => expand(item, context, `${label}/${index}`));
206
+ if (!isObject(node)) return node;
207
+ const keys = Object.keys(node);
208
+ if (keys.length === 1 && keys[0] === "$fromJson") {
209
+ const spec = node.$fromJson;
210
+ if (!isObject(spec) || Object.keys(spec).some((key) => key !== "file" && key !== "pointer") || typeof spec.file !== "string") fail("RELEASE_CONFIG_TEMPLATE", `${label}: $fromJson must be { file, pointer }`);
211
+ const read = readInside(context.appRoot, spec.file, `${label} $fromJson`, context);
212
+ let document;
213
+ try { document = JSON.parse(Buffer.from(read.bytes).toString("utf8")); } catch { fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${spec.file} is not valid JSON`); }
214
+ context.inputs.set(read.relative, read.sha256);
215
+ return scalarString(jsonPointerGet(document, spec.pointer ?? "", `${label} $fromJson`), label);
216
+ }
217
+ if (keys.length === 1 && keys[0] === "$fromToml") {
218
+ const spec = node.$fromToml;
219
+ if (!isObject(spec) || Object.keys(spec).some((key) => key !== "file" && key !== "path") || typeof spec.file !== "string" || typeof spec.path !== "string") fail("RELEASE_CONFIG_TEMPLATE", `${label}: $fromToml must be { file, path }`);
220
+ const read = readInside(context.appRoot, spec.file, `${label} $fromToml`, context);
221
+ let document;
222
+ try { document = parseToml(Buffer.from(read.bytes).toString("utf8")); } catch { fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${spec.file} is not valid TOML`); }
223
+ context.inputs.set(read.relative, read.sha256);
224
+ let current = document;
225
+ for (const key of spec.path.split(".")) {
226
+ if (!isObject(current) || !Object.prototype.hasOwnProperty.call(current, key)) fail("RELEASE_CONFIG_TEMPLATE", `${label}: ${spec.path} is not in ${spec.file}`);
227
+ current = current[key];
228
+ }
229
+ return scalarString(current, label);
230
+ }
231
+ if (keys.length === 1 && keys[0] === "$json") return JSON.stringify(expand(node.$json, context, `${label}/$json`));
232
+ const out = {};
233
+ for (const key of keys) {
234
+ if (key.startsWith("$") && !TOP_LEVEL_KEYS.includes(key)) fail("RELEASE_CONFIG_TEMPLATE", `${label}/${key}: "$" keys other than $fromJson, $fromToml and $json are reserved`);
235
+ out[key] = expand(node[key], context, `${label}/${pointerKey(key)}`);
236
+ }
237
+ return out;
238
+ }
239
+
240
+ // ---- shape ----------------------------------------------------------------------------------------------------------------------
241
+ const SAFE_APP = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
242
+ const SAFE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
243
+
244
+ /** Schema, finite key set and the identity fields. Throws RELEASE_CONFIG_SCHEMA / RELEASE_CONFIG_KEY. */
245
+ export function validateReleaseConfigShape(config, { file = RELEASE_CONFIG_FILE, phase = "final" } = {}) {
246
+ if (!isObject(config)) fail("RELEASE_CONFIG_SCHEMA", `${file} must be a JSON object`, { file });
247
+ if (config.schema !== RELEASE_CONFIG_SCHEMA) fail("RELEASE_CONFIG_SCHEMA", `${file}: unsupported config schema ${JSON.stringify(config.schema ?? null)} (expected ${RELEASE_CONFIG_SCHEMA})`, { file });
248
+ const unknown = Object.keys(config).filter((key) => !TOP_LEVEL_KEYS.includes(key));
249
+ if (unknown.length) fail("RELEASE_CONFIG_KEY", `${file}: unknown top-level key(s): ${unknown.join(", ")}. The config schema is finite (allowed: ${TOP_LEVEL_KEYS.filter((key) => !key.startsWith("$")).join(", ")}); a new capability is a RightKit change.`, { file, unknown });
250
+ if (typeof config.app !== "string" || !SAFE_APP.test(config.app)) fail("RELEASE_CONFIG_SCHEMA", `${file}: app must be a safe name (letters, digits, . _ -)`, { file });
251
+ // Before template expansion a version may still be a { $fromJson | $fromToml } node or carry a {{token}}; the final check is strict.
252
+ const versionIsTemplate = phase === "raw" && (isObject(config.version) || (typeof config.version === "string" && config.version.includes("{{")));
253
+ if (config.version !== undefined && !versionIsTemplate && (typeof config.version !== "string" || !SAFE_VERSION.test(config.version))) fail("RELEASE_CONFIG_SCHEMA", `${file}: version must be a safe version string`, { file });
254
+ for (const key of ["targets", "development", "buildInputs", "distribution"]) {
255
+ if (config[key] !== undefined && !isObject(config[key])) fail("RELEASE_CONFIG_SCHEMA", `${file}: ${key} must be an object`, { file });
256
+ }
257
+ return config;
258
+ }
259
+
260
+ function packageScripts(appRoot, readText) {
261
+ try {
262
+ const parsed = JSON.parse(readText(path.join(appRoot, "package.json")));
263
+ return isObject(parsed?.scripts) ? parsed.scripts : undefined;
264
+ } catch { return undefined; }
265
+ }
266
+
267
+ /**
268
+ * Load, validate and expand a release config. Returns `{ config, configPath, appRoot, sha256, inputs, lineOf }`. Throws ReleaseConfigError
269
+ * (RELEASE_CONFIG_LEGACY_EXECUTABLE | _NOT_JSON | _NOT_FOUND | _INVALID_JSON | _SCHEMA | _KEY | _TEMPLATE | _M24). `validatePipeline: false`
270
+ * skips only the M24 command admission (the pipeline scanner reports those findings itself); nothing else is optional.
271
+ * File access, the generator registry, the environment and the home directory are injectable for tests.
272
+ */
273
+ export function loadReleaseConfigSync(file, {
274
+ validatePipeline = true, registry, containment, env = process.env, homedir = () => os.homedir(),
275
+ readText = (target) => readFileSync(target, "utf8"), readBytes = (target) => readFileSync(target), lstat = lstatSync,
276
+ } = {}) {
277
+ assertReleaseConfigFileName(file);
278
+ const configPath = path.resolve(file);
279
+ const appRoot = path.dirname(configPath);
280
+ let text;
281
+ try { text = readText(configPath); } catch { fail("RELEASE_CONFIG_NOT_FOUND", missingConfigMessage(configPath), { file: configPath }); }
282
+ if (Buffer.byteLength(text) > MAX_CONFIG_BYTES) fail("RELEASE_CONFIG_INVALID_JSON", `${configPath} is larger than ${MAX_CONFIG_BYTES} bytes`, { file: configPath });
283
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
284
+ let parsed;
285
+ try { parsed = parseStrictJson(text); } catch (error) {
286
+ let line = 1;
287
+ for (let i = 0; i < (error.offset ?? 0) && i < text.length; i += 1) if (text[i] === "\n") line += 1;
288
+ fail("RELEASE_CONFIG_INVALID_JSON", `${configPath}:${line} is not valid strict JSON (${error.message}; no comments, trailing commas or duplicate keys)`, { file: configPath, line });
289
+ }
290
+ const raw = parsed.value;
291
+ validateReleaseConfigShape(raw, { file: configPath, phase: "raw" });
292
+ const inputs = new Map();
293
+ const base = { appRoot, inputs, readBytes, lstat, values: { home: homedir(), localAppData: env.LOCALAPPDATA ?? "", app: "", version: "" } };
294
+ base.values.app = expand(raw.app, { ...base }, "/app");
295
+ if (raw.version !== undefined) base.values.version = expand(raw.version, { ...base, values: { ...base.values, version: "" } }, "/version");
296
+ const config = expand(raw, base, "");
297
+ validateReleaseConfigShape(config, { file: configPath });
298
+ const sha256 = createHash("sha256").update(Buffer.from(text)).digest("hex");
299
+ if (validatePipeline) {
300
+ const findings = validateConfigPipeline(config, {
301
+ scripts: packageScripts(appRoot, readText),
302
+ registry: registry ?? loadGeneratorRegistry(),
303
+ appRoot,
304
+ containment,
305
+ readBytes,
306
+ lineOf: (pointer) => lineOfPointer(text, parsed.offsets, pointer),
307
+ });
308
+ if (findings.length) {
309
+ fail("RELEASE_CONFIG_M24", `${configPath} has ${findings.length} M24 pipeline finding(s); every command must be an admitted RightKit invocation:\n${formatFindings(findings, configPath).map((line) => `- ${line}`).join("\n")}`, { file: configPath, findings });
310
+ }
311
+ }
312
+ return {
313
+ config, configPath, appRoot, sha256,
314
+ inputs: [...inputs].map(([file, digest]) => ({ file, sha256: digest })),
315
+ lineOf: (pointer) => lineOfPointer(text, parsed.offsets, pointer),
316
+ };
317
+ }
318
+
319
+ /** The config object only (what `(await import(configPath)).default` used to be). */
320
+ export async function loadReleaseConfig(file, options) {
321
+ return loadReleaseConfigSync(file, options).config;
322
+ }
323
+
324
+ /** `{ config, appRoot, configPath }` for the commands that need the app root beside the config. */
325
+ export async function loadAppConfigFile(file = RELEASE_CONFIG_FILE, options) {
326
+ const loaded = loadReleaseConfigSync(file, options);
327
+ return { config: loaded.config, appRoot: loaded.appRoot, configPath: loaded.configPath };
328
+ }
329
+
330
+ export { collectConfigCommands };