opencode-ruby-upgrader 0.1.6 → 0.1.7

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/PRIVACY.md CHANGED
@@ -1,9 +1,11 @@
1
1
  # Privacy and Local Evidence
2
2
 
3
- This package has no telemetry, analytics, or report-upload feature. It reads and writes migration evidence only in the current project worktree under `.ruby-upgrades/runs/` and serves the dashboard only on `127.0.0.1`.
3
+ This package has no telemetry, analytics, or report-upload feature. It reads and writes migration evidence only in the current project worktree — run reports under `.ruby-upgrades/runs/` and nonsecret runtime metadata in `.ruby-upgrades/runtime.json` — and serves the dashboard only on `127.0.0.1`.
4
4
 
5
5
  Reports can contain target versions, branch names, commit SHAs, changed-file names, dependency source origins, citations, and bounded validation metadata. Absolute local paths and recognized credentials are redacted, but redaction is best-effort. Do not place secrets, customer data, database dumps, or raw command output in report fields.
6
6
 
7
7
  Reports may be staged into local checkpoint commits. Review them before committing, pushing, sharing, or opening the dashboard on a shared machine. Delete `.ruby-upgrades/` when evidence retention is no longer needed. The dashboard has no authentication; other local processes able to reach your loopback interface may read its displayed report data.
8
8
 
9
9
  Dependency installation, tests, and Rails tooling execute project-controlled code with your local user permissions after confirmation. Use an isolated environment for repositories you do not trust.
10
+
11
+ Preparing an isolated runtime creates local Docker containers, a dedicated network, and pulled images that persist after the run finishes; nothing is uploaded and no port is published to your host. Those resources are labelled with the run ID and a SHA-256 hash of the worktree path, never the path itself. That hash exists only on the Docker resources — it is deliberately kept out of `.ruby-upgrades/` so that committing your upgrade evidence does not publish a guessable fingerprint of your filesystem path. Validation recomputes the hash from the current worktree and compares it against the live labels. Validation receipts written under `.ruby-upgrades/runs/` also record ephemeral Docker identifiers (container IDs, network ID, and image IDs) for traceability of the isolated run; these are local environment metadata, not personal data. Remove containers/networks with `docker rm`/`docker network rm` using the names recorded in `.ruby-upgrades/runtime.json`, and remove pulled images separately if you want the disk space back.
package/README.md CHANGED
@@ -115,11 +115,21 @@ Every hop declares its expected changed files before commit. The commit gate blo
115
115
 
116
116
  The agent defaults unknown shell commands to an OpenCode confirmation prompt. Git inspection is allowed, while direct Git mutation, GitHub CLI, publishing, and shell chaining/pipes/substitutions are denied. Dependency installation/updates, recognized tests, state writes, `commit-hop`, and `commit-rails-hop` require confirmation. This protects against accidental agent actions, not malicious project code: dependency installation and tests execute project-controlled code with your local user permissions. Use an isolated environment for repositories you do not trust, and review any command OpenCode asks you to approve.
117
117
 
118
- New reports require `record-executed-iteration --validation <id>` or `record-executed-rails-iteration --validation <test-id>`; asserted results cannot be recorded or committed. The accepted IDs map to fixed no-shell commands: `bundle-rspec`, `bundle-rails-test`, `bundle-rake-test`, `bin-rails-test`, and, for Rails bridges, `rails-app-update`. When the target Ruby is unavailable on your host, the agent runs `prepare-target-runtime --ruby <x.y.z>` after selecting the exact target patch release. One confirmation provisions labeled per-run Ruby and isolated PostgreSQL Docker resources, installs Node, installs Bundler 2.4.22, runs `bundle install`, and, for Rails, creates the isolated test database.
118
+ New reports require `record-executed-iteration --validation <id>` or `record-executed-rails-iteration --validation <test-id>`; asserted results cannot be recorded or committed. The accepted IDs map to fixed no-shell commands: `bundle-rspec`, `bundle-rails-test`, `bundle-rake-test`, `bin-rails-test`, and, for Rails bridges, `rails-app-update`. When the target Ruby is unavailable on your host, the agent runs `prepare-target-runtime --ruby <x.y.z>` after selecting the exact target patch release. One confirmation provisions labeled per-run Ruby and isolated database Docker resources, installs Node, installs Bundler 2.4.22, runs `bundle install`, and, for Rails, creates the isolated test database.
119
119
 
120
- It writes nonsecret `.ruby-upgrades/runtime.json` with only safe preparation digests and the resolved image ID; raw output and `DATABASE_URL` are never persisted. `docker-bundle-rspec` reuses and verifies that manifest, including the exact requested Ruby execution and resolved image ID, before executing the fixed `docker exec --env DATABASE_CLEANER_ALLOW_REMOTE_DATABASE_URL=true <container> bundle exec rspec`. The safeguard override is scoped to the verified isolated test process; no container name, report path, or environment value is needed from the user.
120
+ The isolated test database is PostgreSQL by default. If the project declares MySQL through `mysql2` (the gem or the adapter in `config/database.yml`) and not PostgreSQL, the runtime is prepared with an isolated MySQL 8.4 server instead. The engine is detected from `Gemfile`, `Gemfile.lock`, and `config/database.yml`; the legacy `mysql` and `trilogy` gems are not recognized, and a project that declares both engines stops preparation and asks you to choose:
121
121
 
122
- Receipts persist only an output digest and byte count, plus structured test metrics; raw validation output is deliberately not committed. Each receipt also binds to a non-evidence working-tree fingerprint, which the commit gate rechecks after final validation. A checkpoint commit carries the receipt digest.
122
+ ```bash
123
+ opencode-ruby-upgrader prepare-target-runtime --ruby <x.y.z> --database mysql
124
+ ```
125
+
126
+ Both engines run credential-free on a per-run Docker network that carries two ownership labels: the run ID and a SHA-256 hash of the worktree path. Validation compares those labels against a freshly computed hash of the current worktree, so ownership is asserted against the live containers rather than trusted from a file — a copied manifest cannot vouch for another worktree's resources. The hash stays on the Docker labels and is never written into `.ruby-upgrades/`, which means committing your upgrade evidence does not publish a guessable fingerprint of your filesystem path. A network, container, or manifest belonging to a different run or worktree is refused rather than reused, so a second worktree cannot reach, reuse, or delete another worktree's database.
127
+
128
+ Prepared resources are labelled and bound to the worktree, and validation refuses to use them if they carry extra network attachments, published ports, privileged mode, unexpected mounts or commands, or a drifted image. Docker resources are not removed automatically when a run finishes — they persist so later runs stay reproducible. When you no longer need them, remove the run's containers and network by name and delete `.ruby-upgrades/runtime.json`; the names are recorded in that manifest.
129
+
130
+ It writes nonsecret `.ruby-upgrades/runtime.json` with the run and worktree binding, the selected engine and container names, both resolved image IDs, and preparation digests; raw output, `DATABASE_URL`, and your local path are never persisted. `docker-bundle-rspec` reuses and verifies that manifest — including that it belongs to the run receiving the receipt, the exact requested Ruby execution, the exact requested database image, and the isolated network — before executing the fixed `docker exec --env DATABASE_CLEANER_ALLOW_REMOTE_DATABASE_URL=true <container> bundle exec rspec`. The safeguard override is scoped to the verified isolated test process; no container name, report path, or environment value is needed from the user.
131
+
132
+ Receipts persist only an output digest and byte count, plus structured test metrics; raw validation output is deliberately not committed. Docker receipts additionally record the run identity, container IDs, image IDs, and network ID that produced them, so evidence cannot be silently re-pointed at another run or engine. Each receipt also binds to a non-evidence working-tree fingerprint, which the commit gate rechecks after final validation. A checkpoint commit carries the receipt digest.
123
133
 
124
134
  This is tamper-evident provenance for a committed report, not protection against the same local user rewriting both evidence and Git history.
125
135
 
@@ -190,7 +200,7 @@ The credential scanner is heuristic: it recognizes common token formats and quot
190
200
 
191
201
  ## Privacy
192
202
 
193
- No telemetry, no analytics, and no report uploads. Migration evidence is written only under `.ruby-upgrades/runs/` in the current worktree, and the dashboard binds to `127.0.0.1` only. Reports may contain target versions, branch names, commit SHAs, changed-file names, dependency source origins, citations, and bounded validation metadata — absolute local paths and recognized credentials are redacted, but redaction is best-effort.
203
+ No telemetry, no analytics, and no report uploads. Migration evidence is written only under `.ruby-upgrades/` in the current worktree — run reports under `.ruby-upgrades/runs/` and nonsecret runtime metadata in `.ruby-upgrades/runtime.json` — and the dashboard binds to `127.0.0.1` only. Reports may contain target versions, branch names, commit SHAs, changed-file names, dependency source origins, citations, and bounded validation metadata — absolute local paths and recognized credentials are redacted, but redaction is best-effort. Prepared Docker containers, networks, and images stay on your machine and are labelled with the run ID and a one-way hash of the worktree path, never the path itself.
194
204
 
195
205
  Full detail in [PRIVACY.md](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.6/PRIVACY.md). To report a vulnerability, see [SECURITY.md](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.6/SECURITY.md).
196
206
 
@@ -198,6 +208,8 @@ Full detail in [PRIVACY.md](https://github.com/lilla021/opencode-ruby-upgrader/b
198
208
 
199
209
  Run the self-contained test suite with `npm test`. A CI environment that installs a supported OpenCode CLI can also run `OPENCODE_RUNTIME_E2E=1 npm run test:opencode`; this verifies the installed runtime is available and the plugin registers its agent/command contract before release.
200
210
 
211
+ Database-engine changes require real container evidence, not only mocked tests. `npm run test:docker:mysql` builds a throwaway Rails + `mysql2` project, prepares a real `mysql:8.4` runtime, proves the app connects over the isolated network with a passing RSpec example, checks the resulting receipt and that the persisted manifest stays secret-free, and verifies it left no containers, network, or worktree behind. CI runs this on every pull request, and the release workflow runs it again before publishing. It needs a running Docker daemon and network access to pull images.
212
+
201
213
  CI runs that suite against Node 22, 24, and 26 so the declared `engines.node` range is exercised rather than assumed. Both workflows pin `npm@11.16.0` so the matrix varies Node rather than npm, because `allowScripts` — npm 11's dependency install-script approval gate, used here to narrowly permit the pinned `opencode-ai` postinstall — and npm trusted publishing both require npm 11 or newer. `RELEASING.md` covers the publish path.
202
214
 
203
215
  ## Acknowledgments
@@ -5,6 +5,9 @@ Complete every item before pushing a `v*` tag. The protected `npm-release` envir
5
5
  ## Before every release
6
6
 
7
7
  - [ ] Bump the version and update [RELEASE_NOTES.md](RELEASE_NOTES.md): scope, supported adapters, known limitations, rollback.
8
+ - [ ] For every newly supported adapter — including a database engine — record real container evidence, not only mocked tests. `npm run test:docker:mysql` provisions a real `mysql:8.4`, connects from the app through its own `mysql2` driver, and checks the receipt. Do not ship a new adapter on unit tests alone. The `mysql-runtime` CI job runs it on every pull request and `release.yml` runs it again before `npm publish`, so this is enforced rather than advisory.
9
+ - [ ] Review new or changed CLI options in `opencode-ruby-upgrader <command> --help`; users should not have to read the source to find a documented flag.
10
+ - [ ] Reconcile README, `agents/ruby-upgrade.md`, and release notes with the shipped behavior: defaults, detection and ambiguity behavior, resource lifecycle, and what is persisted.
8
11
  - [ ] Repoint the README's tag-pinned links to the new version tag: `E2E_EVIDENCE.md`, `PRIVACY.md`, `SECURITY.md`, `docs/rails-bridge.md`, and both screenshot URLs. They are pinned to a tag so the registry page always shows the docs of the installed version rather than whatever `main` currently says; leaving them on the previous tag makes the published page drift behind the release.
9
12
  - [ ] Review the package metadata (name, description, keywords, `repository`, `bugs`, `homepage`) and the packed-file list with `npm pack --dry-run`.
10
13
  - [ ] Run `npm test`; confirm the release workflow's runtime smoke gate passes with the pinned OpenCode runtime.
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # opencode-ruby-upgrader — release notes
2
2
 
3
+ ## v0.1.7 — isolated MySQL runtime
4
+
5
+ The isolated validation runtime can now prepare MySQL as well as PostgreSQL.
6
+
7
+ **Scope**
8
+
9
+ - `prepare-target-runtime` accepts `--database postgres|mysql`. Without it, the engine is detected from `Gemfile`, `Gemfile.lock`, and `config/database.yml`; `mysql2` selects MySQL, PostgreSQL declarations select PostgreSQL, no declaration defaults to PostgreSQL, and a project declaring both engines stops and asks for an explicit choice instead of guessing.
10
+ - MySQL uses `mysql:8.4` with an empty root password inside the per-run network. Readiness is probed over TCP, because the official entrypoint briefly runs a socket-only server that answers `ping` before the network path works. The test database is created by the container's own client, so preparation never depends on a driver being compiled first.
11
+ - Only `mysql2` is supported. The legacy `mysql` adapter and `trilogy` are deliberately not recognized: this runtime emits `mysql2://` URLs, and claiming other drivers would produce a runtime that provisions successfully and then fails to load the adapter.
12
+ - The runtime manifest now records the selected engine, the database container, and a second image ID for the database alongside the Ruby image ID. Validation attests both, and Docker receipts record the run identity, container IDs, image IDs, and network ID.
13
+ - Ownership is bound to the worktree: resources carry the run ID and a SHA-256 hash of the canonical worktree path, and validation recomputes that hash to compare against the live container labels instead of trusting a value persisted in `runtime.json`. Binding to the running resources is the stronger check — a copied manifest cannot vouch for a foreign worktree — and it keeps a guessable fingerprint of the user's filesystem path out of a file intended to be committed. Legacy version-2 PostgreSQL manifests migrate in place when the existing app container proves the mount, and resources from another run or worktree are refused rather than reused or deleted.
14
+ - Isolation checks are now fail-closed. Validation rejects extra network attachments, published ports, privileged mode, unexpected mounts or commands, unexpected containers on the run network, and database containers with bind mounts — the properties that make a credential-free database safe to expose only to the app container.
15
+
16
+ **Evidence**
17
+
18
+ `npm run test:docker:mysql` provisions a real MySQL container, prepares the runtime, connects from a throwaway Rails app through its own `mysql2` driver, asserts a passing RSpec receipt and a secret-free persisted manifest, and verifies it left no resources behind. This is real container evidence, not a mocked unit test; the fixture is generated and deleted by the script. CI runs it on every pull request via the `mysql-runtime` job, and `release.yml` runs it again before publishing.
19
+
20
+ **Known limitations**
21
+
22
+ - Prepared containers, networks, and images are not torn down when a run completes or is paused. They persist for reproducibility and are removed manually; the names are recorded in `.ruby-upgrades/runtime.json`.
23
+ - Detection is static text matching. A dynamically computed adapter, or a project whose test environment differs from its other environments, may need an explicit `--database`.
24
+ - No teardown command exists yet; cleanup is documented rather than automated.
25
+
3
26
  ## v0.1.6 — reader path and CI coverage
4
27
 
5
28
  No runtime change. The agent's commands, permission policy, commit gate, and report format are untouched. This release restructures the public documentation and closes a CI coverage gap.
@@ -12,7 +12,7 @@ You own a careful Ruby runtime migration. Be decisive on routine fixes and trans
12
12
 
13
13
  - Begin by running `opencode-ruby-upgrader preflight --json`. If it does not return `ok: true`, do not inspect, edit, test, or resolve dependencies. Require the user to configure `git config opencode-ruby-upgrader.defaultBranch <branch>`; do not infer a default branch. Show the worktree instructions and ask the user to relaunch OpenCode from their user-created linked worktree. If it returns `mode: "non-git"`, permit dry-run inventory only and stop before any durable run, edit, test, or dependency resolution.
14
14
  - Parse user controls before work: `--dry-run`, `--target <version>`, and `--stop-after-hop`. Run `opencode-ruby-upgrader inventory`, `opencode-ruby-upgrader supply-chain`, and `opencode-ruby-upgrader git-capabilities` first. Pause for explicit review on shallow clones, sparse checkout, submodules, or LFS configuration. If the project is unsupported or has no recognized test adapter, stop with the detected evidence and ask for a test command; do not invent one. After official research pins the target, initialize the durable run with `opencode-ruby-upgrader begin --target <version>` (append `--dry-run` or `--stop-after-hop` when requested). Use only the returned report path for this run.
15
- - Drive the durable state machine, not prose alone: record research with `record-research`, then transition `initialized → inventory_complete → research_complete`; record each complete evidence-backed hop only with `record-executed-iteration` (or `record-executed-rails-iteration`), transition to `hop_validated`, and use the returned checkpoint SHA when transitioning to `committed`. Exactly one checkpoint is required before the next iteration. Repeat per hop. Use `paused` to stop safely for user review/manual work (it releases the lock); `complete` is only valid once the pinned target is reached. Use `blocked` only with evidence and an actionable option. For an isolated legacy runtime, ask once for approval to apply the planned Ruby declaration edit and then run `prepare-target-runtime --ruby <x.y.z> --report <the run path you just resumed>` before `docker-bundle-rspec`: it creates isolated Docker resources, installs Node and Bundler 2.4.22, runs `bundle install`, and creates the isolated Rails test database when applicable. Execute preparation with the longest supported shell timeout (at least 15 minutes), not a default short timeout. Do not ask the user for a report path, container name, or environment variables. The persisted nonsecret runtime manifest binds labeled app and PostgreSQL containers to the selected run; `docker-bundle-rspec` executes only the fixed inner argv `bundle exec rspec`.
15
+ - Drive the durable state machine, not prose alone: record research with `record-research`, then transition `initialized → inventory_complete → research_complete`; record each complete evidence-backed hop only with `record-executed-iteration` (or `record-executed-rails-iteration`), transition to `hop_validated`, and use the returned checkpoint SHA when transitioning to `committed`. Exactly one checkpoint is required before the next iteration. Repeat per hop. Use `paused` to stop safely for user review/manual work (it releases the lock); `complete` is only valid once the pinned target is reached. Use `blocked` only with evidence and an actionable option. For an isolated legacy runtime, ask once for approval to apply the planned Ruby declaration edit and then run `prepare-target-runtime --ruby <x.y.z> --report <the run path you just resumed>` before `docker-bundle-rspec`: it creates isolated Docker resources, installs Node and Bundler 2.4.22, runs `bundle install`, and creates the isolated Rails test database when applicable. The isolated test database is PostgreSQL unless the project declares MySQL through `mysql2`, in which case the runtime prepares an isolated MySQL server; the engine is detected from the project's own declarations, so do not guess or pass `--database` on your own. If preparation reports that both MySQL and PostgreSQL were detected, stop and present that conflict with your evidence and ask the user to choose the engine once, then rerun with their answer. Execute preparation with the longest supported shell timeout (at least 15 minutes), not a default short timeout. Do not ask the user for a report path, container name, or environment variables. The persisted nonsecret runtime manifest binds labeled app and database containers to the selected run and worktree; `docker-bundle-rspec` executes only the fixed inner argv `bundle exec rspec`. Prepared Docker resources persist after the run ends; report the container and network names from the manifest so the user can remove them, and never remove containers or networks that the tool did not create for this run.
16
16
  - If the next Ruby hop is incompatible with the resolved Rails version, do not edit Rails as part of the Ruby hop. Cite the official compatibility evidence, explain the required Rails from/to versions, and obtain explicit approval before running `record-framework-bridge --report <report-path> --ruby-from <version> --ruby-to <version> --rails-from <version> --rails-to <version> --rationale <text> --citation 'title|https://...'`. Then transition the Ruby run to `blocked` and start `begin-rails-bridge --ruby-report <blocked-ruby-report>`. In that separate report, research contiguous Rails-minor hops with `record-rails-research`, record each passing hop with `record-executed-rails-iteration` including reviewed `bin/rails app:update` evidence, transition to `hop_validated`, and checkpoint only with `commit-rails-hop`. Start a fresh Ruby run only after the Rails bridge is complete.
17
17
  - Rails `app:update` executes with conflict-skipping semantics so existing application configuration is never overwritten noninteractively. Review every generated file before final tests. If a pending result is unsafe or superseded, revert only that unvalidated hop and run `discard-pending-app-update --report <report-path> --reason <review finding>`; the discarded digest remains durable evidence before a fresh attempt.
18
18
  - When a validated hop changes a lockfile, inspect the resolved dependency delta and license findings, then durably record both with `record-dependency-review --report <report-path> --compatibility <finding> --licenses <finding>` before requesting the checkpoint. Never bypass this gate merely because tests pass.
@@ -14,7 +14,12 @@ const options = (name) => args.flatMap((argument, index) => argument === name &&
14
14
  const reportOption = () => option("--report");
15
15
  const citation = (value) => { const [title, url] = (value ?? "").split("|"); return { title, url }; };
16
16
  const usage = "Usage: opencode-ruby-upgrader <preflight|dashboard|begin|begin-rails-bridge|prepare-target-runtime|status|transition|record-research|record-rails-research|record-risk|record-framework-bridge|record-executed-iteration|record-executed-rails-iteration|discard-pending-app-update|discard-last-rails-iteration|record-dependency-review|inventory|supply-chain|git-capabilities|commit-hop|commit-rails-hop|resume|release-lock> [--help]";
17
- if (command === "help" || args.includes("--help")) {
17
+ const prepareRuntimeHelp = `Usage: opencode-ruby-upgrader prepare-target-runtime --ruby <x.y.z> [--database postgres|mysql] [--report .ruby-upgrades/runs/<run>.json]
18
+
19
+ Prepares an isolated, run-bound Docker runtime. The database is detected from mysql2/PostgreSQL project declarations; absent evidence defaults to PostgreSQL, while conflicting evidence requires --database. Use --database only to override detection deliberately.`;
20
+ if (command === "prepare-target-runtime" && args.includes("--help")) {
21
+ console.log(prepareRuntimeHelp);
22
+ } else if (command === "help" || args.includes("--help")) {
18
23
  console.log(`${usage}\n\nUse status --summary for a concise report view. release-lock is stale-session recovery only and requires --force.`);
19
24
  } else if (command === "preflight") {
20
25
  const result = inspectWorktree();
@@ -35,8 +40,8 @@ if (command === "help" || args.includes("--help")) {
35
40
  catch (error) { console.error(`Rails bridge start blocked: ${error.message}`); process.exitCode = 1; }
36
41
  } else if (command === "prepare-target-runtime") {
37
42
  try {
38
- if (!option("--ruby")) throw new Error("Usage: prepare-target-runtime --ruby <x.y.z> [--report .ruby-upgrades/runs/<run>.json]");
39
- console.log(JSON.stringify(prepareTargetRuntime({ ruby: option("--ruby"), reportPath: option("--report") }), null, 2));
43
+ if (!option("--ruby")) throw new Error("Usage: prepare-target-runtime --ruby <x.y.z> [--database postgres|mysql] [--report .ruby-upgrades/runs/<run>.json]");
44
+ console.log(JSON.stringify(prepareTargetRuntime({ ruby: option("--ruby"), database: option("--database"), reportPath: option("--report") }), null, 2));
40
45
  } catch (error) { console.error(`Target runtime preparation blocked: ${error.message}`); process.exitCode = 1; }
41
46
  } else if (command === "status") {
42
47
  try {
@@ -0,0 +1,98 @@
1
+ # Rails bridge lifecycle
2
+
3
+ Reference for the rare case where the next Ruby hop is blocked by the resolved Rails version. The Ruby run is **not** repaired in place: it transitions to `blocked` and becomes terminal, and a separate Rails-bridge run takes over. See [Recovery](../README.md#recovery) in the README for the general pause/resume/revert path.
4
+
5
+ ## When this applies
6
+
7
+ If the Ruby version you want to reach is incompatible with the Rails version your app currently pins, the agent will not edit Rails as part of a Ruby hop. Rails changes are a framework-major migration and require an explicit, evidence-backed decision from you.
8
+
9
+ The bridge exists to keep one concern per report: the blocked Ruby report records *why* it stopped, the Rails report records *how* the framework moved.
10
+
11
+ ## 1. Record the bridge, then block the Ruby run
12
+
13
+ Cite the official compatibility evidence and state the required Rails from/to versions before anything runs. Approval is required; the agent does not infer it.
14
+
15
+ ```bash
16
+ opencode-ruby-upgrader record-framework-bridge \
17
+ --report .ruby-upgrades/runs/<ruby-report>.json \
18
+ --ruby-from <from> --ruby-to <to> \
19
+ --rails-from <from> --rails-to <to> \
20
+ --rationale "<why this Rails version is required>" \
21
+ --citation 'title|https://...'
22
+ ```
23
+
24
+ Then transition the Ruby run:
25
+
26
+ ```bash
27
+ opencode-ruby-upgrader transition \
28
+ --report .ruby-upgrades/runs/<ruby-report>.json \
29
+ --phase blocked
30
+ ```
31
+
32
+ **That Ruby report is terminal.** Do not attempt to resume it. Complete the Rails bridge, then start a fresh Ruby run.
33
+
34
+ ## 2. Begin the Rails bridge
35
+
36
+ ```bash
37
+ opencode-ruby-upgrader begin-rails-bridge \
38
+ --ruby-report .ruby-upgrades/runs/<blocked-ruby-report>.json
39
+ ```
40
+
41
+ This creates a separate report for the framework migration.
42
+
43
+ ## 3. Research contiguous Rails-minor hops
44
+
45
+ ```bash
46
+ opencode-ruby-upgrader record-rails-research \
47
+ --report .ruby-upgrades/runs/<rails-report>.json \
48
+ --ladder <version>,<version>,<version> \
49
+ --citation 'title|https://...'
50
+ ```
51
+
52
+ Each hop is one contiguous minor version. Rails does not support skipping minor series the way Ruby hops do, so the ladder is denser.
53
+
54
+ ## 4. Run `app:update`, then validate
55
+
56
+ Every Rails iteration executes `bin/rails app:update` **first**, records its receipt, and reviews that exact working-tree fingerprint before final tests run.
57
+
58
+ `app:update` runs with conflict-skipping semantics, so your existing application configuration is never overwritten non-interactively. **Review every generated file** before accepting the hop.
59
+
60
+ Record the iteration with its validation ID:
61
+
62
+ ```bash
63
+ opencode-ruby-upgrader record-executed-rails-iteration \
64
+ --report .ruby-upgrades/runs/<rails-report>.json \
65
+ --json '<iteration payload>' \
66
+ --validation <test-id>
67
+ ```
68
+
69
+ Accepted validation IDs are the fixed no-shell commands, including `bundle-rails-test`, `bin-rails-test`, `bundle-rake-test`, and `rails-app-update` for the update step itself. Asserted results cannot be recorded.
70
+
71
+ Then transition and checkpoint:
72
+
73
+ ```bash
74
+ opencode-ruby-upgrader transition \
75
+ --report .ruby-upgrades/runs/<rails-report>.json \
76
+ --phase hop_validated
77
+
78
+ opencode-ruby-upgrader commit-rails-hop \
79
+ --report .ruby-upgrades/runs/<rails-report>.json
80
+ ```
81
+
82
+ `commit-rails-hop` is the only permitted commit path for a Rails hop. It is mutually exclusive with `commit-hop`, which is Ruby-only — the gate rejects the wrong one rather than producing a mislabelled checkpoint.
83
+
84
+ ## Discarding a bad hop
85
+
86
+ If a pending `app:update` result is unsafe or superseded, revert only that unvalidated hop and discard it:
87
+
88
+ ```bash
89
+ opencode-ruby-upgrader discard-pending-app-update \
90
+ --report .ruby-upgrades/runs/<rails-report>.json \
91
+ --reason "<review finding>"
92
+ ```
93
+
94
+ The discarded digest remains durable evidence, so the report still accounts for the attempt. Use `discard-last-rails-iteration --reason "<finding>"` for a recorded iteration that turned out to be wrong.
95
+
96
+ ## After the bridge completes
97
+
98
+ Start a fresh Ruby run. The new run picks up the upgraded Rails version and continues the original migration path from there.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ruby-upgrader",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "A safe, evidence-driven Ruby runtime upgrade agent and local migration dashboard for OpenCode",
5
5
  "author": "Priscilla Cournoyer (https://github.com/lilla021)",
6
6
  "repository": {
@@ -18,12 +18,13 @@
18
18
  },
19
19
  "scripts": {
20
20
  "test": "node --test tests/*.test.js",
21
+ "test:docker:mysql": "node tests/docker-mysql-smoke.mjs",
21
22
  "test:opencode": "node tests/opencode-runtime-smoke.mjs"
22
23
  },
23
24
  "allowScripts": {
24
25
  "opencode-ai@1.18.30": true
25
26
  },
26
- "files": ["src", "agents", "bin", "README.md", "LICENSE", "SECURITY.md", "PRIVACY.md", "RELEASING.md", "RELEASE_CHECKLIST.md", "RELEASE_NOTES.md", "E2E_EVIDENCE.md"],
27
+ "files": ["src", "agents", "bin", "docs/*.md", "README.md", "LICENSE", "SECURITY.md", "PRIVACY.md", "RELEASING.md", "RELEASE_CHECKLIST.md", "RELEASE_NOTES.md", "E2E_EVIDENCE.md"],
27
28
  "keywords": ["opencode", "opencode-plugin", "ruby", "rails", "upgrade", "worktree", "migration"],
28
29
  "engines": { "node": ">=22.5.0" },
29
30
  "license": "MIT",
package/src/controller.js CHANGED
@@ -142,7 +142,7 @@ function recordRailsIterationInternal({ root = process.cwd(), reportPath, iterat
142
142
  export function recordRailsIteration(options = {}) { return recordRailsIterationInternal(options); }
143
143
 
144
144
  export function recordExecutedIteration({ root = process.cwd(), reportPath, iteration, validationCommandId }) {
145
- const run = validationPreflight(root, reportPath, false); const receipt = executeValidation({ root, inventory: run.inventory, commandId: validationCommandId });
145
+ const run = validationPreflight(root, reportPath, false); const receipt = executeValidation({ root, inventory: run.inventory, commandId: validationCommandId, expectedRuntime: { runId: run.runId, reportPath } });
146
146
  if (receipt.kind !== "test" || receipt.exitCode !== 0 || receipt.testEvidence?.passed !== true) throw new Error(`Validation failed; receipt ${receipt.id} was not recorded as a passing hop.`);
147
147
  return recordIterationInternal({ root, reportPath, executed: true, iteration: { ...iteration, tests: { ...receipt.testEvidence, smoke: iteration?.tests?.smoke }, validationReceipts: [receipt] } });
148
148
  }
@@ -150,7 +150,7 @@ export function recordExecutedIteration({ root = process.cwd(), reportPath, iter
150
150
  export function recordExecutedRailsIteration({ root = process.cwd(), reportPath, iteration, testValidationCommandId }) {
151
151
  const run = validationPreflight(root, reportPath, true);
152
152
  if (!run.pendingAppUpdate) {
153
- const updateReceipt = executeValidation({ root, inventory: run.inventory, commandId: "rails-app-update" });
153
+ const updateReceipt = executeValidation({ root, inventory: run.inventory, commandId: "rails-app-update", expectedRuntime: { runId: run.runId, reportPath } });
154
154
  if (updateReceipt.exitCode !== 0) throw new Error("app:update failed; Rails hop was not recorded.");
155
155
  run.pendingAppUpdate = updateReceipt;
156
156
  writeRun(root, reportPath, run);
@@ -159,7 +159,7 @@ export function recordExecutedRailsIteration({ root = process.cwd(), reportPath,
159
159
  const updateReceipt = run.pendingAppUpdate;
160
160
  const review = { ...iteration?.appUpdateReview, receiptId: updateReceipt.id, executedAt: updateReceipt.startedAt, worktree: updateReceipt.worktree };
161
161
  if (!validRailsReviewForExecution(review, updateReceipt)) throw new Error("Review app:update after it executes and bind the review to its receipt and working-tree fingerprint.");
162
- const testReceipt = executeValidation({ root, inventory: run.inventory, commandId: testValidationCommandId });
162
+ const testReceipt = executeValidation({ root, inventory: run.inventory, commandId: testValidationCommandId, expectedRuntime: { runId: run.runId, reportPath } });
163
163
  if (testReceipt.kind !== "test" || testReceipt.exitCode !== 0 || testReceipt.testEvidence?.passed !== true) throw new Error("Final validation failed; Rails hop was not recorded.");
164
164
  const recorded = recordRailsIterationInternal({ root, reportPath, executed: true, iteration: { ...iteration, tests: { ...testReceipt.testEvidence, smoke: iteration?.tests?.smoke }, validationReceipts: [updateReceipt, testReceipt], appUpdateReview: review } });
165
165
  delete recorded.pendingAppUpdate;
@@ -0,0 +1,92 @@
1
+ // Adapter-encapsulated database lifecycle for the isolated target runtime.
2
+ //
3
+ // Each adapter owns its container name suffix, image, health probe, test-database
4
+ // creation command, and DATABASE_URL format. target-runtime.js stays
5
+ // adapter-agnostic: it only reads `.image`, `.label`, `.port` and calls
6
+ // `.readyArgs` / `.createArgs` / `.databaseUrl`.
7
+ //
8
+ // Every step runs either inside the isolated database container or through the
9
+ // verified `docker exec` path, so the app image is never modified to support a
10
+ // database engine. The app reaches the database only over the per-run,
11
+ // run-id-labelled Docker network.
12
+
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ const DB_NAME = "ruby_upgrade_test";
17
+ const NAME = /^[a-z0-9][a-z0-9-]{0,127}$/;
18
+
19
+ function waitSecond() { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000); }
20
+
21
+ // `probe` returns a docker result; a zero status means the database is serving
22
+ // the kind of traffic the app will generate.
23
+ function readyLoop({ probe, attempts, label }) {
24
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
25
+ if (probe().status === 0) return;
26
+ waitSecond();
27
+ }
28
+ throw new Error(`Target ${label} did not become ready within ${attempts} seconds. Rerun prepare-target-runtime --ruby <x.y.z>.`);
29
+ }
30
+
31
+ export const databases = Object.freeze({
32
+ postgres: Object.freeze({
33
+ adapter: "postgres",
34
+ label: "PostgreSQL",
35
+ image: "postgres:16-alpine",
36
+ port: 5432,
37
+ requiredEnvironment: Object.freeze({ POSTGRES_HOST_AUTH_METHOD: "trust", POSTGRES_DB: DB_NAME }),
38
+ // `trust` keeps the isolated test database credential-free and unreachable
39
+ // from outside the per-run network.
40
+ startArgs: () => ["--env", "POSTGRES_HOST_AUTH_METHOD=trust", "--env", `POSTGRES_DB=${DB_NAME}`, "postgres:16-alpine"],
41
+ readyArgs: (container) => ["exec", container, "pg_isready", "-U", "postgres", "-d", DB_NAME],
42
+ // Use the app's own Rails task so framework-specific database setup remains
43
+ // consistent with the project's PostgreSQL adapter.
44
+ createArgs: ({ appContainer }) => ["exec", appContainer, "bundle", "exec", "rake", "db:create"],
45
+ databaseUrl: (container) => `postgresql://postgres@${container}:5432/${DB_NAME}`,
46
+ ensureReady: ({ probe }) => readyLoop({ probe, attempts: 30, label: "PostgreSQL" })
47
+ }),
48
+ mysql: Object.freeze({
49
+ adapter: "mysql",
50
+ label: "MySQL",
51
+ image: "mysql:8.4",
52
+ port: 3306,
53
+ requiredEnvironment: Object.freeze({ MYSQL_ALLOW_EMPTY_PASSWORD: "yes", MYSQL_DATABASE: DB_NAME }),
54
+ // An empty root password keeps the isolated test database credential-free.
55
+ // The official entrypoint also provisions TCP access for that account.
56
+ startArgs: () => ["--env", "MYSQL_ALLOW_EMPTY_PASSWORD=yes", "--env", `MYSQL_DATABASE=${DB_NAME}`, "mysql:8.4"],
57
+ // Probe over TCP, not the unix socket. The entrypoint briefly runs a
58
+ // socket-only bootstrap server: `mysqladmin ping` and a local `SELECT 1`
59
+ // both succeed against it several seconds before the app could actually
60
+ // connect, which would surface later as a misleading connection failure.
61
+ readyArgs: (container) => ["exec", container, "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "--silent"],
62
+ // Created server-side with the container's own client so preparation never
63
+ // depends on the mysql2 native extension being built yet.
64
+ createArgs: ({ databaseContainer }) => ["exec", databaseContainer, "mysql", "-h", "127.0.0.1", "-u", "root", "-e", `CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4`],
65
+ databaseUrl: (container) => `mysql2://root@${container}:3306/${DB_NAME}`,
66
+ // First boot initializes the data directory, restarts the server to apply
67
+ // settings, then opens TCP; budget well beyond PostgreSQL's startup.
68
+ ensureReady: ({ probe }) => readyLoop({ probe, attempts: 90, label: "MySQL" })
69
+ })
70
+ });
71
+
72
+ export function resolveDatabase(adapter) {
73
+ const key = adapter ?? "postgres";
74
+ if (!Object.hasOwn(databases, key)) throw new Error(`--database must be one of: ${Object.keys(databases).join(", ")}.`);
75
+ return databases[key];
76
+ }
77
+
78
+ // Best-effort adapter detection from the project's own declarations. An
79
+ // explicit `--database` always wins; this only fills the gap so the common case
80
+ // needs no flag.
81
+ export function detectDatabase(root = process.cwd()) {
82
+ const read = (file) => { try { return fs.readFileSync(path.join(root, file), "utf8"); } catch { return ""; } };
83
+ const uncommented = (contents) => contents.replace(/#.*$/gm, "");
84
+ const corpus = [read("Gemfile"), read("Gemfile.lock"), read("config/database.yml")].map(uncommented).join("\n");
85
+ const mysql = /\bmysql2\b/i.test(corpus) || /adapter:\s*mysql2\b/i.test(corpus);
86
+ const postgres = /\bpg\b/i.test(corpus) || /adapter:\s*(?:postgresql|postgres)\b/i.test(corpus) || /postgresql:\/\//i.test(corpus);
87
+ if (mysql && postgres) throw new Error("Both MySQL and PostgreSQL were detected. Specify --database mysql or --database postgres.");
88
+ if (mysql && !postgres) return "mysql";
89
+ return "postgres";
90
+ }
91
+
92
+ export { DB_NAME, NAME };
package/src/run-state.js CHANGED
@@ -34,7 +34,17 @@ function validCitation(value) { return value && typeof value.title === "string"
34
34
  function safeFiles(files) { return Array.isArray(files) && files.every((file) => typeof file === "string" && !path.isAbsolute(file) && !file.includes("..")); }
35
35
  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); }
36
36
  function validFingerprint(fingerprint) { return fingerprint && fingerprint.algorithm === "sha256" && /^[a-f0-9]{40}$/i.test(fingerprint.headSha ?? "") && /^[a-f0-9]{64}$/i.test(fingerprint.diffSha256 ?? ""); }
37
- function validReceipt(receipt) { return receipt && receipt.receiptVersion === 1 && typeof receipt.id === "string" && /^[a-f0-9-]{36}$/i.test(receipt.id) && ["test", "rails_app_update"].includes(receipt.kind) && typeof receipt.commandId === "string" && Array.isArray(receipt.argv) && !Number.isNaN(Date.parse(receipt.startedAt ?? "")) && !Number.isNaN(Date.parse(receipt.finishedAt ?? "")) && typeof receipt.durationMs === "number" && (typeof receipt.exitCode === "number" || receipt.exitCode === null) && typeof receipt.timedOut === "boolean" && /^[a-f0-9]{64}$/i.test(receipt.output?.redactedSha256 ?? "") && Number.isSafeInteger(receipt.output?.bytes) && receipt.output.bytes >= 0 && receipt.output.summary === undefined && validFingerprint(receipt.worktree); }
37
+ function validDockerEnvironment(environment) {
38
+ const containerId = /^[a-f0-9]{64}$/i;
39
+ const imageId = /^sha256:[a-f0-9]{64}$/i;
40
+ const dockerName = /^[a-z0-9][a-z0-9-]{0,127}$/;
41
+ return environment?.type === "docker" && /^[a-f0-9-]{36}$/i.test(environment.runId ?? "") && /^\.ruby-upgrades\/runs\/[A-Za-z0-9][A-Za-z0-9._-]*\.json$/.test(environment.reportPath ?? "") && dockerName.test(environment.name ?? "") && containerId.test(environment.id ?? "") && imageId.test(environment.imageId ?? "") && typeof environment.imageRef === "string" && /^\d+\.\d+\.\d+$/.test(environment.ruby ?? "") && ["postgres", "mysql"].includes(environment.database) && dockerName.test(environment.databaseContainer ?? "") && containerId.test(environment.databaseContainerId ?? "") && imageId.test(environment.databaseImageId ?? "") && typeof environment.databaseImageRef === "string" && dockerName.test(environment.network ?? "") && containerId.test(environment.networkId ?? "");
42
+ }
43
+ function validReceipt(receipt) {
44
+ const dockerCommand = ["docker-bundle-rspec", "rails-app-update"].includes(receipt?.commandId);
45
+ const environmentValid = receipt?.receiptVersion === 1 ? true : receipt?.environment === undefined ? !dockerCommand : validDockerEnvironment(receipt.environment);
46
+ return receipt && [1, 2].includes(receipt.receiptVersion) && environmentValid && typeof receipt.id === "string" && /^[a-f0-9-]{36}$/i.test(receipt.id) && ["test", "rails_app_update"].includes(receipt.kind) && typeof receipt.commandId === "string" && Array.isArray(receipt.argv) && !Number.isNaN(Date.parse(receipt.startedAt ?? "")) && !Number.isNaN(Date.parse(receipt.finishedAt ?? "")) && typeof receipt.durationMs === "number" && (typeof receipt.exitCode === "number" || receipt.exitCode === null) && typeof receipt.timedOut === "boolean" && /^[a-f0-9]{64}$/i.test(receipt.output?.redactedSha256 ?? "") && Number.isSafeInteger(receipt.output?.bytes) && receipt.output.bytes >= 0 && receipt.output.summary === undefined && validFingerprint(receipt.worktree);
47
+ }
38
48
  function receiptErrors(iteration, index, rails, required, errors) {
39
49
  const receipts = iteration?.validationReceipts;
40
50
  if (!required && receipts === undefined) return;
@@ -3,13 +3,22 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { RunStateError, readRun } from "./run-state.js";
6
+ import { NAME, databases, detectDatabase, resolveDatabase } from "./databases.js";
6
7
 
7
8
  const RUNTIME_FILE = "runtime.json";
8
9
  const LABEL = "io.opencode-ruby-upgrader.run-id";
10
+ const WORKTREE_LABEL = "io.opencode-ruby-upgrader.worktree-sha256";
9
11
  const rubyVersion = /^\d+\.\d+\.\d+$/;
10
12
  const NODE_INSTALL = "set -eu; . /etc/os-release; codename=${VERSION_CODENAME:-stretch}; printf '%s\\n' \"deb http://deb.debian.org/debian ${codename} main\" > /etc/apt/sources.list; printf '%s\\n' 'Acquire::Check-Valid-Until \"false\";' > /etc/apt/apt.conf.d/99archive; if ! apt-get update; then printf '%s\\n' \"deb http://archive.debian.org/debian ${codename} main\" > /etc/apt/sources.list; apt-get update; fi; apt-get install -y --no-install-recommends nodejs; if [ ! -x /usr/bin/node ]; then ln -sf /usr/bin/nodejs /usr/local/bin/node; fi; node --version";
11
13
 
12
14
  function canonicalRoot(root) { return fs.realpathSync(root); }
15
+ function worktreeHash(root) { return crypto.createHash("sha256").update(root).digest("hex"); }
16
+ // Ownership is asserted against a freshly computed hash of the canonical root
17
+ // rather than a hash persisted in runtime.json. The Docker resources carry the
18
+ // label, so binding to the live containers is both stronger (a copied manifest
19
+ // cannot vouch for a foreign worktree) and keeps a dictionary-attackable
20
+ // fingerprint of the user's filesystem path out of a file meant to be committed.
21
+ function identity(runId, canonical) { return { runId, worktreeHash: worktreeHash(canonical) }; }
13
22
  function upgradesDirectory(root, create = false) {
14
23
  const directory = path.join(canonicalRoot(root), ".ruby-upgrades");
15
24
  if (!fs.existsSync(directory) && create) fs.mkdirSync(directory, { mode: 0o700 });
@@ -32,25 +41,17 @@ function docker(spawn, args) {
32
41
  if (result.error) throw new Error(`Docker command failed: ${result.error.message}`);
33
42
  return result;
34
43
  }
35
- function waitForPostgres(spawn, runtime) {
36
- const sleeper = new Int32Array(new SharedArrayBuffer(4));
37
- for (let attempt = 0; attempt < 30; attempt += 1) {
38
- if (docker(spawn, ["exec", runtime.postgresContainer, "pg_isready", "-U", "postgres", "-d", "ruby_upgrade_test"]).status === 0) return;
39
- Atomics.wait(sleeper, 0, 0, 1000);
40
- }
41
- throw new Error("Target PostgreSQL did not become ready within 30 seconds. Rerun prepare-target-runtime --ruby <x.y.z>.");
42
- }
43
44
  function preparationResult(result) {
44
45
  const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
45
46
  return { sha256: crypto.createHash("sha256").update(output).digest("hex"), bytes: Buffer.byteLength(output) };
46
47
  }
47
- function bootstrap(spawn, runtime, rails) {
48
- const labels = ["Node.js setup", "Bundler installation", "dependency installation", ...(rails ? ["Rails test database initialization"] : []), "Ruby version attestation"];
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
50
  const commands = [
50
51
  ["exec", runtime.appContainer, "sh", "-c", NODE_INSTALL],
51
52
  ["exec", runtime.appContainer, "gem", "install", "bundler", "-v", "2.4.22", "--no-document"],
52
53
  ["exec", runtime.appContainer, "bundle", "_2.4.22_", "install"],
53
- ...(rails ? [["exec", runtime.appContainer, "bundle", "exec", "rake", "db:create"]] : []),
54
+ ...(rails ? [db.createArgs({ appContainer: runtime.appContainer, databaseContainer: runtime.databaseContainer })] : []),
54
55
  ["exec", runtime.appContainer, "ruby", "--version"]
55
56
  ];
56
57
  const results = commands.map((args, index) => {
@@ -75,8 +76,46 @@ function railsProject(root) {
75
76
  function inspect(spawn, name, message) {
76
77
  const result = docker(spawn, ["inspect", name]);
77
78
  if (result.status !== 0) throw new Error(message);
79
+ return parseInspection(result);
80
+ }
81
+ function inspectNetwork(spawn, name, message) {
82
+ const result = docker(spawn, ["network", "inspect", name]);
83
+ if (result.status !== 0) throw new Error(message);
84
+ return parseInspection(result);
85
+ }
86
+ function parseInspection(result) {
78
87
  try { const parsed = JSON.parse(result.stdout); return parsed[0]; } catch { throw new Error("Docker inspection returned invalid JSON."); }
79
88
  }
89
+ function environment(container) {
90
+ return Object.fromEntries((container?.Config?.Env ?? []).map((entry) => { const index = entry.indexOf("="); return [entry.slice(0, index), entry.slice(index + 1)]; }));
91
+ }
92
+ function labels(resource) { return resource?.Config?.Labels ?? resource?.Labels ?? {}; }
93
+ function hasOwnership(resource, identity) {
94
+ const owned = labels(resource);
95
+ return owned[LABEL] === identity.runId && owned[WORKTREE_LABEL] === identity.worktreeHash;
96
+ }
97
+ function mountedFromRoot(container, root) {
98
+ return container?.Mounts?.some((mount) => mount.Type === "bind" && mount.Source === root && mount.Destination === "/app");
99
+ }
100
+ function onlyNetwork(container, network) {
101
+ const networks = Object.keys(container?.NetworkSettings?.Networks ?? {});
102
+ return networks.length === 1 && networks[0] === network;
103
+ }
104
+ function isolatedContainer(container) {
105
+ return container?.HostConfig?.Privileged !== true && Object.keys(container?.HostConfig?.PortBindings ?? {}).length === 0;
106
+ }
107
+ function appMatches(container, root, runtime, db) {
108
+ const env = environment(container);
109
+ const mounts = container?.Mounts ?? [];
110
+ const command = container?.Config?.Cmd ?? [];
111
+ return container?.State?.Running && container?.Config?.Image === `ruby:${runtime.ruby}` && container?.Config?.WorkingDir === "/app" && mounts.length === 1 && mountedFromRoot(container, root) && command.length === 2 && command[0] === "sleep" && command[1] === "infinity" && onlyNetwork(container, runtime.network) && isolatedContainer(container) && env.RAILS_ENV === "test" && env.DATABASE_URL === db.databaseUrl(runtime.databaseContainer);
112
+ }
113
+ function databaseMatches(container, runtime, db) {
114
+ const env = environment(container);
115
+ const expectedEnvironment = Object.entries(db.requiredEnvironment).every(([key, value]) => env[key] === value);
116
+ const hasBindMount = (container?.Mounts ?? []).some((mount) => mount.Type === "bind");
117
+ return container?.State?.Running && container?.Config?.Image === db.image && onlyNetwork(container, runtime.network) && isolatedContainer(container) && !hasBindMount && expectedEnvironment;
118
+ }
80
119
  function activeRun(root) {
81
120
  const directory = path.join(upgradesDirectory(root), "runs");
82
121
  if (!fs.existsSync(directory)) throw new RunStateError("No active or paused upgrade run exists. Start or resume a run first.", "no-active-run");
@@ -94,9 +133,9 @@ function selectedRun(root, reportPath) {
94
133
  if (!["in_progress", "paused"].includes(run.status)) throw new RunStateError("Target runtime preparation requires an active or paused upgrade run.", "run-not-resumable");
95
134
  return { reportPath, run };
96
135
  }
97
- function names(runId) {
136
+ function names(runId, db) {
98
137
  const prefix = `ruby-upgrader-${runId}`;
99
- return { appContainer: `${prefix}-app`, postgresContainer: `${prefix}-postgres`, network: `${prefix}-network` };
138
+ return { appContainer: `${prefix}-app`, databaseContainer: `${prefix}-${db.adapter}`, network: `${prefix}-network` };
100
139
  }
101
140
  function writeRuntime(root, runtime) {
102
141
  const file = runtimePath(root, true);
@@ -106,44 +145,92 @@ function writeRuntime(root, runtime) {
106
145
  }
107
146
 
108
147
  export function readTargetRuntime(root = process.cwd()) {
109
- const file = runtimePath(root);
148
+ const canonical = canonicalRoot(root);
149
+ const file = runtimePath(canonical);
110
150
  if (!fs.existsSync(file)) return undefined;
111
151
  try {
112
- const runtime = JSON.parse(fs.readFileSync(file, "utf8"));
113
- if (runtime?.version !== 2 || typeof runtime.runId !== "string" || !rubyVersion.test(runtime.ruby ?? "") || !/^sha256:[a-f0-9]{64}$/i.test(runtime.resolvedImageId ?? "") || !runtime.preparation || ![runtime.appContainer, runtime.postgresContainer, runtime.network].every((name) => /^[a-z0-9][a-z0-9-]{0,127}$/.test(name ?? ""))) throw new Error();
152
+ let runtime = JSON.parse(fs.readFileSync(file, "utf8"));
153
+ if (runtime?.version === 2 && runtime.database === undefined && runtime.databaseContainer === undefined && NAME.test(runtime.postgresContainer ?? "")) {
154
+ const { postgresContainer, ...legacy } = runtime;
155
+ runtime = { ...legacy, database: "postgres", databaseContainer: postgresContainer };
156
+ }
157
+ if (runtime?.version !== 2 || typeof runtime.runId !== "string" || !rubyVersion.test(runtime.ruby ?? "") || !/^sha256:[a-f0-9]{64}$/i.test(runtime.resolvedImageId ?? "") || (runtime.databaseImageId !== undefined && !/^sha256:[a-f0-9]{64}$/i.test(runtime.databaseImageId)) || !runtime.preparation || !Object.hasOwn(databases, runtime.database) || ![runtime.appContainer, runtime.databaseContainer, runtime.network].every((name) => NAME.test(name ?? ""))) throw new Error();
114
158
  return runtime;
115
159
  } catch { throw new Error("Target runtime metadata is invalid. Rerun prepare-target-runtime --ruby <x.y.z>."); }
116
160
  }
117
161
 
118
- export function prepareTargetRuntime({ root = process.cwd(), reportPath, ruby, spawn = spawnSync }) {
162
+ export function prepareTargetRuntime({ root = process.cwd(), reportPath, ruby, database, spawn = spawnSync }) {
119
163
  if (!rubyVersion.test(ruby ?? "")) throw new Error("--ruby must be an exact numeric Ruby version such as 3.4.1.");
120
164
  const canonical = canonicalRoot(root);
165
+ const db = resolveDatabase(database ?? detectDatabase(canonical));
121
166
  const selected = selectedRun(canonical, reportPath);
122
- const runtime = { version: 2, runId: selected.run.runId, reportPath: selected.reportPath, ruby, ...names(selected.run.runId) };
167
+ const runtime = { version: 2, runId: selected.run.runId, reportPath: selected.reportPath, ruby, database: db.adapter, ...names(selected.run.runId, db) };
168
+ const owner = identity(runtime.runId, canonical);
123
169
  const existing = readTargetRuntime(canonical);
124
170
  if (existing && existing.runId !== runtime.runId) {
125
171
  const prior = readRun(canonical, existing.reportPath);
126
172
  if (!["blocked", "complete"].includes(prior.status)) throw new Error("Target runtime metadata belongs to another resumable run. Pause and resolve that run before preparing this one.");
127
173
  }
128
174
 
129
- const network = docker(spawn, ["network", "inspect", runtime.network]);
130
- if (network.status !== 0 && docker(spawn, ["network", "create", "--label", `${LABEL}=${selected.run.runId}`, runtime.network]).status !== 0) throw new Error("Could not create the isolated target-runtime Docker network.");
131
- const postgres = docker(spawn, ["inspect", runtime.postgresContainer]);
132
- if (postgres.status !== 0 && docker(spawn, ["run", "--detach", "--name", runtime.postgresContainer, "--label", `${LABEL}=${selected.run.runId}`, "--network", runtime.network, "--env", "POSTGRES_HOST_AUTH_METHOD=trust", "--env", "POSTGRES_DB=ruby_upgrade_test", "postgres:16-alpine"]).status !== 0) throw new Error("Could not start the isolated target-runtime PostgreSQL container.");
133
- waitForPostgres(spawn, runtime);
175
+ const databaseNames = [...new Set([...Object.values(databases).map((candidate) => names(runtime.runId, candidate).databaseContainer), existing?.databaseContainer].filter(Boolean))];
134
176
  let app = docker(spawn, ["inspect", runtime.appContainer]);
135
- if (app.status === 0 && existing && existing.ruby !== ruby) {
136
- let container;
137
- try { [container] = JSON.parse(app.stdout); } catch { throw new Error("Docker validation container inspection returned invalid JSON."); }
138
- if (container?.Config?.Labels?.[LABEL] !== selected.run.runId) throw new Error("Refusing to replace an app container not labelled for this run.");
139
- if (docker(spawn, ["rm", "--force", runtime.appContainer]).status !== 0) throw new Error("Could not replace the prior target Ruby app container.");
140
- app = docker(spawn, ["inspect", runtime.appContainer]);
177
+ let network = docker(spawn, ["network", "inspect", runtime.network]);
178
+ const databaseInspections = new Map(databaseNames.map((name) => [name, docker(spawn, ["inspect", name])]));
179
+ const resources = [app.status === 0 && parseInspection(app), network.status === 0 && parseInspection(network), ...[...databaseInspections.values()].filter((result) => result.status === 0).map(parseInspection)].filter(Boolean);
180
+ for (const resource of resources) {
181
+ const resourceLabels = labels(resource);
182
+ if (resourceLabels[LABEL] !== owner.runId || (resourceLabels[WORKTREE_LABEL] !== undefined && resourceLabels[WORKTREE_LABEL] !== owner.worktreeHash)) throw new Error("Refusing to use or replace a Docker resource not owned by this run and worktree.");
183
+ }
184
+ const legacy = resources.some((resource) => labels(resource)[WORKTREE_LABEL] === undefined);
185
+ if (legacy && resources.length) {
186
+ if (app.status !== 0 || !mountedFromRoot(parseInspection(app), canonical)) throw new Error("Refusing to migrate legacy Docker resources because ownership by this worktree cannot be established.");
187
+ for (const [name, result] of [[runtime.appContainer, app], ...databaseInspections]) {
188
+ if (result.status === 0 && docker(spawn, ["rm", "--force", name]).status !== 0) throw new Error("Could not remove a legacy target-runtime container.");
189
+ }
190
+ if (network.status !== 0 && docker(spawn, ["network", "rm", runtime.network]).status !== 0) throw new Error("Could not remove the legacy target-runtime Docker network.");
191
+ app = { status: 1 };
192
+ network = { status: 1 };
193
+ for (const name of databaseNames) databaseInspections.set(name, { status: 1 });
194
+ } else {
195
+ for (const resource of resources) {
196
+ if (!hasOwnership(resource, owner)) throw new Error("Refusing to use or replace a Docker resource without both ownership labels.");
197
+ }
198
+ }
199
+ const ownershipArgs = ["--label", `${LABEL}=${owner.runId}`, "--label", `${WORKTREE_LABEL}=${owner.worktreeHash}`];
200
+ if (network.status !== 0 && docker(spawn, ["network", "create", ...ownershipArgs, runtime.network]).status !== 0) throw new Error("Could not create the isolated target-runtime Docker network.");
201
+ for (const [name, result] of databaseInspections) {
202
+ if (name !== runtime.databaseContainer && result.status === 0 && docker(spawn, ["rm", "--force", name]).status !== 0) throw new Error("Could not remove the prior target-runtime database container.");
203
+ }
204
+ let databaseContainer = databaseInspections.get(runtime.databaseContainer) ?? { status: 1 };
205
+ if (databaseContainer.status === 0) {
206
+ const container = parseInspection(databaseContainer);
207
+ if (!databaseMatches(container, runtime, db)) {
208
+ if (docker(spawn, ["rm", "--force", runtime.databaseContainer]).status !== 0) throw new Error(`Could not replace the prior target-runtime ${db.label} container.`);
209
+ databaseContainer = docker(spawn, ["inspect", runtime.databaseContainer]);
210
+ }
211
+ }
212
+ if (databaseContainer.status !== 0 && docker(spawn, ["run", "--detach", "--name", runtime.databaseContainer, ...ownershipArgs, "--network", runtime.network, ...db.startArgs()]).status !== 0) throw new Error(`Could not start the isolated target-runtime ${db.label} container.`);
213
+ try {
214
+ db.ensureReady({ probe: () => docker(spawn, db.readyArgs(runtime.databaseContainer)) });
215
+ } catch (error) {
216
+ try { docker(spawn, ["rm", "--force", runtime.databaseContainer]); } catch {}
217
+ throw error;
218
+ }
219
+ if (app.status === 0) {
220
+ const container = parseInspection(app);
221
+ if (!appMatches(container, canonical, runtime, db)) {
222
+ if (docker(spawn, ["rm", "--force", runtime.appContainer]).status !== 0) throw new Error("Could not replace the prior target Ruby app container.");
223
+ app = docker(spawn, ["inspect", runtime.appContainer]);
224
+ }
141
225
  }
142
- if (app.status !== 0 && docker(spawn, ["run", "--detach", "--name", runtime.appContainer, "--label", `${LABEL}=${selected.run.runId}`, "--network", runtime.network, "--mount", `type=bind,src=${canonical},dst=/app`, "--workdir", "/app", "--env", "RAILS_ENV=test", "--env", `DATABASE_URL=postgresql://postgres@${runtime.postgresContainer}:5432/ruby_upgrade_test`, `ruby:${ruby}`, "sleep", "infinity"]).status !== 0) throw new Error("Could not start the target Ruby app container.");
143
- const prepared = bootstrap(spawn, runtime, railsProject(canonical));
226
+ if (app.status !== 0 && docker(spawn, ["run", "--detach", "--name", runtime.appContainer, ...ownershipArgs, "--network", runtime.network, "--mount", `type=bind,src=${canonical},dst=/app`, "--workdir", "/app", "--env", "RAILS_ENV=test", "--env", `DATABASE_URL=${db.databaseUrl(runtime.databaseContainer)}`, `ruby:${ruby}`, "sleep", "infinity"]).status !== 0) throw new Error("Could not start the target Ruby app container.");
227
+ const prepared = bootstrap(spawn, runtime, db, railsProject(canonical));
144
228
  const appInspection = inspect(spawn, runtime.appContainer, "Target Ruby app container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.");
229
+ const databaseInspection = inspect(spawn, runtime.databaseContainer, `Target ${db.label} container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.`);
145
230
  if (!/^sha256:[a-f0-9]{64}$/i.test(appInspection?.Image ?? "")) throw new Error("Target Ruby image ID is unavailable.");
231
+ if (!/^sha256:[a-f0-9]{64}$/i.test(databaseInspection?.Image ?? "")) throw new Error(`Target ${db.label} image ID is unavailable.`);
146
232
  runtime.resolvedImageId = appInspection.Image;
233
+ runtime.databaseImageId = databaseInspection.Image;
147
234
  runtime.preparation = prepared;
148
235
  validateTargetRuntime({ root: canonical, runtime, spawn });
149
236
  writeRuntime(canonical, runtime);
@@ -153,13 +240,15 @@ export function prepareTargetRuntime({ root = process.cwd(), reportPath, ruby, s
153
240
  export function validateTargetRuntime({ root = process.cwd(), runtime = readTargetRuntime(root), spawn = spawnSync }) {
154
241
  if (!runtime) return undefined;
155
242
  const canonical = canonicalRoot(root);
243
+ const owner = identity(runtime.runId, canonical);
244
+ const db = resolveDatabase(runtime.database);
156
245
  const app = inspect(spawn, runtime.appContainer, "Target Ruby app container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.");
157
- const postgres = inspect(spawn, runtime.postgresContainer, "Target PostgreSQL container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.");
158
- const env = Object.fromEntries((app?.Config?.Env ?? []).map((entry) => { const index = entry.indexOf("="); return [entry.slice(0, index), entry.slice(index + 1)]; }));
159
- const mountedRoot = app?.Mounts?.some((mount) => mount.Type === "bind" && mount.Source === canonical && mount.Destination === "/app");
160
- const appNetwork = app?.NetworkSettings?.Networks?.[runtime.network];
161
- const postgresNetwork = postgres?.NetworkSettings?.Networks?.[runtime.network];
162
- const valid = app?.State?.Running && postgres?.State?.Running && app?.Config?.Labels?.[LABEL] === runtime.runId && postgres?.Config?.Labels?.[LABEL] === runtime.runId && app?.Config?.Image === `ruby:${runtime.ruby}` && app?.Image === runtime.resolvedImageId && app?.Config?.WorkingDir === "/app" && mountedRoot && appNetwork && postgresNetwork && env.RAILS_ENV === "test" && env.DATABASE_URL === `postgresql://postgres@${runtime.postgresContainer}:5432/ruby_upgrade_test`;
246
+ const database = inspect(spawn, runtime.databaseContainer, `Target ${db.label} container is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.`);
247
+ const network = inspectNetwork(spawn, runtime.network, "Target runtime Docker network is unavailable. Rerun prepare-target-runtime --ruby <x.y.z>.");
248
+ const env = environment(app);
249
+ const connectedIds = Object.keys(network?.Containers ?? {}).sort();
250
+ const expectedIds = [app?.Id, database?.Id].sort();
251
+ 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);
163
252
  if (!valid) throw new Error("Target runtime no longer matches its prepared run. Rerun prepare-target-runtime --ruby <x.y.z>.");
164
- return { name: runtime.appContainer, id: app.Id, imageId: app.Image, imageRef: app.Config.Image, ruby: runtime.ruby };
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 };
165
254
  }
@@ -23,23 +23,24 @@ const commands = Object.freeze({
23
23
  "rails-app-update": { argv: ["bundle", "exec", "ruby", "-e", railsAppUpdateSkipExisting], kind: "rails_app_update", expected: "bin/rails app:update", docker: true }
24
24
  });
25
25
 
26
- function dockerContainer(root, spawn) {
26
+ function dockerContainer(root, spawn, expectedRuntime) {
27
27
  const runtime = readTargetRuntime(root);
28
28
  if (!runtime) throw new Error("Prepare the isolated target runtime with prepare-target-runtime --ruby <x.y.z> before Docker validation.");
29
+ if (!expectedRuntime || runtime.runId !== expectedRuntime.runId || runtime.reportPath !== expectedRuntime.reportPath) throw new Error("The prepared target runtime does not belong to the selected upgrade report. Rerun prepare-target-runtime for this report before Docker validation.");
29
30
  return validateTargetRuntime({ root, runtime, spawn });
30
31
  }
31
32
 
32
- export function executeValidation({ root = process.cwd(), inventory, commandId, timeoutMs = 600000, spawn = spawnSync }) {
33
+ export function executeValidation({ root = process.cwd(), inventory, commandId, expectedRuntime, timeoutMs = 600000, spawn = spawnSync }) {
33
34
  const definition = commands[commandId];
34
35
  if (!definition) throw new Error("Unknown validation command ID.");
35
36
  if (definition.kind === "rails_app_update" ? inventory.framework !== "rails" : !inventory.recommendedCommands.includes(definition.expected)) throw new Error("Validation command is not supported by this project's detected adapter.");
36
- const environment = definition.docker ? { type: "docker", ...dockerContainer(root, spawn) } : undefined;
37
+ const environment = definition.docker ? { type: "docker", ...dockerContainer(root, spawn, expectedRuntime) } : undefined;
37
38
  const argv = definition.docker ? ["docker", "exec", "--env", "DATABASE_CLEANER_ALLOW_REMOTE_DATABASE_URL=true", environment.name, ...definition.argv] : definition.argv;
38
39
  const startedAt = new Date().toISOString(); const started = Date.now();
39
40
  const result = spawn(argv[0], argv.slice(1), { cwd: root, shell: false, encoding: "utf8", timeout: timeoutMs, maxBuffer: 256 * 1024 });
40
41
  const output = redact(`${result.stdout ?? ""}${result.stderr ?? ""}`).slice(0, 8192);
41
42
  const durationMs = Date.now() - started; const timedOut = result.error?.code === "ETIMEDOUT";
42
- const receipt = { receiptVersion: 1, id: crypto.randomUUID(), kind: definition.kind, commandId, argv, ...(environment ? { environment } : {}), startedAt, finishedAt: new Date().toISOString(), durationMs, exitCode: typeof result.status === "number" ? result.status : null, timedOut, output: { redactedSha256: crypto.createHash("sha256").update(output).digest("hex"), bytes: Buffer.byteLength(output) }, worktree: worktreeFingerprint(root) };
43
+ const receipt = { receiptVersion: 2, id: crypto.randomUUID(), kind: definition.kind, commandId, argv, ...(environment ? { environment } : {}), startedAt, finishedAt: new Date().toISOString(), durationMs, exitCode: typeof result.status === "number" ? result.status : null, timedOut, output: { redactedSha256: crypto.createHash("sha256").update(output).digest("hex"), bytes: Buffer.byteLength(output) }, worktree: worktreeFingerprint(root) };
43
44
  if (definition.kind === "test") receipt.testEvidence = parseTestEvidence(output, definition.expected, durationMs / 1000);
44
45
  return receipt;
45
46
  }