opencode-ruby-upgrader 0.1.5 → 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
@@ -1,6 +1,55 @@
1
1
  # opencode-ruby-upgrader
2
2
 
3
- An evidence-driven Ruby and Rails migration agent for [OpenCode](https://opencode.ai). It upgrades a project one Ruby minor series at a time toward a researched latest-stable or explicitly pinned Ruby target, researches compatibility guidance, updates affected code and dependencies, and leaves a reviewable migration trail.
3
+ An evidence-driven Ruby and Rails migration agent for [OpenCode](https://opencode.ai). It upgrades a project one Ruby minor series at a time toward a researched latest-stable or explicitly pinned Ruby target, researches compatibility guidance, updates affected code and dependencies, and leaves a reviewable migration trail. It ships as an OpenCode plugin: register it in your OpenCode config, then drive it with `/ruby-upgrade`.
4
+
5
+ ## Install
6
+
7
+ ### 1. Install OpenCode
8
+
9
+ This package is a plugin for [OpenCode](https://opencode.ai), an open-source AI coding agent that runs in your terminal. If you don't have it yet:
10
+
11
+ ```bash
12
+ curl -fsSL https://opencode.ai/install | bash
13
+ # or: npm install -g opencode-ai
14
+ # or: brew install anomalyco/tap/opencode
15
+ ```
16
+
17
+ You'll also need an API key for at least one LLM provider — run `/connect` inside OpenCode to sign in or paste a key. The [OpenCode intro](https://opencode.ai/docs/) covers the full first-run setup.
18
+
19
+ ### 2. Register the plugin
20
+
21
+ Add it to your OpenCode config, either the project config `opencode.json` (or `opencode.jsonc`) beside your project, or the global `~/.config/opencode/opencode.json`:
22
+
23
+ ```json
24
+ {
25
+ "$schema": "https://opencode.ai/config.json",
26
+ "plugin": ["opencode-ruby-upgrader"]
27
+ }
28
+ ```
29
+
30
+ Register it **per project** if you want the agent available only where you're upgrading; register it globally to use it across repositories. A project-scoped config is the tighter default here, because the upgrader additionally refuses to run outside a linked Git worktree you created yourself (see [Safety model](#safety-model)).
31
+
32
+ While developing a local checkout, point at the directory instead:
33
+
34
+ ```json
35
+ { "plugin": ["file:///absolute/path/to/opencode-ruby-upgrader"] }
36
+ ```
37
+
38
+ OpenCode installs npm plugins automatically at startup and caches them in `~/.cache/opencode/node_modules/`, so there is no separate install step.
39
+
40
+ ### 3. Restart and confirm
41
+
42
+ Restart OpenCode, then run `opencode` in the worktree you want to upgrade and type `/ruby-upgrade` — or `@ruby-upgrade` to select the agent directly.
43
+
44
+ ## Requirements
45
+
46
+ OpenCode with plugin support, Node `>=22.5.0`, and Git `>=2.5` for the linked-worktree safety model. Docker is optional and only needed when your host cannot run the Ruby version being tested (see [Security boundaries](#security-boundaries)).
47
+
48
+ The automatic validation adapters currently recognize Bundler projects using Rails, RSpec, or Minitest. Other stacks still receive a full inventory and research, but need a user-supplied validation command.
49
+
50
+ The package also installs an `opencode-ruby-upgrader` command. Most of its subcommands are driven by the agent itself; the two you run directly are `dashboard` and `resume`.
51
+
52
+ npm `>=11` is recommended for contributors and for publishing: npm 11 added the install-script approval gate this repository's release path relies on. It is not required to install or run the package, which ships no install scripts and no runtime dependencies.
4
53
 
5
54
  ## Quick start
6
55
 
@@ -22,8 +71,6 @@ opencode
22
71
 
23
72
  Then run `/ruby-upgrade` (or add `--dry-run` to get a no-write assessment first). The agent inventories the project, researches an official-source compatibility ladder, and proposes each validated hop as a local checkpoint commit for your review. See [Safety model](#safety-model) for what it will and will not do automatically.
24
73
 
25
- > Requires Git 2.5+ (linked-worktree safety model) and Docker for the isolated validation container when your host cannot run the Ruby version being tested (see [Security boundaries](#security-boundaries)).
26
-
27
74
  | Step | Who does it | Result |
28
75
  |---|---|---|
29
76
  | Set up worktree + config | You | Linked worktree, agent-ready |
@@ -33,18 +80,30 @@ Then run `/ruby-upgrade` (or add `--dry-run` to get a no-write assessment first)
33
80
  | Repeat | Loop | One minor series per hop |
34
81
  | Review & push | **You** | `git log`/dashboard then push the validated branch |
35
82
 
36
- ## Proof of work
83
+ ## Before you run it
84
+
85
+ Three flags bound how much a single run can change.
86
+
87
+ Use `/ruby-upgrade --dry-run` for a no-write inventory and proposed migration assessment; it creates no report, lock, checkpoint, or durable research evidence. Use `/ruby-upgrade --target <version>` (for example `/ruby-upgrade --target 3.4`) to pin an explicit final Ruby version, or `/ruby-upgrade --stop-after-hop` to validate and commit one hop before stopping.
88
+
89
+ ## Validated end-to-end run
37
90
 
38
91
  The agent has completed a real end-to-end migration against a public fixture: [`ruby2-rails4-bootstrap-heroku`](https://github.com/lilla021/ruby2-rails4-bootstrap-heroku) (BSD-2-Clause) moved from **Ruby 2.4.10 / Rails 4.2.11.3** to **Ruby 3.4.10 / Rails 7.1.6** across 15 receipt-backed hops. Every hop was validated by `bundle exec rspec` in an isolated Docker container, then committed as a local checkpoint before the next hop began.
39
92
 
40
93
  - [Upgrade pull request](https://github.com/lilla021/ruby2-rails4-bootstrap-heroku/pull/1) — the full migration: 19 commits, one per reviewed step, with lint and spec checks currently passing on GitHub Actions.
41
- - [Evidence ledger](E2E_EVIDENCE.md) — every hop's validation receipt, commit SHA, and the fixes the migration required.
94
+ - [Evidence ledger](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.6/E2E_EVIDENCE.md) — every hop's validation receipt, commit SHA, and the fixes the migration required.
42
95
 
43
- This proves the workflow works on a genuinely old, real-world Rails stack. It does not claim every upgrade is safe — see [Product limits](#product-limits).
96
+ This proves the workflow works on a genuinely old, real-world Rails stack. It does not claim every upgrade is safe — see [Safety model](#safety-model) for what the agent refuses to do without you, and [Product limits](#product-limits) for what it cannot prove.
44
97
 
45
98
  ## Safety model
46
99
 
47
- The agent runs durable migrations **only** from a linked Git worktree created by the user. Before starting, explicitly configure the repository default branch with `git config opencode-ruby-upgrader.defaultBranch main` (replace `main` as needed) — see [Quick start](#quick-start) for the three setup commands, which the agent also shows verbatim if you invoke it from a primary checkout. The upgrader fails closed if this configuration is absent and never guesses `main`, `master`, or a remote default. This keeps your normal checkout free for other work. Non-Git projects support dry-run inventory only. The agent never creates, switches, deletes, merges, pushes, or reconfigures branches/remotes. It also never publishes, deploys, or runs destructive database commands.
100
+ The agent runs durable migrations **only** from a linked Git worktree created by the user. Before starting, explicitly configure the repository default branch with `git config opencode-ruby-upgrader.defaultBranch main` (replace `main` as needed) — see [Quick start](#quick-start) for the three setup commands, which the agent also shows verbatim if you invoke it from a primary checkout. The upgrader fails closed if this configuration is absent and never guesses `main`, `master`, or a remote default. This keeps your normal checkout free for other work. Non-Git projects support dry-run inventory only.
101
+
102
+ The agent never:
103
+
104
+ - creates, switches, deletes, merges, pushes, or reconfigures branches or remotes
105
+ - publishes or deploys anything
106
+ - runs destructive database commands
48
107
 
49
108
  After every routine Ruby minor-version hop with passing validation, the agent proposes a **local** checkpoint commit through a guarded commit gate and OpenCode asks for confirmation. The gate verifies the linked worktree and non-default branch, exact expected Git history, an empty initial staging area, a complete passing report iteration, and scans staged content for likely credentials. It cannot push, fetch, alter remotes, switch branches, merge, rebase, reset, or amend history. You can review and push any validated checkpoint; a run becomes `complete` only once it reaches its pinned target.
50
109
 
@@ -52,23 +111,27 @@ The agent pauses—not guesses—when a migration involves data changes, authent
52
111
 
53
112
  Every hop declares its expected changed files before commit. The commit gate blocks undeclared changes, credential-like material, executable Git hooks, non-RubyGems dependency sources, and large lockfile churn unless the user has explicitly reviewed and permitted that specific concern. Any changed lockfile also requires a recorded compatibility and license review. The target Ruby version is pinned with the research timestamp at run start, so a new upstream release cannot silently change the target mid-run. If Git author or commit-signing configuration prevents a commit, the agent reports the exact local setup issue and stops; it never changes Git configuration for you.
54
113
 
55
- ## Install
114
+ ## Security boundaries
56
115
 
57
- During development:
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.
58
117
 
59
- ```json
60
- { "plugin": ["file:///absolute/path/to/opencode-ruby-upgrader"] }
61
- ```
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.
62
119
 
63
- After publishing:
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:
64
121
 
65
- ```json
66
- { "plugin": ["opencode-ruby-upgrader"] }
122
+ ```bash
123
+ opencode-ruby-upgrader prepare-target-runtime --ruby <x.y.z> --database mysql
67
124
  ```
68
125
 
69
- Restart OpenCode, then run `/ruby-upgrade` or select `@ruby-upgrade`.
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.
70
131
 
71
- Git 2.5 or newer is required for the linked-worktree safety model.
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.
133
+
134
+ This is tamper-evident provenance for a committed report, not protection against the same local user rewriting both evidence and Git history.
72
135
 
73
136
  ## Evidence and dashboard
74
137
 
@@ -76,11 +139,11 @@ Every run has a generated JSON record and Markdown companion under `.ruby-upgrad
76
139
 
77
140
  As an example, here is one such run rendered in the dashboard — per-hop summaries, test metrics, and citation links. The full process and per-hop detail live in the run reports themselves:
78
141
 
79
- ![Example of the local evidence dashboard](https://raw.githubusercontent.com/lilla021/opencode-ruby-upgrader/main/docs/dashboard.png)
142
+ ![Example of the local evidence dashboard](https://raw.githubusercontent.com/lilla021/opencode-ruby-upgrader/v0.1.6/docs/dashboard.png)
80
143
 
81
144
  The same reports open as a vault — each run is a Markdown note paired with its JSON record:
82
145
 
83
- ![Example vault view: run reports as paired Markdown and JSON notes](https://raw.githubusercontent.com/lilla021/opencode-ruby-upgrader/main/docs/vault.png)
146
+ ![Example vault view: run reports as paired Markdown and JSON notes](https://raw.githubusercontent.com/lilla021/opencode-ruby-upgrader/v0.1.6/docs/vault.png)
84
147
 
85
148
  Launch the local-only dashboard from the repository worktree:
86
149
 
@@ -92,9 +155,7 @@ It binds exclusively to `127.0.0.1` on an ephemeral port and remains in the fore
92
155
 
93
156
  The dashboard identifies local checkpoint commits from trailers embedded in those commits. Before the final push, inspect them locally with `git log`, `git show`, and the dashboard; after you push, the same individual commits are available for GitHub review.
94
157
 
95
- ## Controls and recovery
96
-
97
- Use `/ruby-upgrade --dry-run` for a no-write inventory and proposed migration assessment; it creates no report, lock, checkpoint, or durable research evidence. Use `/ruby-upgrade --target <version>` (for example `/ruby-upgrade --target 3.4`) to pin an explicit final Ruby version, or `/ruby-upgrade --stop-after-hop` to validate and commit one hop before stopping.
158
+ ## Recovery
98
159
 
99
160
  Each active run holds a local lock. To stop for review or manual work, transition it to `paused`; that releases the lock without marking the migration complete. Resume the existing report rather than starting a second migration:
100
161
 
@@ -102,26 +163,55 @@ Each active run holds a local lock. To stop for review or manual work, transitio
102
163
  opencode-ruby-upgrader resume --report .ruby-upgrades/runs/<run>.json
103
164
  ```
104
165
 
105
- `complete`, `blocked`, and `paused` runs release their lock. For other blockers, inspect the report and use the documented transition/resume path. To undo a completed hop, use the reviewable local history: `git revert <hop-sha>`. Do not use reset, rebase, or force-push as routine migration recovery.
166
+ `complete`, `blocked`, and `paused` runs release their lock. For other blockers, inspect the report and use the documented transition/resume path.
106
167
 
107
- If a resolved Rails version blocks the next Ruby hop, record the user-approved bridge, then transition the Ruby run to `blocked`. That Ruby report is terminal: complete the linked Rails lifecycle and start a fresh Ruby run. Every Rails iteration executes `bin/rails app:update` first, records its receipt, reviews that exact working-tree fingerprint, and only then runs final tests. Validate and checkpoint each one with `commit-rails-hop`.
168
+ If a resolved Rails version blocks the next Ruby hop, that Ruby run becomes terminal and a separate Rails-bridge run takes over. See the [Rails bridge lifecycle](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.6/docs/rails-bridge.md) reference.
108
169
 
109
- ## Security boundaries
170
+ To undo a completed hop, use the reviewable local history: `git revert <hop-sha>`. Do not use reset, rebase, or force-push as routine migration recovery.
110
171
 
111
- 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.
172
+ ## Troubleshooting
173
+
174
+ ### If `/ruby-upgrade` isn't offered
175
+
176
+ The plugin didn't load. Check that your config file still parses — a stray comma breaks the whole file, not just this plugin — and restart OpenCode.
177
+
178
+ ### If you see `EBADENGINE`
179
+
180
+ `EBADENGINE` is a warning, not a failure: npm still completes the install. It appears when your Node is older than the `>=22.5.0` floor in [Requirements](#requirements), which exists because Node 18 is end-of-life and this agent runs your project's dependency install and test commands.
181
+
182
+ To clear it, upgrade Node and reinstall:
183
+
184
+ ```bash
185
+ node -v # confirm >= 22.5.0
186
+ npm i opencode-ruby-upgrader
187
+ ```
188
+
189
+ Upgrading npm alone cannot fix it. Current npm releases require a recent Node themselves, so npm refuses to install over an older Node. If `engine-strict=true` is set in your npm config, the warning becomes a hard error and the install fails until Node is upgraded.
112
190
 
113
- 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.
191
+ ### Uninstalling
114
192
 
115
- 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. 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. This is tamper-evident provenance for a committed report, not protection against the same local user rewriting both evidence and Git history.
193
+ Remove `"opencode-ruby-upgrader"` from your config's `plugin` array and restart OpenCode. Your `.ruby-upgrades/` reports and any checkpoint commits are ordinary local files and Git history — nothing else to clean up.
116
194
 
117
195
  ## Product limits
118
196
 
119
- The upgrader automates evidence collection and compatibility-oriented edits; it cannot prove production behavior, security correctness, deployment safety, or semantic equivalence. It intentionally pauses instead of modifying database behavior, authorization, payments, secrets, and production configuration without a user decision. Supported automatic adapters currently recognize Bundler projects using Rails, RSpec, or Minitest; other stacks receive an inventory and require a user-supplied validation command.
197
+ The upgrader automates evidence collection and compatibility-oriented edits; it cannot prove production behavior, security correctness, deployment safety, or semantic equivalence. It intentionally pauses instead of modifying database behavior, authorization, payments, secrets, and production configuration without a user decision.
120
198
 
121
199
  The credential scanner is heuristic: it recognizes common token formats and quoted credential-like assignments, but may miss other forms such as arbitrary unquoted YAML values. Run reports are local mutable JSON evidence, so their integrity is bounded by the user and local filesystem permissions rather than a tamper-proof store. Credential-bearing source URLs are redacted from supply-chain evidence. Gemfile source detection is static and may not resolve dynamically computed sources; review those manually and explicitly approve private sources.
122
200
 
201
+ ## Privacy
202
+
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.
204
+
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).
206
+
207
+ ## Contributing
208
+
123
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.
124
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
+
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.
214
+
125
215
  ## Acknowledgments
126
216
 
127
217
  This agent was built with [opencode-craft](https://github.com/pauloralves/opencode-craft) (MIT, by [Paulo Alves](https://github.com/pauloralves)) — a senior pair-programming, craftsmanship, and knowledge-ledger skill pack for OpenCode. Its review cadence, evidence discipline, and interview-oriented trade-off notes shaped how this project is designed and presented. Thanks also to the community that produces the [official Ruby release](https://www.ruby-lang.org/en/downloads/releases/) and [Rails](https://guides.rubyonrails.org/) documentation this agent cites.
@@ -5,6 +5,10 @@ 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.
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.
8
12
  - [ ] Review the package metadata (name, description, keywords, `repository`, `bugs`, `homepage`) and the packed-file list with `npm pack --dry-run`.
9
13
  - [ ] Run `npm test`; confirm the release workflow's runtime smoke gate passes with the pinned OpenCode runtime.
10
14
  - [ ] Confirm no credentials, personal data, or local paths appear in the packed files, README, SECURITY.md, PRIVACY.md, or release notes.
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,61 @@
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
+
26
+ ## v0.1.6 — reader path and CI coverage
27
+
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.
29
+
30
+ **Scope**
31
+
32
+ The README was reordered into a reader-state funnel — install, run it small, does it work, what it refuses, what backs that, what you get, how to recover, what it cannot prove — so each section answers the question a reader has at that point:
33
+
34
+ - `Install` is now first and reads as three numbered steps. It previously arrived fifth, with nothing earlier stating that this package is an OpenCode plugin.
35
+ - `Security boundaries` now sits directly after `Safety model`. Intent and enforcement are halves of one argument and had been separated by two unrelated sections.
36
+ - The pre-flight flags split out of the recovery material into `Before you run it`, immediately after Quick start where `--dry-run` is first mentioned.
37
+ - A new `Requirements` section carries the Node/Git/Docker floors and moves the supported-adapter qualification from position ten to before a reader creates a worktree.
38
+ - A new `Troubleshooting` section collects the `EBADENGINE` warning, a missing `/ruby-upgrade` command, and uninstall.
39
+ - A new `Privacy` section gives the previously unlinked `PRIVACY.md` and `SECURITY.md` an entry point in the packaged README.
40
+ - `Contributing` is separated from `Product limits`, so maintainer test instructions no longer sit inside an end-user section.
41
+ - Install now covers the OpenCode prerequisite and config registration for readers new to OpenCode, verified against the current plugin documentation.
42
+ - `Proof of work` is renamed `Validated end-to-end run`.
43
+ - Long paragraphs in `Safety model` and `Security boundaries` are split, and the agent's prohibitions are now a list.
44
+ - The Rails-bridge lifecycle moves to a standalone [`docs/rails-bridge.md`](docs/rails-bridge.md) reference, so `Recovery` ends on the `git revert` guidance instead of burying it.
45
+ - README links to companion documents and both screenshots are now pinned to the release tag instead of resolving against `main`. The registry page previously showed whatever `main` happened to contain, which could drift ahead of the installed version. The release gate now repoints them each release.
46
+
47
+ **CI**
48
+
49
+ `ci.yml` runs the test suite, pack check, and pinned-runtime smoke test across Node 22, 24, and 26. CI previously exercised Node 22 only while `engines.node` declared `>=22.5.0`, leaving most of the advertised support range untested. Both workflows pin `npm@11.16.0` so the matrix varies Node rather than npm. Publishing remains a single Node 22 job in `release.yml`, since every matrix leg would attempt the same version.
50
+
51
+ **Supported adapters:** unchanged — Bundler projects using Rails, RSpec, or Minitest; other stacks receive an inventory and require a user-supplied validation command.
52
+
53
+ **Known limitations:** unchanged. The credential scanner remains heuristic and reports remain local mutable JSON, bounded by the user and local filesystem permissions. See Product limits in the README.
54
+
55
+ **Rollback:** `npm install opencode-ruby-upgrader@0.1.5`. The command surface and on-disk format are identical across this release, so no report conversion is required.
56
+
57
+ Published through the same protected-CI gate.
58
+
3
59
  ## v0.1.5 — registry visuals
4
60
 
5
61
  No functional change. The npm package page now renders its README images: the dashboard and vault-view screenshots moved from relative paths to absolute GitHub URLs, so they display on the registry in addition to GitHub.
@@ -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.5",
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
  }