opencode-ruby-upgrader 0.1.8 → 0.1.10
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 +2 -0
- package/README.md +56 -7
- package/RELEASE_NOTES.md +39 -0
- package/agents/ruby-upgrade.md +4 -2
- package/bin/opencode-ruby-upgrader.mjs +18 -2
- package/docs/bundler-bridge.md +107 -0
- package/package.json +31 -5
- package/src/advisory.js +140 -0
- package/src/bundler-compat.js +68 -0
- package/src/commit-hop.js +24 -13
- package/src/controller.js +83 -10
- package/src/dashboard-page.js +1 -1
- package/src/dashboard.js +9 -1
- package/src/inventory.js +5 -1
- package/src/run-state.js +91 -2
- package/src/services.js +129 -0
- package/src/target-runtime.js +82 -14
package/PRIVACY.md
CHANGED
|
@@ -8,4 +8,6 @@ Reports may be staged into local checkpoint commits. Review them before committi
|
|
|
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
10
|
|
|
11
|
+
The advisory infrastructure review reads version pins and workflow files inside the current worktree (`.ruby-version`, `.tool-versions`, `.mise.toml`, `Gemfile`, `Gemfile.lock`, `Dockerfile`, `.github/workflows/`), and the dashboard computes it locally on each request. It performs no network requests and stores nothing beyond the file and version values already present in those files. Because the findings are recomputed rather than recorded, they never enter a report or commit as durable evidence.
|
|
12
|
+
|
|
11
13
|
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
|
@@ -86,12 +86,18 @@ Three flags bound how much a single run can change.
|
|
|
86
86
|
|
|
87
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
88
|
|
|
89
|
+
### Local credentials and environment files
|
|
90
|
+
|
|
91
|
+
`git worktree add` only checks out files tracked by Git. Untracked files (such as `.env`, local credential files, `config/database.yml.local`, or project-specific test environment configuration) are **not** copied from your main worktree to the new linked worktree. If your specs or local test setup require environment variables or credentials to run, you must manually create or copy them into the new worktree (`../<repo>-ruby-<target>/`). The agent never creates, copies, or commits credentials or secrets.
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
89
95
|
## Validated end-to-end run
|
|
90
96
|
|
|
91
97
|
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.
|
|
92
98
|
|
|
93
99
|
- [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.
|
|
94
|
-
- [Evidence ledger](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.
|
|
100
|
+
- [Evidence ledger](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.9/E2E_EVIDENCE.md) — every hop's validation receipt, commit SHA, and the fixes the migration required.
|
|
95
101
|
|
|
96
102
|
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.
|
|
97
103
|
|
|
@@ -127,6 +133,18 @@ Both engines run credential-free on a per-run Docker network that carries two ow
|
|
|
127
133
|
|
|
128
134
|
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
135
|
|
|
136
|
+
### Additional services (Redis, etc.)
|
|
137
|
+
|
|
138
|
+
The isolated runtime provisions only what the project's tests declare. If the suite requires Redis (Sidekiq, Resque, `gem "redis"`, or references to `REDIS_URL`), the runtime automatically detects it from the project's own declarations and starts an isolated `redis:7-alpine` container on the same per-run network. Other services may be added in the future using the same evidence-based adapter pattern.
|
|
139
|
+
|
|
140
|
+
- **Evidence-based detection only.** Redis is detected from concrete declarations: `gem "redis"`, `gem "sidekiq"`, `gem "resque"`, `gem "redis-rails"`, references to `Redis.new`, `Sidekiq.configure_server`, `REDIS_URL` in test/development config, or `redis (` entries in `Gemfile.lock`. Incidental strings do not trigger provisioning.
|
|
141
|
+
- **Credential-free and isolated.** The Redis container runs without authentication on the private network, has no published ports, no bind mounts, and carries the same ownership labels as the database and app containers (`runId` and worktree SHA-256). The app container receives `REDIS_URL=redis://<redis-container>:6379/0` at creation time.
|
|
142
|
+
- **Ownership and verification.** On every reuse, each service container is verified to belong to the same run and worktree, match the allowlisted image, run on only the isolated network, remain unprivileged with no published ports, and have the expected environment. Any drift causes safe replacement.
|
|
143
|
+
- **No secrets exposed.** Service URLs are derived at runtime from container names; they are not read from user environment, not persisted in committed evidence beyond runtime.json metadata, and not logged.
|
|
144
|
+
- **Automatic service provisioning.** Detects Redis (Sidekiq/Resque/redis gems) and browser/system test drivers (Selenium/Cuprite/Capybara system specs, `js: true`, `driven_by :selenium`) from project declarations. When detected, starts isolated allowlisted containers on the private network with per-run ownership labels, wires environment variables (e.g. `REDIS_URL`, `SELENIUM_URL`/`CHROME_REMOTE_URL`) into the app container, and verifies readiness before running tests.
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
130
148
|
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
149
|
|
|
132
150
|
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.
|
|
@@ -139,11 +157,19 @@ Every run has a generated JSON record and Markdown companion under `.ruby-upgrad
|
|
|
139
157
|
|
|
140
158
|
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:
|
|
141
159
|
|
|
142
|
-

|
|
143
161
|
|
|
144
162
|
The same reports open as a vault — each run is a Markdown note paired with its JSON record:
|
|
145
163
|
|
|
146
|
-

|
|
165
|
+
|
|
166
|
+
### Infrastructure review (advisory)
|
|
167
|
+
|
|
168
|
+
Each report and the dashboard also carry a read-only review of the infrastructure the upgrade may have made inconsistent: Ruby version pins in `.ruby-version`, `.tool-versions`, `.mise.toml`, `Gemfile`, and `Dockerfile`, CI workflows that do not yet test the new target, and the Bundler version recorded in `Gemfile.lock`.
|
|
169
|
+
|
|
170
|
+
These are advisory and never block a commit. The agent validates your app inside an isolated container; it has no access to your CI config, deploy host, or platform dashboard, so it cannot know what your production runtime actually uses. A version pin that deliberately lags the app is legitimate in many repositories, so every finding states the evidence it was read from and asks you to confirm it. Where a value cannot be read with confidence — an unparseable Dockerfile base image, an unfamiliar CI matrix shape — the tool stays silent rather than guessing.
|
|
171
|
+
|
|
172
|
+
The dashboard recomputes these on every request from the current worktree instead of storing them in the report. A report describes what happened during that run; these findings describe the repo as it stands now, and a frozen inference would misrepresent the second as the first.
|
|
147
173
|
|
|
148
174
|
Launch the local-only dashboard from the repository worktree:
|
|
149
175
|
|
|
@@ -151,10 +177,35 @@ Launch the local-only dashboard from the repository worktree:
|
|
|
151
177
|
npx opencode-ruby-upgrader dashboard
|
|
152
178
|
```
|
|
153
179
|
|
|
154
|
-
It binds exclusively to `127.0.0.1` on an ephemeral port and remains in the foreground until you stop it with Ctrl-C. The read-only dashboard displays valid Ruby and
|
|
180
|
+
It binds exclusively to `127.0.0.1` on an ephemeral port and remains in the foreground until you stop it with Ctrl-C. The read-only dashboard displays valid Ruby, Rails-bridge, and Bundler-bridge reports plus locally discoverable checkpoint commits; it never changes reports, Git state, or uploads code.
|
|
155
181
|
|
|
156
182
|
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.
|
|
157
183
|
|
|
184
|
+
## When a prerequisite blocks the hop
|
|
185
|
+
|
|
186
|
+
A Ruby upgrade occasionally cannot cross a version boundary on its own. Two cases are handled, and both work the same way: the Ruby run becomes terminal, and a separately scoped bridge run takes over the prerequisite change.
|
|
187
|
+
|
|
188
|
+
### Rails bridge
|
|
189
|
+
|
|
190
|
+
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.9/docs/rails-bridge.md) reference.
|
|
191
|
+
|
|
192
|
+
### Bundler bridge
|
|
193
|
+
|
|
194
|
+
Your `Gemfile.lock` records the Bundler that wrote it under `BUNDLED WITH`, and Bundler switches to that version automatically. So when an upgrade moves Ruby forward, an older pinned Bundler is what breaks — not the other way round. The official compatibility guide is a table of *minimum floors* (Bundler 2.5 requires Ruby >= 3.0, 2.6 requires >= 3.1, 2.7 and 4.0 require >= 3.2), not fixed pairings: Ruby simply ships a matching Bundler as its default.
|
|
195
|
+
|
|
196
|
+
If your pin sits below the researched floor for the target Ruby, the Ruby run becomes terminal and a separately scoped Bundler bridge takes over, exactly like the Rails bridge. Approving it records the floor, the rationale, and the official citation in the run evidence:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
npx opencode-ruby-upgrader record-bundler-bridge --report <run>.json \
|
|
200
|
+
--ruby-from 3.3 --ruby-to 3.4 --bundler-from 2.4.17 --bundler-to 2.5.22 \
|
|
201
|
+
--minimum-bundler 2.5 --rationale "Bundler 2.4 predates Ruby 3.4 support." \
|
|
202
|
+
--citation "Bundler compatibility with Ruby|https://guides.rubygems.org/bundler-compatibility/"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
A bridge cannot be approved for a pin that already clears the floor, so this cannot block a hop for no reason. The bridge run then researches a contiguous Bundler ladder (`begin-bundler-bridge`, `record-bundler-research`), and a hop is recorded only when the rewritten `BUNDLED WITH` pin **and** the Bundler that actually executed the tests both match the target. Because Bundler 4.0 follows 2.7 directly, the ladder may cross that boundary in a single hop.
|
|
206
|
+
|
|
207
|
+
Full lifecycle detail: [docs/bundler-bridge.md](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.9/docs/bundler-bridge.md).
|
|
208
|
+
|
|
158
209
|
## Recovery
|
|
159
210
|
|
|
160
211
|
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:
|
|
@@ -165,8 +216,6 @@ opencode-ruby-upgrader resume --report .ruby-upgrades/runs/<run>.json
|
|
|
165
216
|
|
|
166
217
|
`complete`, `blocked`, and `paused` runs release their lock. For other blockers, inspect the report and use the documented transition/resume path.
|
|
167
218
|
|
|
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.
|
|
169
|
-
|
|
170
219
|
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.
|
|
171
220
|
|
|
172
221
|
## Troubleshooting
|
|
@@ -202,7 +251,7 @@ The credential scanner is heuristic: it recognizes common token formats and quot
|
|
|
202
251
|
|
|
203
252
|
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
253
|
|
|
205
|
-
Full detail in [PRIVACY.md](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.
|
|
254
|
+
Full detail in [PRIVACY.md](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.9/PRIVACY.md). To report a vulnerability, see [SECURITY.md](https://github.com/lilla021/opencode-ruby-upgrader/blob/v0.1.9/SECURITY.md).
|
|
206
255
|
|
|
207
256
|
## Contributing
|
|
208
257
|
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# opencode-ruby-upgrader — release notes
|
|
2
2
|
|
|
3
|
+
## v0.1.9 — advisory infrastructure review and the Bundler bridge
|
|
4
|
+
|
|
5
|
+
Every run already reported that a container-validated upgrade is not a deployed upgrade. v0.1.9 makes that concrete, and then lets you act on one of the findings: when a Ruby upgrade is blocked by a Bundler that cannot run the target, the Ruby run becomes terminal and a separately scoped Bundler bridge takes over, mirroring the existing Rails bridge.
|
|
6
|
+
|
|
7
|
+
### Infrastructure review (advisory)
|
|
8
|
+
|
|
9
|
+
- New advisory findings for stale Ruby version pins (`.ruby-version`, `.tool-versions`, `.mise.toml`, `Gemfile`, `Dockerfile`), CI workflows that do not yet test the upgraded target, and the Bundler version recorded in `Gemfile.lock` under `BUNDLED WITH`. Each finding carries the file and value it was read from, a stated basis (`observed` vs `review`), and a remediation that asks rather than instructs.
|
|
10
|
+
- These never gate anything. The agent validates the app inside an isolated container and has no access to your CI config, deploy host, or platform dashboard. A version pin that deliberately lags the app is legitimate in many repositories, so a finding that blocked a commit would be wrong as often as it was right.
|
|
11
|
+
- Silence on ambiguity. An unparseable Dockerfile base image is reported as unread rather than as a version mismatch, and CI inspection only reports Ruby versions visible as plain scalars, because workflow matrix shapes vary too much to parse reliably. Reporting nothing beats guessing.
|
|
12
|
+
- The dashboard recomputes findings per request from the live worktree and stores nothing in the report. A report describes what happened during that run; these describe the repo as it stands now, and persisting an inference as durable evidence would misrepresent the second as the first.
|
|
13
|
+
|
|
14
|
+
### The Bundler bridge
|
|
15
|
+
|
|
16
|
+
- `record-bundler-bridge` requires a researched minimum floor, a rationale, and the official citation, and records all three in run evidence. The bridge can only be approved when the recorded pin is genuinely below that floor.
|
|
17
|
+
- `begin-bundler-bridge` and `record-bundler-research` produce a separate report linked back to the blocked Ruby run, with a contiguous Bundler ladder. Bundler 4.0 follows 2.7 directly (there was no 3.x series), so that boundary is a valid single hop; without it a project on 2.4 could never reach 4.0 through a reviewed ladder.
|
|
18
|
+
- `record-executed-bundler-iteration` records a hop only when the rewritten `BUNDLED WITH` pin **and** the Bundler version that actually executed the tests both match the target. This is the honest evidence gate for a bridge whose output is a file change rather than a reviewable generator receipt, and it reuses the runtime Bundler attestation rather than trusting the lockfile alone.
|
|
19
|
+
- `commit-bundler-hop` commits only when the recorded pin agrees with the hop target, and requires `Bundler-Bridge-Ruby-Report` so the linkage survives in Git history.
|
|
20
|
+
|
|
21
|
+
### Behaviour correction
|
|
22
|
+
|
|
23
|
+
The premise this was built on was wrong in an instructive way. "Ruby 4 requires Bundler 4" is not a constraint: the official table gives *minimum floors*, and each Ruby release merely ships a matching pair as its default. The compatibility risk runs the other way — an old Bundler pin breaks when Ruby moves forward — so the bridge is the mirror image of the Rails bridge, where the framework lags the Ruby. Both exist because something in the project cannot cross the hop alone.
|
|
24
|
+
|
|
25
|
+
### No hardcoded compatibility table
|
|
26
|
+
|
|
27
|
+
`src/bundler-compat.js` deliberately stores no versions. The tool performs no network requests, so it cannot look up which Bundler supports which Ruby, and a baked-in table would silently rot at the next series release. The floor arrives the same way Rails and Ruby facts already do — supplied by the agent as cited research at the moment the bridge is approved — and only the comparison arithmetic lives locally, because that has to be deterministic for an approval to be defensible.
|
|
28
|
+
|
|
29
|
+
### Scope of change
|
|
30
|
+
|
|
31
|
+
The Bundler bridge is implemented as a data variation of the existing bridge machinery rather than a third parallel code path. Bridge-specific behaviour is selected by a `bridge` parameter in the commit gate and a bridge-kind prefix in checkpoint trailers, which keeps the `reportType` branches from doubling for every future bridge type.
|
|
32
|
+
|
|
33
|
+
### Evidence
|
|
34
|
+
|
|
35
|
+
Unit suite 80/80, covering the floor comparison, the 2.7→4.0 boundary, ladder contiguity, bridge scoping, terminality, and the pin/runtime agreement gate, plus advisory findings against real application shapes: a project pinning 3.3.4 across four files and a workflow on 3.3.4 yields all four findings; after the pins are updated, the stale findings disappear while the Bundler note remains, since that one is a confirmation rather than a detected defect. A repository with no version files produces no findings, and a `FROM my-registry.internal/ruby:latest` base produces no version mismatch. Both real-container smokes pass against real `mysql:8.4` and `postgres:16-alpine`, each asserting a passing RSpec receipt, a secret-free persisted manifest, and clean teardown.
|
|
36
|
+
|
|
37
|
+
### Known limitations
|
|
38
|
+
|
|
39
|
+
- A Bundler bridge is never raised automatically. The advisory review points at the pin, but deciding whether it needs a bridge requires the researched floor, which only the agent can supply. That judgment stays explicit by design.
|
|
40
|
+
- CI workflow inspection reads only Ruby versions visible as plain scalars. Unusual matrix shapes are left unreported rather than guessed at.
|
|
41
|
+
|
|
3
42
|
## v0.1.8 — real-adapter detection and PostgreSQL container evidence
|
|
4
43
|
|
|
5
44
|
v0.1.7 shipped database detection that had only ever been tested against hand-written fixtures. Running it against real Rails applications found three wrong answers, one of them silent. This release fixes detection and closes the container-evidence gap for the default adapter.
|
package/agents/ruby-upgrade.md
CHANGED
|
@@ -14,12 +14,14 @@ You own a careful Ruby runtime migration. Be decisive on routine fixes and trans
|
|
|
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
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
|
+
- If the project's `Gemfile.lock` records a `BUNDLED WITH` version below the researched minimum Bundler for the target Ruby, treat the pinned Bundler -- not Ruby itself -- as the blocker. Do not edit the lockfile's pin by hand. Consult the official compatibility guide at `https://guides.rubygems.org/bundler-compatibility/` and cite it: it lists minimum Ruby floors per Bundler series, so a higher Bundler supports a wider range of Rubies and an *older* pin is what breaks when Ruby moves forward. Obtain explicit approval before running `record-bundler-bridge --report <report-path> --ruby-from <version> --ruby-to <version> --bundler-from <version> --bundler-to <version> --minimum-bundler <version> --rationale <text> --citation 'title|https://...'`. Then transition the Ruby run to `blocked` and start `begin-bundler-bridge --ruby-report <blocked-ruby-report>`. In that separate report, research contiguous Bundler-series hops with `record-bundler-research --ladder <v1,v2,...>` (a direct 2.7 to 4.0 hop is valid because no Bundler 3.x series exists), rewrite the pin with `bundle lock --bundler <version>` inside the isolated runtime, and record each passing hop with `record-executed-bundler-iteration`; that hop is rejected unless both the rewritten `BUNDLED WITH` pin and the Bundler that executed the tests match the target. Transition to `hop_validated` and checkpoint only with `commit-bundler-hop`. Start a fresh Ruby run only after the Bundler bridge is complete. Never approve a bridge for a pin that already clears the researched floor.
|
|
17
18
|
- 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
19
|
- 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.
|
|
19
|
-
- Never create, switch, delete, merge, push, fetch, reconfigure, or directly commit Git branches/remotes. Never invoke GitHub CLI, publishing, release, deployment, credential, or destructive database commands. The only permitted commit paths are Ruby-only `opencode-ruby-upgrader commit-hop --report <report-path
|
|
20
|
+
- Never create, switch, delete, merge, push, fetch, reconfigure, or directly commit Git branches/remotes. Never invoke GitHub CLI, publishing, release, deployment, credential, or destructive database commands. The only permitted commit paths are Ruby-only `opencode-ruby-upgrader commit-hop --report <report-path>`, Rails-bridge-only `opencode-ruby-upgrader commit-rails-hop --report <report-path>`, and Bundler-bridge-only `opencode-ruby-upgrader commit-bundler-hop --report <report-path>` after their respective validation gates succeed.
|
|
20
21
|
- Before every edit, acknowledge: this is automated migration assistance; the user must review diffs, tests, and any deployment. Never claim production safety.
|
|
21
|
-
- Require a clean working tree before the first iteration, excluding only the plugin-owned `.ruby-upgrades` evidence directory. Do not ask the user to review or clean evidence files that this run just created; inspect and stop for every other tracked, staged, or untracked project change. Record branch, starting SHA, runtime/OS, Bundler, test commands, the pinned target Ruby version, source URLs, and source access date in `.ruby-upgrades/runs/<timestamp>.json` and an adjacent Markdown report. These files are versioned migration evidence. `begin` already acquires the single-worktree lock. Use `resume` only after a paused or interrupted run; `complete`, `blocked`, and `paused` runs release their lock. A Ruby run blocked by an approved Rails bridge must not resume.
|
|
22
|
+
- Require a clean working tree before the first iteration, excluding only the plugin-owned `.ruby-upgrades` evidence directory. Do not ask the user to review or clean evidence files that this run just created; inspect and stop for every other tracked, staged, or untracked project change. Record branch, starting SHA, runtime/OS, Bundler, test commands, the pinned target Ruby version, source URLs, and source access date in `.ruby-upgrades/runs/<timestamp>.json` and an adjacent Markdown report. These files are versioned migration evidence. `begin` already acquires the single-worktree lock. Use `resume` only after a paused or interrupted run; `complete`, `blocked`, and `paused` runs release their lock. A Ruby run blocked by an approved Rails or Bundler bridge must not resume.
|
|
22
23
|
- Never ask a user to provide or restore an internal report path, run ID, container name, or runtime manifest. If no resumable durable run exists, explain that the prior local run evidence is unavailable and direct the user to restart with `/ruby-upgrade --target <version>`; retain the same safety gates and plainly summarize the new plan before any side effect. When stale paused reports exist, retain them as evidence but select the most recently started paused report matching the current branch and requested target; use that report path internally for resume and target-runtime preparation.
|
|
24
|
+
- Each generated report and dashboard card includes an advisory infrastructure review of the reader's own files: stale Ruby pins, CI workflows that do not yet test the target, and the recorded `BUNDLED WITH` Bundler pin. It is read-only and never blocks or gates a hop. Present these findings when the run completes, do not act on them automatically, and do not treat an intentional version pin that lags the app as a defect.
|
|
23
25
|
- A successful routine iteration is proposed locally through the commit gate and requires an OpenCode confirmation. The gate requires the original linked worktree/branch, an unchanged expected HEAD, a completed report iteration with passing tests, an empty initial staging area, and no detected credential material. The user alone reviews, pushes, and deploys; they may push any validated local checkpoint.
|
|
24
26
|
- Stop rather than guess on data migrations, authentication/authorization, payments, serialization, background jobs, native extensions, secrets, production configuration, required framework-major upgrades, private dependency sources, or failed validation. Explain the evidence and offer safe continuation options.
|
|
25
27
|
|
|
@@ -3,7 +3,7 @@ import { inspectGitCapabilities, inspectWorktree, setupInstructions } from "../s
|
|
|
3
3
|
import { startDashboard } from "../src/dashboard.js";
|
|
4
4
|
import { commitValidatedHop, commitValidatedRailsHop, CommitGateError } from "../src/commit-hop.js";
|
|
5
5
|
import { acquireRunLock, readRun, releaseRunLock, RunStateError } from "../src/run-state.js";
|
|
6
|
-
import { beginRailsBridgeRun, beginRun, discardLastRailsIteration, discardPendingRailsAppUpdate, recordDependencyReview, recordExecutedIteration, recordExecutedRailsIteration, recordFrameworkBridge, recordRailsResearch, recordResearch, recordRiskDecision, resumeRun, runStatus, transitionRun } from "../src/controller.js";
|
|
6
|
+
import { beginBundlerBridgeRun, beginRailsBridgeRun, beginRun, discardLastRailsIteration, discardPendingRailsAppUpdate, recordBundlerBridge, recordBundlerResearch, recordDependencyReview, recordExecutedBundlerIteration, recordExecutedIteration, recordExecutedRailsIteration, recordFrameworkBridge, recordRailsResearch, recordResearch, recordRiskDecision, resumeRun, runStatus, transitionRun } from "../src/controller.js";
|
|
7
7
|
import { inventoryProject } from "../src/inventory.js";
|
|
8
8
|
import { inspectSupplyChain } from "../src/supply-chain.js";
|
|
9
9
|
import { prepareTargetRuntime } from "../src/target-runtime.js";
|
|
@@ -13,7 +13,7 @@ const option = (name) => { const index = args.indexOf(name); return index >= 0 ?
|
|
|
13
13
|
const options = (name) => args.flatMap((argument, index) => argument === name && args[index + 1] ? [args[index + 1]] : []);
|
|
14
14
|
const reportOption = () => option("--report");
|
|
15
15
|
const citation = (value) => { const [title, url] = (value ?? "").split("|"); return { title, url }; };
|
|
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]";
|
|
16
|
+
const usage = "Usage: opencode-ruby-upgrader <preflight|dashboard|begin|begin-rails-bridge|begin-bundler-bridge|prepare-target-runtime|status|transition|record-research|record-rails-research|record-bundler-research|record-risk|record-framework-bridge|record-bundler-bridge|record-executed-iteration|record-executed-rails-iteration|record-executed-bundler-iteration|discard-pending-app-update|discard-last-rails-iteration|record-dependency-review|inventory|supply-chain|git-capabilities|commit-hop|commit-rails-hop|commit-bundler-hop|resume|release-lock> [--help]";
|
|
17
17
|
const prepareRuntimeHelp = `Usage: opencode-ruby-upgrader prepare-target-runtime --ruby <x.y.z> [--database postgres|mysql] [--report .ruby-upgrades/runs/<run>.json]
|
|
18
18
|
|
|
19
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.`;
|
|
@@ -38,6 +38,9 @@ if (command === "prepare-target-runtime" && args.includes("--help")) {
|
|
|
38
38
|
} else if (command === "begin-rails-bridge") {
|
|
39
39
|
try { console.log(JSON.stringify(beginRailsBridgeRun({ rubyReportPath: option("--ruby-report"), dryRun: args.includes("--dry-run"), stopAfterHop: args.includes("--stop-after-hop") }), null, 2)); }
|
|
40
40
|
catch (error) { console.error(`Rails bridge start blocked: ${error.message}`); process.exitCode = 1; }
|
|
41
|
+
} else if (command === "begin-bundler-bridge") {
|
|
42
|
+
try { console.log(JSON.stringify(beginBundlerBridgeRun({ rubyReportPath: option("--ruby-report"), dryRun: args.includes("--dry-run"), stopAfterHop: args.includes("--stop-after-hop") }), null, 2)); }
|
|
43
|
+
catch (error) { console.error(`Bundler bridge start blocked: ${error.message}`); process.exitCode = 1; }
|
|
41
44
|
} else if (command === "prepare-target-runtime") {
|
|
42
45
|
try {
|
|
43
46
|
if (!option("--ruby")) throw new Error("Usage: prepare-target-runtime --ruby <x.y.z> [--database postgres|mysql] [--report .ruby-upgrades/runs/<run>.json]");
|
|
@@ -62,18 +65,27 @@ if (command === "prepare-target-runtime" && args.includes("--help")) {
|
|
|
62
65
|
} else if (command === "record-rails-research") {
|
|
63
66
|
try { console.log(JSON.stringify(recordRailsResearch({ reportPath: reportOption(), ladder: (option("--ladder") ?? "").split(",").filter(Boolean), citations: options("--citation").map(citation) }), null, 2)); }
|
|
64
67
|
catch (error) { console.error(`Rails research recording blocked: ${error.message}`); process.exitCode = 1; }
|
|
68
|
+
} else if (command === "record-bundler-research") {
|
|
69
|
+
try { console.log(JSON.stringify(recordBundlerResearch({ reportPath: reportOption(), ladder: option("--ladder")?.split(","), citations: options("--citation").map(citation) }), null, 2)); }
|
|
70
|
+
catch (error) { console.error(`Bundler research blocked: ${error.message}`); process.exitCode = 1; }
|
|
65
71
|
} else if (command === "record-risk") {
|
|
66
72
|
try { console.log(JSON.stringify(recordRiskDecision({ reportPath: reportOption(), risk: option("--risk"), decision: option("--decision"), evidence: option("--evidence") }), null, 2)); }
|
|
67
73
|
catch (error) { console.error(`Risk recording blocked: ${error.message}`); process.exitCode = 1; }
|
|
68
74
|
} else if (command === "record-framework-bridge") {
|
|
69
75
|
try { console.log(JSON.stringify(recordFrameworkBridge({ reportPath: reportOption(), rubyFrom: option("--ruby-from"), rubyTo: option("--ruby-to"), railsFrom: option("--rails-from"), railsTo: option("--rails-to"), rationale: option("--rationale"), citations: options("--citation").map(citation) }), null, 2)); }
|
|
70
76
|
catch (error) { console.error(`Rails compatibility bridge blocked: ${error.message}`); process.exitCode = 1; }
|
|
77
|
+
} else if (command === "record-bundler-bridge") {
|
|
78
|
+
try { console.log(JSON.stringify(recordBundlerBridge({ reportPath: reportOption(), rubyFrom: option("--ruby-from"), rubyTo: option("--ruby-to"), bundlerFrom: option("--bundler-from"), bundlerTo: option("--bundler-to"), minimumBundler: option("--minimum-bundler"), rationale: option("--rationale"), citations: options("--citation").map(citation) }), null, 2)); }
|
|
79
|
+
catch (error) { console.error(`Bundler compatibility bridge blocked: ${error.message}`); process.exitCode = 1; }
|
|
71
80
|
} else if (command === "record-executed-iteration") {
|
|
72
81
|
try { console.log(JSON.stringify(recordExecutedIteration({ reportPath: reportOption(), iteration: JSON.parse(option("--json") ?? ""), validationCommandId: option("--validation") }), null, 2)); }
|
|
73
82
|
catch (error) { console.error(`Executed validation blocked: ${error.message}`); process.exitCode = 1; }
|
|
74
83
|
} else if (command === "record-executed-rails-iteration") {
|
|
75
84
|
try { console.log(JSON.stringify(recordExecutedRailsIteration({ reportPath: reportOption(), iteration: JSON.parse(option("--json") ?? ""), testValidationCommandId: option("--validation") }), null, 2)); }
|
|
76
85
|
catch (error) { console.error(`Executed Rails validation blocked: ${error.message}`); process.exitCode = 1; }
|
|
86
|
+
} else if (command === "record-executed-bundler-iteration") {
|
|
87
|
+
try { console.log(JSON.stringify(recordExecutedBundlerIteration({ reportPath: reportOption(), iteration: JSON.parse(option("--iteration") ?? "{}"), validationCommandId: option("--validation-command-id") }), null, 2)); }
|
|
88
|
+
catch (error) { console.error(`Bundler hop blocked: ${error.message}`); process.exitCode = 1; }
|
|
77
89
|
} else if (command === "discard-pending-app-update") {
|
|
78
90
|
try { console.log(JSON.stringify(discardPendingRailsAppUpdate({ reportPath: reportOption(), reason: option("--reason") }), null, 2)); }
|
|
79
91
|
catch (error) { console.error(`Rails app:update discard blocked: ${error.message}`); process.exitCode = 1; }
|
|
@@ -103,6 +115,10 @@ if (command === "prepare-target-runtime" && args.includes("--help")) {
|
|
|
103
115
|
const reportIndex = args.indexOf("--report");
|
|
104
116
|
try { console.log(JSON.stringify(commitValidatedRailsHop({ reportPath: reportIndex >= 0 ? args[reportIndex + 1] : undefined, allowHooks: args.includes("--allow-hooks"), allowBroadLockfile: args.includes("--allow-broad-lockfile"), allowPrivateSources: args.includes("--allow-private-sources") }), null, 2)); }
|
|
105
117
|
catch (error) { console.error(`${error instanceof CommitGateError ? `Rails commit blocked (${error.code})` : "Rails commit failed"}: ${error.message}`); process.exitCode = 1; }
|
|
118
|
+
} else if (command === "commit-bundler-hop") {
|
|
119
|
+
const reportIndex = args.indexOf("--report");
|
|
120
|
+
try { console.log(JSON.stringify(commitValidatedBundlerHop({ reportPath: reportIndex >= 0 ? args[reportIndex + 1] : undefined, allowHooks: args.includes("--allow-hooks"), allowBroadLockfile: args.includes("--allow-broad-lockfile"), allowPrivateSources: args.includes("--allow-private-sources") }), null, 2)); }
|
|
121
|
+
catch (error) { console.error(`${error instanceof CommitGateError ? `Bundler commit blocked (${error.code})` : "Bundler commit failed"}: ${error.message}`); process.exitCode = 1; }
|
|
106
122
|
} else if (["resume", "release-lock"].includes(command)) {
|
|
107
123
|
const reportIndex = args.indexOf("--report");
|
|
108
124
|
const reportPath = reportIndex >= 0 ? args[reportIndex + 1] : undefined;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Bundler bridge lifecycle
|
|
2
|
+
|
|
3
|
+
A Bundler bridge is the mirror image of a [Rails bridge](rails-bridge.md). Where a Rails hop is blocked because the framework lags the Ruby, a Bundler hop is blocked because the project's own pinned Bundler lags the Ruby it must now run on.
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
`Gemfile.lock` records the Bundler that wrote it under `BUNDLED WITH`, and Bundler switches to that version automatically. It is therefore the most reliable available signal for which Bundler will actually run in your project — more reliable than scanning for an installed gem.
|
|
8
|
+
|
|
9
|
+
The official compatibility guide at <https://guides.rubygems.org/bundler-compatibility/> is a table of **minimum floors**, not fixed pairings:
|
|
10
|
+
|
|
11
|
+
| Bundler | Requires Ruby | Requires RubyGems |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| 4.0 | >= 3.2.0 | >= 3.4.1 |
|
|
14
|
+
| 2.7 | >= 3.2.0 | >= 3.4.1 |
|
|
15
|
+
| 2.6 | >= 3.1.0 | >= 3.3.3 |
|
|
16
|
+
| 2.5 | >= 3.0.0 | >= 3.2.3 |
|
|
17
|
+
|
|
18
|
+
A higher Bundler supports a *wider* range of Rubies, so moving Ruby forward essentially never forces a Bundler upgrade. Each Ruby release simply ships a matching pair as its default. The compatibility risk runs the other way: an **existing Bundler pin breaks when the Ruby moves forward**.
|
|
19
|
+
|
|
20
|
+
Two corrections worth stating plainly, because the intuitive version of this is wrong:
|
|
21
|
+
|
|
22
|
+
- "Ruby 4 requires Bundler 4" is not a constraint. Bundler 2.7 and 4.0 both support Ruby >= 3.2.
|
|
23
|
+
- Ruby 4.0 ships Bundler 4.0 as its bundled default. That is a convenience pairing, not a compatibility requirement.
|
|
24
|
+
|
|
25
|
+
## No hardcoded table
|
|
26
|
+
|
|
27
|
+
`src/bundler-compat.js` deliberately stores no versions. This tool performs no network requests, so it cannot look up which Bundler supports which Ruby, and a baked-in table would silently rot at the next series release.
|
|
28
|
+
|
|
29
|
+
The minimum floor arrives the same way Rails and Ruby compatibility facts already enter this system: supplied as cited research at the moment the bridge is approved, and recorded in the run report so it can be re-verified later. Only the comparison arithmetic — version ordering, contiguity, floor checks — lives locally, because that has to be deterministic for an approval to be defensible.
|
|
30
|
+
|
|
31
|
+
## Lifecycle
|
|
32
|
+
|
|
33
|
+
### 1. Approve the bridge on the blocked Ruby run
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
opencode-ruby-upgrader record-bundler-bridge --report <run>.json \
|
|
37
|
+
--ruby-from 3.3 --ruby-to 3.4 \
|
|
38
|
+
--bundler-from 2.4.17 --bundler-to 2.5.22 \
|
|
39
|
+
--minimum-bundler 2.5 \
|
|
40
|
+
--rationale "Bundler 2.4 predates Ruby 3.4 support." \
|
|
41
|
+
--citation "Bundler compatibility with Ruby|https://guides.rubygems.org/bundler-compatibility/"
|
|
42
|
+
|
|
43
|
+
opencode-ruby-upgrader transition --report <run>.json --phase blocked
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The bridge records the floor, the rationale, the citation, and the compatibility source URL. The Ruby run becomes **terminal**: it cannot be resumed, because resuming would skip the prerequisite change and leave the project on a Bundler that cannot run the target Ruby.
|
|
47
|
+
|
|
48
|
+
Approval is refused when the recorded pin already clears the floor. Blocking a hop that the pin already satisfies would be wrong as often as it is right.
|
|
49
|
+
|
|
50
|
+
### 2. Begin the separate bridge run
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
opencode-ruby-upgrader begin-bundler-bridge --ruby-report <blocked-ruby-report>.json
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
This creates a `reportType: "bundler_bridge"` report linked back to the blocked Ruby run through `bridge.rubyReportPath` and `bridge.rubyRunId`.
|
|
57
|
+
|
|
58
|
+
### 3. Research a contiguous ladder
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
opencode-ruby-upgrader record-bundler-research --report <bridge>.json \
|
|
62
|
+
--ladder 2.4.17,2.5.22,2.6.9,2.7.2,4.0.11 \
|
|
63
|
+
--citation "Bundler compatibility with Ruby|https://guides.rubygems.org/bundler-compatibility/"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The ladder must begin at the recorded `BUNDLED WITH` pin and advance one series at a time. A direct **2.7 → 4.0** hop is valid because Bundler never had a 3.x series; without that boundary rule a project on 2.4 could never reach 4.0 through a reviewed ladder.
|
|
67
|
+
|
|
68
|
+
### 4. Raise the pin and validate
|
|
69
|
+
|
|
70
|
+
Rewrite the pin inside the isolated runtime, then record the hop:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
opencode-ruby-upgrader record-executed-bundler-iteration --report <bridge>.json \
|
|
74
|
+
--iteration '<json>' --validation-command-id docker-bundle-rspec
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This is where a Bundler bridge differs from a Rails bridge. A Rails hop produces a reviewable `app:update` receipt; a Bundler hop produces a **file change**, and a lockfile edit is weak evidence — anyone can hand-write `BUNDLED WITH 2.5.22`.
|
|
78
|
+
|
|
79
|
+
So the hop is accepted only when **two independent facts agree**:
|
|
80
|
+
|
|
81
|
+
1. The rewritten `BUNDLED WITH` pin in `Gemfile.lock` matches the hop target.
|
|
82
|
+
2. The Bundler version that actually executed the tests matches the hop target, attested by the isolated runtime.
|
|
83
|
+
|
|
84
|
+
A mismatch is refused explicitly, for example: `Validation executed Bundler 2.4.22, not 2.5.22. Re-prepare the target runtime on the new Bundler before recording this hop.`
|
|
85
|
+
|
|
86
|
+
The recorded pin is stored on the iteration as `lockfilePin`, and report validation rejects any hop whose pin disagrees with its target.
|
|
87
|
+
|
|
88
|
+
### 5. Checkpoint
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
opencode-ruby-upgrader transition --report <bridge>.json --phase hop_validated
|
|
92
|
+
opencode-ruby-upgrader commit-bundler-hop --report <bridge>.json
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The checkpoint message carries `Bundler-Upgrade-Report`, `Bundler-Upgrade-Hop`, and `Bundler-Bridge-Ruby-Report` trailers, so the linkage back to the blocked Ruby run survives in Git history and can be verified later.
|
|
96
|
+
|
|
97
|
+
### 6. Restart the Ruby upgrade
|
|
98
|
+
|
|
99
|
+
Only after the bridge is complete should a fresh Ruby run begin.
|
|
100
|
+
|
|
101
|
+
## Rollback
|
|
102
|
+
|
|
103
|
+
Use the reviewable local history: `git revert <hop-sha>`. Do not use reset, rebase, or force-push as routine migration recovery.
|
|
104
|
+
|
|
105
|
+
## Reporting to the user
|
|
106
|
+
|
|
107
|
+
Advisory findings about the `BUNDLED WITH` pin appear in every report's **Infrastructure review (advisory)** section and on the dashboard. They are read-only and never gate a hop: an intentional version pin that lags the app is legitimate in many repositories. Where a value cannot be read with confidence, the tool stays silent rather than guessing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-ruby-upgrader",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
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": {
|
|
@@ -26,9 +26,35 @@
|
|
|
26
26
|
"allowScripts": {
|
|
27
27
|
"opencode-ai@1.18.30": true
|
|
28
28
|
},
|
|
29
|
-
"files": [
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
"files": [
|
|
30
|
+
"src",
|
|
31
|
+
"agents",
|
|
32
|
+
"bin",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE",
|
|
35
|
+
"SECURITY.md",
|
|
36
|
+
"PRIVACY.md",
|
|
37
|
+
"RELEASING.md",
|
|
38
|
+
"RELEASE_CHECKLIST.md",
|
|
39
|
+
"RELEASE_NOTES.md",
|
|
40
|
+
"E2E_EVIDENCE.md",
|
|
41
|
+
"docs/bundler-bridge.md",
|
|
42
|
+
"docs/rails-bridge.md"
|
|
43
|
+
],
|
|
44
|
+
"keywords": [
|
|
45
|
+
"opencode",
|
|
46
|
+
"opencode-plugin",
|
|
47
|
+
"ruby",
|
|
48
|
+
"rails",
|
|
49
|
+
"upgrade",
|
|
50
|
+
"worktree",
|
|
51
|
+
"migration"
|
|
52
|
+
],
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=22.5.0"
|
|
55
|
+
},
|
|
32
56
|
"license": "MIT",
|
|
33
|
-
"publishConfig": {
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
|
+
}
|
|
34
60
|
}
|
package/src/advisory.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Read-only advisory findings about infrastructure the upgrade may have made
|
|
2
|
+
// inconsistent.
|
|
3
|
+
//
|
|
4
|
+
// Everything here is advisory by design and never blocks a commit. The agent
|
|
5
|
+
// runs the app in an isolated container; it does not have your CI config, your
|
|
6
|
+
// deploy host, or your platform's build process. A finding says "this file
|
|
7
|
+
// disagrees with the version you just moved to, confirm it" -- never "you must
|
|
8
|
+
// change this". A deliberate version pin that lags the app is legitimate in many
|
|
9
|
+
// repos, and blocking on it would be wrong as often as right.
|
|
10
|
+
//
|
|
11
|
+
// Each finding carries the evidence it was derived from so the reader can judge
|
|
12
|
+
// it. `confidence` distinguishes a value read out of a file ("observed") from a
|
|
13
|
+
// judgment about what that value implies ("review").
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
const exists = (root, file) => fs.existsSync(path.join(root, file));
|
|
19
|
+
const read = (root, file) => (exists(root, file) ? fs.readFileSync(path.join(root, file), "utf8") : "");
|
|
20
|
+
|
|
21
|
+
function finding(severity, area, title, detail, evidence, confidence = "observed") {
|
|
22
|
+
return { severity, area, title, detail, evidence, confidence };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// `BUNDLED WITH` is the project's own record of which Bundler wrote its
|
|
26
|
+
// lockfile, and Bundler auto-switches to it. That makes it the most reliable
|
|
27
|
+
// available signal for "which Bundler will actually run here" -- more reliable
|
|
28
|
+
// than scanning for an installed gem, and the value a Ruby hop can invalidate.
|
|
29
|
+
export function bundledWith(root = process.cwd()) {
|
|
30
|
+
const lockfile = read(root, "Gemfile.lock");
|
|
31
|
+
const match = /^BUNDLED WITH\s*\r?\n\s+(\d+(?:\.\d+)+)/m.exec(lockfile);
|
|
32
|
+
return match?.[1] ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Ruby version declarations, read from the files that actually pin one. Values
|
|
36
|
+
// are reported verbatim: an odd but intentional pin is still worth showing.
|
|
37
|
+
export function rubyPins(root = process.cwd()) {
|
|
38
|
+
const pins = [];
|
|
39
|
+
const versionFiles = [".ruby-version", ".tool-versions", ".mise.toml"];
|
|
40
|
+
for (const file of versionFiles) {
|
|
41
|
+
if (!exists(root, file)) continue;
|
|
42
|
+
const contents = read(root, file);
|
|
43
|
+
const value = contents.match(/^\s*(\d+\.\d+(?:\.\d+)?)\s*$/m)?.[1]
|
|
44
|
+
?? contents.match(/^\s*ruby\s+(\d+\.\d+(?:\.\d+)?)\s*$/m)?.[1]
|
|
45
|
+
?? contents.match(/ruby\s*=\s*["'](\d+\.\d+(?:\.\d+)?)/)?.[1]
|
|
46
|
+
?? null;
|
|
47
|
+
if (value) pins.push({ file, value });
|
|
48
|
+
}
|
|
49
|
+
if (exists(root, "Dockerfile")) {
|
|
50
|
+
const match = read(root, "Dockerfile").match(/^\s*FROM\s+ruby:(\d+\.\d+(?:\.\d+)?)/im);
|
|
51
|
+
if (match) pins.push({ file: "Dockerfile", value: match[1] });
|
|
52
|
+
}
|
|
53
|
+
if (exists(root, "Gemfile")) {
|
|
54
|
+
const match = read(root, "Gemfile").match(/^\s*ruby\s+["']([\d.]+)["']/m);
|
|
55
|
+
if (match) pins.push({ file: "Gemfile", value: match[1] });
|
|
56
|
+
}
|
|
57
|
+
return pins;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// CI workflows are inspected conservatively. YAML matrix shapes vary far too
|
|
61
|
+
// much to parse reliably, so this only reports Ruby versions it can see as plain
|
|
62
|
+
// scalars and stays silent otherwise. Reporting nothing is better than guessing
|
|
63
|
+
// at a matrix it only partly understood.
|
|
64
|
+
export function ciRubyVersions(root = process.cwd()) {
|
|
65
|
+
const found = [];
|
|
66
|
+
const workflows = exists(root, ".github/workflows")
|
|
67
|
+
? fs.readdirSync(path.join(root, ".github/workflows")).filter((f) => /\.ya?ml$/.test(f))
|
|
68
|
+
: [];
|
|
69
|
+
for (const file of workflows) {
|
|
70
|
+
const contents = read(root, path.join(".github/workflows", file));
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
for (const match of contents.matchAll(/(?:ruby[-_]?version|ruby|RUBY_VERSION)["'\s:=]+(\d+\.\d+(?:\.\d+)?)/gi)) {
|
|
73
|
+
seen.add(match[1]);
|
|
74
|
+
}
|
|
75
|
+
if (seen.size) found.push({ file: path.join(".github/workflows", file), versions: [...seen].sort() });
|
|
76
|
+
}
|
|
77
|
+
return found;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const series = (version) => (version ?? "").split(".").slice(0, 2).join(".");
|
|
81
|
+
const sameSeries = (a, b) => series(a) === series(b) && series(a) !== "";
|
|
82
|
+
|
|
83
|
+
// `advisoryFindings` is intentionally read-only and dependency-free so it can be
|
|
84
|
+
// called from report writing without pulling in the Docker runtime machinery.
|
|
85
|
+
export function advisoryFindings({ root = process.cwd(), run = {}, inventory = {} } = {}) {
|
|
86
|
+
const findings = [];
|
|
87
|
+
const target = run.targetRuby;
|
|
88
|
+
const targetSeries = series(target);
|
|
89
|
+
const pins = rubyPins(root);
|
|
90
|
+
const ciVersions = ciRubyVersions(root);
|
|
91
|
+
const bundler = bundledWith(root);
|
|
92
|
+
|
|
93
|
+
for (const pin of pins) {
|
|
94
|
+
if (!target || sameSeries(pin.value, target)) continue;
|
|
95
|
+
findings.push(finding(
|
|
96
|
+
"review",
|
|
97
|
+
"version-pin",
|
|
98
|
+
`${pin.file} pins Ruby ${pin.value}, but this run targeted ${target}`,
|
|
99
|
+
`Update this if the deploy platform reads it, or leave it if it intentionally lags the app. Confirm which one your production runtime actually honours.`,
|
|
100
|
+
`${pin.file}: ${pin.value}`
|
|
101
|
+
));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const pinFiles = new Set(pins.map((p) => p.file));
|
|
105
|
+
if (!pinFiles.has("Dockerfile") && exists(root, "Dockerfile")) {
|
|
106
|
+
findings.push(finding("review", "version-pin", "Dockerfile has no parseable Ruby base image", "The base image tag could not be read, so its Ruby version was not compared against this run.", "Dockerfile: unparsed", "review"));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const workflow of ciVersions) {
|
|
110
|
+
const behind = workflow.versions.filter((version) => targetSeries && series(version) < targetSeries);
|
|
111
|
+
if (!behind.length) continue;
|
|
112
|
+
findings.push(finding(
|
|
113
|
+
"review",
|
|
114
|
+
"ci",
|
|
115
|
+
`${workflow.file} does not test Ruby ${target}`,
|
|
116
|
+
`It references ${behind.join(", ")}. If CI keeps testing the older version, a regression on the new one would not be caught before merge.`,
|
|
117
|
+
`${workflow.file}: ${workflow.versions.join(", ")}`
|
|
118
|
+
));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (bundler && target) {
|
|
122
|
+
findings.push(finding(
|
|
123
|
+
"review",
|
|
124
|
+
"bundler",
|
|
125
|
+
`Gemfile.lock pins Bundler ${bundler}`,
|
|
126
|
+
`This run validated the app inside a container. Bundler switches to the version in \`BUNDLED WITH\`, so confirm your deploy host and CI resolve ${bundler} and that it supports Ruby ${target}. If it does not, the hop needs a Bundler upgrade scoped as its own run.`,
|
|
127
|
+
`Gemfile.lock BUNDLED WITH: ${bundler}`
|
|
128
|
+
));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (inventory.deploy?.length && !exists(root, ".ruby-version") && !exists(root, "Dockerfile")) {
|
|
132
|
+
findings.push(finding("review", "platform", "No shared Ruby version file", `Deploy files exist (${inventory.deploy.join(", ")}) but there is no \`.ruby-version\`, \`.tool-versions\`, \`.mise.toml\`, or \`Dockerfile\`. The Ruby version may then be defined only inside a platform dashboard or base image you cannot see from here.`, `deploy: ${inventory.deploy.join(", ")}`));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (exists(root, "Gemfile.lock") && !fs.statSync(path.join(root, "Gemfile.lock")).isFile()) {
|
|
136
|
+
findings.push(finding("review", "lockfile", "Gemfile.lock is not a regular file", "Its BUNDLED WITH value was not read, so no Bundler finding is reported.", "Gemfile.lock", "review"));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return findings;
|
|
140
|
+
}
|