opencode-ruby-upgrader 0.1.7 → 0.1.9

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.
package/src/run-state.js CHANGED
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { execFileSync } from "node:child_process";
5
+ import { advisoryFindings } from "./advisory.js";
5
6
 
6
7
  const SECRET_VALUE = /(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|\bAKIA[0-9A-Z]{16}\b|-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----|\b(?:xox[baprs]-|npm_|glpat-)[A-Za-z0-9_-]{16,})/g;
7
8
  const SECRET_KEY = /(?:password|secret|token|api[_-]?key|credential|authorization)/i;
@@ -30,6 +31,8 @@ export function redactSourceUrl(value) {
30
31
 
31
32
  function validVersion(value) { return typeof value === "string" && /^\d+\.\d+(?:\.(?:\d+|x))?$/.test(value); }
32
33
  function validRailsVersion(value) { return typeof value === "string" && /^\d+(?:\.\d+)+$/.test(value); }
34
+ function validBundlerVersion(value) { return typeof value === "string" && /^\d+(?:\.\d+)*$/.test(value); }
35
+ const bundlerSeriesOf = (value) => String(value ?? "").split(".").slice(0, 2).join(".");
33
36
  function validCitation(value) { return value && typeof value.title === "string" && /^https:\/\//.test(value.url ?? ""); }
34
37
  function safeFiles(files) { return Array.isArray(files) && files.every((file) => typeof file === "string" && !path.isAbsolute(file) && !file.includes("..")); }
35
38
  function validAppUpdateReview(review) { return review && review.command === "bin/rails app:update" && typeof review.receiptId === "string" && !Number.isNaN(Date.parse(review.executedAt ?? "")) && !Number.isNaN(Date.parse(review.reviewedAt ?? "")) && validFingerprint(review.worktree) && ["no_changes", "changes_applied", "changes_deferred"].includes(review.outcome) && safeFiles(review.files) && typeof review.summary === "string" && Boolean(review.summary); }
@@ -59,6 +62,31 @@ function receiptErrors(iteration, index, rails, required, errors) {
59
62
  }
60
63
  }
61
64
 
65
+ function validateBundlerBridge(run, errors) {
66
+ for (const key of ["runId", "title", "startedAt", "targetBundler", "targetBundlerPinnedAt", "phase", "status", "bridge"]) if (!run[key]) errors.push(`Bundler bridge report requires ${key}.`);
67
+ if (!validBundlerVersion(run.targetBundler)) errors.push("targetBundler must be a Bundler version.");
68
+ if (typeof run.runId !== "string" || !/^[a-f0-9-]{36}$/i.test(run.runId ?? "")) errors.push("runId must be a UUID.");
69
+ if (run.startedAt && Number.isNaN(Date.parse(run.startedAt))) errors.push("startedAt must be ISO-8601.");
70
+ if (!statuses.has(run.status) || !phases.has(run.phase)) errors.push("Bundler bridge has invalid status or phase.");
71
+ if (run.lockNonce !== undefined && (typeof run.lockNonce !== "string" || !/^[a-f0-9-]{36}$/i.test(run.lockNonce))) errors.push("lockNonce must be a UUID.");
72
+ if (run.targetBundlerPinnedAt && Number.isNaN(Date.parse(run.targetBundlerPinnedAt))) errors.push("targetBundlerPinnedAt must be ISO-8601.");
73
+ // The link back to the blocked Ruby run is what makes the bridge separately
74
+ // scoped rather than a second phase of the same run, so it must be complete.
75
+ if (!run.bridge || typeof run.bridge !== "object" || typeof run.bridge.rubyReportPath !== "string" || !/^[a-f0-9-]{36}$/i.test(run.bridge.rubyRunId ?? "") || !validVersion(run.bridge.rubyFrom) || !validVersion(run.bridge.rubyTo) || !validBundlerVersion(run.bridge.bundlerFrom) || !validBundlerVersion(run.bridge.bundlerTo) || !validBundlerVersion(run.bridge.minimumBundler)) errors.push("Bundler bridge report has invalid Ruby-run linkage.");
76
+ if (!Array.isArray(run.research?.ladder) || run.research.ladder.some((version) => !validBundlerVersion(version))) errors.push("Bundler bridge research.ladder must contain Bundler versions.");
77
+ if (!Array.isArray(run.research?.citations) || run.research.citations.some((citation) => !validCitation(citation))) errors.push("research.citations must contain HTTPS citations.");
78
+ if (!Array.isArray(run.iterations)) errors.push("iterations must be an array.");
79
+ for (const [index, iteration] of (run.iterations ?? []).entries()) {
80
+ if (!validBundlerVersion(iteration?.from) || !validBundlerVersion(iteration?.to) || iteration?.status !== "complete") errors.push(`Bundler iteration ${index + 1} is invalid.`);
81
+ if (!safeFiles(iteration?.files) || iteration?.tests?.passed !== true || !iteration.tests?.command || !iteration.tests?.smoke || !Array.isArray(iteration?.citations) || !iteration.citations.length || !Array.isArray(iteration?.fixes) || iteration.fixes.some((fix) => !safeFiles(fix.files) || !fix.explanation)) errors.push(`Bundler iteration ${index + 1} lacks required evidence.`);
82
+ // The pin the hop actually produced, so a later reader can see the lockfile
83
+ // agreed with the validated version instead of taking it on trust.
84
+ if (!validBundlerVersion(iteration?.lockfilePin) || bundlerSeriesOf(iteration.lockfilePin) !== bundlerSeriesOf(iteration.to)) errors.push(`Bundler iteration ${index + 1} must record the BUNDLED WITH pin it produced.`);
85
+ if (iteration?.checkpointSha !== undefined && !/^[a-f0-9]{40}$/i.test(iteration.checkpointSha)) errors.push(`Bundler iteration ${index + 1} checkpointSha must be a Git SHA.`);
86
+ receiptErrors(iteration, index, false, run.validationReceiptsRequired === true, errors);
87
+ }
88
+ }
89
+
62
90
  function validateRailsBridge(run, errors) {
63
91
  for (const key of ["runId", "title", "startedAt", "targetRails", "targetRailsPinnedAt", "phase", "status", "bridge"]) if (!run[key]) errors.push(`Rails bridge report requires ${key}.`);
64
92
  if (!validRailsVersion(run.targetRails)) errors.push("targetRails must be a Rails version.");
@@ -88,6 +116,7 @@ export function validateRun(run) {
88
116
  if (run.schemaVersion !== 2) errors.push("Report schemaVersion must be 2.");
89
117
  if (run.validationReceiptsRequired !== undefined && run.validationReceiptsRequired !== true) errors.push("validationReceiptsRequired must be true when present.");
90
118
  if (run.reportType === "rails_bridge") { validateRailsBridge(run, errors); return { valid: errors.length === 0, errors }; }
119
+ if (run.reportType === "bundler_bridge") { validateBundlerBridge(run, errors); return { valid: errors.length === 0, errors }; }
91
120
  if (run.reportType !== undefined && run.reportType !== "ruby") errors.push("Unknown reportType.");
92
121
  for (const key of ["runId", "title", "startedAt", "targetRuby", "targetPinnedAt", "phase", "status"]) if (!run[key]) errors.push(`Report requires ${key}.`);
93
122
  if (run.lockNonce !== undefined && (typeof run.lockNonce !== "string" || !/^[a-f0-9-]{36}$/i.test(run.lockNonce))) errors.push("lockNonce must be a UUID.");
@@ -109,6 +138,16 @@ export function validateRun(run) {
109
138
  if (typeof bridge?.rationale !== "string" || !bridge.rationale) errors.push("frameworkBridge requires a rationale.");
110
139
  if (!Array.isArray(bridge?.citations) || !bridge.citations.length || bridge.citations.some((citation) => !validCitation(citation))) errors.push("frameworkBridge requires HTTPS citations.");
111
140
  }
141
+ if (run.bundlerBridge !== undefined && run.bundlerBridge !== null) {
142
+ const bridge = run.bundlerBridge;
143
+ if (!bridge || typeof bridge !== "object" || bridge.status !== "approved") errors.push("bundlerBridge must be an approved compatibility decision.");
144
+ if (!validVersion(bridge?.rubyFrom) || !validVersion(bridge?.rubyTo) || !validBundlerVersion(bridge?.bundlerFrom) || !validBundlerVersion(bridge?.bundlerTo) || !validBundlerVersion(bridge?.minimumBundler)) errors.push("bundlerBridge requires Ruby from/to and Bundler from/to/floor versions.");
145
+ // The researched floor is what justifies blocking the hop, so its provenance
146
+ // has to be recorded rather than asserted.
147
+ if (typeof bridge?.compatibilitySource !== "string" || !/^https:\/\//.test(bridge.compatibilitySource)) errors.push("bundlerBridge requires the official compatibility source.");
148
+ if (typeof bridge?.rationale !== "string" || !bridge.rationale) errors.push("bundlerBridge requires a rationale.");
149
+ if (!Array.isArray(bridge?.citations) || !bridge.citations.length || bridge.citations.some((citation) => !validCitation(citation))) errors.push("bundlerBridge requires HTTPS citations.");
150
+ }
112
151
  for (const [index, iteration] of (run.iterations ?? []).entries()) {
113
152
  if (!validVersion(iteration?.from) || !validVersion(iteration?.to)) errors.push(`Iteration ${index + 1} requires Ruby from/to versions.`);
114
153
  if (iteration?.status !== "complete") errors.push(`Iteration ${index + 1} must be complete.`);
@@ -173,13 +212,63 @@ export function writeRun(root, relativePath, run) {
173
212
  const markdown = file.replace(/\.json$/, ".md");
174
213
  if (fs.existsSync(markdown) && (!fs.lstatSync(markdown).isFile() || fs.lstatSync(markdown).isSymbolicLink())) throw new RunStateError("Markdown evidence must be a regular file inside the worktree.", "unsafe-report-path");
175
214
  const safe = redact(run);
176
- const target = safe.reportType === "rails_bridge" ? `- **Target Rails:** ${safe.targetRails}` : `- **Target Ruby:** ${safe.targetRuby}`;
177
- const markdownBody = `# ${safe.title}\n\n- **Status:** ${safe.status}\n- **Phase:** ${safe.phase}\n${target}\n- **Started:** ${safe.startedAt}\n\n## Durable evidence\n\n\`\`\`json\n${JSON.stringify(safe, null, 2)}\n\`\`\`\n`;
215
+ const target = safe.reportType === "rails_bridge" ? `- **Target Rails:** ${safe.targetRails}` : safe.reportType === "bundler_bridge" ? `- **Target Bundler:** ${safe.targetBundler}` : `- **Target Ruby:** ${safe.targetRuby}`;
216
+ const followUps = followUpActions(safe);
217
+ const advisories = collectAdvisories(root, safe);
218
+ const followUpBlock = followUps.length
219
+ ? `\n## Follow-up actions\n\nThese are yours to take. Nothing below was verified by this run; the run cannot test your production topology.\n\n${followUps.map((item) => `- ${item}`).join("\n")}\n`
220
+ : "";
221
+ // Advisory only: never a gate. Rendered as its own section so it is obvious
222
+ // these are observations about the reader's infrastructure, not evidence the
223
+ // run collected about the app.
224
+ const advisoryBlock = advisories.length
225
+ ? `\n## Infrastructure review (advisory)\n\nRead-only observations about files this run did not change. Nothing here blocks the upgrade, and an intentional version pin that lags the app is legitimate. Confirm each against your actual deploy platform.\n\n| Area | Finding | Evidence | Basis |\n| --- | --- | --- | --- |\n${advisories.map((item) => `| ${item.area} | ${item.title}<br>${item.detail} | \`${item.evidence.replace(/\|/g, "\\|")}\` | ${item.confidence} |`).join("\n")}\n`
226
+ : "";
227
+ const markdownBody = `# ${safe.title}\n\n- **Status:** ${safe.status}\n- **Phase:** ${safe.phase}\n${target}\n- **Started:** ${safe.startedAt}\n${followUpBlock}${advisoryBlock}\n## Durable evidence\n\n\`\`\`json\n${JSON.stringify(safe, null, 2)}\n\`\`\`\n`;
178
228
  const markdownTemporary = path.join(path.dirname(markdown), `.${path.basename(markdown)}.${process.pid}.${crypto.randomUUID()}.tmp`);
179
229
  fs.writeFileSync(markdownTemporary, markdownBody, { mode: 0o600, flag: "wx" });
180
230
  fs.renameSync(markdownTemporary, markdown);
181
231
  }
182
232
 
233
+ // Deployment-visible items a local test run provably cannot check. The isolated
234
+ // runtime proves the code runs on the target Ruby in a container; it says nothing
235
+ // about the host that will actually serve it. Each item is phrased as something
236
+ // the reader must verify, never as something this run established, because a run
237
+ // that "passed" here has not touched the real deploy target at all.
238
+ function followUpActions(safe) {
239
+ const actions = [];
240
+ const last = safe.iterations?.at(-1);
241
+ if (last?.checkpointSha) actions.push(`Checkpoint \`${last.checkpointSha.slice(0, 7)}\` is validated locally but not deployed: push it and run your own staging check before it reaches production.`);
242
+ if (safe.phase === "blocked" || safe.status === "blocked") actions.push("This run is blocked. Resolve or explicitly approve the outstanding risks before treating the upgrade as complete.");
243
+ if (safe.frameworkBridge?.status === "approved") actions.push(`A Rails ${safe.frameworkBridge.railsFrom} → ${safe.frameworkBridge.railsTo} bridge was approved here but not performed. It needs its own separate run.`);
244
+ if (safe.bundlerBridge?.status === "approved") actions.push(`A Bundler ${safe.bundlerBridge.bundlerFrom} → ${safe.bundlerBridge.bundlerTo} bridge was approved here but not performed, on a researched floor of ${safe.bundlerBridge.minimumBundler}. Run \`begin-bundler-bridge\`, then restart the Ruby upgrade.`);
245
+ const environment = safe.environment;
246
+ if (environment?.type === "docker") {
247
+ // The single most common surprise: the container pins Bundler 2.4.22 and the
248
+ // app's own CI or deploy host may resolve a different one, changing which
249
+ // lockfile semantics apply.
250
+ actions.push(`The isolated runtime used Bundler ${environment.bundlerVersion ?? "a pinned version (unrecorded)"}${environment.bundlerVersion ? ` on Ruby ${environment.ruby}` : ""}. Confirm your deploy host and CI resolve the same Bundler version, or re-run \`bundle lock\` there.`);
251
+ actions.push(`Native gems were compiled for this container (${environment.database ?? "database"} on Ruby ${environment.ruby}). Rebuild native extensions on your deploy platform rather than copying \`node_modules\`-style build output.`);
252
+ actions.push(`Your deploy host must reach the real database directly; this run used an isolated ${environment.database} reachable only over a private Docker network and deliberately published no ports.`);
253
+ actions.push("Prepared containers and networks persist after the run by design. Remove them when you are done: `docker rm -f` and `docker network rm` on the names in `.ruby-upgrades/runtime.json`.");
254
+ }
255
+ actions.push("Review the validation receipts in `.ruby-upgrades/runs/` for the exact commands, exit codes, and test evidence before merging.");
256
+ return actions;
257
+ }
258
+
259
+ // Read the worktree's own infrastructure and compare it against what this run
260
+ // changed. Wrapped because the report must still render when a worktree cannot
261
+ // be inspected (an explicit `--report` from elsewhere, or a half-removed tree);
262
+ // a missing advisory section must not fail a run whose evidence is already
263
+ // recorded.
264
+ export function collectAdvisories(root, safe) {
265
+ try {
266
+ return advisoryFindings({ root, run: safe, inventory: safe.inventory ?? {} });
267
+ } catch {
268
+ return [];
269
+ }
270
+ }
271
+
183
272
  function lockPath(root) {
184
273
  try { return execFileSync("git", ["rev-parse", "--git-path", "opencode-ruby-upgrade.lock"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); }
185
274
  catch { return path.join(runsDirectory(root, true), ".run.lock"); }
@@ -46,27 +46,36 @@ function preparationResult(result) {
46
46
  return { sha256: crypto.createHash("sha256").update(output).digest("hex"), bytes: Buffer.byteLength(output) };
47
47
  }
48
48
  function bootstrap(spawn, runtime, db, rails) {
49
- const labels = ["Node.js setup", "Bundler installation", "dependency installation", ...(rails ? [`${db.label} test database initialization`] : []), "Ruby version attestation"];
49
+ const labels = ["Node.js setup", "Bundler installation", "dependency installation", ...(rails ? [`${db.label} test database initialization`] : []), "Ruby version attestation", "Bundler version attestation"];
50
50
  const commands = [
51
51
  ["exec", runtime.appContainer, "sh", "-c", NODE_INSTALL],
52
- ["exec", runtime.appContainer, "gem", "install", "bundler", "-v", "2.4.22", "--no-document"],
53
- ["exec", runtime.appContainer, "bundle", "_2.4.22_", "install"],
52
+ ["exec", runtime.appContainer, "gem", "install", "bundler", "-v", runtime.bundlerToInstall ?? "2.4.22", "--no-document"],
53
+ ["exec", runtime.appContainer, "bundle", `_${runtime.bundlerToInstall ?? "2.4.22"}_`, "install"],
54
54
  ...(rails ? [db.createArgs({ appContainer: runtime.appContainer, databaseContainer: runtime.databaseContainer })] : []),
55
- ["exec", runtime.appContainer, "ruby", "--version"]
55
+ ["exec", runtime.appContainer, "ruby", "--version"],
56
+ // Attest the Bundler version actually installed, so the report can warn when
57
+ // CI/host bundler differs. `gem install` output is advisory; `--version` is
58
+ // authoritative for what `bundle exec` will actually resolve.
59
+ ["exec", runtime.appContainer, "bundle", `_${runtime.bundlerToInstall ?? "2.4.22"}_`, "--version"]
56
60
  ];
57
61
  const results = commands.map((args, index) => {
58
62
  const result = docker(spawn, args);
59
63
  if (result.status !== 0) throw new Error(`Target runtime bootstrap failed during ${labels[index]}. Rerun prepare-target-runtime --ruby <x.y.z>.`);
60
64
  return result;
61
65
  });
62
- const rubyOutput = `${results.at(-1).stdout ?? ""}${results.at(-1).stderr ?? ""}`;
66
+ // Index the attestations explicitly rather than from the end of the array:
67
+ // `ruby --version` is no longer the final command, and an off-by-one here would
68
+ // validate the Bundler banner against the Ruby regex.
69
+ const rubyIndex = 3 + (rails ? 1 : 0);
70
+ const rubyOutput = `${results[rubyIndex].stdout ?? ""}${results[rubyIndex].stderr ?? ""}`;
63
71
  if (!new RegExp(`^ruby ${runtime.ruby.replaceAll(".", "\\.")}(?:p\\d+|\\s|$)`).test(rubyOutput.trim())) throw new Error("Target runtime did not execute the requested Ruby version.");
64
72
  return {
65
73
  node: preparationResult(results[0]),
66
74
  bundler: preparationResult(results[1]),
67
75
  bundleInstall: preparationResult(results[2]),
68
76
  ...(rails ? { databaseCreate: preparationResult(results[3]) } : {}),
69
- rubyVersion: preparationResult(results.at(-1))
77
+ rubyVersion: preparationResult(results[rubyIndex]),
78
+ bundlerVersion: `${results.at(-1).stdout ?? ""}${results.at(-1).stderr ?? ""}`.trim()
70
79
  };
71
80
  }
72
81
  function railsProject(root) {
@@ -164,7 +173,9 @@ export function prepareTargetRuntime({ root = process.cwd(), reportPath, ruby, d
164
173
  const canonical = canonicalRoot(root);
165
174
  const db = resolveDatabase(database ?? detectDatabase(canonical));
166
175
  const selected = selectedRun(canonical, reportPath);
167
- const runtime = { version: 2, runId: selected.run.runId, reportPath: selected.reportPath, ruby, database: db.adapter, ...names(selected.run.runId, db) };
176
+ const run = readRun(canonical, selected.reportPath);
177
+ const bundlerToInstall = run.reportType === "bundler_bridge" ? run.targetBundler : (run.bundlerBridge ? run.bundlerBridge.bundlerTo : undefined) || "2.4.22";
178
+ const runtime = { version: 2, runId: selected.run.runId, reportPath: selected.reportPath, ruby, database: db.adapter, bundlerToInstall, ...names(selected.run.runId, db) };
168
179
  const owner = identity(runtime.runId, canonical);
169
180
  const existing = readTargetRuntime(canonical);
170
181
  if (existing && existing.runId !== runtime.runId) {
@@ -250,5 +261,10 @@ export function validateTargetRuntime({ root = process.cwd(), runtime = readTarg
250
261
  const expectedIds = [app?.Id, database?.Id].sort();
251
262
  const valid = appMatches(app, canonical, runtime, db) && databaseMatches(database, runtime, db) && hasOwnership(network, owner) && hasOwnership(app, owner) && hasOwnership(database, owner) && connectedIds.length === 2 && connectedIds.every((id, index) => id === expectedIds[index]) && app?.Image === runtime.resolvedImageId && (!runtime.databaseImageId || database?.Image === runtime.databaseImageId) && env.RAILS_ENV === "test" && env.DATABASE_URL === db.databaseUrl(runtime.databaseContainer);
252
263
  if (!valid) throw new Error("Target runtime no longer matches its prepared run. Rerun prepare-target-runtime --ruby <x.y.z>.");
253
- return { runId: runtime.runId, reportPath: runtime.reportPath, name: runtime.appContainer, id: app.Id, imageId: app.Image, imageRef: app.Config.Image, ruby: runtime.ruby, database: runtime.database, databaseContainer: runtime.databaseContainer, databaseContainerId: database.Id, databaseImageId: database.Image, databaseImageRef: database.Config.Image, network: runtime.network, networkId: network.Id };
264
+ // bundlerVersion is surfaced so the generated report can warn that the deploy
265
+ // host and CI may resolve a different Bundler than this container pinned. It
266
+ // comes from `bundle --version` in the prepared container, not from the
267
+ // bootstrap log, so it reflects what `bundle exec` actually resolved.
268
+ const bundlerVersion = /Bundler version (\d+(?:\.\d+)+)/.exec(runtime.preparation?.bundlerVersion ?? "")?.[1] ?? undefined;
269
+ return { runId: runtime.runId, reportPath: runtime.reportPath, name: runtime.appContainer, id: app.Id, imageId: app.Image, imageRef: app.Config.Image, ruby: runtime.ruby, ...(bundlerVersion ? { bundlerVersion } : {}), database: runtime.database, databaseContainer: runtime.databaseContainer, databaseContainerId: database.Id, databaseImageId: database.Image, databaseImageRef: database.Config.Image, network: runtime.network, networkId: network.Id };
254
270
  }