Skip to content

Add BUNDLE_GEMFILE-aware dual-boot support (rebased on v359)#4

Open
JuanVqz wants to merge 12 commits into
sync_v359from
use_gemfile_next_v359
Open

Add BUNDLE_GEMFILE-aware dual-boot support (rebased on v359)#4
JuanVqz wants to merge 12 commits into
sync_v359from
use_gemfile_next_v359

Conversation

@JuanVqz

@JuanVqz JuanVqz commented May 25, 2026

Copy link
Copy Markdown
Member

Summary

Adds dual-boot (next_rails) support on top of upstream v359. The buildpack reads the BUNDLE_GEMFILE env var (typically a Heroku config var) to decide which Gemfile drives the build. When BUNDLE_GEMFILE is unset, the buildpack behaves like stock heroku/ruby, making this a drop-in replacement.

Concretely:

  • BUNDLE_GEMFILE unset → uses Gemfile and Gemfile.lock (default Heroku behavior).
  • BUNDLE_GEMFILE=Gemfile.next → uses Gemfile.next and Gemfile.next.lock for the build, including bundler/ruby version detection, bundle install, bundle clean, bundle list, rake task detection, and runtime dyno boot.

A Gemfile.next that is a symlink to Gemfile (the common next_rails setup) works as-is: Bundler does not resolve the symlink, so File.basename(__FILE__) and the lockfile name both resolve to Gemfile.next. Verified across Bundler 2.1.4, 2.5.17, 2.5.22, 2.6.6, and 4.0.9. No symlink materialization is needed.

⚠️ Known limitation: switching BUNDLE_GEMFILE needs a rebuild, not just a config change

Heroku triggers a re-release but not a rebuild when a config var changes, and the build is what installs gems. So flipping BUNDLE_GEMFILE alone never installs the other Rails version's gems.

If an app built with Rails 6.1 has its BUNDLE_GEMFILE changed to Gemfile.next (Rails 7.0) without a redeploy, and it has a release: command in its Procfile, the release phase runs against Rails 7.0 whose gems are not in the slug, so it fails and Heroku reverts the config var. In the latest version of this fork the re-release no longer fails, but the change is not applied at runtime either, so Heroku reports the var changed while the app keeps running the previous version.

Always change BUNDLE_GEMFILE and then deploy, so the correct gems are installed at build time. This is documented at the top of the README.

Deployment steps

  1. Make sure your app boots locally with BUNDLE_GEMFILE=Gemfile.next bundle exec rails -v showing the next Rails version.
  2. Point the app at this branch: heroku buildpacks:set https://github.com/fastruby/heroku-buildpack-ruby#use_gemfile_next_v359 -a <app>.
  3. Set the config var: heroku config:set BUNDLE_GEMFILE=Gemfile.next -a <app>.
  4. Purge cache (recommended on first switch): heroku repo:purge_cache -a <app> (requires heroku-repo plugin).
  5. Deploy: git push heroku <branch>:main.
  6. Verify: heroku run "bin/rails --version" -a <app> should show the next Rails version.

To flip back: heroku config:unset BUNDLE_GEMFILE -a <app> and redeploy.

QA notes

  1. Pick a project that already has the dual-boot configured and running locally.

  2. In Heroku, go to Settings → Buildpacks. Remove heroku/ruby (or any previous fork branch) and add:

    https://github.com/fastruby/heroku-buildpack-ruby#use_gemfile_next_v359
    
  3. In Settings → Config Vars, add BUNDLE_GEMFILE with value Gemfile.next, then click Add. Heroku may take a moment to apply.

  4. In the Overview tab, confirm the new config var is listed.

  5. Trigger a deploy (push to the Heroku remote, or use Deploy → Manual deploy for GitHub-connected apps). The build log should install gems for Gemfile.next.lock.

  6. Once deployed, verify the running version:

    heroku run "bin/rails --version" -a <app>
    # Rails 8.1.x
    
    heroku run "bash -c 'echo $BUNDLE_GEMFILE'" -a <app>
    # /app/Gemfile.next
    
  7. To flip back to the current Gemfile:

    • heroku config:unset BUNDLE_GEMFILE -a <app>
    • Redeploy (this rebuilds and installs Gemfile.lock). The buildpack behaves like stock heroku/ruby again. A config-var change alone is not enough (see the known limitation above).

Comparison vs the previous fork branches

Aspect add_gemfile_next_support (2024) use_gemfile_next_v359 (this PR)
Base v270-ish (2 years stale) v359 (current upstream)
Selection BUNDLE_GEMFILE env var BUNDLE_GEMFILE, defaults to Gemfile
rake_env BUNDLE_GEMFILE injection no yes
initialize_env before lockfile read no (bug) yes (fixed)
Reads from user_env_hash no yes
Bundler 4 lockfile support no (bootstrap 1.17.3) yes (inherited from v359)

What changed upstream between v270 (Apr 2024) and v359 (May 2026)

The rebase brings in 89 releases of fixes and new features. Highlights relevant to Ruby/Rails/bundler:

Bundler

  • v333: Bundler 4.0 support (apps with BUNDLED WITH 4.0.x now receive Bundler 4.0).
  • v341: Bundler version installed now matches BUNDLED WITH exactly. Previously the buildpack mapped to a "known good" version (e.g. 2.7.x would always install 2.7.2).
  • v340: Bundler is now installed via gem install bundler instead of being pre-built and downloaded from S3.
  • v346: Default bundler version bumped from 2.3.25 to 2.5.23. Ruby's stdlib bundler takes precedence when greater.
  • v330: Supports BUNDLED WITH written with 2-space indent (newer bundler format).
  • v326: Default 2.6.x → 2.6.9, 2.7.x → 2.7.2.

Ruby

  • v338: Ruby 4.0.0 available. Subsequent: 4.0.1 (v343), 4.0.2 (v352), 4.0.3 (v357), 4.0.5 (v359).
  • v336: Ruby 3.4.8. v351: 3.4.9.
  • v344: Ruby 3.2.10. v355: 3.2.11.
  • v354: Ruby 3.3.11. v327: 3.3.10. v323: 3.4.7. v276: 3.3.4.
  • v332 / v339: Ruby version is now read directly from Gemfile.lock instead of being detected by running bundle platform --ruby. As a result, Ruby is now installed before Bundler.

Stack and platform

  • v353: heroku-26 stack support.
  • v345: Default Node.js version is now 24.13.0.
  • v348: S3 downloads use dual-stack (IPv6) endpoints; fix for invalid DATABASE_URL when adapter is unknown.
  • v322: Sets PUMA_PERSISTENT_TIMEOUT=95 to match Router 2.0 recommendations. Warns on Puma < 7.0.0, errors on Puma 7.0.0 to 7.0.2.

Removed / cleaned up

  • v277: Stopped bundling bootstrap Ruby for each stack inside the buildpack archive.

Net effect for this PR

  • We no longer fight a stale bootstrap Bundler 1.17.3 to read modern lockfiles.
  • Bundler 4 lockfiles deploy without manual downgrade.
  • The buildpack reads Ruby version directly from Gemfile.lock (or in our case Gemfile.next.lock), so the dual-boot wiring threads through cleanly without needing to reproduce the old bundle platform --ruby shell-out.

JuanVqz added 5 commits May 25, 2026 14:24
Rebases the spirit of PR #3 (use_gemfile_next) onto upstream v359.
All buildpack phases now operate on Gemfile.next / Gemfile.next.lock
instead of the default Gemfile / Gemfile.lock.

Spots patched:
- lib/language_pack.rb: top-level lockfile detection reads
  Gemfile.next.lock (drives bundler version and ruby version resolution).
- lib/language_pack/helpers/bundler_wrapper.rb: default gemfile_path is
  ./Gemfile.next.
- lib/language_pack/ruby.rb:
  * self.use? detects by Gemfile.next presence.
  * setup_language_pack_environment sets ENV['BUNDLE_GEMFILE'] for
    in-process build steps.
  * setup_export exports BUNDLE_GEMFILE for subsequent buildpacks.
  * setup_profiled sets the runtime override so dynos boot Gemfile.next.
  * build_bundler installs gems for Gemfile.next.
  * rake_env injects BUNDLE_GEMFILE into the rake subprocess (this is
    the spot the original PR #3 missed, which caused the rake-task
    detection step to fall back to Gemfile.lock).
When an app uses the common next_rails dual-boot setup with Gemfile.next
as a symlink to Gemfile, the __FILE__-based 'next?' check can resolve
inconsistently across Bundler versions: older Bundler keeps __FILE__ as
the symlink path ('Gemfile.next'), newer Bundler may dereference it to
'Gemfile', silently flipping the dual-boot conditional and installing
the wrong Rails version.

Rewrite the symlink to a real file containing the same contents at the
start of build_bundler. The repo keeps the symlink for the developer
workflow; only the buildpack's working copy is changed.
Replace the hardcoded 'Gemfile.next' references with a single source of
truth: LanguagePack.gemfile_name, which reads ENV['BUNDLE_GEMFILE'] and
returns its basename, defaulting to 'Gemfile' when unset. Lockfile name
is derived as '<gemfile>.lock'.

With this change, the fork acts as a drop-in replacement for the stock
heroku/ruby buildpack when BUNDLE_GEMFILE is not set, and switches to a
next_rails-style alternative (e.g. Gemfile.next) when the env var is set
via a Heroku config var. The symlink materializer also operates on the
chosen Gemfile generically.
Heroku passes user-set config vars to buildpacks via the env dir
(ARGV[2] of ruby_compile), not by setting ENV directly. The previous
implementation read ENV['BUNDLE_GEMFILE'] before calling initialize_env,
so the value was always empty and gemfile_name defaulted to 'Gemfile'
even when the user had set BUNDLE_GEMFILE=Gemfile.next.

Two fixes:
- ruby_compile.rb: call initialize_env before LanguagePack.gemfile_lock
  so the env dir is loaded before we touch any lockfile.
- LanguagePack.gemfile_name: fall back to user_env_hash when ENV is empty
  so the value is visible to all phases of the build, not just those that
  pipe with user_env: true.
- Gate the integration-test job on github.repository == upstream so forks
  do not fail on a job that requires Heroku API secrets they cannot have.
- Add CHANGELOG entries describing the BUNDLE_GEMFILE wiring and the CI
  fork-gate. Satisfies the check-changelog CI job.
@JuanVqz JuanVqz force-pushed the use_gemfile_next_v359 branch from 57def8f to f8b404f Compare May 25, 2026 21:47
Comment thread .github/workflows/ci.yml Outdated
# read. Without this, gemfile_lock would always read Gemfile.lock regardless
# of the user's BUNDLE_GEMFILE setting.
LanguagePack::ShellHelpers.initialize_env(ARGV[2])
gemfile_lock = LanguagePack.gemfile_lock(app_path: app_path)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heroku passes user config vars to buildpacks via a directory of files (one file per var), not as actual ENV entries. The path to that dir is ARGV[2] in ruby_compile.rb.
Until LanguagePack::ShellHelpers.initialize_env(ARGV[2]) runs, that data isn't loaded anywhere.

Sequence before our fix:

gemfile_lock = LanguagePack.gemfile_lock(...)   # reads ENV["BUNDLE_GEMFILE"] → ""
Dir.chdir(app_path)
LanguagePack::ShellHelpers.initialize_env(...)  # now user_env_hash["BUNDLE_GEMFILE"] = "Gemfile.next"

LanguagePack.gemfile_name (which gemfile_lock calls via lockfile_name) reads:

  ENV["BUNDLE_GEMFILE"]                            # empty, Heroku doesn't set it in ENV
  || LanguagePack::ShellHelpers.user_env_hash[..]  # empty too, initialize_env hasn't run yet
  || "Gemfile"                                     # falls through to default

So it picked Gemfile.lock. The bundler/Ruby version detection then used the wrong lockfile, and build_bundler later built against Gemfile. Meanwhile bundle list (called with user_env: true) read user_env_hash["BUNDLE_GEMFILE"] = Gemfile.next and tried to verify gems from Gemfile.next.lock which had never been installed. That's the exact mismatch the failed build exposed.

After moving the line below initialize_env, user_env_hash is populated first, gemfile_name resolves to Gemfile.next, and every later phase agrees on the same lockfile.

@JuanVqz JuanVqz changed the base branch from sync-upstream-main to sync_v359 May 25, 2026 22:03
Comment thread .github/workflows/ci.yml Outdated
Comment thread lib/language_pack/ruby.rb Outdated
JuanVqz added 4 commits June 9, 2026 13:22
Bundler does not resolve the Gemfile.next symlink: File.basename(__FILE__)
and the lockfile name both resolve to Gemfile.next across Bundler 2.1.4,
2.5.17, 2.5.22, 2.6.6, and 4.0.9. The dual-boot trick works with the symlink
as-is. The earlier staging failures were the BUNDLE_GEMFILE env-dir ordering
bug (fixed separately), not the symlink, so this materializer is unnecessary.
The integration-test job needs Heroku API secrets only present on the
upstream repo, and lint runs locally. Drop the workflow in this fork
rather than carry a fork-only conditional.
CHANGELOG.md tracks the upstream buildpack releases; fork-specific notes
do not belong here. Documented in the README instead.
Trim the inherited upstream content (which drifts and confuses) and link to
the official buildpack instead. State the fork's purpose, document the
BUNDLE_GEMFILE dual-boot usage, point to the next-only sibling fork, and add
a top-level warning that switching BUNDLE_GEMFILE needs a rebuild, not just a
config change, or a Procfile release command fails on the uninstalled version.
Comment thread README.md

## Documentation
```sh
heroku buildpacks:set https://github.com/fastruby/heroku-buildpack-ruby#use_gemfile_next_v359 -a <app>

@JuanVqz JuanVqz Jun 9, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@arielj are we going to merge this PR or keep it as the previous version? if so, we need to update this to main or probably just the URL will pickup the default branch.

JuanVqz added 2 commits June 9, 2026 13:34
This fork does not maintain its own CHANGELOG (it tracks upstream's), so the
upstream convention check that requires every PR to touch CHANGELOG.md does
not apply and would block all fork PRs.
document_ruby_version, hatchet_app_cleaner, and prepare-release are upstream
release/maintenance automation that needs upstream secrets and conventions.
They do not apply to this fork.
@JuanVqz

JuanVqz commented Jun 10, 2026

Copy link
Copy Markdown
Member Author

@arielj, I want your opinion on the release process. AI suggested 3 options:

Option A: rebase a thin patch per upstream release (recommended)

  • Fork main mirrors upstream/main exactly. Never commit fork code to it.
  • Dual-boot lives as a handful of commits on a branch, rebased on top of upstream when you sync.
  • This is literally what you're already doing: use_gemfile_next_v359 = v359 + patch.

Sync flow:

git fetch upstream
git checkout main && git merge --ff-only upstream/main && git push origin main
git checkout gemfile-next && git rebase main
# resolve conflicts (only in the ~4 files you touch), then:
git push --force-with-lease origin gemfile-next
  • Pro: clean linear history, tiny conflict surface, easy to see "our patch vs upstream," easy to re-cut per version.
  • Con: force-push each sync (fine, nobody depends on commit stability, only the buildpack URL needs latest).

Option B: merge upstream into a long-lived branch

Keep one permanent gemfile-next branch, git merge upstream/main into it each sync.

  • Pro: no force-push, conflicts resolved incrementally.
  • Con: merge-commit noise, harder to isolate "just our change," README/CHANGELOG churn every merge.

Option C: automate the sync

A scheduled GitHub Action (or local bin/sync-upstream script) that fetches upstream, fast-forwards main, rebases the patch, opens a PR.

  • Pro: hands-off, regular.
  • Con: you just removed all workflows; conflicts still need a human. Better as a local script than a re-added Action.

My recommendation

Option A, plus a small local bin/sync-upstream helper so the rebase dance is one command. It matches your existing per-version workflow, keeps the patch reviewable, and the tiny footprint means rebase
conflicts are rare and localized. Drop the version suffix and use a stable branch name like gemfile-next so the buildpack URL never changes; tag v359, v360 etc. at each sync if you want pinnable points.

@JuanVqz JuanVqz requested a review from arielj June 10, 2026 21:02
@arielj

arielj commented Jun 11, 2026

Copy link
Copy Markdown

I think we should do option A, main branch being the latest official buildpack code + some patches, and we tag versions so they can be used in heroku

Comment thread README.md Outdated
Comment on lines +15 to +16
> - Heroku reverts the config var change because the release failed.
> - In the latest version of this fork the re-release no longer fails, but the env change is **not actually applied at runtime** either, so Heroku reports the var changed while the app keeps running the previous version. Confusing, but expected.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if these 2 lines are needed, or maybe they can be confusing

someone using this version might not understand what's this referring to since they won't experience the failure/revert

I think that we can say something like...

When changing an environment variable, Heroku will trigger a re-release. In some cases, if the re-release fails, Heroku might revert the value to the previous one. If that happens for your app, we recommend using that supports deploying the next version without the use of the BUNDLE_GEMFILE variable.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated, is this any better?

Co-authored-by: arielj <ariel@ombulabs.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants