.offrails/generators/upgrade.md

Generator: upgrade (rails app:update)

TL;DR

  • Brings an app to a newer release: $FW is ~/.offrails/current, the plugin root, or a tag export, never a moving working tree.
  • Detect drift by content fingerprint; start from a clean git tree; read the CHANGELOG's App migration steps between versions.
  • Reconcile, don't re-create: classify each change (new / conforming / divergent / conflicting); never edit applied migrations; show the plan and wait for a yes.
  • Re-vendor, apply in green vertical slices, verify nothing shows a stale version, and log the upgrade in docs/decisions.md.
  • From 0.7.1: compare the app with .offrails/template/app/ (the tested kernel of the new release) as a reference for kernel changes, never a blind overwrite; and offer the parts for areas the app built by hand (adopt with cargo offrails add-part, borrow, or skip; nothing without the human's yes).

Read it all whenever you upgrade an app (offrails upgrade launches this recipe).

Brings an existing app up to a newer Rust off Rails release: it re-vendors .offrails/ and migrates the app to any changed conventions. Any agent can run this recipe (Claude Code, Codex, opencode, Cursor…). In Claude Code, /offrails:upgrade runs it for you.

$FW is the new framework:

  • after the one-line install: ~/.offrails/current (run offrails update first to get the latest release);
  • in Claude Code: the plugin root;
  • otherwise: a checkout of the framework at a release tag. Never vendor a moving working tree: vendor and update-part read a checkout's HEAD (git archive HEAD), not its working tree, and vendor records the commit as .offrails/VERSION's third line, from git v<version> (<sha>) at the tag, or … (unreleased: …) when HEAD is past it (a release branch before tagging, whose VERSION still names the previous release). Unreleased commits in a versioned app are then visible, not silent; check out the tag for a real upgrade.

Run it from the app's root (the directory with .offrails/).

1. Is there anything to upgrade?

Compare content, not just version numbers: docs can change without a bump.

bash "$FW/hooks/fingerprint.sh" .offrails
bash "$FW/hooks/fingerprint.sh" "$FW"
head -n1 .offrails/VERSION "$FW/VERSION"

An installed offrails older than 0.6.0 silently ignores --fw (and every other flag): offrails upgrade --check --fw "$FW" then compares against ~/.offrails/current instead and can report "current" for the wrong release. Run the target release's own script, sh "$FW/tools/offrails" upgrade --check --fw "$FW", or offrails update first (offrails version prints the installed release). From 0.6.1 the CLI rejects unknown flags with exit 2 and answers -h/--help on every subcommand without doing anything else.

If the fingerprints match, the app is current: say so and stop. (offrails upgrade --check does exactly this against ~/.offrails/current; offrails upgrade --check --fw "$FW" against any other release, e.g. a tag export: git -C <framework checkout> archive v0.6.0 | tar -x -C /tmp/offrails-0.6.0. Exit 0 = current, 10 = an upgrade is due. --check never starts an agent; plain offrails upgrade from a script or another agent needs --yes, else it prints the agent command and exits 3.)

2. Start clean

Require a clean git tree. If it isn't clean, ask the human to commit first, or commit for them if they agree.

Preconditions for the proofs. The App migration steps prove UI changes with the framework's browser smokes (.offrails/tools/browser-smoke/run.sh), which need Node ≥ 22.12 (Node 24 LTS recommended; puppeteer-core 25 refuses older) and an installed Chrome. Check node --version now. Too old or missing: say so in the plan, and run the smokes once a Node that qualifies is on PATH (run.sh refuses older with that message). Postgres for TEST_DATABASE_URL is the other precondition (workflow).

3. Read what changed

Read $FW/CHANGELOG.md from the app's version up to the new one. Each release lists App migration steps written for an agent, in reconcile style (detect → final shape → converge). Then diff the docs (diff -ru .offrails "$FW" -x .git -x .claude-plugin -x skills -x hooks -x evals -x brand -x .in_use) to catch anything the changelog doesn't mention.

Compare with the template (0.7.1 and later). $FW/template/app/ is the new release's kernel, built and tested. Two diffs show what changed and where the app stands:

diff -ru .offrails/template/app "$FW/template/app"     # what the release changed in the kernel (old template → new); empty before 0.7.1
diff -ru -x target -x .git -x .offrails -x storage "$FW/template/app" .   # the app against the new template: long, the app has grown

Read the first one change by change: each is a candidate slice for step 7 (a fixed header, a new test, a hardened default). Use the second only to locate the app's version of each changed file. It's a reference, never an overwrite: the app's files have diverged on purpose (its routes, models, layout, copy, renamed crate), so never copy a template file over an app file. Port each change into the app's code, with its test, and classify it like any other change (step 4). Template tokens (offrails_app, Offrails App) are the app's crate and product name in the app.

4. Reconcile, don't re-create

For every change, first look for an equivalent the app already has, often built early from older docs or by hand: a table, migration, kernel module, route, template or test. Classify each change:

  • new: absent. Build it per the new docs.
  • present, conforming: matches the new spec. Nothing to do, though you may add missing tests.
  • present, divergent: exists in another shape, so reconcile it in place. Never re-create or edit an applied migration. Write a new migration that alters, renames, backfills or copies data, keeps existing rows, and has a working down. Rename modules, routes and templates to the spec's names, with redirects for any URL that moved.
  • conflicting: the app's version encodes a product decision (check docs/decisions.md and product/). Keep it, record it as a deviation, and ask the human only if it blocks the upgrade.

5. Show the plan, wait for a yes

List each change with its classification and what it means for this app: files affected, migrations, risk. Upgrading to 0.7.0 or later, add the parts offer from step 7b to the same message, so the human answers once. Wait for the human's yes.

Non-interactive runs (a headless agent, or a request relayed by another agent): proceed only if the human's request explicitly asked for the upgrade and nothing in the plan is outward-facing (no deploys, no production data). Write the plan to docs/decisions.md first, so the human can review what was decided.

6. Update the parts, then re-vendor the framework

First, while .offrails/ is still the old release (it is the base of the merge), bring each installed part to the new version:

cargo offrails update-part <part>… --framework "$FW" --dry-run   # the plan: take / keep / merge / CONFLICT per file and wire
cargo offrails update-part <part>… --framework "$FW"

An app on 0.7.1 or older has no update-part (its cargo offrails is the vendored, older offrails-gen): run the new release's instead, the same arguments plus --app .:

cargo run -q --manifest-path "$FW/tools/offrails-gen/Cargo.toml" --target-dir "target/offrails-gen-$(cat "$FW/VERSION")" -- \
  update-part <part>… --framework "$FW" --app . --dry-run    # then without --dry-run

After step 6's re-vendoring, cargo offrails is the new release's, and update-part works directly from then on.

Per file it is a three-way merge: the part as installed (.offrails/), the new part ($FW), the app's copy. A file only the part changed is taken; one only the app changed is kept; both changed merges line by line, and overlapping edits are written with <<<<<<< app / ||||||| <part> <old> / ======= / >>>>>>> <part> <new> markers (exit 1). Wiring the part changed is swapped where the old text still stands. It never overwrites an app change silently. Resolve every marker (grep -rn '<<<<<<<' src templates tests migration), then cargo fmt --all, cargo run -q -- db migrate, and the part's CHECKS.md. update-part logs updated sample <name> <old> -> <new> in docs/decisions.md.

Replace .offrails/ with the release, with the same exclusions as new.md step 1, and write .offrails/VERSION (framework version, then vendored <date>). On any OS:

cargo run -q --manifest-path "$FW/tools/offrails-gen/Cargo.toml" --target-dir "target/offrails-gen-$(cat "$FW/VERSION")" -- vendor --framework "$FW"

The target directory is the release's own (target/offrails-gen-0.7.3), never target/offrails-gen: a release whose files are older than the binary cached there would reuse the previous release's offrails-gen (seen as update-part: "unknown command"). Delete the old ones when you're done (rm -rf target/offrails-gen-*).

(Or rsync -a --delete --exclude /.git --exclude /.claude-plugin --exclude /skills --exclude /hooks --exclude /evals --exclude /brand --exclude /.in_use --exclude /media "$FW"/ .offrails/ then date -u +"vendored %F" >> .offrails/VERSION.) An app from before 0.7.1 also gets the cargo offrails alias: copy .offrails/template/app/.cargo/config.toml's [alias] into the app's .cargo/config.toml, and .gitattributes from the same place.

7. Apply in vertical slices

Each slice must be green under the Definition of Done before the next one.

A step applies only where its Detect matches. Many steps are for one kind of app: accounts, live regions, a development pending-migrations page, a CSS banner, channels. Run the detect first; a step for a feature the app doesn't have is n/a, listed as such in the plan with the reason, not built "to match". Where a step's proof needs something the app lacks, use the alternative it names (no accounts: run.sh a11y instead of run.sh login; its preflight still fails on an untrusted certificate). A detect that would match every app for a change that only some need is a changelog bug: apply what clearly applies, and say so in your report. Log YYYY-MM-DD: upgraded Off Rails <old> → <new> and each deviation in docs/decisions.md.

7b. Offer the parts

From 0.7.0, the parts most apps have (mailer, dev mailbox, jobs, jobs dashboard, accounts, realtime, a dev dashboard) exist as tested, security-reviewed code: the samples, shipped in every release as parts in .offrails/template/parts/ (samples). For an app that built these from the guides, they're often better than what it grew, and sometimes they aren't. Recommend the parts that fit, show the human what they get (https://github.com/rust-off-rails/samples has screenshots of every screen), and install only what they say yes to.

Skip the step when docs/decisions.md records an earlier answer (unless the parts gained something since; then offer only that). In a non-interactive run, install nothing: put the offer in the report so the human can answer later.

  1. Find the areas the app has. Run each detect:

    AreaDetectSample
    Mailerls src/kernel/mailer.rs src/mailers/mod.rsmailer
    Dev mailboxgrep -rln '"/dev/mailbox' srcdev-mailbox
    Jobsls -d src/kernel/jobsjobs
    Jobs dashboardgrep -rln '"/admin/jobs' srcjobs-dashboard
    Accountsthe ACCOUNTS: header line isn't none; passwords: grep -rln password_digest src/models; magic links: ls src/models/login_link.rs; two-factor: grep -rli totp srcauth-core + auth-password / auth-magic-link, auth-totp
    Realtimels -d src/channels src/kernel/channels.rs src/kernel/pubsub.rsrealtime
    Dev dashboardgrep -rn '"/dev/dashboard' src (usually nothing: it's new; recommend it for every app)dev-dashboard
  2. Compare honestly. For each area found, read the sample's README (first section and Adapting it) and look at what the app's version already does. Write one or two sentences per area: what the sample would add to this app, and what adopting costs (the files it replaces, tables to reconcile, new routes, more code to own). Where the app already does it, say so, and don't offer what it doesn't need. The samples table has the headline features: a mailbox with HTML, text and source views and a 320 px phone lint; jobs on the database with a dashboard that groups errors by root cause; accounts with TOTP, recovery codes, magic link + code and an account security page; channels with presence and authorization on every join; a LiveDashboard-style dev dashboard with traces, SQL with N+1 detection, a log tail and a routes explorer.

  3. Ask once, with the plan (step 5), as one question per area: adopt (install the sample in place of the app's version), borrow (port named ideas into the app's own code) or skip. Be enthusiastic about what's genuinely better and plain about the cost. For example:

    This release brings parts: tested, security-reviewed code for things this app has or should have. See every screen first: https://github.com/rust-off-rails/samples. I recommend these; nothing changes unless you say yes.

    • Dev mailbox: ours shows each email's HTML and text. The sample adds a live inbox, phone-width (320 px) and dark previews that flag mail that breaks in real inboxes, one-key copy of sign-in links and codes, search and a CLI. It replaces kernel/mailbox.rs and its templates; stored mail stays.
    • Jobs dashboard: ours lists jobs and retries them. The sample groups failures by root cause, charts throughput and queue wait, and shows what's running now. It replaces kernel/jobs/web.rs and its templates; no schema change.
    • Dev dashboard (new): charts, request traces, every SQL query with N+1 warnings and EXPLAIN, a live log, and a routes explorer at /dev/dashboard, in development only. It's new code with no change to existing pages.

    For each: adopt it, borrow specific ideas into our code, or skip?

  4. Do what they chose, after the convergence slices of step 7, one slice per sample:

    • Adopt: cargo offrails add-part <name> (dependencies come first), per samples → Using one. It refuses, changing nothing, when an anchor is missing or a file it brings exists and differs: add the anchors from new → anchors and port the template pieces it names (an app built from the guides before 0.7.1 often needs this), or apply the wiring by hand from the part's README. Never copy a migration for a table the app already has: reconcile it with a new migration. Keep the app's working code until the part's tests and the app's are green with the part in place; then delete what it replaced. Add its CHECKS.md rows to the relevant feature spec's ## Security table. Then the Definition of Done on SQLite and Postgres.
    • Borrow: port each named idea into the app's own code, with a test, as an ordinary change.
    • Skip: nothing to build.
    • Never replace working code without that yes, and never adopt a sample for an area the human didn't pick.
  5. Record each answer: YYYY-MM-DD: parts offered on upgrade to <version>: dev-mailbox adopted (v<x>), jobs-dashboard borrowed (error groups), dev-dashboard skipped (add-part also logs each install).

8. Verify nothing is stale

  • sh .offrails/tools/offrails-check exits 0. Its FAIL lines are convergence work for this upgrade, not pre-existing debt to report and leave. Two that older specs and configs hit:

    • n/a threat rows. Detect: grep -nE '^\|[^|]*\|[[:space:]]*(n/a|N/A|none|—)[[:space:]]*\|' product/features/*.md, or [security] … says "n/a" with no reason / names no test. Converge: a STRIDE category that can't apply becomes | <Category> | n/a: <why it can't happen here> | — | — |; a row that names a real threat gets its test written and named, never —.
    • .env.example gaps. Detect: [env] .env.example is missing variable(s) (the old DoD grep only matched var("…"), so variables read through parse("X", …) or another helper were never checked). Converge: add each as NAME=value or # NAME=value with a comment (config); a "SCREAMING_SNAKE" literal in src/config.rs that isn't an env var gets a trailing // not an env var.
  • Reconcile docs/decisions.md. Read every line. For each decision this release makes obsolete (a workaround for a bug the release fixes, a deviation the framework now adopts, a limit the release resizes), add a line that supersedes it by date (YYYY-MM-DD: supersedes 2026-10-04 (stall watchdog): 0.6.0 serves HTTP/2, removed the watchdog) and remove the code it justified in the same slice. Never edit or delete the old line (workflow). Keep a workaround only if you can still reproduce its evidence on the new release, and say so in the superseding line.

  • Copy the app header's NEVER list from blueprints/AGENTS.md into the app's AGENTS.md when it's missing or older.

  • Grep the app (templates, views, web/src, docs, tests) for the old version string. Anything that displays the framework version must read .offrails/VERSION, never a literal.

  • Run the app's own post-upgrade checks (its AGENTS.md, "Post-upgrade checks"). If the app's AGENTS.md predates that section, add it from blueprints/AGENTS.md and list anything this upgrade taught you to verify next time.

  • bash "$FW/hooks/fingerprint.sh" .offrails now equals the framework's.

9. Report

What changed, in product terms where it matters, plus the deviations you kept.