diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..7fbb2a885 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,12 @@ +# Default to requesting pull request reviews from the Heroku Languages team. +#ECCN:Open Source +#GUSINFO:Heroku - Languages,Heroku Ruby Platform +* @heroku/languages + +# However, request review from the language owner instead for files that are updated +# by Dependabot or release automation, to reduce team review request noise. +CHANGELOG.md @schneems +Gemfile.lock @schneems +/.github/workflows/ @schneems +/changelogs/ @schneems +/lib/language_pack/version.rb @schneems diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..8fcdec8d6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,24 @@ +version: 2 +updates: + - package-ecosystem: "bundler" + directory: "/" + open-pull-requests-limit: 1 # Limit concurrent CI runs from executing, do not remove + schedule: + interval: "monthly" + labels: + - "dependencies" + - "ruby" + - "skip changelog" + groups: + ruby-dependencies: + update-types: + - "minor" + - "patch" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + labels: + - "dependencies" + - "github actions" + - "skip changelog" diff --git a/.github/workflows/check_changelog.yml b/.github/workflows/check_changelog.yml new file mode 100644 index 000000000..240c20261 --- /dev/null +++ b/.github/workflows/check_changelog.yml @@ -0,0 +1,20 @@ +name: Check Changelog + +on: + pull_request: + types: [opened, reopened, labeled, unlabeled, synchronize] + +permissions: + contents: read + +jobs: + check-changelog: + runs-on: ubuntu-latest + if: (!contains(github.event.pull_request.labels.*.name, 'skip changelog')) + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Check that CHANGELOG is touched + run: | + git fetch origin ${{ github.base_ref }} --depth 1 && \ + git diff remotes/origin/${{ github.base_ref }} --name-only | grep CHANGELOG.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..754e06ac5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + # Avoid duplicate builds on PRs. + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: "3.3.9" + - name: Run StandardRB + run: bundle exec standardrb + - name: Run ShellCheck bin top level + run: | + # All bash files that don't end in '.rb' + # https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefpathspecapathspec + git ls-files -z --cached --others --exclude-standard ':(exclude)*.rb' 'bin/*' '*/bin/*' '*.sh' | \ + xargs -0 shellcheck --check-sourced --color=always + + integration-test: + runs-on: ubuntu-24.04 + env: + HATCHET_APP_LIMIT: 300 + HATCHET_EXPENSIVE_MODE: 1 + HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} + HEROKU_API_USER: ${{ secrets.HEROKU_API_USER }} + RAILS_LTS_CREDS: ${{ secrets.RAILS_LTS_CREDS }} + HEROKU_DISABLE_AUTOUPDATE: 1 + PARALLEL_SPLIT_TEST_PROCESSES: 85 + RSPEC_RETRY_RETRY_COUNT: 1 + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Install Ruby and dependencies + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: "3.3.9" + - name: Hatchet setup + run: bundle exec hatchet ci:setup + - name: Run Hatchet integration tests + # parallel_split_test runs rspec in parallel, with concurrency equal to PARALLEL_SPLIT_TEST_PROCESSES. + run: bundle exec parallel_split_test spec/ diff --git a/.github/workflows/document_ruby_version.yml b/.github/workflows/document_ruby_version.yml new file mode 100644 index 000000000..8eda9a650 --- /dev/null +++ b/.github/workflows/document_ruby_version.yml @@ -0,0 +1,43 @@ +name: Add Ruby version to changelog && prepare release +run-name: "Add ${{ inputs.is_jruby && 'J' || ''}}Ruby ${{ inputs.ruby_version }} to the CHANGELOG.md and prepare a release" + +on: + workflow_dispatch: + inputs: + ruby_version: + description: "The Ruby version to announce" + type: string + required: true + is_jruby: + description: "JRuby release? (as opposed to MRI)" + type: boolean + default: false + required: false + +# Disable all GITHUB_TOKEN permissions, since the GitHub App token is used instead. +permissions: {} + +jobs: + prepare-release: + uses: heroku/languages-github-actions/.github/workflows/_classic-buildpack-prepare-release.yml@latest + secrets: inherit + with: + custom_update_command: | + set -euo pipefail + DATE_TODAY="$(date --utc --iso-8601)" + + sed --in-place "/## \[v${NEW_VERSION}\] - ${DATE_TODAY}/a\\ + \\ + - ${{ inputs.is_jruby && 'J' || ''}}Ruby ${{inputs.ruby_version}} is now available" CHANGELOG.md + + sed --in-place --regexp-extended \ + --expression "s/v${EXISTING_VERSION}/v${NEW_VERSION}/" \ + lib/language_pack/version.rb + + if compgen -G 'changelogs/unreleased/*.md' > /dev/null; then + # The unreleased changelogs directory contains a `.gitkeep` file, so we have to + # copy the markdown files individually instead of renaming the directory. + NEW_CHANGELOG_DIR="changelogs/v${NEW_VERSION}/" + mkdir -p "${NEW_CHANGELOG_DIR}" + mv changelogs/unreleased/*.md "${NEW_CHANGELOG_DIR}" + fi diff --git a/.github/workflows/hatchet_app_cleaner.yml b/.github/workflows/hatchet_app_cleaner.yml new file mode 100644 index 000000000..974e30d42 --- /dev/null +++ b/.github/workflows/hatchet_app_cleaner.yml @@ -0,0 +1,31 @@ +name: Hatchet app cleaner + +on: + schedule: + # Daily at 6am UTC. + - cron: "0 6 * * *" + # Allow the workflow to be manually triggered too. + workflow_dispatch: + +permissions: + contents: read + +jobs: + hatchet-app-cleaner: + runs-on: ubuntu-latest + env: + HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} + HEROKU_API_USER: ${{ secrets.HEROKU_API_USER }} + HEROKU_DISABLE_AUTOUPDATE: 1 + steps: + - name: Checkout + uses: actions/checkout@v7 + - name: Install Ruby and dependencies + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: "3.3.9" + - name: Run Hatchet destroy + # Only apps older than 10 minutes are destroyed, to ensure that any + # in progress CI runs are not interrupted. + run: bundle exec hatchet destroy --older-than 10 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 000000000..2e6ff4c71 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,27 @@ +name: Prepare Release + +on: + workflow_dispatch: + +# Disable all GITHUB_TOKEN permissions, since the GitHub App token is used instead. +permissions: {} + +jobs: + prepare-release: + uses: heroku/languages-github-actions/.github/workflows/_classic-buildpack-prepare-release.yml@latest + secrets: inherit + with: + custom_update_command: | + set -euo pipefail + + sed --in-place --regexp-extended \ + --expression "s/v${EXISTING_VERSION}/v${NEW_VERSION}/" \ + lib/language_pack/version.rb + + if compgen -G 'changelogs/unreleased/*.md' > /dev/null; then + # The unreleased changelogs directory contains a `.gitkeep` file, so we have to + # copy the markdown files individually instead of renaming the directory. + NEW_CHANGELOG_DIR="changelogs/v${NEW_VERSION}/" + mkdir -p "${NEW_CHANGELOG_DIR}" + mv changelogs/unreleased/*.md "${NEW_CHANGELOG_DIR}" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..67261321d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,36 @@ +name: Release Buildpack + +on: + # Auto-trigger when the "Prepare release" PR (created by the + # _classic-buildpack-prepare-release.yml workflow) is merged. + pull_request: + branches: + - main + types: + - closed + workflow_dispatch: + +permissions: + id-token: write + contents: write + +jobs: + release: + name: Release + # On `pull_request`, only run for the merged auto-generated + # "Prepare release" PR (branch name set by + # _classic-buildpack-prepare-release.yml). Manual dispatches always run. + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.merged == true && + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref == 'prepare-release' && + github.event.pull_request.user.login == 'heroku-linguist[bot]') + uses: heroku/languages-github-actions/.github/workflows/_classic-buildpack-publish.yml@latest + with: + buildpack_id: "heroku/ruby" + app_id: ${{ vars.LINGUIST_GH_APP_ID }} + secrets: + app_private_key: ${{ secrets.LINGUIST_GH_PRIVATE_KEY }} + slack_webhook_url: ${{ secrets.BUILDPACK_RELEASE_FAILURE_WEBHOOK_URL }} + honeycomb_api_key: ${{ secrets.HONEYCOMB_API_KEY }} diff --git a/.gitignore b/.gitignore index d3c5516c2..79d3e9fdc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ vendor/bundle/* .ruby-version buildpacks/* .anvil/ +tmp/*.log +log/*.log +spec/examples.txt diff --git a/.rspec b/.rspec new file mode 100644 index 000000000..e69de29bb diff --git a/.standard.yml b/.standard.yml new file mode 100644 index 000000000..f2d6f73eb --- /dev/null +++ b/.standard.yml @@ -0,0 +1,7 @@ +# StandardRB configuration +# https://github.com/standardrb/standard + +ignore: + - "repos/**/*" # Test fixture repos (external Rails apps for integration tests) + - "lib/language_pack/ruby.rb": + - Lint/IneffectiveAccessModifier # Class methods after `private` are intentionally public; private only affects instance methods diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index d994c0faf..000000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ ---- -language: ruby -rvm: -- 2.0.0 - -before_script: bundle exec rake hatchet:setup_travis - -# Run tests in parallel -script: bundle exec parallel_rspec -n 11 spec/ - -after_script: - - heroku keys:remove ~/.ssh/id_rsa - -env: - global: - - HATCHET_RETRIES=3 - - IS_RUNNING_ON_TRAVIS=true - - HATCHET_DEPLOY_STRATEGY=git - # sets the HEROKU_API_KEY to a dummy user for Heroku deploys - - secure: |- - ccqb7fKumq2+VEkSrkCqmLjALj5R/ZDgAQGZULSFYeD0O0man3hezUEbMB53 - U+7gLkbqz5saFX9S5Sz26f99vmhWz1bMHkC2UtlyFSlgaqSvXMZNHJAweqRY - ptqugqtvT0VVl1g3DInVgjDZhjINICv/CIorNuHijOFanP1Zmxg= diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a0caf34..4d9a1dc88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,1239 @@ -## Master +# Changelog -## v108 (2/27/2014) +## [Unreleased] + + +## [v362] - 2026-08-25 + +- Yarn is now downloaded from the npm registry instead of the `heroku-nodebin` S3 bucket ([#1761](https://github.com/heroku/heroku-buildpack-ruby/pull/1761)) +- Warn when app has a `bin/bundle` binstub that can cause bundler version issues ([#1728](https://github.com/heroku/heroku-buildpack-ruby/pull/1728)) + +## [v361] - 2026-06-11 + +- JRuby 10.0.6.0 is now available + + +## [v360] - 2026-06-08 + +- JRuby 9.4.15.0 is now available + + +## [v359] - 2026-05-29 + + +## [v359] - 2026-05-20 + +- Ruby 4.0.5 is now available + + +## [v358] - 2026-05-18 + +- Changed curl calls from `--silent` to `--no-progress-meter`. Using `curl -s` without `--show-error` silently swallows error messages when downloads fail, making failures hard to debug. Replace with `--no-progress-meter` which hides only the progress bar while still showing error messages and retry backoff status. + +## [v357] - 2026-04-21 + +- Ruby 4.0.3 and JRuby 10.1.0.0 is now available + + +## [v356] - 2026-04-08 + +- JRuby 10.0.5.0 is now available + + +## [v355] - 2026-03-27 + +- Ruby 3.2.11 is now available + + +## [v354] - 2026-03-26 + +- Ruby 3.3.11 is now available + + +## [v353] - 2026-03-25 + +- Add heroku-26 stack support + +## [v352] - 2026-03-16 + +- Ruby 4.0.2 is now available + + +## [v351] - 2026-03-11 + +- Ruby 3.4.9 is now available + + +## [v349] - 2026-03-06 + +- JRuby 10.0.4.0 is now available + + +## [v348] - 2026-03-02 + +- Changed the S3 URLs used for downloads to use AWS' dual-stack (IPv6 compatible) endpoint. ([#1717](https://github.com/heroku/heroku-buildpack-ruby/pull/1717)) +- Fix bug where an invalid DATABASE_URL is set when the adapter is not known (https://github.com/heroku/heroku-buildpack-ruby/pull/1715). + +## [v347] - 2026-02-02 + +- JRuby 10.0.3.0 is now available + +## [v346] - 2026-02-02 + +- Add metric: Track apps with and without `BUNDLED WITH` in the `Gemfile.lock` +- Default bundler version changed from `2.3.25` to `2.5.23`. Ruby's [standard version of bundler](https://stdgems.org/bundler/) will take precedence if it is greater than Heroku's installed version. When there is no `BUNDLED WITH` in the `Gemfile.lock`, then `bundle install` will use the highest version of Bundler available. + +## [v345] - 2026-01-26 + +- Set `WEB_CONCURRENCY_SET_BY=heroku/ruby` if `WEB_CONCURRENCY` gets set in (deprecated) `SENSIBLE_DEFAULTS` mode (https://github.com/heroku/heroku-buildpack-ruby/pull/1700) +- Default Node.js version now 24.13.0 (https://github.com/heroku/heroku-buildpack-ruby/pull/1704) +- Fix yarn and nodejs version change warnings (https://github.com/heroku/heroku-buildpack-ruby/pull/1699) + +## [v344] - 2026-01-14 + +- Ruby 3.2.10 is now available + + +## [v343] - 2026-01-13 + +- Ruby 4.0.1 is now available + +## [v342] - 2026-01-09 + +- Update bundler version warning output (https://github.com/heroku/heroku-buildpack-ruby/pull/1697) + +## [v341] - 2026-01-09 + +- Bundler version installed now directly matches the value in `BUNDLED WITH` from the `Gemfile.lock` + Previously, this value was converted to a "known good version." For example: + + `BUNDLED WITH` 2.7.x installs `bundler 2.7.2` + + Now, the exact version from the `Gemfile.lock` is installed instead. Applications without + a `BUNDLED WITH` value will receive a default bundler version. (https://github.com/heroku/heroku-buildpack-ruby/pull/1695) + +## [v340] - 2026-01-06 + +- Bundler version is now installed via running `gem install bundler` previously it was pre-built + and downloaded directly from S3. + + This change should be a refactor (no observed change in build behavior). If your app can build with `https://github.com/heroku/heroku-buildpack-ruby#v334` but not with this version, please open a + support ticket https://help.heroku.com/. (https://github.com/heroku/heroku-buildpack-ruby/pull/1680) + +## [v339] - 2026-01-05 + +- Ruby is now installed before bundler. Previously, Bundler was used to detect the Ruby version by calling + `bundle platform --ruby`. Now that the Ruby version is detected directly from the `Gemfile.lock`, the + order of installation can be changed such that Ruby is installed before Bundler. + + This change should be a refactor (no observed change in build behavior), but involved substantial + internal changes. If your app can build with `https://github.com/heroku/heroku-buildpack-ruby#v335` + but not with this version, please open a support ticket https://help.heroku.com/. (https://github.com/heroku/heroku-buildpack-ruby/pull/1684) +- The `PATH` order on Heroku CI relying on `bin/test` interface has changed for applications using the `heroku/ruby` + buildpack. It now starts with: `/app/bin:/app/vendor/bundle/bin:/app/vendor/bundle/ruby/3.3.0/bin` which matches + the behavior of regular `git push heroku` and customers who specify tests via `app.json`. + (https://github.com/heroku/heroku-buildpack-ruby/pull/1684) + + +## [v338] - 2025-12-25 + +- Ruby 4.0.0 is now available + + +## [v337] - 2025-12-18 + +- Ruby 4.0.0.preview3 is now available + + +## [v336] - 2025-12-17 + +- Ruby 3.4.8 is now available + + +## [v335] - 2025-12-15 + +- Ruby CI applications not using Rails now have `RACK_ENV=test` set (https://github.com/heroku/heroku-buildpack-ruby/pull/1683) + +## [v334] - 2025-12-12 + +- Rolled back due to https://github.com/heroku/heroku-buildpack-ruby/issues/1681 + +## [v333] - 2025-12-03 + +- Ruby apps using bundler 4.0.x will now receive bundler 4.0.0 (https://github.com/heroku/heroku-buildpack-ruby/pull/1677) + +## [v332] - 2025-12-02 + +- Ruby version is now pulled directly from `Gemfile.lock`. Previously the version was indirectly pulled + via shelling out to `bundle platform --ruby`, which requires that the version of Bundler must be resolved + and installed first. This new logic allows us to install the requested Ruby version before installing + bundler. (https://github.com/heroku/heroku-buildpack-ruby/pull/1665) + +## [v331] - 2025-12-02 + +- Ruby apps using bundler 4.0.x will now receive bundler 4.0.0.beta2 (https://github.com/heroku/heroku-buildpack-ruby/pull/1673) + +## [v330] - 2025-12-02 + +- Support `BUNDLED WITH` in `Gemfile.lock` with two spaces. This was [updated in bundler](https://github.com/ruby/rubygems/pull/9076). [#1668](https://github.com/heroku/heroku-buildpack-ruby/pull/1668) + +## [v329] - 2025-11-18 + +- Fix "BUILD_DIR: unbound variable" error when deploying without a Gemfile with `heroku/ruby` buildpack [#1663](https://github.com/heroku/heroku-buildpack-ruby/pull/1663) + +## [v328] - 2025-11-17 + +- Ruby 4.0.0-preview2 is now available + + +## [v327] - 2025-10-24 + +- Ruby 3.3.10 is now available + + +## [v326] - 2025-10-21 + +- Ruby apps using bundler 2.6.x will now receive bundler 2.6.9 (https://github.com/heroku/heroku-buildpack-ruby/pull/1651) +- Ruby apps using bundler 2.7.x will now receive bundler 2.7.2 (https://github.com/heroku/heroku-buildpack-ruby/pull/1651) + +## [v325] - 2025-10-21 + +- Internal refactor: Remove Cache class dependency from Metadata class (https://github.com/heroku/heroku-buildpack-ruby/pull/1649) +- Improve message on `bin/detect` failure to include the list of files in the root directory (https://github.com/heroku/heroku-buildpack-ruby/pull/1647) + +## [v324] - 2025-10-09 + +- JRuby 9.4.14.0 is now available + + +## [v323] - 2025-10-08 + +- Ruby 3.4.7 is now available + + +## [v322] - 2025-09-29 + +- Set `export PUMA_PERSISTENT_TIMEOUT=95` to match recommended router 2.0 settings for Rails applications (https://github.com/heroku/heroku-buildpack-ruby/pull/1645) +- Warn when using Puma prior to 7.0.0 for Router 2.0 compatibility (https://github.com/heroku/heroku-buildpack-ruby/pull/1645) +- Error when using Puma 7.0.0 to 7.0.2 (inclusive) to prevent runtime error with `PUMA_PERSISTENT_TIMEOUT` (https://github.com/heroku/heroku-buildpack-ruby/pull/1645) +- The `config_vars` field is no longer set in `bin/release`, this feature only affected the first deploy and is redundant with `.profile.d` usage that already exists. (https://github.com/heroku/heroku-buildpack-ruby/pull/1645) + +## [v321] - 2025-09-16 + +- Ruby 3.4.6 is now available + + +## [v320] - 2025-09-09 + +- Set `export PUMA_PERSISTENT_TIMEOUT=95` to match recommended router 2.0 settings for Rack applications (https://github.com/heroku/heroku-buildpack-ruby/pull/1641) + +## [v319] - 2025-08-27 + +- Add bash based failure metrics reporting for `bin/report`. This allows the buildpack to distinguish beteween types of failures that occur before Ruby code in the buildpack has executed (such as when bootstrapping). (https://github.com/heroku/heroku-buildpack-ruby/pull/1635) + +## [v318] - 2025-08-11 + +- Fix `heroku_buildpack_ruby_dir: unbound variable` warning (https://github.com/heroku/heroku-buildpack-ruby/pull/1632) + +## [v317] - 2025-08-07 + +- JRuby 10.0.2.0 is now available + + +## [v316] - 2025-08-06 + +- Explicitly error on end-of-life (EOL) stack `heroku-20` (https://github.com/heroku/heroku-buildpack-ruby/pull/1629) +- Default Ruby version is now 3.3.9 (https://github.com/heroku/heroku-buildpack-ruby/pull/1624) + +## [v315] - 2025-07-24 + +- Ruby 3.2.9 and 3.3.9 is now available + + +## [v314] - 2025-07-18 + +- JRuby 10.0.1.0 is now available + + +## [v313] - 2025-07-15 + +- Ruby 3.4.5 is now available + + +## [v312] - 2025-06-10 + +- JRuby 9.4.13.0 is now available + + +## [v311] - 2025-06-09 + + +- Stream `bundle list` when `bundle install` emits no gem information. This condition happens when bundler 2.4+ runs with no gem additions or deletions (https://github.com/heroku/heroku-buildpack-ruby/pull/1610) + +## [v310] - 2025-05-27 + +- Introduce internal metrics for deriving Ruby version directly from the `Gemfile.lock` to avoid needing to call `bundle platform --ruby` in the future. No change in behavior is expected. (https://github.com/heroku/heroku-buildpack-ruby/pull/1603) + +## [v309] - 2025-05-19 + +- Default Ruby version is now 3.3.8 (https://github.com/heroku/heroku-buildpack-ruby/pull/1595) + +## [v308] - 2025-05-15 + +- Removed support for Ubuntu 20.04 (and thus Heroku-20 / `heroku/builder:20`). This includes removing support for Ruby < 3.1 and Bundler < 2.3. (https://github.com/heroku/buildpacks-ruby/pull/1591) + +## [v307] - 2025-05-14 + +- Ruby 3.4.4 is now available + + +## [v306] - 2025-05-08 + +- JRuby 9.4.12.0, 9.4.12.1, 10.0.0.0, and 10.0.0.1 is now available + + +## [v305] - 2025-04-28 + +- Fix `cp --update=none` warning `heroku-22` (https://github.com/heroku/heroku-buildpack-ruby/pull/1588) + +## [v304] - 2025-04-28 + +- Fix `cp --update=none` on `heroku-20` (https://github.com/heroku/heroku-buildpack-ruby/pull/1586) + +## [v303] - 2025-04-25 + +- Ruby 3.5.0-preview1 is now available +- Fix warning message about `cp -n` (https://github.com/heroku/heroku-buildpack-ruby/pull/1583) + +## [v302] - 2025-04-16 + +- Ruby 3.3.8 is now available + + +## [v301] - 2025-04-14 + +- Observe `.ruby-version` formats (https://github.com/heroku/heroku-buildpack-ruby/pull/1576) + +## [v300] - 2025-04-14 + +- Ruby 3.4.3 is now available + + +## [v299] - 2025-04-07 + + +## [v298] - 2025-04-03 + +- Added observability metrics (https://github.com/heroku/heroku-buildpack-ruby/pull/1569) + +## [v297] - 2025-03-26 + +- Ruby 3.1.7 and 3.2.8 is now available + +## [v296] - 2025-03-21 + +- Bundler `1.x` usage error is downgraded to a warning. This warning will be moved to an error once `heroku-20` is Sunset (https://github.com/heroku/heroku-buildpack-ruby/pull/1565) + +## [v295] - 2025-03-20 + +- Explicit error message raised with upgrade instructions for applications specifying bundler `1.x` in the Gemfile.lock (https://github.com/heroku/heroku-buildpack-ruby/pull/1561) + +## [v294] - 2025-03-19 + +- Default Ruby version is now 3.3.7 (https://github.com/heroku/heroku-buildpack-ruby/pull/1534) +- Default bundler version (when no version present in the `Gemfile.lock`) is now bundler `2.3.x` which is currently `2.3.25` (https://github.com/heroku/heroku-buildpack-ruby/pull/1534) +- Bundler 1.x will no longer work with the Ruby buildpack (https://github.com/heroku/heroku-buildpack-ruby/pull/1534) + +## [v293] - 2025-02-15 + +- Ruby 3.4.2 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1548) + +## [v292] - 2025-02-10 + +- Ruby 3.2.7 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1545) + + +## [v291] - 2025-01-30 + +- JRuby 9.4.11.0 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1542) + +## [v290] - 2025-01-23 + +- JRuby 9.4.10.0 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1540) + +## [v289] - 2025-01-17 + +- Ruby 3.3.7 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1538) + +## [v288] - 2025-01-06 + +- Ruby apps using bundler 2.6+ will now receive bundler 2.6.2 (https://github.com/heroku/heroku-buildpack-ruby/pull/1535) +- Ruby apps using bundler 2.5.x will now receive bundler 2.5.23 (https://github.com/heroku/heroku-buildpack-ruby/pull/1535) + +## [v287] - 2024-12-25 + +- Ruby 3.4.0 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1531) +- Ruby 3.4.1 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1531) + +## [v286] - 2024-12-13 + +- Ruby 3.4.0-rc1 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1528) + +## [v285] - 2024-11-21 + +- Default `UV_USE_IO_URING=0` due to build timeouts [context](https://github.com/heroku/heroku-buildpack-nodejs/pull/1347) (https://github.com/heroku/heroku-buildpack-ruby/pull/1523) + +## [v284] - 2024-11-15 + +- Default Node.js version now 22.11.0 (https://github.com/heroku/heroku-buildpack-ruby/pull/1503) +- Default Yarn version now 1.22.22 (https://github.com/heroku/heroku-buildpack-ruby/pull/1503) + +## [v283] - 2024-11-14 + +- No customer facing changes + +## [v282] - 2024-11-08 + +- [Rolled back] Default Node.js version now 22.11.0 (https://github.com/heroku/heroku-buildpack-ruby/pull/1503) +- [Rolled back] Default Yarn version now 1.22.22 (https://github.com/heroku/heroku-buildpack-ruby/pull/1503) + +## [v281] - 2024-11-07 + +- Support Node.js on ARM builds (https://github.com/heroku/heroku-buildpack-ruby/pull/1499) +- Add detection support for Rails 8 (https://github.com/heroku/heroku-buildpack-ruby/pull/1498) +- Ruby 3.2.6 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1504) +- JRuby 9.4.9.0 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1506) +- Ruby 3.3.6 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1511) + +## [v280] - 2024-10-08 + +- Ruby 3.4.0-preview2 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1496) + +## [v279] - 2024-09-04 + +- Ruby 3.3.5 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1491) + +## [v278] - 2024-08-05 + +- Ruby 3.2.5 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1488) + +## [v277] - 2024-07-11 + +- Stop bundling bootstrap Ruby for each stack inside the buildpack archive on the buildpack registry (https://github.com/heroku/heroku-buildpack-ruby/pull/1481) + +## [v276] - 2024-07-09 + +- Ruby 3.3.4 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1482) + +## [v275] - 2024-07-03 + +- JRuby 9.4.8.0 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1479) + +## [v274] - 2024-06-28 + +- JRuby 9.3.15.0 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1476) + +## [v273] - 2024-06-26 + +- Native Gemfile.lock support for Windows Ruby users with Bundler 2.2+ (https://github.com/heroku/heroku-buildpack-ruby/pull/1469) + +## [v272] - 2024-06-13 + +- Remove experimental CNB support. For official CNB support use [heroku/buildpacks-ruby](https://github.com/heroku/buildpacks-ruby) instead. (https://github.com/heroku/heroku-buildpack-ruby/pull/1464) +- Default Ruby version is now 3.1.6 (https://github.com/heroku/heroku-buildpack-ruby/pull/1466) +- Ruby 3.3.3 is now available + +## [v271] - 2024-06-03 + +- Ruby 3.4.0-preview1 is now available +- Ruby 3.1.6 and 3.3.2 are now available +- JRuby 9.4.7.0, 9.4.6.0, and 9.3.14.0 are now available +- Fix issue with sprockets reference being nil when + propshaft gem is used instead (https://github.com/heroku/heroku-buildpack-ruby/pull/1452) + +## [v270] - 2024-04-23 + +- Ruby versions 3.0.7, 3.1.5, 3.2.4, and 3.3.1 are now available on Heroku (https://github.com/heroku/heroku-buildpack-ruby/pull/1449) + +## [v269] - 2024-04-23 + +- Fix Heroku-24 Ruby buildpack bootstrapping logic (https://github.com/heroku/heroku-buildpack-ruby/pull/1446) +- Deprecate CNB support in this buildpack; CNB support for Ruby is provided by [heroku/buildpacks-ruby](https://github.com/heroku/buildpacks-ruby) instead (https://github.com/heroku/heroku-buildpack-ruby/pull/1445) + +## [v268] - 2024-04-17 + +- Heroku-24 stack initial support. Includes multi-architecture (arm64/amd64) logic that has not been tested on the platform (https://github.com/heroku/heroku-buildpack-ruby/pull/1439) +- Remove unused Rubinius and Ruby 1.9.2 codepaths (https://github.com/heroku/heroku-buildpack-ruby/pull/1440) + +## [v267] - 2024-02-28 + +- Bundler version installation is now based on both major and minor version (https://github.com/heroku/heroku-buildpack-ruby/pull/1428) +- Applications using bundler 2.4+ must now specify a ruby version in the Gemfile.lock or they will receive the default Ruby version (https://github.com/heroku/heroku-buildpack-ruby/pull/1428) + +## [v266] - 2024-02-20 + +- Officially deprecate SENSIBLE_DEFAULTS environment variable (https://github.com/heroku/heroku-buildpack-ruby/pull/1420) + +## [v265] - 2024-01-22 + +- Ruby 3.2.3 is now available + +## [v264] - 2023-12-25 + +- Ruby 3.3.0 is now available + +## [v263] - 2023-12-11 + +- Ruby 3.3.0-rc1 is now available (https://github.com/heroku/heroku-buildpack-ruby/pull/1411) +- Fix BUILDPACK_VENDOR_URL support (https://github.com/heroku/heroku-buildpack-ruby/pull/1406) + +## [v262] - 2023-11-08 + +- Warn when relying on default Node.js or Yarn versions (https://github.com/heroku/heroku-buildpack-ruby/pull/1401) +- Warn when default Node.js or Yarn versions change (https://github.com/heroku/heroku-buildpack-ruby/pull/1401) + +## [v261] - 2023-11-02 + +- JRuby 9.4.5.0 is now available +- JRuby 9.3.13.0 is now available +- Default Node.js version now 20.9.0 + +## [v260] - 2023-10-23 + +- JRuby 9.4.4.0 is now available + +## [v259] - 2023-10-02 + +- Ruby 3.3.0-preview2 is now available + +## [v258] - 2023-09-26 + +- No changes + +## [v257] - 2023-09-20 + +- JRuby 9.3.11.0 is now available + +## [v256] - 2023-08-04 + +- Fix Ruby 2.5.7 download on Heroku-20 + +## [v255] - 2023-07-24 + +- JRuby 9.4.3.0 is now available +- JRuby 9.4.2.0 is now available + +## [v254] - 2023-05-12 + +- Default Ruby version is now 3.1.4 +- Ruby 3.3.0-preview1 is now available + +## [v253] - 2023-03-31 + +- Ruby versions 2.7.8, 3.0.6, 3.1.4, 3.2.2 are now available + +## [v252] - 2023-02-08 + +* Ruby 3.2.1 is now available +* JRuby 9.4.1.0 is now available + +## [v251] - 2023-02-03 + +* Jruby 9.3.10.0 is available + +## [v250] - 2022-12-25 + +* Ruby 3.2.0 is available + +## [v249] - 2022-12-16 + +* Apps with the environment variable `HEROKU_SKIP_DATABASE_PROVISION=1` set will no longer receive a database on the first push to a new Heroku app. This environment variable interface is not standard across other buildpacks and may be deprecated via warnings in the build output and changed in the future. + +## [v248] - 2022-12-06 + +* Ruby 3.2.0-rc1 is available + +## [v247] - 2022-12-01 + +* JRuby 9.3.7.0, 9.3.8.0, 9.3.9.0, 9.4.0.0 + +## [v246] - 2022-11-29 + +* Default Node.js version now 16.18.1 (https://github.com/heroku/heroku-buildpack-ruby/pull/1342) +* Default Yarn version now 1.22.19 (https://github.com/heroku/heroku-buildpack-ruby/pull/1342) + +## [v245] - 2022-11-16 + +* Bump Bundler 2 wrapper to 2.3.25 (https://github.com/heroku/heroku-buildpack-ruby/pull/1337) + +## [v244] - 2022-07-25 + +* Default Ruby version is now 3.1.2 (https://github.com/heroku/heroku-buildpack-ruby/pull/1316) + +## [v243] - 2022-06-14 + +* Switch away from deprecated path-based S3 URLs (https://github.com/heroku/heroku-buildpack-ruby/pull/1311) +* Adjust curl retry and connection timeout handling (https://github.com/heroku/heroku-buildpack-ruby/pull/1312) + +## [v242] - 2022-06-07 + +* Ensure `bin/release` exits zero if `tmp/heroku-buildpack-release-step.yml` does not exist (https://github.com/heroku/heroku-buildpack-ruby/pull/1309) +* Bootstrap Ruby version used by the buildpack internals is updated to Ruby 3.1.2 (https://github.com/heroku/heroku-buildpack-ruby/pull/1310) + +## [v241] - 2022-06-06 + +* `bin/release` is re-written in bash, so it supports Heroku-22 (https://github.com/heroku/heroku-buildpack-ruby/pull/1308) +* Download presence check now includes heroku-22 (https://github.com/heroku/heroku-buildpack-ruby/pull/1290) + +## [v240] - 2022-04-05 + +* Add initial support for heroku-22 (https://github.com/heroku/heroku-buildpack-ruby/pull/1289) +* Bundler 2.x is now 2.3.10 (https://github.com/heroku/heroku-buildpack-ruby/pull/1296) + +## [v239] - 2022-03-02 + +* Rollback bundler 2.x change. Bundler 2.x is now back at 2.2.33 (https://github.com/heroku/heroku-buildpack-ruby/pull/1281) + +## [v238] - 2022-03-02 + +* Bundler 2.x is now 2.3.7 (https://github.com/heroku/heroku-buildpack-ruby/pull/1276) + +## [v237] - 2022-02-24 + +* Default Ruby version is now 3.0.3 (https://github.com/heroku/heroku-buildpack-ruby/pull/1270) + +## [v236] - 2022-01-04 + +* Fix deprecated rake tasks for Rails 7 on Heroku CI (https://github.com/heroku/heroku-buildpack-ruby/pull/1257) + +## [v235] - 2022-01-03 + +* Bundler 2.x is now 2.2.33 (https://github.com/heroku/heroku-buildpack-ruby/pull/1248) + +## [v234] - 2021-12-16 + +* Fix YML indentation from v233 (https://github.com/heroku/heroku-buildpack-ruby/pull/1252) + +## [v233] - 2021-12-16 + +* Default node version now 16.13.1, yarn is 1.22.17 (https://github.com/heroku/heroku-buildpack-ruby/pull/1238) +* Default Ruby version is now 2.7.5 (https://github.com/heroku/heroku-buildpack-ruby/pull/1237) +* Remove instrumentation and LPXC logic (https://github.com/heroku/heroku-buildpack-ruby/pull/1229) + +## [v232] - 2021-11-09 + +* Deactivate LPXC (https://github.com/heroku/heroku-buildpack-ruby/pull/1228) + +## [v231] - 2021-10-27 + +* Applications with `package.json` now get `nodejs` installed (https://github.com/heroku/heroku-buildpack-ruby/pull/1212) +* Applications with `yarn.lock` now get `yarn` installed (https://github.com/heroku/heroku-buildpack-ruby/pull/1212) + +## [v230] - 2021-10-05 + +* Default Ruby version is now 2.7.4 (https://github.com/heroku/heroku-buildpack-ruby/pull/1193) + +## [v229] - 2021-08-30 + +* Fix interoperability with other Heroku buildpacks' `$WEB_CONCURRENCY` handling (https://github.com/heroku/heroku-buildpack-ruby/pull/1188) + +## [v228] - 2021-06-24 + +* Bundler 2.x is now 2.2.21 (https://github.com/heroku/heroku-buildpack-ruby/pull/1170) +* Remove support for the Cedar-14 and Heroku-16 stacks (https://github.com/heroku/heroku-buildpack-ruby/pull/1163) + +## [v227] - 2021-04-19 + +* Bundler 2.x is now 2.2.16 (https://github.com/heroku/heroku-buildpack-ruby/pull/1150) + +## [v226] - 2021-04-13 + +* Bundler 2.x is now 2.2.15 (https://github.com/heroku/heroku-buildpack-ruby/pull/1144) + +## [v225] - 2021-02-25 + +* Bundler 2.x is now 2.2.11 (https://github.com/heroku/heroku-buildpack-ruby/pull/1132) + +## [v224] - 2021-02-24 + +* Ruby buildpack now relies on the JVM buildpack to install java for Jruby apps (https://github.com/heroku/heroku-buildpack-ruby/pull/1119) + +## [v223] - 2021-01-22 + +* Fix Gemfile.lock read bug from preventing proper removal of BUNDLED WITH declaration (https://github.com/heroku/heroku-buildpack-ruby/pull/1108) +* Fail detection with a CNB-friendly exit code (https://github.com/heroku/heroku-buildpack-ruby/pull/1111) + +## [v222] - 2020-11-02 + +* CNB support for Heroku-20 (https://github.com/heroku/heroku-buildpack-ruby/pull/1096) + +## [v221] - 2020-10-22 + +* Remove excessive Active Storage warnings (https://github.com/heroku/heroku-buildpack-ruby/pull/1087) +* Add Heroku-20 to the download presence check (https://github.com/heroku/heroku-buildpack-ruby/pull/1093) + +## [v220] - 2020-08-07 + +* BUNDLE_WITHOUT now accommodates values with single spaces (https://github.com/heroku/heroku-buildpack-ruby/pull/1083) + +## [v219] - 2020-08-06 + +* Fix double installation of bundler on CI runs when no test script is specified (https://github.com/heroku/heroku-buildpack-ruby/pull/1073) +* Bundler 2.x is now 2.1.4 (https://github.com/heroku/heroku-buildpack-ruby/pull/1052) +* Persistent bundler config is now being set using the `BUNDLE_*` env vars (https://github.com/heroku/heroku-buildpack-ruby/pull/1039) +* Rake task "assets:clean" will not get called if it does not exist (https://github.com/heroku/heroku-buildpack-ruby/pull/1018) +* CNB: Fix the `gems` layer not being made accessible by subsequent buildpacks (https://github.com/heroku/heroku-buildpack-ruby/pull/1033) + +## [v218] - 2020-07-13 + +* The rake binstub generated from compiling Ruby will no longer be placed in the local `bin/rake` location (https://github.com/heroku/heroku-buildpack-ruby/pull/1031) +* A bug in 2.6.0, 2.6.1, 2.6.3 require a Ruby upgrade, a warning has been added (https://github.com/heroku/heroku-buildpack-ruby/pull/1015) +* The spring library is now disabled by setting the environment variable DISABLE_SPRING=1 (https://github.com/heroku/heroku-buildpack-ruby/pull/1017) +* Warn when a bad "shebang" line in a binstub is detected (https://github.com/heroku/heroku-buildpack-ruby/pull/1014) +* Default node version now 12.16.2, yarn is 1.22.4 (https://github.com/heroku/heroku-buildpack-ruby/pull/986) + + ## [v217] - 2020-07-02 + +* Gracefully handle unrecognised stacks ([#982](https://github.com/heroku/heroku-buildpack-ruby/pull/982)) + +## [v216] (rolled back) + + +## [v215] - 2020-04-09 + +* Fix bundler cache not being used in CI builds (https://github.com/heroku/heroku-buildpack-ruby/pull/978) + +## [v214] - 2020-04-02 + +* Default Ruby version is now 2.6.6 (https://github.com/heroku/heroku-buildpack-ruby/pull/974) +* Fix regression. PATH value for `yarn` at runtime was relative instead of absolute (https://github.com/heroku/heroku-buildpack-ruby/pull/975) + +## [v213] - 2020-04-01 + +* Fix regression. PATH value for `ruby` at runtime was relative instead of absolute (https://github.com/heroku/heroku-buildpack-ruby/pull/973) + +## [v212] - 2020-03-26 + +* Cloud Native Buildpack support (https://github.com/heroku/heroku-buildpack-ruby/pull/888) + +## [v211] - 2020-03-12 + +* Fix issue where the wrong version of bundler is used on CI apps (https://github.com/heroku/heroku-buildpack-ruby/pull/961) +* Remove libpq external dependency (https://github.com/heroku/heroku-buildpack-ruby/pull/959) + +## [v210] - 2020-03-06 + +* Fix version download error warning inversion logic (https://github.com/heroku/heroku-buildpack-ruby/pull/958) + +## [v209] - 2020-03-05 + +* Fix bug in version download error message logic (https://github.com/heroku/heroku-buildpack-ruby/pull/957) + +## [v208] - 2020-03-04 + +* Improve Ruby version download error messages (https://github.com/heroku/heroku-buildpack-ruby/pull/953) +* Update default Ruby version to 2.6.5 (https://github.com/heroku/heroku-buildpack-ruby/pull/947) + +## [v207] - 2019-12-16 + +* Vendor in libpq 5.12.1 for Heroku-18 (https://github.com/heroku/heroku-buildpack-ruby/pull/936) +* Remove possibilities of false exceptions being raised by removing `BUNDLED WITH` from the `Gemfile.lock` (https://github.com/heroku/heroku-buildpack-ruby/pull/928) + +## [v206] - 2019-10-15 + +* Default Ruby version for new apps is now 2.5.7 (https://github.com/heroku/heroku-buildpack-ruby/pull/926) +* Using old and EOL versions of Ruby now generate warnings (https://github.com/heroku/heroku-buildpack-ruby/pull/864) + +## [v205] - 2019-09-24 + +* Update bundler 1.x to 1.17.3 (https://github.com/heroku/heroku-buildpack-ruby/pull/845) +* Default `MALLOC_ARENA_MAX=2` for new applications (https://github.com/heroku/heroku-buildpack-ruby/pull/752) + +## [v204] - 2019-09-12 + +* Default Ruby version for new apps is now 2.5.6 (https://github.com/heroku/heroku-buildpack-ruby/pull/919) +* Ensure that old binstubs are removed before new ones are generated (https://github.com/heroku/heroku-buildpack-ruby/pull/914) +* Fix windows Gemfile.lock BUNDLED WITH support (https://github.com/heroku/heroku-buildpack-ruby/pull/898) + +## [v203] - 2019-08-20 + +* Make sure Rails 6 apps have a `tmp/pids` folder so they can boot (https://github.com/heroku/heroku-buildpack-ruby/pull/909) + +## [v202] - 2019-08-20 + +* Add support class for Rails 6 (https://github.com/heroku/heroku-buildpack-ruby/pull/908) + +## [v201] - 2019-06-23 + +* Set memory default for Node builds (https://github.com/heroku/heroku-buildpack-ruby/pull/861) +* Default Ruby version is now 2.5.5, was previously 2.5.3 (https://github.com/heroku/heroku-buildpack-ruby/pull/863) +* Default Node version is now 10.15.3 and default Yarn version is now 1.16.0 (https://github.com/heroku/heroku-buildpack-ruby/pull/884) +* Bundler 2 now uses 2.0.2 (https://github.com/heroku/heroku-buildpack-ruby/pull/894) + +## [v200] - 2019-03-07 + +* Fix: Environment variables not being exported for other buildpacks in CI (https://github.com/heroku/heroku-buildpack-ruby/pull/858) +* Ignore invalid byte encodings when detecting rails config (https://github.com/heroku/heroku-buildpack-ruby/pull/854) + +## v199 - 2019-02-19 + +* Add support for arbitrary Bundler major versions, most notably bundler 2 (https://github.com/heroku/heroku-buildpack-ruby/pull/850) + +## v198 - 2019-01-17 + +* Rev-default Ruby version to be the latest patch release of last years Ruby version 2.5.3 (https://github.com/heroku/heroku-buildpack-ruby/pull/846) +* Allow apps to enable `RUBYOPT=--jit` (https://github.com/heroku/heroku-buildpack-ruby/pull/848) + +## v197 - 2018-12-18 + +* Upgrade node version (https://github.com/heroku/heroku-buildpack-ruby/pull/831) +* Upgrade yarn version (https://github.com/heroku/heroku-buildpack-ruby/pull/832) + +## v196 - 2018-10-31 + +* Delete the sprockets temp directory for a smaller runtime slug if they are not building assets at runtime (https://github.com/heroku/heroku-buildpack-ruby/pull/812) + +## v195 - 2018-10-18 + +* Default Ruby version is now 2.4.5 (https://github.com/heroku/heroku-buildpack-ruby/pull/821) + +## v194 - 2018-10-16 + +* Do not add the `jobs:work` command if an app does not have that rake task available (https://github.com/heroku/heroku-buildpack-ruby/pull/810) + +## v193 - 2018-09-14 + +* Fix link (https://github.com/heroku/heroku-buildpack-ruby/pull/811) + +## v192 - 2018-09-14 + +* Add error messages when using unsupported Ruby versions on the Heroku-18 stack (https://github.com/heroku/heroku-buildpack-ruby/pull/809) + +## v191 - 2018-08-23 + +* Warn when `config.action_dispatch.x_sendfile_header` is set but apache and nginx are not being used (https://github.com/heroku/heroku-buildpack-ruby/pull/795) + +## v190 - 2018-07-24 + +* Support TAP output for Heroku CI (https://github.com/heroku/heroku-buildpack-ruby/pull/790). + +## v189 - 2018-07-10 + +* Colorize build failures and warnings. (https://github.com/heroku/heroku-buildpack-ruby/pull/788) + +## v188 - 2018-06-26 + +* Fix rails config detect timeout. Addresses the process deadlock when detecting rails config that contains an infinite task. This was originally addressed in #770 but the implementation did not handle all cases. (#781) + +## v187 - 2018-06-19 + +* Prevent apps from deploying with known security vulnerability activated via config (#776) + +## v186 - 2018-06-12 + +* The Ruby buildpack can now detect Rails configuration in a project (#758 #770) + +## v185 - 2018-05-31 + +* The Ruby buildpack bootstrap Ruby version is now 2.5.1. This is not a customer facing feature (#765) + +## v184 - 2018-05-30 + +* Default Ruby version is now 2.4.4 (#734) + +## v183 - 2018-04-26 + +* Support for not yet released heroku-18 stack (#750) + +## v182 - 2018-04-24 + +* Do not warn when `rails runner` cannot be executed (#749) + +## v181 - 2018-04-23 + +* The `active_storage` is not guaranteed to be present (#748) + +## v180 - 2018-04-23 + +* Fix case where user environment variables were not being used (#745) + +## v179 - 2018-04-23 + +* Emit warnings for Active Storage (#739) + +## v178 - 2018-04-17 + +* Use S3 directly instead of s3pository for Node downloads (#740) + +## v177 - 2018-04-10 + +* New apps that do not specify a Ruby version now get 2.3.7 (#732) +* Bugfix: `bundle install` output no longer has an extra newline (#735) +* Bugfix: when deploying an application the `ruby` version specified in the Gemfile is available outside of the home directory (#733) + +## v176 - 2018-03-27 + +* Node version upgraded to v8.9.4 (#714) +* Yarn version upgraded to v1.5.1 (#714) +* Fix issue with malformed UTF-8 string parsing (#724) + +## v175 - 2018-03-21 + +* Suggest users encountering a specific sprockets error in specific beta versions to upgrade (#718) +* Log metrics for common failures (#716) + +## v174 - 2018-02-13 + +* Only set JAVA_HOME for Bundler when using JRuby (#649, @jkutner) + +## v173 - 2017-12-22 + +* Remove Bundler shim since Bundler 2.5.0 will not vendor Bundler (#645) + +## v172 - 2017-12-22 + +* updated Ruby 2.5.0 support + Bundler shim (#640) +* Disable bundler version check (#632) +* set JAVA_HOME to absolute path during `bundle install` (#631) + +## v170 - 2017-10-19 + +* Compatibility for Ruby 2.5.0 preview 1 (#628) + +## v169 - 2017-09-28 + +* Yarn version upgraded to v1.0.2 + +## v168 - 2017-08-14 + +* Install Node when using either ExecJS _or_ Webpacker (#608) +* Make installs more robust against temporary network issues by retrying failed + downloads in `LanguagePack::Fetcher#fetch_untar`, which installs Rubies (#585) + +## v167 - 2016-07-25 + +* Update Bundler to 1.15.2 (#602) + +## v166 - 2017-07-11 + +* no changes from v165 + +## v165 - 2017-07-11 + +* Set `$JAVA_HOME` for JRuby apps during build (#593) +* Update Node to 6.11.1 (#598) + +## v164 - 2017-06-21 + +* Update Bundler to 1.15.1 (#579) + +## v163 - 2017-05-22 + +* Fix CI rake tasks not running (#571) + +## v162 - 2017-05-18 + +* Disable RuboCop warnings for `heroku_clear_tasks` (#567) + +## v161 - 2017-05-18 + +* Ruby apps being run on CI are no longer expected to have Rails commands (#565) + +## v160 - 2017-05-18 + +* `bin/rails test` only gets called in CI for Rails 5+ apps +* support `:sql` (structure) Rails schema for CI (#549) + +## v159 - 2017-04-24 + +* Blacklist JAVA_OPTS and JAVA_TOOL_OPTIONS during build (#559) + +## v158 - 2017-04-12 + +* Fix CI issue causing system Ruby to be used (#558) + +## v157 - 2017-04-11 + +* Fix "double ruby rainbow bug" caused by executing two `compile` actions on the same +app (#553 & #555) +* Remove Ruby 1.8.7 compatibility to allow for #555. This version of Ruby has been EOL +for a long time. It is not available on Cedar-14 and Cedar-10 is EOL + +## v156 - 2017-04-11 + +* Update default Ruby version to 2.3.4. + +## v155 - 2017-03-16 + +* Yarn now installed for apps with `webpacker` gem (#547) + +## v154 - 2017-03-01 + +* Postgres database add-on will only be provisioned if app has a postgres driver in the `Gemfile`. (#535) +* Fix regression, where JRuby patchlevel was being pulled from `Gemfile.lock` and used when not appropriate (#536) + +## v153 - 2017-01-18 + +* Fix regression, where defaults would override user env with rake (#528) + +## v152 - 2017-01-18 + +* Remove RAILS_GROUPS=assets from being set in .profile.d (#526) + +## v151 - 2017-01-16 + +* Upgrade to bundler 1.13.7 (#519) +* Vendor Default Ruby to execute the buildpack (#515) +* Heroku CI Support (#516) + +## v150 - 2016-12-23 + +* Allow deployment of pre-release rubies (preview and rc) with Bundler 1.13.6+. This is needed because the patch level is recorded in the gemfile as `-1` since it is not released yet. For example 2.4.0rc1 will show up in a `Gemfile.lock` like this: + +``` +RUBY VERSION + ruby 2.4.0p-1 +``` + +## v149 - 2016-12-01 + +* Guarantee we always show warning when upgrading bundler version. + +## v148 - 2016-11-17 + +* Default Ruby Version is 2.2.6 +* Update libyaml to 0.1.7 for [CVE-2014-9130](https://devcenter.heroku.com/changelog-items/1016) + +## v147 - 2016-11-15 + +* Bump bundler to 1.13.6 [Bundler changelog](https://github.com/bundler/bundler/blob/v1.13.6/CHANGELOG.md). Allows for use of Ruby version operators. + +## v146 - 2016-03-23 + +* Warn when `.bundle/config` is checked in (#471) +* Do not cache `.bundle/config` between builds (#471) +* Set WEB_CONCURRENCY for M-Performance dynos using sensible defaults (#474) +* Fix rake task detection in Rails apps actually fails builds (#475) + +## v145 - 2016-03-08 + +* Bump bundler to 1.11.2 [Bundler changelog](https://github.com/bundler/bundler/blob/master/CHANGELOG.md#1112-2015-12-15) (#461) +* Rails 5 Support for logging to STDOUT via environment variable (#460) +* Fail build when rake tasks cannot be detected in a Rails app (#462) + +## v144 - 2016-02-01 + +* Fix default ruby to actually be Ruby 2.2.4 (#456) + +## v143 - 2016-01-28 + +* Change default for new apps to Ruby 2.2.4 (#454) + +## v142 - 2016-01-14 + +* Added pgconfig jar to JDK for JRuby JDBC (#450) +* Let API pick exact postgres plan (#449) +* Follow redirects on `curl` command (#443) +* Check for preinstalled JDK (#434) + +## v141 - 2015-11-03 + +* Support for custom JDK versions in system.properties (#423) +* Fix nodejs buildpack integration (#429) +* Automatic jruby heap setting for IX dynos (#426) +* Warn when RAILS_ENV != production (https://devcenter.heroku.com/articles/deploying-to-a-custom-rails-environment) +* Warn when using asset_sync (https://devcenter.heroku.com/articles/please-do-not-use-asset-sync) + +## v140 - 2015-09-09 + +* JRuby specific ruby error message (#412) + +## v139 - 2015-08-31 + +* Cached asset file should never take precedent over existing file (#402) +* Do not write `database.yml` when using active record >= 4.1 (previously we only detected >= Rails 4.1) (#403) + +## v138 - 2015-05-19 + +* Bump bundler to 1.9.7 [Bundler changelog](https://github.com/bundler/bundler/blob/master/CHANGELOG.md#196-2015-05-02) (#378) + +## v137 - 2015-05-11 + +* Blacklist `JRUBY_OPTS`, use `JRUBY_BUILD_OPTS` to override build `JRUBY_OPTS`. (#384) +* Revert `--dev` during JRuby build for now. (#384) + +## v136 - 2015-05-06 + +* JRUBY_BUILD_OPTS env var will override any build time jruby opts (#381) + +## v135 - 2015-05-05 + +* Support sprockets 3.0 manifest file naming convention (#367) +* Set `--dev` by default for JRuby builds (but not at runtime). This optimizes the JVM for short process and is ideal for `bundle install` and asset precompiles. +* Cleanup `.git` folders in the bundle directory after `bundle install`. + +## v134 - 2015-03-01 + +* JVM is now available on cedar-14, do not vendor in JVM based on individual gems. If customer needs a specific version they should use multibuildpack with java and ruby buildpacks. +* Set a default value of WEB_CONCURRENCY based on dyno size when `SENSIBLE_DEFAULTS` environment variable is present. +* Run `bundle clean` in the same context as `bundle install` heroku/heroku-buildpack-ruby#347 +* Rails 4.2+ apps will have environment variable RAILS_SERVE_STATIC_FILES set to "enabled" by default #349 +* Rails 5 apps now work on Heroku #349 + +## v133 - 2015-01-22 + +* Bump bundler to 1.7.12 which includes multiple fixes and support for block source declaration (https://github.com/bundler/bundler/blob/1-7-stable/CHANGELOG.md). + +## v132 - 2015-01-21 + +* Support multibuildpack export file (#319) +* Auto set the JVM MAX HEAP based on dyno size for JRuby (#323) +* Use s3 based npmjs servers for node (#336) +* Support system.properties file for specifying JDK in JRuby (#305) +* Fix ruby version parsing to support JRuby 9.0.0.0.pre1 (#339) + +## v131 - 2015-01-15 + +* Revert v130 due to lack of proper messaging around WEB_CONCURRENCY settings. + +## v130 - 2015-01-15 + +* Auto set WEB_CONCURRENCY based on dyno size if not already set. +* Support multibuildpack export file (#319) +* Auto set the JVM MAX HEAP based on dyno size for JRuby (#323) +* Use s3 based npmjs servers for node (#336) +* Support system.properties file for specifying JDK in JRuby (#305) + +## v129 - 2014-11-06 + +* Fix asset caching bug (#300) + +## v128 - 2014-11-04 + +* Better cedar14 Ruby install error message + +## v127 - 2014-09-18 + +* rbx is now stack aware + +## v126 - 2014-08-04 + +* fix bundler cache clearing on ruby version change +* vendor the jvm when yui-compressor is detected + +## v125 - 2014-08-01 + +* bump to node 0.10.30 on cedar-14 + +## v124 - 2014-08-01 + +* use node 0.10.29 on cedar-14 +* properly use vendored jvm, so not to be dependent on java on the stack image + +## v123 - 2014-07-25 + +* fix permission denied edge cases when copying the bundler cache with minitest + +## v122 - 2014-07-25 + +* handle bundler cache for stack changes on existing apps + +## v121 - 2014-06-30 + +* on new apps, source default envs instead of replacing them +* support different stacks for new apps + +## v120 - 2014-06-16 + +* Bump bundler to 1.6.3 which includes improved dependency resolver + +## v119 - 2014-05-09 + +* Temporarily disable default ruby cache + +## v118 - 2014-05-06 + +* Ruby version detection now loads user environment variables + +## v117 - 2014-04-14 + +Features: + + +Bugfixes: + +* fix anvil use case of multibuildpack with node + + +## v116 - 2014-04-10 + +Features: + + +Bugfixes: + +* Revert back to Bundler 1.5.2 + + +## v115 - 2014-04-09 + +Features: + + +Bugfixes: + +* Add default process types to all apps deployed regardless of `Procfile` + +## v114 - 2014-04-09 + +Features: + +* Bundler 1.6.1 +* Warn when not using a Procfile (looking at you webrick) + +Bugfixes: + + +## v113 - 2014-04-08 + +Features: + +* use heroku-buildpack-nodejs's node binary +* `CURL_CONNECT_TIMEOUT` and `CURL_TIMEOUT` are configurable as ENV vars + +Bugfixes: + +* Don't double print "Running: rake assets:precompile" on Ruby apps + + +## v112 - 2014-03-27 + +Features: + + +Bugfixes: + +* compile psych with libyaml 0.1.6 for CVE-2014-2525 + +## v111 - 2014-03-20 + +Features: + + +Bugfixes: + +* spelling + + +## v110 - 2014-03-20 + +Features: + +* Better message when running `assets:precompile` without a database + +Bugfixes: + +## v108 - 2014-02-27 Features: @@ -10,13 +1243,13 @@ Bugfixes: * don't let users step on themselves by replacing `env` in `$PATH` -## v107 (2/26/2014) +## v107 - 2014-02-26 Features: Bugfixes: -* more shellescaping bug fixes +* more shell escaping bug fixes ## v105 @@ -24,16 +1257,16 @@ Bugfixes: Rollbacked to v103 -## v104 (2/26/2014) +## v104 - 2014-02-26 Features: Bugfixes: -* fix bugs in shellescaping (#231) +* fix bugs in shell escaping (#231) -## v103 (2/18/2014) +## v103 - 2014-02-18 Features: @@ -42,7 +1275,7 @@ Features: Bugfixes: -## v102 (2/6/2014) +## v102 - 2014-02-06 Features: @@ -51,7 +1284,7 @@ Bugfixes: * use blacklist of env vars, so users can't break the build process -## v101 (2/5/2014) +## v101 - 2014-02-05 Features: @@ -62,7 +1295,7 @@ Bugfixes: * quote ponies env vars, so build doesn't break -## v100 (2/4/2014) +## v100 - 2014-02-04 Features: @@ -70,7 +1303,7 @@ Bugfixes: * compile psych with libyaml 0.1.5 for CVE-2013-6393 -## v99 (2/4/2014) +## v99 - 2014-02-04 Features: @@ -79,7 +1312,7 @@ Features: Bugfixes: -## v98 (1/30/2014) +## v98 - 2014-01-30 Features: @@ -88,7 +1321,7 @@ Bugfixes: * Use vendored JDK binary during build -## v97 (1/30/2014) +## v97 - 2014-01-30 Features: @@ -97,7 +1330,7 @@ Bugfixes: * Actually finalize method rename to `install_bundler_in_app` -## v96 (1/29/2014) +## v96 - 2014-01-29 Features: @@ -109,7 +1342,7 @@ Bugfixes: Rollback to v93 -## v94 (1/29/2014) +## v94 - 2014-01-29 Features: @@ -117,7 +1350,7 @@ Bugfixes: * Fixed `uninitialized constant Rake::DSL` error when running rake tasks on Ruby 1.9.2 -## v93 (01/28/2014) +## v93 - 2014-01-28 Features: @@ -125,7 +1358,7 @@ Features: Bugfixes: -## v92 (01/27/2014) +## v92 - 2014-01-27 Features: @@ -134,7 +1367,7 @@ Bugfixes: * Only display rake error messages if a `Rakefile` exists * when detecting for ruby version, don't use stderr messages -## v91 (01/16/2014) +## v91 - 2014-01-16 Features: @@ -143,7 +1376,7 @@ Features: Bugfixes: -## v90 (01/09/2014) +## v90 - 2014-01-09 Features: @@ -151,7 +1384,7 @@ Features: Bugfixes: -## v89 (01/09/2014) +## v89 - 2014-01-09 Features: @@ -159,7 +1392,7 @@ Features: Bugfixes: -## v86 (12/11/2013) +## v86 - 2013-12-11 Features: @@ -168,7 +1401,7 @@ Bugfixes: * Windows warnings will now display before bundle install, this prevents an un-resolvable `Gemfile` from erroring which previously prevented the warning roll up from being shown. When this happened the developer did not see that we are clearing the `Gemfile.lock` from the git repository when bundled on a windows machine. * Checks for `public/assets/manifest*.json` and `public/assets/manifest.yml` will now come before Rake task detection introduced in v85. -## v85 (12/05/2013) +## v85 - 2013-12-05 Features: @@ -178,7 +1411,7 @@ Bugfixes: * Any errors in a Rakefile will now be explicitly shown as such instead of hidden in a `assets:precompile` task detection failure (#171) * Now using correct default "hobby" database #179 -## v84 (11/06/2013) +## v84 - 2013-11-06 Features: @@ -189,7 +1422,7 @@ Bugfixes: * Fix default gem cache -## v83 (10/29/2013) +## v83 - 2013-10-29 Features: @@ -203,20 +1436,20 @@ Bugfixes: * Errors in Gemfiles will no longer show up as bad ruby versions #36 * Fix warning warning libjffi-1.2.so on < JRuby 1.7.3 -## v82 (10/28/2013) +## v82 - 2013-10-28 Bugfixes: * Rails 3 deploys that do not successfully run `assets:precompile` will now fail. -## v81 (10/15/2013) +## v81 - 2013-10-15 Features: * add Default Bundler Cache for new Ruby 2.0.0 apps * use Virginia S3 bucket instead of Cloudfront -## v80 (9/23/2013) +## v80 - 2013-09-23 Features: @@ -225,16 +1458,16 @@ Features: Bugfixes: -* Disable invoke dynamic on JRuby by default until JDK stabalizes it +* Disable invoke dynamic on JRuby by default until JDK stabilizes it -## v79 (9/3/2013) +## v79 - 2013-09-03 Bugfixes: * Remove LPXC debug output when `DEBUG` env var is set (#141) * Symlink ruby.exe, so Rails 4 bins work for Windows (#139) -## v78 (8/28/2013) +## v78 - 2013-08-28 Features: @@ -244,19 +1477,19 @@ Bugfixes: * Fix issue #127 Race condition with LPXC -## v77 (8/5/2013) +## v77 - 2013-08-05 Features: * Force nokogiri to compile with system libs -## v76 (7/29/2013) +## v76 - 2013-07-29 Bugfixes: * fix request_id for instrumentation to follow standard -## v75 (7/29/2013) +## v75 - 2013-07-29 Features: @@ -267,45 +1500,45 @@ Bugfixes: * OpenJDK version was rolled back, stop special casing JRuby 1.7.3. -## v74 (7/24/2013) +## v74 - 2013-07-24 Bugfixes: * Lock JRuby 1.7.3 and lower to older version of JDK due to -## v73 (7/23/2013) +## v73 - 2013-07-23 * Revert to v69 due to asset:precompile bugs -## v72 (7/23/2013) +## v72 - 2013-07-23 Bugfixes: * Fix rake task detection for Rails 3 (@hynkle, #118) -## v71 (7/18/2013) +## v71 - 2013-07-18 * Revert to v69 due to asset:precompile bugs -## v70 (7/18/2013) +## v70 - 2013-07-18 Bugfixes: * Don't silently fail rake task checks (@gabrielg, #34) -## v69 (7/16/2013) +## v69 - 2013-07-16 Bugfixes: * Add spacing to end of instrumentation -## v68 (7/16/2013) +## v68 - 2013-07-16 Features: * Log buildpack name and entering rails3/4 compile -## v67 (7/10/2013) +## v67 - 2013-07-10 Features: @@ -317,13 +1550,13 @@ Bugfixes: * Don't print DEBUG messages for lxpc when env var is present * Fix ruby gemfile warning line for JRuby -## v66 (7/9/2013) +## v66 - 2013-07-09 Bugfixes: * Include logtoken properly -## v65 (7/9/2013) +## v65 - 2013-07-09 Features: @@ -333,7 +1566,7 @@ Bugfixes: * Fix DATABASE_URL to use jdbc-postgres for JRuby (@jkrall, #116) -## v64 (6/19/2013) +## v64 - 2013-06-19 Features: @@ -345,7 +1578,7 @@ Bugfixes: * fix sqlite3 error messaging detection -## v63 (6/17/2013) +## v63 - 2013-06-17 Features: @@ -353,14 +1586,14 @@ Features: * Change default ruby to 2.0.0 * Stop using the stack image ruby and always vendor ruby -## v62 (5/21/2013) +## v62 - 2013-05-21 Bugfixes: * Correctly detect asset manifest files in Rails 4 * Fix jruby 1.8.7 bundler/psych require bug -## v61 (4/18/2013) +## v61 - 2013-04-18 Features: @@ -370,62 +1603,62 @@ Bugfixes: * Rebuild bundler cache if rubygems 2 is detected. Bugfixes in later rubygems. -## v60 (4/17/2013) +## v60 - 2013-04-17 Security: * Disable Java RMI Remote Classloading for CVE-2013-1537, -## v59 (4/4/2013) +## v59 - 2013-04-04 Bugfixes: * Change JVM S3 bucket -## v58 (3/19/2013) +## v58 - 2013-03-19 Bugfixes: * Fix ruby 1.8.7 not being able to compile native extensions -## v57 (3/18/2013) +## v57 - 2013-03-18 Bugfixes: * Fix git gemspec bug in bundler -## v56 (3/11/2013) +## v56 - 2013-03-11 Bugfixes: * Upgrade bundler to 1.3.2 to fix --dry-clean/Would have removed bug in bundle clean, part 2. -## v55 (3/7/2013) +## v55 - 2013-03-07 Bugfixes: * Revert back to Bundler 1.3.0.pre.5, see https://gist.github.com/mattonrails/e063caf86962995e7ba0 -## v54 (3/7/2013) +## v54 - 2013-03-07 Bugfixes: * Upgrade bundler to 1.3.2 to fix --dry-clean/Would have removed bug in bundle clean -## v53 (3/6/2013) +## v53 - 2013-03-06 Bugfixes: * bin/detect for Rails 3 and 4 will use railties for detection vs the rails gem * bin/detect does not error out when Gemfile + Gemfile.lock are missing -## v52 (2/25/2013) +## v52 - 2013-02-25 Bugfixes: * Revert back to 1.3.0.pre.5 due to bundler warnings -## v51 (2/25/2013) +## v51 - 2013-02-25 Features: @@ -436,39 +1669,39 @@ Bugfixes: * Better buildpack detection through Gemfile.lock gems -## v50 (1/31/2013) +## v50 - 2013-01-31 Features: * Restore ruby deploys back to normal -## v49 (1/30/2013) +## v49 - 2013-01-30 Features: * Re-enable ruby deploys for apps just using the heroku cache * Display ruby version change when busting the cache -## v48 (1/30/2013) +## v48 - 2013-01-30 Features: * Update deploy error message copy to link to status incident. -## v47 (1/30/2013) +## v47 - 2013-01-30 Features: * Disable ruby deploys due to rubygems.org compromise -## v46 (1/10/2013) +## v46 - 2013-01-10 Features: * Upgrade Bundler to 1.3.0.pre.5 * bundler binstubs now go in vendor/bundle/bin -## v45 (12/14/2012) +## v45 - 2012-12-14 Features: @@ -476,87 +1709,87 @@ Features: * Enable Invoke Dynamic on JRuby by default * GEM_PATH is now updated on each push -## v44 (12/14/2012) +## v44 - 2012-12-14 Faulty Release -## v43 (12/13/2012) +## v43 - 2012-12-13 Features: * Upgrade Bundler to 1.3.0.pre.2 -## v42 (11/26/2012) +## v42 - 2012-11-26 Features: * Upgrade Bundler to 1.2.2 to fix Ruby 2.0.0/YAML issues -## v41 (11/1/2012) +## v41 - 2012-11-01 Features: * Enable ruby 2.0.0 support for testing -## v40 (10/14/2012) +## v40 - 2012-10-14 Features: * Cache version of the buildpack we used to deploy * Purge cache when v38 is detected -## v39 (10/14/2012) +## v39 - 2012-10-14 Bugfixes: * Don't display cache clearing message for new apps * Actually clear bundler cache on ruby version change -## v38 (10/14/2012) +## v38 - 2012-10-14 Bugfixes: * Stop bundle cache from continually growing -## v37 (10/12/2012) +## v37 - 2012-10-12 Bugfixes: * Remove temporary workaround from v36. * Clear bundler cache upon Ruby version change -## v36 (10/12/2012) +## v36 - 2012-10-12 Bugfixes: * Always clear the cache for ruby 1.9.3 as a temporary workaround due to the security upgrade -## v35 (9/19/2012) +## v35 - 2012-09-19 Features: * Upgrade to Bundler 1.2.1 * Display bundle clean output -* More resilent to rubygems.org API outages +* More resilient to rubygems.org API outages Bugfixes: * `bundle clean` works again -## v34 (8/30/2012) +## v34 - 2012-08-30 Features: * Upgrade to Bundler 1.2.0 -## v33 (8/9/2012) +## v33 - 2012-08-09 Features: * Upgrade to Bundler 1.2.0.rc.2 * vendor JDK7 for JRuby, but disable invoke dynamic -## v29 (7/19/2012) +## v29 - 2012-07-19 Features: @@ -564,7 +1797,7 @@ Features: * sync stdout so that the buildpack streams even in non-interactive shells * Upgrade to Bundler 1.2.0.rc -## v28 (7/16/2012) +## v28 - 2012-07-16 Features: @@ -575,39 +1808,39 @@ Bugfixes: * sqlite3 error gets displayed again -## v27 (6/14/2012) +## v27 - 2012-06-14 Bugfixes: * Remove `vendor/bundle` message only appears when dir actually exists -## v26 (6/14/2012) +## v26 - 2012-06-14 Features: * print message when assets:precompile finishes successfully * Remove `vendor/bundle` if user commits it to their git repo. -## v25 (6/12/2012) +## v25 - 2012-06-12 Features: * support "ruby-xxx-jruby-yyy" for jruby detection packages -## v24 (6/7/2012) +## v24 - 2012-06-07 Features: * removes bundler cache in the slug, to minimize slug size (@stevenh512, #16) * optimize push time with caching -## v23 (5/8/2012) +## v23 - 2012-05-08 Bugfixes: * fix ruby version bug with "fatal:-Not-a-git-repository" -## v22 (5/7/2012) +## v22 - 2012-05-07 Features: @@ -618,25 +1851,25 @@ Deprecation: * ENV['RUBY_VERSION'] in favor of bundler's ruby DSL -## v21 (3/21/2012) +## v21 - 2012-03-21 Features: * bundler 1.1.2 -## v20 (3/12/2012) +## v20 - 2012-03-12 Features: * bundler 1.1.0 \o/ -## v19 (1/25/2012) +## v19 - 2012-01-25 Bugfixes: * fix native extension building for rbx 2.0.0dev -## v18 (1/18/2012) +## v18 - 2012-01-18 Features: @@ -647,49 +1880,49 @@ Bugfixes: * force db password to be a string in the yaml file -## v17 (12/29/2011) +## v17 - 2011-12-29 Features: * bundler 1.1.rc.7 -## v16 (12/29/2011) +## v16 - 2011-12-29 Features: * pass DATABASE_URL to rails 3.1 assets:precompile rake task detection -## v15 (12/27/2011) +## v15 - 2011-12-27 Features: * bundler 1.1.rc.6 -## v14 (12/22/2011) +## v14 - 2011-12-22 Bugfixes: * stop freedom patching syck in ruby 1.9.3+ -## v13 (12/15/2011) +## v13 - 2011-12-15 Features: * bundler 1.1.rc.5 -## v12 (12/13/2011) +## v12 - 2011-12-13 Bugfixes: * syck workaround for yaml/psych issues -## v11 (12/12/2011) +## v11 - 2011-12-12 Features: * bundler 1.1.rc.3 -## v10 (11/23/2011) +## v10 - 2011-11-23 Features: @@ -700,7 +1933,7 @@ Bugfixes: * don't show sqlite3 error if it's in a bundle without group on failed bundle install -## v9 (11/14/2011) +## v9 - 2011-11-14 Features: @@ -711,13 +1944,13 @@ Bugfixes: * don't leave behind ruby_versions.yml -## v8 (11/8/2011) +## v8 - 2011-11-08 Features: * use vm as part of RUBY_VERSION -## v7 (11/8/2011) +## v7 - 2011-11-08 Features: @@ -728,7 +1961,7 @@ Bugfixes: * move "bin/" to the front of the PATH, so apps can override existing bins -## v6 (11/2/2011) +## v6 - 2011-11-02 Features: @@ -738,3 +1971,170 @@ Bugfixes: * Change gem detection to use lockfile parser * use `$RACK_ENV` when thin is detected for rack apps + +[unreleased]: https://github.com/heroku/heroku-buildpack-ruby/compare/v362...main +[v362]: https://github.com/heroku/heroku-buildpack-ruby/compare/v361...v362 +[v361]: https://github.com/heroku/heroku-buildpack-ruby/compare/v360...v361 +[v360]: https://github.com/heroku/heroku-buildpack-ruby/compare/v359...v360 +[v359]: https://github.com/heroku/heroku-buildpack-ruby/compare/v358...v359 +[v359]: https://github.com/heroku/heroku-buildpack-ruby/compare/v358...v359 +[v358]: https://github.com/heroku/heroku-buildpack-ruby/compare/v357...v358 +[v357]: https://github.com/heroku/heroku-buildpack-ruby/compare/v356...v357 +[v356]: https://github.com/heroku/heroku-buildpack-ruby/compare/v355...v356 +[v355]: https://github.com/heroku/heroku-buildpack-ruby/compare/v354...v355 +[v354]: https://github.com/heroku/heroku-buildpack-ruby/compare/v353...v354 +[v353]: https://github.com/heroku/heroku-buildpack-ruby/compare/v352...v353 +[v352]: https://github.com/heroku/heroku-buildpack-ruby/compare/v351...v352 +[v351]: https://github.com/heroku/heroku-buildpack-ruby/compare/v350...v351 +[v350]: https://github.com/heroku/heroku-buildpack-ruby/compare/v349...v350 +[v349]: https://github.com/heroku/heroku-buildpack-ruby/compare/v348...v349 +[v348]: https://github.com/heroku/heroku-buildpack-ruby/compare/v347...v348 +[v347]: https://github.com/heroku/heroku-buildpack-ruby/compare/v346...v347 +[v346]: https://github.com/heroku/heroku-buildpack-ruby/compare/v345...v346 +[v345]: https://github.com/heroku/heroku-buildpack-ruby/compare/v344...v345 +[v344]: https://github.com/heroku/heroku-buildpack-ruby/compare/v343...v344 +[v343]: https://github.com/heroku/heroku-buildpack-ruby/compare/v342...v343 +[v342]: https://github.com/heroku/heroku-buildpack-ruby/compare/v341...v342 +[v341]: https://github.com/heroku/heroku-buildpack-ruby/compare/v340...v341 +[v340]: https://github.com/heroku/heroku-buildpack-ruby/compare/v339...v340 +[v339]: https://github.com/heroku/heroku-buildpack-ruby/compare/v338...v339 +[v338]: https://github.com/heroku/heroku-buildpack-ruby/compare/v337...v338 +[v337]: https://github.com/heroku/heroku-buildpack-ruby/compare/v336...v337 +[v336]: https://github.com/heroku/heroku-buildpack-ruby/compare/v335...v336 +[v335]: https://github.com/heroku/heroku-buildpack-ruby/compare/v334...v335 +[v334]: https://github.com/heroku/heroku-buildpack-ruby/compare/v333...v334 +[v333]: https://github.com/heroku/heroku-buildpack-ruby/compare/v332...v333 +[v332]: https://github.com/heroku/heroku-buildpack-ruby/compare/v331...v332 +[v331]: https://github.com/heroku/heroku-buildpack-ruby/compare/v330...v331 +[v330]: https://github.com/heroku/heroku-buildpack-ruby/compare/v329...v330 +[v329]: https://github.com/heroku/heroku-buildpack-ruby/compare/v328...v329 +[v328]: https://github.com/heroku/heroku-buildpack-ruby/compare/v327...v328 +[v327]: https://github.com/heroku/heroku-buildpack-ruby/compare/v326...v327 +[v326]: https://github.com/heroku/heroku-buildpack-ruby/compare/v325...v326 +[v325]: https://github.com/heroku/heroku-buildpack-ruby/compare/v324...v325 +[v324]: https://github.com/heroku/heroku-buildpack-ruby/compare/v323...v324 +[v323]: https://github.com/heroku/heroku-buildpack-ruby/compare/v322...v323 +[v322]: https://github.com/heroku/heroku-buildpack-ruby/compare/v321...v322 +[v321]: https://github.com/heroku/heroku-buildpack-ruby/compare/v320...v321 +[v320]: https://github.com/heroku/heroku-buildpack-ruby/compare/v319...v320 +[v319]: https://github.com/heroku/heroku-buildpack-ruby/compare/v318...v319 +[v318]: https://github.com/heroku/heroku-buildpack-ruby/compare/v317...v318 +[v317]: https://github.com/heroku/heroku-buildpack-ruby/compare/v316...v317 +[v316]: https://github.com/heroku/heroku-buildpack-ruby/compare/v315...v316 +[v315]: https://github.com/heroku/heroku-buildpack-ruby/compare/v314...v315 +[v314]: https://github.com/heroku/heroku-buildpack-ruby/compare/v313...v314 +[v313]: https://github.com/heroku/heroku-buildpack-ruby/compare/v312...v313 +[v312]: https://github.com/heroku/heroku-buildpack-ruby/compare/v311...v312 +[v311]: https://github.com/heroku/heroku-buildpack-ruby/compare/v310...v311 +[v310]: https://github.com/heroku/heroku-buildpack-ruby/compare/v309...v310 +[v309]: https://github.com/heroku/heroku-buildpack-ruby/compare/v308...v309 +[v308]: https://github.com/heroku/heroku-buildpack-ruby/compare/v307...v308 +[v307]: https://github.com/heroku/heroku-buildpack-ruby/compare/v306...v307 +[v306]: https://github.com/heroku/heroku-buildpack-ruby/compare/v305...v306 +[v305]: https://github.com/heroku/heroku-buildpack-ruby/compare/v304...v305 +[v304]: https://github.com/heroku/heroku-buildpack-ruby/compare/v303...v304 +[v303]: https://github.com/heroku/heroku-buildpack-ruby/compare/v302...v303 +[v302]: https://github.com/heroku/heroku-buildpack-ruby/compare/v301...v302 +[v301]: https://github.com/heroku/heroku-buildpack-ruby/compare/v300...v301 +[v300]: https://github.com/heroku/heroku-buildpack-ruby/compare/v299...v300 +[v299]: https://github.com/heroku/heroku-buildpack-ruby/compare/v298...v299 +[v298]: https://github.com/heroku/heroku-buildpack-ruby/compare/v297...v298 +[v297]: https://github.com/heroku/heroku-buildpack-ruby/compare/v296...v297 +[v296]: https://github.com/heroku/heroku-buildpack-ruby/compare/v295...v296 +[v295]: https://github.com/heroku/heroku-buildpack-ruby/compare/v294...v295 +[v294]: https://github.com/heroku/heroku-buildpack-ruby/compare/v293...v294 +[v293]: https://github.com/heroku/heroku-buildpack-ruby/compare/v292...v293 +[v292]: https://github.com/heroku/heroku-buildpack-ruby/compare/v291...v292 +[v291]: https://github.com/heroku/heroku-buildpack-ruby/compare/v290...v291 +[v290]: https://github.com/heroku/heroku-buildpack-ruby/compare/v289...v290 +[v289]: https://github.com/heroku/heroku-buildpack-ruby/compare/v288...v289 +[v288]: https://github.com/heroku/heroku-buildpack-ruby/compare/v287...v288 +[v287]: https://github.com/heroku/heroku-buildpack-ruby/compare/v286...v287 +[v286]: https://github.com/heroku/heroku-buildpack-ruby/compare/v285...v286 +[v285]: https://github.com/heroku/heroku-buildpack-ruby/compare/v284...v285 +[v284]: https://github.com/heroku/heroku-buildpack-ruby/compare/v283...v284 +[v283]: https://github.com/heroku/heroku-buildpack-ruby/compare/v282...v283 +[v282]: https://github.com/heroku/heroku-buildpack-ruby/compare/v281...v282 +[v281]: https://github.com/heroku/heroku-buildpack-ruby/compare/v280...v281 +[v280]: https://github.com/heroku/heroku-buildpack-ruby/compare/v279...v280 +[v279]: https://github.com/heroku/heroku-buildpack-ruby/compare/v278...v279 +[v278]: https://github.com/heroku/heroku-buildpack-ruby/compare/v277...v278 +[v277]: https://github.com/heroku/heroku-buildpack-ruby/compare/v276...v277 +[v276]: https://github.com/heroku/heroku-buildpack-ruby/compare/v275...v276 +[v275]: https://github.com/heroku/heroku-buildpack-ruby/compare/v274...v275 +[v274]: https://github.com/heroku/heroku-buildpack-ruby/compare/v273...v274 +[v273]: https://github.com/heroku/heroku-buildpack-ruby/compare/v272...v273 +[v272]: https://github.com/heroku/heroku-buildpack-ruby/compare/v271...v272 +[v271]: https://github.com/heroku/heroku-buildpack-ruby/compare/v270...v271 +[v271]: https://github.com/heroku/heroku-buildpack-ruby/compare/v270...v271 +[v270]: https://github.com/heroku/heroku-buildpack-ruby/compare/v269...v270 +[v269]: https://github.com/heroku/heroku-buildpack-ruby/compare/v268...v269 +[v268]: https://github.com/heroku/heroku-buildpack-ruby/compare/v267...v268 +[v267]: https://github.com/heroku/heroku-buildpack-ruby/compare/v266...v267 +[v266]: https://github.com/heroku/heroku-buildpack-ruby/compare/v265...v266 +[v265]: https://github.com/heroku/heroku-buildpack-ruby/compare/v264...v265 +[v264]: https://github.com/heroku/heroku-buildpack-ruby/compare/v263...v264 +[v263]: https://github.com/heroku/heroku-buildpack-ruby/compare/v262...v263 +[v262]: https://github.com/heroku/heroku-buildpack-ruby/compare/v261...v262 +[v261]: https://github.com/heroku/heroku-buildpack-ruby/compare/v260...v261 +[v260]: https://github.com/heroku/heroku-buildpack-ruby/compare/v259...v260 +[v259]: https://github.com/heroku/heroku-buildpack-ruby/compare/v258...v259 +[v258]: https://github.com/heroku/heroku-buildpack-ruby/compare/v257...v258 +[v257]: https://github.com/heroku/heroku-buildpack-ruby/compare/v256...v257 +[v256]: https://github.com/heroku/heroku-buildpack-ruby/compare/v255...v256 +[v255]: https://github.com/heroku/heroku-buildpack-ruby/compare/v254...v255 +[v254]: https://github.com/heroku/heroku-buildpack-ruby/compare/v253...v254 +[v253]: https://github.com/heroku/heroku-buildpack-ruby/compare/v252...v253 +[v252]: https://github.com/heroku/heroku-buildpack-ruby/compare/v251...v252 +[v251]: https://github.com/heroku/heroku-buildpack-ruby/compare/v250...v251 +[v250]: https://github.com/heroku/heroku-buildpack-ruby/compare/v249...v250 +[v249]: https://github.com/heroku/heroku-buildpack-ruby/compare/v248...v249 +[v248]: https://github.com/heroku/heroku-buildpack-ruby/compare/v247...v248 +[v247]: https://github.com/heroku/heroku-buildpack-ruby/compare/v246...v247 +[v246]: https://github.com/heroku/heroku-buildpack-ruby/compare/v245...v246 +[v245]: https://github.com/heroku/heroku-buildpack-ruby/compare/v244...v245 +[v244]: https://github.com/heroku/heroku-buildpack-ruby/compare/v243...v244 +[v243]: https://github.com/heroku/heroku-buildpack-ruby/compare/v242...v243 +[v242]: https://github.com/heroku/heroku-buildpack-ruby/compare/v241...v242 +[v241]: https://github.com/heroku/heroku-buildpack-ruby/compare/v240...v241 +[v240]: https://github.com/heroku/heroku-buildpack-ruby/compare/v239...v240 +[v239]: https://github.com/heroku/heroku-buildpack-ruby/compare/v238...v239 +[v238]: https://github.com/heroku/heroku-buildpack-ruby/compare/v237...v238 +[v237]: https://github.com/heroku/heroku-buildpack-ruby/compare/v236...v237 +[v236]: https://github.com/heroku/heroku-buildpack-ruby/compare/v235...v236 +[v235]: https://github.com/heroku/heroku-buildpack-ruby/compare/v234...v235 +[v234]: https://github.com/heroku/heroku-buildpack-ruby/compare/v233...v234 +[v233]: https://github.com/heroku/heroku-buildpack-ruby/compare/v232...v233 +[v232]: https://github.com/heroku/heroku-buildpack-ruby/compare/v231...v232 +[v231]: https://github.com/heroku/heroku-buildpack-ruby/compare/v230...v231 +[v230]: https://github.com/heroku/heroku-buildpack-ruby/compare/v229...v230 +[v229]: https://github.com/heroku/heroku-buildpack-ruby/compare/v228...v229 +[v228]: https://github.com/heroku/heroku-buildpack-ruby/compare/v227...v228 +[v227]: https://github.com/heroku/heroku-buildpack-ruby/compare/v226...v227 +[v226]: https://github.com/heroku/heroku-buildpack-ruby/compare/v225...v226 +[v225]: https://github.com/heroku/heroku-buildpack-ruby/compare/v224...v225 +[v224]: https://github.com/heroku/heroku-buildpack-ruby/compare/v223...v224 +[v223]: https://github.com/heroku/heroku-buildpack-ruby/compare/v222...v223 +[v222]: https://github.com/heroku/heroku-buildpack-ruby/compare/v221...v222 +[v221]: https://github.com/heroku/heroku-buildpack-ruby/compare/v220...v221 +[v220]: https://github.com/heroku/heroku-buildpack-ruby/compare/v219...v220 +[v219]: https://github.com/heroku/heroku-buildpack-ruby/compare/v218...v219 +[v218]: https://github.com/heroku/heroku-buildpack-ruby/compare/v217...v218 +[v217]: https://github.com/heroku/heroku-buildpack-ruby/compare/v216...v217 +[v216]: https://github.com/heroku/heroku-buildpack-ruby/compare/v215...v216 +[v215]: https://github.com/heroku/heroku-buildpack-ruby/compare/v214...v215 +[v214]: https://github.com/heroku/heroku-buildpack-ruby/compare/v213...v214 +[v213]: https://github.com/heroku/heroku-buildpack-ruby/compare/v212...v213 +[v212]: https://github.com/heroku/heroku-buildpack-ruby/compare/v211...v212 +[v211]: https://github.com/heroku/heroku-buildpack-ruby/compare/v210...v211 +[v210]: https://github.com/heroku/heroku-buildpack-ruby/compare/v209...v210 +[v209]: https://github.com/heroku/heroku-buildpack-ruby/compare/v208...v209 +[v208]: https://github.com/heroku/heroku-buildpack-ruby/compare/v207...v208 +[v207]: https://github.com/heroku/heroku-buildpack-ruby/compare/v206...v207 +[v206]: https://github.com/heroku/heroku-buildpack-ruby/compare/v205...v206 +[v205]: https://github.com/heroku/heroku-buildpack-ruby/compare/v204...v205 +[v204]: https://github.com/heroku/heroku-buildpack-ruby/compare/v203...v204 +[v203]: https://github.com/heroku/heroku-buildpack-ruby/compare/v202...v203 +[v202]: https://github.com/heroku/heroku-buildpack-ruby/compare/v201...v202 +[v201]: https://github.com/heroku/heroku-buildpack-ruby/compare/v200...v201 +[v200]: https://github.com/heroku/heroku-buildpack-ruby/compare/v199...v200 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..9e229777d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,105 @@ +# Salesforce Open Source Community Code of Conduct + +## About the Code of Conduct + +Equality is a core value at Salesforce. We believe a diverse and inclusive +community fosters innovation and creativity, and are committed to building a +culture where everyone feels included. + +Salesforce open-source projects are committed to providing a friendly, safe, and +welcoming environment for all, regardless of gender identity and expression, +sexual orientation, disability, physical appearance, body size, ethnicity, nationality, +race, age, religion, level of experience, education, socioeconomic status, or +other similar personal characteristics. + +The goal of this code of conduct is to specify a baseline standard of behavior so +that people with different social values and communication styles can work +together effectively, productively, and respectfully in our open source community. +It also establishes a mechanism for reporting issues and resolving conflicts. + +All questions and reports of abusive, harassing, or otherwise unacceptable behavior +in a Salesforce open-source project may be reported by contacting the Salesforce +Open Source Conduct Committee at ossconduct@salesforce.com. + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of gender +identity and expression, sexual orientation, disability, physical appearance, +body size, ethnicity, nationality, race, age, religion, level of experience, education, +socioeconomic status, or other similar personal characteristics. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy toward other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Personal attacks, insulting/derogatory comments, or trolling +* Public or private harassment +* Publishing, or threatening to publish, others' private information—such as +a physical or electronic address—without explicit permission +* Other conduct which could reasonably be considered inappropriate in a +professional setting +* Advocating for or encouraging any of the above behaviors + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned with this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project email +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the Salesforce Open Source Conduct Committee +at ossconduct@salesforce.com. All complaints will be reviewed and investigated +and will result in a response that is deemed necessary and appropriate to the +circumstances. The committee is obligated to maintain confidentiality with +regard to the reporter of an incident. Further details of specific enforcement +policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership and the Salesforce Open Source Conduct +Committee. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][contributor-covenant-home], +version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html. +It includes adaptations and additions from [Go Community Code of Conduct][golang-coc], +[CNCF Code of Conduct][cncf-coc], and [Microsoft Open Source Code of Conduct][microsoft-coc]. + +This Code of Conduct is licensed under the [Creative Commons Attribution 3.0 License][cc-by-3-us]. + +[contributor-covenant-home]: https://www.contributor-covenant.org +[golang-coc]: https://golang.org/conduct +[cncf-coc]: https://github.com/cncf/foundation/blob/master/code-of-conduct.md +[microsoft-coc]: https://opensource.microsoft.com/codeofconduct/ +[cc-by-3-us]: https://creativecommons.org/licenses/by/3.0/us/ diff --git a/Gemfile b/Gemfile index c8b4a4f5f..6075fe158 100644 --- a/Gemfile +++ b/Gemfile @@ -1,13 +1,18 @@ source "https://rubygems.org" +git_source(:github) { |repo| "https://github.com/#{repo}.git" } + +ruby "3.3.9" group :development, :test do + gem "toml-rb" gem "heroku_hatchet" gem "rspec-core" gem "rspec-expectations" gem "excon" gem "rake" - gem "parallel_tests" - gem 'rspec-retry' - gem "netrc" - gem "git", github: "hone/ruby-git", branch: "master" + gem "parallel_split_test" + gem "rspec-retry" + gem "json" + gem "redis" + gem "standard" end diff --git a/Gemfile.lock b/Gemfile.lock index ea111ea9c..dcd16611b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,79 +1,122 @@ -GIT - remote: git://github.com/hone/ruby-git.git - revision: 264836fcff3c037d8d8fc44bd770b150b46fdc4e - branch: master - specs: - git (1.2.6) - GEM remote: https://rubygems.org/ specs: - activesupport (4.0.3) - i18n (~> 0.6, >= 0.6.4) - minitest (~> 4.2) - multi_json (~> 1.3) - thread_safe (~> 0.1) - tzinfo (~> 0.3.37) - anvil-cli (0.16.1) - progress (~> 2.4.0) - rest-client (~> 1.6.7) - thor (~> 0.15.2) - atomic (1.1.14) - diff-lcs (1.1.3) - excon (0.31.0) - heroku-api (0.3.17) - excon (~> 0.27) - multi_json (~> 1.8.2) - heroku_hatchet (1.3.2) - activesupport (~> 4) - anvil-cli (~> 0) - excon (~> 0) - heroku-api (~> 0) - repl_runner (~> 0.0.3) + ast (2.4.3) + base64 (0.3.0) + citrus (3.0.2) + connection_pool (2.5.3) + diff-lcs (1.6.2) + erubis (2.7.0) + excon (1.6.0) + logger + heroics (0.1.3) + base64 + erubis (~> 2.0) + excon + moneta + multi_json (>= 1.9.2) + webrick + heroku_hatchet (8.0.6) + excon (< 2) + platform-api (~> 3) rrrretry (~> 1) - thor (~> 0) + thor (~> 1) threaded (~> 0) - i18n (0.6.9) - mime-types (2.1) - minitest (4.7.5) - multi_json (1.8.4) - netrc (0.7.7) - parallel (0.6.5) - parallel_tests (0.13.1) - parallel - progress (2.4.0) - rake (10.0.4) - repl_runner (0.0.3) - activesupport - rest-client (1.6.7) - mime-types (>= 1.16) + json (2.21.2) + language_server-protocol (3.17.0.6) + lint_roller (1.1.0) + logger (1.7.0) + moneta (1.0.0) + multi_json (1.17.0) + parallel (2.1.0) + parallel_split_test (0.10.0) + parallel (>= 0.5.13) + rspec-core (>= 3.9.0) + parser (3.3.12.0) + ast (~> 2.4.1) + racc + platform-api (3.8.0) + heroics (~> 0.1.1) + moneta (~> 1.0.0) + rate_throttle_client (~> 0.1.0) + prism (1.9.0) + racc (1.8.1) + rainbow (3.1.1) + rake (13.4.2) + rate_throttle_client (0.1.2) + redis (5.4.1) + redis-client (>= 0.22.0) + redis-client (0.25.1) + connection_pool + regexp_parser (2.12.0) rrrretry (1.0.0) - rspec (2.2.0) - rspec-core (~> 2.2) - rspec-expectations (~> 2.2) - rspec-mocks (~> 2.2) - rspec-core (2.13.1) - rspec-expectations (2.12.1) - diff-lcs (~> 1.1.3) - rspec-mocks (2.13.1) - rspec-retry (0.2.1) - rspec - thor (0.15.4) - thread_safe (0.1.3) - atomic + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-retry (0.6.2) + rspec-core (> 3.3) + rspec-support (3.13.6) + rubocop (1.88.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + ruby-progressbar (1.13.0) + standard (1.56.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.88.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.9.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.26.0) + thor (1.4.0) threaded (0.0.4) - tzinfo (0.3.38) + toml-rb (4.2.0) + citrus (~> 3.0, > 3.0) + racc (~> 1.7) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + webrick (1.9.1) PLATFORMS ruby DEPENDENCIES excon - git! heroku_hatchet - netrc - parallel_tests + json + parallel_split_test rake + redis rspec-core rspec-expectations rspec-retry + standard + toml-rb + +RUBY VERSION + ruby 3.3.9p170 + +BUNDLED WITH + 2.5.22 diff --git a/README.md b/README.md index d23812a94..3e4a55d1a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ -Heroku buildpack: Ruby -====================== +# Heroku Buildpack for Ruby +![ruby](https://raw.githubusercontent.com/heroku/buildpacks/refs/heads/main/assets/images/buildpack-banner-ruby.png) -This is a [Heroku buildpack](http://devcenter.heroku.com/articles/buildpacks) for Ruby, Rack, and Rails apps. It uses [Bundler](http://gembundler.com) for dependency management. +This is a [Heroku Buildpack](http://devcenter.heroku.com/articles/buildpacks) for Ruby, Rack, and Rails apps. It uses [Bundler](https://bundler.io) for dependency management. -Usage ------ +This buildpack requires 64-bit Linux. + +## Usage ### Ruby @@ -13,9 +14,9 @@ Example Usage: $ ls Gemfile Gemfile.lock - $ heroku create --stack cedar --buildpack https://github.com/heroku/heroku-buildpack-ruby.git + $ heroku create --buildpack heroku/ruby - $ git push heroku master + $ git push heroku main ... -----> Heroku receiving push -----> Fetching custom buildpack @@ -31,162 +32,28 @@ Example Usage: Procfile declares types -> (none) Default types for Ruby -> console, rake -The buildpack will detect your app as Ruby if it has a `Gemfile` and `Gemfile.lock` files in the root directory. It will then proceed to run `bundle install` after setting up the appropriate environment for [ruby](http://ruby-lang.org) and [Bundler](http://gembundler.com). +The buildpack will detect your app as Ruby if it has a `Gemfile` and `Gemfile.lock` files in the root directory. It will then proceed to run `bundle install` after setting up the appropriate environment for [ruby](http://ruby-lang.org) and [Bundler](https://bundler.io). -#### Run the Tests +## Documentation -The tests on this buildpack are written in Rspec to allow the use of -`focused: true`. Parallelization of testing is provided by -https://github.com/grosser/parallel_tests this lib spins up an arbitrary -number of processes and running a different test file in each process, -it does not parallelize tests within a test file. To run the tests: clone the repo, then `bundle install` then clone the test fixtures by running: +For more information about using Ruby and buildpacks on Heroku, see these Dev Center articles: -```sh -$ hatchet install -``` +- [Heroku Ruby Support](https://devcenter.heroku.com/articles/ruby-support) +- [Getting Started with Ruby on Heroku](https://devcenter.heroku.com/articles/getting-started-with-ruby) +- [Getting Started with Rails 7 on Heroku](https://devcenter.heroku.com/articles/getting-started-with-rails7) +- [Buildpacks](https://devcenter.heroku.com/articles/buildpacks) +- [Buildpack API](https://devcenter.heroku.com/articles/buildpack-api) -Now run the tests: +## Hacking + +To use this buildpack, fork it on Github. Push up changes to your fork, then create a test app with `--buildpack ` and push to it. + +### Testing ```sh -$ bundle exec parallel_rspec -n 6 spec/ +$ bundle exec hatchet install ``` -If you don't want to run them in parallel you can still: - ```sh $ bundle exec rake spec ``` - -Now go take a nap or something for a really long time. - -#### Bundler - -For non-windows `Gemfile.lock` files, the `--deployment` flag will be used. In the case of windows, the Gemfile.lock will be deleted and Bundler will do a full resolve so native gems are handled properly. The `vendor/bundle` directory is cached between builds to allow for faster `bundle install` times. `bundle clean` is used to ensure no stale gems are stored between builds. - -### Rails 2 - -Example Usage: - - $ ls - app config db doc Gemfile Gemfile.lock lib log public Rakefile README script test tmp vendor - - $ ls config/environment.rb - config/environment.rb - - $ heroku create --stack cedar --buildpack https://github.com/heroku/heroku-buildpack-ruby.git - - $ git push heroku master - ... - -----> Heroku receiving push - -----> Ruby/Rails app detected - -----> Installing dependencies using Bundler version 1.1.rc - ... - -----> Writing config/database.yml to read from DATABASE_URL - -----> Rails plugin injection - Injecting rails_log_stdout - -----> Discovering process types - Procfile declares types -> (none) - Default types for Ruby/Rails -> console, rake, web, worker - -The buildpack will detect your app as a Rails 2 app if it has a `environment.rb` file in the `config` directory. - -#### Rails Log STDOUT - A [rails_log_stdout](http://github.com/ddollar/rails_log_stdout) is installed by default so Rails' logger will log to STDOUT and picked up by Heroku's [logplex](http://github.com/heroku/logplex). - -#### Auto Injecting Plugins - -Any vendored plugin can be stopped from being installed by creating the directory it's installed to in the slug. For instance, to prevent rails_log_stdout plugin from being injected, add `vendor/plugins/rails_log_stdout/.gitkeep` to your git repo. - -### Rails 3 - -Example Usage: - - $ ls - app config config.ru db doc Gemfile Gemfile.lock lib log Procfile public Rakefile README script tmp vendor - - $ ls config/application.rb - config/application.rb - - $ heroku create --stack cedar --buildpack https://github.com/heroku/heroku-buildpack-ruby.git - - $ git push heroku master - -----> Heroku receiving push - -----> Ruby/Rails app detected - -----> Installing dependencies using Bundler version 1.1.rc - Running: bundle install --without development:test --path vendor/bundle --deployment - ... - -----> Writing config/database.yml to read from DATABASE_URL - -----> Preparing app for Rails asset pipeline - Running: rake assets:precompile - -----> Rails plugin injection - Injecting rails_log_stdout - Injecting rails3_serve_static_assets - -----> Discovering process types - Procfile declares types -> web - Default types for Ruby/Rails -> console, rake, worker - -The buildpack will detect your apps as a Rails 3 app if it has an `application.rb` file in the `config` directory. - -#### Assets - -To enable static assets being served on the dyno, [rails3_serve_static_assets](http://github.com/pedro/rails3_serve_static_assets) is installed by default. If the [execjs gem](http://github.com/sstephenson/execjs) is detected then [node.js](http://github.com/joyent/node) will be vendored. The `assets:precompile` rake task will get run if no `public/manifest.yml` is detected. See [this article](http://devcenter.heroku.com/articles/rails31_heroku_cedar) on how rails 3.1 works on cedar. - -Hacking -------- - -To use this buildpack, fork it on Github. Push up changes to your fork, then create a test app with `--buildpack ` and push to it. - -To change the vendored binaries for Bundler, [Node.js](http://github.com/joyent/node), and rails plugins, use the rake tasks provided by the `Rakefile`. You'll need an S3-enabled AWS account and a bucket to store your binaries in as well as the [vulcan](http://github.com/heroku/vulcan) gem to build the binaries on heroku. - -For example, you can change the vendored version of Bundler to 1.1.rc. - -First you'll need to build a Heroku-compatible version of Node.js: - - $ export AWS_ID=xxx AWS_SECRET=yyy S3_BUCKET=zzz - $ s3 create $S3_BUCKET - $ rake gem:install[bundler,1.1.rc] - -Open `lib/language_pack/ruby.rb` in your editor, and change the following line: - - BUNDLER_VERSION = "1.1.rc" - -Open `lib/language_pack/base.rb` in your editor, and change the following line: - - VENDOR_URL = "https://s3.amazonaws.com/zzz" - -Commit and push the changes to your buildpack to your Github fork, then push your sample app to Heroku to test. You should see: - - -----> Installing dependencies using Bundler version 1.1.rc - -NOTE: You'll need to vendor the plugins, node, Bundler, and libyaml by running the rake tasks for the buildpack to work properly. - -Flow ----- - -Here's the basic flow of how the buildpack works: - -Ruby (Gemfile and Gemfile.lock is detected) - -* runs Bundler -* installs binaries - * installs node if the gem execjs is detected -* runs `rake assets:precompile` if the rake task is detected - -Rack (config.ru is detected) - -* everything from Ruby -* sets RACK_ENV=production - -Rails 2 (config/environment.rb is detected) - -* everything from Rack -* sets RAILS_ENV=production -* install rails 2 plugins - * [rails_log_stdout](http://github.com/ddollar/rails_log_stdout) - -Rails 3 (config/application.rb is detected) - -* everything from Rails 2 -* install rails 3 plugins - * [rails3_server_static_assets](https://github.com/pedro/rails3_serve_static_assets) - diff --git a/Rakefile b/Rakefile index cad0c21f8..186bbeb60 100644 --- a/Rakefile +++ b/Rakefile @@ -1,295 +1,56 @@ +# CAREFUL! Changes made to this file aren't tested +# +# If you need new functionality, consider putting it in lib/rake +# and also adding tests, then calling that code from here +# require "fileutils" require "tmpdir" -require 'hatchet/tasks' - -S3_BUCKET_NAME = "heroku-buildpack-ruby" -VENDOR_URL = "https://s3.amazonaws.com/#{S3_BUCKET_NAME}" - -def s3_tools_dir - File.expand_path("../support/s3", __FILE__) -end - -def s3_upload(tmpdir, name) - sh("#{s3_tools_dir}/s3 put #{S3_BUCKET_NAME} #{name}.tgz #{tmpdir}/#{name}.tgz") -end - -def vendor_plugin(git_url, branch = nil) - name = File.basename(git_url, File.extname(git_url)) - Dir.mktmpdir("#{name}-") do |tmpdir| - FileUtils.rm_rf("#{tmpdir}/*") - - Dir.chdir(tmpdir) do - sh "git clone #{git_url} ." - sh "git checkout origin/#{branch}" if branch - FileUtils.rm_rf("#{name}/.git") - sh("tar czvf #{tmpdir}/#{name}.tgz *") - s3_upload(tmpdir, name) - end - end -end - -def in_gem_env(gem_home, &block) - old_gem_home = ENV['GEM_HOME'] - old_gem_path = ENV['GEM_PATH'] - ENV['GEM_HOME'] = ENV['GEM_PATH'] = gem_home.to_s - - yield - - ENV['GEM_HOME'] = old_gem_home - ENV['GEM_PATH'] = old_gem_path -end - -def install_gem(gem, version) - name = "#{gem}-#{version}" - Dir.mktmpdir("#{gem}-#{version}") do |tmpdir| - Dir.chdir(tmpdir) do |dir| - FileUtils.rm_rf("#{tmpdir}/*") - - in_gem_env(tmpdir) do - sh("unset RUBYOPT; gem install #{gem} --version #{version} --no-ri --no-rdoc --env-shebang") - sh("rm #{gem}-#{version}.gem") - sh("rm -rf cache/#{gem}-#{version}.gem") - sh("tar czvf #{tmpdir}/#{name}.tgz *") - s3_upload(tmpdir, name) - end - end - end -end - -desc "update plugins" -task "plugins:update" do - vendor_plugin "http://github.com/heroku/rails_log_stdout.git", "legacy" - vendor_plugin "http://github.com/pedro/rails3_serve_static_assets.git" - vendor_plugin "http://github.com/hone/rails31_enable_runtime_asset_compilation.git" -end - -desc "install vendored gem" -task "gem:install", :gem, :version do |t, args| - gem = args[:gem] - version = args[:version] - - install_gem(gem, version) -end - -desc "generate ruby versions manifest" -task "ruby:manifest" do - require 'rexml/document' - require 'yaml' - - document = REXML::Document.new(`curl https://#{S3_BUCKET_NAME}.s3.amazonaws.com`) - rubies = document.elements.to_a("//Contents/Key").map {|node| node.text }.select {|text| text.match(/^(ruby|rbx|jruby)-\\\\d+\\\\.\\\\d+\\\\.\\\\d+(-p\\\\d+)?/) } - - Dir.mktmpdir("ruby_versions-") do |tmpdir| - name = 'ruby_versions.yml' - File.open(name, 'w') {|file| file.puts(rubies.to_yaml) } - sh("#{s3_tools_dir}/s3 put #{S3_BUCKET_NAME} #{name} #{name}") - end -end +require "hatchet/tasks" +require_relative "lib/rake/deploy_check" namespace :buildpack do - require 'netrc' - require 'excon' - require 'json' - require 'time' - require 'cgi' - require 'git' - require 'fileutils' - require 'digest/md5' - require 'securerandom' - - def connection - @connection ||= begin - user, password = Netrc.read["api.heroku.com"] - Excon.new("https://#{CGI.escape(user)}:#{password}@buildkits.herokuapp.com") - end + desc "prepares the next version of the buildpack for release" + task :prepare do + puts("Use https://github.com/heroku/heroku-buildpack-ruby/actions/workflows/prepare-release.yml") end - def latest_release - @latest_release ||= begin - buildpack_name = "heroku/ruby" - response = connection.get(path: "buildpacks/#{buildpack_name}/revisions") - releases = JSON.parse(response.body) - - # { - # "tar_link": "https://codon-buildpacks.s3.amazonaws.com/buildpacks/heroku/ruby-v84.tgz", - # "created_at": "2013-11-06T18:55:04Z", - # "published_by": "richard@heroku.com", - # "id": 84 - # } - releases.map! do |a| - a["created_at"] = Time.parse(a["created_at"]) - a - end.sort! { |a,b| b["created_at"] <=> a["created_at"] } - releases.first - end - end - - def new_version - @new_version ||= "v#{latest_release["id"] + 1}" - end - - def git - @git ||= Git.open(".") - end - - desc "increment buildpack version" - task :increment do - version_file = './lib/language_pack/version' - require './lib/language_pack' - require version_file - - if LanguagePack::Base::BUILDPACK_VERSION != new_version - stashes = nil - - if git.status.changed.any? - stashes = Git::Stashes.new(git) - stashes.save("WIP") - end - - File.open("#{version_file}.rb", 'w') do |file| - file.puts <<-FILE -require "language_pack/base" - -# This file is automatically generated by rake -module LanguagePack - class LanguagePack::Base - BUILDPACK_VERSION = "#{new_version}" - end -end -FILE + desc "releases the next version of the buildpack" + task :release do + puts "Checking login state" + sh("heroku whoami") do |out, status| + if status.success? + puts "Success" + else + raise "Ensure login works: `heroku login`" end - - git.add "#{version_file}.rb" - git.commit "bump to #{new_version}" - - stashes.pop if stashes - - puts "Bumped to #{new_version}" - else - puts "Already on #{new_version}" end - end - - def changelog_entry? - File.read("./CHANGELOG.md").split("\n").any? {|line| line.match(/^## #{new_version}/) } - end - - desc "check if there's a changelog for the new version" - task :changelog do - if changelog_entry? - puts "Changelog for #{new_version} exists" - else - puts "Please add a changelog entry for #{new_version}" - end - end - - def github_remote - @github_remote ||= git.remotes.detect {|remote| remote.url.match(%r{heroku/heroku-buildpack-ruby.git$}) } - - end - - def git_push_master - puts "Pushing master" - git.push(github_remote, 'master', false) - $?.success? - end - desc "update master branch" - task :git_push_master do - git_push_master - end - - desc "stage a tarball of the buildpack" - task :stage do - Dir.mktmpdir("heroku-buildpack-ruby") do |tmpdir| - Git.clone(File.expand_path("."), 'heroku-buildpack-ruby', path: tmpdir) - Dir.chdir(tmpdir) do |dir| - streamer = lambda do |chunk, remaining_bytes, total_bytes| - File.open("ruby.tgz", "w") {|file| file.print(chunk) } - end - Excon.get(latest_release["tar_link"], :response_block => streamer) - Dir.chdir("heroku-buildpack-ruby") do |dir| - sh "tar xzf ../ruby.tgz .env" - sh "tar czf ../buildpack.tgz * .env" - end - - @digest = Digest::MD5.hexdigest(File.read("buildpack.tgz")) + deploy = DeployCheck.new(github: "heroku/heroku-buildpack-ruby") + puts "Attempting to deploy #{deploy.next_version}, overwrite with RELEASE_VERSION env var" + deploy.check! + if deploy.push_tag? + sh("git tag -f #{deploy.next_version}") do |out, status| + raise "Could not `git tag -f #{deploy.next_version}`: #{out}" unless status.success? end - - filename = "buildpacks/#{@digest}.tgz" - puts "Writing to #{filename}" - FileUtils.mkdir_p("buildpacks/") - FileUtils.cp("#{tmpdir}/buildpack.tgz", filename) - FileUtils.cp("#{tmpdir}/buildpack.tgz", "buildpacks/buildpack.tgz") - end - end - - def multipart_form_data(buildpack_file_path) - body = '' - boundary = SecureRandom.hex(4) - data = File.open(buildpack_file_path) - - data.binmode if data.respond_to?(:binmode) - data.pos = 0 if data.respond_to?(:pos=) - - body << "--#{boundary}" << Excon::CR_NL - body << %{Content-Disposition: form-data; name="buildpack"; filename="#{File.basename(buildpack_file_path)}"} << Excon::CR_NL - body << 'Content-Type: application/x-gtar' << Excon::CR_NL - body << Excon::CR_NL - body << File.read(buildpack_file_path) - body << Excon::CR_NL - body << "--#{boundary}--" << Excon::CR_NL - - { - :headers => { 'Content-Type' => %{multipart/form-data; boundary="#{boundary}"} }, - :body => body - } - end - - desc "publish buildpack" - task :publish do - buildpack_name = "heroku/ruby" - puts "Publishing #{buildpack_name} buildpack" - resp = connection.post(multipart_form_data("buildpacks/buildpack.tgz").merge(path: "/buildpacks/#{buildpack_name}")) - puts resp.status - puts resp.body - end - - desc "tag a release" - task :tag do - tagged_version = - if @new_version.nil? - "v#{latest_release["id"]}" - else - new_version + sh("git push --tags") do |out, status| + raise "Could not `git push --tags`: #{out}" unless status.success? end + end - git.add_tag(tagged_version) - puts "Created tag #{tagged_version}" - - puts "Pushing tag to remote #{github_remote}" - git.push(github_remote, nil, true) - end - - desc "release a new version of the buildpack" - task :release do - Rake::Task["buildpack:increment"].invoke - raise "Please add a changelog entry for #{new_version}" unless changelog_entry? - raise "Can't push to master" unless git_push_master - Rake::Task["buildpack:stage"].invoke - Rake::Task["buildpack:publish"].invoke - Rake::Task["buildpack:tag"].invoke + command = "heroku buildpacks:publish heroku/ruby #{deploy.next_version}" + puts "Releasing to heroku: `#{command}`" + exec(command) end end begin - require 'rspec/core/rake_task' + require "rspec/core/rake_task" desc "Run specs" RSpec::Core::RakeTask.new(:spec) do |t| - t.rspec_opts = %w(-fs --color) - #t.ruby_opts = %w(-w) + t.rspec_opts = %w[-fd --color] + # t.ruby_opts = %w(-w) end - task :default => :spec -rescue LoadError => e + task default: :spec +rescue LoadError end diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..72541d6bd --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security + +Please report any security issue to [https://www.sfdc.co/SubmitVuln](https://www.sfdc.co/SubmitVuln) +as soon as it is discovered. This project limits its runtime dependencies in +order to reduce the total cost of ownership as much as can be, but all consumers +should remain vigilant and have their security stakeholders review all third-party +products (3PP) like this one and their dependencies. diff --git a/bin/compile b/bin/compile index c603d4e31..d27bd300c 100755 --- a/bin/compile +++ b/bin/compile @@ -1,18 +1,48 @@ -#!/usr/bin/env ruby - -# sync output -$stdout.sync = true - -$:.unshift File.expand_path("../../lib", __FILE__) -require "language_pack" -require "language_pack/shell_helpers" - -LanguagePack::Instrument.trace 'compile', 'app.compile' do - if pack = LanguagePack.detect(ARGV[0], ARGV[1]) - LanguagePack::ShellHelpers.initialize_env(ARGV[2]) - pack.topic("Compiling #{pack.name}") - pack.log("compile") do - pack.compile - end - end -end +#!/usr/bin/env bash +# The actual compilation code lives in `bin/support/ruby_compile`. This file instead +# bootstraps the ruby needed and then executes `bin/support/ruby_compile` + +set -euo pipefail + +BUILD_DIR=$1 +CACHE_DIR=$2 +ENV_DIR=$3 +BIN_DIR=$(cd "$(dirname "$0")" || exit; pwd) # absolute path + +# shellcheck source=bin/support/bash_functions.sh +source "$BIN_DIR/support/bash_functions.sh" + +# Initialize metrics for bin/report +build_data::init "${CACHE_DIR}" +build_data::clear + +checks::ensure_supported_stack "${STACK:?Required env var STACK is not set}" + +bootstrap_ruby_dir=$(mktemp -d) +if "$BIN_DIR"/support/download_ruby "$BIN_DIR" "$bootstrap_ruby_dir"; then + : +else + build_data::kv_string "failure_reason" "bootstrap_ruby_fail" + exit 1 +fi +trap 'rm -rf "$bootstrap_ruby_dir"' EXIT + +if detect_needs_java "$BUILD_DIR"; then + cat < Installing Java + +EOM + + build_data::kv_string "java_origin" "ruby_buildpack" + compile_buildpack_v2 "$BUILD_DIR" "$CACHE_DIR" "$ENV_DIR" "https://buildpack-registry.s3.dualstack.us-east-1.amazonaws.com/buildpacks/heroku/jvm.tgz" "heroku/jvm" +fi + +"$bootstrap_ruby_dir"/bin/ruby "$BIN_DIR/support/ruby_compile.rb" "$@" diff --git a/bin/detect b/bin/detect index b87aa5660..c9cb89e18 100755 --- a/bin/detect +++ b/bin/detect @@ -1,11 +1,43 @@ -#!/usr/bin/env ruby +#!/usr/bin/env bash -require 'pathname' +set -euo pipefail -if Pathname.new(ARGV.first).join("Gemfile").exist? - puts "Ruby" +APP_DIR=$1 +BIN_DIR=$(cd "$(dirname "$0")" || exit; pwd) # absolute path + +if [ -f "$APP_DIR/Gemfile" ]; then + echo "Ruby" exit 0 -else - puts "no" - exit 1 -end +fi + +# shellcheck source=bin/support/bash_functions.sh +source "$BIN_DIR/support/bash_functions.sh" + +output::error < + +# Produces a build report containing metadata about the build, that's consumed by the build system. +# This script is run for both successful and failing builds, so it should not assume the build ran +# to completion (e.g. Python or other tools may not even have been installed). +# +# Metrics are collected from the part of the buildpack written in Ruby and the +# part written in Bash (that is used to bootstrap Ruby). They both write to the +# same JSON file on disk. +# +# Metadata must be emitted to stdout as valid un-nested json (values must not be objects or arrays). +# +# Example valid stdout: +# { +# "ruby_version": "X.Y.Z", +# "ruby_install_duration": 1.234 +# } +# +# Failures in this script don't cause the overall build to fail (and won't appear in user +# facing build logs) to avoid breaking builds unnecessarily / causing confusion. To debug +# issues check the internal build system logs for `buildpack.report.failed` events, or +# when developing run `make run` in this repo locally, which runs `bin/report` too. + +set -euo pipefail + +CACHE_DIR="${2}" +# The absolute path to the root of the buildpack. +BIN_DIR=$(cd "$(dirname "$0")" || exit; pwd) # absolute path + +# Initialize metrics for bin/report +# shellcheck source=bin/support/bash_functions.sh +source "$BIN_DIR/support/bash_functions.sh" +build_data::init "${CACHE_DIR}" +build_data::print_bin_report_json diff --git a/bin/support/bash_functions.sh b/bin/support/bash_functions.sh new file mode 100755 index 000000000..79f2713a2 --- /dev/null +++ b/bin/support/bash_functions.sh @@ -0,0 +1,371 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ANSI_RED=$'\e[1;31m' +ANSI_RESET=$'\e[0m' + +# Output a styled multi-line error message to stderr. +# +# Usage: +# ``` +# output::error <<-EOF +# Error: The error summary. +# +# Detailed description. +# EOF +# ``` +function output::error() { + echo >&2 + sed -e "s/^/${ANSI_RED} ! /" -e "s/$/${ANSI_RESET}/" >&2 + echo >&2 +} + +curl_retry_on_18() { + local ec=18; + local attempts=0; + while (( ec == 18 && attempts++ < 3 )); do + curl "$@" # -C - would return code 33 if unsupported by server + ec=$? + done + return "$ec" +} + +which_java() +{ + command -v java > /dev/null +} + +# Detects if a given Gemfile.lock has jruby in it +# $ cat Gemfile.lock | grep jruby # => ruby 2.5.7p001 (jruby 9.2.13.0) +detect_needs_java() +{ + local app_dir=$1 + local gemfile_lock="$app_dir/Gemfile.lock" + # local needs_jruby=0 + local skip_java_install=1 + + if which_java; then + build_data::kv_string "java_origin" "previously_installed" + return "$skip_java_install" + fi + + grep "(jruby " "$gemfile_lock" --quiet &> /dev/null +} + +# Runs another buildpack against the build dir +# +# Example: +# +# compile_buildpack_v2 "$build_dir" "$cache_dir" "$env_dir" "https://buildpack-registry.s3.dualstack.us-east-1.amazonaws.com/buildpacks/heroku/nodejs.tgz" "heroku/nodejs" +# +compile_buildpack_v2() +{ + local build_dir=$1 + local cache_dir=$2 + local env_dir=$3 + local buildpack=$4 + local name=$5 + + local dir + local url + local branch + + dir=$(mktemp -t buildpackXXXXX) + rm -rf "$dir" + + url=${buildpack%#*} + branch=${buildpack#*#} + + if [ "$branch" == "$url" ]; then + branch="" + fi + + if [ "$url" != "" ]; then + echo "-----> Downloading Buildpack: ${name}" + + if [[ "$url" =~ \.tgz$ ]] || [[ "$url" =~ \.tgz\? ]]; then + mkdir -p "$dir" + if curl_retry_on_18 --no-progress-meter --fail --retry 3 --retry-connrefused --connect-timeout "${CURL_CONNECT_TIMEOUT:-3}" "$url" | tar xz -C "$dir"; then + : + else + echo "Failed to download $url" + build_data::kv_string "failure_reason" "compile_buildpack_v2_download_fail" + build_data::kv_string "failure_detail" "url: $url" + exit 1 + fi + else + if git clone --quiet "$url" "$dir"; then + : + else + echo "Failed to clone $url" + build_data::kv_string "failure_reason" "compile_buildpack_v2_download_fail" + build_data::kv_string "failure_detail" "url: $url" + exit 1 + fi + fi + cd "$dir" || return + + if [ "$branch" != "" ]; then + if git checkout "$branch" >/dev/null 2>&1; then + : + else + echo "Failed to checkout branch $branch" + build_data::kv_string "failure_reason" "compile_buildpack_v2_checkout_fail" + build_data::kv_string "failure_detail" "buildpack: $buildpack, branch: $branch" + exit 1 + fi + fi + + # we'll get errors later if these are needed and don't exist + chmod -f +x "$dir/bin/{detect,compile,release}" || true + + if framework=$("$dir"/bin/detect "$build_dir"); then + : + else + echo "Couldn't detect any framework for this buildpack. Exiting." + build_data::kv_string "failure_reason" "compile_buildpack_v2_detect_fail" + build_data::kv_string "failure_detail" "buildpack: $buildpack" + + exit 1 + fi + + echo "-----> Detected Framework: $framework" + if "$dir"/bin/compile "$build_dir" "$cache_dir" "$env_dir"; then + : + else + echo "Failed to compile with $buildpack" + build_data::kv_string "failure_reason" "compile_buildpack_v2_compile_fail" + build_data::kv_string "failure_detail" "buildpack: $buildpack" + exit 1 + fi + + # check if the buildpack left behind an environment for subsequent ones + if [ -e "$dir/export" ]; then + set +u # http://redsymbol.net/articles/unofficial-bash-strict-mode/#sourcing-nonconforming-document + # shellcheck disable=SC1091 + source "$dir/export" + set -u # http://redsymbol.net/articles/unofficial-bash-strict-mode/#sourcing-nonconforming-document + fi + + if [ -x "$dir/bin/release" ]; then + if "$dir"/bin/release "$build_dir" > "$1"/last_pack_release.out; then + : + else + echo "Failed bin/release with $buildpack" + build_data::kv_string "failure_reason" "compile_buildpack_v2_release_fail" + build_data::kv_string "failure_detail" "buildpack: $buildpack" + exit 1 + fi + fi + fi +} + +# After a stack is EOL, updates to the buildpack may fail with unexpected or unhelpful errors. +# This can happen when the buildpack is being used off of the platform such as with `dokku` +# which is not officially supported. +function checks::ensure_supported_stack() { + local stack="${1}" + + case "${stack}" in + # When removing support from a stack, move it to the bottom of the list + heroku-22 | heroku-24 | heroku-26) + return 0 + ;; + heroku-18 | heroku-20) + build_data::kv_string "failure_reason" "stack_eol" + build_data::kv_string "failure_detail" "${stack} stack" + # This error will only ever be seen on non-Heroku environments, since the + # Heroku build system rejects builds using EOL stacks. + cat <<-EOF + Error: The '${stack}' stack is no longer supported. + + This buildpack no longer supports the '${stack}' stack since it has + reached its end-of-life: + https://devcenter.heroku.com/articles/stack#stack-support-details + + Upgrade to a newer stack to continue using this buildpack. + EOF + exit 1 + ;; + *) + build_data::kv_string "failure_reason" "stack_unknown" + build_data::kv_string "failure_detail" "${stack} stack" + cat <<-EOF + Error: The '${stack}' stack isn't recognised. + + This buildpack doesn't recognise or support the '${stack}' stack. + + If '${stack}' is a valid stack, make sure that you are using the latest + version of this buildpack and haven't pinned to an older release: + https://devcenter.heroku.com/articles/managing-buildpacks#view-your-buildpacks + https://devcenter.heroku.com/articles/managing-buildpacks#classic-buildpacks-references + EOF + exit 1 + ;; + esac +} + +## ============================== +# Start of build_data section +## ============================== + +# Contains functions for storing build data from the buildpack in bash. +# +# The format of the report file is JSON. +# +# Example: +# { +# "ruby_version": "3.3.3", +# "ruby_install_duration": 1.234 +# } +# +# All keys get `ruby.` prepended to them in the backend automatically. + +# Variables shared by this whole module +BUILD_DATA_FILE="/dev/null" +# Exported for use by Ruby code +HEROKU_RUBY_BUILD_REPORT_FILE="/dev/null" + +# Must be called before you can use any other methods +# +# Usage: +# ``` +# build_data::init "${CACHE_DIR}" +# ``` +build_data::init() { + local cache_dir="${1}" + BUILD_DATA_FILE="${cache_dir}/build-data/ruby.json" + HEROKU_RUBY_BUILD_REPORT_FILE="${BUILD_DATA_FILE}" + + # Used later in the `HerokuBuildReport.set_global` call in `bin/support/ruby_compile` + export HEROKU_RUBY_BUILD_REPORT_FILE +} + +# Clears any prior build data since it persists between builds +# +# Usage: +# ``` +# build_data::init "${CACHE_DIR}" +# build_data::clear +# ``` +build_data::clear() { + mkdir -p "$(dirname "${BUILD_DATA_FILE}")" + echo "{}" >"${BUILD_DATA_FILE}" +} + +# Adds a key-value pair to the report file without any attempt to quote or escape the value. +# +# Usage: +# ``` +# build_data::kv_raw "ruby_version_major" "3" +# build_data::kv_raw "ruby_version_default" "true" +# ``` +build_data::kv_raw() { + local key="${1}" + local value="${2}" + build_report::_set "${key}" "${value}" "false" +} + +# Adds a key-value pair to the report file, quoting the value. +# +# Usage: +# ``` +# build_data::kv_string "ruby_version" "3.3.3" +# ``` +build_data::kv_string() { + local key="${1}" + local value="${2}" + build_report::_set "${key}" "${value}" "true" +} + +# Internal helper to write a key/value pair to the build data store. The buildpack shouldn't call this directly. +# Takes a key, value, and a boolean flag indicating whether the value needs to be quoted. +# +# Usage: +# ``` +# build_report::_set "foo_string" "quote me" "true" +# build_report::_set "bar_number" "99" "false" +# ``` +function build_report::_set() { + local key="${1}" + # Truncate the value to an arbitrary 200 characters since it will sometimes contain user-provided + # inputs which may be unbounded in size. Ideally individual call sites will perform more aggressive + # truncation themselves based on the expected value size, however this is here as a fallback. + # (Honeycomb supports string fields up to 64KB in size, however, it's not worth filling up the + # build data store or bloating the payload passed back to Vacuole/submitted to Honeycomb given the + # extra content in those cases is not normally useful.) + local value="${2:0:200}" + local needs_quoting="${3}" + + if [[ "${needs_quoting}" == "true" ]]; then + # Values passed using `--arg` are treated as strings, and so have double quotes added and any JSON + # special characters (such as newlines, carriage returns, double quotes, backslashes) are escaped. + local jq_args=(--arg value "${value}") + else + # Values passed using `--argjson` are treated as raw JSON values, and so aren't escaped or quoted. + local jq_args=(--argjson value "${value}") + fi + + local new_data_file_contents + new_data_file_contents="$(jq --arg key "${key}" "${jq_args[@]}" '. + { ($key): ($value) }' "${BUILD_DATA_FILE}")" + echo "${new_data_file_contents}" >"${BUILD_DATA_FILE}" +} + +# Returns the current time since the UNIX Epoch, as a float with microseconds precision. +# +# Usage (2025-08-22 11:15 UTC): +# ``` +# build_data::current_unix_realtime +# # => 1755879324.771610 +# ``` +build_data::current_unix_realtime() { + # We use a subshell with `LC_ALL=C` to ensure the output format isn't affected by system locale. + ( + LC_ALL=C + echo "${EPOCHREALTIME}" + ) +} + +# Adds a key=duration to the report file. +# +# Usage: +# ``` +# start_time=$(build_data::current_unix_realtime) +# sleep 1 +# build_data::kv_duration_since "ruby_install" "${start_time}" +# +# build_data::print_bin_report_json +# # => { "ruby_install": 1.234 } +# ``` +build_data::kv_duration_since() { + local key="${1}" + local start_time="${2}" + local end_time duration + end_time="$(build_data::current_unix_realtime)" + duration="$(awk -v start="${start_time}" -v end="${end_time}" 'BEGIN { printf "%f", (end - start) }')" + + build_data::kv_raw "${key}" "${duration}" +} + +# Prints the build data in JSON format. +# +# Usage: +# ``` +# build_data::print_bin_report_json +# # => { "ruby_install": 1.234 } +# ``` +build_data::print_bin_report_json() { + if [ "${HEROKU_RUBY_BUILD_REPORT_FILE}" != "${BUILD_DATA_FILE}" ]; then + echo "Error: HEROKU_RUBY_BUILD_REPORT_FILE does not match BUILD_DATA_FILE" + echo "HEROKU_RUBY_BUILD_REPORT_FILE: ${HEROKU_RUBY_BUILD_REPORT_FILE}" + echo "BUILD_DATA_FILE: ${BUILD_DATA_FILE}" + exit 1 + fi + + jq --sort-keys '.' "${BUILD_DATA_FILE}" +} + +## ============================== +# End of build data section +## ============================== diff --git a/bin/support/download_ruby b/bin/support/download_ruby new file mode 100755 index 000000000..96d8eb770 --- /dev/null +++ b/bin/support/download_ruby @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +# Downloads a bootstrap copy of Ruby for execution of the buildpack +# this is needed so we can totally control the Ruby version and are +# not dependant on the Ruby version of the stack image + +# fail hard +set -o pipefail +# fail harder +set -eu + +BIN_DIR=$1 +RUBY_BOOTSTRAP_DIR=$2 + +# Stack is set by codon, listed here so shellcheck knows about it +STACK=${STACK:-} + +curl_retry_on_18() { + local ec=18; + local attempts=0; + while (( ec == 18 && attempts++ < 3 )); do + curl "$@" # -C - would return code 33 if unsupported by server + ec=$? + done + return $ec +} + +ruby_url() { + local stack=$1 + local version=$2 + + if [ "$stack" == "heroku-22" ]; then + echo "${BUILDPACK_VENDOR_URL:-https://heroku-buildpack-ruby.s3.dualstack.us-east-1.amazonaws.com}/$stack/ruby-$version.tgz" + else + local arch + arch=$(dpkg --print-architecture) + echo "${BUILDPACK_VENDOR_URL:-https://heroku-buildpack-ruby.s3.dualstack.us-east-1.amazonaws.com}/$stack/$arch/ruby-$version.tgz" + fi +} + +# Pull ruby version out of buildpack.toml to be used with bootstrapping +regex=".*ruby_version = [\'\"]([0-9]+\.[0-9]+\.[0-9]+)[\'\"].*" +if [[ $(cat "$BIN_DIR/../buildpack.toml") =~ $regex ]] + then + heroku_buildpack_ruby_url=$(ruby_url "$STACK" "${BASH_REMATCH[1]}") + else + heroku_buildpack_ruby_url="" + echo "Could not detect ruby version to bootstrap" + exit 1 +fi + +mkdir -p "$RUBY_BOOTSTRAP_DIR" + +if curl_retry_on_18 --fail --retry 3 --retry-connrefused --connect-timeout "${CURL_CONNECT_TIMEOUT:-3}" --no-progress-meter --location --output "$RUBY_BOOTSTRAP_DIR/ruby.tgz" "$heroku_buildpack_ruby_url"; then + : +else +cat< e # standard:disable Lint/RescueException + LanguagePack::ShellHelpers.display_error_and_exit(e) +end diff --git a/bin/support/ruby_test-compile.rb b/bin/support/ruby_test-compile.rb new file mode 100755 index 000000000..c89d5e9ef --- /dev/null +++ b/bin/support/ruby_test-compile.rb @@ -0,0 +1,36 @@ +#!/usr/bin/env ruby + +# This script installs an app's Ruby version and dependencies. It's +# essentially the same thing as `ruby_compile` (which is called by bin/compile) +# except that some behavior is cusomtized by the inclusion of the +# `language_pack/test` file. One difference is dependencies +# the `bin/compile` installs dependencies via bundler and excludes `development:test` +# gems, however we need `test` gems to run tests, so instead only `development` +# is excluded. +# +# It also sets up the database (if one is present) and populates the database +# with the appropriate schema. +$stdout.sync = true + +$:.unshift File.expand_path("../../../lib", __FILE__) +require "language_pack" +require "language_pack/shell_helpers" +require "language_pack/test" + +begin + app_path = Pathname(ARGV[0]) + cache_path = Pathname(ARGV[1]) + gemfile_lock = LanguagePack.gemfile_lock(app_path: app_path) + Dir.chdir(app_path) + + LanguagePack::ShellHelpers.initialize_env(ARGV[2]) + LanguagePack.call( + app_path: app_path, + cache_path: cache_path, + gemfile_lock: gemfile_lock, + bundle_default_without: "development", + environment_name: "test" + ) +rescue Exception => e # standard:disable Lint/RescueException + LanguagePack::ShellHelpers.display_error_and_exit(e) +end diff --git a/bin/support/ruby_test.rb b/bin/support/ruby_test.rb new file mode 100755 index 000000000..a80eb6c8c --- /dev/null +++ b/bin/support/ruby_test.rb @@ -0,0 +1,55 @@ +#!/usr/bin/env ruby + +# This script will detect which test script or framework an app is likely to be +# using and run the appropriate command. It is expected that `ruby_test-compile` +# will be run before this file, this installs a version of Ruby and any dependencies +# a customer's app needs. +$stdout.sync = true + +$:.unshift File.expand_path("../../../lib", __FILE__) +require "language_pack" +require "language_pack/shell_helpers" +require "language_pack/test" +require "language_pack/ruby" + +include LanguagePack::ShellHelpers # standard:disable Style/MixinUsage + +def execute_test(command) + topic("Running test: #{command}") + execute_command(command) + exit $?.exitstatus +end + +def execute_command(command) + # Normally the `pipe` command will indent output so that it + # matches the build output, however in a test TAP depends on + # having no whitespace before output. To avoid adding whitespace + # for the original Kernel.puts to be used by passing in the + # Kernel object. + pipe(command, user_env: true, output_object: Kernel) +end + +# $ bin/test app_path ENV_DIR ARTIFACT_DIR +app_path, env_dir, _ = ARGV.map { |arg| Pathname(arg).expand_path } +LanguagePack::ShellHelpers.initialize_env(env_dir) +Dir.chdir(app_path) + +gems_list = LanguagePack::Helpers::BundleList::HumanCommand.new( + stream_to_user: false +).call + +execute_test( + if gems_list.has_gem?("rspec-core") + if File.exist?("bin/rspec") + "bin/rspec" + else + "bundle exec rspec" + end + elsif File.exist?("bin/rails") && gems_list.has_gem?("railties") && gems_list.gem_version("railties") >= Gem::Version.new("5.x") + "bin/rails test" + elsif File.exist?("bin/rake") + "bin/rake test" + else + "rake test" + end +) diff --git a/bin/test b/bin/test new file mode 100755 index 000000000..2c7f8091d --- /dev/null +++ b/bin/test @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# The actual `bin/test-compile` code lives in `bin/ruby_test-compile`. This file instead +# bootstraps the ruby needed and then executes `bin/ruby_test-compile` + +set -euo pipefail + +BIN_DIR=$(cd "$(dirname "$0")" || exit; pwd) # absolute path + +# shellcheck source=bin/support/bash_functions.sh +source "$BIN_DIR/support/bash_functions.sh" + +# The bin/report mechanism is not available in bin/test-compile or bin/test +# https://github.com/heroku/heroku-buildpack-ruby/pull/1635/files#r2293558218 +# +# Do not initialize 'build_data::init' here. Without initialization, +# the build_data calls will write to `/dev/null`, preventing errors. +# Do not add additional 'build_data::' calls to this file. + +bootstrap_ruby_dir=$(mktemp -d) +"$BIN_DIR"/support/download_ruby "$BIN_DIR" "$bootstrap_ruby_dir" +trap 'rm -rf "$bootstrap_ruby_dir"' EXIT + +"$bootstrap_ruby_dir"/bin/ruby "$BIN_DIR/support/ruby_test.rb" "$@" diff --git a/bin/test-compile b/bin/test-compile new file mode 100755 index 000000000..a6ff5edec --- /dev/null +++ b/bin/test-compile @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# The actual `bin/test-compile` code lives in `bin/ruby_test-compile`. This file instead +# bootstraps the ruby needed and then executes `bin/ruby_test-compile` + +set -euo pipefail + +BUILD_DIR=$1 +CACHE_DIR=$2 +ENV_DIR=$3 +BIN_DIR=$(cd "$(dirname "$0")" || exit; pwd) # absolute path + +# shellcheck source=bin/support/bash_functions.sh +source "$BIN_DIR/support/bash_functions.sh" + +# The bin/report mechanism is not available in bin/test-compile or bin/test +# https://github.com/heroku/heroku-buildpack-ruby/pull/1635/files#r2293558218 +# +# Do not initialize 'build_data::init' here. Without initialization, +# the build_data calls will write to `/dev/null`, preventing errors. +# Do not add additional 'build_data::' calls to this file. + +bootstrap_ruby_dir=$(mktemp -d) +"$BIN_DIR"/support/download_ruby "$BIN_DIR" "$bootstrap_ruby_dir" +trap 'rm -rf "$bootstrap_ruby_dir"' EXIT + +if detect_needs_java "$BUILD_DIR"; then + cat < Installing Java + +EOM + + compile_buildpack_v2 "$BUILD_DIR" "$CACHE_DIR" "$ENV_DIR" "https://buildpack-registry.s3.dualstack.us-east-1.amazonaws.com/buildpacks/heroku/jvm.tgz" "heroku/jvm" +fi + +"$bootstrap_ruby_dir"/bin/ruby "$BIN_DIR/support/ruby_test-compile.rb" "$@" diff --git a/buildpack.toml b/buildpack.toml new file mode 100644 index 000000000..ce9393270 --- /dev/null +++ b/buildpack.toml @@ -0,0 +1,15 @@ +[buildpack] +name = "Ruby" +ruby_version = "3.3.9" + +[publish.Ignore] +files = [ + "changelogs/", + "spec/", + "Gemfile", + "Gemfile.lock", + "Rakefile", + "app.json", + "hatchet.json", + "hatchet.lock", +] diff --git a/changelogs/unreleased/.gitkeep b/changelogs/unreleased/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/changelogs/v204/clean_binstubs.md b/changelogs/v204/clean_binstubs.md new file mode 100644 index 000000000..6c7c3c9cf --- /dev/null +++ b/changelogs/v204/clean_binstubs.md @@ -0,0 +1,5 @@ +## Ruby buildpack now cleans unused binstubs before install + +After a Ruby application's dependencies have been installed via `bundle install` the old dependencies are cleaned up by running `bundle clean`. Recently it was discovered that this does not clean up unused binstubs. To correct this problem the Ruby buildpack now manually cleans up generated binstubs in `vendor/bundler/bin`. Then when `bundle install` is executed, only currently used binstubs will be generated. + +[Buildpack PR](https://github.com/heroku/heroku-buildpack-ruby/pull/914) and [Heroku Ruby support](https://devcenter.heroku.com/articles/ruby-support). \ No newline at end of file diff --git a/changelogs/v204/default_ruby.md b/changelogs/v204/default_ruby.md new file mode 100644 index 000000000..ac67b0c70 --- /dev/null +++ b/changelogs/v204/default_ruby.md @@ -0,0 +1,3 @@ +## Default Ruby version for new apps is now 2.5.6 + +The [default Ruby version for new Ruby applications is 2.5.6](https://devcenter.heroku.com/articles/ruby-support#default-ruby-version-for-new-apps). You’ll only get the default if the application does not specify a ruby version. \ No newline at end of file diff --git a/changelogs/v265/ruby-323.md b/changelogs/v265/ruby-323.md new file mode 100644 index 000000000..dda99e999 --- /dev/null +++ b/changelogs/v265/ruby-323.md @@ -0,0 +1,10 @@ +## Ruby version 3.2.3 is now available + +[Ruby v3.2.3](/articles/ruby-support#ruby-versions) is now available on Heroku. To run +your app using this version of Ruby, add the following `ruby` directive to your Gemfile: + +```ruby +ruby "3.2.3" +``` + +For more information on [Ruby 3.2.3, you can view the release announcement](https://www.ruby-lang.org/en/news/). diff --git a/changelogs/v267/bundler_major_minor.md b/changelogs/v267/bundler_major_minor.md new file mode 100644 index 000000000..614a9c068 --- /dev/null +++ b/changelogs/v267/bundler_major_minor.md @@ -0,0 +1,18 @@ +## Bundler versions 2.4.22 and 2.5.6 are now available for Ruby Applications + +The [Ruby Buildpack](https://devcenter.heroku.com/articles/ruby-support#libraries) now installs a version of bundler based on the major and minor version listed in the `Gemfile.lock` under the `BUNDLED WITH` key. Previously, it only used the major version. Now, this logic will be used: + +- `BUNDLED WITH` 1.x will receive bundler `1.17.3` +- `BUNDLED WITH` 2.0.x to 2.3.x will receive bundler `2.3.25` +- `BUNDLED WITH` 2.4.x will receive bundler `2.4.22` +- `BUNDLED WITH` 2.5.x and above will receive bundler `2.5.6` + +It is strongly recommended that you have both a `RUBY VERSION` and `BUNDLED WITH` version listed in your `Gemfile.lock`. If you do not have those values, you can generate them and commit them to git: + +``` +$ bundle update --ruby +$ git add Gemfile.lock +$ git commit -m "Update Gemfile.lock" +``` + +Applications without these values specified in the `Gemfile.lock` may break unexpectedly when the defaults change. diff --git a/changelogs/v267/ruby_gemfile_lock.md b/changelogs/v267/ruby_gemfile_lock.md new file mode 100644 index 000000000..1d9e44101 --- /dev/null +++ b/changelogs/v267/ruby_gemfile_lock.md @@ -0,0 +1,15 @@ +## Ruby applications without a `RUBY VERSION` in the Gemfile.lock may receive a default Ruby version + +Previously, it was possible to specify a full version of Ruby in the `Gemfile` even if it was not present in the `Gemfile.lock`. The Ruby directive in the `Gemfile` was parsed by bundler and emitted via the command `bundle --platform ruby`. This behavior has changed with bundler `2.4+`, so only ruby versions listed in the `RUBY VERSION` key of the `Gemfile.lock` will be returned. If your application uses bundler 2.4+ and does not have a `RUBY VERSION` specified in the `Gemfile.lock`, it will receive a default version of Ruby. + +It is strongly recommended that you have both a `RUBY VERSION` and `BUNDLED WITH` version listed in your `Gemfile.lock`. If you do not have those values, you can generate them and commit them to git: + +``` +$ bundle update --ruby +$ git add Gemfile.lock +$ git commit -m "Update Gemfile.lock" +``` + +Applications without these values specified in the `Gemfile.lock` may break unexpectedly when the defaults change. + +If your app relies on specifying the ruby version in the `Gemfile` but not the `Gemfile.lock` and it is not yet using Bundler 2.4+, you may preserve this behavior by not upgrading the bundler version in your `Gemfile.lock`, however, this behavior is deprecated. It will be removed at a future date. It is recommended you lock your Ruby version now to avoid an unexpected breakage in the future. diff --git a/changelogs/v270/ruby_versions_307_315_324_331.md b/changelogs/v270/ruby_versions_307_315_324_331.md new file mode 100644 index 000000000..dfe2375c4 --- /dev/null +++ b/changelogs/v270/ruby_versions_307_315_324_331.md @@ -0,0 +1,13 @@ +# Ruby versions 3.0.7, 3.1.5, 3.2.4, 3.3.1 are now available + +The following Ruby versions are now available on the Heroku platform: + +- Ruby 3.0.7 +- Ruby 3.1.5 +- Ruby 3.2.4 +- Ruby 3.3.1 + +The latest versions are on the [Ruby support page](https://devcenter.heroku.com/articles/ruby-support). + +> note +> [Ruby 3.0.x is now EOL](https://www.ruby-lang.org/en/news/2024/04/23/ruby-3-0-7-released/) and therefore is no longer within Heroku's support policy. Heroku strongly recommends upgrading to Ruby 3.1.x or later. diff --git a/changelogs/v272/default_ruby.md b/changelogs/v272/default_ruby.md new file mode 100644 index 000000000..a06f083bb --- /dev/null +++ b/changelogs/v272/default_ruby.md @@ -0,0 +1,3 @@ +## Default Ruby version for new apps is now 3.1.6 + +The [default Ruby version for new Ruby applications is 3.1.6](https://devcenter.heroku.com/articles/ruby-support#default-ruby-version-for-new-apps). You’ll only get the default if the application does not specify a ruby version. diff --git a/changelogs/v273/windows_gemfile.md b/changelogs/v273/windows_gemfile.md new file mode 100644 index 000000000..19920e449 --- /dev/null +++ b/changelogs/v273/windows_gemfile.md @@ -0,0 +1,73 @@ +## Native Gemfile.lock support for Windows Ruby users with Bundler 2.2+ + +Ruby applications that use Windows and Bundler 2.2+ will no longer have their `Gemfile.lock` deleted and re-generated on deployment. Instead, they will rely on Bundler 2.2+'s support for multiple platforms to correctly resolve dependencies. We recommend any Windows Ruby user to upgrade their Bundler version in the `Gemfile.lock` to Bundler 2.2+ to take advantage of this behavior: + +> note +> The leading `>` indicates a command prompt and should not be copied. + +``` +> gem install bundler +> bundle update --bundler +> bundle lock --add-platform ruby +> bundle lock --add-platform x86_64-linux +> bundle install +> git add Gemfile.lock +> git commit -m "Upgrade bundler" +``` + +This change is reflected in the [Deploying a Ruby Project Generated on Windows](https://devcenter.heroku.com/articles/bundler-windows-gemfile) Dev Center article. More information on the history of the behavior can be found [on this GitHub issue](https://github.com/heroku/heroku-buildpack-ruby/issues/1157). + + diff --git a/changelogs/v274/jruby_93150.md b/changelogs/v274/jruby_93150.md new file mode 100644 index 000000000..cdc0821f1 --- /dev/null +++ b/changelogs/v274/jruby_93150.md @@ -0,0 +1,18 @@ +## JRuby version 9.3.15.0 is now available + +[JRuby v9.3.15.0](/articles/ruby-support#ruby-versions) is now available on Heroku. To run +your app using this version of Ruby, add the following `ruby` directive to your Gemfile: + +```ruby +ruby "2.6.8", engine: "jruby", engine_version: "9.3.15.0" +``` + +The JRuby release notes can be found on the [JRuby website](https://www.jruby.org/news). + + diff --git a/changelogs/v275/jruby-9.4.8.0.md b/changelogs/v275/jruby-9.4.8.0.md new file mode 100644 index 000000000..53d205557 --- /dev/null +++ b/changelogs/v275/jruby-9.4.8.0.md @@ -0,0 +1,10 @@ +## JRuby version 9.4.8.0 is now available + +[JRuby v9.4.8.0](/articles/ruby-support#ruby-versions) is now available on Heroku. To run +your app using this version of Ruby, add the following `ruby` directive to your Gemfile: + +```ruby +ruby "3.1.4", engine: "jruby", engine_version: "9.4.8.0" +``` + +The JRuby release notes can be found on the [JRuby website](https://www.jruby.org/news). diff --git a/changelogs/v276/ruby_3.3.4.md b/changelogs/v276/ruby_3.3.4.md new file mode 100644 index 000000000..4a35190b5 --- /dev/null +++ b/changelogs/v276/ruby_3.3.4.md @@ -0,0 +1,10 @@ +## Ruby version 3.3.4 is now available + +[Ruby v3.3.4](/articles/ruby-support#ruby-versions) is now available on Heroku. To run +your app using this version of Ruby, add the following `ruby` directive to your Gemfile: + +```ruby +ruby "3.3.4" +``` + +For more information on [Ruby 3.3.4, you can view the release announcement](https://www.ruby-lang.org/en/news/). diff --git a/changelogs/v278/ruby_3.2.5.md b/changelogs/v278/ruby_3.2.5.md new file mode 100644 index 000000000..e1a140f81 --- /dev/null +++ b/changelogs/v278/ruby_3.2.5.md @@ -0,0 +1,10 @@ +## Ruby version 3.2.5 is now available + +[Ruby v3.2.5](/articles/ruby-support#ruby-versions) is now available on Heroku. To run +your app using this version of Ruby, add the following `ruby` directive to your Gemfile: + +```ruby +ruby "3.2.5" +``` + +For more information on [Ruby 3.2.5, you can view the release announcement](https://www.ruby-lang.org/en/news/). diff --git a/changelogs/v279/ruby-3.3.5.md b/changelogs/v279/ruby-3.3.5.md new file mode 100644 index 000000000..abce00f2d --- /dev/null +++ b/changelogs/v279/ruby-3.3.5.md @@ -0,0 +1,10 @@ +## Ruby version 3.3.5 is now available + +[Ruby v3.3.5](/articles/ruby-support#ruby-versions) is now available on Heroku. To run +your app using this version of Ruby, add the following `ruby` directive to your Gemfile: + +```ruby +ruby "3.3.5" +``` + +For more information on [Ruby 3.3.5, you can view the release announcement](https://www.ruby-lang.org/en/news/). diff --git a/changelogs/v280/ruby-340-preview2.md b/changelogs/v280/ruby-340-preview2.md new file mode 100644 index 000000000..353e9ad00 --- /dev/null +++ b/changelogs/v280/ruby-340-preview2.md @@ -0,0 +1,16 @@ +## Ruby version 3.4.0-preview2 is now available + +[Ruby v3.4.0-preview2](/articles/ruby-support#ruby-versions) is now available on Heroku. To run +your app using this version of Ruby, add the following `ruby` directive to your Gemfile: + +```ruby +ruby "3.4.0.preview2" +``` + +For more information on [Ruby 3.4.0-preview2, you can view the release announcement](https://www.ruby-lang.org/en/news/). + +> Note +> This version of Ruby is not suitable for production applications. +> However, it can be used to test that your application is ready for +> the official release of Ruby 3.4.0 and +> to provide feedback to the Ruby core team. diff --git a/changelogs/v281/ruby-336.md b/changelogs/v281/ruby-336.md new file mode 100644 index 000000000..9af79b538 --- /dev/null +++ b/changelogs/v281/ruby-336.md @@ -0,0 +1,10 @@ +## Ruby version 3.3.6 is now available + +[Ruby v3.3.6](/articles/ruby-support#ruby-versions) is now available on Heroku. To run +your app using this version of Ruby, add the following `ruby` directive to your Gemfile: + +```ruby +ruby "3.3.6" +``` + +For more information on [Ruby 3.3.6, you can view the release announcement](https://www.ruby-lang.org/en/news/). diff --git a/changelogs/v284/node-yarn.md b/changelogs/v284/node-yarn.md new file mode 100644 index 000000000..b469a66de --- /dev/null +++ b/changelogs/v284/node-yarn.md @@ -0,0 +1,8 @@ +## Ruby apps now default to Node version 22.11.0 and Yarn version 1.22.22 + +Applications using the `heroku/ruby` buildpack that do not have a version of Node installed by another buildpack (such as the `heroku/nodejs` buildpack) will now receive: + +- Node version 22.11.0 +- Yarn version 1.22.22 + +These versions and instructions on how to specify a specific version of these binaries can be found on the [installed binaries section of the Heroku Ruby Support page](https://devcenter.heroku.com/articles/ruby-support#installed-binaries). diff --git a/changelogs/v286/3.4.0-rc1.md b/changelogs/v286/3.4.0-rc1.md new file mode 100644 index 000000000..c26eed3c8 --- /dev/null +++ b/changelogs/v286/3.4.0-rc1.md @@ -0,0 +1,15 @@ +## Ruby version 3.4.0-rc1 is now available + +[Ruby v3.4.0-rc1](/articles/ruby-support-reference#ruby-versions) is now available on Heroku. To run your app using this version of Ruby, add the following `ruby` directive to your Gemfile: + +```ruby +ruby "3.4.0.rc1" +``` + +For more information on [Ruby 3.4.0-rc1, you can view the release announcement](https://www.ruby-lang.org/en/news/). + +> Note +> This version of Ruby is not suitable for production applications. +> However, it can be used to test that your application is ready for +> the official release of Ruby 3.4.0 and +> to provide feedback to the Ruby core team. diff --git a/changelogs/v287/ruby_3.4.0.md b/changelogs/v287/ruby_3.4.0.md new file mode 100644 index 000000000..b63ee6503 --- /dev/null +++ b/changelogs/v287/ruby_3.4.0.md @@ -0,0 +1,9 @@ +## Ruby version 3.4.0 is now available + +[Ruby v3.4.0](/articles/ruby-support-reference#ruby-versions) is now available on Heroku. To run your app using this version of Ruby, add the following `ruby` directive to your Gemfile: + +```ruby +ruby "3.4.0" +``` + +For more information on [Ruby 3.4.0, you can view the release announcement](https://www.ruby-lang.org/en/news/). diff --git a/changelogs/v288/bundler_2.5.23.md b/changelogs/v288/bundler_2.5.23.md new file mode 100644 index 000000000..9267483f4 --- /dev/null +++ b/changelogs/v288/bundler_2.5.23.md @@ -0,0 +1,15 @@ +## Bundler version 2.5.23 is now available for Ruby Applications + +The [Ruby Buildpack](https://devcenter.heroku.com/articles/ruby-support#libraries) installs a version of bundler based on the major and minor version listed in the `Gemfile.lock` under the `BUNDLED WITH` key: + +- `BUNDLED WITH` 2.5.x will receive bundler `2.5.23` + +It is strongly recommended that you have both a `RUBY VERSION` and `BUNDLED WITH` version listed in your `Gemfile.lock`. If you do not have those values, you can generate them and commit them to git: + +``` +$ bundle update --ruby +$ git add Gemfile.lock +$ git commit -m "Update Gemfile.lock" +``` + +Applications without these values specified in the `Gemfile.lock` may break unexpectedly when the defaults change. diff --git a/changelogs/v288/bundler_2.6.x_support.md b/changelogs/v288/bundler_2.6.x_support.md new file mode 100644 index 000000000..e9d131563 --- /dev/null +++ b/changelogs/v288/bundler_2.6.x_support.md @@ -0,0 +1,15 @@ +## Bundler version 2.6.2 is now available for Ruby Applications + +The [Ruby Buildpack](https://devcenter.heroku.com/articles/ruby-support#libraries) installs a version of bundler based on the major and minor version listed in the `Gemfile.lock` under the `BUNDLED WITH` key: + +- `BUNDLED WITH` 2.6.x and above will receive bundler `2.6.2` + +It is strongly recommended that you have both a `RUBY VERSION` and `BUNDLED WITH` version listed in your `Gemfile.lock`. If you do not have those values, you can generate them and commit them to git: + +``` +$ bundle update --ruby +$ git add Gemfile.lock +$ git commit -m "Update Gemfile.lock" +``` + +Applications without these values specified in the `Gemfile.lock` may break unexpectedly when the defaults change. diff --git a/changelogs/v289/ruby-3-3-7.md b/changelogs/v289/ruby-3-3-7.md new file mode 100644 index 000000000..75a40f185 --- /dev/null +++ b/changelogs/v289/ruby-3-3-7.md @@ -0,0 +1,9 @@ +## Ruby version 3.3.7 is now available + +[Ruby v3.3.7](/articles/ruby-support#ruby-versions) is now available on Heroku. To run your app using this version of Ruby, add the following `ruby` directive to your Gemfile: + +```ruby +ruby "3.3.7" +``` + +For more information on [Ruby 3.3.7, you can view the release announcement](https://www.ruby-lang.org/en/news/). diff --git a/changelogs/v290/jruby-9.4.10.0.md b/changelogs/v290/jruby-9.4.10.0.md new file mode 100644 index 000000000..2cfa8465b --- /dev/null +++ b/changelogs/v290/jruby-9.4.10.0.md @@ -0,0 +1,10 @@ +## JRuby version 9.4.10.0 is now available + +[JRuby v9.4.10.0](/articles/ruby-support-reference#ruby-versions) is now available on Heroku. To run +your app using this version of Ruby, add the following `ruby` directive to your Gemfile: + +```ruby +ruby "3.1.4", engine: "jruby", engine_version: "9.4.10.0" +``` + +The JRuby release notes can be found on the [JRuby website](https://www.jruby.org/news). diff --git a/changelogs/v291/jruby-9.4.11.0.md b/changelogs/v291/jruby-9.4.11.0.md new file mode 100644 index 000000000..a4bc9607c --- /dev/null +++ b/changelogs/v291/jruby-9.4.11.0.md @@ -0,0 +1,10 @@ +## JRuby version 9.4.11.0 is now available + +[JRuby v9.4.11.0](/articles/ruby-support-reference#ruby-versions) is now available on Heroku. To run +your app using this version of Ruby, add the following `ruby` directive to your Gemfile: + +```ruby +ruby "3.1.4", engine: "jruby", engine_version: "9.4.11.0" +``` + +The JRuby release notes can be found on the [JRuby website](https://www.jruby.org/news). diff --git a/changelogs/v292/ruby-3.2.7.md b/changelogs/v292/ruby-3.2.7.md new file mode 100644 index 000000000..1219f91be --- /dev/null +++ b/changelogs/v292/ruby-3.2.7.md @@ -0,0 +1,9 @@ +## Ruby version 3.2.7 is now available + +[Ruby v3.2.7](/articles/ruby-support#ruby-versions) is now available on Heroku. To run your app using this version of Ruby, add the following `ruby` directive to your Gemfile: + +```ruby +ruby "3.2.7" +``` + +For more information on [Ruby 3.2.7, you can view the release announcement](https://www.ruby-lang.org/en/news/). diff --git a/changelogs/v293/ruby-3.4.2.md b/changelogs/v293/ruby-3.4.2.md new file mode 100644 index 000000000..6527e6497 --- /dev/null +++ b/changelogs/v293/ruby-3.4.2.md @@ -0,0 +1,9 @@ +## Ruby version 3.4.2 is now available + +[Ruby v3.4.2](/articles/ruby-support#ruby-versions) is now available on Heroku. To run your app using this version of Ruby, add the following `ruby` directive to your Gemfile: + +```ruby +ruby "3.4.2" +``` + +For more information on [Ruby 3.4.2, you can view the release announcement](https://www.ruby-lang.org/en/news/). diff --git a/changelogs/v294/bundler_default.md b/changelogs/v294/bundler_default.md new file mode 100644 index 000000000..03f692719 --- /dev/null +++ b/changelogs/v294/bundler_default.md @@ -0,0 +1,13 @@ +## Ruby applications with no specified bundler versions now receive Bundler 2.x + +Previously applications with no `BUNDLED WITH` in their `Gemfile.lock` would receive bundler `1.x`. They will now receive the new [default bundler version](https://devcenter.heroku.com/articles/ruby-support-reference#default-bundler-version) `2.3.x`. + +It is strongly recommended that you have both a `RUBY VERSION` and `BUNDLED WITH` version listed in your `Gemfile.lock`. If you do not have those values, you can generate them and commit them to git: + +``` +$ bundle update --ruby +$ git add Gemfile.lock +$ git commit -m "Update Gemfile.lock" +``` + +Applications without these values specified in the `Gemfile.lock` may break unexpectedly when the defaults change. diff --git a/changelogs/v294/default_ruby.md b/changelogs/v294/default_ruby.md new file mode 100644 index 000000000..9aaf2a5fc --- /dev/null +++ b/changelogs/v294/default_ruby.md @@ -0,0 +1,3 @@ +## Default Ruby version for new apps is now 3.3.7 + +The [default Ruby version for new Ruby applications is 3.3.7](https://devcenter.heroku.com/articles/ruby-support#default-ruby-version-for-new-apps). You’ll only get the default if the application does not specify a ruby version. diff --git a/changelogs/v309/default_ruby.md b/changelogs/v309/default_ruby.md new file mode 100644 index 000000000..92d78578b --- /dev/null +++ b/changelogs/v309/default_ruby.md @@ -0,0 +1,22 @@ +## Default Ruby version for new apps is now 3.3.8 + +The [default Ruby version for new Ruby applications is 3.3.8](https://devcenter.heroku.com/articles/ruby-support#default-ruby-version-for-new-apps). You’ll only get the default if the application does not specify a Ruby version. + +Heroku highly recommends specifying your desired Ruby version. You can specify a Ruby version in your `Gemfile`: + +```ruby +ruby "3.3.9" +``` + +Once you have a Ruby version specified in your `Gemfile`, update the `Gemfile.lock` by running the following command: + +```term +$ bundle update --ruby +``` + +Make sure you commit the results to git before attempting to deploy again: + +```term +$ git add Gemfile Gemfile.lock +$ git commit -m "update ruby version" +``` diff --git a/changelogs/v316/default_v339.md b/changelogs/v316/default_v339.md new file mode 100644 index 000000000..00e77c025 --- /dev/null +++ b/changelogs/v316/default_v339.md @@ -0,0 +1,22 @@ +## Default Ruby version for new apps is now 3.3.9 + +The [default Ruby version for new Ruby applications is 3.3.9](https://devcenter.heroku.com/articles/ruby-support#default-ruby-version-for-new-apps). You’ll only get the default if the application does not specify a Ruby version. + +Heroku highly recommends specifying your desired Ruby version. You can specify a Ruby version in your `Gemfile`: + +```ruby +ruby "3.3.9" +``` + +Once you have a Ruby version specified in your `Gemfile`, update the `Gemfile.lock` by running the following command: + +```term +$ bundle update --ruby +``` + +Make sure you commit the results to git before attempting to deploy again: + +```term +$ git add Gemfile Gemfile.lock +$ git commit -m "update ruby version" +``` diff --git a/changelogs/v320/puma_persistent_timeout.md b/changelogs/v320/puma_persistent_timeout.md new file mode 100644 index 000000000..dc6e6880c --- /dev/null +++ b/changelogs/v320/puma_persistent_timeout.md @@ -0,0 +1,16 @@ +## Ruby apps now have `PUMA_PERSISTENT_TIMEOUT=95` set by default + +All Ruby applications now have `PUMA_PERSISTENT_TIMEOUT` environment variable set to a default value of `95`. + +Puma [7.0](https://github.com/puma/puma/pull/3378) introduced the ability to configure the `persistent_timeout` value via an environment variable. Router 2.0 uses an idle timeout value of 90s https://devcenter.heroku.com/articles/http-routing#keepalives. To avoid a situation where a request is sent right before Puma closes the connection, the value needs to be slightly higher than the Router's value. + +Applications that are not on Puma 7+ can use it manually in their `config/puma.rb` file: + +```ruby +# config/puma.rb + +# Only required for Puma 6 and below +persistent_timeout(ENV.fetch("PUMA_PERSISTENT_TIMEOUT") || 95) +``` + +Other web server users can use this as a stable interface to retrieve a suggested idle timeout setting. diff --git a/changelogs/v322/persistent-timeout-rails.md b/changelogs/v322/persistent-timeout-rails.md new file mode 100644 index 000000000..48edf1934 --- /dev/null +++ b/changelogs/v322/persistent-timeout-rails.md @@ -0,0 +1,16 @@ +## Rails apps now have `PUMA_PERSISTENT_TIMEOUT=95` set by default + +Previously, this setting was only applied to [Rack applications](https://devcenter.heroku.com/changelog-items/3391). With this change, all Ruby applications now have the `PUMA_PERSISTENT_TIMEOUT` environment variable set to a default value of `95`. + +Puma [7.0.3+](https://github.com/puma/puma/pull/3378) introduced the ability to configure the `persistent_timeout` value via an environment variable. Router 2.0 uses an idle timeout value of 90s https://devcenter.heroku.com/articles/http-routing#keepalives. To avoid a situation where a request is sent right before Puma closes the connection, the value needs to be slightly higher than the Router's value. + +Applications that are not on Puma 7.0.3+ can use it manually in their `config/puma.rb` file: + +```ruby +# config/puma.rb + +# Only required for Puma 6 and below +persistent_timeout(ENV.fetch("PUMA_PERSISTENT_TIMEOUT") { 95 }.to_i) +``` + +Other web server users can use this as a stable interface to retrieve a suggested idle timeout setting. diff --git a/changelogs/v326/bundler-272-269.md b/changelogs/v326/bundler-272-269.md new file mode 100644 index 000000000..2634de7ee --- /dev/null +++ b/changelogs/v326/bundler-272-269.md @@ -0,0 +1,16 @@ +## Bundler version 2.6.9 and 2.7.2 are now available for Ruby Applications + +The [Ruby Buildpack](https://devcenter.heroku.com/articles/ruby-support#libraries) installs a version of bundler based on the major and minor version listed in the `Gemfile.lock` under the `BUNDLED WITH` key: + +- `BUNDLED WITH` 2.6.x will receive bundler `2.6.9` +- `BUNDLED WITH` 2.7.x will receive bundler `2.7.2` + +It is strongly recommended that you have both a `RUBY VERSION` and `BUNDLED WITH` version listed in your `Gemfile.lock`. If you do not have those values, you can generate them and commit them to git: + +``` +$ bundle update --ruby +$ git add Gemfile.lock +$ git commit -m "Update Gemfile.lock" +``` + +Applications without these values specified in the `Gemfile.lock` may break unexpectedly when the defaults change. diff --git a/changelogs/v330/bundler-two-space.md b/changelogs/v330/bundler-two-space.md new file mode 100644 index 000000000..9c8b0477f --- /dev/null +++ b/changelogs/v330/bundler-two-space.md @@ -0,0 +1,3 @@ +## Support two space `BUNDLED WITH` indented `Gemfile.lock` + +The `Gemfile.lock` file generated with the latest version of Bundler [now uses two spaces instead of three](https://github.com/ruby/rubygems/pull/9076). The `heroku/ruby` buildpack now supports this format. diff --git a/changelogs/v331/bundler-4.0.0.beta2.md b/changelogs/v331/bundler-4.0.0.beta2.md new file mode 100644 index 000000000..163e01ce5 --- /dev/null +++ b/changelogs/v331/bundler-4.0.0.beta2.md @@ -0,0 +1,20 @@ +## Bundler version 4.0.0.beta2 is now available for Ruby Applications + +The [Ruby Buildpack](https://devcenter.heroku.com/articles/ruby-support#libraries) installs a version of bundler based on the major and minor version listed in the `Gemfile.lock` under the `BUNDLED WITH` key: + +- `BUNDLED WITH` 4.0.x will receive bundler `4.0.0.beta2` + +It is strongly recommended that you have both a `RUBY VERSION` and `BUNDLED WITH` version listed in your `Gemfile.lock`. If you do not have those values, you can generate them and commit them to git: + +``` +$ bundle update --ruby +$ git add Gemfile.lock +$ git commit -m "Update Gemfile.lock" +``` + +Applications without these values specified in the `Gemfile.lock` may break unexpectedly when the defaults change. + +> Note +> This version of Bundler is not suitable for production applications. +> However, it can be used to test that your application is ready for +> the official release of Bundler 4.0. diff --git a/changelogs/v333/bundler-4.0.0.md b/changelogs/v333/bundler-4.0.0.md new file mode 100644 index 000000000..7b10fe4d4 --- /dev/null +++ b/changelogs/v333/bundler-4.0.0.md @@ -0,0 +1,16 @@ +## Bundler version 4.0.0 is now available for Ruby Applications + +The [Ruby Buildpack](https://devcenter.heroku.com/articles/ruby-support) installs a version of bundler based on the major and minor version listed in the `Gemfile.lock` under the `BUNDLED WITH` key: + +- `BUNDLED WITH` 4.0.x will receive bundler `4.0.0` + +It is strongly recommended that you have both a `RUBY VERSION` and `BUNDLED WITH` version listed in your `Gemfile.lock`. If you do not have those values, you can generate them and commit them to git: + +``` +$ bundle update --ruby +$ git add Gemfile.lock +$ git commit -m "Update Gemfile.lock" +``` + +Applications without these values specified in the `Gemfile.lock` may break unexpectedly when the defaults change. + diff --git a/changelogs/v335/rack-env-ci-test.md b/changelogs/v335/rack-env-ci-test.md new file mode 100644 index 000000000..ab6b7c636 --- /dev/null +++ b/changelogs/v335/rack-env-ci-test.md @@ -0,0 +1,20 @@ +## Heroku CI now sets `RACK_ENV=test` for all Ruby applications + +Previously, only Rails applications running on Heroku CI would have `RACK_ENV` and `RAILS_ENV` set to `test`. Non-Rails [Ruby applications](https://devcenter.heroku.com/articles/ruby-support-reference) (such as Sinatra or plain Rack apps) would incorrectly receive `RACK_ENV=production` during CI test runs. + +Now, all Ruby applications running on Heroku CI will have `RACK_ENV=test` set by default. This ensures that test-specific configurations are properly loaded and prevents issues with gems like DatabaseCleaner that have safeguards against running in production environments. + +If your application relies on the previous behavior, you can explicitly set `RACK_ENV` in your `app.json`: + +```json +{ + "environments": { + "test": { + "env": { + "RACK_ENV": "production" + } + } + } +} +``` + diff --git a/changelogs/v339/ci-path-order.md b/changelogs/v339/ci-path-order.md new file mode 100644 index 000000000..f821968f1 --- /dev/null +++ b/changelogs/v339/ci-path-order.md @@ -0,0 +1,81 @@ +## Ruby applications using Heroku CI have a different PATH load order + +The `PATH` order on Heroku CI relying on `bin/test` interface has changed for applications using the `heroku/ruby` buildpack. + +It now starts with: + +- `/app/bin:/app/vendor/bundle/bin:/app/vendor/bundle/ruby/..0/bin` + +> Note `.` is for the Ruby version so Ruby 3.3.10 would show `/app/vendor/bundle/ruby/3.3.0/bin` on the path . + +This matches the behavior of regular `$ git push heroku` deploys and applications specifying a test command via `app.json`. + +Previously it started with: + +- `/app/bin:vendor/bundle/ruby/..0/bin:/bin:/app/vendor/bundle/ruby/..0/bin:/app/vendor/bundle/bin` + +This discrepancy between has resulted in zero reported issues or tickets, so the fix is not expected to be disruptive. However, it's still a change, and if your application is affected it helps to understand each of the parts of those path to help with debugging. + +## Heroku CI `bin/test` + +Only applications that do not specify a test command in their `app.json` will trigger calling `bin/test` of the +buildpack. This `bin/test` runs a Ruby script to determine what test command should be called (such as `bin/rake test`). +Applications relying on this behavior will now get a different `PATH` order. + +## What is the `PATH`? + +When you type in `$ rspec` the operating system will look for the executable `rspec` by breaking the `PATH` environment variable into parts with a colon (`:`) separator in order from back to front. That means that it will now look for the `rspec` executable in this order: + +- `/app/bin/rspec` +- `/app/vendor/bundle/bin/rspec` +- `/app/vendor/bundle/ruby/..0/bin/rspec` + +By changing the contents or the ordering of the `PATH` you'll possibly change which executable is run. You can see the executable order by using the `which` tool. + +``` +$ heroku run bash +~ $ which -a rake +/app/bin/rake +/app/vendor/bundle/bin/rake +/app/vendor/bundle/ruby/3.3.0/bin/rake +``` + +The `-a` flag tells `which` to list all found executables, not just the first. But when you run `$ rake` it will effectively be the same as calling the full path `$ /app/bin/rake` directly. + +## Path parts + +The following describes the parts that `heroku/ruby` places on the `PATH` both before and after the change. + +### App binstubs `/app/bin` + +This is the local `./bin` "binstubs" directory that all recent Rails applications have. +In addition, the Ruby buildpack also places a symlink to the `ruby` executable we install there, as +well as other default gems. + +This path is first for the current and prior `PATH`. + +### Bundler binstubs `/app/vendor/bundle/bin` + +The location of binstubs installed by `bundle install`. So if you have `rake` in your `Gemfile` you would get a `/app/vendor/bundle/bin/rake` executable file. Notably, these executables load `bundler/setup`, so if you call `$ /app/vendor/bundle/bin/rake` it's similar to calling `$ bundle exec /app/vendor/bundle/bin/rake`. + +Unlike on a local machine, the difference between activating `$ rspec` and `$ bundle exec rspec` is very small, because Heroku cleans unused gem versions. The only time there are multiple versions of a gem on the system is due to default gems or multiple Ruby installations (due to conflicting "bootstrap" Ruby). + +This is now second on the `PATH`, previously it was last (as installed by `heroku/ruby`). + +> Note that this is bundler version dependent Bundler 2.6 places files here Bundler 2.7+ does not + +### RubyGems binstubs`/app/vendor/bundle/ruby/..0/bin` + +The location of binstubs installed by RubyGems (`gem`). When you `bundle install`, it also installs "binstubs" to this directory. These executables do NOT load bundler, so on Ruby 3.3.10 `$ bundle exec /app/vendor/bundle/ruby/3.3.0/bin/rake` and `$ /app/vendor/bundle/ruby/3.3.0/bin` would possibly produce different results on a system where there are many versions of a default gem installed. + +This is now third on the `PATH`, previously it was second as a relative path and again later as an absolute path. + +When a relative path is on the `PATH` as the application changes working directories it changes the effective value of the path. For example, `Dir.chdir("tmp")` would trigger path lookups in `tmp/vendor/bundle/ruby/3.3.0/bin` (for Ruby 3.3.10). It's unlikely this affected many people, but the difference is worth noting. + +### Bootstrap Ruby `/bin` + +The Ruby buildpack uses a "bootstrap" version of Ruby to execute itself. + +Because `/app/bin` is on the path first, the correct version of Ruby will always be used (since that is where we symlink Ruby). However, if you were trying to call a default gem binstub, it's possible that prior to this change, you could have activated the "bootstrap" Ruby's copy instead of the Ruby version you requested. + +This is no longer on the path as the implementation was refactored, so it's no longer needed. Previously, it was on the path by necessity of the implementation of `bin/test`. diff --git a/changelogs/v341/exact-bundler-version.md b/changelogs/v341/exact-bundler-version.md new file mode 100644 index 000000000..eff3de1a6 --- /dev/null +++ b/changelogs/v341/exact-bundler-version.md @@ -0,0 +1,22 @@ +## Ruby applications now receive exact bundler version + +The exact bundler version from the `Gemfile.lock` is now installed with no conversion. + +If your application fails after this change, you can manually adjust your `BUNDLED WITH` value in the `Gemfile.lock` to match the previously used exact version: + +- `BUNDLED WITH 1.x.` installs `bundler 1.17.3` +- `BUNDLED WITH 2.0.x to 2.3.x` installs `bundler 2.3.25` +- `BUNDLED WITH 2.4.x` installs `bundler 2.4.22` +- `BUNDLED WITH 2.5.x` installs `bundler 2.5.23` +- `BUNDLED WITH 2.6.x` installs `bundler 2.6.9` +- `BUNDLED WITH 2.7.x` installs `bundler 2.7.2` +- `BUNDLED WITH 4.0.x` installs `bundler 4.0.0` + +For example, if your application started failing, using bundler 2.6.1, you could adjust it to instead use the 2.6.x series above which would be: + +``` +BUNDLED WITH + 2.6.9 +``` + +Applications without a `BUNDLED WITH` value will receive a [default bundler version ](https://devcenter.heroku.com/articles/ruby-support-reference#default-bundler-version). diff --git a/changelogs/v345/node-lts-24.md b/changelogs/v345/node-lts-24.md new file mode 100644 index 000000000..1a19c325c --- /dev/null +++ b/changelogs/v345/node-lts-24.md @@ -0,0 +1,7 @@ +## Ruby apps now default to Node version 24.13.0 + +Applications using the `heroku/ruby` buildpack that do not have a version of Node installed by another buildpack (such as the `heroku/nodejs` buildpack) will now receive: + +- Node version 24.13.0 + +These versions and instructions on how to specify a specific version of these binaries can be found on the [installed binaries section of the Heroku Ruby Support page](https://devcenter.heroku.com/articles/ruby-support#installed-binaries). diff --git a/changelogs/v346/bundler_default.md b/changelogs/v346/bundler_default.md new file mode 100644 index 000000000..fdbb34fc5 --- /dev/null +++ b/changelogs/v346/bundler_default.md @@ -0,0 +1,19 @@ +## Ruby applications with no specified bundler versions now receive Bundler 2.5.23 + +If your `Gemfile.lock` doesn’t include the `BUNDLED WITH` key, Heroku installs a [default bundler version](https://devcenter.heroku.com/articles/ruby-support-reference#default-bundler-version): + +- Apps using [Classic Buildpacks](https://devcenter.heroku.com/articles/buildpacks#classic-buildpacks) was `2.3.25` now `2.5.23` +- Apps using [Cloud Native Buildpacks](https://devcenter.heroku.com/articles/buildpacks#heroku-cloud-native-buildpacks) stays `2.5.23` + +>note +>Ruby's [standard version of bundler](https://stdgems.org/bundler/) takes precedence if it's greater than Heroku's installed version. When there is no `BUNDLED WITH` in the `Gemfile.lock`, then `bundle install` uses the highest version of Bundler available. + +It is strongly recommended that you have both a `RUBY VERSION` and `BUNDLED WITH` version listed in your `Gemfile.lock`. If you do not have those values, you can generate them and commit them to git: + +``` +$ bundle update --ruby +$ git add Gemfile.lock +$ git commit -m "Update Gemfile.lock" +``` + +Applications without these values specified in the `Gemfile.lock` may break unexpectedly when the defaults change. diff --git a/config/cdn.yml b/config/cdn.yml deleted file mode 100644 index ed97d539c..000000000 --- a/config/cdn.yml +++ /dev/null @@ -1 +0,0 @@ ---- diff --git a/hatchet.json b/hatchet.json index 2c201e99b..2dc933628 100644 --- a/hatchet.json +++ b/hatchet.json @@ -3,52 +3,40 @@ "sharpstone/asset_precompile_fail", "sharpstone/asset_precompile_pass", "sharpstone/asset_precompile_not_found", - "sharpstone/database_url_expected_in_rakefile", "sharpstone/no_rakefile", - "sharpstone/bad_rakefile", - "sharpstone/mri_187_no_rake", - "sharpstone/mri_187_rake", - "sharpstone/mri_200_no_rake", - "sharpstone/mri_200_rake" + "sharpstone/bad_rakefile" ], "bundler": [ - "sharpstone/bad_gemfile_on_platform", "sharpstone/git_gemspec", "sharpstone/no_lockfile", - "sharpstone/sqlite3_gemfile", - "sharpstone/nokogiri_160" + "sharpstone/sqlite3_gemfile" ], "ruby": [ - "sharpstone/mri_187", - "sharpstone/ruby_193_jruby_173", - "sharpstone/ruby_193_jruby_176" + "sharpstone/jruby-minimal", + "sharpstone/empty-procfile", + "sharpstone/bad_ruby_version" ], "rack": [ - "sharpstone/default_ruby", - "sharpstone/mri_187_nokogiri", - "sharpstone/mri_192", - "sharpstone/mri_193", - "sharpstone/mri_200", - "sharpstone/mri_210" + "sharpstone/default_ruby" ], - "rails2": [ - "sharpstone/rails23_mri_187" + "rails_versions": [ + "sharpstone/rails_lts_23_default_ruby", + "sharpstone/rails3_default_ruby", + "sharpstone/rails42_default_ruby", + "sharpstone/rails61", + "sharpstone/rails-jsbundling", + "sharpstone/rails_8_ruby_schema", + "sharpstone/rails_8_sql_schema", + "sharpstone/rails_8_1_trilogy_db_minimal" ], - "rails3": [ - "sharpstone/rails3_mri_193", - "sharpstone/railties3_mri_193", - "sharpstone/rails3_12factor", - "sharpstone/rails3_one_plugin", - "sharpstone/rails3_runtime_assets", - "sharpstone/rails3-fail-assets-compile" + "heroku": [ + "heroku/ruby-getting-started" ], - "rails4": [ - "sharpstone/rails4-manifest", - "sharpstone/rails3-to-4-no-bin", - "sharpstone/rails4_windows_mri193", - "sharpstone/rails4-fail-assets-compile" + "node": [ + "sharpstone/minimal_webpacker" ], - "rails41": [ - "sharpstone/rails41_scaffold" + "ci": [ + "sharpstone/heroku-ci-json-example", + "sharpstone/ruby_no_rails_test" ] } diff --git a/hatchet.lock b/hatchet.lock new file mode 100644 index 000000000..c59a4d166 --- /dev/null +++ b/hatchet.lock @@ -0,0 +1,49 @@ +--- +- - "./repos/bundler/git_gemspec" + - 7755e19caf122e1373bce73ffe9b333e9411d732 +- - "./repos/bundler/no_lockfile" + - 1947ce9a9c276d5df1c323b2ad78d1d85c7ab4c0 +- - "./repos/bundler/sqlite3_gemfile" + - 116db685f54dae18f703b3beb90e64fdbddb048d +- - "./repos/ci/heroku-ci-json-example" + - 728cc99c8e80290cc07441d61a8bcd4596e696fb +- - "./repos/ci/ruby_no_rails_test" + - 8f6b08138db00ef7f3b5a21b1eb42611b83e363b +- - "./repos/heroku/ruby-getting-started" + - main +- - "./repos/node/minimal_webpacker" + - d659577a612b12ddb44ce34e28124c025ecb22f3 +- - "./repos/rack/default_ruby" + - master +- - "./repos/rails_versions/rails-jsbundling" + - 50e9fdf7c3a37623c676989ddebac4ee5349734b +- - "./repos/rails_versions/rails3_default_ruby" + - 984b6d02353519251c2d1e885bf8914a2584f26a +- - "./repos/rails_versions/rails42_default_ruby" + - fbdaf031823f09d1514ec1d55dcb04ca2f4264ca +- - "./repos/rails_versions/rails61" + - 47828a3e8c79b7869ca0d9a3afa4be065fa46e96 +- - "./repos/rails_versions/rails_8_1_trilogy_db_minimal" + - 3a4ac8b53c68c504b980c4403f6b27b5cf57dd4b +- - "./repos/rails_versions/rails_8_ruby_schema" + - a993a3f00c8e09f733bc3b783f7e37148d0af1d4 +- - "./repos/rails_versions/rails_8_sql_schema" + - d5089812196931e858a97119de1640a86825a272 +- - "./repos/rails_versions/rails_lts_23_default_ruby" + - f3597fd6f0887763eb65ec2f83a5e9a5155d5625 +- - "./repos/rake/asset_precompile_fail" + - 16f7834331d6bb3fc5c284130b14eb1ff74d99d5 +- - "./repos/rake/asset_precompile_not_found" + - 57fc7f239bf9319e2f77fe580ba68445f6635191 +- - "./repos/rake/asset_precompile_pass" + - ce976c727d9f477957c499f39f4cf9603c378103 +- - "./repos/rake/bad_rakefile" + - 3cb9c7bf6494c59bd25fa74c2aa4531119e12a46 +- - "./repos/rake/no_rakefile" + - d2ec2084b825218418dac54bd6276ac896b31cdd +- - "./repos/ruby/bad_ruby_version" + - 7bf3470265a87ea6361640aa4bfce6ce3b743520 +- - "./repos/ruby/empty-procfile" + - 7cae0aae424c2028b81b5d37ee24d42db8e545b9 +- - "./repos/ruby/jruby-minimal" + - 3fa8a57f3dbb4ad6f1db411dca7cdc9b343955ad diff --git a/lib/heroku_build_report.rb b/lib/heroku_build_report.rb new file mode 100644 index 000000000..19985194c --- /dev/null +++ b/lib/heroku_build_report.rb @@ -0,0 +1,86 @@ +require "yaml" +require "json" +require "pathname" + +# Observability reporting for builds +# +# Example usage: +# +# HerokuBuildReport::GLOBAL.capture( +# "ruby_version" => "3.4.2" +# ) +module HerokuBuildReport + # Accumulates data in memory and writes it to the specified path in JSON format + # + # Writes data to disk on every capture. Later `bin/report` emits the disk contents + class JsonReport + MALFORMED_JSON_KEY = "build_report_file_malformed" + attr_reader :data + + def initialize(path:, io: $stdout) + @io = io + @path = Pathname(path).expand_path + @path.dirname.mkpath + FileUtils.touch(@path) + @data = safe_load(@path.read) + + if @data[MALFORMED_JSON_KEY] + @path.write(@data.to_json) + end + end + + def safe_load(contents) + if !contents || contents.empty? + {} + else + JSON.parse(contents) + end + rescue + @io.puts "Internal Warning: Expected build report to be JSON, but it is malformed: #{contents.inspect}" + {MALFORMED_JSON_KEY => true} + end + + def complex_object?(value) + value.to_yaml.match?(/!ruby\/object:/) + end + + def capture(metrics = {}) + metrics.each do |(key, value)| + next if key.nil? || key.to_s.strip.empty? + + key = key&.strip + raise "Key cannot be empty (#{key.inspect} => #{value})" if key.nil? || key.empty? + + # Don't serialize complex values by accident + if complex_object?(value) + value = value.to_s + end + + @data[key.to_s] = value + end + + @path.write(@data.to_json) + end + end + + # Current load order of the various "language packs" + def self.set_global(path:) + JsonReport.new(path: path).tap { |report| + # Silence warning about setting a constant + begin + old_verbose = $VERBOSE + $VERBOSE = nil + const_set(:GLOBAL, report) + ensure + $VERBOSE = old_verbose + end + } + end + + # Stores data in memory only, does not persist to disk + def self.dev_null + JsonReport.new(path: "/dev/null") + end + + GLOBAL = dev_null # Changed via `set_global` +end diff --git a/lib/language_pack.rb b/lib/language_pack.rb index 7dc3de377..96cb76f1f 100644 --- a/lib/language_pack.rb +++ b/lib/language_pack.rb @@ -1,45 +1,164 @@ require "pathname" -require 'benchmark' +require "benchmark" + +require "language_pack/shell_helpers" +require "language_pack/helpers/gemfile_lock" # General Language Pack module module LanguagePack module Helpers end - # detects which language pack to use - # @param [Array] first argument is a String of the build directory - # @return [LanguagePack] the {LanguagePack} detected - def self.detect(*args) - Instrument.instrument 'detect' do - Dir.chdir(args.first) + def self.gemfile_lock(app_path:) + path = app_path.join("Gemfile.lock") + if path.exist? + LanguagePack::Helpers::GemfileLock.new( + contents: path.read + ) + else + raise BuildpackError.new("Gemfile.lock required. Please check it in.") + end + end + + def self.call(app_path:, cache_path:, gemfile_lock:, bundle_default_without:, environment_name: "production") + arch = LanguagePack::Base.get_arch + stack = ENV.fetch("STACK") + cache = LanguagePack::Cache.new(cache_path) + warn_io = LanguagePack::ShellHelpers::WarnIO.new + user_env_hash = LanguagePack::ShellHelpers.user_env_hash + bundler_cache = LanguagePack::BundlerCache.new(cache, stack) + bundler_version = LanguagePack::Helpers::BundlerWrapper.resolve_bundler_version( + warn_io: warn_io, + gemfile_lock: gemfile_lock + ) + + metadata = LanguagePack::Metadata.new(cache_path: cache_path) + new_app = metadata.empty? + + dot_ruby_version_path = app_path.join(".ruby-version") + dot_ruby_version_result = if dot_ruby_version_path.exist? + LanguagePack::Helpers::DotRubyVersionFile.new( + contents: dot_ruby_version_path.read + ).call + end + + ruby_version = Ruby.get_ruby_version( + report: HerokuBuildReport::GLOBAL, + metadata: metadata, + gemfile_lock: gemfile_lock, + dot_ruby_version_result: dot_ruby_version_result + ) - pack = [ NoLockfile, Rails41, Rails4, Rails3, Rails2, Rack, Ruby ].detect do |klass| - klass.use? - end + Ruby.remove_vendor_bundle(app_path: app_path) + Ruby.warn_bundler_upgrade(metadata: metadata, bundler_version: bundler_version) + Ruby.warn_bad_binstubs(app_path: app_path, warn_object: warn_io) + Ruby.warn_bundle_binstub(app_path: app_path, warn_object: warn_io) + Ruby.install_ruby( + app_path: app_path, + ruby_version: ruby_version, + stack: stack, + arch: arch, + metadata: metadata, + io: warn_io + ) - return pack ? pack.new(*args) : nil + bundler = Helpers::BundlerWrapper.new(bundler_path: ruby_version.bundler_directory, bundler_version: bundler_version).install + default_config_vars = Ruby.default_config_vars(metadata: metadata, ruby_version: ruby_version, bundler: bundler, environment_name: environment_name) + Ruby.setup_language_pack_environment( + app_path: app_path.expand_path, + ruby_version: ruby_version, + user_env_hash: user_env_hash, + bundle_default_without: bundle_default_without, + default_config_vars: default_config_vars + ) + Ruby.load_bundler_cache( + ruby_version: ruby_version, + new_app: new_app, + cache: cache, + metadata: metadata, + stack: stack, + bundler_cache: bundler_cache, + bundler_version: bundler_version, + bundler: bundler, + io: warn_io + ) + + bundler_output = "" # buffer + Ruby.build_bundler( + ruby_version: ruby_version, + app_path: app_path, + io: warn_io, + bundler_cache: bundler_cache, + bundler_version: bundler_version, + bundler_output: bundler_output + ) + + Ruby.bundle_list( + io: warn_io, + stream_to_user: !bundler_output.match?(/Installing|Fetching|Using/) + ) + + if (pack = LanguagePack.detect( + arch: arch, + new_app: new_app, + warn_io: warn_io, + bundler: bundler, + app_path: app_path, + cache_path: cache_path, + ruby_version: ruby_version, + gemfile_lock: gemfile_lock, + environment_name: environment_name + )) + pack.topic("Compiling #{pack.name}") + pack.compile end end -end + # detects which language pack to use + def self.detect(arch:, app_path:, cache_path:, environment_name:, gemfile_lock:, new_app:, ruby_version:, warn_io:, bundler:) + pack_klass = [Rails8, Rails7, Rails6, Rails5, Rails4, Rails3, Rails2, Rack, Ruby].detect do |klass| + klass.use?(bundler: bundler) + end + pack_klass&.new( + arch: arch, + bundler: bundler, + new_app: new_app, + warn_io: warn_io, + app_path: app_path, + cache_path: cache_path, + environment_name: environment_name, + gemfile_lock: gemfile_lock, + ruby_version: ruby_version + ) + end +end $:.unshift File.expand_path("../../vendor", __FILE__) $:.unshift File.expand_path("..", __FILE__) -require 'dotenv' -require 'language_pack/shell_helpers' -require 'language_pack/instrument' +require "heroku_build_report" + require "language_pack/helpers/plugin_installer" require "language_pack/helpers/stale_file_cleaner" +require "language_pack/helpers/bundle_list" require "language_pack/helpers/rake_runner" +require "language_pack/helpers/rails_runner" +require "language_pack/helpers/puma_warn_error" require "language_pack/helpers/bundler_wrapper" +require "language_pack/helpers/lockfile_shell_parser" +require "language_pack/helpers/default_env_vars" +require "language_pack/helpers/outdated_ruby_version" +require "language_pack/helpers/download_presence" +require "language_pack/helpers/dot_ruby_version_file" +require "language_pack/installers/heroku_ruby_installer" require "language_pack/ruby" require "language_pack/rack" require "language_pack/rails2" require "language_pack/rails3" -require "language_pack/disable_deploys" require "language_pack/rails4" -require "language_pack/rails41" -require "language_pack/no_lockfile" +require "language_pack/rails5" +require "language_pack/rails6" +require "language_pack/rails7" +require "language_pack/rails8" diff --git a/lib/language_pack/base.rb b/lib/language_pack/base.rb index 4e5d63459..23cc658a5 100644 --- a/lib/language_pack/base.rb +++ b/lib/language_pack/base.rb @@ -4,46 +4,60 @@ require "digest/sha1" require "language_pack/shell_helpers" require "language_pack/cache" +require "language_pack/helpers/bundler_cache" require "language_pack/metadata" require "language_pack/fetcher" -require "language_pack/instrument" Encoding.default_external = Encoding::UTF_8 if defined?(Encoding) # abstract class that all the Ruby based Language Packs inherit from class LanguagePack::Base include LanguagePack::ShellHelpers - - VENDOR_URL = "https://s3-external-1.amazonaws.com/heroku-buildpack-ruby" - - attr_reader :build_path, :cache - - # changes directory to the build_path - # @param [String] the path of the build dir - # @param [String] the path of the cache dir this is nil during detect and release - def initialize(build_path, cache_path=nil) - self.class.instrument "base.initialize" do - @build_path = build_path - @cache = LanguagePack::Cache.new(cache_path) if cache_path - @metadata = LanguagePack::Metadata.new(@cache) - @id = Digest::SHA1.hexdigest("#{Time.now.to_f}-#{rand(1000000)}")[0..10] - @warnings = [] - @deprecations = [] - @fetchers = {:buildpack => LanguagePack::Fetcher.new(VENDOR_URL) } - - Dir.chdir build_path + extend LanguagePack::ShellHelpers + + VENDOR_URL = ENV["BUILDPACK_VENDOR_URL"] || "https://heroku-buildpack-ruby.s3.dualstack.us-east-1.amazonaws.com" + ROOT_DIR = File.expand_path("../../..", __FILE__) + AMD_ONLY_STACKS = ["heroku-22"] + KNOWN_ARCHITECTURES = ["amd64", "arm64"] + + attr_reader :app_path, :bundler, :cache, :environment_name, :stack + + def initialize(app_path:, arch:, bundler:, cache_path:, environment_name:, gemfile_lock:, new_app:, ruby_version:, warn_io:) + @app_path = app_path + @arch = arch + @bundler = bundler + @environment_name = environment_name + @gemfile_lock = gemfile_lock + @new_app = new_app + @ruby_version = ruby_version + @warn_io = warn_io + @stack = ENV.fetch("STACK") + @cache = LanguagePack::Cache.new(cache_path) + @metadata = LanguagePack::Metadata.new(cache_path: cache_path) + @bundler_cache = LanguagePack::BundlerCache.new(@cache, @stack) + @fetchers = {buildpack: LanguagePack::Fetcher.new(VENDOR_URL)} + @report = HerokuBuildReport::GLOBAL + end + + def self.get_arch + command = "dpkg --print-architecture" + arch = run!(command, silent: true).strip + + if !KNOWN_ARCHITECTURES.include?(arch) + raise <<~EOF + Architecture '#{arch}' returned from command `#{command}` is unknown. + Known architectures include: #{KNOWN_ARCHITECTURES.inspect}" + EOF end - end - def instrument(*args, &block) - self.class.instrument(*args, &block) + arch end - def self.instrument(*args, &block) - LanguagePack::Instrument.instrument(*args, &block) + def new_app? + @new_app end - def self.===(build_path) + def self.===(app_path) raise "must subclass" end @@ -58,13 +72,6 @@ def default_addons raise "must subclass" end - # config vars to be set on first push. - # @return [Hash] the result - # @not: this is only set the first time an app is pushed to. - def default_config_vars - raise "must subclass" - end - # process types to provide for the app # Ex. for rails we provide a web process # @return [Hash] the result @@ -75,61 +82,47 @@ def default_process_types # this is called to build the slug def compile write_release_yaml - instrument 'base.compile' do - if @warnings.any? - topic "WARNINGS:" - puts @warnings.join("\n") - end - if @deprecations.any? - topic "DEPRECATIONS:" - puts @deprecations.join("\n") - end + Kernel.puts "" + warnings.each do |warning| + Kernel.puts "\e[1m\e[33m###### WARNING:\e[0m" # Bold yellow + Kernel.puts "" + puts warning + Kernel.puts "" end + Kernel.puts "" end - def write_release_yaml + def build_release release = {} - release["addons"] = default_addons + release["addons"] = default_addons release["default_process_types"] = default_process_types - release["config_vars"] = default_config_vars - FileUtils.mkdir("tmp") unless File.exists?("tmp") - File.open("tmp/heroku-buildpack-release-step.yml", 'w') do |f| - f.write(release.to_yaml) - end - end - # log output - # Ex. log "some_message", "here", :someattr="value" - def log(*args) - args.concat [:id => @id] - args.concat [:framework => self.class.to_s.split("::").last.downcase] - - start = Time.now.to_f - log_internal args, :start => start - - if block_given? - begin - ret = yield - finish = Time.now.to_f - log_internal args, :status => "complete", :finish => finish, :elapsed => (finish - start) - return ret - rescue StandardError => ex - finish = Time.now.to_f - log_internal args, :status => "error", :finish => finish, :elapsed => (finish - start), :message => ex.message - raise ex - end - end + release end -private ################################## + def write_release_yaml + release = build_release + FileUtils.mkdir("tmp") unless File.exist?("tmp") + File.write("tmp/heroku-buildpack-release-step.yml", release.to_yaml) - # sets up the environment variables for the build process - def setup_language_pack_environment + warn_webserver end - def add_to_profiled(string) - FileUtils.mkdir_p "#{build_path}/.profile.d" - File.open("#{build_path}/.profile.d/ruby.sh", "a") do |file| + def warn_webserver + return if File.exist?("Procfile") + msg = "No Procfile detected, using the default web server.\n" + msg << "We recommend explicitly declaring how to boot your server process via a Procfile.\n" + msg << "https://devcenter.heroku.com/articles/ruby-default-web-server" + warn msg + end + + private ################################## + + def add_to_profiled(string, filename: "ruby.sh", mode: "a") + profiled_path = "#{app_path}/.profile.d/" + + FileUtils.mkdir_p profiled_path + File.open("#{profiled_path}/#{filename}", mode) do |file| file.puts string end end @@ -139,23 +132,43 @@ def set_env_default(key, val) end def set_env_override(key, val) - add_to_profiled %{export #{key}="#{val.gsub('"','\"')}"} + add_to_profiled %(export #{key}="#{val.gsub('"', '\"')}") end - def log_internal(*args) - message = build_log_message(args) - %x{ logger -p user.notice -t "slugc[$$]" "buildpack-ruby #{message}" } + def add_to_export(string) + export = File.join(ROOT_DIR, "export") + File.open(export, "a") do |file| + file.puts string + end end - def build_log_message(args) - args.map do |arg| - case arg - when Float then "%0.2f" % arg - when Array then build_log_message(arg) - when Hash then arg.map { |k,v| "#{k}=#{build_log_message([v])}" }.join(" ") - else arg + # option can be :path, :default, :override + # https://github.com/buildpacks/spec/blob/366ac1aa0be59d11010cc21aa06c16d81d8d43e7/buildpack.md#environment-variable-modification-rules + def export(key, val, option: nil) + string = + if option == :default + %(export #{key}="${#{key}:-#{val}}") + elsif option == :path + %(export #{key}="#{val}:$#{key}") + else + %(export #{key}="#{val.gsub('"', '\"')}") end - end.join(" ") + + export = File.join(ROOT_DIR, "export") + File.open(export, "a") do |file| + file.puts string + end + end + + def set_export_default(key, val) + export key, val, option: :default end -end + def set_export_override(key, val) + export key, val, option: :override + end + + def set_export_path(key, val) + export key, val, option: :path + end +end diff --git a/lib/language_pack/cache.rb b/lib/language_pack/cache.rb index 912daa120..a849c37e4 100644 --- a/lib/language_pack/cache.rb +++ b/lib/language_pack/cache.rb @@ -1,45 +1,98 @@ require "pathname" require "language_pack" +# Manipulates/handles contents of the cache directory class LanguagePack::Cache + # @param [String] path to the cache store def initialize(cache_path) - @cache_base = Pathname.new(cache_path) + @cache_base = if cache_path + Pathname.new(cache_path) + end end - # removes the the specified + # removes the the specified path from the cache # @param [String] relative path from the cache_base def clear(path) + return unless @cache_base + target = (@cache_base + path) target.exist? && target.rmtree end - # write cache contents + # Overwrite cache contents + # When called the cache destination will be cleared and the new contents coppied over + # This method is perferable as LanguagePack::Cache#add can cause accidental cache bloat. + # # @param [String] path of contents to store. it will be stored using this a relative path from the cache_base. - # @param [Boolean] defaults to true. if set to true, the cache store directory will be cleared before writing to it. - def store(path, clear_first=true) - clear(path) if clear_first - copy path, (@cache_base + path) + # @param [String] relative path to store the cache contents, if nil it will assume the from path + def store(from, path = nil) + return unless @cache_base + + path ||= from + clear path + copy from, (@cache_base + path) + end + + # Adds file to cache without clearing the destination + # Use LanguagePack::Cache#store to avoid accidental cache bloat + def add(from, path = nil) + return unless @cache_base + + path ||= from + copy from, (@cache_base + path) end # load cache contents # @param [String] relative path of the cache contents - def load(path) - copy (@cache_base + path), path + # @param [String] path of where to store it locally, if nil, assume same relative path as the cache contents + def load(path, dest = nil) + return unless @cache_base + + dest ||= path + copy (@cache_base + path), dest + end + + def load_without_overwrite(path, dest = nil) + return unless @cache_base + + dest ||= path + + case ENV["STACK"] + when "heroku-22" + copy (@cache_base + path), dest, "-a -n" + else + copy (@cache_base + path), dest, "-a --update=none" + end end # copy cache contents # @param [String] source directory # @param [String] destination directory - def copy(from, to) + def copy(from, to, options = "-a") + return unless @cache_base + return false unless File.exist?(from) FileUtils.mkdir_p File.dirname(to) - system("cp -a #{from}/. #{to}") + command = "cp #{options} #{from}/. #{to}" + system(command) + raise "Command failed `#{command}`" unless $? + end + + # copy contents between to places in the cache + # @param [String] source cache directory + # @param [String] destination directory + def cache_copy(from, to) + return unless @cache_base + + copy(@cache_base + from, @cache_base + to) end # check if the cache content exists # @param [String] relative path of the cache contents # @param [Boolean] true if the path exists in the cache and false if otherwise def exists?(path) - File.exists?(@cache_base + path) + return unless @cache_base + + File.exist?(@cache_base + path) end end diff --git a/lib/language_pack/disable_deploys.rb b/lib/language_pack/disable_deploys.rb deleted file mode 100644 index 46570bb8d..000000000 --- a/lib/language_pack/disable_deploys.rb +++ /dev/null @@ -1,17 +0,0 @@ -require "language_pack" -require "language_pack/base" - -class LanguagePack::DisableDeploys < LanguagePack::Base - def self.use? - File.exist?("Gemfile") - end - - def name - "Ruby/DisableDeploys" - end - - def compile - error "Ruby deploys have been temporarily disabled due to a Rubygems.org security breach.\nPlease see https://status.heroku.com/incidents/489 for more info and a workaround if you need to deploy." - end -end - diff --git a/lib/language_pack/fetcher.rb b/lib/language_pack/fetcher.rb index 003096bf3..3f22ec932 100644 --- a/lib/language_pack/fetcher.rb +++ b/lib/language_pack/fetcher.rb @@ -3,45 +3,54 @@ module LanguagePack class Fetcher + class FetchError < StandardError; end + include ShellHelpers - CDN_YAML_FILE = File.expand_path("../../../config/cdn.yml", __FILE__) - def initialize(host_url) - @config = load_config - @host_url = fetch_cdn(host_url) + def initialize(host_url, stack: nil, arch: nil) + @host_url = Pathname.new(host_url) + # File.basename prevents accidental directory traversal + @host_url += File.basename(stack) if stack + @host_url += File.basename(arch) if arch + end + + def exists?(path, max_attempts = 1) + curl = curl_command("--head #{@host_url.join(path)}") + run!(curl, error_class: FetchError, max_attempts: max_attempts, silent: true) + rescue FetchError + false end def fetch(path) - curl = curl_command("-O #{@host_url.join(path)}") - run!(curl) + curl = curl_command("--remote-name #{@host_url.join(path)}") + run!(curl, error_class: FetchError) end - def fetch_untar(path) - curl = curl_command("#{@host_url.join(path)} -s -o") - run!("#{curl} - | tar zxf -") + def fetch_untar(path, files_to_extract = nil, strip_components: 0) + curl = curl_command("#{@host_url.join(path)} --no-progress-meter --output") + tar_cmd = ["tar", "--strip-components=#{strip_components}", "-xzf", "- #{files_to_extract}"] + run! "#{curl} - | #{tar_cmd.join(" ")}", + error_class: FetchError, + max_attempts: 3 end - def fetch_bunzip2(path) - curl = curl_command("#{@host_url.join(path)} -s -o") - run!("#{curl} - | tar jxf -") + def fetch_bunzip2(path, files_to_extract = nil) + curl = curl_command("#{@host_url.join(path)} --no-progress-meter --output") + run!("#{curl} - | tar jxf - #{files_to_extract}", error_class: FetchError) end private + def curl_command(command) - "set -o pipefail; curl --fail --retry 3 --retry-delay 1 --connect-timeout 3 --max-time #{curl_timeout_in_seconds} #{command}" + "set -o pipefail; curl --location --fail --retry 5 --retry-delay 1 --connect-timeout #{curl_connect_timeout_in_seconds} --max-time #{curl_timeout_in_seconds} #{command}" end def curl_timeout_in_seconds - ENV['CURL_TIMEOUT'] || 30 - end - - def load_config - YAML.load_file(CDN_YAML_FILE) || {} + env("CURL_TIMEOUT") || 30 end - def fetch_cdn(url) - url = @config[url] || url - Pathname.new(url) + def curl_connect_timeout_in_seconds + env("CURL_CONNECT_TIMEOUT") || 3 end end end diff --git a/lib/language_pack/helpers/binstub_check.rb b/lib/language_pack/helpers/binstub_check.rb new file mode 100644 index 000000000..48ad2e2f0 --- /dev/null +++ b/lib/language_pack/helpers/binstub_check.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "language_pack/helpers/binstub_wrapper" +# This class is designed to check for binstubs for validity +# +# Example: +# +# check = LanguagePack::Helpers::BinstubCheck.new(app_root_dir: Dir.pwd, warn_object: self) +# check.call +class LanguagePack::Helpers::BinstubCheck + attr_reader :bad_binstubs + + def initialize(app_root_dir:, warn_object:) + @bin_dir = Pathname.new(app_root_dir).join("bin") + @warn_object = warn_object + @bad_binstubs = [] + end + + # Checks all binstubs in the directory for a + # bad shebang line. If any are present + # a warning is created on the passed in `warn_object` + def call + return unless @bin_dir.directory? + + each_binstub do |binstub| + @bad_binstubs << binstub if binstub.bad_shebang? + end + + warn unless @bad_binstubs.empty? + end + + # Iterates and yields each binstub in a directory + # as a BinstubWrapper + private def each_binstub + @bin_dir.entries.each do |basename| + binstub = LanguagePack::Helpers::BinstubWrapper.new(@bin_dir.join(basename)) + + next unless binstub.file? # Needed since "." and ".." are returned by Pathname#entries + yield binstub + end + end + + private def warn + message = <<~EOM + Improperly formatted binstubs detected in your project + + The following file(s) have appear to contain a problematic "shebang" line + + #{@bad_binstubs.map { |binstub| " - bin/#{binstub.basename}" }.join("\n")} + + For example bin/#{@bad_binstubs.first.basename} has the shebang line: + + ``` + #{@bad_binstubs.first.open(&:readline).strip} + ``` + + It should be: + + ``` + #!/usr/bin/env ruby + ``` + + A malformed shebang line may cause your program to crash. + + For more information about binstubs and "shebang" lines see: + https://devcenter.heroku.com/articles/bad-ruby-binstub-shebang + EOM + + @warn_object.warn(message, inline: true) + end +end diff --git a/lib/language_pack/helpers/binstub_wrapper.rb b/lib/language_pack/helpers/binstub_wrapper.rb new file mode 100644 index 000000000..93c295219 --- /dev/null +++ b/lib/language_pack/helpers/binstub_wrapper.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "delegate" + +# This is a helper class, it wraps a pathname object +# and adds helper methods used to pull out the first line of the file ("the shebang") +# as well as determining if the file is binary or not. +# +# binstub = BinstubWrapper.new(Pathname.new(Dir.pwd).join("bin/rails")) +# binstub.file? # => true +# binstub.binary? #=> false +# binstub.bad_shebang? #=> false +class LanguagePack::Helpers::BinstubWrapper < SimpleDelegator + def initialize(string_or_pathname) + @binstub = Pathname.new(string_or_pathname) + super(@binstub) + end + + # Returns false if the shebang line has a ruby binary + # that is not simply "ruby" or "ruby.exe" + # + # Example: + # + # bin_dir = Pathname.new(Dir.pwd).join("bin") + # binstub = BinstubWrapper.new(bin_dir.join("rails_good")) + # binstub.shebang # => "#!/usr/bin/env ruby\n" + # binstub.bad_shebang? # => false + # + # binstub = BinstubWrapper.new(bin_dir.join("rails_bad")) + # binstub.shebang # => "#!/usr/bin/env ruby2.5\n" + # binstub.bad_shebang? # => true + def bad_shebang? + return false if binary? + + shebang.match?(/^#!\s*\/usr\/bin\/env\s*ruby(\d.*)$/) # https://rubular.com/r/ozbNEPVInc3sSN + end + + # The first line of a binstub contains the "shebang" line + # that tells the operating system how to execute the file + # for example: + # + # binstub = BinstubWrapper.new(Pathname.new(Dir.pwd).join("bin/rails")) + # binstub.shebang # => "#!/usr/bin/env ruby\n" + def shebang + @shebang ||= begin + @binstub.open(&:readline) + rescue EOFError + +"" + end + end + + # Binary files (may) not have valid UTF-8 encoding. In order to + # compare a shebang line, we must first check if the shebang + # line is binary or not. To do that, we can see if it is not valid + # UTF-8 + def binary? + !valid_utf8? + end + + def valid_utf8? + shebang.force_encoding("UTF-8").valid_encoding? + end +end diff --git a/lib/language_pack/helpers/bundle_binstub_check.rb b/lib/language_pack/helpers/bundle_binstub_check.rb new file mode 100644 index 000000000..5564cacb5 --- /dev/null +++ b/lib/language_pack/helpers/bundle_binstub_check.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +# Checks for the presence of a `bin/bundle` binstub in the app. +# +# When `bin/bundle` exists, bundler may remove itself during `bundle clean` +# and then fall back to the default bundler version shipped with Ruby at +# runtime, which can be a different major version than what the app expects. +# +# The fix is to remove the `bin/bundle` binstub from the app. +# +# See: https://github.com/heroku/heroku-buildpack-ruby/issues/1690 +# See: https://github.com/ruby/rubygems/issues/9218 +# +# Example: +# +# check = LanguagePack::Helpers::BundleBinstubCheck.new( +# app_root_dir: Dir.pwd, +# warn_object: self +# ) +# check.call +# +class LanguagePack::Helpers::BundleBinstubCheck + def initialize(app_root_dir:, warn_object:) + @bin_bundle = Pathname.new(app_root_dir).join("bin", "bundle") + @warn_object = warn_object + end + + def call + if @bin_bundle.exist? + @warn_object.warn(warning_message, inline: true) + end + end + + private def warning_message + <<~WARNING + Your app has a `bin/bundle` binstub that may cause bundler to + malfunction. We recommend you remove it. + + When `bin/bundle` is present, `bundle clean` may remove the + installed version of bundler and then your app falls back to + the default bundler version that ships with Ruby, which can be + a different major version than expected. + + To fix this issue, run: + + ``` + $ rm bin/bundle + $ git add . + $ git commit -m "Remove bin/bundle binstub" + ``` + + For more information see: + https://github.com/heroku/heroku-buildpack-ruby/issues/1690 + WARNING + end +end diff --git a/lib/language_pack/helpers/bundle_list.rb b/lib/language_pack/helpers/bundle_list.rb new file mode 100644 index 000000000..43f5f5487 --- /dev/null +++ b/lib/language_pack/helpers/bundle_list.rb @@ -0,0 +1,85 @@ +require "language_pack/shell_helpers" + +module LanguagePack::Helpers + class BundleList + class CmdError < BuildpackError + def initialize(command:, output:) + super(<<~EOF) + Error detecting dependencies + + The Ruby buildpack requires information about your application’s dependencies to + complete the build. Without this information, the Ruby buildpack cannot continue. + + Command failed: `#{command}` + + #{output} + EOF + end + end + + # Runs `bundle list` and builds a BundleList object + # from the output + # + # Announces the run, optionally streams results to the user. + # This is useful for the case where bundler doesn't print + # any version info. + # + # Example: + # + # BundleList::Command.new( + # stream_to_user: stream_to_user + # ).call + class HumanCommand + include LanguagePack::ShellHelpers + + private attr_reader :stream_to_user, :io + + def initialize(stream_to_user:, io: self) + @io = io + @stream_to_user = stream_to_user + end + + def call + command = "bundle list" + io.puts "Running: #{command}" + + output = if stream_to_user + pipe(command, user_env: true, output_object: io) + else + run(command, user_env: true) + end + + if $?.success? + BundleList.new( + output: output + ) + else + raise CmdError.new(output: output, command: command) + end + end + end + + def initialize(output:) + @raw = output + @gems = {} + @raw.scan(/\* (?\S+) \((?[a-zA-Z0-9.]+)(? [a-zA-Z0-9]+)?\)/) do + captures = Regexp.last_match.named_captures + @gems[captures["name"]] = captures["version"] + end + end + + def has_gem?(name) + @gems[name] + end + + def length + @gems.length + end + + def gem_version(name) + if (version = @gems[name]) + Gem::Version.new(version) + end + end + end +end diff --git a/lib/language_pack/helpers/bundler_cache.rb b/lib/language_pack/helpers/bundler_cache.rb new file mode 100644 index 000000000..a56674b62 --- /dev/null +++ b/lib/language_pack/helpers/bundler_cache.rb @@ -0,0 +1,51 @@ +require "pathname" +require "fileutils" +require "language_pack/cache" + +# manipulating the `vendor/bundle` Bundler cache directory. +# supports storing the cache in a "stack" directory +class LanguagePack::BundlerCache + attr_reader :bundler_dir + + # @param [LanguagePack::Cache] cache object + # @param [String] stack buildpack is running on + def initialize(cache, stack = nil) + @cache = cache + @stack = stack + @bundler_dir = Pathname.new("vendor/bundle") + @stack_dir = @stack ? Pathname.new(@stack) + @bundler_dir : @bundler_dir + end + + # removes the bundler cache dir BOTH in the cache and local directory + def clear(stack = nil) + stack ||= @stack + @cache.clear(stack) + @bundler_dir.rmtree + end + + # converts to cache directory to support stacks. only copy contents if the stack hasn't changed + # @param [Boolean] denote if there's a stack change or not + def convert_stack(stack_change) + @cache.cache_copy(@bundler_dir, @stack_dir) unless stack_change + @cache.clear(@bundler_dir) + end + + # detects if using the non stack directory layout + def old? + @cache.exists?(@bundler_dir) + end + + def exists? + @cache.exists?(@stack_dir) + end + + # writes cache contents to cache store + def store + @cache.store(@bundler_dir, @stack_dir) + end + + # loads cache contents from the cache store + def load + @cache.load(@stack_dir, @bundler_dir) + end +end diff --git a/lib/language_pack/helpers/bundler_wrapper.rb b/lib/language_pack/helpers/bundler_wrapper.rb index c0524d256..e8fbc699f 100644 --- a/lib/language_pack/helpers/bundler_wrapper.rb +++ b/lib/language_pack/helpers/bundler_wrapper.rb @@ -1,53 +1,74 @@ +# frozen_string_literal: true + +require "json" + +# This class is responsible for installing and maintaining a +# reference to bundler. It contains access to bundler internals +# that are used to introspect a project such as detecting presence +# of gems and their versions. +# +# Example: +# +# bundler = LanguagePack::Helpers::BundlerWrapper.new(bundler_path: "vendor/bundle/ruby/3.2.0", bundler_version: "2.5.7") +# bundler.install +# bundler.version => "2.5.23" +# bundler.dir_name => "bundler-2.5.23" +# bundler.has_gem?("railties") => true +# bundler.gem_version("railties") => "5.2.2" +# bundler.clean +# +# IMPORTANT: Calling `BundlerWrapper#install` on this class mutates the environment variable +# ENV['BUNDLE_GEMFILE']. If you're calling in a test context (or anything outside) +# of an isolated dyno, you must call `BundlerWrapper#clean`. To reset the environment +# variable: +# +# bundler = LanguagePack::Helpers::BundlerWrapper.new(bundler_path: "vendor/bundle/ruby/3.2.0", bundler_version: "2.5.7") +# bundler.install +# bundler.clean # <========== IMPORTANT ============= +# class LanguagePack::Helpers::BundlerWrapper - class GemfileParseError < StandardError - def initialize(error) - msg = "There was an error parsing your Gemfile, we cannot continue\n" - msg << error.message - self.set_backtrace(error.backtrace) - super msg - end - end - - VENDOR_URL = LanguagePack::Base::VENDOR_URL # coupling - DEFAULT_FETCHER = LanguagePack::Fetcher.new(VENDOR_URL) # coupling - BUNDLER_DIR_NAME = LanguagePack::Ruby::BUNDLER_GEM_PATH # coupling - BUNDLER_PATH = File.expand_path("../../../../tmp/#{BUNDLER_DIR_NAME}", __FILE__) - GEMFILE_PATH = Pathname.new "./Gemfile" - - attr_reader :bundler_path - - def initialize(options = {}) - @fetcher = options[:fetcher] || DEFAULT_FETCHER - @bundler_path = options[:bundler_path] || File.join(Dir.mktmpdir, BUNDLER_DIR_NAME) - @gemfile_path = options[:gemfile_path] || GEMFILE_PATH - @bundler_tar = options[:bundler_tar] || "#{BUNDLER_DIR_NAME}.tgz" - @gemfile_lock_path = "#{@gemfile_path}.lock" - @orig_bundle_gemfile = ENV['BUNDLE_GEMFILE'] - ENV['BUNDLE_GEMFILE'] = @gemfile_path.to_s - @unlock = false - @path = Pathname.new "#{@bundler_path}/gems/#{BUNDLER_DIR_NAME}/lib" + include LanguagePack::ShellHelpers + + DEFAULT_VERSION = "2.5.23" + + attr_reader :bundler_path + + def initialize( + bundler_path:, + bundler_version:, + gemfile_path: Pathname.new("./Gemfile"), + report: HerokuBuildReport::GLOBAL + ) + @report = report + @gemfile_path = gemfile_path + @gemfile_lock_path = Pathname.new("#{@gemfile_path}.lock") + + dot_ruby_version_file = @gemfile_lock_path.join("..").join(".ruby-version") + @report.capture( + "dot_ruby_version.contents" => dot_ruby_version_file.exist? ? dot_ruby_version_file.read&.strip : nil + ) + @version = bundler_version + parts = @version.split(".") + @report.capture( + "bundler.version_installed" => @version, + "bundler.major" => parts&.shift, + "bundler.minor" => parts&.shift, + "bundler.patch" => parts&.shift + ) + @dir_name = "bundler-#{@version}" + + @bundler_path = Pathname(bundler_path) + @orig_bundle_gemfile = ENV["BUNDLE_GEMFILE"] end def install + ENV["BUNDLE_GEMFILE"] = @gemfile_path.to_s fetch_bundler - $LOAD_PATH << @path - without_warnings do - load @path.join("bundler.rb") - end self end def clean - ENV['BUNDLE_GEMFILE'] = @orig_bundle_gemfile - FileUtils.remove_entry_secure(bundler_path) if Dir.exist?(bundler_path) - end - - def without_warnings(&block) - orig_verb = $VERBOSE - $VERBOSE = nil - yield - ensure - $VERBOSE = orig_verb + ENV["BUNDLE_GEMFILE"] = @orig_bundle_gemfile end def has_gem?(name) @@ -55,80 +76,67 @@ def has_gem?(name) end def gem_version(name) - instrument "ruby.gem_version" do - if spec = specs[name] - spec.version - end - end - end - - # detects whether the Gemfile.lock contains the Windows platform - # @return [Boolean] true if the Gemfile.lock was created on Windows - def windows_gemfile_lock? - platforms.detect do |platform| - /mingw|mswin/.match(platform.os) if platform.is_a?(Gem::Platform) - end + specs[name] end def specs - @specs ||= lockfile_parser.specs.each_with_object({}) {|spec, hash| hash[spec.name] = spec } + @specs ||= specs_from_lockfile end - def platforms - @platforms ||= lockfile_parser.platforms - end + attr_reader :version - def version - Bundler::VERSION - end + attr_reader :dir_name - def instrument(*args, &block) - LanguagePack::Instrument.instrument(*args, &block) - end + private def fetch_bundler + return true if Dir.exist?(bundler_path.join("gems", dir_name)) - def ui - Bundler.ui = Bundler::UI::Shell.new({}) - end + topic("Installing bundler #{@version}") - def definition - Bundler.definition(@unlock) - rescue => e - raise GemfileParseError.new(e) + # Install directory structure (as of Bundler 2.1.4): + # - cache + # - bin + # - gems + # - specifications + # - build_info + # - extensions + # - doc + FileUtils.mkdir_p(bundler_path) + run!("GEM_HOME=#{bundler_path} gem install bundler --version #{@version} --no-document --env-shebang") end - def unlock - @unlock = true - yield - ensure - @unlock = false + # Runs a Ruby subprocess to parse the Gemfile.lock and return specs as a hash. + private def specs_from_lockfile + LanguagePack::Helpers::LockfileShellParser.call(lockfile_path: @gemfile_lock_path) end - def ruby_version - unlock do - definition.ruby_version - end - end + def self.resolve_bundler_version(gemfile_lock:, warn_io:) + version = gemfile_lock.bundler.version + if version + version + else + warn_io.warn(<<~WARNING) + Using default bundler version `#{DEFAULT_VERSION}` - def lockfile_parser - @lockfile_parser ||= parse_gemfile_lock - end + The Ruby buildpack uses the `BUNDLED WITH` value in your `Gemfile.lock` to determine the version + of bundler to install. Your `Gemfile.lock` does not contain this section, so a default version + of bundler will be installed instead. - private - def fetch_bundler - instrument 'fetch_bundler' do - return true if Dir.exists?(bundler_path) - FileUtils.mkdir_p(bundler_path) - Dir.chdir(bundler_path) do - @fetcher.fetch_untar(@bundler_tar) - end - Dir["bin/*"].each {|path| `chmod 755 #{path}` } - end - end + Heroku recommends that you have both a `RUBY VERSION` and `BUNDLED WITH` version listed in your `Gemfile.lock`. + You can add it to your project by running: + + ``` + $ bundle update --bundler + ``` + + Commit the results to git before redeploying: + + ``` + $ git add Gemfile.lock + $ git commit -m "Add BUNDLED WITH version" + ``` + WARNING - def parse_gemfile_lock - instrument 'parse_bundle' do - gemfile_contents = File.read(@gemfile_lock_path) - Bundler::LockfileParser.new(gemfile_contents) + DEFAULT_VERSION end end end diff --git a/lib/language_pack/helpers/default_env_vars.rb b/lib/language_pack/helpers/default_env_vars.rb new file mode 100644 index 000000000..4a2874d4e --- /dev/null +++ b/lib/language_pack/helpers/default_env_vars.rb @@ -0,0 +1,52 @@ +module LanguagePack::Helpers::DefaultEnvVars + # Returns a hash of default environment variables for the given inputs + # + # Values will be written to disk as defaults + # + # i.e. `export RAILS_ENV=${RAILS_ENV:-production}` + # + # Therefore it's important that we don't return any values from user provided config vars + # or customers will not be able to `heroku config:unset` them. + # + # @param is_jruby [Boolean] whether the app is using JRuby + # @param rack_version [Gem::Version] the version of the rack gem + # @param rails_version [Gem::Version] the version of the rails gem + # @param secret_key_base [String] the secret key base for the app + # @param environment_name [String] the environment name to use for RACK_ENV/RAILS_ENV + # @return [Hash] a hash of default environment variables + def self.call(is_jruby:, rack_version:, rails_version:, secret_key_base:, environment_name:) + out = {} + out["LANG"] = "en_US.UTF-8" + out["PUMA_PERSISTENT_TIMEOUT"] = "95" + + if is_jruby + out["JRUBY_OPTS"] = "-Xcompile.invokedynamic=false" + end + + if rack_version + out["RACK_ENV"] = environment_name + end + + if rails_version + out["RAILS_ENV"] = environment_name + end + + if rails_version&.>= Gem::Version.new("4.1.0.beta1") + if (secret_key_base = secret_key_base&.to_s) + out["SECRET_KEY_BASE"] = secret_key_base + else + raise ArgumentError, "secret_key_base is required for rails 4.1+. Provided: #{secret_key_base.inspect}" + end + end + + if rails_version&.>= Gem::Version.new("4.2.0") + out["RAILS_SERVE_STATIC_FILES"] = "enabled" + end + + if rails_version&.>= Gem::Version.new("5.0.0") + out["RAILS_LOG_TO_STDOUT"] = "enabled" + end + + out + end +end diff --git a/lib/language_pack/helpers/dot_ruby_version_file.rb b/lib/language_pack/helpers/dot_ruby_version_file.rb new file mode 100644 index 000000000..29e232f14 --- /dev/null +++ b/lib/language_pack/helpers/dot_ruby_version_file.rb @@ -0,0 +1,120 @@ +require "language_pack/shell_helpers" +require "language_pack/ruby_version" + +module LanguagePack + module Helpers + # Parses contents of `.ruby-version` file to transform it into a RubyVersion + # + # Example: + # + # version = DotRubyVersionFile.new( + # contents: File.read(".ruby-version") + # ).call + # assert_eq [], version.warnings + # assert_eq :ruby, version.ruby_version.engine + class DotRubyVersionFile + VERSION_PATTERN = /\A(?\d+\.\d+\.\d+)(?:[.-](?
\S+))?\z/
+      JRUBY_PATTERN = /\Ajruby-/i
+      SPECIFIER_PATTERN = /((>|<|~|=)+)/
+
+      Result = Data.define(:ruby_version, :warnings)
+
+      def initialize(contents:)
+        @contents = contents
+      end
+
+      def call
+        warnings = []
+        lines = meaningful_lines
+
+        if lines.empty?
+          return Result.new(ruby_version: nil, warnings: warnings)
+        end
+
+        line = lines.first
+        version_string = strip_ruby_prefix_and_at_suffix(line)
+
+        if lines.length > 1
+          warnings << <<~EOF
+            The `.ruby-version` file contains multiple version lines.
+            Only a single version is supported. Remove additional lines.
+
+            Contents:
+
+            ```
+            #{@contents}
+            ```
+          EOF
+        end
+
+        if version_string.match?(JRUBY_PATTERN)
+          warnings << <<~EOF
+            JRuby not supported in `.ruby-version` file.
+
+            The `.ruby-version` file contains a JRuby version however the
+            JRuby engine is not supported by `.ruby-version` on Heroku at this time.
+
+            Contents:
+
+            ```
+            #{@contents}
+            ```
+          EOF
+        end
+
+        if (match = version_string.match(SPECIFIER_PATTERN))
+          specifier = match[1]
+          warnings << <<~EOF
+            Cannot parse `.ruby-version` file, version specifiers (`#{specifier}`) are not supported.
+
+            Only exact versions are supported such as `3.4.8` or `ruby-3.4.8`.
+            Got:
+
+            ```
+            #{@contents}
+            ```
+          EOF
+        end
+
+        if warnings.any?
+          Result.new(ruby_version: nil, warnings: warnings)
+        elsif (match = version_string.match(VERSION_PATTERN))
+          Result.new(
+            ruby_version: LanguagePack::RubyVersion.new(
+              pre: match[:pre],
+              engine: :ruby,
+              default: false,
+              ruby_version: match[:version],
+              engine_version: match[:version]
+            ),
+            warnings: warnings
+          )
+        else
+          warnings << <<~EOF
+            Cannot parse Ruby version from `.ruby-version` file.
+
+            Only full Ruby versions with major, minor, and patch are supported
+            such as `3.4.8` or `ruby-3.4.8`. Got:
+
+            ```
+            #{@contents}
+            ```
+          EOF
+          Result.new(ruby_version: nil, warnings: warnings)
+        end
+      end
+
+      private def meaningful_lines
+        @contents.gsub(/\s*#.*/, "").each_line.filter_map { |line|
+          stripped = line.strip
+          stripped unless stripped.empty?
+        }
+      end
+
+      private def strip_ruby_prefix_and_at_suffix(line)
+        version = line.delete_prefix("ruby-")
+        version.split("@").first || ""
+      end
+    end
+  end
+end
diff --git a/lib/language_pack/helpers/download_presence.rb b/lib/language_pack/helpers/download_presence.rb
new file mode 100644
index 000000000..749a61579
--- /dev/null
+++ b/lib/language_pack/helpers/download_presence.rb
@@ -0,0 +1,79 @@
+# This class is used to check whether a binary exists on one or more stacks.
+# The main motivation for adding this logic is to help people who are upgrading
+# to a new stack if it does not have a given Ruby version. For example if someone
+# is using Ruby 1.9.3 on the cedar-14 stack then they should be informed that it
+# does not exist if they try to use it on the Heroku-18 stack.
+#
+# Example
+#
+#    download = LanguagePack::Helpers::DownloadPresence.new(
+#      'ruby-3.1.7.tgz',
+#      stacks: ['heroku-22', 'heroku-24', 'heroku-26']
+#    )
+#
+#    download.call
+#
+#    puts download.exists? #=> true
+#    puts download.valid_stack_list #=> ['heroku-22', 'heroku-24', 'heroku-26']
+class LanguagePack::Helpers::DownloadPresence
+  # heroku-22, heroku-24, and heroku-26 have identical ruby versions supported
+  STACKS = ["heroku-22", "heroku-24", "heroku-26"]
+
+  def initialize(file_name:, arch:, amd_only_stacks:, stacks: STACKS)
+    @file_name = file_name
+    @stacks = stacks
+    @fetchers = []
+    @threads = []
+    @stacks.each do |stack|
+      @fetchers << if amd_only_stacks.include?(stack)
+        LanguagePack::Fetcher.new(LanguagePack::Base::VENDOR_URL, stack: stack)
+      else
+        LanguagePack::Fetcher.new(LanguagePack::Base::VENDOR_URL, stack: stack, arch: arch)
+      end
+    end
+  end
+
+  def supported_stack?(current_stack:)
+    @stacks.include?(current_stack)
+  end
+
+  def next_stack(current_stack:)
+    return unless supported_stack?(current_stack: current_stack)
+
+    next_index = @stacks.index(current_stack) + 1
+    @stacks[next_index]
+  end
+
+  def exists_on_next_stack?(current_stack:)
+    return false unless supported_stack?(current_stack: current_stack)
+
+    next_index = @stacks.index(current_stack) + 1
+    @threads[next_index].value
+  end
+
+  def valid_stack_list
+    raise "not invoked yet, use the `call` method first" if @threads.empty?
+
+    @threads.map.with_index do |thread, i|
+      @stacks[i] if thread.value
+    end.compact
+  end
+
+  def exists?
+    raise "not invoked yet, use the `call` method first" if @threads.empty?
+
+    @threads.any? { |t| t.value }
+  end
+
+  def does_not_exist?
+    !exists?
+  end
+
+  def call
+    @fetchers.map do |fetcher|
+      @threads << Thread.new do
+        fetcher.exists?(@file_name, 3)
+      end
+    end
+  end
+end
diff --git a/lib/language_pack/helpers/gemfile_lock.rb b/lib/language_pack/helpers/gemfile_lock.rb
new file mode 100644
index 000000000..c568b974e
--- /dev/null
+++ b/lib/language_pack/helpers/gemfile_lock.rb
@@ -0,0 +1,102 @@
+module LanguagePack
+  module Helpers
+    # Centralize logic for extracting information from the `Gemfile.lock` format
+    #
+    # - Extracts Ruby version from `RUBY VERSION`
+    # - Extracts Bundler version from `BUNDLED WITH`
+    #
+    # Example:
+    #
+    #   gemfile_lock = GemfileLock.new(contents: <<~EOF)
+    #     RUBY VERSION
+    #        ruby 3.3.5p100
+    #     BUNDLED WITH
+    #        2.3.4
+    #   EOF
+    #
+    #   expect(gemfile_lock.bundler.version).to eq("2.3.4")
+    #   expect(gemfile_lock.ruby.ruby_version).to eq("3.3.5")
+    class GemfileLock
+      attr_reader :ruby, :bundler, :contents
+
+      def initialize(contents:, report: HerokuBuildReport::GLOBAL)
+        @ruby = RubyVersionParse.new(contents: contents, report: report)
+        @bundler = BundlerVersionParse.new(contents: contents, report: report)
+        @contents = contents
+      end
+
+      # Holds information about the RUBY VERSION of the parsed Gemfile.lock
+      class RubyVersionParse
+        # Ruby version from Gemfile.lock i.e. `3.3.8`
+        # Either 3 numbers or nil
+        attr_reader :ruby_version,
+          # Contains pre-release info
+          # - String: i.e. "rc2" is a prerelease
+          # - nil: No pre-release (or no version at all)
+          :pre,
+          # Either :ruby or :jruby
+          :engine,
+          # `engine_version` is the JRuby version or for Ruby, it is the same as `ruby_version`
+          # i.e. `..`
+          :engine_version
+
+        def initialize(contents:, report: HerokuBuildReport::GLOBAL)
+          if (match = contents.match(/^RUBY VERSION(?:\r?\n) {2,3}ruby (?\d+\.\d+\.\d+)(?:(?:-|\.)(?
\S*))?/m))
+            @pre = match[:pre]
+            @empty = false
+            @ruby_version = match[:version]
+          else
+            if contents.match?(/RUBY VERSION/)
+              report.capture("gemfile_lock.ruby_version.failed_parse" => true)
+              if (match = contents.match(/(?RUBY VERSION(?:\r?\n).*)$/))
+                report.capture("gemfile_lock.ruby_version.failed_contents" => match[:contents])
+              end
+            end
+            @pre = nil
+            @empty = true
+            @ruby_version = nil
+          end
+
+          if (jruby = contents.to_s.match(/^RUBY VERSION(?:\r?\n) {2,3}ruby [^(]*\(jruby (?(?:\d+|\.)+)\)/m))
+            @engine = :jruby
+            @engine_version = jruby[:version]
+          else
+            @engine = :ruby
+            @engine_version = ruby_version
+          end
+        end
+
+        def empty?
+          @empty
+        end
+      end
+
+      class BundlerVersionParse
+        # Bundler value from `Gemfile.lock` (String or nil) i.e. `2.5.23`
+        attr_reader :version
+
+        def initialize(contents:, report: HerokuBuildReport::GLOBAL)
+          if (match = contents.match(/^BUNDLED WITH(?:\r?\n) {2,3}(?(?\d+)\.(?\d+)\.\d+)/m))
+            @empty = false
+            @version = match[:version]
+          else
+            if contents.match?(/BUNDLED WITH/)
+              report.capture("gemfile_lock.bundler_version.failed_parse" => true)
+              if (match = contents.match(/(?BUNDLED WITH(?:\r?\n).*)$/))
+                report.capture("gemfile_lock.bundler_version.failed_contents" => match[:contents])
+              end
+            end
+            @empty = true
+            @version = nil
+          end
+
+          report.capture("gemfile_lock.bundled_with.empty" => @empty)
+        end
+
+        def empty?
+          @empty
+        end
+      end
+    end
+  end
+end
diff --git a/lib/language_pack/helpers/lockfile_shell_parser.rb b/lib/language_pack/helpers/lockfile_shell_parser.rb
new file mode 100644
index 000000000..c6f964252
--- /dev/null
+++ b/lib/language_pack/helpers/lockfile_shell_parser.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require "json"
+require "tempfile"
+require "open3"
+
+module LanguagePack
+  module Helpers
+    # Parses gem specs from a Gemfile.lock file using Bundler::LockfileParser.
+    #
+    # This module encapsulates the logic for extracting gem names and versions
+    # from a lockfile, running the parsing in a subprocess to avoid polluting
+    # the current process's Bundler state.
+    #
+    # This can be called before gems are installed (but after Ruby and Bundler are installed).
+    # The output of this and `bundle list` (which can only be invoked after gems are installed)
+    # will disagree based on env vars such as `BUNDLE_WITHOUT`.
+    #
+    # This output will usually be a superset of `bundle list` output.
+    #
+    # Example:
+    #
+    #   specs = LockfileShellParser.call(lockfile_path: "/path/to/Gemfile.lock")
+    #   specs["rake"] # => #
+    #
+    module LockfileShellParser
+      RUBY_PARSER_CODE = <<~RUBY
+        require "json"
+        require "bundler"
+
+        specs = Bundler::LockfileParser.new(STDIN.read)
+          .specs
+          .each_with_object({}) {|spec, hash| hash[spec.name.to_s] = spec.version.to_s }
+        puts specs.to_json
+      RUBY
+
+      # Parses gem specs from a Gemfile.lock file path.
+      #
+      # @param lockfile_path [String, Pathname] Path to the Gemfile.lock file
+      # @return [Hash{String => Gem::Version}] Hash of gem names to their versions
+      #
+      # Example:
+      #
+      #   specs = LockfileShellParser.call(lockfile_path: "Gemfile.lock")
+      #   specs["rails"]    # => #
+      #   specs["nokogiri"] # => #
+      #
+      def self.call(lockfile_path:)
+        lockfile_path = Pathname(lockfile_path)
+        Tempfile.create(["lockfile_parser", ".rb"]) do |tempfile|
+          tempfile.write(RUBY_PARSER_CODE)
+          tempfile.flush
+
+          stdout, stderr, status = Open3.capture3("ruby", tempfile.path, stdin_data: lockfile_path.read(mode: "rt"))
+          if status.success?
+            JSON.parse(stdout).transform_values { |version| Gem::Version.new(version) }
+          else
+            raise <<~ERROR
+              Cannot parse `Gemfile.lock` file at path `#{lockfile_path}`
+
+              The Ruby buildpack runs a Ruby script that uses Bundler::LockfileParser to parse the lockfile of your application.
+              This information is needed to set environment variables based on requested gems such as `RAILS_ENV`
+              before gems are installed via `bundle install`.
+
+              This script failed to parse `#{lockfile_path}` and the buildpack cannot continue.
+
+              Debugging information:
+
+              status: #{status}
+              stdout: #{stdout}
+              stderr: #{stderr}
+            ERROR
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/language_pack/helpers/node_installer.rb b/lib/language_pack/helpers/node_installer.rb
new file mode 100644
index 000000000..27724eb63
--- /dev/null
+++ b/lib/language_pack/helpers/node_installer.rb
@@ -0,0 +1,30 @@
+class LanguagePack::Helpers::NodeInstaller
+  attr_reader :version
+
+  def initialize(arch:)
+    nodebin = LanguagePack::Helpers::Nodebin.node_lts(arch: arch)
+    @version = nodebin["number"]
+    @url = nodebin["url"]
+    @fetcher = LanguagePack::Fetcher.new("")
+  end
+
+  def binary_path
+    File.basename(@url).delete_suffix(".tar.gz")
+  end
+
+  def install
+    # Untar of this file produces artifacts that the app does not need to run.
+    # If we ran this command in the app directory, we would have to manually
+    # clean up un-used files. Instead we untar in a temp directory which
+    # helps us avoid accidentally deleting code out of the user's slug by mistake.
+    Dir.mktmpdir do |dir|
+      node_bin = "#{binary_path}/bin/node"
+
+      Dir.chdir(dir) do
+        @fetcher.fetch_untar(@url, node_bin)
+      end
+
+      FileUtils.mv("#{dir}/#{node_bin}", ".")
+    end
+  end
+end
diff --git a/lib/language_pack/helpers/nodebin.rb b/lib/language_pack/helpers/nodebin.rb
new file mode 100644
index 000000000..9156a1a98
--- /dev/null
+++ b/lib/language_pack/helpers/nodebin.rb
@@ -0,0 +1,29 @@
+require "json"
+
+class LanguagePack::Helpers::Nodebin
+  NODE_VERSION = "24.13.0"
+  YARN_VERSION = "1.22.22"
+
+  def self.hardcoded_node_lts(arch:)
+    arch = "x64" if arch == "amd64"
+    {
+      "number" => NODE_VERSION,
+      "url" => "https://nodejs.org/download/release/v#{NODE_VERSION}/node-v#{NODE_VERSION}-linux-#{arch}.tar.gz"
+    }
+  end
+
+  def self.hardcoded_yarn
+    {
+      "number" => YARN_VERSION,
+      "url" => "https://registry.npmjs.org/yarn/-/yarn-#{YARN_VERSION}.tgz"
+    }
+  end
+
+  def self.node_lts(arch:)
+    hardcoded_node_lts(arch: arch)
+  end
+
+  def self.yarn
+    hardcoded_yarn
+  end
+end
diff --git a/lib/language_pack/helpers/outdated_ruby_version.rb b/lib/language_pack/helpers/outdated_ruby_version.rb
new file mode 100644
index 000000000..82641e3e7
--- /dev/null
+++ b/lib/language_pack/helpers/outdated_ruby_version.rb
@@ -0,0 +1,182 @@
+# Queries S3 in the background to determine
+# what versions are supported so they can be recommended
+# to the user
+#
+# Example:
+#
+#   ruby_version = LanguagePack::RubyVersion.from_gemfile_lock(ruby: gemfile_lock.ruby)
+#   outdated = LanguagePack::Helpers::OutdatedRubyVersion.new(
+#     current_ruby_version: ruby_version,
+#     fetcher: LanguagePack::Fetcher.new(LanguagePack::Base::VENDOR_URL, stack: "heroku-22")
+#     fetcher: LanguagePack::Fetcher.new(LanguagePack::Base::VENDOR_URL, stack: "heroku-22", arch: "amd64")
+#   )
+#
+#   outdated.call
+#   puts outdated.suggested_ruby_minor_version
+#   #=> "ruby-2.2.10"
+class LanguagePack::Helpers::OutdatedRubyVersion
+  DEFAULT_RANGE = 1..5
+  attr_reader :current_ruby_version
+
+  def initialize(current_ruby_version:, fetcher:)
+    @current_ruby_version = current_ruby_version
+    @fetcher = fetcher
+    @already_joined = false
+
+    @minor_versions = []
+    @eol_versions = []
+
+    @minor_version_threads = []
+    @eol_version_threads = []
+
+    @suggested_eol_version = nil
+    @suggested_minor_version = nil
+  end
+
+  def can_check?
+    return false if current_ruby_version.jruby?
+
+    true
+  end
+
+  # Enqueues checks in the background
+  def call
+    return self unless can_check?
+    raise "Cannot `call()` twice" unless @minor_version_threads.empty?
+
+    check_minor_versions
+    check_eol_versions_major
+    check_eol_versions_minor
+    self
+  end
+
+  def join
+    return unless can_check?
+    return true if @already_joined
+    raise "Must initalize threads with `call()` before joining" if @minor_version_threads.empty?
+
+    @eol_versions = @eol_version_threads.map(&:value).compact
+    @minor_versions = @minor_version_threads.map(&:value).compact
+    @minor_versions << current_ruby_version.ruby_version
+
+    @suggested_minor_version = @minor_versions
+      .map { |v| v.sub("ruby-", "") }
+      .max_by { |v| Gem::Version.new(v) }
+
+    @suggested_eol_version = @eol_versions
+      .map { |v| v.sub("ruby-", "") }
+      .sort_by { |v| Gem::Version.new(v) }
+      .last(3)
+      .first
+
+    @already_joined = true
+  end
+
+  def suggested_ruby_minor_version
+    join
+    @suggested_minor_version
+  end
+
+  def latest_minor_version?
+    join
+    @suggested_minor_version == current_ruby_version.ruby_version
+  end
+
+  def eol?
+    join
+
+    @eol_versions.length > 3
+  end
+
+  # Account for preview releases
+  def maybe_eol?
+    join
+
+    @eol_versions.length > 2
+  end
+
+  def suggest_ruby_eol_version
+    return false unless maybe_eol?
+    @suggested_eol_version
+      .sub(/0$/, "x")
+  end
+
+  # Checks for a range of "tiny" versions in parallel
+  #
+  # For example if 2.5.0 is given it will check for the existance of
+  # - 2.5.1
+  # - 2.5.2
+  # - 2.5.3
+  # - 2.5.4
+  # - 2.5.5
+  #
+  # If the last elment in the series exists, it will continue to
+  # search by enqueuing additional numbers until the final
+  # value in the series is found
+  private def check_minor_versions(range: DEFAULT_RANGE, base_version: current_ruby_version, &block)
+    range.each do |i|
+      @minor_version_threads << Thread.new do
+        version = base_version.next_logical_version(i)
+        next if !@fetcher.exists?("#{version}.tgz")
+
+        if i == range.last
+          check_minor_versions(
+            range: Range.new(i + 1, i + i),
+            base_version: base_version
+          )
+        end
+
+        version
+      end
+    end
+  end
+
+  # Checks to see if 3 minor versions exist above current version
+  #
+  # for example 2.4.0 would check for existance of:
+  #   - 2.5.0
+  #   - 2.6.0
+  #   - 2.7.0
+  #   - 2.8.0
+  private def check_eol_versions_minor(range: DEFAULT_RANGE, base_version: current_ruby_version)
+    range.each do |i|
+      @eol_version_threads << Thread.new do
+        version = base_version.next_minor_version(i)
+
+        next if !@fetcher.exists?("#{version}.tgz")
+
+        if i == range.last
+          check_eol_versions_minor(
+            range: Range.new(i + 1, i + i),
+            base_version: base_version
+          )
+        end
+
+        version
+      end
+    end
+  end
+
+  # Checks to see if one major version exists above current version
+  # if it does, then it will check for minor versions of that version
+  #
+  # For checking 2.5. it would check for the existance of 3.0.0
+  #
+  # If 3.0.0 exists then it will check for:
+  #   - 3.1.0
+  #   - 3.2.0
+  #   - 3.3.0
+  private def check_eol_versions_major
+    @eol_version_threads << Thread.new do
+      version = current_ruby_version.next_major_version(1)
+
+      next if !@fetcher.exists?("#{version}.tgz")
+
+      check_eol_versions_minor(
+        base_version: LanguagePack::RubyVersion.default(last_version: version)
+      )
+
+      version
+    end
+  end
+end
diff --git a/lib/language_pack/helpers/plugin_installer.rb b/lib/language_pack/helpers/plugin_installer.rb
index 7c659b00a..bc4d4f5b4 100644
--- a/lib/language_pack/helpers/plugin_installer.rb
+++ b/lib/language_pack/helpers/plugin_installer.rb
@@ -9,7 +9,7 @@ class PluginsInstaller
       include LanguagePack::ShellHelpers
 
       def initialize(plugins, vendor_url = LanguagePack::Base::VENDOR_URL)
-        @plugins    = plugins || []
+        @plugins = plugins || []
         @vendor_url = vendor_url
       end
 
@@ -30,9 +30,13 @@ def vendor(name)
         return true if directory.exist?
         directory.mkpath
         Dir.chdir(directory) do |dir|
-          run("curl #{vendor_url}/#{name}.tgz -s -o - | tar xzf -")
+          run("curl #{vendor_url}/#{name}.tgz --no-progress-meter --fail --retry 3 --retry-connrefused --connect-timeout #{curl_connect_timeout_in_seconds} --output - | tar xzf -")
         end
       end
+
+      def curl_connect_timeout_in_seconds
+        env("CURL_CONNECT_TIMEOUT") || 3
+      end
     end
   end
 end
diff --git a/lib/language_pack/helpers/puma_warn_error.rb b/lib/language_pack/helpers/puma_warn_error.rb
new file mode 100644
index 000000000..017ec6b48
--- /dev/null
+++ b/lib/language_pack/helpers/puma_warn_error.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+# Checks for Puma specific warnings and errors
+class LanguagePack::Helpers::PumaWarnError
+  attr_reader :warnings, :error, :puma_version
+
+  def initialize(puma_version:, env:)
+    @warnings = []
+    @error = nil
+    @env = env
+    @puma_version = puma_version
+
+    warn_router_2_0_compatibility
+    error_persistent_timeout
+  end
+
+  private def warn_router_2_0_compatibility
+    return if @puma_version >= Gem::Version.new("7.0.3")
+    warnings << <<~WARNING
+      Heroku recommends using Puma 7.0.3+ for compatibility with Router 2.0
+
+      Please upgrade your application to Puma 7.0.3+ by running the following commands:
+
+      ```
+      $ gem install puma
+      $ bundle update puma
+      $ git add Gemfile.lock && git commit -m "Upgrade Puma to 7.0.3+"
+      ```
+    WARNING
+  end
+
+  private def error_persistent_timeout
+    return if @puma_version >= Gem::Version.new("7.0.3")
+    return if @puma_version < Gem::Version.new("7.0.0")
+    # If they manually set it, it is likely being used in their `config/puma.rb` file
+    return if @env["PUMA_PERSISTENT_TIMEOUT"]
+
+    @error = <<~ERROR
+      Your application is using Puma #{puma_version}.
+
+      This has a known issue with the `PUMA_PERSISTENT_TIMEOUT` environment variable.
+
+      This is fixed in Puma 7.0.3+ via https://github.com/puma/puma/pull/3749.
+      Please upgrade your application to Puma 7.0.3+ by running the following commands:
+
+      ```
+      $ gem install puma
+      $ bundle update puma
+      $ git add Gemfile.lock && git commit -m "Upgrade Puma to 7.0.3+"
+      ```
+    ERROR
+  end
+end
diff --git a/lib/language_pack/helpers/rails_runner.rb b/lib/language_pack/helpers/rails_runner.rb
new file mode 100644
index 000000000..a24092635
--- /dev/null
+++ b/lib/language_pack/helpers/rails_runner.rb
@@ -0,0 +1,138 @@
+# frozen_string_literal: true
+
+# This class is used for running `rails runner` against
+# apps, primarially for the intention of detecting configuration.
+#
+# The main benefit of this class is that multiple config
+# queries can be grouped together so the application only
+# has to be booted once. Calling `did_match` on a
+# RailsConfig object will trigger the `rails runner` command
+# to be executed.
+#
+# Example usage:
+#
+#    rails_config   = RailsRunner.new
+#    local_storage  = rails_config.detect("active_storage.service")
+#    assets_compile = rails_config.detect("assets.compile")
+#
+#    local_storage.success?             # => true
+#    local_storage.did_match?("local")  # => false
+#
+#    assets_compile.success?            # => true
+#    assets_compile.did_match?("false") # => true
+#
+class LanguagePack::Helpers::RailsRunner
+  # This class is used to help pull configuration values
+  # from a rails application. It takes in a configuration key
+  # and a reference to the parent RailsRunner object which
+  # allows it obtain the `rails runner` output and success
+  # status of the operation.
+  #
+  # For example:
+  #
+  #    config = RailsConfig.new("active_storage.service", rails_runner)
+  #    config.to_command # => "puts %Q{heroku.detecting.config.for.active_storage.service=Rails.application.config.try(:active_storage).try(:service)}; "
+  #
+  class RailsConfig
+    def initialize(config, rails_runner, options = {})
+      @config = config
+      @rails_runner = rails_runner
+      @debug = options[:debug]
+
+      @success = nil
+      @did_time_out = false
+      @heroku_key = "heroku.detecting.config.for.#{config}"
+
+      @rails_config = +'#{'
+      @rails_config << "Rails.application.config"
+      config.split(".").each do |part|
+        @rails_config << ".try(:#{part})"
+      end
+      @rails_config << "}"
+    end
+
+    def success?
+      @rails_runner.success? && @rails_runner.output =~ %r{#{@heroku_key}}
+    end
+
+    def did_match?(val)
+      @rails_runner.output =~ %r{#{@heroku_key}=#{val}}
+    end
+
+    def to_command
+      cmd = +"begin; "
+      cmd << "puts %Q{"
+      cmd << "#{@heroku_key}=#{@rails_config}"
+      cmd << "}; "
+      cmd << "rescue => e; "
+      cmd << "puts e; puts e.backtrace; " if @debug
+      cmd << "end;"
+      cmd
+    end
+  end
+
+  include LanguagePack::ShellHelpers
+
+  def initialize(debug = env("HEROKU_DEBUG_RAILS_RUNNER"), timeout = 65)
+    @command_array = []
+    @output = nil
+    @success = false
+    @debug = debug
+    @timeout_val = timeout # seconds
+  end
+
+  def detect(config_string)
+    config = RailsConfig.new(config_string, self, debug: @debug)
+    @command_array << config.to_command
+    config
+  end
+
+  def output
+    @output ||= call
+  end
+
+  def success?
+    output && @success
+  end
+
+  def command
+    %(rails runner "#{@command_array.join(" ")}")
+  end
+
+  def timeout?
+    @did_time_out
+  end
+
+  private
+
+  def call
+    topic("Detecting rails configuration")
+    puts "$ #{command}" if @debug
+    out = execute_command!
+    puts out if @debug
+    out
+  end
+
+  def execute_command!
+    process = ProcessSpawn.new(command,
+      user_env: true,
+      timeout: @timeout_val,
+      file: "./.heroku/ruby/config_detect/rails.txt")
+
+    @success = process.success?
+    @did_time_out = process.timeout?
+    out = process.output
+
+    if timeout?
+      message = +"Detecting rails configuration timeout\n"
+      message << "set HEROKU_DEBUG_RAILS_RUNNER=1 to debug" unless @debug
+      warn(message)
+    elsif !@success
+      message = +"Detecting rails configuration failed\n"
+      message << "set HEROKU_DEBUG_RAILS_RUNNER=1 to debug" unless @debug
+      warn(message)
+    end
+
+    out
+  end
+end
diff --git a/lib/language_pack/helpers/rake_runner.rb b/lib/language_pack/helpers/rake_runner.rb
index 84b9b2d40..82d2cdac3 100644
--- a/lib/language_pack/helpers/rake_runner.rb
+++ b/lib/language_pack/helpers/rake_runner.rb
@@ -1,21 +1,29 @@
 class LanguagePack::Helpers::RakeRunner
   include LanguagePack::ShellHelpers
 
+  class CannotLoadRakefileError < StandardError
+  end
+
   class RakeTask
     ALLOWED = [:pass, :fail, :no_load, :not_found]
     include LanguagePack::ShellHelpers
 
-    attr_accessor :output, :time, :task, :status, :task_defined, :rakefile_can_load
+    attr_accessor :output, :time, :task, :task_defined, :rakefile_can_load
+    attr_writer :status
 
-    alias :rakefile_can_load? :rakefile_can_load
-    alias :task_defined?      :task_defined
-    alias :is_defined?        :task_defined
+    alias_method :rakefile_can_load?, :rakefile_can_load
+    alias_method :task_defined?, :task_defined
+    alias_method :is_defined?, :task_defined
 
     def initialize(task, options = {})
-      @task            = task
+      @task = task
       @default_options = {user_env: true}.merge(options)
-      @status          = :nil
-      @output          = ""
+      @status = :nil
+      @output = ""
+    end
+
+    def not_defined?
+      !is_defined?
     end
 
     def success?
@@ -26,41 +34,39 @@ def status?
       @status && @status != :nil
     end
 
+    # Is set by RakeTask#invoke to one of the ALLOWED verbs
     def status
-      raise "Status not set for #{self.inspect}" if @status == :nil
-      raise "Not allowed status: #{@status} for #{self.inspect}" unless ALLOWED.include?(@status)
+      raise "Status not set for #{inspect}" if @status == :nil
+      raise "Not allowed status: #{@status} for #{inspect}" unless ALLOWED.include?(@status)
       @status
     end
 
     def invoke(options = {})
-      options      = @default_options.merge(options)
+      options = @default_options.merge(options)
       quiet_option = options.delete(:quiet)
 
       puts "Running: rake #{task}" unless quiet_option
       time = Benchmark.realtime do
-        cmd = "rake #{task}"
-
-        if quiet_option
-          self.output = run("rake #{task}", options)
+        self.output = if quiet_option
+          run("rake #{task}", options)
         else
-          self.output = pipe("rake #{task}", options)
+          pipe("rake #{task}", options)
         end
       end
       self.time = time
 
-      if $?.success?
-        self.status = :pass
+      self.status = if $?.success?
+        :pass
       else
-        self.status = :fail
+        :fail
       end
-      return self
+      self
     end
   end
 
-  def initialize(has_rake_gem = true)
-    @has_rake = has_rake_gem && has_rakefile?
-    if !@has_rake
-      @rake_tasks    = ""
+  def initialize
+    if !has_rake_installed?
+      @rake_tasks = ""
       @rakefile_can_load = false
     end
   end
@@ -73,33 +79,32 @@ def rakefile_can_load?
     @rakefile_can_load
   end
 
-  def instrument(*args, &block)
-    LanguagePack::Instrument.instrument(*args, &block)
+  def load_rake_tasks(options = {})
+    @rake_tasks ||= RakeTask.new("-P --trace").invoke(options.merge(quiet: true)).output
+    @rakefile_can_load ||= $?.success?
+    @rake_tasks
   end
 
-  def load_rake_tasks(options = {})
-    instrument "ruby.rake_task_defined" do
-      @rake_tasks        ||= RakeTask.new("-P --trace").invoke(options.merge(quiet: true)).output
-      @rakefile_can_load ||= $?.success?
-      @rake_tasks
+  def load_rake_tasks!(options = {}, raise_on_fail = false)
+    return if !has_rake_installed?
+
+    out = load_rake_tasks(options)
+
+    if cannot_load_rakefile?
+      msg = "Could not detect rake tasks\n"
+      msg << "ensure you can run `$ bundle exec rake -P` against your app\n"
+      msg << "and using the production group of your Gemfile.\n"
+      msg << out
+      raise CannotLoadRakefileError, msg if raise_on_fail
+      puts msg
     end
-  end
 
-  def load_rake_tasks!(options = {})
-    out =  load_rake_tasks(options)
-    msg =  "Could not detect rake tasks\n"
-    msg << "ensure you can run `$ bundle exec rake -P` against your app with no environment variables present\n"
-    msg << "and using the production group of your Gemfile.\n"
-    msg << "This may be intentional, if you expected rake tasks to be run\n"
-    msg << "cancel the build (CTRL+C) and fix the error then commit the fix:\n"
-    msg << out
-    puts msg if cannot_load_rakefile?
-    return self
+    self
   end
 
   def task_defined?(task)
     return false if cannot_load_rakefile?
-    @task_available ||= Hash.new {|hash, key| hash[key] = @rake_tasks.match(/\s#{key}\s/) }
+    @task_available ||= Hash.new { |hash, key| hash[key] = @rake_tasks.match(/\s#{key}\s/) }
     @task_available[task]
   end
 
@@ -109,7 +114,7 @@ def not_found?(task)
 
   def task(rake_task, options = {})
     t = RakeTask.new(rake_task, options)
-    t.task_defined      = task_defined?(rake_task)
+    t.task_defined = task_defined?(rake_task)
     t.rakefile_can_load = rakefile_can_load?
     t
   end
@@ -118,9 +123,11 @@ def invoke(task, options = {})
     self.task(task, options).invoke
   end
 
-private
+  def has_rake_installed?
+    @has_rake ||= has_rakefile?
+  end
 
-  def has_rakefile?
-    %W{ Rakefile rakefile  rakefile.rb Rakefile.rb}.detect {|file| File.exist?(file) }
+  private def has_rakefile?
+    %W[Rakefile rakefile rakefile.rb Rakefile.rb].detect { |file| File.exist?(file) }
   end
 end
diff --git a/lib/language_pack/helpers/stale_file_cleaner.rb b/lib/language_pack/helpers/stale_file_cleaner.rb
index 1f7d82658..a740e8da9 100644
--- a/lib/language_pack/helpers/stale_file_cleaner.rb
+++ b/lib/language_pack/helpers/stale_file_cleaner.rb
@@ -1,13 +1,13 @@
 class LanguagePack::Helpers::StaleFileCleaner
-  FILE_STAT_CACHE = Hash.new {|h, k| h[k] = File.stat(k) }
+  FILE_STAT_CACHE = Hash.new { |h, k| h[k] = File.stat(k) }
 
   def initialize(dir)
-    @dir  = dir
+    @dir = dir
     raise "need or dir" if @dir.nil?
   end
 
   def clean_over(limit) # limit in bytes
-    old_files_over(limit).each {|asset| FileUtils.rm(asset) }
+    old_files_over(limit).each { |asset| FileUtils.rm(asset) }
   end
 
   def glob
@@ -15,20 +15,19 @@ def glob
   end
 
   def files
-    @files ||= Dir[glob].reject {|file| File.directory?(file) }
+    @files ||= Dir[glob].reject { |file| File.directory?(file) }
   end
 
   def sorted_files
-    @sorted ||= files.sort_by {|a| FILE_STAT_CACHE[a].mtime }
+    @sorted ||= files.sort_by { |a| FILE_STAT_CACHE[a].mtime }
   end
 
   def total_size
-    @size   ||= sorted_files.inject(0) {|sum, asset| sum += FILE_STAT_CACHE[asset].size }
+    @size ||= sorted_files.inject(0) { |sum, asset| sum + FILE_STAT_CACHE[asset].size }
   end
 
-
   def old_files_over(limit)
     diff = total_size - limit
-    sorted_files.take_while {|asset| diff -= FILE_STAT_CACHE[asset].size if diff > 0 } || []
+    sorted_files.take_while { |asset| diff -= FILE_STAT_CACHE[asset].size if diff > 0 } || []
   end
 end
diff --git a/lib/language_pack/helpers/yarn_installer.rb b/lib/language_pack/helpers/yarn_installer.rb
new file mode 100644
index 000000000..e83b4c30c
--- /dev/null
+++ b/lib/language_pack/helpers/yarn_installer.rb
@@ -0,0 +1,30 @@
+class LanguagePack::Helpers::YarnInstaller
+  attr_reader :version
+
+  def initialize
+    # Grab latest yarn, until release practice stabilizes
+    # https://github.com/yarnpkg/yarn/issues/376#issuecomment-253366910
+    nodebin = LanguagePack::Helpers::Nodebin.yarn
+    @version = nodebin["number"]
+    @url = nodebin["url"]
+    @fetcher = LanguagePack::Fetcher.new("")
+  end
+
+  def name
+    "yarn-v#{@version}"
+  end
+
+  def binary_path
+    "#{name}/bin/"
+  end
+
+  def install
+    Dir.mktmpdir do |dir|
+      Dir.chdir(dir) do
+        @fetcher.fetch_untar(@url, strip_components: 1)
+      end
+
+      FileUtils.cp_r(dir, name)
+    end
+  end
+end
diff --git a/lib/language_pack/installers/heroku_ruby_installer.rb b/lib/language_pack/installers/heroku_ruby_installer.rb
new file mode 100644
index 000000000..9add9c886
--- /dev/null
+++ b/lib/language_pack/installers/heroku_ruby_installer.rb
@@ -0,0 +1,109 @@
+require "language_pack/base"
+require "language_pack/shell_helpers"
+
+module LanguagePack::Installers; end
+
+class LanguagePack::Installers::HerokuRubyInstaller
+  BASE_URL = LanguagePack::Base::VENDOR_URL
+
+  include LanguagePack::ShellHelpers
+
+  attr_reader :fetcher, :environment
+
+  def initialize(stack:, amd_only_stacks:, arch:, app_path:, env:, report: HerokuBuildReport::GLOBAL)
+    @report = report
+    @environment = env
+    @app_path = Pathname.new(app_path).expand_path
+    @fetcher = self.class.fetcher(amd_only_stacks: amd_only_stacks, stack: stack, arch: arch)
+  end
+
+  def self.fetcher(amd_only_stacks:, stack:, arch:)
+    if amd_only_stacks.include?(stack)
+      LanguagePack::Fetcher.new(BASE_URL, stack: stack)
+    else
+      LanguagePack::Fetcher.new(BASE_URL, stack: stack, arch: arch)
+    end
+  end
+
+  def install(ruby_version, install_dir)
+    install_dir = Pathname.new(install_dir).expand_path
+    @report.capture(
+      # i.e. `jruby` or `ruby`
+      "ruby_version_engine" => ruby_version.engine,
+      # i.e. `ruby-3.4.2-jruby-10.0.2.0` or `ruby-3.4.2` or `ruby-3.5.0.pre1`
+      "ruby_version_unique" => ruby_version.version_for_download,
+      # i.e. `default` or `Gemfile.lock`
+      "ruby_version_origin" => ruby_version.default? ? "default" : "Gemfile.lock"
+    )
+
+    case ruby_version.engine
+    when :ruby
+      @report.capture(
+        "ruby_version_major_minor" => "#{ruby_version.major}.#{ruby_version.minor}",
+        "ruby_version_full" => ruby_version.engine_version_full
+      )
+    when :jruby
+      @report.capture(
+        # i.e. `3.4.2` the target spec ruby version
+        "jruby_version_ruby_version" => ruby_version.ruby_version,
+        # i.e. `10.0.2.0` the version of jruby
+        "jruby_version_full" => ruby_version.engine_version_full,
+        # i.e. `9.4` or `10.0`
+        "jruby_version_major_minor" => [
+          ruby_version.engine_version.split(".")[0],
+          ruby_version.engine_version.split(".")[1]
+        ].join(".")
+      )
+    else
+      raise "Internal error: Unknown engine: #{ruby_version.engine}"
+    end
+
+    fetch_unpack(ruby_version, install_dir)
+    setup_binstubs(install_dir)
+
+    if ruby_version.jruby?
+      environment["JRUBY_OPTS"] = environment["JRUBY_BUILD_OPTS"] ||
+        user_env_hash["JRUBY_BUILD_OPTS"] ||
+        environment["JRUBY_OPTS"] ||
+        user_env_hash["JRUBY_OPTS"]
+    end
+    # Ruby binstub path
+    environment["PATH"] = [install_dir.join("bin"), environment["PATH"]].compact.join(":")
+  end
+
+  def fetch_unpack(ruby_version, install_dir)
+    FileUtils.mkdir_p(install_dir)
+    Dir.chdir(install_dir) do
+      @fetcher.fetch_untar("#{ruby_version.version_for_download}.tgz")
+    end
+  end
+
+  private def setup_binstubs(install_dir)
+    bin_dir = @app_path.join("bin")
+    bin_dir.mkpath
+    run("ln -s ruby #{install_dir}/bin/ruby.exe")
+
+    Dir["#{install_dir}/bin/*"].each do |vendor_bin|
+      # for Ruby 2.6.0+ don't symlink the Bundler bin so our shim works
+      next if vendor_bin.include?("bundle")
+
+      # The bin/rake binstub generated when compiling ruby does not load bundler
+      # which can cause unexpected failures. Deleting this binstub allows two things:
+      #
+      #   - If the app includes a custom binstub allows it to be used
+      #   - If the app does not include a custom binstub, then it will fall back to vendor/bundle/bin/rake
+      #     which is generated by bundler
+      #
+      # Discussion: https://github.com/heroku/heroku-buildpack-ruby/issues/1025#issuecomment-653102430
+      next if vendor_bin.include?("rake")
+
+      # Calculate relative path from bin_dir to vendor_bin so symlinks work
+      # at runtime when the app moves from /tmp/build_xxx to /app
+      vendor_bin_path = Pathname.new(vendor_bin).expand_path
+      relative_path = vendor_bin_path.relative_path_from(bin_dir)
+      bin_name = File.basename(vendor_bin)
+
+      run("ln -s #{relative_path} #{bin_dir.join(bin_name)}")
+    end
+  end
+end
diff --git a/lib/language_pack/instrument.rb b/lib/language_pack/instrument.rb
deleted file mode 100644
index ccc7397ef..000000000
--- a/lib/language_pack/instrument.rb
+++ /dev/null
@@ -1,69 +0,0 @@
-require 'benchmark'
-require 'stringio'
-require 'lpxc'
-require 'date'
-require 'language_pack/ruby'
-
-module LanguagePack
-  module Instrument
-    def self.bench_msg(message, level = 0, start_time, end_time, duration, build_id, buildpack_version)
-      out.puts "measure.#{message}.start=#{start_time} measure.#{message}.end=#{end_time} measure.#{message}.duration=#{duration} measure.#{message}.level=#{level} measure.#{message}.build_id=#{build_id} request_id=#{request_id} measure.#{message}.buildpack_version=#{buildpack_version} measure.#{message}.buildpack=#{buildpack_name} "
-    end
-
-    def self.instrument(cat, title = "", *args)
-      ret        = nil
-      start_time = DateTime.now.iso8601(6)
-      duration = Benchmark.realtime do
-        yield_with_block_depth do
-          ret = yield
-        end
-      end
-      end_time   = DateTime.now.iso8601(6)
-      bench_msg(cat, block_depth, start_time, end_time, duration, build_id, buildpack_version)
-
-      ret
-    end
-
-    def self.out
-      Thread.current[:out] ||= ENV['LOGPLEX_DEFAULT_TOKEN'] ? Lpxc.new(batch_size: 1) : StringIO.new
-    end
-
-    def self.trace(name, *args, &blk)
-      ret         = nil
-      block_depth = 0
-
-      instrument(name) { blk.call }
-    end
-
-    def self.yield_with_block_depth
-      self.block_depth += 1
-      yield
-    ensure
-      self.block_depth -= 1
-    end
-
-    def self.block_depth
-      Thread.current[:block_depth] || 0
-    end
-
-    def self.block_depth=(value)
-      Thread.current[:block_depth] = value
-    end
-
-    def self.build_id
-      ENV['REQUEST_ID'] || ENV['SLUG_ID']
-    end
-
-    def self.request_id
-      ENV['REQUEST_ID']
-    end
-
-    def self.buildpack_version
-      LanguagePack::Ruby::BUILDPACK_VERSION
-    end
-
-    def self.buildpack_name
-      LanguagePack::Ruby::NAME
-    end
-  end
-end
diff --git a/lib/language_pack/metadata.rb b/lib/language_pack/metadata.rb
index 3694159a6..ad98e9793 100644
--- a/lib/language_pack/metadata.rb
+++ b/lib/language_pack/metadata.rb
@@ -1,35 +1,49 @@
 require "language_pack"
 require "language_pack/base"
 
+# Store data about the build in the cache
+#
+# Uses `/vendor/heroku` as the metadata directory. Which
+# is special cased in cache clearing code to be durable. This allows
+# for persistant generated data such as SECRET_KEY_BASE that would otherwise
+# cause session invalidation if it changed unexpectedly between deploys.
 class LanguagePack::Metadata
-  FOLDER = "vendor/heroku"
+  def initialize(cache_path:)
+    @dir = Pathname(cache_path)
+      .join("vendor")
+      .join("heroku")
+      .tap(&:mkpath)
+  end
+
+  def empty?
+    @dir.children.empty?
+  end
 
-  def initialize(cache)
-    if cache
-      @cache = cache
-      @cache.load FOLDER
+  def try_read(key)
+    if exists?(key)
+      read(key)
     end
   end
 
   def read(key)
-    full_key = "#{FOLDER}/#{key}"
-    File.read(full_key) if exists?(key)
+    @dir.join(key).read&.strip
   end
-  
+
   def exists?(key)
-    full_key = "#{FOLDER}/#{key}"
-    File.exists?(full_key) && !Dir.exists?(full_key)
+    @dir.join(key).file?
   end
 
-  def write(key, value, isave = true)
-    FileUtils.mkdir_p(FOLDER)
-
-    full_key = "#{FOLDER}/#{key}"
-    File.open(full_key, 'w') {|f| f.puts value }
-    save if isave
+  def write(key, value)
+    @dir.join(key).write(value)
   end
 
-  def save
-    @cache ? @cache.store(FOLDER) : false
+  def fetch(key)
+    if exists?(key)
+      read(key)
+    else
+      value = yield
+      write(key, value.to_s)
+      value
+    end
   end
 end
diff --git a/lib/language_pack/no_lockfile.rb b/lib/language_pack/no_lockfile.rb
deleted file mode 100644
index 7a52af279..000000000
--- a/lib/language_pack/no_lockfile.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-require "language_pack"
-require "language_pack/base"
-
-class LanguagePack::NoLockfile < LanguagePack::Base
-  def self.use?
-    !File.exists?("Gemfile.lock")
-  end
-
-  def name
-    "Ruby/NoLockfile"
-  end
-
-  def compile
-    error "Gemfile.lock required. Please check it in."
-  end
-end
diff --git a/lib/language_pack/rack.rb b/lib/language_pack/rack.rb
index 19ef59f09..eb973a30d 100644
--- a/lib/language_pack/rack.rb
+++ b/lib/language_pack/rack.rb
@@ -3,47 +3,24 @@
 
 # Rack Language Pack. This is for any non-Rails Rack apps like Sinatra.
 class LanguagePack::Rack < LanguagePack::Ruby
-
   # detects if this is a valid Rack app by seeing if "config.ru" exists
   # @return [Boolean] true if it's a Rack app
-  def self.use?
-    instrument "rack.use" do
-      bundler.gem_version('rack')
-    end
+  def self.use?(bundler:)
+    bundler.gem_version("rack")
   end
 
   def name
     "Ruby/Rack"
   end
 
-  def default_config_vars
-    instrument "rack.default_config_vars" do
-      super.merge({
-        "RACK_ENV" => "production"
-      })
-    end
-  end
-
   def default_process_types
-    instrument "rack.default_process_types" do
-      # let's special case thin here if we detect it
-      web_process = bundler.has_gem?("thin") ?
-        "bundle exec thin start -R config.ru -e $RACK_ENV -p $PORT" :
-        "bundle exec rackup config.ru -p $PORT"
-
-      super.merge({
-        "web" => web_process
-      })
-    end
+    # let's special case thin here if we detect it
+    web_process = bundler.has_gem?("thin") ?
+      "bundle exec thin start -R config.ru -e $RACK_ENV -p ${PORT:-5000}" :
+      "bundle exec rackup config.ru -p ${PORT:-5000}"
+
+    super.merge({
+      "web" => web_process
+    })
   end
-
-private
-
-  # sets up the profile.d script for this buildpack
-  def setup_profiled
-    super
-    set_env_default "RACK_ENV", "production"
-  end
-
 end
-
diff --git a/lib/language_pack/rails2.rb b/lib/language_pack/rails2.rb
index 64b0f62e8..0b3b84759 100644
--- a/lib/language_pack/rails2.rb
+++ b/lib/language_pack/rails2.rb
@@ -6,71 +6,59 @@
 class LanguagePack::Rails2 < LanguagePack::Ruby
   # detects if this is a valid Rails 2 app
   # @return [Boolean] true if it's a Rails 2 app
-  def self.use?
-    instrument "rails2.use" do
-      rails_version = bundler.gem_version('rails')
-      return false unless rails_version
-      is_rails2 = rails_version >= Gem::Version.new('2.0.0') &&
-                  rails_version <  Gem::Version.new('3.0.0')
-      return is_rails2
-    end
+  def self.use?(bundler:)
+    rails_version = bundler.gem_version("rails")
+    return false unless rails_version
+    rails_version >= Gem::Version.new("2.0.0") &&
+      rails_version < Gem::Version.new("3.0.0")
   end
 
-  def name
-    "Ruby/Rails"
+  def initialize(...)
+    super
+    @rails_runner = LanguagePack::Helpers::RailsRunner.new
   end
 
-  def default_config_vars
-    instrument "rails2.default_config_vars" do
-      super.merge({
-        "RAILS_ENV" => "production",
-        "RACK_ENV" => "production"
-      })
-    end
+  def name
+    "Ruby/Rails"
   end
 
   def default_process_types
-    instrument "rails2.default_process_types" do
-      web_process = bundler.has_gem?("thin") ?
-        "bundle exec thin start -e $RAILS_ENV -p $PORT" :
-        "bundle exec ruby script/server -p $PORT"
+    web_process = bundler.has_gem?("thin") ?
+      "bundle exec thin start -e $RAILS_ENV -p ${PORT:-5000}" :
+      "bundle exec ruby script/server -p ${PORT:-5000}"
 
-      super.merge({
-        "web" => web_process,
-        "worker" => "bundle exec rake jobs:work",
-        "console" => "bundle exec script/console"
-      })
-    end
+    process_types = super
+    process_types["web"] = web_process
+    process_types["worker"] = "bundle exec rake jobs:work" if has_jobs_work_task?
+    process_types["console"] = "bundle exec script/console"
+    process_types
   end
 
   def compile
-    instrument "rails2.compile" do
-      install_plugins
-      super
-    end
+    install_plugins
+    super
   end
 
-private
-
-  def install_plugins
-    instrument "rails2.install_plugins" do
-      plugins = ["rails_log_stdout"].reject { |plugin| bundler.has_gem?(plugin) }
-      topic "Rails plugin injection"
-      LanguagePack::Helpers::PluginsInstaller.new(plugins).install
+  def best_practice_warnings
+    if env("RAILS_ENV") != "production"
+      warn(<<~WARNING)
+        You are deploying to a non-production environment: #{env("RAILS_ENV").inspect}.
+        This is not recommended.
+        See https://devcenter.heroku.com/articles/deploying-to-a-custom-rails-environment for more information.
+      WARNING
     end
+    super
   end
 
-  # most rails apps need a database
-  # @return [Array] shared database addon
-  def add_dev_database_addon
-    ['heroku-postgresql:hobby-dev']
-  end
+  private
 
-  # sets up the profile.d script for this buildpack
-  def setup_profiled
-    super
-    set_env_default "RACK_ENV",  "production"
-    set_env_default "RAILS_ENV", "production"
+  def has_jobs_work_task?
+    rake.task("jobs:work").is_defined?
   end
 
+  def install_plugins
+    plugins = ["rails_log_stdout"].reject { |plugin| bundler.has_gem?(plugin) }
+    topic "Rails plugin injection"
+    LanguagePack::Helpers::PluginsInstaller.new(plugins).install
+  end
 end
diff --git a/lib/language_pack/rails3.rb b/lib/language_pack/rails3.rb
index 685a438f7..4cdcb3eb7 100644
--- a/lib/language_pack/rails3.rb
+++ b/lib/language_pack/rails3.rb
@@ -5,14 +5,11 @@
 class LanguagePack::Rails3 < LanguagePack::Rails2
   # detects if this is a Rails 3.x app
   # @return [Boolean] true if it's a Rails 3.x app
-  def self.use?
-    instrument "rails3.use" do
-      rails_version = bundler.gem_version('railties')
-      return false unless rails_version
-      is_rails3 = rails_version >= Gem::Version.new('3.0.0') &&
-                  rails_version <  Gem::Version.new('4.0.0')
-      return is_rails3
-    end
+  def self.use?(bundler:)
+    rails_version = bundler.gem_version("railties")
+    return false unless rails_version
+    rails_version >= Gem::Version.new("3.0.0") &&
+      rails_version < Gem::Version.new("4.0.0")
   end
 
   def name
@@ -20,93 +17,181 @@ def name
   end
 
   def default_process_types
-    instrument "rails3.default_process_types" do
-      # let's special case thin here
-      web_process = bundler.has_gem?("thin") ?
-        "bundle exec thin start -R config.ru -e $RAILS_ENV -p $PORT" :
-        "bundle exec rails server -p $PORT"
-
-      super.merge({
-        "web" => web_process,
-        "console" => "bundle exec rails console"
-      })
-    end
+    # let's special case thin here
+    web_process = bundler.has_gem?("thin") ?
+      "bundle exec thin start -R config.ru -e $RAILS_ENV -p ${PORT:-5000}" :
+      "bundle exec rails server -p ${PORT:-5000}"
+
+    super.merge({
+      "web" => web_process,
+      "console" => "bundle exec rails console"
+    })
+  end
+
+  def rake_env
+    {
+      "RAILS_ENV" => @environment_name,
+      "RACK_ENV" => @environment_name,
+      "RAILS_GROUPS" => "assets"
+    }.merge(super)
   end
 
   def compile
-    instrument "rails3.compile" do
-      super
+    super
+  end
+
+  def config_detect
+    super
+    @assets_compile_config = @rails_runner.detect("assets.compile")
+    @x_sendfile_config = @rails_runner.detect("action_dispatch.x_sendfile_header")
+  end
+
+  def best_practice_warnings
+    super
+    warn_x_sendfile_use!
+
+    if assets_compile_enabled?
+      safe_sprockets_version_needed = sprocket_version_upgrade_needed
+      if safe_sprockets_version_needed
+        message = <<~ERROR
+          A security vulnerability has been detected in your application.
+          To protect your application you must take action. Your application
+          is currently exposing its credentials via an easy to exploit directory
+          traversal.
+          
+          To protect your application you must either upgrade to Sprockets version "#{safe_sprockets_version_needed}"
+          or disable dynamic compilation at runtime by setting:
+          
+          ```
+          config.assets.compile = false # Disables security vulnerability
+          ```
+          
+          To read more about this security vulnerability please refer to this blog post:
+            https://blog.heroku.com/rails-asset-pipeline-vulnerability
+          
+        ERROR
+        error(message)
+      end
+
+      warn(<<~WARNING)
+        You set your `config.assets.compile = true` in production.
+        This can negatively impact the performance of your application.
+
+        For more information can be found in this article:
+          https://devcenter.heroku.com/articles/rails-asset-pipeline#compile-set-to-true-in-production
+
+      WARNING
+    end
+  end
+
+  private
+
+  def warn_x_sendfile_use!
+    return false unless @x_sendfile_config.success?
+    if @x_sendfile_config.did_match?("X-Sendfile") && !has_apache? # Apache
+      warn(<<~WARNING)
+        You set `config.action_dispatch.x_sendfile_header = 'X-Sendfile'` in production,
+        but you do not have `apache` installed on this app. This setting will cause any assets
+        being served by your application to be returned without a body.
+
+        To fix this issue, please set:
+
+        ```
+        config.action_dispatch.x_sendfile_header = nil
+        ```
+      WARNING
     end
+
+    if @x_sendfile_config.did_match?("X-Accel-Redirect") && !has_nginx? # Nginx
+      warn(<<~WARNING)
+        You set `config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect'` in production,
+        but you do not have `nginx` installed on this app. This setting will cause any assets
+        being served by your application to be returned without a body.
+
+        To fix this issue, please set:
+
+        ```
+        config.action_dispatch.x_sendfile_header = nil
+        ```
+      WARNING
+    end
+  end
+
+  def has_apache?
+    path = run("command -v apachectl")
+    return true if path && $?.success?
+    false
   end
 
-private
+  def has_nginx?
+    path = run("command -v nginx")
+    return true if path && $?.success?
+    false
+  end
+
+  def sprocket_version_upgrade_needed
+    # Due to https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2018-3760
+    sprockets_version = bundler.gem_version("sprockets")
+    if sprockets_version.nil?
+      false
+    elsif sprockets_version < Gem::Version.new("2.12.5")
+      "2.12.5"
+    elsif sprockets_version > Gem::Version.new("3") &&
+        sprockets_version < Gem::Version.new("3.7.2")
+      "3.7.2"
+    elsif sprockets_version > Gem::Version.new("4") &&
+        sprockets_version < Gem::Version.new("4.0.0.beta8")
+      "4.0.0.beta8"
+    else
+      false
+    end
+  end
+
+  def assets_compile_enabled?
+    return false unless @assets_compile_config.success?
+    @assets_compile_config.did_match?("true")
+  end
 
   def install_plugins
-    instrument "rails3.install_plugins" do
-      return false if bundler.has_gem?('rails_12factor')
-      plugins = {"rails_log_stdout" => "rails_stdout_logging", "rails3_serve_static_assets" => "rails_serve_static_assets" }.
-                 reject { |plugin, gem| bundler.has_gem?(gem) }
-      return false if plugins.empty?
-      plugins.each do |plugin, gem|
-        warn "Injecting plugin '#{plugin}'"
-      end
-      warn "Add 'rails_12factor' gem to your Gemfile to skip plugin injection"
-      LanguagePack::Helpers::PluginsInstaller.new(plugins.keys).install
+    return false if bundler.has_gem?("rails_12factor")
+    plugins = {"rails_log_stdout" => "rails_stdout_logging", "rails3_serve_static_assets" => "rails_serve_static_assets"}
+      .reject { |plugin, gem| bundler.has_gem?(gem) }
+    return false if plugins.empty?
+    plugins.each do |plugin, gem|
+      warn "Injecting plugin '#{plugin}'"
     end
+    warn "Add 'rails_12factor' gem to your Gemfile to skip plugin injection"
+    LanguagePack::Helpers::PluginsInstaller.new(plugins.keys).install
   end
 
   # runs the tasks for the Rails 3.1 asset pipeline
   def run_assets_precompile_rake_task
-    instrument "rails3.run_assets_precompile_rake_task" do
-      log("assets_precompile") do
-        if File.exists?("public/assets/manifest.yml")
-          puts "Detected manifest.yml, assuming assets were compiled locally"
-          return true
-        end
-
-        precompile = rake.task("assets:precompile")
-        return true unless precompile.is_defined?
-
-        topic("Preparing app for Rails asset pipeline")
-
-        precompile.invoke(env: rake_env)
-
-        if precompile.success?
-          log "assets_precompile", :status => "success"
-          puts "Asset precompilation completed (#{"%.2f" % precompile.time}s)"
-        else
-          log "assets_precompile", :status => "failure"
-          error "Precompiling assets failed."
-        end
-      end
+    if File.exist?("public/assets/manifest.yml")
+      puts "Detected manifest.yml, assuming assets were compiled locally"
+      return true
     end
-  end
 
-  def rake_env
-    if user_env_hash.empty?
-      default_env = {
-        "RAILS_GROUPS" => ENV["RAILS_GROUPS"] || "assets",
-        "RAILS_ENV"    => ENV["RAILS_ENV"]    || "production",
-        "DATABASE_URL" => database_url
-      }
+    precompile = rake.task("assets:precompile")
+    return true unless precompile.is_defined?
+
+    topic("Preparing app for Rails asset pipeline")
+
+    precompile.invoke(env: rake_env)
+
+    if precompile.success?
+      puts "Asset precompilation completed (#{"%.2f" % precompile.time}s)"
     else
-      default_env = {
-        "RAILS_GROUPS" => "assets",
-        "RAILS_ENV"    => "production",
-        "DATABASE_URL" => database_url
-      }
+      precompile_fail(precompile.output)
     end
-    default_env.merge(user_env_hash)
   end
 
   # generate a dummy database_url
   def database_url
-    instrument "rails3.setup_database_url_env" do
-      # need to use a dummy DATABASE_URL here, so rails can load the environment
-      return env("DATABASE_URL") if env("DATABASE_URL")
-      scheme =
-        if bundler.has_gem?("pg") || bundler.has_gem?("jdbc-postgres")
-          "postgres"
+    # need to use a dummy DATABASE_URL here, so rails can load the environment
+    return env("DATABASE_URL") if env("DATABASE_URL")
+    scheme =
+      if bundler.has_gem?("pg") || bundler.has_gem?("jdbc-postgres")
+        "postgres"
       elsif bundler.has_gem?("mysql")
         "mysql"
       elsif bundler.has_gem?("mysql2")
@@ -114,6 +199,8 @@ def database_url
       elsif bundler.has_gem?("sqlite3") || bundler.has_gem?("sqlite3-ruby")
         "sqlite3"
       end
+
+    if scheme
       "#{scheme}://user:pass@127.0.0.1/dbname"
     end
   end
diff --git a/lib/language_pack/rails4.rb b/lib/language_pack/rails4.rb
index 04626b633..7b019fd91 100644
--- a/lib/language_pack/rails4.rb
+++ b/lib/language_pack/rails4.rb
@@ -7,14 +7,11 @@ class LanguagePack::Rails4 < LanguagePack::Rails3
 
   # detects if this is a Rails 4.x app
   # @return [Boolean] true if it's a Rails 4.x app
-  def self.use?
-    instrument "rails4.use" do
-      rails_version = bundler.gem_version('railties')
-      return false unless rails_version
-      is_rails4 = rails_version >= Gem::Version.new('4.0.0.beta') &&
-                  rails_version <  Gem::Version.new('4.1.0.beta1')
-      return is_rails4
-    end
+  def self.use?(bundler:)
+    rails_version = bundler.gem_version("railties")
+    return false unless rails_version
+    rails_version >= Gem::Version.new("4.0.0.beta") &&
+      rails_version < Gem::Version.new("5.x")
   end
 
   def name
@@ -22,40 +19,28 @@ def name
   end
 
   def default_process_types
-    instrument "rails4.default_process_types" do
-      super.merge({
-        "web"     => "bin/rails server -p $PORT -e $RAILS_ENV",
-        "console" => "bin/rails console"
-      })
-    end
-  end
-
-  def build_bundler
-    instrument "rails4.build_bundler" do
-      super
-    end
+    super.merge({
+      "web" => "bin/rails server -p ${PORT:-5000} -e $RAILS_ENV",
+      "console" => "bin/rails console"
+    })
   end
 
   def compile
-    instrument "rails4.compile" do
-      super
-    end
+    super
   end
 
   private
 
   def install_plugins
-    instrument "rails4.install_plugins" do
-      return false if bundler.has_gem?('rails_12factor')
-      plugins = ["rails_serve_static_assets", "rails_stdout_logging"].reject { |plugin| bundler.has_gem?(plugin) }
-      return false if plugins.empty?
-
-    warn <<-WARNING
-Include 'rails_12factor' gem to enable all platform features
-See https://devcenter.heroku.com/articles/rails-integration-gems for more information.
-WARNING
+    return false if bundler.has_gem?("rails_12factor")
+    plugins = ["rails_serve_static_assets", "rails_stdout_logging"].reject { |plugin| bundler.has_gem?(plugin) }
+    return false if plugins.empty?
+
+    warn <<~WARNING
+      Include 'rails_12factor' gem to enable all platform features
+      See https://devcenter.heroku.com/articles/rails-integration-gems for more information.
+    WARNING
     # do not install plugins, do not call super
-    end
   end
 
   def public_assets_folder
@@ -66,45 +51,47 @@ def default_assets_cache
     "tmp/cache/assets"
   end
 
+  def cleanup
+    super
+    return if assets_compile_enabled?
+    return unless Dir.exist?(default_assets_cache)
+    FileUtils.remove_dir(default_assets_cache)
+  end
+
   def run_assets_precompile_rake_task
-    instrument "rails4.run_assets_precompile_rake_task" do
-      log("assets_precompile") do
-        if Dir.glob('public/assets/manifest-*.json').any?
-          puts "Detected manifest file, assuming assets were compiled locally"
-          return true
-        end
+    if Dir.glob("public/assets/{.sprockets-manifest-*.json,manifest-*.json}", File::FNM_DOTMATCH).any?
+      puts "Detected manifest file, assuming assets were compiled locally"
+      return true
+    end
 
-        precompile = rake.task("assets:precompile")
-        return true unless precompile.is_defined?
+    precompile = rake.task("assets:precompile")
+    return true if precompile.not_defined?
 
-        topic("Preparing app for Rails asset pipeline")
+    topic("Preparing app for Rails asset pipeline")
 
-        @cache.load public_assets_folder
-        @cache.load default_assets_cache
+    @cache.load_without_overwrite public_assets_folder
+    @cache.load default_assets_cache
 
-        precompile.invoke(env: rake_env)
+    precompile.invoke(env: rake_env)
 
-        if precompile.success?
-          log "assets_precompile", :status => "success"
-          puts "Asset precompilation completed (#{"%.2f" % precompile.time}s)"
+    if precompile.success?
+      puts "Asset precompilation completed (#{"%.2f" % precompile.time}s)"
 
-          puts "Cleaning assets"
-          rake.task("assets:clean").invoke(env: rake_env)
+      clean_task = rake.task("assets:clean")
+      if clean_task.task_defined?
+        puts "Cleaning assets"
+        clean_task.invoke(env: rake_env)
 
-          cleanup_assets_cache
-          @cache.store public_assets_folder
-          @cache.store default_assets_cache
-        else
-          log "assets_precompile", :status => "failure"
-          error "Precompiling assets failed."
-        end
+        cleanup_assets_cache
+        @cache.store public_assets_folder
+        @cache.store default_assets_cache
       end
+    else
+      precompile_fail(precompile.output)
     end
   end
 
   def cleanup_assets_cache
-    instrument "rails4.cleanup_assets_cache" do
-      LanguagePack::Helpers::StaleFileCleaner.new(default_assets_cache).clean_over(ASSETS_CACHE_LIMIT)
-    end
+    LanguagePack::Helpers::StaleFileCleaner.new(default_assets_cache).clean_over(ASSETS_CACHE_LIMIT)
   end
 end
diff --git a/lib/language_pack/rails41.rb b/lib/language_pack/rails41.rb
deleted file mode 100644
index 924924a4b..000000000
--- a/lib/language_pack/rails41.rb
+++ /dev/null
@@ -1,51 +0,0 @@
-require 'securerandom'
-require "language_pack"
-require "language_pack/rails4"
-
-class LanguagePack::Rails41 < LanguagePack::Rails4
-  # detects if this is a Rails 4.x app
-  # @return [Boolean] true if it's a Rails 4.x app
-  def self.use?
-    instrument "rails4.use" do
-      rails_version = bundler.gem_version('railties')
-      return false unless rails_version
-      is_rails4 = rails_version >= Gem::Version.new('4.1.0.beta1') &&
-                  rails_version <  Gem::Version.new('5.0.0')
-      return is_rails4
-    end
-  end
-
-  def create_database_yml
-    instrument 'ruby.create_database_yml' do
-    end
-  end
-
-  def setup_profiled
-    instrument 'setup_profiled' do
-      super
-      set_env_default "SECRET_KEY_BASE", app_secret
-    end
-  end
-
-  def default_config_vars
-    super.merge({
-      "SECRET_KEY_BASE" => app_secret
-    })
-  end
-
-  private
-  def app_secret
-    key = "secret_key_base"
-
-    @app_secret ||= begin
-      if @metadata.exists?(key)
-        @metadata.read(key).chomp
-      else
-        secret = SecureRandom.hex(64)
-        @metadata.write(key, secret)
-
-        secret
-      end
-    end
-  end
-end
diff --git a/lib/language_pack/rails5.rb b/lib/language_pack/rails5.rb
new file mode 100644
index 000000000..cee936e39
--- /dev/null
+++ b/lib/language_pack/rails5.rb
@@ -0,0 +1,16 @@
+require "securerandom"
+require "language_pack"
+
+class LanguagePack::Rails5 < LanguagePack::Rails4
+  # @return [Boolean] true if it's a Rails 5.x app
+  def self.use?(bundler:)
+    rails_version = bundler.gem_version("railties")
+    return false unless rails_version
+    rails_version >= Gem::Version.new("5.x") &&
+      rails_version < Gem::Version.new("6.0.0")
+  end
+
+  def install_plugins
+    # do not install plugins, do not call super, do not warn
+  end
+end
diff --git a/lib/language_pack/rails6.rb b/lib/language_pack/rails6.rb
new file mode 100644
index 000000000..5ab4e9a45
--- /dev/null
+++ b/lib/language_pack/rails6.rb
@@ -0,0 +1,18 @@
+require "securerandom"
+require "language_pack"
+require "language_pack/rails5"
+
+class LanguagePack::Rails6 < LanguagePack::Rails5
+  # @return [Boolean] true if it's a Rails 6.x app
+  def self.use?(bundler:)
+    rails_version = bundler.gem_version("railties")
+    return false unless rails_version
+    rails_version >= Gem::Version.new("6.x") &&
+      rails_version < Gem::Version.new("7.a")
+  end
+
+  def compile
+    FileUtils.mkdir_p("tmp/pids")
+    super
+  end
+end
diff --git a/lib/language_pack/rails7.rb b/lib/language_pack/rails7.rb
new file mode 100644
index 000000000..ec8a23448
--- /dev/null
+++ b/lib/language_pack/rails7.rb
@@ -0,0 +1,13 @@
+require "securerandom"
+require "language_pack"
+require "language_pack/rails6"
+
+class LanguagePack::Rails7 < LanguagePack::Rails6
+  # @return [Boolean] true if it's a Rails 7.x app
+  def self.use?(bundler:)
+    rails_version = bundler.gem_version("railties")
+    return false unless rails_version
+    rails_version >= Gem::Version.new("7.a") &&
+      rails_version < Gem::Version.new("8.a")
+  end
+end
diff --git a/lib/language_pack/rails8.rb b/lib/language_pack/rails8.rb
new file mode 100644
index 000000000..8f1606f4c
--- /dev/null
+++ b/lib/language_pack/rails8.rb
@@ -0,0 +1,13 @@
+require "securerandom"
+require "language_pack"
+require "language_pack/rails7"
+
+class LanguagePack::Rails8 < LanguagePack::Rails7
+  # @return [Boolean] true if it's a Rails 8.x app
+  def self.use?(bundler:)
+    rails_version = bundler.gem_version("railties")
+    return false unless rails_version
+    rails_version >= Gem::Version.new("8.a") &&
+      rails_version < Gem::Version.new("9.a")
+  end
+end
diff --git a/lib/language_pack/ruby.rb b/lib/language_pack/ruby.rb
index 3615252bc..ed080380d 100644
--- a/lib/language_pack/ruby.rb
+++ b/lib/language_pack/ruby.rb
@@ -5,43 +5,28 @@
 require "language_pack"
 require "language_pack/base"
 require "language_pack/ruby_version"
+require "language_pack/helpers/nodebin"
+require "language_pack/helpers/node_installer"
+require "language_pack/helpers/yarn_installer"
+require "language_pack/helpers/binstub_check"
+require "language_pack/helpers/bundle_binstub_check"
 require "language_pack/version"
 
 # base Ruby Language Pack. This is for any base ruby app.
 class LanguagePack::Ruby < LanguagePack::Base
-  NAME                 = "ruby"
-  LIBYAML_VERSION      = "0.1.5"
-  LIBYAML_PATH         = "libyaml-#{LIBYAML_VERSION}"
-  BUNDLER_VERSION      = "1.5.2"
-  BUNDLER_GEM_PATH     = "bundler-#{BUNDLER_VERSION}"
-  NODE_VERSION         = "0.4.7"
-  NODE_JS_BINARY_PATH  = "node-#{NODE_VERSION}"
-  JVM_BASE_URL         = "http://heroku-jdk.s3.amazonaws.com"
-  LATEST_JVM_VERSION   = "openjdk7-latest"
-  LEGACY_JVM_VERSION   = "openjdk1.7.0_25"
-  DEFAULT_RUBY_VERSION = "ruby-2.0.0"
-  RBX_BASE_URL         = "http://binaries.rubini.us/heroku"
+  NAME = "ruby"
+  NODE_BP_PATH = "vendor/node/bin"
 
   # detects if this is a valid Ruby app
   # @return [Boolean] true if it's a Ruby app
-  def self.use?
-    instrument "ruby.use" do
-      File.exist?("Gemfile")
-    end
-  end
-
-  def self.bundler
-    @bundler ||= LanguagePack::Helpers::BundlerWrapper.new.install
-  end
-
-  def bundler
-    self.class.bundler
+  def self.use?(bundler: nil)
+    File.exist?("Gemfile")
   end
 
-  def initialize(build_path, cache_path=nil)
-    super(build_path, cache_path)
-    @fetchers[:jvm] = LanguagePack::Fetcher.new(JVM_BASE_URL)
-    @fetchers[:rbx] = LanguagePack::Fetcher.new(RBX_BASE_URL)
+  def initialize(...)
+    super
+    @node_installer = LanguagePack::Helpers::NodeInstaller.new(arch: @arch)
+    @yarn_installer = LanguagePack::Helpers::YarnInstaller.new
   end
 
   def name
@@ -49,142 +34,255 @@ def name
   end
 
   def default_addons
-    instrument "ruby.default_addons" do
-      add_dev_database_addon
-    end
+    add_dev_database_addon
   end
 
-  def default_config_vars
-    instrument "ruby.default_config_vars" do
-      vars = {
-        "LANG"     => "en_US.UTF-8",
-      }
-
-      ruby_version.jruby? ? vars.merge({
-        "JAVA_OPTS" => default_java_opts,
-        "JRUBY_OPTS" => default_jruby_opts,
-        "JAVA_TOOL_OPTIONS" => default_java_tool_options
-      }) : vars
+  # Environment variable defaults that are passed to ENV, `export` (for future buildpacks), and `.profile.d` (for launch/runtime)
+  #
+  # All values returned must be sourced from Heroku. User provided config vars
+  # are handled in the interfaces that consume this method's result.
+  #
+  # @param environment_name [String] the environment name to use for RACK_ENV/RAILS_ENV
+  # @return [Hash] the ENV var like result
+  def self.default_config_vars(metadata:, ruby_version:, bundler:, environment_name:)
+    @app_secret ||= if metadata.exists?("secret_key_base")
+      metadata.read("secret_key_base").strip
+    else
+      SecureRandom.hex(64).tap { |secret| metadata.write("secret_key_base", secret) }
     end
+
+    LanguagePack::Helpers::DefaultEnvVars.call(
+      is_jruby: ruby_version.jruby?,
+      rack_version: bundler.gem_version("rack"),
+      rails_version: bundler.gem_version("railties"),
+      secret_key_base: @app_secret,
+      environment_name: environment_name
+    )
   end
 
   def default_process_types
-    instrument "ruby.default_process_types" do
-      {
-        "rake"    => "bundle exec rake",
-        "console" => "bundle exec irb"
-      }
+    {
+      "rake" => "bundle exec rake",
+      "console" => "bundle exec irb"
+    }
+  end
+
+  def best_practice_warnings
+    if bundler.has_gem?("asset_sync")
+      warn(<<~WARNING)
+        You are using the `asset_sync` gem.
+        This is not recommended.
+        See https://devcenter.heroku.com/articles/please-do-not-use-asset-sync for more information.
+      WARNING
     end
   end
 
   def compile
-    instrument 'ruby.compile' do
-      # check for new app at the beginning of the compile
-      new_app?
-      Dir.chdir(build_path)
-      remove_vendor_bundle
-      install_ruby
-      install_jvm
-      setup_language_pack_environment
-      setup_profiled
-      allow_git do
-        install_bundler_in_app
-        build_bundler
-        create_database_yml
-        install_binaries
-        run_assets_precompile_rake_task
+    @outdated_version_check = LanguagePack::Helpers::OutdatedRubyVersion.new(
+      current_ruby_version: ruby_version,
+      fetcher: LanguagePack::Installers::HerokuRubyInstaller.fetcher(amd_only_stacks: AMD_ONLY_STACKS, stack: stack, arch: @arch)
+    ).call
+
+    @warn_io.warnings.each { |warning| warnings << warning }
+
+    post_bundler(ruby_version: @ruby_version, app_path: app_path)
+    create_database_yml
+    install_binaries
+    run_assets_precompile_rake_task
+    @report.capture(
+      "gem.railties_version" => bundler.gem_version("railties"),
+      "gem.rack_version" => bundler.gem_version("rack")
+    )
+    if (puma_version = bundler.gem_version("puma"))
+      @report.capture(
+        "gem.puma_version" => puma_version
+      )
+
+      puma_warn_error = LanguagePack::Helpers::PumaWarnError.new(
+        puma_version: puma_version,
+        env: user_env_hash
+      )
+
+      error(puma_warn_error.error) if puma_warn_error.error
+      puma_warn_error.warnings.each do |warning|
+        warn(warning)
       end
-      super
     end
+    config_detect
+    best_practice_warnings
+    warn_outdated_ruby
+    default_config_vars = self.class.default_config_vars(metadata: @metadata, ruby_version: @ruby_version, bundler: bundler, environment_name: environment_name)
+    setup_profiled(ruby_layer_path: "$HOME", gem_layer_path: "$HOME", ruby_version: @ruby_version, default_config_vars: default_config_vars, report: @report) # $HOME is set to /app at run time
+    setup_export(app_path: app_path, ruby_version: @ruby_version, default_config_vars: default_config_vars)
+    cleanup
+    super
+  rescue => e
+    warn_outdated_ruby
+    raise e
   end
 
-private
-
-  # the base PATH environment variable to be used
-  # @return [String] the resulting PATH
-  def default_path
-    # need to remove bin/ folder since it links
-    # to the wrong --prefix ruby binstubs
-    # breaking require. This only applies to Ruby 1.9.2 and 1.8.7.
-    safe_binstubs = binstubs_relative_paths - ["bin"]
-    paths         = [
-      ENV["PATH"],
-      "bin",
-      system_paths,
-    ]
-    paths.unshift("#{slug_vendor_jvm}/bin") if ruby_version.jruby?
-    paths.unshift(safe_binstubs)
+  def cleanup
+  end
 
-    paths.join(":")
+  def config_detect
   end
 
-  def binstubs_relative_paths
-    [
-      "bin",
-      bundler_binstubs_path,
-      "#{slug_vendor_base}/bin"
-    ]
+  # Runs `bundle list` and optionally streams the result to the user
+  #
+  # Streaming helps with build log visibility i.e. "what version of X" am I using at a glance.
+  #
+  # Checks if the information from `bundle list` matches information collected from bundler internals
+  # if not, emits the difference. The goal is to eventually replace requiring bundler internals with
+  # information retrieved from `bundle list`.
+  def self.bundle_list(stream_to_user:, io:, report: HerokuBuildReport::GLOBAL)
+    LanguagePack::Helpers::BundleList::HumanCommand.new(
+      io: io,
+      stream_to_user: stream_to_user
+    ).call
   end
 
-  def system_paths
-    "/usr/local/bin:/usr/bin:/bin"
+  private
+
+  # A bad shebang line looks like this:
+  #
+  # ```
+  # #!/usr/bin/env ruby2.5
+  # ```
+  #
+  # Since `ruby2.5` is not a valid binary name
+  #
+  def self.warn_bad_binstubs(app_path:, warn_object:)
+    check = LanguagePack::Helpers::BinstubCheck.new(
+      warn_object: warn_object,
+      app_root_dir: app_path
+    )
+    check.call
   end
 
-  # the relative path to the bundler directory of gems
-  # @return [String] resulting path
-  def slug_vendor_base
-    instrument 'ruby.slug_vendor_base' do
-      if @slug_vendor_base
-        @slug_vendor_base
-      elsif ruby_version.ruby_version == "1.8.7"
-        @slug_vendor_base = "vendor/bundle/1.8"
-      else
-        @slug_vendor_base = run_no_pipe(%q(ruby -e "require 'rbconfig';puts \"vendor/bundle/#{RUBY_ENGINE}/#{RbConfig::CONFIG['ruby_version']}\"")).chomp
-        error "Problem detecting bundler vendor directory: #{@slug_vendor_base}" unless $?.success?
-        @slug_vendor_base
-      end
-    end
+  def self.warn_bundle_binstub(app_path:, warn_object:)
+    check = LanguagePack::Helpers::BundleBinstubCheck.new(
+      warn_object: warn_object,
+      app_root_dir: app_path
+    )
+    check.call
   end
 
-  # the relative path to the vendored ruby directory
-  # @return [String] resulting path
-  def slug_vendor_ruby
-    "vendor/#{ruby_version.version_without_patchlevel}"
+  def default_malloc_arena_max?
+    return true if @metadata.exists?("default_malloc_arena_max")
+    return @metadata.write("default_malloc_arena_max", "true") if new_app?
+
+    false
   end
 
-  # the relative path to the vendored jvm
-  # @return [String] resulting path
-  def slug_vendor_jvm
-    "vendor/jvm"
+  def self.warn_bundler_upgrade(metadata:, bundler_version:)
+    old_bundler_version = metadata.read("bundler_version").strip if metadata.exists?("bundler_version")
+
+    if old_bundler_version && old_bundler_version != bundler_version
+      warn(<<~WARNING, inline: true)
+        Your bundler version changed from `#{old_bundler_version}` to `#{bundler_version}`.
+
+        If you see problems related to the new bundler version, you can revert to the previous version
+        by updating the `BUNDLED WITH` value in your `Gemfile.lock`. For example:
+
+        ```
+        BUNDLED WITH
+           #{old_bundler_version}
+        ```
+
+        Commit the results to git before redeploying:
+
+        ```
+        $ git add Gemfile.lock
+        $ git commit -m "Revert to previous bundler version"
+        ```
+      WARNING
+    end
   end
 
-  # the absolute path of the build ruby to use during the buildpack
-  # @return [String] resulting path
-  def build_ruby_path
-    "/tmp/#{ruby_version.version_without_patchlevel}"
+  def self.install_ruby_path(app_path:, ruby_version:)
+    app_path.join("vendor").join(ruby_version.version_for_download)
   end
 
   # fetch the ruby version from bundler
   # @return [String, nil] returns the ruby version if detected or nil if none is detected
   def ruby_version
-    instrument 'ruby.ruby_version' do
-      return @ruby_version if @ruby_version
-      new_app           = !File.exist?("vendor/heroku")
-      last_version_file = "buildpack_ruby_version"
-      last_version      = nil
-      last_version      = @metadata.read(last_version_file).chomp if @metadata.exists?(last_version_file)
+    @ruby_version or raise "Internal error: @ruby_version is not set. Call `get_ruby_version` and set @ruby_version"
+  end
 
-      @ruby_version = LanguagePack::RubyVersion.new(bundler,
-        is_new:       new_app,
-        last_version: last_version)
-      return @ruby_version
+  def self.get_ruby_version(metadata:, gemfile_lock:, dot_ruby_version_result: nil, report: HerokuBuildReport::GLOBAL)
+    lockfile_ruby_version = LanguagePack::RubyVersion.from_gemfile_lock(
+      ruby: gemfile_lock.ruby,
+      last_version: metadata.try_read("buildpack_ruby_version")
+    )
+    report.capture(
+      "gemfile_lock.ruby_version.version" => lockfile_ruby_version.ruby_version,
+      "gemfile_lock.ruby_version.engine" => lockfile_ruby_version.engine,
+      "gemfile_lock.ruby_version.engine.version" => lockfile_ruby_version.engine_version,
+      "gemfile_lock.ruby_version.major" => lockfile_ruby_version.major,
+      "gemfile_lock.ruby_version.minor" => lockfile_ruby_version.minor,
+      "gemfile_lock.ruby_version.patch" => lockfile_ruby_version.patch,
+      "gemfile_lock.ruby_version.default" => lockfile_ruby_version.default?
+    )
+
+    if dot_ruby_version_result&.ruby_version
+      report.capture("dot_ruby_version.version" => dot_ruby_version_result.ruby_version.ruby_version)
+
+      if !lockfile_ruby_version.default?
+        report.capture(
+          "dot_ruby_version.vs_gemfile_lock" =>
+            case Gem::Version.new(dot_ruby_version_result.ruby_version.ruby_version) <=> Gem::Version.new(lockfile_ruby_version.ruby_version)
+            when 0 then "match"
+            when 1 then "dot_ruby_version_higher"
+            when -1 then "gemfile_lock_higher"
+            end
+        )
+      end
     end
+
+    lockfile_ruby_version
   end
 
-  # default JAVA_OPTS
-  # return [String] string of JAVA_OPTS
-  def default_java_opts
-    "-Xmx384m -Xss512k -XX:+UseCompressedOops -Dfile.encoding=UTF-8"
+  def set_default_web_concurrency
+    warn(<<~WARNING)
+      Your application is using an undocumented feature SENSIBLE_DEFAULTS
+
+      This feature is not supported and may be removed at any time. Please remove the SENSIBLE_DEFAULTS environment variable from your app.
+
+      $ heroku config:unset SENSIBLE_DEFAULTS
+
+      To configure your application's web concurrency, use the WEB_CONCURRENCY environment variable following this documentation:
+
+      - https://devcenter.heroku.com/articles/deploying-rails-applications-with-the-puma-web-server#recommended-default-puma-process-and-thread-configuration
+      - https://devcenter.heroku.com/articles/h12-request-timeout-in-ruby-mri#puma-pool-usage
+      - https://help.heroku.com/88G3XLA6/what-is-an-acceptable-amount-of-dyno-load
+    WARNING
+
+    <<~EOF
+      case $(ulimit -u) in
+      256)
+        export HEROKU_RAM_LIMIT_MB=${HEROKU_RAM_LIMIT_MB:-512}
+        export WEB_CONCURRENCY=${WEB_CONCURRENCY:-2}
+        export WEB_CONCURRENCY_SET_BY=heroku/ruby
+        ;;
+      512)
+        export HEROKU_RAM_LIMIT_MB=${HEROKU_RAM_LIMIT_MB:-1024}
+        export WEB_CONCURRENCY=${WEB_CONCURRENCY:-4}
+        export WEB_CONCURRENCY_SET_BY=heroku/ruby
+        ;;
+      16384)
+        export HEROKU_RAM_LIMIT_MB=${HEROKU_RAM_LIMIT_MB:-2560}
+        export WEB_CONCURRENCY=${WEB_CONCURRENCY:-8}
+        export WEB_CONCURRENCY_SET_BY=heroku/ruby
+        ;;
+      32768)
+        export HEROKU_RAM_LIMIT_MB=${HEROKU_RAM_LIMIT_MB:-6144}
+        export WEB_CONCURRENCY=${WEB_CONCURRENCY:-16}
+        export WEB_CONCURRENCY_SET_BY=heroku/ruby
+        ;;
+      *)
+        ;;
+      esac
+    EOF
   end
 
   # default JRUBY_OPTS
@@ -193,469 +291,450 @@ def default_jruby_opts
     "-Xcompile.invokedynamic=false"
   end
 
-  # default JAVA_TOOL_OPTIONS
-  # return [String] string of JAVA_TOOL_OPTIONS
-  def default_java_tool_options
-    "-Djava.rmi.server.useCodebaseOnly=true"
-  end
+  # sets up the environment variables for the build process
+  def self.setup_language_pack_environment(app_path:, ruby_version:, bundle_default_without:, default_config_vars:, user_env_hash:)
+    # By default Node can address 1.5GB of memory, a limitation it inherits from
+    # the underlying v8 engine. This can occasionally cause issues during frontend
+    # builds where memory use can exceed this threshold.
+    #
+    # This passes an argument to all Node processes during the build, so that they
+    # can take advantage of all available memory on the build dynos.
+    ENV["NODE_OPTIONS"] ||= "--max_old_space_size=2560"
+
+    default_config_vars.each do |key, value|
+      ENV[key] ||= value
+    end
 
-  # list the available valid ruby versions
-  # @note the value is memoized
-  # @return [Array] list of Strings of the ruby versions available
-  def ruby_versions
-    return @ruby_versions if @ruby_versions
+    paths = []
+    ENV["GEM_PATH"] = app_path.join(ruby_version.bundler_directory).to_s
+    ENV["GEM_HOME"] = ENV["GEM_PATH"]
 
-    Dir.mktmpdir("ruby_versions-") do |tmpdir|
-      Dir.chdir(tmpdir) do
-        @fetchers[:buildpack].fetch("ruby_versions.yml")
-        @ruby_versions = YAML::load_file("ruby_versions.yml")
-      end
-    end
+    ENV["DISABLE_SPRING"] = "1"
 
-    @ruby_versions
-  end
+    # Rails has a binstub for yarn that doesn't work for all applications
+    # we need to ensure that yarn comes before local bin dir for that case
+    paths << yarn_preinstall_bin_path if yarn_preinstalled?
+    paths << app_path.join("bin").to_s
+    paths << app_path.join("vendor/bundle/bin").to_s # Binstubs from bundler, eg. vendor/bundle/bin
+    paths << app_path.join(ruby_version.bundler_directory).join("bin").to_s  # Binstubs from rubygems, eg. vendor/bundle/ruby/2.6.0/bin
+    paths << ENV["PATH"]
 
-  # sets up the environment variables for the build process
-  def setup_language_pack_environment
-    instrument 'ruby.setup_language_pack_environment' do
-      setup_ruby_install_env
-
-      # TODO when buildpack-env-args rolls out, we can get rid of
-      # ||= and the manual setting below
-      config_vars = default_config_vars.each do |key, value|
-        ENV[key] ||= value
-      end
+    ENV["PATH"] = paths.join(":")
 
-      ENV["GEM_PATH"] = slug_vendor_base
-      ENV["GEM_HOME"] = slug_vendor_base
-      ENV["PATH"]     = default_path
+    ENV["BUNDLE_WITHOUT"] ||= user_env_hash["BUNDLE_WITHOUT"] || bundle_default_without
+    if ENV["BUNDLE_WITHOUT"].include?(" ")
+      ENV["BUNDLE_WITHOUT"] = ENV["BUNDLE_WITHOUT"].tr(" ", ":")
+
+      warn("Your BUNDLE_WITHOUT contains a space, we are converting it to a colon `:` BUNDLE_WITHOUT=#{ENV["BUNDLE_WITHOUT"]}", inline: true)
     end
+    ENV["BUNDLE_PATH"] = "vendor/bundle"
+    ENV["BUNDLE_BIN"] = "vendor/bundle/bin"
+    ENV["BUNDLE_DEPLOYMENT"] = "1"
   end
 
-  # sets up the profile.d script for this buildpack
-  def setup_profiled
-    instrument 'setup_profiled' do
-      set_env_override "GEM_PATH", "$HOME/#{slug_vendor_base}:$GEM_PATH"
-      set_env_default  "LANG",     "en_US.UTF-8"
-      set_env_override "PATH",     binstubs_relative_paths.map {|path| "$HOME/#{path}" }.join(":") + ":$PATH"
-
-      if ruby_version.jruby?
-        set_env_default "JAVA_OPTS", default_java_opts
-        set_env_default "JRUBY_OPTS", default_jruby_opts
-        set_env_default "JAVA_TOOL_OPTIONS", default_java_tool_options
-      end
+  # Sets up the environment variables for subsequent processes run by
+  # muiltibuildpack. We can't use profile.d because $HOME isn't set up
+  def setup_export(app_path:, ruby_version:, default_config_vars:)
+    paths = ENV["PATH"].split(":").map do |path|
+      (/^\/.*/ !~ path) ? "#{app_path}/#{path}" : path
+    end.join(":")
+
+    # TODO ensure path exported is correct
+    set_export_path "PATH", paths
+
+    gem_path = app_path.join(ruby_version.bundler_directory)
+    set_export_path "GEM_PATH", gem_path
+    set_export_default "LANG", "en_US.UTF-8"
+
+    # TODO handle jruby
+    if ruby_version.jruby?
+      set_export_default "JRUBY_OPTS", default_jruby_opts
+    end
+
+    set_export_default "BUNDLE_PATH", ENV["BUNDLE_PATH"]
+    set_export_default "BUNDLE_WITHOUT", ENV["BUNDLE_WITHOUT"]
+    set_export_default "BUNDLE_BIN", ENV["BUNDLE_BIN"]
+    set_export_default "BUNDLE_DEPLOYMENT", ENV["BUNDLE_DEPLOYMENT"] # Unset on windows since we delete the Gemfile.lock
+    default_config_vars.each do |key, value|
+      set_export_default key, value
     end
   end
 
-  # install the vendored ruby
-  # @return [Boolean] true if it installs the vendored ruby and false otherwise
-  def install_ruby
-    instrument 'ruby.install_ruby' do
-      return false unless ruby_version
-
-      invalid_ruby_version_message = < web_concurrency.empty?)
+    add_to_profiled(web_concurrency, filename: "WEB_CONCURRENCY.sh", mode: "w") # always write that file, even if its empty (meaning no defaults apply), for interop with other buildpacks - and we overwrite the file rather than appending (which is the default)
 
-      topic "Using Ruby version: #{ruby_version.version}"
-      if !ruby_version.set
-        warn(<= Gem::Version.new("1.7.4")
-            LATEST_JVM_VERSION
-          else
-            LEGACY_JVM_VERSION
-          end
+  def warn_stack_upgrade
+    return unless defined?(@ruby_download_check)
+    return unless @ruby_download_check.next_stack(current_stack: stack)
+    return if @ruby_download_check.exists_on_next_stack?(current_stack: stack)
 
-        topic "Installing JVM: #{jvm_version}"
+    warn(<<~WARNING)
+      Your Ruby version is not present on the next stack
 
-        FileUtils.mkdir_p(slug_vendor_jvm)
-        Dir.chdir(slug_vendor_jvm) do
-          @fetchers[:jvm].fetch_untar("#{jvm_version}.tar.gz")
-        end
+      You are currently using #{ruby_version.version_for_download} on #{stack} stack.
+      This version does not exist on #{@ruby_download_check.next_stack(current_stack: stack)}. In order to upgrade your stack you will
+      need to upgrade to a supported Ruby version.
 
-        bin_dir = "bin"
-        FileUtils.mkdir_p bin_dir
-        Dir["#{slug_vendor_jvm}/bin/*"].each do |bin|
-          run("ln -s ../#{bin} #{bin_dir}")
-        end
-      end
-    end
-  end
+      For a list of supported Ruby versions see:
+        https://devcenter.heroku.com/articles/ruby-support#supported-runtimes
 
-  # find the ruby install path for its binstubs during build
-  # @return [String] resulting path or empty string if ruby is not vendored
-  def ruby_install_binstub_path
-    @ruby_install_binstub_path ||=
-      if ruby_version.build?
-        "#{build_ruby_path}/bin"
-      elsif ruby_version
-        "#{slug_vendor_ruby}/bin"
-      else
-        ""
-      end
+      For a list of the oldest Ruby versions present on a given stack see:
+        https://devcenter.heroku.com/articles/ruby-support#oldest-available-runtimes
+    WARNING
   end
 
-  # setup the environment so we can use the vendored ruby
-  def setup_ruby_install_env
-    instrument 'ruby.setup_ruby_install_env' do
-      ENV["PATH"] = "#{ruby_install_binstub_path}:#{ENV["PATH"]}"
+  def warn_outdated_eol
+    return unless @outdated_version_check.maybe_eol?
 
-      if ruby_version.jruby?
-        ENV['JAVA_OPTS']  = default_java_opts
-      end
+    if @outdated_version_check.eol?
+      warn(<<~WARNING)
+        EOL Ruby Version
+
+        You are using a Ruby version that has reached its End of Life (EOL)
+
+        We strongly suggest you upgrade to Ruby #{@outdated_version_check.suggest_ruby_eol_version} or later
+
+        Your current Ruby version no longer receives security updates from
+        Ruby Core and may have serious vulnerabilities. While you will continue
+        to be able to deploy on Heroku with this Ruby version you must upgrade
+        to a non-EOL version to be eligible to receive support.
+
+        Upgrade your Ruby version as soon as possible.
+
+        For a list of supported Ruby versions see:
+          https://devcenter.heroku.com/articles/ruby-support#supported-runtimes
+      WARNING
+    else
+      # Maybe EOL
+      warn(<<~WARNING)
+        Potential EOL Ruby Version
+
+        You are using a Ruby version that has either reached its End of Life (EOL)
+        or will reach its End of Life on December 25th of this year.
+
+        We suggest you upgrade to Ruby #{@outdated_version_check.suggest_ruby_eol_version} or later
+
+        Once a Ruby version becomes EOL, it will no longer receive
+        security updates from Ruby core and may have serious vulnerabilities.
+
+        Please upgrade your Ruby version.
+
+        For a list of supported Ruby versions see:
+          https://devcenter.heroku.com/articles/ruby-support#supported-runtimes
+      WARNING
     end
   end
 
-  # installs vendored gems into the slug
-  def install_bundler_in_app
-    instrument 'ruby.install_language_pack_gems' do
-      FileUtils.mkdir_p(slug_vendor_base)
-      Dir.chdir(slug_vendor_base) do |dir|
-        `cp -R #{bundler.bundler_path}/. .`
+  def warn_outdated_minor
+    return if @outdated_version_check.latest_minor_version?
+
+    warn(<<~WARNING)
+      There is a more recent Ruby version available for you to use:
+
+      #{@outdated_version_check.suggested_ruby_minor_version}
+
+      The latest version will include security and bug fixes. We always recommend
+      running the latest version of your minor release.
+
+      Please upgrade your Ruby version.
+
+      For all available Ruby versions see:
+        https://devcenter.heroku.com/articles/ruby-support#supported-runtimes
+    WARNING
+  end
+
+  # install the vendored ruby
+  # @return [Boolean] true if it installs the vendored ruby and false otherwise
+  def self.install_ruby(app_path:, ruby_version:, stack:, arch:, metadata:, io:)
+    # Could do a compare operation to avoid re-downloading ruby
+    return false unless ruby_version
+
+    installer = LanguagePack::Installers::HerokuRubyInstaller.new(
+      amd_only_stacks: AMD_ONLY_STACKS,
+      stack: stack,
+      arch: arch,
+      app_path: app_path,
+      env: ENV
+    )
+
+    @ruby_download_check = LanguagePack::Helpers::DownloadPresence.new(
+      amd_only_stacks: AMD_ONLY_STACKS,
+      file_name: ruby_version.file_name,
+      arch: arch
+    )
+    @ruby_download_check.call
+
+    installer.install(ruby_version, install_ruby_path(app_path: app_path, ruby_version: ruby_version))
+
+    metadata.write("buildpack_ruby_version", ruby_version.version_for_download)
+
+    io.topic "Using Ruby version: #{ruby_version.version_for_download}"
+    if ruby_version.default?
+      warn(<<~WARNING)
+        You have not declared a Ruby version in your Gemfile.
+
+        To declare a Ruby version add this line to your Gemfile:
+
+        ```
+        ruby "#{LanguagePack::RubyVersion::DEFAULT_VERSION_NUMBER}"
+        ```
+
+        For more information see:
+          https://devcenter.heroku.com/articles/ruby-versions
+      WARNING
+    end
+
+    true
+  rescue LanguagePack::Fetcher::FetchError
+    if @ruby_download_check.does_not_exist?
+      message = <<~ERROR
+        The Ruby version you are trying to install does not exist: #{ruby_version.version_for_download}
+      ERROR
+    else
+      message = <<~ERROR
+        The Ruby version you are trying to install does not exist on this stack.
+
+        You are trying to install #{ruby_version.version_for_download} on #{stack}.
+
+        Ruby #{ruby_version.version_for_download} is present on the following stacks:
+
+          - #{@ruby_download_check.valid_stack_list.join("\n  - ")}
+      ERROR
+
+      if env("CI")
+        message << <<~ERROR
+
+          On Heroku CI you can set your stack in the `app.json`. For example:
+
+          ```
+          "stack": "heroku-24"
+          ```
+        ERROR
       end
     end
+
+    message << <<~ERROR
+
+      Heroku recommends you use the latest supported Ruby version listed here:
+        https://devcenter.heroku.com/articles/ruby-support#supported-runtimes
+
+      For more information on syntax for declaring a Ruby version see:
+        https://devcenter.heroku.com/articles/ruby-versions
+    ERROR
+
+    io.error message
   end
 
   # default set of binaries to install
   # @return [Array] resulting list
   def binaries
-    add_node_js_binary
+    add_node_js_binary + add_yarn_binary
   end
 
   # vendors binaries into the slug
   def install_binaries
-    instrument 'ruby.install_binaries' do
-      binaries.each {|binary| install_binary(binary) }
-      Dir["bin/*"].each {|path| run("chmod +x #{path}") }
-    end
+    binaries.each { |binary| install_binary(binary) }
+    Dir["bin/*"].each { |path| run("chmod +x #{path}") }
   end
 
   # vendors individual binary into the slug
   # @param [String] name of the binary package from S3.
-  #   Example: https://s3.amazonaws.com/language-pack-ruby/node-0.4.7.tgz, where name is "node-0.4.7"
+  #   Example: https://heroku-buildpack-ruby.s3.dualstack.us-east-1.amazonaws.com/node-0.4.7.tgz, where name is "node-0.4.7"
   def install_binary(name)
+    topic "Installing #{name}"
     bin_dir = "bin"
     FileUtils.mkdir_p bin_dir
     Dir.chdir(bin_dir) do |dir|
-      @fetchers[:buildpack].fetch_untar("#{name}.tgz")
+      if /^node-/.match?(name)
+        @node_installer.install
+        # need to set PATH here b/c `node-gyp` can change the CWD, but still depends on executing node.
+        # the current PATH is relative, but it needs to be absolute for this.
+        # doing this here also prevents it from being exported during runtime
+        node_bin_path = File.absolute_path(".")
+        # this needs to be set after so other binaries in bin/ don't take precedence"
+        ENV["PATH"] = "#{ENV["PATH"]}:#{node_bin_path}"
+      elsif /^yarn-/.match?(name)
+        FileUtils.mkdir_p("../vendor")
+        Dir.chdir("../vendor") do |vendor_dir|
+          @yarn_installer.install
+          yarn_path = File.absolute_path("#{vendor_dir}/#{@yarn_installer.binary_path}")
+          ENV["PATH"] = "#{yarn_path}:#{ENV["PATH"]}"
+        end
+      else
+        @fetchers[:buildpack].fetch_untar("#{name}.tgz")
+      end
     end
   end
 
   # removes a binary from the slug
   # @param [String] relative path of the binary on the slug
   def uninstall_binary(path)
-    FileUtils.rm File.join('bin', File.basename(path)), :force => true
-  end
-
-  def load_default_cache?
-    new_app? && ruby_version.default?
-  end
-
-  # loads a default bundler cache for new apps to speed up initial bundle installs
-  def load_default_cache
-    instrument "ruby.load_default_cache" do
-      if load_default_cache?
-        puts "New app detected loading default bundler cache"
-        patchlevel = run("ruby -e 'puts RUBY_PATCHLEVEL'").chomp
-        cache_name  = "#{DEFAULT_RUBY_VERSION}-p#{patchlevel}-default-cache"
-        @fetchers[:buildpack].fetch_untar("#{cache_name}.tgz")
-      end
-    end
-  end
-
-  # install libyaml into the LP to be referenced for psych compilation
-  # @param [String] tmpdir to store the libyaml files
-  def install_libyaml(dir)
-    instrument 'ruby.install_libyaml' do
-      FileUtils.mkdir_p dir
-      Dir.chdir(dir) do |dir|
-        @fetchers[:buildpack].fetch_untar("#{LIBYAML_PATH}.tgz")
-      end
-    end
+    FileUtils.rm File.join("bin", File.basename(path)), force: true
   end
 
   # remove `vendor/bundle` that comes from the git repo
   # in case there are native ext.
   # users should be using `bundle pack` instead.
   # https://github.com/heroku/heroku-buildpack-ruby/issues/21
-  def remove_vendor_bundle
-    if File.exists?("vendor/bundle")
-      warn(< "#{pwd}/Gemfile",
-            "BUNDLE_CONFIG"                 => "#{pwd}/.bundle/config",
-            "CPATH"                         => noshellescape("#{yaml_include}:$CPATH"),
-            "CPPATH"                        => noshellescape("#{yaml_include}:$CPPATH"),
-            "LIBRARY_PATH"                  => noshellescape("#{yaml_lib}:$LIBRARY_PATH"),
-            "RUBYOPT"                       => syck_hack,
-            "NOKOGIRI_USE_SYSTEM_LIBRARIES" => "true"
-          }
-          env_vars["BUNDLER_LIB_PATH"] = "#{bundler_path}" if ruby_version.ruby_version == "1.8.7"
-          puts "Running: #{bundle_command}"
-          instrument "ruby.bundle_install" do
-            bundle_time = Benchmark.realtime do
-              bundler_output << pipe("#{bundle_command} --no-clean", out: "2>&1", env: env_vars, user_env: true)
-            end
-          end
-        end
+    bundle_command = +""
+    bundle_command << "BUNDLE_WITHOUT='#{ENV["BUNDLE_WITHOUT"]}' "
+    bundle_command << "BUNDLE_PATH=#{ENV["BUNDLE_PATH"]} "
+    bundle_command << "BUNDLE_BIN=#{ENV["BUNDLE_BIN"]} "
+    bundle_command << "BUNDLE_DEPLOYMENT=#{ENV["BUNDLE_DEPLOYMENT"]} " if ENV["BUNDLE_DEPLOYMENT"] # Unset on windows since we delete the Gemfile.lock
+    bundle_command << "bundle install -j4"
 
-        if $?.success?
-          puts "Bundle completed (#{"%.2f" % bundle_time}s)"
-          log "bundle", :status => "success"
-          puts "Cleaning up the bundler cache."
-          instrument "ruby.bundle_clean" do
-            # Only show bundle clean output when not using default cache
-            if load_default_cache?
-              run "bundle clean > /dev/null"
-            else
-              pipe("#{bundle_bin} clean", out: "2> /dev/null")
-            end
-          end
-          cache.store ".bundle"
-          cache.store "vendor/bundle"
-
-          # Keep gem cache out of the slug
-          FileUtils.rm_rf("#{slug_vendor_base}/cache")
-        else
-          log "bundle", :status => "failure"
-          error_message = "Failed to install gems via Bundler."
-          puts "Bundler Output: #{bundler_output}"
-          if bundler_output.match(/An error occurred while installing sqlite3/)
-            error_message += <&1", env: env_vars, user_env: true)
     end
-  end
-
-  # writes ERB based database.yml for Rails. The database.yml uses the DATABASE_URL from the environment during runtime.
-  def create_database_yml
-    instrument 'ruby.create_database_yml' do
-      log("create_database_yml") do
-        return unless File.directory?("config")
-        topic("Writing config/database.yml to read from DATABASE_URL")
-        File.open("config/database.yml", "w") do |file|
-          file.puts <<-DATABASE_YML
-<%
-
-require 'cgi'
-require 'uri'
-
-begin
-  uri = URI.parse(ENV["DATABASE_URL"])
-rescue URI::InvalidURIError
-  raise "Invalid DATABASE_URL"
-end
 
-raise "No RACK_ENV or RAILS_ENV found" unless ENV["RAILS_ENV"] || ENV["RACK_ENV"]
+    if $?.success?
+      io.puts "Bundle completed (#{"%.2f" % bundle_time}s)"
+      io.puts "Cleaning up the bundler cache."
+      pipe("bundle clean", out: "2> /dev/null", user_env: true, env: env_vars)
+      bundler_cache.store
 
-def attribute(name, value, force_string = false)
-  if value
-    value_string =
-      if force_string
-        '"' + value + '"'
-      else
-        value
+      # Keep gem cache out of the slug
+      app_path.join(ruby_version.bundler_directory).join("cache").rmtree
+    else
+      error_message = "Failed to install gems via Bundler."
+      io.puts "Bundler Output: #{bundler_output}"
+      if /An error occurred while installing sqlite3/.match?(bundler_output)
+        error_message += <<~ERROR
+
+          Detected sqlite3 gem which is not supported on Heroku:
+          https://devcenter.heroku.com/articles/sqlite3
+        ERROR
       end
-    "\#{name}: \#{value_string}"
-  else
-    ""
-  end
-end
 
-adapter = uri.scheme
-adapter = "postgresql" if adapter == "postgres"
+      if /but your Gemfile specified/.match?(bundler_output)
+        error_message += <<~ERROR
 
-database = (uri.path || "").split("/")[1]
+          Detected a mismatch between your Ruby version installed and
+          Ruby version specified in Gemfile or Gemfile.lock. You can
+          correct this by running:
 
-username = uri.user
-password = uri.password
+              $ bundle update --ruby
+              $ git add Gemfile.lock
+              $ git commit -m "update ruby version"
 
-host = uri.host
-port = uri.port
+          If this does not solve the issue please see this documentation:
 
-params = CGI.parse(uri.query || "")
-
-%>
+          https://devcenter.heroku.com/articles/ruby-versions#your-ruby-version-is-x-but-your-gemfile-specified-y
+        ERROR
+      end
 
-<%= ENV["RAILS_ENV"] || ENV["RACK_ENV"] %>:
-  <%= attribute "adapter",  adapter %>
-  <%= attribute "database", database %>
-  <%= attribute "username", username %>
-  <%= attribute "password", password, true %>
-  <%= attribute "host",     host %>
-  <%= attribute "port",     port %>
+      io.error error_message
+    end
+  end
 
-<% params.each do |key, value| %>
-  <%= key %>: <%= value.first %>
-<% end %>
-        DATABASE_YML
-        end
-      end
+  def post_bundler(ruby_version:, app_path:)
+    Dir[app_path.join(ruby_version.bundler_directory, "**", ".git")].each do |dir|
+      FileUtils.rm_rf(dir)
     end
+    bundler.clean
   end
 
   def rake
     @rake ||= begin
-      LanguagePack::Helpers::RakeRunner.new(
-                bundler.has_gem?("rake") || ruby_version.rake_is_vendored?
-              ).load_rake_tasks!(env: rake_env)
+      raise_on_fail = bundler.gem_version("railties") && bundler.gem_version("railties") > Gem::Version.new("3.x")
+
+      topic "Detecting rake tasks"
+      rake = LanguagePack::Helpers::RakeRunner.new
+      rake.load_rake_tasks!({env: rake_env}, raise_on_fail)
+      rake
     end
   end
 
   def rake_env
     if database_url
-      { "DATABASE_URL" => database_url }
+      {"DATABASE_URL" => database_url}
     else
       {}
     end.merge(user_env_hash)
@@ -665,125 +744,293 @@ def database_url
     env("DATABASE_URL") if env("DATABASE_URL")
   end
 
-  # executes the block with GIT_DIR environment variable removed since it can mess with the current working directory git thinks it's in
-  # @param [block] block to be executed in the GIT_DIR free context
-  def allow_git(&blk)
-    git_dir = ENV.delete("GIT_DIR") # can mess with bundler
-    blk.call
-    ENV["GIT_DIR"] = git_dir
-  end
-
   # decides if we need to enable the dev database addon
   # @return [Array] the database addon if the pg gem is detected or an empty Array if it isn't.
   def add_dev_database_addon
-    bundler.has_gem?("pg") ? ['heroku-postgresql:hobby-dev'] : []
+    return [] if env("HEROKU_SKIP_DATABASE_PROVISION")
+
+    ([
+      "pg",
+      "activerecord-jdbcpostgresql-adapter",
+      "jdbc-postgres",
+      "jdbc-postgresql",
+      "jruby-pg",
+      "rjack-jdbc-postgres",
+      "tgbyte-activerecord-jdbcpostgresql-adapter"
+    ].any? { |a| bundler.has_gem?(a) }) ? ["heroku-postgresql"] : []
   end
 
   # decides if we need to install the node.js binary
   # @note execjs will blow up if no JS RUNTIME is detected and is loaded.
   # @return [Array] the node.js binary path if we need it or an empty Array
   def add_node_js_binary
-    bundler.has_gem?('execjs') ? [NODE_JS_BINARY_PATH] : []
+    return [] if node_js_preinstalled?
+
+    if Pathname(app_path).join("package.json").exist? ||
+        bundler.has_gem?("execjs") ||
+        bundler.has_gem?("webpacker")
+
+      version = @node_installer.version
+      old_version = @metadata.fetch("default_node_version") { version }
+
+      # Make available for `rake assets:precompile` and other sub-shells
+      ENV["UV_USE_IO_URING"] ||= "0"
+      # Make available to future buildpacks (export), but not runtime (profile.d)
+      set_export_default "UV_USE_IO_URING", "0"
+
+      if old_version != version
+        warn(<<~WARNING, inline: true)
+          Default version of Node.js changed (#{old_version} to #{version})
+        WARNING
+      end
+
+      warn(<<~WARNING, inline: true)
+        Installing a default version (#{version}) of Node.js.
+        This version is not pinned and can change over time, causing unexpected failures.
+
+        Heroku recommends placing the `heroku/nodejs` buildpack in front of
+        `heroku/ruby` to install a specific version of node:
+
+        https://devcenter.heroku.com/articles/ruby-support-reference#node-js-and-yarn-support
+      WARNING
+
+      [@node_installer.binary_path]
+    else
+      []
+    end
   end
 
-  def run_assets_precompile_rake_task
-    instrument 'ruby.run_assets_precompile_rake_task' do
+  def add_yarn_binary
+    return [] if yarn_preinstalled?
 
-      precompile = rake.task("assets:precompile")
-      return true unless precompile.is_defined?
+    if Pathname(app_path).join("yarn.lock").exist? || bundler.has_gem?("webpacker")
 
-      topic "Running: rake assets:precompile"
-      precompile.invoke(env: rake_env)
-      if precompile.success?
-        puts "Asset precompilation completed (#{"%.2f" % precompile.time}s)"
-      else
-        log "assets_precompile", :status => "failure"
-        error "Precompiling assets failed."
+      version = @yarn_installer.version
+      old_version = @metadata.fetch("default_yarn_version") { version }
+
+      # Make available for `rake assets:precompile` and other sub-shells
+      ENV["UV_USE_IO_URING"] ||= "0"
+      # Make available to future buildpacks (export), but not runtime (profile.d)
+      set_export_default "UV_USE_IO_URING", "0"
+
+      if old_version != version
+        warn(<<~WARNING, inline: true)
+          Default version of Yarn changed (#{old_version} to #{version})
+        WARNING
       end
+
+      warn(<<~WARNING, inline: true)
+        Installing a default version (#{version}) of Yarn
+        This version is not pinned and can change over time, causing unexpected failures.
+
+        Heroku recommends placing the `heroku/nodejs` buildpack in front of the `heroku/ruby`
+        buildpack as it offers more comprehensive Node.js support, including the ability to
+        customise the Node.js version:
+
+        https://devcenter.heroku.com/articles/ruby-support-reference#node-js-and-yarn-support
+      WARNING
+
+      [@yarn_installer.name]
+    else
+      []
     end
   end
 
-  def bundler_cache
-    "vendor/bundle"
+  def has_yarn_binary?
+    add_yarn_binary.any?
   end
 
-  def load_bundler_cache
-    instrument "ruby.load_bundler_cache" do
-      cache.load "vendor"
+  # checks if node.js is installed via the official heroku-buildpack-nodejs using multibuildpack
+  # @return String if it's detected and false if it isn't
+  def node_preinstall_bin_path
+    return @node_preinstall_bin_path if defined?(@node_preinstall_bin_path)
+
+    legacy_path = "#{Dir.pwd}/#{NODE_BP_PATH}"
+    path = run("command -v node").strip
+    @node_preinstall_bin_path = if path && $?.success?
+      path
+    elsif run("#{legacy_path}/node -v") && $?.success?
+      legacy_path
+    else
+      false
+    end
+  end
+  alias_method :node_js_preinstalled?, :node_preinstall_bin_path
 
-      full_ruby_version       = run_stdout(%q(ruby -v)).chomp
-      rubygems_version        = run_stdout(%q(gem -v)).chomp
-      heroku_metadata         = "vendor/heroku"
-      old_rubygems_version    = nil
-      ruby_version_cache      = "ruby_version"
-      buildpack_version_cache = "buildpack_version"
-      bundler_version_cache   = "bundler_version"
-      rubygems_version_cache  = "rubygems_version"
+  def node_not_preinstalled?
+    !node_js_preinstalled?
+  end
 
-      old_rubygems_version = @metadata.read(ruby_version_cache).chomp if @metadata.exists?(ruby_version_cache)
+  def yarn_preinstall_bin_path
+    LanguagePack::Ruby.yarn_preinstall_bin_path
+  end
 
-      load_default_cache
+  # Example: tmp/build_8523f77fb96a956101d00988dfeed9d4/.heroku/yarn/bin/ (without the `yarn` at the end)
+  def self.yarn_preinstall_bin_path
+    (yarn_preinstall_binary_path || "").chomp("/yarn")
+  end
 
-      # fix bug from v37 deploy
-      if File.exists?("vendor/ruby_version")
-        puts "Broken cache detected. Purging build cache."
-        cache.clear("vendor")
-        FileUtils.rm_rf("vendor/ruby_version")
-        purge_bundler_cache
-        # fix bug introduced in v38
-      elsif !@metadata.exists?(buildpack_version_cache) && @metadata.exists?(ruby_version_cache)
-        puts "Broken cache detected. Purging build cache."
-        purge_bundler_cache
-      elsif cache.exists?(bundler_cache) && @metadata.exists?(ruby_version_cache) && full_ruby_version != @metadata.read(ruby_version_cache).chomp
-        puts "Ruby version change detected. Clearing bundler cache."
-        puts "Old: #{@metadata.read(ruby_version_cache).chomp}"
-        puts "New: #{full_ruby_version}"
-        purge_bundler_cache
-      end
+  # Example `tmp/build_8523f77fb96a956101d00988dfeed9d4/.heroku/yarn/bin/yarn`
+  def self.yarn_preinstall_binary_path
+    return @yarn_preinstall_binary_path if defined?(@yarn_preinstall_binary_path)
 
-      # fix git gemspec bug from Bundler 1.3.0+ upgrade
-      if File.exists?(bundler_cache) && !@metadata.exists?(bundler_version_cache) && !run("find vendor/bundle/*/*/bundler/gems/*/ -name *.gemspec").include?("No such file or directory")
-        puts "Old bundler cache detected. Clearing bundler cache."
-        purge_bundler_cache
-      end
+    path = run("command -v yarn").strip
+    @yarn_preinstall_binary_path = if path && $?.success?
+      path
+    else
+      false
+    end
+  end
 
-      # fix for https://github.com/heroku/heroku-buildpack-ruby/issues/86
-      if (!@metadata.exists?(rubygems_version_cache) ||
-          (old_rubygems_version == "2.0.0" && old_rubygems_version != rubygems_version)) &&
-          @metadata.exists?(ruby_version_cache) && @metadata.read(ruby_version_cache).chomp.include?("ruby 2.0.0p0")
-        puts "Updating to rubygems #{rubygems_version}. Clearing bundler cache."
-        purge_bundler_cache
-      end
+  def yarn_preinstalled?
+    LanguagePack::Ruby.yarn_preinstalled?
+  end
 
-      # fix for https://github.com/sparklemotion/nokogiri/issues/923
-      if @metadata.exists?(buildpack_version_cache) && (bv = @metadata.read(buildpack_version_cache).sub('v', '').to_i) && bv != 0 && bv <= 76
-        puts "Fixing nokogiri install. Clearing bundler cache."
-        puts "See https://github.com/sparklemotion/nokogiri/issues/923."
-        purge_bundler_cache
-      end
+  def self.yarn_preinstalled?
+    yarn_preinstall_binary_path
+  end
 
-      # recompile nokogiri to use new libyaml
-      if @metadata.exists?(buildpack_version_cache) && (bv = @metadata.read(buildpack_version_cache).sub('v', '').to_i) && bv != 0 && bv <= 99 && bundler.has_gem?("psych")
-        puts "Need to recompile psych for CVE-2013-6393. Clearing bundler cache."
-        puts "See http://bugs.debian.org/cgi-bin/bugreport.cgi?bug=737076."
-        purge_bundler_cache
-      end
+  def run_assets_precompile_rake_task
+    precompile = rake.task("assets:precompile")
+    return true unless precompile.is_defined?
+
+    topic "Precompiling assets"
+    precompile.invoke(env: rake_env)
+    if precompile.success?
+      puts "Asset precompilation completed (#{"%.2f" % precompile.time}s)"
+    else
+      precompile_fail(precompile.output)
+    end
+  end
 
-      FileUtils.mkdir_p(heroku_metadata)
-      @metadata.write(ruby_version_cache, full_ruby_version, false)
-      @metadata.write(buildpack_version_cache, BUILDPACK_VERSION, false)
-      @metadata.write(bundler_version_cache, BUNDLER_VERSION, false)
-      @metadata.write(rubygems_version_cache, rubygems_version, false)
-      @metadata.save
+  def precompile_fail(output)
+    msg = "Precompiling assets failed.\n"
+    if /(127\.0\.0\.1)|(org\.postgresql\.util)/.match?(output)
+      msg << "Attempted to access a nonexistent database:\n"
+      msg << "https://devcenter.heroku.com/articles/pre-provision-database\n"
     end
+
+    sprockets_version = bundler.gem_version("sprockets")
+    if output.match(/Sprockets::FileNotFound/) && sprockets_version < Gem::Version.new("4.0.0.beta7") && sprockets_version > Gem::Version.new("4.0.0.beta4")
+      msg << "If you have this file in your project\n"
+      msg << "try upgrading to Sprockets 4.0.0.beta7 or later:\n"
+      msg << "https://github.com/rails/sprockets/pull/547\n"
+    end
+
+    error msg
   end
 
-  def purge_bundler_cache
-    instrument "ruby.purge_bundler_cache" do
-      FileUtils.rm_rf(bundler_cache)
-      cache.clear bundler_cache
-      # need to reinstall language pack gems
-      install_bundler_in_app
+  def self.load_bundler_cache(cache:, metadata:, stack:, bundler_cache:, bundler_version:, bundler:, io:, new_app:, ruby_version:)
+    cache.load "vendor"
+
+    full_ruby_version = `ruby -v 2>/dev/null`.strip
+    ruby_version_cache = "ruby_version"
+    stack_cache = "stack"
+
+    # bundle clean does not remove binstubs
+    FileUtils.rm_rf("vendor/bundler/bin")
+
+    old_stack = metadata.try_read(stack_cache)
+
+    stack_change = old_stack != stack
+    convert_stack = bundler_cache.old?
+    bundler_cache.convert_stack(stack_change) if convert_stack
+    if !new_app && stack_change
+      io.puts "Purging Cache. Changing stack from #{old_stack} to #{stack}"
+      purge_bundler_cache(bundler_cache: bundler_cache, stack: old_stack, ruby_version: ruby_version, bundler: bundler)
+    elsif !new_app && !convert_stack
+      bundler_cache.load
+    end
+
+    if (bundler_cache.exists? || bundler_cache.old?) &&
+        metadata.exists?(ruby_version_cache) &&
+        full_ruby_version != metadata.read(ruby_version_cache).strip
+      io.puts "Ruby version change detected. Clearing bundler cache."
+      io.puts "Old: #{metadata.read(ruby_version_cache).strip}"
+      io.puts "New: #{full_ruby_version}"
+      purge_bundler_cache(bundler_cache: bundler_cache, stack: nil, ruby_version: ruby_version, bundler: bundler)
+    end
+
+    metadata.write(ruby_version_cache, full_ruby_version)
+    metadata.write("buildpack_version", BUILDPACK_VERSION)
+    metadata.write("bundler_version", bundler_version)
+    metadata.write("rubygems_version", `gem -v 2>/dev/null`.strip)
+    metadata.write(stack_cache, stack)
+  end
+
+  def self.purge_bundler_cache(bundler_cache:, ruby_version:, bundler:, stack: nil)
+    bundler_cache.clear(stack)
+    # need to reinstall bundler
+    bundler.install
+  end
+
+  def purge_bundler_cache(stack = nil)
+    @bundler_cache.clear(stack)
+    bundler.install
+  end
+
+  # writes ERB based database.yml for Rails. The database.yml uses the DATABASE_URL from the environment during runtime.
+  def create_database_yml
+    return false unless File.directory?("config")
+    return false if bundler.has_gem?("activerecord") && bundler.gem_version("activerecord") >= Gem::Version.new("4.1.0.beta1")
+
+    topic("Writing config/database.yml to read from DATABASE_URL")
+    File.open("config/database.yml", "w") do |file|
+      file.puts <<~DATABASE_YML
+        <%
+
+        require 'cgi'
+        require 'uri'
+
+        begin
+          uri = URI.parse(ENV["DATABASE_URL"])
+        rescue URI::InvalidURIError
+          raise "Invalid DATABASE_URL"
+        end
+
+        raise "No RACK_ENV or RAILS_ENV found" unless ENV["RAILS_ENV"] || ENV["RACK_ENV"]
+
+        def attribute(name, value, force_string = false)
+          if value
+            value_string =
+              if force_string
+                '"' + value + '"'
+              else
+                value
+              end
+            "\#{name}: \#{value_string}"
+          else
+            ""
+          end
+        end
+
+        adapter = uri.scheme
+        adapter = "postgresql" if adapter == "postgres"
+
+        database = (uri.path || "").split("/")[1]
+
+        username = uri.user
+        password = uri.password
+
+        host = uri.host
+        port = uri.port
+
+        params = CGI.parse(uri.query || "")
+
+        %>
+
+        <%= ENV["RAILS_ENV"] || ENV["RACK_ENV"] %>:
+          <%= attribute "adapter",  adapter %>
+          <%= attribute "database", database %>
+          <%= attribute "username", username %>
+          <%= attribute "password", password, true %>
+          <%= attribute "host",     host %>
+          <%= attribute "port",     port %>
+
+        <% params.each do |key, value| %>
+          <%= key %>: <%= value.first %>
+        <% end %>
+      DATABASE_YML
     end
   end
 end
diff --git a/lib/language_pack/ruby_version.rb b/lib/language_pack/ruby_version.rb
index deee8371a..842b60b3a 100644
--- a/lib/language_pack/ruby_version.rb
+++ b/lib/language_pack/ruby_version.rb
@@ -1,48 +1,115 @@
-require "language_pack/shell_helpers"
+require "language_pack/shell_helpers" # Holds BuildpackError
 
 module LanguagePack
   class RubyVersion
-    class BadVersionError < StandardError
+    class BadVersionError < BuildpackError
       def initialize(output = "")
-        msg = "Can not parse Ruby Version:\n"
-        msg << "Valid versions listed on: https://devcenter.heroku.com/articles/ruby-support\n"
+        msg = ""
         msg << output
-        super msg
+        msg << "Can not parse Ruby Version:\n"
+        msg << "Valid versions listed on: https://devcenter.heroku.com/articles/ruby-support\n"
+        super(msg)
       end
     end
 
-    DEFAULT_VERSION_NUMBER = "2.0.0"
-    DEFAULT_VERSION        = "ruby-#{DEFAULT_VERSION_NUMBER}"
-    LEGACY_VERSION_NUMBER  = "1.9.2"
-    LEGACY_VERSION         = "ruby-#{LEGACY_VERSION_NUMBER}"
-    RUBY_VERSION_REGEX     = %r{
-        (?\d+\.\d+\.\d+){0}
-        (?p\d+){0}
-        (?\w+){0}
-        (?.+){0}
+    BOOTSTRAP_VERSION_NUMBER = "3.3.9".freeze
+    DEFAULT_VERSION_NUMBER = "3.3.9".freeze
+    DEFAULT_VERSION = "ruby-#{DEFAULT_VERSION_NUMBER}".freeze
+
+    # String formatted `..` for Ruby and JRuby
+    attr_reader :ruby_version,
+      # `engine` is `:ruby` or `:jruby`
+      :engine,
+      # `engine_version` is the Jruby version or for MRI it is the same as `ruby_version`
+      # i.e. `..`
+      :engine_version,
+      # Pre-release identifier e.g. "rc1", "preview2", or nil for stable releases
+      :pre
+
+    def self.from_gemfile_lock(ruby:, last_version: nil)
+      if ruby.empty?
+        default(last_version: last_version)
+      else
+        new(
+          pre: ruby.pre,
+          engine: ruby.engine,
+          default: false,
+          ruby_version: ruby.ruby_version,
+          engine_version: ruby.engine_version
+        )
+      end
+    end
 
-        ruby-\g(-\g)?(-\g-\g)?
-      }x
+    def self.default(last_version:)
+      ruby_version = last_version&.split("-")&.last || DEFAULT_VERSION_NUMBER
+      new(
+        pre: nil,
+        engine: :ruby,
+        default: true,
+        ruby_version: ruby_version,
+        engine_version: ruby_version
+      )
+    end
 
-    attr_reader :set, :version, :version_without_patchlevel, :patchlevel, :engine, :ruby_version, :engine_version
-    include LanguagePack::ShellHelpers
+    def initialize(
+      pre:,
+      engine:,
+      default:,
+      ruby_version:,
+      engine_version:
+    )
+      @pre = pre
+      @engine = engine
+      @default = default
+      @ruby_version = ruby_version
+      @engine_version = engine_version
+    end
 
-    def initialize(bundler, app = {})
-      @set          = nil
-      @bundler      = bundler
-      @app          = app
-      set_version
-      parse_version
+    # Also used as for metrics to track unique installs
+    # i.e. `ruby-3.4.2`
+    def version_for_download
+      if @engine == :jruby
+        "ruby-#{ruby_version}-jruby-#{engine_version}"
+      else
+        "ruby-#{engine_version_full}"
+      end
+    end
+
+    # Full qualifier for the version including pre-release information
+    # i.e. `3.5.0.preview1` or `3.5.0` or `3.5.0.rc1` for ruby
+    # i.e. `9.4.9.0` for jruby
+    def engine_version_full
+      if @engine == :jruby
+        engine_version
+      elsif @pre
+        "#{engine_version}.#{@pre}"
+      else
+        engine_version.to_s
+      end
+    end
 
-      @version_without_patchlevel = @version.sub(/-p[\d]+/, '')
+    # Ruby versioned bundler directory
+    #
+    # When installing gems via `BUNDLE_DEPLOYMENT=1 bundle install`, they're installed into a versioned directory based on the ruby version.
+    #
+    # This becomes the location of GEM_PATH on disk https://www.schneems.com/2014/04/15/gem-path.html.
+    # - Executables are at bundler_directory.join("bin")
+    # - Gems are at bundler_directory.join("gems")
+    #
+    # For example:
+    #
+    # - Ruby 3.4.7 would be "vendor/bundle/ruby/3.4.0"
+    # - JRuby 9.4.14.0 would be "vendor/bundle/jruby/3.1.0" (As it implements Ruby 3.1.7 spec)
+    def bundler_directory
+      "vendor/bundle/#{engine}/#{major}.#{minor}.0"
     end
 
-    def rake_is_vendored?
-      Gem::Version.new(self.ruby_version) >= Gem::Version.new("1.9")
+    def file_name
+      "#{version_for_download}.tgz"
     end
 
     def default?
-      @version == none
+      @default
     end
 
     # determine if we're using jruby
@@ -51,18 +118,6 @@ def jruby?
       engine == :jruby
     end
 
-    # determine if we're using rbx
-    # @return [Boolean] true if we are and false if we aren't
-    def rbx?
-      engine == :rbx
-    end
-
-    # determines if a build ruby is required
-    # @return [Boolean] true if a build ruby is required
-    def build?
-      engine == :ruby && %w(1.8.7 1.9.2).include?(ruby_version)
-    end
-
     # convert to a Gemfile ruby DSL incantation
     # @return [String] the string representation of the Gemfile ruby DSL
     def to_gemfile
@@ -73,52 +128,32 @@ def to_gemfile
       end
     end
 
-    private
-    def gemfile
-      ruby_version = @bundler.ruby_version
-      return "" unless ruby_version
+    def major
+      @ruby_version.split(".")[0].to_i
+    end
 
-      parts = [
-        "ruby",
-        ruby_version.version
-      ]
-      parts << "p#{ruby_version.patchlevel}" if ruby_version.patchlevel
-      unless ruby_version.engine == "ruby"
-        parts << ruby_version.engine
-        parts << ruby_version.engine_version
-      end
+    def minor
+      @ruby_version.split(".")[1].to_i
+    end
 
-      parts.compact.join("-")
+    def patch
+      @ruby_version.split(".")[2].to_i
     end
 
-    def none
-      if @app[:is_new]
-        DEFAULT_VERSION
-      elsif @app[:last_version]
-        @app[:last_version]
-      else
-        LEGACY_VERSION
-      end
+    # Returns the next logical version in the minor series
+    # for example if the current ruby version is
+    # `ruby-2.3.1` then then `next_logical_version(1)`
+    # will produce `ruby-2.3.2`.
+    def next_logical_version(increment = 1)
+      "ruby-#{major}.#{minor}.#{patch + increment}"
     end
 
-    def set_version
-      bundler_output = gemfile
-      if bundler_output.empty?
-        @set     = false
-        @version = none
-      else
-        @set     = :gemfile
-        @version = gemfile
-      end
+    def next_minor_version(increment = 1)
+      "ruby-#{major}.#{minor + increment}.0"
     end
 
-    def parse_version
-      md = RUBY_VERSION_REGEX.match(version)
-      raise BadVersionError.new("'#{version}' is not valid") unless md
-      @ruby_version   = md[:ruby_version]
-      @patchlevel     = md[:patchlevel]
-      @engine_version = md[:engine_version] || @ruby_version
-      @engine         = (md[:engine]        || :ruby).to_sym
+    def next_major_version(increment = 1)
+      "ruby-#{major + increment}.0.0"
     end
   end
 end
diff --git a/lib/language_pack/shell_helpers.rb b/lib/language_pack/shell_helpers.rb
index 3a16d33f1..13248e01f 100644
--- a/lib/language_pack/shell_helpers.rb
+++ b/lib/language_pack/shell_helpers.rb
@@ -1,4 +1,8 @@
 require "shellwords"
+require "timeout"
+
+class BuildpackError < StandardError
+end
 
 class NoShellEscape < String
   def shellescape
@@ -9,6 +13,11 @@ def shellescape
 module LanguagePack
   module ShellHelpers
     @@user_env_hash = {}
+    @@warnings = []
+
+    def warnings
+      @@warnings
+    end
 
     def self.user_env_hash
       @@user_env_hash
@@ -23,43 +32,64 @@ def env(var)
     end
 
     def self.blacklist?(key)
-      %w(PATH GEM_PATH GEM_HOME GIT_DIR).include?(key)
+      %w[PATH GEM_PATH GEM_HOME GIT_DIR JRUBY_OPTS JAVA_OPTS JAVA_TOOL_OPTIONS RUBYOPT].include?(key)
     end
 
     def self.initialize_env(path)
-      env_dir = Pathname.new("#{path}")
+      env_dir = Pathname.new(path.to_s)
       if env_dir.exist? && env_dir.directory?
         env_dir.each_child do |file|
-          key   = file.basename.to_s
+          key = file.basename.to_s
           value = file.read.strip
           user_env_hash[key] = value unless blacklist?(key)
         end
       end
     end
 
-    # display error message and stop the build process
-    # @param [String] error message
-    def error(message)
+    # Should only be used once in the top level script
+    # for example $ bin/support/ruby_compile
+    def self.display_error_and_exit(e)
+      Kernel.puts "\e[1m\e[31m" # Bold Red
       Kernel.puts " !"
-      message.split("\n").each do |line|
+      e.message.split("\n").each do |line|
         Kernel.puts " !     #{line.strip}"
       end
-      Kernel.puts " !"
-      log "exit", :error => message if respond_to?(:log)
-      exit 1
+      Kernel.puts " !\e[0m"
+      if e.is_a?(BuildpackError)
+        exit 1
+      else
+        raise e
+      end
     end
 
+    # run a shell command (deferring to #run), and raise an error if it fails
+    # @param [String] command to be run
+    # @return [String] result of #run
+    # @option options [Error] :error_class Class of error to raise, defaults to Standard Error
+    # @option options [Integer] :max_attempts Number of times to attempt command before raising
     def run!(command, options = {})
-      result = run(command, options)
-      error("Command: '#{command}' failed unexpectedly:\n#{result}") unless $?.success?
-      return result
+      max_attempts = options[:max_attempts] || 1
+      error_class = options[:error_class] || StandardError
+      silent = options.key?(:silent) ? options[:silent] : false
+      max_attempts.times do |attempt_number|
+        result = run(command, options)
+        if $?.success?
+          return result
+        end
+        if attempt_number == max_attempts - 1
+          raise error_class, "Command: '#{command}' failed unexpectedly:\n#{result}"
+        else
+          next if silent
+          puts "Command: '#{command}' failed on attempt #{attempt_number + 1} of #{max_attempts}."
+        end
+      end
     end
 
     # doesn't do any special piping. stderr won't be redirected.
     # @param [String] command to be run
     # @return [String] output of stdout
     def run_no_pipe(command, options = {})
-      run(command, options.merge({:out => ""}))
+      run(command, options.merge({out: ""}))
     end
 
     # run a shell command and pipe stderr to stdout
@@ -68,70 +98,175 @@ def run_no_pipe(command, options = {})
     # @option options [Hash] :env explicit environment to run command in
     # @option options [Boolean] :user_env whether or not a user's environment variables will be loaded
     def run(command, options = {})
-      %x{ #{command_options_to_string(command, options)} }
-    end
-
-    # run a shell command and pipe stderr to /dev/null
-    # @param [String] command to be run
-    # @return [String] output of stdout
-    def run_stdout(command, options = {})
-      options[:out] ||= '2>/dev/null'
-      run(command, options)
+      `#{command_options_to_string(command, options)}`
     end
 
     def command_options_to_string(command, options)
       options[:env] ||= {}
       options[:out] ||= "2>&1"
       options[:env] = user_env_hash.merge(options[:env]) if options[:user_env]
-      env = options[:env].map {|key, value| "#{key.shellescape}=#{value.shellescape}" }.join(" ")
+      env = options[:env].map { |key, value| "#{key.shellescape}=#{value.shellescape}" }.join(" ")
       "/usr/bin/env #{env} bash -c #{command.shellescape} #{options[:out]} "
     end
 
+    # Class for running process spawn with a timeout
+    #
+    # Example:
+    #
+    #   spawn = ProcessSpawn.new("echo 'hello'")
+    #   spawn.success? # => true
+    #   spawn.timeout? # => false
+    #
+    # If you want to get the output of the process, you will need to pass
+    # in a file:
+    #
+    #   spawn = ProcessSpawn.new("echo 'hello'", file: "hello.txt")
+    #   spawn.success? # => true
+    #   spawn.output   # => "hello"
+    #
+    # The main benefit of using a file is that even if the command fails
+    # or times out, there will still be partial results in the output file.
+    #
+    # To add a timeout, instantiate pass in a hash with a `timeout` key
+    # in seconds:
+    #
+    #   spawn = ProcessSpawn.new("sleep 10", timeout: 4)
+    #   spawn.success? # => false
+    #   spawn.timeout? # => true
+    #
+    class ProcessSpawn
+      include ShellHelpers
+
+      def initialize(command, options = {})
+        @timeout_value = options.delete(:timeout)
+        @file = options.delete(:file)
+        if @file
+          raise "Cannot specify :file, and :out" if options[:out]
+          @file = Pathname.new(@file)
+          @file.dirname.mkpath
+          FileUtils.touch(@file)
+
+          options[:out] = ">> #{@file} 2>&1"
+        end
+
+        @command = command_options_to_string(command, options)
+        @did_time_out = false
+        @success = false
+      end
+
+      def output
+        raise "no file name given" if @file.nil?
+        exec_once
+        @file.read.encode("UTF-8", invalid: :replace)
+      end
+
+      def timeout?
+        exec_once
+        @did_time_out
+      end
+
+      def success?
+        exec_once
+        @success
+      end
+
+      private
+
+      def exec_once
+        return if @exec_once
+        @exec_once = true
+        wait_with_timeout
+        true
+      end
+
+      def wait_with_timeout
+        pid = Process.spawn(@command)
+        Timeout.timeout(@timeout_value) do
+          Process.wait(pid)
+          @success = $?.success?
+        end
+      rescue Timeout::Error
+        Process.kill("SIGKILL", pid)
+        @did_time_out = true
+        @success = false
+      end
+    end
+
     # run a shell command and stream the output
     # @param [String] command to be run
     def pipe(command, options = {})
-      output = ""
+      output = options[:buffer] || ""
+      silent = options[:silent]
+      # self.puts uses the method defined in this file
+      output_object = options[:output_object] || self
       IO.popen(command_options_to_string(command, options)) do |io|
         until io.eof?
           buffer = io.gets
           output << buffer
-          puts buffer
+          output_object.puts(buffer) unless silent
         end
       end
 
       output
     end
 
-    # display a topic message
-    # (denoted by ----->)
-    # @param [String] topic message to be displayed
-    def topic(message)
-      Kernel.puts "-----> #{message}"
-      $stdout.flush
-    end
+    module BuildOutput
+      # display a topic message
+      # (denoted by ----->)
+      # @param [String] topic message to be displayed
+      def topic(message)
+        Kernel.puts "-----> #{message}"
+        $stdout.flush
+      end
+
+      # display a message in line
+      # (indented by 6 spaces)
+      # @param [String] message to be displayed
+      def puts(message)
+        message.to_s.each_line do |line|
+          if line.end_with?("\n".freeze)
+            print "       #{line}"
+          else
+            print "       #{line}\n"
+          end
+        end
 
-    # display a message in line
-    # (indented by 6 spaces)
-    # @param [String] message to be displayed
-    def puts(message)
-      message.split("\n").each do |line|
-        super "       #{line.strip}"
+        $stdout.flush
+      rescue ArgumentError, Encoding::CompatibilityError => e
+        error_message = e.message
+        raise e if !/invalid byte sequence/.match?(error_message)
+
+        error_message << "\n       Invalid string: #{message}"
+        raise e, error_message
       end
-      $stdout.flush
-    end
 
-    def warn(message, options = {})
-      if options.key?(:inline) ? options[:inline] : false
-        topic "Warning:"
-        puts message
+      def error(message)
+        raise BuildpackError, message
+      end
+
+      # Requires a self#warnings method that returns an array
+      def warn(message, options = {})
+        if options.key?(:inline) ? options[:inline] : false
+          Kernel.puts ""
+          Kernel.puts "\e[1m\e[33m###### WARNING:\e[0m" # Bold yellow
+          Kernel.puts ""
+          puts message
+          Kernel.puts ""
+        end
+        warnings << message
       end
-      @warnings ||= []
-      @warnings << message
     end
+    include BuildOutput
+
+    # Prints output and stores warnings
+    class WarnIO
+      include BuildOutput
+
+      def initialize
+        @warnings = []
+      end
 
-    def deprecate(message)
-      @deprecations ||= []
-      @deprecations << message
+      attr_reader :warnings
     end
 
     def noshellescape(string)
diff --git a/lib/language_pack/test.rb b/lib/language_pack/test.rb
new file mode 100644
index 000000000..0f8f7eb07
--- /dev/null
+++ b/lib/language_pack/test.rb
@@ -0,0 +1,10 @@
+require "language_pack"
+
+module LanguagePack::Test
+end
+
+# Behavior changes for the test pack work by opening existing language_pack
+# classes and over-writing their behavior to extend test functionality
+require "language_pack/test/ruby"
+require "language_pack/test/rails2"
+require "language_pack/test/rails7"
diff --git a/lib/language_pack/test/rails2.rb b/lib/language_pack/test/rails2.rb
new file mode 100644
index 000000000..b61c98a70
--- /dev/null
+++ b/lib/language_pack/test/rails2.rb
@@ -0,0 +1,62 @@
+# Opens up the class of the Rails2 language pack and
+# overwrites methods defined in `language_pack/test/ruby.rb`
+class LanguagePack::Rails2
+  def prepare_tests
+    # need to clear db:create before db:schema:load_if_ruby gets called by super
+    topic "Clearing #{db_test_tasks_to_clear.join(" ")} rake tasks"
+    clear_db_test_tasks
+    super
+  end
+
+  def db_test_tasks_to_clear
+    # db:test:purge is called by everything in the db:test namespace
+    # db:create is called by :db:schema:load_if_ruby
+    # db:structure:dump is not needed for tests, but breaks Rails 3.2 db:structure:load on Heroku
+    ["db:test:purge", "db:create", "db:structure:dump"]
+  end
+
+  # rails test runner + rspec depend on db:test:purge which drops/creates a db which doesn't work on Heroku's DB plans
+  def clear_db_test_tasks
+    FileUtils.mkdir_p "lib/tasks"
+    File.open("lib/tasks/heroku_clear_tasks.rake", "w") do |file|
+      file.puts "# rubocop:disable all"
+      content = db_test_tasks_to_clear.map do |task_name|
+        <<~FILE
+          if Rake::Task.task_defined?('#{task_name}')
+            Rake::Task['#{task_name}'].clear
+            task '#{task_name}' do
+            end
+          end
+        FILE
+      end.join("\n")
+      file.print content
+      file.puts "# rubocop:enable all"
+    end
+  end
+
+  def db_prepare_test_rake_tasks
+    schema_load = rake.task("db:schema:load_if_ruby")
+    structure_load = rake.task("db:structure:load_if_sql")
+    db_migrate = rake.task("db:migrate")
+
+    return [] if db_migrate.not_defined?
+
+    if schema_load.not_defined? && structure_load.not_defined?
+      result = detect_schema_format
+      case result.lines.last.strip
+      when "ruby"
+        schema_load = rake.task("db:schema:load")
+      when "sql" # currently not a possible edge case, we think
+        structure_load = rake.task("db:structure:load")
+      else
+        puts "Could not determine schema/structure from `ActiveRecord::Base.schema_format`:\n#{result}"
+      end
+    end
+
+    [schema_load, structure_load, db_migrate]
+  end
+
+  def detect_schema_format
+    run("rails runner 'puts ActiveRecord::Base.schema_format'", user_env: true)
+  end
+end
diff --git a/lib/language_pack/test/rails7.rb b/lib/language_pack/test/rails7.rb
new file mode 100644
index 000000000..97fd20ea8
--- /dev/null
+++ b/lib/language_pack/test/rails7.rb
@@ -0,0 +1,9 @@
+# Opens up the class of the Rails7 language pack and
+# overwrites methods defined in `language_pack/test/ruby.rb` or `language_pack/test/rails2.rb`
+class LanguagePack::Rails7
+  # Rails removed the db:schema:load_if_ruby and `db:structure:load_if_sql` tasks
+  # they've been replaced by `db:schema:load` instead
+  def db_prepare_test_rake_tasks
+    ["db:schema:load", "db:migrate"].map { |name| rake.task(name) }
+  end
+end
diff --git a/lib/language_pack/test/ruby.rb b/lib/language_pack/test/ruby.rb
new file mode 100644
index 000000000..bf0277889
--- /dev/null
+++ b/lib/language_pack/test/ruby.rb
@@ -0,0 +1,41 @@
+# Opens up the class of the Ruby language pack and
+# overwrites methods defined in `language_pack/ruby.rb`
+#
+# Other "test packs" futher extend this behavior by hooking into
+# methods or over writing methods defined here.
+class LanguagePack::Ruby
+  def compile
+    @outdated_version_check = LanguagePack::Helpers::OutdatedRubyVersion.new(
+      current_ruby_version: ruby_version,
+      fetcher: LanguagePack::Installers::HerokuRubyInstaller.fetcher(amd_only_stacks: AMD_ONLY_STACKS, stack: stack, arch: @arch)
+    ).call
+    @warn_io.warnings.each { |warning| warnings << warning }
+    post_bundler(ruby_version: @ruby_version, app_path: app_path)
+    create_database_yml
+    install_binaries
+    prepare_tests
+    default_config_vars = self.class.default_config_vars(metadata: @metadata, ruby_version: @ruby_version, bundler: bundler, environment_name: environment_name)
+    setup_profiled(ruby_layer_path: "$HOME", gem_layer_path: "$HOME", ruby_version: @ruby_version, default_config_vars: default_config_vars, report: @report) # $HOME is set to /app at run time
+    setup_export(app_path: app_path, ruby_version: @ruby_version, default_config_vars: default_config_vars)
+    super
+  end
+
+  def db_prepare_test_rake_tasks
+    ["db:schema:load", "db:migrate"].map { |name| rake.task(name) }
+  end
+
+  def prepare_tests
+    rake_tasks = db_prepare_test_rake_tasks.select(&:is_defined?)
+    return true if rake_tasks.empty?
+
+    topic "Preparing test database"
+    rake_tasks.each do |rake_task|
+      rake_task.invoke(env: rake_env)
+      if rake_task.success?
+        puts "#{rake_task.task} completed (#{"%.2f" % rake_task.time}s)"
+      else
+        error "Could not prepare database for test"
+      end
+    end
+  end
+end
diff --git a/lib/language_pack/version.rb b/lib/language_pack/version.rb
index a94f79c8a..9d1d34c5c 100644
--- a/lib/language_pack/version.rb
+++ b/lib/language_pack/version.rb
@@ -1,8 +1,7 @@
 require "language_pack/base"
 
-# This file is automatically generated by rake
 module LanguagePack
   class LanguagePack::Base
-    BUILDPACK_VERSION = "v108"
+    BUILDPACK_VERSION = "v334"
   end
 end
diff --git a/lib/rake/deploy_check.rb b/lib/rake/deploy_check.rb
new file mode 100644
index 000000000..00cd8c080
--- /dev/null
+++ b/lib/rake/deploy_check.rb
@@ -0,0 +1,131 @@
+# A class for checking if a local copy of a repo can be tagged for a deploy
+#
+# Example:
+#
+#   deploy = DeployCheck.new(github: "heroku/heroku-buildpack-ruby")
+#   deploy.check! # Checks that current main branch matches what's on github
+#
+# It also can pull tag versions and determine the next sequential version automatically:
+#
+#   deploy.next_version # => "v999" # Assuming the last version was v998
+#
+# It can determine status of tags
+#
+#   deploy.push_tags? #=> false
+#
+class DeployCheck
+  attr_reader :github_url
+
+  def initialize(github:, next_version: ENV["RELEASE_VERSION"])
+    @github = github
+    @github_url = "https://github.com/#{github}"
+    @next_version = next_version
+    @remote_tag_array = nil
+  end
+
+  def check!
+    check_version!
+    check_unstaged!
+    check_branch!
+    check_changelog!
+    check_sync!
+  end
+
+  def push_tag?
+    return true if !tag_exists_on_remote?
+    return false if remote_tag_matches?
+
+    # Tag exists, but is not the same commit, raise an error
+    raise <<~EOM
+      The tag you're pushing (#{next_version}) to #{@github} already exists and does not have the same SHA.
+      You must resolve this manually.
+    EOM
+  end
+
+  # Returns tuthy value if the remote contains the next version already
+  def tag_exists_on_remote?
+    remote_tag_array.include?(next_version)
+  end
+
+  # Returns a truthy value if the remote tag SHA matches the current local sha
+  def remote_tag_matches?(remote_sha: remote_commit_sha(next_version), local_sha: local_commit_sha)
+    remote_sha == local_sha
+  end
+
+  # Raises an error if there are unstaged modifications
+  def check_unstaged!
+    run!("git diff --quiet HEAD") do
+      raise "Must have all changes committed. There are unstaged commits locally"
+    end
+  end
+
+  # Raises an error if not on the designated branch
+  def check_branch!(name = "main")
+    out = run("git rev-parse --abbrev-ref HEAD")
+    raise "Must be on main branch. Branch: #{out}" unless out == name
+  end
+
+  # Raises an error if the changelog does not have an entry with the designated version
+  def check_changelog!
+    if !File.read("CHANGELOG.md").include?("## [#{next_version}]")
+      raise "Expected CHANGELOG.md to include [#{next_version}] but it did not"
+    end
+  end
+
+  # Raises an error if the local sha does not match the remote sha
+  def check_sync!(local_sha: local_commit_sha, remote_sha: remote_commit_sha)
+    return if remote_sha == local_sha
+
+    raise <<~EOM
+      Must be in-sync with #{@github}. Local commit: #{local_sha.inspect} #{@github}: #{remote_sha.inspect}
+      "Make sure that you've pulled: `git pull --rebase #{@github_url} main`
+    EOM
+  end
+
+  def check_version!
+    version = next_version
+
+    raise "Must look like a version: #{version}. Must start with `v` and include only digits" unless version.match?(/^v\d+$/)
+  end
+
+  def local_commit_sha
+    run!("git rev-parse HEAD")
+  end
+
+  def remote_commit_sha(branch_or_tag = "main")
+    run!("git ls-remote #{@github_url} #{branch_or_tag}").split("\t").first
+  end
+
+  def run(cmd)
+    `#{cmd}`.strip
+  end
+
+  def next_version
+    @next_version || "v#{next_version_number}"
+  end
+
+  def remote_tag_array
+    @remote_tag_array ||= begin
+      cmd = +""
+      cmd << "git ls-remote --tags #{@github_url}"
+      cmd << "| awk '{print $2}' | cut -d '/' -f 3 | cut -d '^' -f 1"
+      run!(cmd).each_line.map(&:strip).select { |line| line.strip.match?(/^v\d+$/) } # https://rubular.com/r/8eFB9r8nOVrM7H
+    end
+  end
+
+  private def next_version_number
+    integer_tag_array = remote_tag_array.map { |line| line.sub(/^v/, "").to_i }.sort # Ascending order
+    integer_tag_array.last.next
+  end
+
+  def run!(cmd)
+    out = run(cmd)
+    return out if $?.success?
+
+    if block_given?
+      yield out, $?
+    else
+      raise "Command #{cmd} expected to return successfully did not: #{out.inspect}"
+    end
+  end
+end
diff --git a/spec/bugs_spec.rb b/spec/bugs_spec.rb
deleted file mode 100644
index 5bd177863..000000000
--- a/spec/bugs_spec.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-require_relative 'spec_helper'
-
-describe "Bugs" do
-  context "MRI 1.8.7" do
-    it "should install nokogiri" do
-      Hatchet::Runner.new("mri_187_nokogiri").deploy do |app|
-        expect(app.output).to match("Installing nokogiri")
-        expect(app.output).to match("Your bundle is complete!")
-      end
-    end
-  end
-
-  it "nokogiri should use the system libxml2" do
-    Hatchet::Runner.new("nokogiri_160").deploy do |app|
-      expect(app.output).to match("nokogiri")
-      expect(app.run("bundle exec nokogiri -v")).not_to include("WARNING: Nokogiri was built against LibXML version")
-    end
-  end
-end
diff --git a/spec/default_cache_spec.rb b/spec/default_cache_spec.rb
deleted file mode 100644
index b83c0ff90..000000000
--- a/spec/default_cache_spec.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-require 'spec_helper'
-
-describe "Default Cache" do
-  it "gets loaded successfully" do
-    Hatchet::Runner.new("default_ruby").deploy do |app|
-      expect(app.output).to match("loading default bundler cache")
-    end
-  end
-end
-
diff --git a/spec/fixtures/invalid_encoding.log b/spec/fixtures/invalid_encoding.log
new file mode 100644
index 000000000..16047e6e6
--- /dev/null
+++ b/spec/fixtures/invalid_encoding.log
@@ -0,0 +1 @@
+ACENOCUMAROL,AZECAR,1 MG 30 COMPRIMIDOS, ññóénñ
diff --git a/spec/fixtures/windows_lockfile/Gemfile b/spec/fixtures/windows_lockfile/Gemfile
new file mode 100644
index 000000000..e69de29bb
diff --git a/spec/fixtures/windows_lockfile/Gemfile.lock b/spec/fixtures/windows_lockfile/Gemfile.lock
new file mode 100644
index 000000000..4d0fc2948
--- /dev/null
+++ b/spec/fixtures/windows_lockfile/Gemfile.lock
@@ -0,0 +1,2 @@
+BUNDLED WITH
+   2.0.2
diff --git a/spec/gem_detect_errors_spec.rb b/spec/gem_detect_errors_spec.rb
deleted file mode 100644
index 5e311c92f..000000000
--- a/spec/gem_detect_errors_spec.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-require 'spec_helper'
-
-describe "Raise errors on specific gems" do
-  it "should should raise on sqlite3" do
-    Hatchet::Runner.new("sqlite3_gemfile", allow_failure: true).deploy do |app|
-      expect(app).not_to be_deployed
-      expect(app.output).to include("Detected sqlite3 gem which is not supported")
-      expect(app.output).to include("devcenter.heroku.com/articles/sqlite3")
-    end
-  end
-end
-
diff --git a/spec/hatchet/bugs_spec.rb b/spec/hatchet/bugs_spec.rb
new file mode 100644
index 000000000..8e2a80244
--- /dev/null
+++ b/spec/hatchet/bugs_spec.rb
@@ -0,0 +1,48 @@
+require_relative "../spec_helper"
+
+describe "Bugs" do
+  context "database connections" do
+    it "fails with better error message" do
+      Hatchet::Runner.new("ruby-getting-started", allow_failure: true).tap do |app|
+        app.before_deploy do
+          Pathname("Rakefile").write(<<~EOM)
+            require 'bundler'
+            Bundler.require(:default)
+
+            require 'active_record'
+
+            task "assets:precompile" do
+              # Try to connect to a database that doesn't exist yet
+              ActiveRecord::Base.establish_connection
+              ActiveRecord::Base.connection.execute("")
+            end
+          EOM
+        end
+
+        app.deploy do
+          expect(app.output).to match("https://devcenter.heroku.com/articles/pre-provision-database")
+        end
+      end
+    end
+
+    it "does not generate an incorrect DATABASE_URL if adapter is not known" do
+      Hatchet::Runner.new("rails_8_1_trilogy_db_minimal").tap do |app|
+        app.deploy do
+          # Works
+        end
+      end
+    end
+  end
+
+  it "detect fails when no Gemfile is present" do
+    Hatchet::Runner.new("default_ruby", allow_failure: true).tap do |app|
+      app.before_deploy do
+        FileUtils.rm("Gemfile")
+      end
+      app.deploy do |app|
+        expect(app.output).to include("A Ruby app on Heroku must have a 'Gemfile' and 'Gemfile.lock' in the root directory of its source code.")
+        expect(app).not_to be_deployed
+      end
+    end
+  end
+end
diff --git a/spec/hatchet/buildpack_spec.rb b/spec/hatchet/buildpack_spec.rb
new file mode 100644
index 000000000..dac1087a1
--- /dev/null
+++ b/spec/hatchet/buildpack_spec.rb
@@ -0,0 +1,14 @@
+require_relative "../spec_helper"
+
+describe "Buildpack internals" do
+  it "handles PATH with a newline in it correctly" do
+    buildpacks = [
+      "https://github.com/sharpstone/export_path_with_newlines_buildpack",
+      :default,
+      "https://github.com/heroku/null-buildpack"
+    ]
+    Hatchet::Runner.new("default_ruby", buildpacks: buildpacks).deploy do |app|
+      expect(app.output).to_not match("No such file or directory")
+    end
+  end
+end
diff --git a/spec/hatchet/bundler_spec.rb b/spec/hatchet/bundler_spec.rb
new file mode 100644
index 000000000..41f9f7b50
--- /dev/null
+++ b/spec/hatchet/bundler_spec.rb
@@ -0,0 +1,12 @@
+require "spec_helper"
+
+describe "Bundler" do
+  it "can be configured with BUNDLE_WITHOUT env var with spaces in it" do
+    Hatchet::Runner.new("default_ruby", config: {"BUNDLE_WITHOUT" => "foo bar baz"}).tap do |app|
+      app.deploy do
+        expect(app.output).to match("BUNDLE_WITHOUT='foo:bar:baz'")
+        expect(app.output).to match("Your BUNDLE_WITHOUT contains a space")
+      end
+    end
+  end
+end
diff --git a/spec/hatchet/ci_spec.rb b/spec/hatchet/ci_spec.rb
new file mode 100644
index 000000000..8661661c5
--- /dev/null
+++ b/spec/hatchet/ci_spec.rb
@@ -0,0 +1,280 @@
+require "spec_helper"
+
+describe "CI" do
+  it "Does not cause the double ruby rainbow bug" do
+    Hatchet::Runner.new("heroku-ci-json-example").run_ci do |test_run|
+      expect(test_run.status).to eq(:succeeded)
+
+      install_bundler_count = test_run.output.scan("Installing bundler").count
+      expect(install_bundler_count).to eq(1), "Expected output to only install bundler once but was found #{install_bundler_count} times. output:\n#{test_run.output}"
+    end
+  end
+
+  it "Works with Rails: ruby schema apps" do
+    Hatchet::Runner.new("rails_8_ruby_schema", stack: "heroku-24").tap do |app|
+      app.before_deploy do
+        Pathname("app.json").write(<<~EOF)
+          {
+            "environments": {
+              "test": {
+                "addons":[
+                  "heroku-postgresql:in-dyno"
+                ]
+              }
+            }
+          }
+        EOF
+      end
+
+      app.run_ci do |test_run|
+        expect(test_run.output).to match("db:schema:load completed")
+      end
+    end
+  end
+
+  it "Works with Rails: SQL schema apps" do
+    Hatchet::Runner.new("rails_8_sql_schema", stack: "heroku-24").tap do |app|
+      app.before_deploy do
+        Pathname("app.json").write(<<~EOF)
+          {
+            "environments": {
+              "test": {
+                "addons":[
+                  "heroku-postgresql:in-dyno"
+                ]
+              }
+            }
+          }
+        EOF
+      end
+
+      app.run_ci do |test_run|
+        expect(test_run.output).to match("db:schema:load completed")
+      end
+    end
+  end
+
+  it "Works with a vanilla ruby app" do
+    Hatchet::Runner.new("ruby_no_rails_test").run_ci do |test_run|
+      # Test no whitespace in front of output
+      expect(test_run.output).to_not match(/^ +Finished in/)
+      expect(test_run.output).to match(/^Finished in/)
+    end
+  end
+
+  it "Uses the cache" do
+    runner = Hatchet::Runner.new("ruby_no_rails_test")
+    runner.run_ci do |test_run|
+      fetching_rake = "Fetching rake"
+      expect(test_run.output).to match(fetching_rake)
+
+      test_run.run_again
+
+      expect(test_run.output).to_not match(fetching_rake)
+    end
+  end
+
+  it "Purges cache when stack changes" do
+    Hatchet::Runner.new("ruby_no_rails_test", stack: "heroku-24").tap do |app|
+      app.before_deploy do
+        Pathname("app.json").write(<<~EOF)
+          { "stack": "heroku-24" }
+        EOF
+      end
+
+      app.run_ci do |test_run|
+        expect(test_run.output).to include("Fetching rake")
+        expect(test_run.output).to_not include("Purging Cache")
+
+        # Change stack from heroku-24 to heroku-22
+        Pathname("app.json").write(<<~EOF)
+          { "stack": "heroku-22" }
+        EOF
+        run!("git add app.json && git commit -m 'Change stack to heroku-22'")
+
+        test_run.run_again
+
+        expect(test_run.output).to include("Purging Cache. Changing stack from heroku-24 to heroku-22")
+      end
+    end
+  end
+
+  it "CI build time config var behavior" do
+    script = "echo '## PRINTING ENV ##' && env | sort && echo '## PRINTING ENV DONE ##'; echo '## PRINTING BIN ## ' && ls -1 ./bin | sort && echo '## PRINTING BIN DONE ##'"
+
+    # First, capture env BEFORE the Ruby buildpack runs using an inline buildpack
+    before_output = nil
+    Hatchet::Runner.new("default_ruby", stack: DEFAULT_STACK, buildpacks: ["https://github.com/heroku/heroku-buildpack-inline.git"]).tap do |app|
+      app.before_deploy do
+        bin = Pathname("bin").tap(&:mkpath)
+        %w[detect compile release].each do |name|
+          path = bin.join(name)
+          FileUtils.touch(path)
+          FileUtils.chmod("+x", path)
+          path.write("#!/usr/bin/env bash\nexit 0\n")
+        end
+
+        Pathname("app.json").write(<<~EOF)
+          {
+            "environments": {
+              "test": {
+                "scripts": {
+                  "test": "#{script}"
+                }
+              }
+            }
+          }
+        EOF
+      end
+
+      app.run_ci do |test_run|
+        before_output = test_run.output
+      end
+    end
+
+    # Test `bin/test` auto executing code, which is a slightly different path than if they define a test task in the `app.json`
+    Hatchet::Runner.new("default_ruby", stack: DEFAULT_STACK).tap do |app|
+      app.before_deploy do
+        bin_rake = Pathname("bin").tap(&:mkpath).join("rake")
+        bin_rake.write(<<~EOF)
+          #!/usr/bin/env bash
+
+          # Only print markers when running the test task, not for other rake invocations
+          # like `rake -v` which the buildpack uses to detect rake tasks
+          if [[ "$1" == "test" ]]; then
+            #{script}
+          else
+            # Remove this script's directory from PATH to find the real rake
+            SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+            PATH="${PATH//$SCRIPT_DIR:/}" exec rake "$@"
+          fi
+        EOF
+        FileUtils.chmod("+x", bin_rake)
+
+        Pathname("Rakefile").write("") # triggers `rake test`
+      end
+
+      app.run_ci do |test_run|
+        combined_output = before_output + "\n" + test_run.output
+        diff = EnvDiff.new(combined_output, build_dir_pattern: nil)
+
+        expect(diff.added.join("\n")).to eq(<<~EOF.strip)
+          BUNDLE_BIN=vendor/bundle/bin
+          BUNDLE_DEPLOYMENT=1
+          BUNDLE_PATH=vendor/bundle
+          BUNDLE_WITHOUT=development
+          DISABLE_SPRING=1
+          GEM_PATH=/app/vendor/bundle/ruby/3.3.0:
+          LANG=en_US.UTF-8
+          MALLOC_ARENA_MAX=2
+          PUMA_PERSISTENT_TIMEOUT=95
+          RACK_ENV=test
+        EOF
+
+        expect(diff.path_after).to include(diff.path_before)
+        expect(diff.path_after).to start_with(
+          [
+            "/app/bin",
+            "/app/vendor/bundle/bin",
+            "/app/vendor/bundle/ruby/3.3.0/bin"
+          ].join(":")
+        )
+
+        expect(extract_remote_lines(test_run.output, start_marker: "## PRINTING BIN ##", end_marker: "## PRINTING BIN DONE ##"))
+          .to eq([
+            "rake", # `bin/rake` is explicitly added in this test
+            "erb", "gem", "irb", "racc", "rbs", "rdbg", "rdoc", "ri", "ruby", "ruby.exe", "syntax_suggest", "typeprof"
+          ].sort)
+      end
+    end
+  end
+
+  it "works" do
+    Hatchet::Runner.new("default_ruby", stack: DEFAULT_STACK).tap do |app|
+      app.before_deploy do
+        Pathname("Gemfile").write(<<~EOF)
+          source 'http://rubygems.org'
+          ruby '3.1.3'
+          gem 'sinatra'
+          gem 'puma'
+          gem 'rack'
+          gem 'rake'
+          gem 'rackup'
+
+          group :test do
+            gem 'rack-test'
+            gem 'test-unit'
+          end
+        EOF
+
+        Pathname("Gemfile.lock").write(<<~EOF)
+          GEM
+            remote: http://rubygems.org/
+            specs:
+              base64 (0.3.0)
+              logger (1.7.0)
+              mustermann (3.0.4)
+                ruby2_keywords (~> 0.0.1)
+              nio4r (2.7.4)
+              power_assert (3.0.1)
+              puma (7.1.0)
+                nio4r (~> 2.0)
+              rack (3.2.4)
+              rack-protection (4.2.1)
+                base64 (>= 0.1.0)
+                logger (>= 1.6.0)
+                rack (>= 3.0.0, < 4)
+              rack-session (2.1.1)
+                base64 (>= 0.1.0)
+                rack (>= 3.0.0)
+              rack-test (2.2.0)
+                rack (>= 1.3)
+              rackup (2.3.1)
+                rack (>= 3)
+              rake (13.3.1)
+              ruby2_keywords (0.0.5)
+              sinatra (4.2.1)
+                logger (>= 1.6.0)
+                mustermann (~> 3.0)
+                rack (>= 3.0.0, < 4)
+                rack-protection (= 4.2.1)
+                rack-session (>= 2.0.0, < 3)
+                tilt (~> 2.0)
+              test-unit (3.7.3)
+                power_assert
+              tilt (2.6.1)
+
+          PLATFORMS
+            ruby
+
+          DEPENDENCIES
+            puma
+            rack
+            rack-test
+            rackup
+            rake
+            sinatra
+            test-unit
+
+          RUBY VERSION
+             ruby 3.1.3p185
+
+          BUNDLED WITH
+             2.3.26
+
+        EOF
+
+        Pathname("Rakefile").write(<<~EOF)
+          require 'bundler/setup'
+          require 'rake/testtask'
+
+          Rake::TestTask.new
+        EOF
+      end
+
+      app.run_ci do |test_run|
+        expect(test_run.output).to match("Fetching rake")
+      end
+    end
+  end
+end
diff --git a/spec/hatchet/getting_started_spec.rb b/spec/hatchet/getting_started_spec.rb
new file mode 100644
index 000000000..56d802de0
--- /dev/null
+++ b/spec/hatchet/getting_started_spec.rb
@@ -0,0 +1,72 @@
+require_relative "../spec_helper"
+
+describe "Heroku ruby getting started" do
+  it "works on Heroku-24" do
+    Hatchet::Runner.new("ruby-getting-started", stack: "heroku-24").deploy do |app|
+      expect(app.output).to_not include("Purging Cache")
+      expect(app.output).to include("Fetching puma")
+
+      secret_key_base = app.run("echo $SECRET_KEY_BASE")
+
+      set_bundler_version(version: "2.6.9")
+
+      # Re-deploy with cache
+      run!("git add .; git commit -m 'Change bundler version'")
+      app.push!
+
+      # Assert used cached gems
+      expect(app.output).to_not include("Fetching puma")
+      expect(app.output).to include("Gems included by the bundle") # bundle list output
+
+      # Assert no warnings from `cp`
+      # https://github.com/heroku/heroku-buildpack-ruby/pull/1586/files#r2064284286
+      expect(app.output).to_not include("cp --help")
+      expect(app.run("command -v ruby").strip).to eq("/app/bin/ruby")
+
+      environment_variables = app.run("env")
+      expect(environment_variables).to match("PUMA_PERSISTENT_TIMEOUT")
+      # Assert cached value persisted
+      expect(environment_variables).to match("SECRET_KEY_BASE=#{secret_key_base}")
+
+      profile_d = app.run("cat .profile.d/ruby.sh")
+        .strip
+        .split("\n")
+        .sort
+        .join("\n")
+
+      # SECRET_KEY_BASE has a non-deterministic value, assert it exists but remove from equality check
+      expect(profile_d).to match("SECRET_KEY_BASE")
+      profile_d.gsub!(/$\s*export SECRET_KEY_BASE=.*/, "")
+
+      expect(profile_d).to eq(<<~EOF.strip)
+        export BUNDLE_BIN=${BUNDLE_BIN:-vendor/bundle/bin}
+        export BUNDLE_DEPLOYMENT=${BUNDLE_DEPLOYMENT:-1}
+        export BUNDLE_PATH=${BUNDLE_PATH:-vendor/bundle}
+        export BUNDLE_WITHOUT=${BUNDLE_WITHOUT:-development:test}
+        export DISABLE_SPRING="1"
+        export GEM_PATH="$HOME/vendor/bundle/ruby/3.4.0:$GEM_PATH"
+        export LANG=${LANG:-en_US.UTF-8}
+        export MALLOC_ARENA_MAX=${MALLOC_ARENA_MAX:-2}
+        export PATH="$HOME/bin:$HOME/vendor/bundle/bin:$HOME/vendor/bundle/ruby/3.4.0/bin:$PATH"
+        export PUMA_PERSISTENT_TIMEOUT=${PUMA_PERSISTENT_TIMEOUT:-95}
+        export RACK_ENV=${RACK_ENV:-production}
+        export RAILS_ENV=${RAILS_ENV:-production}
+        export RAILS_LOG_TO_STDOUT=${RAILS_LOG_TO_STDOUT:-enabled}
+        export RAILS_SERVE_STATIC_FILES=${RAILS_SERVE_STATIC_FILES:-enabled}
+      EOF
+    end
+  end
+
+  it "works on Heroku-22" do
+    Hatchet::Runner.new("ruby-getting-started", stack: "heroku-22").deploy do |app|
+      # Re-deploy with cache
+      run!("git commit --allow-empty -m empty")
+      app.push!
+
+      # Assert no warnings from `cp`
+      # https://github.com/heroku/heroku-buildpack-ruby/pull/1586/files#r2064284286
+      expect(app.output).to_not include("cp --help")
+      expect(app.run("command -v ruby").strip).to eq("/app/bin/ruby")
+    end
+  end
+end
diff --git a/spec/hatchet/node_spec.rb b/spec/hatchet/node_spec.rb
new file mode 100644
index 000000000..52caae77d
--- /dev/null
+++ b/spec/hatchet/node_spec.rb
@@ -0,0 +1,44 @@
+require "spec_helper"
+
+describe "Node and Yarn" do
+  it "works without the node buildpack" do
+    buildpacks = [
+      :default,
+      "https://github.com/sharpstone/force_absolute_paths_buildpack"
+    ]
+    config = {FORCE_ABSOLUTE_PATHS_BUILDPACK_IGNORE_PATHS: "BUNDLE_PATH"}
+
+    Hatchet::Runner.new("minimal_webpacker", buildpacks: buildpacks, config: config).deploy do |app, heroku|
+      # https://rubular.com/r/4bkL8fYFTQwt0Q
+      expect(app.output).to match(/vendor\/yarn-v\d+\.\d+\.\d+\/bin\/yarn is the yarn directory/)
+      expect(app.output).to_not include(".heroku/yarn/bin/yarn is the yarn directory")
+
+      expect(app.output).to include("bin/node is the node directory")
+      expect(app.output).to_not include(".heroku/node/bin/node is the node directory")
+
+      expect(app.output).to include("Installing a default version (#{LanguagePack::Helpers::Nodebin::YARN_VERSION}) of Yarn")
+      expect(app.output).to include("Installing a default version (#{LanguagePack::Helpers::Nodebin::NODE_VERSION}) of Node.js")
+
+      expect(app.run("command -v node")).to match("/app/bin/node")     # We put node in bin/node
+      expect(app.run("command -v yarn")).to match("/app/vendor/yarn-") # We put yarn in /app/vendor/yarn-
+    end
+  end
+
+  it "works with the node buildpack" do
+    buildpacks = [
+      "heroku/nodejs",
+      :default,
+      "https://github.com/sharpstone/force_absolute_paths_buildpack"
+    ]
+    config = {FORCE_ABSOLUTE_PATHS_BUILDPACK_IGNORE_PATHS: "BUNDLE_PATH"}
+
+    Hatchet::Runner.new("minimal_webpacker", buildpacks: buildpacks, config: config).deploy do |app, heroku|
+      expect(app.output).to include("yarn install")
+      expect(app.output).to include(".heroku/node/bin/yarn is the yarn directory ")
+      expect(app.output).to include(".heroku/node/bin/node is the node directory")
+
+      expect(app.run("command -v node")).to match("/app/.heroku/node/bin")
+      expect(app.run("command -v yarn")).to match("/app/.heroku/node/bin")
+    end
+  end
+end
diff --git a/spec/hatchet/rails23_spec.rb b/spec/hatchet/rails23_spec.rb
new file mode 100644
index 000000000..9d42926a4
--- /dev/null
+++ b/spec/hatchet/rails23_spec.rb
@@ -0,0 +1,13 @@
+require_relative "../spec_helper"
+
+describe "Rails 2.3.x" do
+  it "should deploy" do
+    skip("Need RAILS_LTS_CREDS env var set") unless ENV["RAILS_LTS_CREDS"]
+
+    Hatchet::Runner.new("rails_lts_23_default_ruby", config: rails_lts_config, stack: rails_lts_stack).tap do |app|
+      app.deploy do
+        # assert deploy is successful
+      end
+    end
+  end
+end
diff --git a/spec/hatchet/rails3_spec.rb b/spec/hatchet/rails3_spec.rb
new file mode 100644
index 000000000..210607ccf
--- /dev/null
+++ b/spec/hatchet/rails3_spec.rb
@@ -0,0 +1,29 @@
+require_relative "../spec_helper"
+
+describe "Rails 3.x" do
+  it "should deploy and inject plugins" do
+    skip("Need RAILS_LTS_CREDS env var set") unless ENV["RAILS_LTS_CREDS"]
+
+    Hatchet::Runner.new("rails3_default_ruby", config: rails_lts_config, stack: rails_lts_stack).tap do |app|
+      app.before_deploy do
+        set_lts_ruby_version
+        set_bundler_version(version: :default)
+      end
+
+      app.deploy do
+        # Rails 3 doesn't work with Postgres 8+ out of the box and Rails
+        # LTS hasn't patched this yet. We're skipping asset compilation for now
+        # by deleting the Rakefile
+        #
+        # expect(app.output).to include("Asset precompilation completed")
+
+        expect(app.output).to match("WARNING")
+        expect(app.output).to match("Add 'rails_12factor' gem to your Gemfile to skip plugin injection")
+
+        ls = app.run("ls vendor/plugins")
+        expect(ls).to match("rails3_serve_static_assets")
+        expect(ls).to match("rails_log_stdout")
+      end
+    end
+  end
+end
diff --git a/spec/hatchet/rails4_spec.rb b/spec/hatchet/rails4_spec.rb
new file mode 100644
index 000000000..cdd1b5234
--- /dev/null
+++ b/spec/hatchet/rails4_spec.rb
@@ -0,0 +1,60 @@
+require_relative "../spec_helper"
+
+describe "Rails 4.x" do
+  it "should be able to run a migration without heroku specific database.yml" do
+    skip("Need RAILS_LTS_CREDS env var set") unless ENV["RAILS_LTS_CREDS"]
+
+    Hatchet::Runner.new("rails42_default_ruby", config: rails_lts_config, stack: rails_lts_stack).tap do |app|
+      app.before_deploy do
+        set_lts_ruby_version
+        set_bundler_version(version: :default)
+      end
+      app.deploy do
+        # it Don't over-write database.yml
+        expect(app.output).not_to include("Writing config/database.yml to read from DATABASE_URL")
+
+        # it sets RAILS_SERVE_STATIC_FILES env var
+        output = app.run("rails runner 'puts ENV[%Q{RAILS_SERVE_STATIC_FILES}].present?'")
+        expect(output).to match(/true/)
+      end
+    end
+  end
+
+  it "should skip asset compilation when deployed with NEW manifest file" do
+    skip("Need RAILS_LTS_CREDS env var set") unless ENV["RAILS_LTS_CREDS"]
+
+    Hatchet::Runner.new("rails42_default_ruby", config: rails_lts_config, stack: rails_lts_stack).tap do |app|
+      app.before_deploy do
+        set_lts_ruby_version
+        set_bundler_version(version: :default)
+        Pathname("public/assets/manifest-ccf61eade4793995271564a4767ce6b6.json").tap { |p|
+          p.dirname.mkpath
+          FileUtils.touch(p)
+        }
+      end
+
+      app.deploy do
+        expect(app.output).to include("Detected manifest file, assuming assets were compiled locally")
+      end
+    end
+  end
+
+  it "should skip asset compilation when deployed with OLD manifest file" do
+    skip("Need RAILS_LTS_CREDS env var set") unless ENV["RAILS_LTS_CREDS"]
+
+    Hatchet::Runner.new("rails42_default_ruby", config: rails_lts_config, stack: rails_lts_stack).tap do |app|
+      app.before_deploy do
+        set_lts_ruby_version
+        set_bundler_version(version: :default)
+        Pathname("public/assets/.sprockets-manifest-040763ccc5036260c52c6adcf77d73f7.json").tap { |p|
+          p.dirname.mkpath
+          FileUtils.touch(p)
+        }
+      end
+
+      app.deploy do
+        expect(app.output).to include("Detected manifest file, assuming assets were compiled locally")
+      end
+    end
+  end
+end
diff --git a/spec/hatchet/rails6_spec.rb b/spec/hatchet/rails6_spec.rb
new file mode 100644
index 000000000..e28470e2b
--- /dev/null
+++ b/spec/hatchet/rails6_spec.rb
@@ -0,0 +1,33 @@
+require_relative "../spec_helper"
+
+describe "Rails 6" do
+  it "should detect successfully" do
+    Hatchet::App.new("rails61").in_directory_fork do
+      bundler = LanguagePack::Helpers::BundlerWrapper.new(
+        bundler_path: Dir.mktmpdir,
+        bundler_version: LanguagePack::Helpers::BundlerWrapper::DEFAULT_VERSION
+      )
+      expect(LanguagePack::Rails5.use?(bundler: bundler)).to eq(false)
+      expect(LanguagePack::Rails6.use?(bundler: bundler)).to eq(true)
+    end
+  end
+
+  it "deploys and serves web requests via puma" do
+    before_deploy = proc do
+      run! "echo 'web: bundle exec puma -t 5:5 -p ${PORT:-3000} -e ${RACK_ENV:-development}' > Procfile"
+
+      # Test Clean task does not get called if it does not exist
+      # This file will only have the `assets:precompile` task in it, but not `assets:clean`
+      run! %(echo 'task "assets:precompile" do ; end' > Rakefile)
+    end
+
+    Hatchet::Runner.new("rails61", before_deploy: before_deploy, config: rails_lts_config, stack: rails_lts_stack).deploy do |app|
+      expect(app.output).to match("Fetching railties 6")
+
+      expect(app.output).to match("rake assets:precompile")
+      expect(app.output).to_not match("rake assets:clean")
+
+      expect(web_boot_status(app)).to_not eq("crashed")
+    end
+  end
+end
diff --git a/spec/hatchet/rails7_spec.rb b/spec/hatchet/rails7_spec.rb
new file mode 100644
index 000000000..853fd1142
--- /dev/null
+++ b/spec/hatchet/rails7_spec.rb
@@ -0,0 +1,30 @@
+require_relative "../spec_helper"
+
+describe "Rails 7" do
+  it "should detect successfully" do
+    Hatchet::App.new("rails-jsbundling").in_directory_fork do
+      bundler = LanguagePack::Helpers::BundlerWrapper.new(
+        bundler_path: Dir.mktmpdir,
+        bundler_version: LanguagePack::Helpers::BundlerWrapper::DEFAULT_VERSION
+      )
+      expect(LanguagePack::Rails6.use?(bundler: bundler)).to eq(false)
+      expect(LanguagePack::Rails7.use?(bundler: bundler)).to eq(true)
+    end
+  end
+
+  it "works with jsbundling" do
+    Hatchet::Runner.new("rails-jsbundling").tap do |app|
+      app.deploy do
+        expect(app.output).to include("yarn install")
+        expect(app.output).to include("Asset precompilation completed")
+      end
+    end
+  end
+
+  it "Works on Heroku CI" do
+    Hatchet::Runner.new("rails-jsbundling").run_ci do |test_run|
+      expect(test_run.output).to match("db:schema:load")
+      expect(test_run.output).to match("db:migrate")
+    end
+  end
+end
diff --git a/spec/hatchet/rails8_spec.rb b/spec/hatchet/rails8_spec.rb
new file mode 100644
index 000000000..4edc92ff4
--- /dev/null
+++ b/spec/hatchet/rails8_spec.rb
@@ -0,0 +1,4 @@
+require_relative "../spec_helper"
+
+describe "Rails 8" do
+end
diff --git a/spec/hatchet/rubies_spec.rb b/spec/hatchet/rubies_spec.rb
new file mode 100644
index 000000000..1407532e2
--- /dev/null
+++ b/spec/hatchet/rubies_spec.rb
@@ -0,0 +1,85 @@
+require_relative "../spec_helper"
+
+describe "Ruby versions" do
+  it "should deploy jdk on heroku-24" do
+    Hatchet::Runner.new("default_ruby", stack: "heroku-24").tap do |app|
+      app.before_deploy do |app|
+        Pathname("Gemfile.lock").write(<<~EOM)
+          GEM
+            remote: https://rubygems.org/
+            specs:
+              rack (3.1.8)
+              rake (13.2.1)
+              webrick (1.9.1)
+
+          PLATFORMS
+            java
+
+          DEPENDENCIES
+            rack
+            rake
+            webrick
+
+          RUBY VERSION
+             ruby 3.1.4p0 (jruby 9.4.8.0)
+
+          BUNDLED WITH
+             2.5.23
+        EOM
+
+        Pathname("Rakefile").write(<<~'EOM')
+          task "assets:precompile" do
+            puts "JRUBY_OPTS is: #{ENV['JRUBY_OPTS']}"
+          end
+        EOM
+      end
+
+      app.deploy do
+        expect(app.output).to match("JRUBY_OPTS is: -Xcompile.invokedynamic=false")
+
+        app.set_config("JRUBY_BUILD_OPTS" => "--dev")
+        app.commit!
+        app.push!
+        expect(app.output).to match("JRUBY_OPTS is: --dev")
+
+        expect(app.run("ruby -v")).to match("jruby")
+      end
+    end
+  end
+end
+
+describe "Ruby on heroku-26" do
+  it "deploys Ruby 3.3.10" do
+    Hatchet::Runner.new("default_ruby", stack: "heroku-26").tap do |app|
+      app.before_deploy do
+        set_ruby_version(version: "3.3.10")
+      end
+
+      app.deploy do |app|
+        expect(app.run("command -v ruby").strip).to eq("/app/bin/ruby")
+      end
+    end
+  end
+end
+
+describe "Upgrading ruby apps" do
+  it "works when changing versions" do
+    version = "3.3.1"
+    expect(version).to_not eq(LanguagePack::RubyVersion::DEFAULT_VERSION_NUMBER)
+    app = Hatchet::Runner.new("default_ruby", stack: DEFAULT_STACK)
+    app.deploy do |app|
+      # default version
+      expect(app.run("env | grep MALLOC_ARENA_MAX")).to match("MALLOC_ARENA_MAX=2")
+      expect(app.run("env | grep DISABLE_SPRING")).to match("DISABLE_SPRING=1")
+
+      # Deploy again
+      set_ruby_version(version: version)
+
+      run!("git add -A; git commit -m update-ruby")
+      app.push!
+      expect(app.output).to match(version)
+      expect(app.run("ruby -v")).to match(version)
+      expect(app.output).to match("Ruby version change detected")
+    end
+  end
+end
diff --git a/spec/hatchet/ruby_spec.rb b/spec/hatchet/ruby_spec.rb
new file mode 100644
index 000000000..7182ef3c5
--- /dev/null
+++ b/spec/hatchet/ruby_spec.rb
@@ -0,0 +1,399 @@
+require_relative "../spec_helper"
+
+describe "Ruby apps" do
+  # https://github.com/heroku/heroku-buildpack-ruby/issues/1025
+  describe "bin/rake binstub" do
+    it "loads git gems at build time when executing `rake`" do
+      Hatchet::Runner.new("git_gemspec").tap do |app|
+        app.before_deploy do
+          File.open("Rakefile", "w+") do |f|
+            f.puts(<<~EOF)
+              task "assets:precompile" do
+                require 'mini_histogram'
+                puts "successfully loaded git gem"
+              end
+            EOF
+          end
+        end
+        app.deploy do
+          expect(app.output).to match("successfully loaded git gem")
+          expect(app.run("rake assets:precompile")).to match("successfully loaded git gem")
+        end
+      end
+    end
+
+    it "loads bundler into memory" do
+      Hatchet::Runner.new("default_ruby").tap do |app|
+        app.before_deploy do
+          File.open("Rakefile", "w+") do |f|
+            f.puts(<<~EOF)
+              task "assets:precompile" do
+                puts Bundler.methods
+
+                puts "bundler loaded in rake context"
+              end
+            EOF
+          end
+        end
+        app.deploy do
+          expect(app.output).to match("bundler loaded in rake context")
+          expect(app.run("rake assets:precompile")).to match("bundler loaded in rake context")
+        end
+      end
+    end
+
+    it "loads custom rake binstub" do
+      Hatchet::Runner.new("default_ruby").tap do |app|
+        app.before_deploy do
+          FileUtils.mkdir_p("bin")
+
+          File.open("bin/rake", "w+") do |f|
+            f.puts(<<~EOF)
+              #!/usr/bin/env ruby
+
+              puts "rake assets:precompile" # Needed to trigger the `rake -P` task detection
+              puts "custom rake binstub called"
+            EOF
+          end
+          FileUtils.chmod("+x", "bin/rake")
+        end
+        app.deploy do
+          expect(app.output).to match("custom rake binstub called")
+          # Using `bash -c rake` here to bypass heroku process type detection because
+          # rake = "bundle exec rake" is a process type. This test was previously
+          # relying on a bug in the CLI that was fixed in https://github.com/heroku/cli/pull/3804.
+          # The goal is to test PATH resolution at build/runtime.
+          expect(app.run("bash -c rake")).to match("custom rake binstub called")
+        end
+      end
+    end
+  end
+
+  describe "bad ruby version" do
+    it "gives a helpful error" do
+      Hatchet::Runner.new("default_ruby", allow_failure: true, stack: DEFAULT_STACK).tap do |app|
+        app.before_deploy do
+          set_ruby_version(version: "2.9.0.lol")
+        end
+
+        app.deploy do |app|
+          expect(app.output).to match("The Ruby version you are trying to install does not exist: ruby-2.9.0.lol")
+        end
+      end
+    end
+  end
+
+  describe "exporting path" do
+    it "puts local bin dir in path" do
+      before_deploy = proc do
+        FileUtils.mkdir_p("bin")
+        File.open("bin/bloop", "w+") do |f|
+          f.puts(<<~EOF)
+            #!/usr/bin/env bash
+
+            echo "bloop"
+          EOF
+        end
+        FileUtils.chmod("+x", "bin/bloop")
+
+        File.open("Rakefile", "a") do |f|
+          f.puts(<<~EOF)
+            task "run:bloop" do
+              puts `bloop`
+              raise "Could not bloop" unless $?.success?
+            end
+          EOF
+        end
+      end
+      buildpacks = [
+        :default,
+        "https://github.com/schneems/buildpack-ruby-rake-deploy-tasks"
+      ]
+      config = {"DEPLOY_TASKS" => "run:bloop"}
+      Hatchet::Runner.new("default_ruby", stack: DEFAULT_STACK, buildpacks: buildpacks, config: config, before_deploy: before_deploy).deploy do |app|
+        expect(app.output).to match("bloop")
+      end
+    end
+  end
+
+  describe "running Ruby from outside the default dir" do
+    it "works" do
+      buildpacks = [
+        :default,
+        "https://github.com/sharpstone/force_absolute_paths_buildpack",
+        "https://github.com/heroku/heroku-buildpack-inline.git"
+      ]
+      config = {FORCE_ABSOLUTE_PATHS_BUILDPACK_IGNORE_PATHS: "BUNDLE_PATH"}
+
+      Hatchet::Runner.new("default_ruby", stack: DEFAULT_STACK, buildpacks: buildpacks, config: config).tap do |app|
+        app.before_deploy do
+          Pathname("Gemfile").write(<<~EOF)
+            source "https://rubygems.org"
+
+            gem "rake"
+          EOF
+
+          Pathname("Gemfile.lock").write(<<~EOF)
+            GEM
+              remote: https://rubygems.org/
+              specs:
+                rake (13.0.6)
+
+            PLATFORMS
+              ruby
+              x86_64-darwin-20
+
+            DEPENDENCIES
+              rake
+
+            RUBY VERSION
+               ruby 3.3.1p0
+          EOF
+
+          Pathname("Rakefile").write(<<~'EOF')
+            task "assets:precompile" do
+              out = `cd client && bundle exec ruby -v`
+              puts "cd version #{out}"
+              unless $?.success?
+                puts "Failed: #{out}"
+                exit 1
+              end
+            end
+          EOF
+
+          Pathname(".ruby-version").write("3.3.1")
+
+          dir = Pathname("client")
+          dir.mkpath
+          FileUtils.touch(dir.join(".gitkeep"))
+
+          # Inline buildpack to ensure build_report file is emitted on compile
+          bin = Pathname("bin").tap(&:mkpath)
+          detect = bin.join("detect")
+          compile = bin.join("compile")
+          release = bin.join("release")
+
+          [detect, compile, release].each do |path|
+            FileUtils.touch(path)
+            FileUtils.chmod("+x", path)
+            path.write(<<~EOF)
+              #!/usr/bin/env bash
+              exit 0
+            EOF
+          end
+
+          bash_functions_contents = Pathname(__dir__).join("..").join("..").join("bin").join("support").join("bash_functions.sh").read
+          bin.join("support").tap(&:mkpath).join("bash_functions.sh").write(<<~EOF)
+            #{bash_functions_contents}
+          EOF
+
+          compile.write(<<~EOF)
+            #!/usr/bin/env bash
+
+            echo "## PRINTING REPORT FILE ##"
+            #{Pathname(__dir__).join("..").join("..").join("bin").join("report").read}
+            echo "## REPORT FILE DONE ##"
+          EOF
+        end
+
+        app.deploy do |app|
+          expected = "3.3.1"
+          expect(expected).to_not eq(LanguagePack::RubyVersion::DEFAULT_VERSION_NUMBER)
+          expect(app.output).to match("cd version ruby #{expected}")
+          begin
+            report_match = app.output.match(/## PRINTING REPORT FILE ##(?.*)## REPORT FILE DONE/m) # https://rubular.com/r/FfaV5AEstigaMO
+            expect(report_match).to be_truthy
+            yaml = report_match[:yaml].gsub("remote: ", "")
+            report = YAML.load(yaml)
+            expect(report.fetch("ruby_version_full")).to eq(expected)
+            expect(report.fetch("dot_ruby_version.version")).to eq(expected)
+            expect(report.fetch("dot_ruby_version.vs_gemfile_lock")).to eq("match")
+          rescue Exception => e # standard:disable Lint/RescueException
+            puts app.output
+            puts yaml if yaml
+            puts report.inspect if report
+            raise e
+          end
+
+          expect(app.run("command -v ruby").strip).to eq("/app/bin/ruby")
+        end
+      end
+    end
+  end
+
+  describe "database configuration" do
+    context "no active record" do
+      it "writes a heroku specific database.yml" do
+        Hatchet::Runner.new("default_ruby").deploy do |app, heroku|
+          expect(app.output).to include("Writing config/database.yml to read from DATABASE_URL")
+          expect(app.output).not_to include("Your app was upgraded to bundler")
+          expect(app.output).not_to include("Your Ruby version is not present on the next stack")
+        end
+      end
+    end
+
+    context "active record 4.1+" do
+      it "doesn't write a heroku specific database.yml" do
+        Hatchet::Runner.new("rails61", config: rails_lts_config, stack: rails_lts_stack).tap do |app|
+          app.deploy do
+            expect(app.output).not_to include("Writing config/database.yml to read from DATABASE_URL")
+          end
+        end
+      end
+    end
+  end
+end
+
+describe "Raise errors on specific gems" do
+  it "should raise on sqlite3" do
+    Hatchet::Runner.new("sqlite3_gemfile", allow_failure: true).deploy do |app|
+      expect(app).not_to be_deployed
+      expect(app.output).to include("Detected sqlite3 gem which is not supported")
+      expect(app.output).to include("devcenter.heroku.com/articles/sqlite3")
+    end
+  end
+end
+
+describe "No Lockfile" do
+  it "should not deploy" do
+    Hatchet::Runner.new("no_lockfile", allow_failure: true).deploy do |app|
+      expect(app).not_to be_deployed
+      expect(app.output).to include("Gemfile.lock required")
+    end
+  end
+end
+
+describe "Rack" do
+  it "should not overwrite already set environment variables" do
+    custom_env = SecureRandom.hex(16)
+    app = Hatchet::Runner.new("default_ruby", config: {"RACK_ENV" => custom_env, "BUNDLE_SIMULATE_VERSION" => "4"})
+    app.before_deploy do
+      Pathname("Rakefile").write(<<~'EOF')
+        task "assets:precompile" do
+          puts "Build time RACK_ENV: #{ENV["RACK_ENV"]}"
+        end
+      EOF
+
+      set_bundler_version(version: "2.7.2")
+    end
+
+    app.deploy do |app|
+      # Assert build time user provided value takes precedence over default
+      expect(app.output).to match("Build time RACK_ENV: #{custom_env}")
+
+      # Assert runtime/launch user provided value takes precedence over default
+      environment_variables = app.run("env")
+      expect(environment_variables).to match("RACK_ENV=#{custom_env}")
+      expect(environment_variables).to match("PUMA_PERSISTENT_TIMEOUT")
+
+      profile_d = app.run("cat .profile.d/ruby.sh")
+        .strip
+        .split("\n")
+        .sort
+        .join("\n")
+
+      expect(profile_d).to eq(<<~EOF.strip)
+        export BUNDLE_BIN=${BUNDLE_BIN:-vendor/bundle/bin}
+        export BUNDLE_DEPLOYMENT=${BUNDLE_DEPLOYMENT:-1}
+        export BUNDLE_PATH=${BUNDLE_PATH:-vendor/bundle}
+        export BUNDLE_WITHOUT=${BUNDLE_WITHOUT:-development:test}
+        export DISABLE_SPRING="1"
+        export GEM_PATH="$HOME/vendor/bundle/ruby/3.3.0:$GEM_PATH"
+        export LANG=${LANG:-en_US.UTF-8}
+        export MALLOC_ARENA_MAX=${MALLOC_ARENA_MAX:-2}
+        export PATH="$HOME/bin:$HOME/vendor/bundle/bin:$HOME/vendor/bundle/ruby/3.3.0/bin:$PATH"
+        export PUMA_PERSISTENT_TIMEOUT=${PUMA_PERSISTENT_TIMEOUT:-95}
+        export RACK_ENV=${RACK_ENV:-production}
+      EOF
+    end
+  end
+end
+
+describe "build time config var behavior" do
+  it "works" do
+    # Print out the `env` of the build process before and after the Ruby buildpack and diff the results
+    buildpacks = [
+      "https://github.com/heroku/heroku-buildpack-inline.git",
+      :default,
+      "https://github.com/heroku/heroku-buildpack-inline.git"
+    ]
+
+    Hatchet::Runner.new("default_ruby", stack: DEFAULT_STACK, buildpacks: buildpacks).tap do |app|
+      app.before_deploy do
+        bin = Pathname("bin").tap(&:mkpath)
+        detect = bin.join("detect")
+        compile = bin.join("compile")
+        release = bin.join("release")
+
+        [detect, compile, release].each do |path|
+          FileUtils.touch(path)
+          FileUtils.chmod("+x", path)
+          path.write(<<~EOF)
+            #!/usr/bin/env bash
+            exit 0
+          EOF
+        end
+
+        compile.write(<<~EOF)
+          #!/usr/bin/env bash
+          set -euo pipefail
+
+          BUILD_DIR=$1
+          echo "BUILD_DIR: $BUILD_DIR"
+
+          echo "## PRINTING ENV ##"
+          env | sort
+          echo "## PRINTING ENV DONE ##"
+          exit 0
+        EOF
+      end
+
+      app.deploy do
+        diff = EnvDiff.new(app.output)
+
+        expect(diff.added.join("\n")).to eq(<<~EOF.strip)
+          BUNDLE_BIN=vendor/bundle/bin
+          BUNDLE_DEPLOYMENT=1
+          BUNDLE_PATH=vendor/bundle
+          BUNDLE_WITHOUT=development:test
+          GEM_PATH=/vendor/bundle/ruby/3.3.0:
+          PUMA_PERSISTENT_TIMEOUT=95
+          RACK_ENV=production
+        EOF
+
+        expect(diff.path_after).to include(diff.path_before)
+        expect(diff.path_after).to include(
+          [
+            "/bin",
+            "/vendor/bundle/bin",
+            "/vendor/bundle/ruby/3.3.0/bin",
+            "/vendor/ruby-3.3.9/bin"
+          ].join(":")
+        )
+      end
+    end
+  end
+end
+
+describe "WEB_CONCURRENCY.sh" do
+  it "from a preceding buildpack is overwritten by this buildpack" do
+    buildpacks = [
+      "heroku/nodejs",
+      :default
+    ]
+    before_deploy = -> { run!(%(echo "{}" > package.json)) }
+    Hatchet::Runner.new("default_ruby", stack: DEFAULT_STACK, buildpacks: buildpacks, before_deploy: before_deploy).deploy do |app|
+      expect(app.run("cat .profile.d/WEB_CONCURRENCY.sh").strip).to be_empty
+      expect(app.run("echo $WEB_CONCURRENCY").strip).to be_empty
+      expect(app.run("echo $WEB_CONCURRENCY", heroku: {env: "WEB_CONCURRENCY=0"}).strip).to eq("0")
+    end
+  end
+
+  it "with SENSIBLE_DEFAULTS on sets environment variables" do
+    Hatchet::Runner.new("default_ruby", stack: DEFAULT_STACK, config: {"SENSIBLE_DEFAULTS" => "1"}).deploy do |app|
+      expect(app.run("cat .profile.d/WEB_CONCURRENCY.sh").strip).not_to be_empty
+      expect(app.run("echo $HEROKU_RAM_LIMIT_MB $WEB_CONCURRENCY $WEB_CONCURRENCY_SET_BY").strip).to eq("512 2 heroku/ruby")
+      expect(app.run("echo $WEB_CONCURRENCY", heroku: {env: "WEB_CONCURRENCY=3"}).strip).to eq("3")
+    end
+  end
+end
diff --git a/spec/helpers/binstub_check_spec.rb b/spec/helpers/binstub_check_spec.rb
new file mode 100644
index 000000000..477efd55f
--- /dev/null
+++ b/spec/helpers/binstub_check_spec.rb
@@ -0,0 +1,94 @@
+require_relative "../spec_helper"
+
+describe LanguagePack::Helpers::BinstubCheck do
+  def get_ruby_path!
+    out = `command -v ruby 2>/dev/null`.strip
+    raise "command `command -v ruby` failed with output: #{out}" unless $?.success?
+
+    Pathname.new(out)
+  end
+
+  def get_ruby_bin_dir!
+    ruby_bin_dir = get_ruby_path!.join("..")
+    raise "#{ruby_bin_dir} is not a directory" unless File.directory?(ruby_bin_dir)
+
+    ruby_bin_dir
+  end
+
+  it "handles empty binstubs" do
+    Tempfile.create("foo.txt") do |f|
+      expect { Pathname.new(f).open(&:readline) }.to raise_error(EOFError)
+
+      binstub = LanguagePack::Helpers::BinstubWrapper.new(f.path)
+      expect(binstub.bad_shebang?).to be_falsey
+      expect(binstub.binary?).to be_falsey
+    end
+  end
+
+  it "can determine if a file is binary or not" do
+    binstub = LanguagePack::Helpers::BinstubWrapper.new(get_ruby_path!)
+
+    expect(binstub.bad_shebang?).to be_falsey
+    expect(binstub.binary?).to be_truthy
+
+    Tempfile.create("foo.txt") do |f|
+      f.write("foo")
+      f.close
+      binstub = LanguagePack::Helpers::BinstubWrapper.new(f.path)
+
+      expect(binstub.bad_shebang?).to be_falsey
+      expect(binstub.binary?).to be_falsey
+    end
+  end
+
+  it "doesn't error on empty directories" do
+    Dir.mktmpdir do |dir|
+      warn_obj = Object.new
+      check = LanguagePack::Helpers::BinstubCheck.new(app_root_dir: dir, warn_object: warn_obj)
+      check.call
+    end
+  end
+
+  it "does not raise an error when running against a directory with a binary file in it" do
+    ruby_bin_dir = get_ruby_bin_dir!
+    check = LanguagePack::Helpers::BinstubCheck.new(app_root_dir: ruby_bin_dir, warn_object: Object.new)
+    check.call
+  end
+
+  it "checks binstubs and finds bad ones" do
+    Dir.mktmpdir do |dir|
+      bin_dir = Pathname.new(dir).join("bin")
+      bin_dir.mkpath
+
+      # Bad binstub
+      bin_dir.join("bad_binstub_example").write(<<~EOM)
+        #!/usr/bin/env ruby2.5
+
+        nothing else matters
+      EOM
+
+      # Good binstub
+      bin_dir.join("good_binstub_example").write(<<~EOM)
+        #!/usr/bin/env bash
+
+        nothing else matters
+      EOM
+      bin_dir.join("good_binstub_example_two").write("#!/usr/bin/env ruby")
+
+      warn_obj = Object.new
+      def warn_obj.warn(*args, **kwargs)
+        @msg = args.first
+      end
+
+      def warn_obj.msg
+        @msg
+      end
+
+      check = LanguagePack::Helpers::BinstubCheck.new(app_root_dir: dir, warn_object: warn_obj)
+      check.call
+
+      expect(check.bad_binstubs.count).to eq(1)
+      expect(warn_obj.msg).to include("bin/bad_binstub_example")
+    end
+  end
+end
diff --git a/spec/helpers/bundle_binstub_check_spec.rb b/spec/helpers/bundle_binstub_check_spec.rb
new file mode 100644
index 000000000..09d931706
--- /dev/null
+++ b/spec/helpers/bundle_binstub_check_spec.rb
@@ -0,0 +1,64 @@
+require_relative "../spec_helper"
+
+describe LanguagePack::Helpers::BundleBinstubCheck do
+  def make_warn_obj
+    warn_obj = Object.new
+    def warn_obj.warn(*args, **kwargs)
+      @msg = args.first
+    end
+
+    def warn_obj.msg
+      @msg
+    end
+
+    warn_obj
+  end
+
+  it "warns when bin/bundle exists" do
+    Dir.mktmpdir do |dir|
+      bin_dir = Pathname.new(dir).join("bin")
+      bin_dir.mkpath
+      bin_dir.join("bundle").write("#!/usr/bin/env ruby\n")
+
+      warn_obj = make_warn_obj
+      check = LanguagePack::Helpers::BundleBinstubCheck.new(
+        app_root_dir: dir,
+        warn_object: warn_obj
+      )
+      check.call
+
+      expect(warn_obj.msg).to include("bin/bundle")
+      expect(warn_obj.msg).to include("rm bin/bundle")
+    end
+  end
+
+  it "does not warn when bin/bundle does not exist" do
+    Dir.mktmpdir do |dir|
+      bin_dir = Pathname.new(dir).join("bin")
+      bin_dir.mkpath
+      bin_dir.join("rails").write("#!/usr/bin/env ruby\n")
+
+      warn_obj = make_warn_obj
+      check = LanguagePack::Helpers::BundleBinstubCheck.new(
+        app_root_dir: dir,
+        warn_object: warn_obj
+      )
+      check.call
+
+      expect(warn_obj.msg).to be_nil
+    end
+  end
+
+  it "does not warn when bin directory does not exist" do
+    Dir.mktmpdir do |dir|
+      warn_obj = make_warn_obj
+      check = LanguagePack::Helpers::BundleBinstubCheck.new(
+        app_root_dir: dir,
+        warn_object: warn_obj
+      )
+      check.call
+
+      expect(warn_obj.msg).to be_nil
+    end
+  end
+end
diff --git a/spec/helpers/bundle_list_spec.rb b/spec/helpers/bundle_list_spec.rb
new file mode 100644
index 000000000..3cdb68917
--- /dev/null
+++ b/spec/helpers/bundle_list_spec.rb
@@ -0,0 +1,50 @@
+require "spec_helper"
+
+describe "Bundle list" do
+  it "parses bundle list output" do
+    output = <<~EOF
+      Gems included by the bundle:
+        * actioncable (6.1.4.1)
+        * actionmailbox (6.1.4.1)
+        * actionmailer (6.1.4.1)
+        * actionpack (6.1.4.1)
+        * actiontext (6.1.4.1)
+        * actionview (6.1.4.1)
+        * activejob (6.1.4.1)
+        * activemodel (6.1.4.1)
+        * activerecord (6.1.4.1)
+        * activestorage (6.1.4.1)
+        * activesupport (6.1.4.1)
+        * addressable (2.8.0)
+        * ast (2.4.2)
+        * railties (6.1.4.1)
+      Use `bundle info` to print more detailed information about a gem
+    EOF
+
+    bundle_list = LanguagePack::Helpers::BundleList.new(
+      output: output
+    )
+    expect(bundle_list.has_gem?("railties")).to be_truthy
+    expect(bundle_list.gem_version("railties")).to eq(Gem::Version.new("6.1.4.1"))
+    expect(bundle_list.has_gem?("nope")).to be_falsey
+
+    expect(bundle_list.length).to eq(14)
+  end
+
+  it "handles git SHA gems" do
+    output = <<~EOF
+      Gems included by the bundle:
+        * railties (6.1.4.1 asdf1)
+      Use `bundle info` to print more detailed information about a gem
+    EOF
+
+    bundle_list = LanguagePack::Helpers::BundleList.new(
+      output: output
+    )
+    expect(bundle_list.has_gem?("railties")).to be_truthy
+    expect(bundle_list.gem_version("railties")).to eq(Gem::Version.new("6.1.4.1"))
+    expect(bundle_list.has_gem?("nope")).to be_falsey
+
+    expect(bundle_list.length).to eq(1)
+  end
+end
diff --git a/spec/helpers/bundler_wrapper_spec.rb b/spec/helpers/bundler_wrapper_spec.rb
index 8affc1f74..eb3a60454 100644
--- a/spec/helpers/bundler_wrapper_spec.rb
+++ b/spec/helpers/bundler_wrapper_spec.rb
@@ -1,19 +1,49 @@
-require 'spec_helper'
+require "spec_helper"
 
-describe "BundlerWrapper" do
+describe "Multiple platform detection" do
+  it "reports true on bundler 2.2+" do
+    Dir.mktmpdir do |dir|
+      gemfile = Pathname(dir).join("Gemfile")
+      report = HerokuBuildReport.dev_null
 
-  before(:each) do
-    @bundler = LanguagePack::Helpers::BundlerWrapper.new
+      LanguagePack::Helpers::BundlerWrapper.new(
+        bundler_path: Dir.mktmpdir,
+        bundler_version: "2.5.7",
+        gemfile_path: gemfile,
+        report: report
+      )
+      expect(report.data).to eq(
+        {
+          "dot_ruby_version.contents" => nil,
+          "bundler.major" => "2",
+          "bundler.minor" => "5",
+          "bundler.patch" => "7",
+          "bundler.version_installed" => "2.5.7"
+        }
+      )
+    end
   end
+end
 
-  after(:each) do
-    @bundler.clean
-  end
+describe "BundlerWrapper" do
+  it "handles windows BUNDLED WITH" do
+    Dir.mktmpdir do |dir|
+      tmp_dir = Pathname(dir)
+      FileUtils.cp_r(fixture_path("windows_lockfile/."), tmp_dir)
 
-  it "detects windows gemfiles" do
-    Hatchet::App.new("rails4_windows_mri193").in_directory do |dir|
-      expect(@bundler.install.windows_gemfile_lock?).to be_true
+      tmp_gemfile_path = tmp_dir.join("Gemfile")
+      tmp_gemfile_lock_path = tmp_dir.join("Gemfile.lock")
+
+      expect(tmp_gemfile_lock_path.read).to match("BUNDLED")
+
+      gemfile_lock = LanguagePack::Helpers::GemfileLock.new(contents: tmp_gemfile_lock_path.read)
+      wrapper = LanguagePack::Helpers::BundlerWrapper.new(
+        bundler_path: Dir.mktmpdir,
+        bundler_version: gemfile_lock.bundler.version,
+        gemfile_path: tmp_gemfile_path
+      )
+
+      expect(wrapper.version).to eq("2.0.2")
     end
   end
 end
-
diff --git a/spec/helpers/config_spec.rb b/spec/helpers/config_spec.rb
new file mode 100644
index 000000000..2b90347bb
--- /dev/null
+++ b/spec/helpers/config_spec.rb
@@ -0,0 +1,17 @@
+require "spec_helper"
+
+describe "Boot Strap Config" do
+  it "matches toml config" do
+    require "toml-rb"
+    config = TomlRB.load_file("buildpack.toml")
+    bootstrap_version = config["buildpack"]["ruby_version"]
+    expect(bootstrap_version).to eq(LanguagePack::RubyVersion::BOOTSTRAP_VERSION_NUMBER)
+
+    expect(`ruby -v`).to match(Regexp.escape(LanguagePack::RubyVersion::BOOTSTRAP_VERSION_NUMBER))
+
+    ci_task = Pathname(".github").join("workflows").join("hatchet_app_cleaner.yml").read
+    ci_task_yml = YAML.load(ci_task)
+    task = ci_task_yml["jobs"]["hatchet-app-cleaner"]["steps"].detect { |step| step["uses"].match?(/ruby\/setup-ruby/) } or raise "Not found"
+    expect(task["with"]["ruby-version"]).to match(LanguagePack::RubyVersion::BOOTSTRAP_VERSION_NUMBER)
+  end
+end
diff --git a/spec/helpers/default_env_vars_spec.rb b/spec/helpers/default_env_vars_spec.rb
new file mode 100644
index 000000000..b4a5a9420
--- /dev/null
+++ b/spec/helpers/default_env_vars_spec.rb
@@ -0,0 +1,151 @@
+require "spec_helper"
+
+describe LanguagePack::Helpers::DefaultEnvVars do
+  it "returns when everything falsey" do
+    env = LanguagePack::Helpers::DefaultEnvVars.call(
+      is_jruby: nil,
+      rack_version: nil,
+      rails_version: nil,
+      secret_key_base: nil,
+      environment_name: "production"
+    )
+
+    expect(env).to eq({
+      "LANG" => "en_US.UTF-8",
+      "PUMA_PERSISTENT_TIMEOUT" => "95"
+    })
+  end
+
+  it "jruby" do
+    env = LanguagePack::Helpers::DefaultEnvVars.call(
+      is_jruby: true,
+      rack_version: nil,
+      rails_version: nil,
+      secret_key_base: nil,
+      environment_name: "production"
+    )
+
+    expect(env).to eq({
+      "LANG" => "en_US.UTF-8",
+      "PUMA_PERSISTENT_TIMEOUT" => "95",
+      "JRUBY_OPTS" => "-Xcompile.invokedynamic=false"
+    })
+  end
+
+  it "rack" do
+    env = LanguagePack::Helpers::DefaultEnvVars.call(
+      is_jruby: false,
+      rack_version: Gem::Version.new("2.0.0"),
+      rails_version: nil,
+      secret_key_base: nil,
+      environment_name: "production"
+    )
+
+    expect(env).to eq({
+      "LANG" => "en_US.UTF-8",
+      "PUMA_PERSISTENT_TIMEOUT" => "95",
+      "RACK_ENV" => "production"
+    })
+  end
+
+  # if rails_version&. >= Gem::Version.new("4.1.0.beta1")
+  #   out["SECRET_KEY_BASE"] = secret_key_base
+  # end
+
+  # if rails_version&. >= Gem::Version.new("4.2.0")
+  #   out["RAILS_SERVE_STATIC_FILES"] = "enabled"
+  # end
+
+  # if rails_version&. >= Gem::Version.new("5.0.0")
+  #   out["RAILS_LOG_TO_STDOUT"] = "enabled"
+  # end
+
+  it "rails 4.1" do
+    env = LanguagePack::Helpers::DefaultEnvVars.call(
+      is_jruby: false,
+      rack_version: Gem::Version.new("2.0.0"),
+      rails_version: Gem::Version.new("4.1.0.beta1"),
+      secret_key_base: "secret_key_base",
+      environment_name: "production"
+    )
+
+    expect(env).to eq({
+      "LANG" => "en_US.UTF-8",
+      "PUMA_PERSISTENT_TIMEOUT" => "95",
+      "RACK_ENV" => "production",
+      "RAILS_ENV" => "production",
+      "SECRET_KEY_BASE" => "secret_key_base"
+    })
+  end
+
+  it "rails 4.2" do
+    env = LanguagePack::Helpers::DefaultEnvVars.call(
+      is_jruby: false,
+      rack_version: Gem::Version.new("2.0.0"),
+      rails_version: Gem::Version.new("4.2.0"),
+      secret_key_base: "secret_key_base",
+      environment_name: "production"
+    )
+
+    expect(env).to eq({
+      "LANG" => "en_US.UTF-8",
+      "PUMA_PERSISTENT_TIMEOUT" => "95",
+      "RACK_ENV" => "production",
+      "RAILS_ENV" => "production",
+      "SECRET_KEY_BASE" => "secret_key_base",
+      "RAILS_SERVE_STATIC_FILES" => "enabled"
+    })
+  end
+
+  it "rails 5.0" do
+    env = LanguagePack::Helpers::DefaultEnvVars.call(
+      is_jruby: false,
+      rack_version: Gem::Version.new("2.0.0"),
+      rails_version: Gem::Version.new("5.0.0"),
+      secret_key_base: "secret_key_base",
+      environment_name: "production"
+    )
+
+    expect(env).to eq({
+      "LANG" => "en_US.UTF-8",
+      "PUMA_PERSISTENT_TIMEOUT" => "95",
+      "RACK_ENV" => "production",
+      "RAILS_ENV" => "production",
+      "SECRET_KEY_BASE" => "secret_key_base",
+      "RAILS_SERVE_STATIC_FILES" => "enabled",
+      "RAILS_LOG_TO_STDOUT" => "enabled"
+    })
+  end
+
+  it "uses environment_name for RACK_ENV and RAILS_ENV" do
+    env = LanguagePack::Helpers::DefaultEnvVars.call(
+      is_jruby: false,
+      rack_version: Gem::Version.new("2.0.0"),
+      rails_version: Gem::Version.new("5.0.0"),
+      secret_key_base: "secret_key_base",
+      environment_name: "test"
+    )
+
+    expect(env).to eq({
+      "LANG" => "en_US.UTF-8",
+      "PUMA_PERSISTENT_TIMEOUT" => "95",
+      "RACK_ENV" => "test",
+      "RAILS_ENV" => "test",
+      "SECRET_KEY_BASE" => "secret_key_base",
+      "RAILS_SERVE_STATIC_FILES" => "enabled",
+      "RAILS_LOG_TO_STDOUT" => "enabled"
+    })
+  end
+
+  it "raises an error if secret_key_base is not provided and rails 4.1+" do
+    expect {
+      LanguagePack::Helpers::DefaultEnvVars.call(
+        is_jruby: false,
+        rack_version: Gem::Version.new("2.0.0"),
+        rails_version: Gem::Version.new("4.1.0"),
+        secret_key_base: nil,
+        environment_name: "production"
+      )
+    }.to raise_error(ArgumentError)
+  end
+end
diff --git a/spec/helpers/dot_ruby_version_file_spec.rb b/spec/helpers/dot_ruby_version_file_spec.rb
new file mode 100644
index 000000000..7154c3ce4
--- /dev/null
+++ b/spec/helpers/dot_ruby_version_file_spec.rb
@@ -0,0 +1,199 @@
+require "spec_helper"
+
+describe "DotRubyVersionFile" do
+  it "parses a plain version" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "3.4.8").call
+    expect(result.ruby_version).to be_a(LanguagePack::RubyVersion)
+    expect(result.ruby_version.ruby_version).to eq("3.4.8")
+    expect(result.ruby_version.engine).to eq(:ruby)
+    expect(result.ruby_version.engine_version).to eq("3.4.8")
+    expect(result.ruby_version.default?).to eq(false)
+    expect(result.warnings).to eq([])
+  end
+
+  it "strips ruby- prefix" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby-3.4.8").call
+    expect(result.ruby_version.ruby_version).to eq("3.4.8")
+    expect(result.ruby_version.engine).to eq(:ruby)
+    expect(result.warnings).to eq([])
+  end
+
+  it "trims trailing comment" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby-3.4.8 # our version").call
+    expect(result.ruby_version.ruby_version).to eq("3.4.8")
+    expect(result.warnings).to eq([])
+
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby-3.4.8# our version").call
+    expect(result.ruby_version.ruby_version).to eq("3.4.8")
+    expect(result.warnings).to eq([])
+  end
+
+  it "strips @company-name suffix with ruby- prefix" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby-3.4.8@company-name").call
+    expect(result.ruby_version.ruby_version).to eq("3.4.8")
+    expect(result.warnings).to eq([])
+
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby-3.4.8@company=>name").call
+    expect(result.ruby_version.ruby_version).to eq("3.4.8")
+    expect(result.warnings).to eq([])
+  end
+
+  it "strips @org suffix from plain version" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "3.4.8@myorg").call
+    expect(result.ruby_version.ruby_version).to eq("3.4.8")
+    expect(result.warnings).to eq([])
+  end
+
+  it "skips comments and blank lines" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: <<~EOF).call
+      # This is a comment
+
+      3.4.8
+    EOF
+    expect(result.ruby_version.ruby_version).to eq("3.4.8")
+    expect(result.warnings).to eq([])
+  end
+
+  it "strips leading and trailing whitespace from the version line" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "  3.4.8  \n").call
+    expect(result.ruby_version.ruby_version).to eq("3.4.8")
+    expect(result.warnings).to eq([])
+  end
+
+  it "parses pre-release with dot syntax" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "3.4.0.rc1").call
+    expect(result.ruby_version.ruby_version).to eq("3.4.0")
+    expect(result.ruby_version.pre).to eq("rc1")
+    expect(result.ruby_version.version_for_download).to eq("ruby-3.4.0.rc1")
+    expect(result.warnings).to eq([])
+  end
+
+  it "normalizes pre-release dash syntax to dot syntax" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "3.4.0-preview2").call
+    expect(result.ruby_version.ruby_version).to eq("3.4.0")
+    expect(result.ruby_version.pre).to eq("preview2")
+    expect(result.ruby_version.version_for_download).to eq("ruby-3.4.0.preview2")
+    expect(result.warnings).to eq([])
+  end
+
+  it "normalizes pre-release with ruby- prefix" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby-3.4.0-rc1").call
+    expect(result.ruby_version.ruby_version).to eq("3.4.0")
+    expect(result.ruby_version.pre).to eq("rc1")
+    expect(result.ruby_version.version_for_download).to eq("ruby-3.4.0.rc1")
+    expect(result.warnings).to eq([])
+  end
+
+  it "handles combined prefix strip, gemset strip, and pre-release" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby-3.4.0-rc1@gemset").call
+    expect(result.ruby_version.ruby_version).to eq("3.4.0")
+    expect(result.ruby_version.pre).to eq("rc1")
+    expect(result.ruby_version.version_for_download).to eq("ruby-3.4.0.rc1")
+    expect(result.warnings).to eq([])
+  end
+
+  it "handles CRLF line endings" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "3.4.8\r\n").call
+    expect(result.ruby_version.ruby_version).to eq("3.4.8")
+    expect(result.ruby_version.version_for_download).to eq("ruby-3.4.8")
+    expect(result.warnings).to eq([])
+  end
+
+  it "is empty for an empty string" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "").call
+    expect(result.ruby_version).to be_nil
+    expect(result.warnings).to eq([])
+  end
+
+  it "is empty for only whitespace" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "   \n  \n").call
+    expect(result.ruby_version).to be_nil
+    expect(result.warnings).to eq([])
+  end
+
+  it "is empty for only comments" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "# just a comment\n# another").call
+    expect(result.ruby_version).to be_nil
+    expect(result.warnings).to eq([])
+  end
+
+  it "warns for multiple meaningful lines" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: <<~EOF).call
+      3.4.8
+      3.5.0
+    EOF
+    expect(result.ruby_version).to be_nil
+    expect(result.warnings.length).to eq(1)
+    expect(result.warnings.first).to include("multiple version lines")
+  end
+
+  it "warns for jruby" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "jruby-10.0.2.0").call
+    expect(result.ruby_version).to be_nil
+    expect(result.warnings.length).to eq(1)
+    expect(result.warnings.first).to include("JRuby")
+  end
+
+  it "warns for version specifiers with >=" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby >= 3.1.6, < 3.3").call
+    expect(result.ruby_version).to be_nil
+    expect(result.warnings.length).to eq(1)
+    expect(result.warnings.first).to include("Cannot parse `.ruby-version` file, version specifiers (`>=`) are not supported.")
+  end
+
+  it "warns for ~> specifier" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "~> 3.1").call
+    expect(result.ruby_version).to be_nil
+    expect(result.warnings.length).to eq(1)
+    expect(result.warnings.first).to include("Cannot parse `.ruby-version` file, version specifiers (`~>`) are not supported.")
+  end
+
+  it "warns for >= specifier" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: ">= 3.1.6").call
+    expect(result.ruby_version).to be_nil
+    expect(result.warnings.length).to eq(1)
+    expect(result.warnings.first).to include("Cannot parse `.ruby-version` file, version specifiers (`>=`) are not supported.")
+  end
+
+  it "warns for > specifier" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "> 3.1.6").call
+    expect(result.ruby_version).to be_nil
+    expect(result.warnings.length).to eq(1)
+    expect(result.warnings.first).to include("Cannot parse `.ruby-version` file, version specifiers (`>`) are not supported.")
+  end
+
+  it "warns for < specifier" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "< 3.1.6").call
+    expect(result.ruby_version).to be_nil
+    expect(result.warnings.length).to eq(1)
+    expect(result.warnings.first).to include("Cannot parse `.ruby-version` file, version specifiers (`<`) are not supported.")
+  end
+
+  it "warns for = specifier" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "=3.1.6").call
+    expect(result.ruby_version).to be_nil
+    expect(result.warnings.length).to eq(1)
+    expect(result.warnings.first).to include("Cannot parse `.ruby-version` file, version specifiers (`=`) are not supported.")
+  end
+
+  it "warns for garbage input" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "not-a-version").call
+    expect(result.ruby_version).to be_nil
+    expect(result.warnings.length).to eq(1)
+    expect(result.warnings.first).to include("Cannot parse Ruby version from")
+  end
+
+  it "warns for two-part version like 3.4" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "3.4").call
+    expect(result.ruby_version).to be_nil
+    expect(result.warnings.length).to eq(1)
+    expect(result.warnings.first).to include("Only full Ruby versions with major, minor, and patch are supported")
+  end
+
+  it "does not crash on bare ruby- prefix" do
+    result = LanguagePack::Helpers::DotRubyVersionFile.new(contents: "ruby-").call
+    expect(result.ruby_version).to be_nil
+    expect(result.warnings.length).to eq(1)
+    expect(result.warnings.first).to include("Cannot parse Ruby version from")
+  end
+end
diff --git a/spec/helpers/download_presence_spec.rb b/spec/helpers/download_presence_spec.rb
new file mode 100644
index 000000000..c2e5aac3c
--- /dev/null
+++ b/spec/helpers/download_presence_spec.rb
@@ -0,0 +1,118 @@
+require "spec_helper"
+
+describe LanguagePack::Helpers::DownloadPresence do
+  it "handles multi-arch transitions for files that exist" do
+    download = LanguagePack::Helpers::DownloadPresence.new(
+      amd_only_stacks: ["heroku-22"],
+      file_name: "ruby-3.1.4.tgz",
+      stacks: ["heroku-22", "heroku-24"],
+      arch: "amd64"
+    )
+
+    download.call
+
+    expect(download.next_stack(current_stack: "heroku-22")).to eq("heroku-24")
+    expect(download.next_stack(current_stack: "heroku-24")).to be_falsey
+
+    expect(download.exists_on_next_stack?(current_stack: "heroku-22")).to be_truthy
+  end
+
+  it "handles multi-arch transitions for files that do not exist" do
+    download = LanguagePack::Helpers::DownloadPresence.new(
+      amd_only_stacks: [],
+      file_name: "ruby-3.0.5.tgz",
+      stacks: ["heroku-24", "heroku-26"],
+      arch: "amd64"
+    )
+
+    download.call
+
+    expect(download.next_stack(current_stack: "heroku-24")).to eq("heroku-26")
+    expect(download.next_stack(current_stack: "heroku-26")).to be_falsey
+
+    expect(download.exists_on_next_stack?(current_stack: "heroku-24")).to be_falsey
+  end
+
+  it "knows if exists on the next stack" do
+    download = LanguagePack::Helpers::DownloadPresence.new(
+      amd_only_stacks: ["heroku-22"],
+      file_name: "#{LanguagePack::RubyVersion::DEFAULT_VERSION}.tgz",
+      stacks: ["heroku-22", "heroku-24"],
+      arch: "amd64"
+    )
+
+    download.call
+
+    expect(download.next_stack(current_stack: "heroku-22")).to eq("heroku-24")
+    expect(download.next_stack(current_stack: "heroku-24")).to be_falsey
+
+    expect(download.exists_on_next_stack?(current_stack: "heroku-22")).to be_truthy
+  end
+
+  it "detects when a package is present on two stacks but not a third" do
+    download = LanguagePack::Helpers::DownloadPresence.new(
+      amd_only_stacks: ["cedar-14", "heroku-16", "heroku-18"],
+      file_name: "ruby-2.3.0.tgz",
+      stacks: ["cedar-14", "heroku-16", "heroku-18"],
+      arch: nil
+    )
+
+    download.call
+
+    expect(download.exists?).to eq(true)
+    expect(download.valid_stack_list).to eq(["cedar-14", "heroku-16"])
+  end
+
+  it "detects when a package does not exist" do
+    download = LanguagePack::Helpers::DownloadPresence.new(
+      amd_only_stacks: ["heroku-22"],
+      file_name: "does-not-exist.tgz",
+      stacks: ["heroku-22", "heroku-24"],
+      arch: nil
+    )
+
+    download.call
+
+    expect(download.exists?).to eq(false)
+    expect(download.valid_stack_list).to eq([])
+  end
+
+  it "detects default ruby version" do
+    download = LanguagePack::Helpers::DownloadPresence.new(
+      amd_only_stacks: LanguagePack::Base::AMD_ONLY_STACKS,
+      file_name: "#{LanguagePack::RubyVersion::DEFAULT_VERSION}.tgz",
+      arch: "amd64"
+    )
+
+    download.call
+
+    expect(download.exists?).to eq(true)
+    expect(download.valid_stack_list).to include("heroku-24")
+  end
+
+  it "next stack for ruby 3.0.10 from heroku-24 is heroku-26" do
+    download = LanguagePack::Helpers::DownloadPresence.new(
+      amd_only_stacks: LanguagePack::Base::AMD_ONLY_STACKS,
+      file_name: "ruby-3.0.10.tgz",
+      arch: "amd64"
+    )
+
+    download.call
+
+    expect(download.next_stack(current_stack: "heroku-24")).to eq("heroku-26")
+  end
+
+  it "handles the current stack not being in the known stacks list" do
+    download = LanguagePack::Helpers::DownloadPresence.new(
+      amd_only_stacks: LanguagePack::Base::AMD_ONLY_STACKS,
+      file_name: "#{LanguagePack::RubyVersion::DEFAULT_VERSION}.tgz",
+      arch: nil
+    )
+
+    download.call
+
+    expect(download.supported_stack?(current_stack: "unknown-stack")).to be_falsey
+    expect(download.next_stack(current_stack: "unknown-stack")).to be_nil
+    expect(download.exists_on_next_stack?(current_stack: "unknown-stack")).to be_falsey
+  end
+end
diff --git a/spec/helpers/fetcher_spec.rb b/spec/helpers/fetcher_spec.rb
deleted file mode 100644
index 03fd6d8ad..000000000
--- a/spec/helpers/fetcher_spec.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-require 'spec_helper'
-
-describe "Fetches" do
-
-  it "bundler" do
-    Dir.mktmpdir do |dir|
-      Dir.chdir(dir) do
-        fetcher = LanguagePack::Fetcher.new(LanguagePack::Base::VENDOR_URL)
-        fetcher.fetch_untar("#{LanguagePack::Ruby::BUNDLER_GEM_PATH}.tgz")
-        expect(`ls bin`).to match("bundle")
-      end
-    end
-  end
-end
-
diff --git a/spec/helpers/gemfile_lock_spec.rb b/spec/helpers/gemfile_lock_spec.rb
new file mode 100644
index 000000000..7f7c13149
--- /dev/null
+++ b/spec/helpers/gemfile_lock_spec.rb
@@ -0,0 +1,195 @@
+require "spec_helper"
+
+describe LanguagePack::Helpers::GemfileLock do
+  it "parses empty gemfile without error" do
+    report = HerokuBuildReport.dev_null
+    gemfile_lock = LanguagePack::Helpers::GemfileLock.new(
+      report: report,
+      contents: ""
+    )
+    expect(gemfile_lock.ruby.ruby_version).to eq(nil)
+    expect(gemfile_lock.ruby.pre).to eq(nil)
+    expect(gemfile_lock.ruby.engine).to eq(:ruby)
+    expect(gemfile_lock.ruby.empty?).to eq(true)
+    expect(gemfile_lock.ruby.engine_version).to eq(nil)
+
+    expect(gemfile_lock.bundler.version).to eq(nil)
+    expect(gemfile_lock.bundler.empty?).to eq(true)
+    expect(report.data).to eq({"gemfile_lock.bundled_with.empty" => true})
+  end
+
+  it "records invalid parsing" do
+    report = HerokuBuildReport.dev_null
+    LanguagePack::Helpers::GemfileLock.new(
+      report: report,
+      contents: <<~EOF
+        RUBY VERSION
+        ruby 3.3.5p100
+        BUNDLED WITH
+        2.3.4
+      EOF
+    )
+    expect(
+      report.data["gemfile_lock.bundler_version.failed_parse"]
+    ).to eq(true)
+    expect(
+      report.data["gemfile_lock.bundler_version.failed_contents"]
+    ).to eq(<<~EOF.strip)
+      BUNDLED WITH
+      2.3.4
+    EOF
+
+    expect(
+      report.data["gemfile_lock.ruby_version.failed_parse"]
+    ).to eq(true)
+    expect(
+      report.data["gemfile_lock.ruby_version.failed_contents"]
+    ).to eq(<<~EOF.strip)
+      RUBY VERSION
+      ruby 3.3.5p100
+    EOF
+  end
+
+  it "captures MRI version and ignores patchlevel" do
+    gemfile_lock = LanguagePack::Helpers::GemfileLock.new(
+      contents: <<~EOF
+        RUBY VERSION
+           ruby 3.3.5p100
+        BUNDLED WITH
+           2.3.4
+      EOF
+    )
+    expect(gemfile_lock.ruby.ruby_version).to eq("3.3.5")
+    expect(gemfile_lock.ruby.pre).to eq(nil)
+    expect(gemfile_lock.ruby.engine).to eq(:ruby)
+    expect(gemfile_lock.ruby.empty?).to eq(false)
+    expect(gemfile_lock.ruby.engine_version).to eq("3.3.5")
+
+    expect(gemfile_lock.bundler.version).to eq("2.3.4")
+    expect(gemfile_lock.bundler.empty?).to eq(false)
+  end
+
+  it "works with windows line endings" do
+    gemfile_lock = LanguagePack::Helpers::GemfileLock.new(
+      contents: <<~EOF.gsub("\n", "\r\n")
+        RUBY VERSION
+           ruby 3.3.5p100
+        BUNDLED WITH
+           2.3.4
+      EOF
+    )
+    expect(gemfile_lock.ruby.ruby_version).to eq("3.3.5")
+    expect(gemfile_lock.ruby.pre).to eq(nil)
+    expect(gemfile_lock.ruby.engine).to eq(:ruby)
+    expect(gemfile_lock.ruby.empty?).to eq(false)
+    expect(gemfile_lock.ruby.engine_version).to eq("3.3.5")
+
+    expect(gemfile_lock.bundler.version).to eq("2.3.4")
+    expect(gemfile_lock.bundler.empty?).to eq(false)
+  end
+
+  it "captures jruby version" do
+    gemfile_lock = LanguagePack::Helpers::GemfileLock.new(
+      contents: <<~EOF
+        GEM
+          remote: https://rubygems.org/
+          specs:
+        PLATFORMS
+          java
+        RUBY VERSION
+           ruby 2.5.7p001 (jruby 9.2.13.0)
+      EOF
+    )
+    expect(gemfile_lock.ruby.ruby_version).to eq("2.5.7")
+    expect(gemfile_lock.ruby.pre).to eq(nil)
+    expect(gemfile_lock.ruby.engine).to eq(:jruby)
+    expect(gemfile_lock.ruby.empty?).to eq(false)
+    expect(gemfile_lock.ruby.engine_version).to eq("9.2.13.0")
+  end
+
+  it "is resiliant to gemfile.lock format changes" do
+    gemfile_lock = LanguagePack::Helpers::GemfileLock.new(
+      contents: <<~EOF
+        GEM
+          remote: https://rubygems.org/
+          specs:
+        # Pretend format change
+        METADATA
+          (jruby 9)
+        PLATFORMS
+          java
+        RUBY VERSION
+           ruby 2.5.7p001 (jruby 9.2.13.0)
+      EOF
+    )
+    expect(gemfile_lock.ruby.ruby_version).to eq("2.5.7")
+    expect(gemfile_lock.ruby.pre).to eq(nil)
+    expect(gemfile_lock.ruby.engine).to eq(:jruby)
+    expect(gemfile_lock.ruby.empty?).to eq(false)
+    expect(gemfile_lock.ruby.engine_version).to eq("9.2.13.0")
+  end
+
+  it "handles RC dot syntax" do
+    gemfile_lock = LanguagePack::Helpers::GemfileLock.new(
+      contents: <<~EOF
+        RUBY VERSION
+           ruby 3.4.0.rc1
+        BUNDLED WITH
+           2.3.4
+      EOF
+    )
+    expect(gemfile_lock.ruby.ruby_version).to eq("3.4.0")
+    expect(gemfile_lock.ruby.pre).to eq("rc1")
+    expect(gemfile_lock.ruby.engine).to eq(:ruby)
+    expect(gemfile_lock.ruby.empty?).to eq(false)
+    expect(gemfile_lock.ruby.engine_version).to eq("3.4.0")
+  end
+
+  it "handles pre without a number" do
+    gemfile_lock = LanguagePack::Helpers::GemfileLock.new(
+      contents: <<~EOF
+        RUBY VERSION
+           ruby 3.4.0.lol
+        BUNDLED WITH
+           2.3.4
+      EOF
+    )
+    expect(gemfile_lock.ruby.ruby_version).to eq("3.4.0")
+    expect(gemfile_lock.ruby.pre).to eq("lol")
+    expect(gemfile_lock.ruby.engine).to eq(:ruby)
+    expect(gemfile_lock.ruby.empty?).to eq(false)
+    expect(gemfile_lock.ruby.engine_version).to eq("3.4.0")
+  end
+
+  it "handles preview dot syntax" do
+    gemfile_lock = LanguagePack::Helpers::GemfileLock.new(
+      contents: <<~EOF
+        RUBY VERSION
+           ruby 3.4.0.preview2
+        BUNDLED WITH
+           2.3.4
+      EOF
+    )
+    expect(gemfile_lock.ruby.ruby_version).to eq("3.4.0")
+    expect(gemfile_lock.ruby.pre).to eq("preview2")
+    expect(gemfile_lock.ruby.engine).to eq(:ruby)
+    expect(gemfile_lock.ruby.empty?).to eq(false)
+    expect(gemfile_lock.ruby.engine_version).to eq("3.4.0")
+  end
+
+  it "handles Bundler 4 style" do
+    gemfile_lock = LanguagePack::Helpers::GemfileLock.new(
+      contents: <<~EOF
+        RUBY VERSION
+          ruby 3.4.0
+        BUNDLED WITH
+          2.3.4
+      EOF
+    )
+    expect(gemfile_lock.ruby.ruby_version).to eq("3.4.0")
+    expect(gemfile_lock.ruby.pre).to eq(nil)
+    expect(gemfile_lock.ruby.engine).to eq(:ruby)
+    expect(gemfile_lock.ruby.empty?).to eq(false)
+    expect(gemfile_lock.ruby.engine_version).to eq("3.4.0")
+  end
+end
diff --git a/spec/helpers/heroku_build_report_spec.rb b/spec/helpers/heroku_build_report_spec.rb
new file mode 100644
index 000000000..f3f1ab0ba
--- /dev/null
+++ b/spec/helpers/heroku_build_report_spec.rb
@@ -0,0 +1,72 @@
+require "spec_helper"
+
+describe "Build report" do
+  it "loads previously written report" do
+    Dir.mktmpdir do |dir|
+      path = Pathname(dir).join(".report.json").tap { |p| p.write('{ "prior": true }') }
+      io = StringIO.new
+      report = HerokuBuildReport::JsonReport.new(
+        io: io,
+        path: path
+      )
+      report.capture("key" => "value")
+      expect(report.data).to eq({"prior" => true, "key" => "value"})
+    end
+  end
+
+  it "handles malformed json" do
+    Dir.mktmpdir do |dir|
+      path = Pathname(dir).join(".report.json").tap { |p| p.write("zomg") }
+      io = StringIO.new
+      report = HerokuBuildReport::JsonReport.new(
+        io: io,
+        path: path
+      )
+      report.capture("key" => "value")
+      expect(report.data).to eq({"build_report_file_malformed" => true, "key" => "value"})
+    end
+  end
+
+  it "handles complex object serialization by converting them to strings" do
+    Dir.mktmpdir do |dir|
+      path = Pathname(dir).join(".report.json")
+      report = HerokuBuildReport::JsonReport.new(
+        path: path
+      )
+      value = Gem::Version.new("3.4.2")
+      expect(report.complex_object?(value)).to eq(true)
+      expect(value.to_yaml).to_not eq(value.to_s.to_yaml)
+      report.capture("key" => value)
+
+      expect(report.data).to eq({"key" => "3.4.2"})
+      expect(path.read.strip).to eq(<<~EOF.strip)
+        {"key":"3.4.2"}
+      EOF
+    end
+  end
+
+  it "writes valid json" do
+    Dir.mktmpdir do |dir|
+      path = Pathname(dir).join(".report.json")
+      report = HerokuBuildReport::JsonReport.new(
+        path: path
+      )
+      report.capture(
+        "string" => "'with single quotes'",
+        "string_plain" => "plain",
+        "number" => 22,
+        "boolean" => true
+      )
+
+      parsed = JSON.parse(path.read).sort_by { |k, _| k }.to_h
+      expect(parsed).to eq(
+        {
+          "string" => "'with single quotes'",
+          "string_plain" => "plain",
+          "number" => 22,
+          "boolean" => true
+        }
+      )
+    end
+  end
+end
diff --git a/spec/helpers/heroku_ruby_installer_spec.rb b/spec/helpers/heroku_ruby_installer_spec.rb
new file mode 100644
index 000000000..7d78e52fc
--- /dev/null
+++ b/spec/helpers/heroku_ruby_installer_spec.rb
@@ -0,0 +1,136 @@
+require "spec_helper"
+
+describe LanguagePack::Installers::HerokuRubyInstaller do
+  def installer(app_path:, report: HerokuBuildReport::GLOBAL, env: {})
+    LanguagePack::Installers::HerokuRubyInstaller.new(
+      amd_only_stacks: LanguagePack::Base::AMD_ONLY_STACKS,
+      stack: "heroku-24",
+      arch: "amd64",
+      app_path: app_path,
+      report: report,
+      env: env
+    )
+  end
+
+  def sort_hash(hash)
+    hash.sort_by { |k, _| k.to_s }.to_h
+  end
+
+  def ruby_version
+    LanguagePack::RubyVersion.default(last_version: "3.1.7")
+  end
+
+  describe "#fetch_unpack" do
+    it "should fetch and unpack mri" do
+      Dir.mktmpdir do |dir|
+        Dir.chdir(dir) do
+          installer(app_path: dir).fetch_unpack(ruby_version, dir)
+
+          expect(File).to exist("bin/ruby")
+        end
+      end
+    end
+  end
+
+  describe "ruby installation" do
+    it "should install ruby and setup binstubs" do
+      Dir.mktmpdir do |dir|
+        Dir.chdir(dir) do
+          report = HerokuBuildReport.dev_null
+          installer(app_path: Pathname(dir).expand_path, report: report).install(ruby_version, "#{dir}/vendor/ruby")
+
+          expect(File.symlink?("#{dir}/bin/ruby")).to be true
+          expect(File.symlink?("#{dir}/bin/ruby.exe")).to be true
+          expect(File).to exist("#{dir}/vendor/ruby/bin/ruby")
+
+          expect(File.readlink("#{dir}/bin/ruby")).to eq("../vendor/ruby/bin/ruby")
+
+          expected = {
+            "ruby_version_engine" => :ruby,
+            "ruby_version_full" => "3.1.7",
+            "ruby_version_major_minor" => "3.1",
+            "ruby_version_origin" => "default",
+            "ruby_version_unique" => "ruby-3.1.7"
+          }
+
+          expect(sort_hash(report.data)).to eq(sort_hash(expected))
+        end
+      end
+    end
+
+    it "should work with pre-release versions" do
+      Dir.mktmpdir do |dir|
+        Dir.chdir(dir) do
+          dir = Pathname(dir).expand_path
+          report = HerokuBuildReport.dev_null
+          env = {"PATH" => "priorpath"}
+          gemfile_lock = LanguagePack::Helpers::GemfileLock.new(report: report, contents: <<~EOF)
+            RUBY VERSION
+               ruby 3.5.0.preview1
+          EOF
+          installer(app_path: dir, report: report, env: env).install(
+            LanguagePack::RubyVersion.from_gemfile_lock(ruby: gemfile_lock.ruby),
+            "#{dir}/vendor/ruby"
+          )
+
+          expect(File.symlink?("#{dir}/bin/ruby")).to be true
+          expect(File.symlink?("#{dir}/bin/ruby.exe")).to be true
+          expect(File).to exist("#{dir}/vendor/ruby/bin/ruby")
+
+          expected = {
+            "ruby_version_engine" => :ruby,
+            "ruby_version_major_minor" => "3.5",
+            "ruby_version_full" => "3.5.0.preview1",
+            "ruby_version_origin" => "Gemfile.lock",
+            "ruby_version_unique" => "ruby-3.5.0.preview1",
+            "gemfile_lock.bundled_with.empty" => true
+          }
+
+          expect(sort_hash(report.data)).to eq(sort_hash(expected))
+          expect(env).to eq({"PATH" => "#{dir.join("vendor/ruby/bin")}:priorpath"})
+        end
+      end
+    end
+
+    it "should report jruby correctly" do
+      Dir.mktmpdir do |dir|
+        Dir.chdir(dir) do
+          dir = Pathname(dir).expand_path
+          report = HerokuBuildReport.dev_null
+          gemfile_lock = LanguagePack::Helpers::GemfileLock.new(report: report, contents: <<~EOF)
+            RUBY VERSION
+               ruby 3.1.4p001 (jruby 9.4.9.0)
+          EOF
+
+          env = {"PATH" => "priorpath"}
+
+          LanguagePack::Installers::HerokuRubyInstaller.new(
+            amd_only_stacks: LanguagePack::Base::AMD_ONLY_STACKS,
+            stack: "heroku-24",
+            arch: "arm64",
+            app_path: dir,
+            report: report,
+            env: env
+          ).install(
+            LanguagePack::RubyVersion.from_gemfile_lock(ruby: gemfile_lock.ruby),
+            "#{dir}/vendor/ruby"
+          )
+
+          expected = {
+            "ruby_version_engine" => :jruby,
+            "jruby_version_full" => "9.4.9.0",
+            "jruby_version_major_minor" => "9.4",
+            "jruby_version_ruby_version" => "3.1.4",
+            "ruby_version_origin" => "Gemfile.lock",
+            "ruby_version_unique" => "ruby-3.1.4-jruby-9.4.9.0",
+            "gemfile_lock.bundled_with.empty" => true
+          }
+
+          expect(sort_hash(report.data)).to eq(sort_hash(expected))
+
+          expect(env).to eq({"JRUBY_OPTS" => nil, "PATH" => "#{dir.join("vendor/ruby/bin")}:priorpath"})
+        end
+      end
+    end
+  end
+end
diff --git a/spec/helpers/lockfile_shell_parser_spec.rb b/spec/helpers/lockfile_shell_parser_spec.rb
new file mode 100644
index 000000000..dce27893c
--- /dev/null
+++ b/spec/helpers/lockfile_shell_parser_spec.rb
@@ -0,0 +1,111 @@
+require "spec_helper"
+
+describe LanguagePack::Helpers::LockfileShellParser do
+  describe ".call" do
+    it "parses gem specs from a Gemfile.lock" do
+      Dir.mktmpdir do |dir|
+        lockfile_path = Pathname(dir).join("Gemfile.lock")
+        lockfile_path.write(<<~EOF)
+          GEM
+            remote: https://rubygems.org/
+            specs:
+              rake (13.2.1)
+
+          PLATFORMS
+            arm64-darwin-22
+            ruby
+            x86_64-linux
+
+          DEPENDENCIES
+            rake
+
+          RUBY VERSION
+             ruby 3.3.9
+
+          BUNDLED WITH
+             2.7.9
+        EOF
+
+        specs = LanguagePack::Helpers::LockfileShellParser.call(lockfile_path: lockfile_path)
+
+        expect(specs["rake"]).to eq(Gem::Version.new("13.2.1"))
+      end
+    end
+
+    it "reports error on nil input" do
+      expect {
+        LanguagePack::Helpers::LockfileShellParser.call(lockfile_path: nil)
+      }.to raise_error(StandardError, /no implicit conversion of nil into String/)
+    end
+
+    it "reports error on file that does not exist" do
+      expect {
+        LanguagePack::Helpers::LockfileShellParser.call(lockfile_path: "does-not-exist.lock")
+      }.to raise_error(StandardError, /No such file or directory/)
+    end
+
+    it "reports error on invalid lockfile contents" do
+      Dir.mktmpdir do |dir|
+        lockfile_path = Pathname(dir).join("Gemfile.lock").tap { |p|
+          # Write invalid UTF-8 bytes to trigger Bundler::LockfileError
+          p.binwrite("\xff\xfe")
+        }
+        expect {
+          LanguagePack::Helpers::LockfileShellParser.call(lockfile_path: lockfile_path)
+        }.to raise_error(StandardError, /Your lockfile is unreadable/)
+      end
+    end
+
+    it "parses multiple gems from a Gemfile.lock" do
+      Dir.mktmpdir do |dir|
+        lockfile_path = Pathname(dir).join("Gemfile.lock")
+        lockfile_path.write(<<~EOF)
+          GEM
+            remote: https://rubygems.org/
+            specs:
+              concurrent-ruby (1.2.3)
+              i18n (1.14.1)
+                concurrent-ruby (~> 1.0)
+              rack (3.0.8)
+
+          PLATFORMS
+            ruby
+
+          DEPENDENCIES
+            i18n
+            rack
+
+          BUNDLED WITH
+             2.7.9
+        EOF
+
+        specs = LanguagePack::Helpers::LockfileShellParser.call(lockfile_path: lockfile_path)
+
+        expect(specs["concurrent-ruby"]).to eq(Gem::Version.new("1.2.3"))
+        expect(specs["i18n"]).to eq(Gem::Version.new("1.14.1"))
+        expect(specs["rack"]).to eq(Gem::Version.new("3.0.8"))
+      end
+    end
+
+    it "returns an empty hash for a lockfile with no gems" do
+      Dir.mktmpdir do |dir|
+        lockfile_path = Pathname(dir).join("Gemfile.lock")
+        lockfile_path.write(<<~EOF)
+          GEM
+            remote: https://rubygems.org/
+            specs:
+
+          PLATFORMS
+            ruby
+
+          BUNDLED WITH
+             2.7.9
+        EOF
+
+        specs = LanguagePack::Helpers::LockfileShellParser.call(lockfile_path: lockfile_path)
+
+        expect(specs).to eq({})
+      end
+    end
+  end
+end
diff --git a/spec/helpers/metadata_spec.rb b/spec/helpers/metadata_spec.rb
new file mode 100644
index 000000000..75636a139
--- /dev/null
+++ b/spec/helpers/metadata_spec.rb
@@ -0,0 +1,36 @@
+require "spec_helper"
+
+describe "Metadata" do
+  it "can read and write to the metadata" do
+    Dir.mktmpdir do |dir|
+      metadata = LanguagePack::Metadata.new(cache_path: dir)
+      expect(metadata.empty?).to be_truthy
+
+      expect(metadata.try_read("test")).to be_nil
+      expect(metadata.exists?("test")).to be_falsey
+      metadata.write("test", "test")
+
+      expect(metadata.try_read("test")).to eq("test")
+      expect(metadata.exists?("test")).to be_truthy
+      expect(metadata.read("test")).to eq("test")
+      expect(metadata.empty?).to be_falsey
+    end
+  end
+
+  it "can write a value conditionally" do
+    Dir.mktmpdir do |dir|
+      metadata = LanguagePack::Metadata.new(cache_path: dir)
+      called = false
+
+      expect(metadata.empty?).to be_truthy
+      expect(metadata.exists?("test")).to be_falsey
+      metadata.fetch("test") do
+        called = true
+        "test"
+      end
+      expect(metadata.read("test")).to eq("test")
+      expect(called).to be_truthy
+      expect(metadata.empty?).to be_falsey
+    end
+  end
+end
diff --git a/spec/helpers/node_installer_spec.rb b/spec/helpers/node_installer_spec.rb
new file mode 100644
index 000000000..9da9941d2
--- /dev/null
+++ b/spec/helpers/node_installer_spec.rb
@@ -0,0 +1,18 @@
+require "spec_helper"
+
+describe LanguagePack::Helpers::NodeInstaller do
+  describe "#install" do
+    LanguagePack::Base::KNOWN_ARCHITECTURES.each do |arch|
+      it "should extract a node binary on #{arch}" do
+        Dir.mktmpdir do |dir|
+          Dir.chdir(dir) do
+            installer = LanguagePack::Helpers::NodeInstaller.new(arch: "arm64")
+            installer.install
+
+            expect(File.exist?("node")).to be(true)
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/spec/helpers/nodebin_spec.rb b/spec/helpers/nodebin_spec.rb
new file mode 100644
index 000000000..79aabd206
--- /dev/null
+++ b/spec/helpers/nodebin_spec.rb
@@ -0,0 +1,13 @@
+require "spec_helper"
+
+describe LanguagePack::Helpers::Nodebin do
+  describe ".yarn" do
+    it "downloads Yarn from the npm registry (not the heroku-nodebin S3 bucket)" do
+      url = LanguagePack::Helpers::Nodebin.yarn["url"]
+
+      expect(url).to include("registry.npmjs.org")
+      expect(url).to_not include("heroku-nodebin")
+      expect(url).to eq("https://registry.npmjs.org/yarn/-/yarn-#{LanguagePack::Helpers::Nodebin::YARN_VERSION}.tgz")
+    end
+  end
+end
diff --git a/spec/helpers/outdated_ruby_version_spec.rb b/spec/helpers/outdated_ruby_version_spec.rb
new file mode 100644
index 000000000..5487afdf7
--- /dev/null
+++ b/spec/helpers/outdated_ruby_version_spec.rb
@@ -0,0 +1,137 @@
+require "spec_helper"
+
+describe LanguagePack::Helpers::OutdatedRubyVersion do
+  let(:stack) { "heroku-16" }
+  let(:fetcher) {
+    LanguagePack::Fetcher.new(LanguagePack::Base::VENDOR_URL, stack: stack)
+  }
+
+  it "handles amd â†—ï¸ architecture on heroku-24" do
+    ruby_version = LanguagePack::RubyVersion.default(last_version: "3.1.0")
+    fetcher = LanguagePack::Fetcher.new(
+      LanguagePack::Base::VENDOR_URL,
+      stack: "heroku-24",
+      arch: "amd64"
+    )
+    outdated = LanguagePack::Helpers::OutdatedRubyVersion.new(
+      current_ruby_version: ruby_version,
+      fetcher: fetcher
+    )
+
+    outdated.call
+    expect(outdated.suggested_ruby_minor_version).to eq("3.1.7")
+  end
+
+  it "handles arm 💪 architecture on heroku-24" do
+    ruby_version = LanguagePack::RubyVersion.default(last_version: "3.1.0")
+    fetcher = LanguagePack::Fetcher.new(
+      LanguagePack::Base::VENDOR_URL,
+      stack: "heroku-24",
+      arch: "arm64"
+    )
+    outdated = LanguagePack::Helpers::OutdatedRubyVersion.new(
+      current_ruby_version: ruby_version,
+      fetcher: fetcher
+    )
+
+    outdated.call
+    expect(outdated.suggested_ruby_minor_version).to eq("3.1.7")
+  end
+
+  it "finds the latest version on a stack" do
+    ruby_version = LanguagePack::RubyVersion.default(last_version: "2.2.5")
+    outdated = LanguagePack::Helpers::OutdatedRubyVersion.new(
+      current_ruby_version: ruby_version,
+      fetcher: fetcher
+    )
+
+    outdated.call
+    expect(outdated.suggested_ruby_minor_version).to eq("2.2.10")
+    expect(outdated.eol?).to eq(true)
+    expect(outdated.maybe_eol?).to eq(true)
+  end
+
+  it "detects returns original ruby version when using the latest" do
+    ruby_version = LanguagePack::RubyVersion.default(last_version: "2.2.10")
+    outdated = LanguagePack::Helpers::OutdatedRubyVersion.new(
+      current_ruby_version: ruby_version,
+      fetcher: fetcher
+    )
+
+    outdated.call
+    expect(outdated.suggested_ruby_minor_version).to eq("2.2.10")
+    expect(outdated.latest_minor_version?).to be_truthy
+  end
+
+  it "recommends a non EOL version of Ruby" do
+    ruby_version_one = LanguagePack::RubyVersion.default(last_version: "2.1.10")
+    ruby_version_two = LanguagePack::RubyVersion.default(last_version: "2.2.10")
+
+    outdated_one = LanguagePack::Helpers::OutdatedRubyVersion.new(
+      current_ruby_version: ruby_version_one,
+      fetcher: fetcher
+    )
+    outdated_two = LanguagePack::Helpers::OutdatedRubyVersion.new(
+      current_ruby_version: ruby_version_two,
+      fetcher: fetcher
+    )
+
+    outdated_one.call
+    outdated_two.call
+
+    expect(outdated_one.eol?).to be_truthy
+    expect(outdated_one.maybe_eol?).to be_truthy
+
+    expect(outdated_two.eol?).to be_truthy
+    expect(outdated_one.maybe_eol?).to be_truthy
+
+    suggested_one = outdated_one.suggest_ruby_eol_version
+    expect(suggested_one).to eq(outdated_two.suggest_ruby_eol_version)
+    expect(suggested_one[-1]).to eq("x") # i.e. 2.5.x
+
+    actual = Gem::Version.new(suggested_one)
+    expect(actual).to be > Gem::Version.new("2.4.x")
+  end
+
+  it "does not recommend EOL for recent ruby version" do
+    ruby_version = LanguagePack::RubyVersion.default(last_version: "2.2.10")
+
+    outdated = LanguagePack::Helpers::OutdatedRubyVersion.new(
+      current_ruby_version: ruby_version,
+      fetcher: fetcher
+    )
+
+    outdated.call
+
+    good_version = outdated.suggest_ruby_eol_version.sub("x", "0")
+    ruby_version = LanguagePack::RubyVersion.default(last_version: good_version)
+
+    outdated = LanguagePack::Helpers::OutdatedRubyVersion.new(
+      current_ruby_version: ruby_version,
+      fetcher: fetcher
+    )
+    outdated.call
+
+    expect(outdated.eol?).to be_falsey
+    expect(outdated.maybe_eol?).to be_falsey
+  end
+
+  it "can call eol? on the latest Ruby version" do
+    ruby_version = LanguagePack::RubyVersion.default(last_version: "2.6.0")
+
+    new_fetcher = fetcher.dup
+    def new_fetcher.exists?(value)
+      false
+    end
+
+    outdated = LanguagePack::Helpers::OutdatedRubyVersion.new(
+      current_ruby_version: ruby_version,
+      fetcher: new_fetcher
+    )
+
+    outdated.call
+
+    expect(outdated.eol?).to be_falsey
+    expect(outdated.maybe_eol?).to be_falsey
+  end
+end
diff --git a/spec/helpers/puma_warn_error_spec.rb b/spec/helpers/puma_warn_error_spec.rb
new file mode 100644
index 000000000..4a9f469fc
--- /dev/null
+++ b/spec/helpers/puma_warn_error_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+describe LanguagePack::Helpers::PumaWarnError do
+  it "warns about Router 2.0 compatibility" do
+    puma_warn_error = LanguagePack::Helpers::PumaWarnError.new(
+      puma_version: Gem::Version.new("6.0.0"),
+      env: {}
+    )
+    expect(puma_warn_error.warnings.join).to include("Heroku recommends using Puma 7.0.3+ for compatibility with Router 2.0")
+  end
+
+  it "errors if incompatible version with persistent timeout env var" do
+    puma_warn_error = LanguagePack::Helpers::PumaWarnError.new(
+      puma_version: Gem::Version.new("7.0.0"),
+      env: {}
+    )
+    expect(puma_warn_error.error).to include("known issue with the `PUMA_PERSISTENT_TIMEOUT`")
+
+    puma_warn_error = LanguagePack::Helpers::PumaWarnError.new(
+      puma_version: Gem::Version.new("7.0.1"),
+      env: {}
+    )
+    expect(puma_warn_error.error).to include("known issue with the `PUMA_PERSISTENT_TIMEOUT`")
+
+    puma_warn_error = LanguagePack::Helpers::PumaWarnError.new(
+      puma_version: Gem::Version.new("7.0.2"),
+      env: {}
+    )
+    expect(puma_warn_error.error).to include("known issue with the `PUMA_PERSISTENT_TIMEOUT`")
+
+    puma_warn_error = LanguagePack::Helpers::PumaWarnError.new(
+      puma_version: Gem::Version.new("7.0.3"),
+      env: {}
+    )
+    expect(puma_warn_error.error).to be_nil
+    expect(puma_warn_error.warnings).to eq([])
+  end
+
+  it "warns, but does not error if customer manually set PUMA_PERSISTENT_TIMEOUT" do
+    puma_warn_error = LanguagePack::Helpers::PumaWarnError.new(
+      puma_version: Gem::Version.new("7.0.0"),
+      env: {"PUMA_PERSISTENT_TIMEOUT" => "95"}
+    )
+    expect(puma_warn_error.error).to be_nil
+    expect(puma_warn_error.warnings.join).to include("Heroku recommends using Puma 7.0.3+ for compatibility with Router 2.0")
+  end
+end
diff --git a/spec/helpers/rails_runner_spec.rb b/spec/helpers/rails_runner_spec.rb
new file mode 100644
index 000000000..b3c963065
--- /dev/null
+++ b/spec/helpers/rails_runner_spec.rb
@@ -0,0 +1,156 @@
+require "spec_helper"
+
+describe "Rails Runner" do
+  def isolate(&block)
+    # Process and disk isolation
+    Hatchet::App.new("default_ruby").in_directory_fork do
+      ENV["PATH"]
+      ENV["PATH"] = "./bin/:#{ENV["PATH"]}"
+
+      Dir.mktmpdir do |tmpdir|
+        Dir.chdir(tmpdir) do
+          block.call
+        end
+      end
+    end
+  end
+
+  it "config objects build propperly formatted commands" do
+    isolate do
+      rails_runner = LanguagePack::Helpers::RailsRunner.new
+      rails_runner.detect("active_storage.service")
+
+      expected = 'rails runner "begin; puts %Q{heroku.detecting.config.for.active_storage.service=#{Rails.application.config.try(:active_storage).try(:service)}}; rescue => e; end;"' # standard:disable Lint/InterpolationCheck
+      expect(rails_runner.command).to eq(expected)
+
+      rails_runner.detect("assets.compile")
+
+      expected = 'rails runner "begin; puts %Q{heroku.detecting.config.for.active_storage.service=#{Rails.application.config.try(:active_storage).try(:service)}}; rescue => e; end; begin; puts %Q{heroku.detecting.config.for.assets.compile=#{Rails.application.config.try(:assets).try(:compile)}}; rescue => e; end;"' # standard:disable Lint/InterpolationCheck
+      expect(rails_runner.command).to eq(expected)
+    end
+  end
+
+  it "calls run through child object" do
+    isolate do
+      rails_runner = LanguagePack::Helpers::RailsRunner.new
+      def rails_runner.call
+        @called ||= 0
+        @called += 1
+        ""
+      end
+
+      def rails_runner.called
+        @called
+      end
+
+      local_storage = rails_runner.detect("active_storage.service")
+      local_storage.success?
+      expect(rails_runner.called).to eq(1)
+
+      local_storage.success?
+      local_storage.did_match?("foo")
+      expect(rails_runner.called).to eq(1)
+    end
+  end
+
+  it "calls a mock interface" do
+    isolate do
+      mock_rails_runner
+      expect(File.executable?("bin/rails")).to eq(true)
+
+      rails_runner = LanguagePack::Helpers::RailsRunner.new
+      rails_runner.detect("active_storage.service")
+      rails_runner.detect("foo.bar")
+
+      expect(rails_runner.output).to match("heroku.detecting.config.for.active_storage.service=active_storage.service")
+      expect(rails_runner.output).to match("heroku.detecting.config.for.foo.bar=foo.bar")
+      expect(rails_runner.success?).to be(true)
+    end
+  end
+
+  it "timeout works as expected" do
+    isolate do
+      mock_rails_runner("pid = Process.spawn('sleep 5'); Process.wait(pid)")
+
+      diff = time_it do
+        rails_runner = LanguagePack::Helpers::RailsRunner.new(false, 0.01)
+        rails_runner.detect("active_storage.service")
+        expect(rails_runner.success?).to eq(false)
+        expect(rails_runner.timeout?).to eq(true)
+      end
+
+      expect(diff < 1).to eq(true), "expected time difference #{diff} to be less than 1 second, but was longer"
+    end
+  end
+
+  it "failure in one task does not cause another to fail" do
+    isolate do
+      mock_rails_runner('raise "bad" if value == :bad')
+
+      rails_runner = LanguagePack::Helpers::RailsRunner.new(false, 15)
+      bad_value = rails_runner.detect("bad.value")
+      local_storage = rails_runner.detect("active_storage.service")
+
+      expect(!!bad_value.success?).to eq(false)
+      expect(!!local_storage.success?).to eq(true)
+    end
+  end
+
+  it "does not fail when there is an invalid byte sequence" do
+    isolate do
+      mock_rails_runner('puts "hi \255"')
+
+      rails_runner = LanguagePack::Helpers::RailsRunner.new
+      local_storage = rails_runner.detect("active_storage.service")
+
+      expect(local_storage.success?).to be_truthy
+    end
+  end
+
+  def time_it
+    start = Time.now
+    yield
+    Time.now - start
+  end
+
+  def mock_rails_runner(try_code = "")
+    executable_contents = <<~FILE
+      #!/usr/bin/env ruby
+      require 'ostruct'
+
+      module Rails; end
+      def Rails.application
+        OpenStruct.new(config: TryMock.new) # Rails.application.config #=> TryMock instance
+      end
+
+      # Mock object used to record calls
+      # for example:
+      #
+      #   obj = Try.new
+      #   obj.try(:active_storage).try(:service)
+      #   puts obj.to_s # => "active_storage.service"
+      #
+      class TryMock
+        def initialize(array = [])
+          @try_array = array
+        end
+
+        def try(value)
+          @try_array << value
+          #{try_code}
+          return TryMock.new(@try_array)
+        end
+
+        def to_s
+          @try_array.join(".")
+        end
+      end
+
+      ARGV.shift           # remove "runner"
+      eval(ARGV.join(" ")) # Execute command passed in
+    FILE
+    FileUtils.mkdir("bin")
+    File.open("bin/rails", "w") { |f| f << executable_contents }
+    File.chmod(0o777, "bin/rails")
+  end
+end
diff --git a/spec/helpers/rake_runner_spec.rb b/spec/helpers/rake_runner_spec.rb
index deecb1ddf..086d696d5 100644
--- a/spec/helpers/rake_runner_spec.rb
+++ b/spec/helpers/rake_runner_spec.rb
@@ -1,50 +1,50 @@
-require 'spec_helper'
+require "spec_helper"
 
 describe "Rake Runner" do
   it "runs rake tasks that exist" do
-    Hatchet::App.new('asset_precompile_pass').in_directory do
+    Hatchet::App.new("asset_precompile_pass").in_directory_fork do
       rake = LanguagePack::Helpers::RakeRunner.new.load_rake_tasks!
       task = rake.task("assets:precompile")
       task.invoke
 
-      expect(task.status).to   eq(:pass)
-      expect(task.output).to   match("success!")
+      expect(task.output).to match("success!")
+      expect(task.status).to eq(:pass)
       expect(task.time).not_to be_nil
     end
   end
 
   it "detects when rake tasks fail" do
-    Hatchet::App.new('asset_precompile_fail').in_directory do
+    Hatchet::App.new("asset_precompile_fail").in_directory_fork do
       rake = LanguagePack::Helpers::RakeRunner.new.load_rake_tasks!
       task = rake.task("assets:precompile")
       task.invoke
 
-      expect(task.status).to   eq(:fail)
-      expect(task.output).to   match("assets:precompile fails")
+      expect(task.output).to match("assets:precompile fails")
+      expect(task.status).to eq(:fail)
       expect(task.time).not_to be_nil
     end
   end
 
   it "can show errors from bad Rakefiles" do
-    Hatchet::App.new('bad_rakefile').in_directory do
+    Hatchet::App.new("bad_rakefile").in_directory_fork do
       rake = LanguagePack::Helpers::RakeRunner.new.load_rake_tasks!
       task = rake.task("assets:precompile")
-      expect(rake.rakefile_can_load?).to be_false
-      expect(task.task_defined?).to      be_false
+      expect(rake.rakefile_can_load?).to be_falsey
+      expect(task.task_defined?).to be_falsey
     end
   end
 
   it "detects if task is missing" do
-    Hatchet::App.new('asset_precompile_not_found').in_directory do
+    Hatchet::App.new("asset_precompile_not_found").in_directory_fork do
       task = LanguagePack::Helpers::RakeRunner.new.task("assets:precompile")
-      expect(task.task_defined?).to be_false
+      expect(task.task_defined?).to be_falsey
     end
   end
 
   it "detects when no rakefile is present" do
-    Hatchet::App.new('no_rakefile').in_directory do
+    Hatchet::App.new("no_rakefile").in_directory_fork do
       runner = LanguagePack::Helpers::RakeRunner.new
-      expect(runner.rakefile_can_load?).to be_false
+      expect(runner.rakefile_can_load?).to be_falsey
     end
   end
 end
diff --git a/spec/helpers/ruby_version_spec.rb b/spec/helpers/ruby_version_spec.rb
index 46b429f41..6f6f2f94e 100644
--- a/spec/helpers/ruby_version_spec.rb
+++ b/spec/helpers/ruby_version_spec.rb
@@ -1,85 +1,295 @@
-require 'spec_helper'
+require "spec_helper"
 
 describe "RubyVersion" do
-  before(:each) do
-    @bundler = LanguagePack::Helpers::BundlerWrapper.new
-  end
+  it "knows the next logical version" do
+    version_number = "2.5.0"
+    ruby_version = LanguagePack::RubyVersion.default(last_version: version_number)
+    version = "ruby-#{version_number}"
 
-  after(:each) do
-    @bundler.clean
-  end
+    expect(ruby_version.version_for_download).to eq(version)
+    expect(ruby_version.next_logical_version).to eq("ruby-2.5.1")
+    expect(ruby_version.next_logical_version).to eq("ruby-2.5.1")
+    expect(ruby_version.next_logical_version(2)).to eq("ruby-2.5.2")
+    expect(ruby_version.next_logical_version(20)).to eq("ruby-2.5.20")
+    expect(ruby_version.bundler_directory).to eq("vendor/bundle/ruby/2.5.0")
 
-  it "correctly sets ruby version for bundler specified versions" do
-    Hatchet::App.new("mri_193").in_directory do |dir|
-      ruby_version   = LanguagePack::RubyVersion.new(@bundler.install, is_new: true)
-      version_number = "1.9.3"
-      version        = "ruby-#{version_number}"
-      expect(ruby_version.version_without_patchlevel).to eq(version)
-      expect(ruby_version.engine_version).to eq(version_number)
-      expect(ruby_version.to_gemfile).to eq("ruby '#{version_number}'")
-      expect(ruby_version.engine).to eq(:ruby)
-    end
+    # Minor version
+    expect(ruby_version.next_minor_version).to eq("ruby-2.6.0")
+    expect(ruby_version.next_minor_version(2)).to eq("ruby-2.7.0")
+
+    # Major Version
+    expect(ruby_version.next_major_version).to eq("ruby-3.0.0")
+    expect(ruby_version.next_major_version(2)).to eq("ruby-4.0.0")
   end
 
   it "correctly sets default ruby versions" do
-    Hatchet::App.new("default_ruby").in_directory do |dir|
-      ruby_version   = LanguagePack::RubyVersion.new(@bundler.install, is_new: true)
+    Hatchet::App.new("default_ruby").in_directory_fork do |_dir|
+      dir = Pathname(Dir.pwd)
+      ruby_version = LanguagePack::RubyVersion.from_gemfile_lock(
+        ruby: LanguagePack::Helpers::GemfileLock.new(
+          contents: dir.join("Gemfile.lock").read
+        ).ruby
+      )
+      version = LanguagePack::RubyVersion::DEFAULT_VERSION
       version_number = LanguagePack::RubyVersion::DEFAULT_VERSION_NUMBER
-      version        = LanguagePack::RubyVersion::DEFAULT_VERSION
-      expect(ruby_version.version_without_patchlevel).to eq(version)
+      major, minor, _ = version_number.split(".")
+      expect(ruby_version.version_for_download).to eq(version)
       expect(ruby_version.engine_version).to eq(version_number)
       expect(ruby_version.to_gemfile).to eq("ruby '#{version_number}'")
       expect(ruby_version.engine).to eq(:ruby)
       expect(ruby_version.default?).to eq(true)
+      expect(ruby_version.bundler_directory).to eq("vendor/bundle/ruby/#{major}.#{minor}.0")
     end
   end
 
-  it "correctly sets default legacy version" do
-    Hatchet::App.new("default_ruby").in_directory do |dir|
-      ruby_version   = LanguagePack::RubyVersion.new(@bundler.install, is_new: false)
-      version_number = LanguagePack::RubyVersion::LEGACY_VERSION_NUMBER
-      version        = LanguagePack::RubyVersion::LEGACY_VERSION
-      expect(ruby_version.version_without_patchlevel).to eq(version)
+  it "detects Ruby from Gemfile.lock" do
+    Hatchet::App.new("default_ruby").in_directory_fork do |_|
+      dir = Pathname(Dir.pwd)
+      dir.join("Gemfile.lock").write(<<~EOF)
+        GEM
+          remote: https://rubygems.org/
+          specs:
+            rake (13.2.1)
+
+        PLATFORMS
+          arm64-darwin-22
+          ruby
+          x86_64-linux
+
+        DEPENDENCIES
+          rake
+
+        RUBY VERSION
+           ruby 3.2.3p157
+
+        BUNDLED WITH
+           2.4.19
+      EOF
+
+      version_number = "3.2.3"
+      version = "ruby-#{version_number}"
+      ruby_version = LanguagePack::RubyVersion.from_gemfile_lock(
+        ruby: LanguagePack::Helpers::GemfileLock.new(
+          contents: dir.join("Gemfile.lock").read
+        ).ruby
+      )
+      expect(ruby_version.version_for_download).to eq(version)
       expect(ruby_version.engine_version).to eq(version_number)
-      expect(ruby_version.to_gemfile).to eq("ruby '#{version_number}'")
       expect(ruby_version.engine).to eq(:ruby)
     end
   end
 
-  it "detects Ruby 2.0.0" do
-    Hatchet::App.new("mri_200").in_directory do |dir|
-      ruby_version   = LanguagePack::RubyVersion.new(@bundler.install, is_new: true)
-      version_number = "2.0.0"
-      version        = "ruby-#{version_number}"
-      expect(ruby_version.version_without_patchlevel).to eq(version)
+  it "detects RC Ruby from Gemfile.lock" do
+    Hatchet::App.new("default_ruby").in_directory_fork do |_|
+      dir = Pathname(Dir.pwd)
+      dir.join("Gemfile.lock").write(<<~EOF)
+        GEM
+          remote: https://rubygems.org/
+          specs:
+            rake (13.2.1)
+
+        PLATFORMS
+          arm64-darwin-22
+          ruby
+          x86_64-linux
+
+        DEPENDENCIES
+          rake
+
+        RUBY VERSION
+           ruby 3.2.3.rc1
+
+        BUNDLED WITH
+           2.4.19
+      EOF
+
+      version_number = "3.2.3"
+      version = "ruby-#{version_number}.rc1"
+
+      # Shadow logic validation
+      ruby_version = LanguagePack::RubyVersion.from_gemfile_lock(
+        ruby: LanguagePack::Helpers::GemfileLock.new(
+          contents: dir.join("Gemfile.lock").read
+        ).ruby
+      )
+      expect(ruby_version.version_for_download).to eq(version)
       expect(ruby_version.engine_version).to eq(version_number)
-      expect(ruby_version.to_gemfile).to eq("ruby '#{version_number}'")
       expect(ruby_version.engine).to eq(:ruby)
     end
   end
 
+  it "detects pre versions that do not end in numbers" do
+    Hatchet::App.new("default_ruby").in_directory_fork do |_|
+      dir = Pathname(Dir.pwd)
+      dir.join("Gemfile.lock").write(<<~EOF)
+        GEM
+          remote: https://rubygems.org/
+          specs:
+            rake (13.2.1)
+
+        PLATFORMS
+          arm64-darwin-22
+          ruby
+          x86_64-linux
+
+        DEPENDENCIES
+          rake
+
+        RUBY VERSION
+           ruby 3.2.3.lol
+
+        BUNDLED WITH
+           2.4.19
+      EOF
+
+      version_number = "3.2.3"
+      version = "ruby-#{version_number}.lol"
+
+      ruby_version = LanguagePack::RubyVersion.from_gemfile_lock(
+        ruby: LanguagePack::Helpers::GemfileLock.new(
+          contents: dir.join("Gemfile.lock").read
+        ).ruby
+      )
+      expect(ruby_version.version_for_download).to eq(version)
+      expect(ruby_version.engine_version).to eq(version_number)
+      expect(ruby_version.engine).to eq(:ruby)
+      expect(ruby_version.bundler_directory).to eq("vendor/bundle/ruby/3.2.0")
+    end
+  end
 
   it "detects non mri engines" do
-    Hatchet::App.new("ruby_193_jruby_173").in_directory do |dir|
-      ruby_version   = LanguagePack::RubyVersion.new(@bundler.install, is_new: true)
-      version_number = "1.9.3"
-      engine_version = "1.7.3"
-      engine         = :jruby
-      version        = "ruby-#{version_number}-#{engine}-#{engine_version}"
-      to_gemfile     = "ruby '#{version_number}', :engine => '#{engine}', :engine_version => '#{engine_version}'"
-      expect(ruby_version.version_without_patchlevel).to eq(version)
+    Hatchet::App.new("default_ruby").in_directory_fork do |_|
+      dir = Pathname(Dir.pwd)
+      dir.join("Gemfile.lock").write(<<~EOF)
+        GEM
+          remote: https://rubygems.org/
+          specs:
+        PLATFORMS
+          java
+
+        DEPENDENCIES
+
+        RUBY VERSION
+           ruby 2.6.8p001 (jruby 9.3.6.0)
+
+        BUNDLED WITH
+           2.3.25
+      EOF
+
+      version_number = "2.6.8"
+      engine_version = "9.3.6.0"
+      engine = :jruby
+
+      ruby_version = LanguagePack::RubyVersion.from_gemfile_lock(
+        ruby: LanguagePack::Helpers::GemfileLock.new(
+          contents: dir.join("Gemfile.lock").read
+        ).ruby
+      )
+      expect(ruby_version.version_for_download).to eq("ruby-#{version_number}-#{engine}-#{engine_version}")
       expect(ruby_version.engine_version).to eq(engine_version)
-      expect(ruby_version.to_gemfile).to eq(to_gemfile)
       expect(ruby_version.engine).to eq(engine)
+      expect(ruby_version.bundler_directory).to eq("vendor/bundle/jruby/2.6.0")
     end
   end
+end
+
+describe "get_ruby_version .ruby-version comparison" do
+  def gemfile_lock_with_ruby(version)
+    LanguagePack::Helpers::GemfileLock.new(
+      report: HerokuBuildReport.dev_null,
+      contents: <<~EOF
+        RUBY VERSION
+           ruby #{version}
+        BUNDLED WITH
+           2.5.23
+      EOF
+    )
+  end
 
-  it "surfaces error message from bundler"  do
-    bundle_error_msg = "Zomg der was a problem in da gemfile"
-    error_klass      = LanguagePack::Helpers::BundlerWrapper::GemfileParseError
-    Hatchet::App.new("bad_gemfile_on_platform").in_directory do |dir|
-      @bundler       = LanguagePack::Helpers::BundlerWrapper.new()
-      expect {LanguagePack::RubyVersion.new(@bundler)}.to raise_error(error_klass, /#{Regexp.escape(bundle_error_msg)}/)
+  def gemfile_lock_default
+    LanguagePack::Helpers::GemfileLock.new(
+      report: HerokuBuildReport.dev_null,
+      contents: <<~EOF
+        BUNDLED WITH
+           2.5.23
+      EOF
+    )
+  end
+
+  def dot_ruby_version_result(version)
+    LanguagePack::Helpers::DotRubyVersionFile.new(contents: version).call
+  end
+
+  def get_ruby_version(gemfile_lock:, dot_ruby_version_result: nil)
+    report = HerokuBuildReport.dev_null
+    Dir.mktmpdir do |dir|
+      metadata = LanguagePack::Metadata.new(cache_path: dir)
+      LanguagePack::Ruby.get_ruby_version(
+        metadata: metadata,
+        gemfile_lock: gemfile_lock,
+        dot_ruby_version_result: dot_ruby_version_result,
+        report: report
+      )
     end
+    report
+  end
+
+  it "sets no comparison keys when .ruby-version is absent" do
+    report = get_ruby_version(gemfile_lock: gemfile_lock_with_ruby("3.4.2"))
+
+    expect(report.data).not_to have_key("dot_ruby_version.version")
+    expect(report.data).not_to have_key("dot_ruby_version.vs_gemfile_lock")
+  end
+
+  it "sets no comparison keys when .ruby-version is unparseable" do
+    result = dot_ruby_version_result("not-a-version")
+    report = get_ruby_version(
+      gemfile_lock: gemfile_lock_with_ruby("3.4.2"),
+      dot_ruby_version_result: result
+    )
+
+    expect(result.ruby_version).to be_nil
+    expect(report.data).not_to have_key("dot_ruby_version.version")
+    expect(report.data).not_to have_key("dot_ruby_version.vs_gemfile_lock")
+  end
+
+  it "reports match when versions are equal" do
+    report = get_ruby_version(
+      gemfile_lock: gemfile_lock_with_ruby("3.4.2"),
+      dot_ruby_version_result: dot_ruby_version_result("3.4.2")
+    )
+
+    expect(report.data["dot_ruby_version.version"]).to eq("3.4.2")
+    expect(report.data["dot_ruby_version.vs_gemfile_lock"]).to eq("match")
+  end
+
+  it "reports dot_ruby_version_higher when .ruby-version is newer" do
+    report = get_ruby_version(
+      gemfile_lock: gemfile_lock_with_ruby("3.4.2"),
+      dot_ruby_version_result: dot_ruby_version_result("3.5.0")
+    )
+
+    expect(report.data["dot_ruby_version.version"]).to eq("3.5.0")
+    expect(report.data["dot_ruby_version.vs_gemfile_lock"]).to eq("dot_ruby_version_higher")
+  end
+
+  it "reports gemfile_lock_higher when Gemfile.lock is newer" do
+    report = get_ruby_version(
+      gemfile_lock: gemfile_lock_with_ruby("3.4.2"),
+      dot_ruby_version_result: dot_ruby_version_result("3.3.0")
+    )
+
+    expect(report.data["dot_ruby_version.version"]).to eq("3.3.0")
+    expect(report.data["dot_ruby_version.vs_gemfile_lock"]).to eq("gemfile_lock_higher")
+  end
+
+  it "sets version but skips comparison when Gemfile.lock has no ruby version" do
+    report = get_ruby_version(
+      gemfile_lock: gemfile_lock_default,
+      dot_ruby_version_result: dot_ruby_version_result("3.4.2")
+    )
+
+    expect(report.data["dot_ruby_version.version"]).to eq("3.4.2")
+    expect(report.data).not_to have_key("dot_ruby_version.vs_gemfile_lock")
   end
 end
diff --git a/spec/helpers/shell_spec.rb b/spec/helpers/shell_spec.rb
index 3f1591d97..93a12b537 100644
--- a/spec/helpers/shell_spec.rb
+++ b/spec/helpers/shell_spec.rb
@@ -1,23 +1,83 @@
-require 'spec_helper'
+require "spec_helper"
 
+describe "ShellHelpers" do
+  module RecordPuts # standard:disable Lint/ConstantDefinitionInBlock
+    attr_reader :puts_calls, :print_calls
+    def puts(*args)
+      @puts_calls ||= []
+      @puts_calls << args
+    end
 
-class FakeShell
-  include LanguagePack::ShellHelpers
-end
+    def print(*args)
+      @print_calls ||= []
+      @print_calls << args
+    end
+  end
+
+  class FakeShell # standard:disable Lint/ConstantDefinitionInBlock
+    include RecordPuts
+    include LanguagePack::ShellHelpers
+  end
+
+  describe "pipe" do
+    it "does not double append newlines" do
+      sh = FakeShell.new
+      sh.pipe("bundle install")
+      first_line = sh.print_calls.first.first
+      expect(first_line.end_with?("\n\n")).to be(false)
+    end
+  end
 
+  describe "#command_options_to_string" do
+    it "formats ugly keys correctly" do
+      env = {%( un"matched ) => "bad key"}
+      result = FakeShell.new.command_options_to_string("bundle install", env: env)
+      expected = %r{env \\ un\\"matched\\ =bad\\ key bash -c bundle\\ install 2>&1}
+      expect(result.strip).to match(expected)
+    end
 
-describe "ShellHelpers" do
-  it "format ugly keys correctly" do
-    env      = {%Q{ un"matched } => "bad key"}
-    result   = FakeShell.new.command_options_to_string("bundle install", env:  env)
-    expected = %r{env \\ un\\\"matched\\ =bad\\ key bash -c bundle\\ install 2>&1}
-    expect(result.strip).to match(expected)
+    it "formats ugly values correctly" do
+      env = {"BAD VALUE" => %{ )(*&^%$#'$'\n''@!~' }}
+      result = FakeShell.new.command_options_to_string("bundle install", env: env)
+      expected = %r{env BAD\\ VALUE=\\ \\\)\\\(\\\*\\&\\\^\\%\\\$\\#\\'\\\$\\''\n'\\'\\'@\\!\\~\\'\\  bash -c bundle\\ install 2>&1}
+      expect(result.strip).to match(expected)
+    end
   end
 
-  it "formats ugly values correctly" do
-    env      = {"BAD VALUE"      => %Q{ )(*&^%$#'$'\n''@!~\'\ }}
-    result   = FakeShell.new.command_options_to_string("bundle install", env:  env)
-    expected = %r{env BAD\\ VALUE=\\ \\\)\\\(\\\*\\&\\\^\\%\\\$\\#\\'\\\$\\''\n'\\'\\'@\\!\\~\\'\\  bash -c bundle\\ install 2>&1}
-    expect(result.strip).to match(expected)
+  describe "#run!" do
+    it "retries failed commands when passed max_attempts: > 1" do
+      sh = FakeShell.new
+      expect { sh.run!("false", max_attempts: 3) }.to raise_error(StandardError)
+
+      expect(sh.print_calls).to eq([
+        ["       Command: 'false' failed on attempt 1 of 3.\n"],
+        ["       Command: 'false' failed on attempt 2 of 3.\n"]
+      ])
+    end
+  end
+
+  describe "#puts" do
+    context "when the message has an invalid utf-8 character" do
+      it "no error is raised by puts directly" do
+        sh = FakeShell.new
+
+        bad_lines = File.read("spec/fixtures/invalid_encoding.log")
+        sh.puts(bad_lines)
+      end
+
+      it "from an internal call, it catches and annotates it" do
+        sh = FakeShell.new
+
+        def sh.print(string)
+          # Strip emits a UTF-8 error
+          string.strip
+        end
+
+        bad_lines = File.read("spec/fixtures/invalid_encoding.log")
+        expect { sh.puts(bad_lines) }.to raise_error do |error|
+          expect(error.message).to include("Invalid string:")
+        end
+      end
+    end
   end
 end
diff --git a/spec/helpers/stale_file_cleaner_spec.rb b/spec/helpers/stale_file_cleaner_spec.rb
index 52fef9632..58f0cd5cb 100644
--- a/spec/helpers/stale_file_cleaner_spec.rb
+++ b/spec/helpers/stale_file_cleaner_spec.rb
@@ -1,7 +1,6 @@
-require 'spec_helper'
+require "spec_helper"
 
 describe "Cleans Stale Files" do
-
   it "removes files if they go over the limit" do
     file_size = 1000
     Dir.mktmpdir do |dir|
@@ -9,13 +8,13 @@
       sleep 1 # need mtime of files to be different
       new_file = create_file_with_size_in(file_size, dir)
 
-      expect(old_file.exist?).to be_true
-      expect(new_file.exist?).to be_true
+      expect(old_file.exist?).to be_truthy
+      expect(new_file.exist?).to be_truthy
 
-      ::LanguagePack::Helpers::StaleFileCleaner.new(dir).clean_over(2*file_size - 50)
+      ::LanguagePack::Helpers::StaleFileCleaner.new(dir).clean_over(2 * file_size - 50)
 
-      expect(old_file.exist?).to be_false
-      expect(new_file.exist?).to be_true
+      expect(old_file.exist?).to be_falsey
+      expect(new_file.exist?).to be_truthy
     end
   end
 
@@ -26,14 +25,14 @@
       sleep 1 # need mtime of files to be different
       new_file = create_file_with_size_in(file_size, dir)
 
-      expect(old_file.exist?).to be_true
-      expect(new_file.exist?).to be_true
-      dir_size = File.stat(dir)
+      expect(old_file.exist?).to be_truthy
+      expect(new_file.exist?).to be_truthy
+      File.stat(dir)
 
-      ::LanguagePack::Helpers::StaleFileCleaner.new(dir).clean_over(2*file_size + 50)
+      ::LanguagePack::Helpers::StaleFileCleaner.new(dir).clean_over(2 * file_size + 50)
 
-      expect(old_file.exist?).to be_true
-      expect(new_file.exist?).to be_true
+      expect(old_file.exist?).to be_truthy
+      expect(new_file.exist?).to be_truthy
     end
   end
 end
diff --git a/spec/helpers/yarn_installer_spec.rb b/spec/helpers/yarn_installer_spec.rb
new file mode 100644
index 000000000..72ababca7
--- /dev/null
+++ b/spec/helpers/yarn_installer_spec.rb
@@ -0,0 +1,18 @@
+require "spec_helper"
+
+describe LanguagePack::Helpers::YarnInstaller do
+  describe "#install" do
+    it "should extract the yarn package" do
+      Dir.mktmpdir do |dir|
+        Dir.chdir(dir) do
+          installer = LanguagePack::Helpers::YarnInstaller.new
+          installer.install
+
+          # webpacker gem checks for yarnpkg
+          # https://github.com/rails/webpacker/blob/master/lib/install/bin/yarn.tt#L5
+          expect(File.exist?("yarn-v#{installer.version}/bin/yarnpkg")).to be(true)
+        end
+      end
+    end
+  end
+end
diff --git a/spec/no_lockfile_spec.rb b/spec/no_lockfile_spec.rb
deleted file mode 100644
index e3d121f54..000000000
--- a/spec/no_lockfile_spec.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-require 'spec_helper'
-
-describe "No Lockfile" do
-  it "should not deploy" do
-    Hatchet::Runner.new("no_lockfile", allow_failure: true).deploy do |app|
-      expect(app).not_to be_deployed
-      expect(app.output).to include("Gemfile.lock required")
-    end
-  end
-end
diff --git a/spec/rails23_spec.rb b/spec/rails23_spec.rb
deleted file mode 100644
index a09f0284b..000000000
--- a/spec/rails23_spec.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-require_relative 'spec_helper'
-
-describe "Rails 2.3.x" do
-  it "should deploy on ruby 1.8.7" do
-    Hatchet::Runner.new("rails23_mri_187").deploy do |app, heroku|
-      add_database(app, heroku)
-      expect(successful_body(app)).to eq("hello")
-    end
-  end
-end
diff --git a/spec/rails3_spec.rb b/spec/rails3_spec.rb
deleted file mode 100644
index 6b5fe9c6b..000000000
--- a/spec/rails3_spec.rb
+++ /dev/null
@@ -1,55 +0,0 @@
-require_relative 'spec_helper'
-
-describe "Rails 3.x" do
-  it "should deploy on ruby 1.9.3" do
-    Hatchet::Runner.new("rails3_mri_193").deploy do |app, heroku|
-      expect(app.output).to include("Asset precompilation completed")
-      add_database(app, heroku)
-
-      expect(app.output).to match("WARNINGS")
-      expect(app.output).to match("Add 'rails_12factor' gem to your Gemfile to skip plugin injection")
-
-      ls = app.run("ls vendor/plugins")
-      expect(ls).to match("rails3_serve_static_assets")
-      expect(ls).to match("rails_log_stdout")
-
-      expect(successful_body(app)).to eq("hello")
-    end
-  end
-
-  it "should not have warnings when using the rails_12factor gem" do
-    Hatchet::Runner.new("rails3_12factor").deploy do |app, heroku|
-      add_database(app, heroku)
-      expect(app.output).not_to match("Add 'rails_12factor' gem to your Gemfile to skip plugin injection")
-      expect(successful_body(app)).to eq("hello")
-    end
-  end
-
-  it "should only display the correct plugin warning" do
-    Hatchet::Runner.new("rails3_one_plugin").deploy do |app, heroku|
-      add_database(app, heroku)
-      expect(app.output).not_to match("rails_log_stdout")
-      expect(app.output).to match("rails3_serve_static_assets")
-      expect(app.output).to match("Add 'rails_12factor' gem to your Gemfile to skip plugin injection")
-      expect(successful_body(app)).to eq("hello")
-    end
-  end
-
-  context "when not using the rails gem" do
-    it "should deploy on ruby 1.9.3" do
-      Hatchet::Runner.new("railties3_mri_193").deploy do |app, heroku|
-        expect(app.output).to include("Asset precompilation completed")
-        add_database(app, heroku)
-        expect(app.output).to match("Ruby/Rails")
-        expect(successful_body(app)).to eq("hello")
-      end
-    end
-  end
-
-  it "fails compile if assets:precompile fails" do
-    Hatchet::Runner.new("rails3-fail-assets-compile", allow_failure: true).deploy do |app, heroku|
-      expect(app.output).to include("raising on assets:precompile on purpose")
-      expect(app).not_to be_deployed
-    end
-  end
-end
diff --git a/spec/rails41_spec.rb b/spec/rails41_spec.rb
deleted file mode 100644
index ca43052ab..000000000
--- a/spec/rails41_spec.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-require_relative 'spec_helper'
-
-describe "Rails 4.1.x" do
-  it "should detect rails successfully" do
-    Hatchet::App.new('rails41_scaffold').in_directory do
-      expect(LanguagePack::Rails41.use?).to eq(true)
-    end
-    Hatchet::App.new('rails41_scaffold').in_directory do
-      expect(LanguagePack::Rails4.use?).to eq(false)
-    end
-  end
-
-  it "should be able to run a migration without heroku specific database.yml" do
-    Hatchet::Runner.new("rails41_scaffold").deploy do |app, heroku|
-      add_database(app, heroku)
-      expect(app.output).not_to include("Writing config/database.yml to read from DATABASE_URL")
-      expect(app.run("rake db:migrate")).to include("20140218165801 CreatePeople")
-    end
-  end
-
-  it "should handle secrets.yml properly" do
-    Hatchet::Runner.new("rails41_scaffold").deploy do |app, heroku|
-      add_database(app, heroku)
-      ReplRunner.new(:rails_console, "heroku run bin/rails console -a #{app.name}").run do |console|
-        console.run("ENV['SECRET_KEY_BASE'] == Rails.application.config.secrets.secret_key_base") {|result| expect(result).not_to eq("true") }
-      end
-    end
-  end
-end
diff --git a/spec/rails4_spec.rb b/spec/rails4_spec.rb
deleted file mode 100644
index d9372cacd..000000000
--- a/spec/rails4_spec.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-require_relative 'spec_helper'
-
-describe "Rails 4.0.x" do
-  it "should detect rails successfully" do
-    Hatchet::App.new('rails4-manifest').in_directory do
-      expect(LanguagePack::Rails4.use?).to eq(true)
-    end
-    Hatchet::App.new('rails4-manifest').in_directory do
-      expect(LanguagePack::Rails3.use?).to eq(false)
-    end
-  end
-
-  it "should deploy on ruby 2.0.0" do
-    Hatchet::Runner.new("rails4-manifest").deploy do |app, heroku|
-      add_database(app, heroku)
-      expect(app.output).to include("Detected manifest file, assuming assets were compiled locally")
-      expect(app.output).not_to match("Include 'rails_12factor' gem to enable all platform features")
-    end
-  end
-
-  it "upgraded from 3 to 4 missing ./bin still works" do
-    Hatchet::Runner.new("rails3-to-4-no-bin").deploy do |app, heroku|
-      expect(app.output).to include("Asset precompilation completed")
-      add_database(app, heroku)
-
-      expect(app.output).to match("WARNINGS")
-      expect(app.output).to match("Include 'rails_12factor' gem to enable all platform features")
-
-      app.run("rails console") do |console|
-        console.run("'hello' + 'world'") {|result| expect(result).to match('helloworld')}
-      end
-    end
-  end
-
-  it "works with windows" do
-    Hatchet::Runner.new("rails4_windows_mri193").deploy do |app, heroku|
-      result = app.run("rails -v")
-      expect(result).to match("4.0.0")
-
-      result = app.run("rake -T")
-      expect(result).to match("assets:precompile")
-
-      result = app.run("bundle show rails")
-      expect(result).to match("rails-4.0.0")
-
-      before_warnings = app.output.split("WARNINGS:").first
-      expect(before_warnings).to match("Removing `Gemfile.lock`")
-    end
-  end
-
-  it "fails compile if assets:precompile fails" do
-    Hatchet::Runner.new("rails4-fail-assets-compile", allow_failure: true).deploy do |app, heroku|
-      expect(app.output).to include("raising on assets:precompile on purpose")
-      expect(app).not_to be_deployed
-    end
-  end
-end
diff --git a/spec/rake/deploy_check_spec.rb b/spec/rake/deploy_check_spec.rb
new file mode 100644
index 000000000..a755fe3b2
--- /dev/null
+++ b/spec/rake/deploy_check_spec.rb
@@ -0,0 +1,170 @@
+require "spec_helper"
+require "rake/deploy_check"
+
+describe "A helper class for deploying" do
+  describe "tests that hit github" do
+    it "know remote tags" do
+      Dir.mktmpdir do |dir|
+        Dir.chdir(dir) do
+          run!("touch foo; git init; git add .; git commit -m first")
+
+          deploy = DeployCheck.new(github: "heroku/heroku-buildpack-ruby")
+          expect(deploy.remote_tag_array.class).to eq(Array)
+          expect(deploy.remote_tag_array).to include("v218")
+
+          expect(deploy.remote_tag_matches?(local_sha: "nope")).to be_falsey
+          expect(deploy.remote_tag_matches?(remote_sha: "nope")).to be_falsey
+        end
+      end
+    end
+
+    it "remote sha" do
+      deploy = DeployCheck.new(github: "sharpstone/do_not_delete_or_modify")
+      expect(deploy.remote_commit_sha).to eq("3a9ff6433a05560acfd06dda03a11605a96ae133")
+    end
+
+    it "local_commit_sha" do
+      Dir.mktmpdir do |dir|
+        Dir.chdir(dir) do
+          run!("git clone https://github.com/sharpstone/default_ruby #{dir} 2>&1 && cd #{dir} && git checkout 6e642963acec0ff64af51bd6fba8db3c4176ed6e 2>&1 && git checkout -b mybranch 2>&1")
+          deploy = DeployCheck.new(github: "heroku/heroku-buildpack-ruby")
+          expect(deploy.local_commit_sha).to eq("6e642963acec0ff64af51bd6fba8db3c4176ed6e")
+        end
+      end
+    end
+  end
+
+  describe "tests that do NOT hit github" do
+    it "checks multiple things" do
+      deploy = DeployCheck.new(github: "heroku/heroku-buildpack-ruby")
+      deploy.instance_variable_set(:@methods_called, [])
+
+      def deploy.check_version!
+        @methods_called << :check_version!
+      end
+
+      def deploy.check_unstaged!
+        @methods_called << :check_unstaged!
+      end
+
+      def deploy.check_branch!
+        @methods_called << :check_branch!
+      end
+
+      def deploy.check_changelog!
+        @methods_called << :check_changelog!
+      end
+
+      def deploy.check_sync!
+        @methods_called << :check_sync!
+      end
+
+      deploy.check!
+
+      methods_called = deploy.instance_variable_get(:@methods_called)
+      expect(methods_called).to include(:check_version!)
+      expect(methods_called).to include(:check_unstaged!)
+      expect(methods_called).to include(:check_branch!)
+      expect(methods_called).to include(:check_changelog!)
+      expect(methods_called).to include(:check_sync!)
+    end
+
+    it "checks version" do
+      ["v123abc", "123", "V123"].each do |bad_version|
+        deploy = DeployCheck.new(github: "heroku/heroku-buildpack-ruby", next_version: bad_version)
+        expect {
+          deploy.check_version!
+        }.to raise_error(/Must look like a version/)
+      end
+    end
+
+    it "checks local sha and remote sha match" do
+      deploy = DeployCheck.new(github: "heroku/heroku-buildpack-ruby")
+      expect {
+        deploy.check_sync!(local_sha: "a", remote_sha: "not a")
+      }.to raise_error(/Must be in-sync/)
+
+      deploy = DeployCheck.new(github: "heroku/heroku-buildpack-ruby")
+      deploy.check_sync!(
+        local_sha: "cbe100933b1e50953f0da35aafc50374ae2a31f9",
+        remote_sha: "cbe100933b1e50953f0da35aafc50374ae2a31f9"
+      )
+    end
+
+    it "github url" do
+      deploy = DeployCheck.new(github: "heroku/heroku-buildpack-ruby")
+      expect(deploy.github_url).to eq("https://github.com/heroku/heroku-buildpack-ruby")
+    end
+
+    it "knows the next version" do
+      deploy = DeployCheck.new(github: "heroku/heroku-buildpack-ruby", next_version: "v123")
+
+      def deploy.remote_tag_array
+        ["v123"]
+      end
+
+      expect(deploy.tag_exists_on_remote?).to be_truthy
+
+      deploy = DeployCheck.new(github: "heroku/heroku-buildpack-ruby", next_version: "v124")
+
+      def deploy.remote_tag_array
+        ["v123"]
+      end
+
+      expect(deploy.tag_exists_on_remote?).to be_falsey
+    end
+
+    it "checks remote tags for existance" do
+      deploy = DeployCheck.new(github: "heroku/heroku-buildpack-ruby")
+
+      def deploy.remote_tag_array
+        ["v123"]
+      end
+
+      expect(deploy.next_version).to eq("v124")
+    end
+
+    it "checks unstaged" do
+      Dir.mktmpdir do |dir|
+        Dir.chdir(dir) do
+          deploy = DeployCheck.new(github: "heroku/heroku-buildpack-ruby")
+          run!("echo 'blerg' >> foo.txt")
+          run!("git init .; git add . ; git commit -m first")
+          deploy.check_unstaged!
+
+          run!("echo 'foo' >> foo.txt")
+          expect { deploy.check_unstaged! }.to raise_error(/Must have all changes committed/)
+        end
+      end
+    end
+
+    it "checks branch" do
+      Dir.mktmpdir do |dir|
+        Dir.chdir(dir) do
+          deploy = DeployCheck.new(github: "heroku/heroku-buildpack-ruby")
+          run!("echo 'blerg' >> foo.txt")
+          run!("git init .; git add . ; git commit -m first")
+          run!("git checkout -B main")
+          deploy.check_branch!
+
+          expect { deploy.check_branch!("not_main") }.to raise_error(/Must be on main branch/)
+        end
+      end
+    end
+
+    it "checks CHANGELOG" do
+      Dir.mktmpdir do |dir|
+        Dir.chdir(dir) do
+          deploy = DeployCheck.new(github: "heroku/heroku-buildpack-ruby", next_version: "v999")
+          run!("touch CHANGELOG.md")
+          expect {
+            deploy.check_changelog!
+          }.to raise_error(/Expected CHANGELOG.md to include \[v999\]/)
+
+          run!("echo '## [v999]' >> CHANGELOG.md")
+          deploy.check_changelog!
+        end
+      end
+    end
+  end
+end
diff --git a/spec/rubies_spec.rb b/spec/rubies_spec.rb
deleted file mode 100644
index 9d61c33f5..000000000
--- a/spec/rubies_spec.rb
+++ /dev/null
@@ -1,61 +0,0 @@
-require_relative 'spec_helper'
-
-describe "Ruby Versions" do
-  it "should deploy ruby 1.8.7 properly" do
-    Hatchet::Runner.new("mri_187").deploy do |app|
-      version = '1.8.7'
-      expect(app.output).to match(version)
-      expect(app.run('ruby -v')).to match(version)
-    end
-  end
-
-  it "should deploy ruby 1.9.2 properly" do
-    Hatchet::Runner.new("mri_192").deploy do |app|
-      version = '1.9.2'
-      expect(app.output).to match(version)
-      expect(app.run('ruby -v')).to match(version)
-    end
-  end
-
-  it "should deploy ruby 1.9.2 properly (git)" do
-    Hatchet::GitApp.new("mri_192", buildpack: git_repo).deploy do |app|
-      version = '1.9.2'
-      expect(app.output).to match(version)
-      expect(app.run('ruby -v')).to match(version)
-    end
-  end
-
-  it "should deploy ruby 1.9.3 properly" do
-    Hatchet::Runner.new("mri_193").deploy do |app|
-      version = '1.9.3'
-      expect(app.output).to match(version)
-      expect(app.run('ruby -v')).to match(version)
-    end
-  end
-
-  it "should deploy ruby 2.0.0 properly" do
-    Hatchet::Runner.new("mri_200").deploy do |app|
-      version = '2.0.0'
-      expect(app.output).to match(version)
-      expect(app.run('ruby -v')).to match(version)
-    end
-  end
-
-  it "should deploy jruby 1.7.3 (legacy jdk) properly" do
-    Hatchet::AnvilApp.new("ruby_193_jruby_173").deploy do |app|
-      expect(app.output).to match("Installing JVM: openjdk1.7.0_25")
-      expect(app.output).to match("ruby-1.9.3-jruby-1.7.3")
-      expect(app.output).not_to include("OpenJDK 64-Bit Server VM warning")
-      expect(app.run('ruby -v')).to match("jruby 1.7.3")
-    end
-  end
-
-  it "should deploy jruby 1.7.6 (latest jdk) properly" do
-    Hatchet::AnvilApp.new("ruby_193_jruby_176").deploy do |app|
-      expect(app.output).to match("Installing JVM: openjdk7-latest")
-      expect(app.output).to match("ruby-1.9.3-jruby-1.7.6")
-      expect(app.output).not_to include("OpenJDK 64-Bit Server VM warning")
-      expect(app.run('ruby -v')).to match("jruby 1.7.6")
-    end
-  end
-end
diff --git a/spec/ruby_spec.rb b/spec/ruby_spec.rb
deleted file mode 100644
index d6216fe2c..000000000
--- a/spec/ruby_spec.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-require_relative 'spec_helper'
-
-describe "Ruby apps" do
-  describe "Rake detection" do
-    context "Ruby 1.8.7" do
-      it "doesn't run rake tasks if no rake gem" do
-        Hatchet::Runner.new('mri_187_no_rake').deploy do |app, heroku|
-          expect(app.output).not_to include("foo")
-        end
-      end
-
-      it "runs a rake task if the gem exists" do
-        Hatchet::Runner.new('mri_187_rake').deploy do |app, heroku|
-          expect(app.output).to include("foo")
-        end
-      end
-    end
-
-    context "Ruby 1.9+" do
-      it "runs rake tasks if no rake gem" do
-        Hatchet::Runner.new('mri_200_no_rake').deploy do |app, heroku|
-          expect(app.output).to include("foo")
-        end
-      end
-
-      it "runs a rake task if the gem exists" do
-        Hatchet::Runner.new('mri_200_rake').deploy do |app, heroku|
-          expect(app.output).to include("foo")
-        end
-      end
-    end
-  end
-end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 2a64d3ff4..802052210 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -1,47 +1,198 @@
-require 'rspec/core'
-require 'hatchet'
-require 'fileutils'
-require 'hatchet'
-require 'rspec/retry'
-require 'language_pack'
+require "rspec/core"
+require "hatchet"
+require "fileutils"
+require "stringio"
+require "rspec/retry"
+require "language_pack"
+require "language_pack/shell_helpers"
 
-require 'language_pack'
+ENV["HATCHET_BUILDPACK_BASE"] ||= "https://github.com/heroku/heroku-buildpack-ruby"
 
-ENV['RACK_ENV'] = 'test'
+ENV["RACK_ENV"] = "test"
+
+DEFAULT_STACK = "heroku-24"
+
+def hatchet_path(path = "")
+  Pathname(__FILE__).join("../../repos").expand_path.join(path)
+end
 
 RSpec.configure do |config|
-  config.filter_run focused: true unless ENV['IS_RUNNING_ON_TRAVIS']
-  config.run_all_when_everything_filtered = true
   config.alias_example_to :fit, focused: true
-  config.full_backtrace      = true
-  config.verbose_retry       = true # show retry status in spec process
-  config.default_retry_count = 2 if ENV['IS_RUNNING_ON_TRAVIS'] # retry all tests that fail again
+  config.full_backtrace = true
+  config.verbose_retry = true # show retry status in spec process
+  config.example_status_persistence_file_path = "spec/examples.txt"
 
   config.expect_with :rspec do |c|
+    c.max_formatted_output_length = Float::INFINITY
     c.syntax = :expect
   end
-  config.mock_with :none
-end
-
-def git_repo
-  "https://github.com/heroku/heroku-buildpack-ruby.git"
-end
-
-def add_database(app, heroku)
-  Hatchet::RETRIES.times.retry do
-    heroku.post_addon(app.name, 'heroku-postgresql:hobby-dev')
-    _, value = heroku.get_config_vars(app.name).body.detect {|key, value| key.match(/HEROKU_POSTGRESQL_[A-Z]+_URL/) }
-    heroku.put_config_vars(app.name, 'DATABASE_URL' => value)
-  end
+  config.mock_with :nothing
+  config.include LanguagePack::ShellHelpers
 end
 
 def successful_body(app, options = {})
   retry_limit = options[:retry_limit] || 50
-  Excon.get("http://#{app.name}.herokuapp.com", :idempotent => true, :expects => 200, :retry_limit => retry_limit).body
+  url = "http://#{app.name}.herokuapp.com"
+  Excon.get(url, idempotent: true, expects: 200, retry_limit: retry_limit).body
 end
 
 def create_file_with_size_in(size, dir)
   name = File.join(dir, SecureRandom.hex(16))
-  File.open(name, 'w') {|f| f.print([ 1 ].pack("C") * size) }
+  File.open(name, "w") { |f| f.print([1].pack("C") * size) }
   Pathname.new name
 end
+
+def buildpack_path
+  File.expand_path(File.join("../.."), __FILE__)
+end
+
+def fixture_path(path)
+  Pathname.new(__FILE__).join("../fixtures").expand_path.join(path)
+end
+
+def set_lts_ruby_version
+  set_ruby_version(version: "3.3.6")
+end
+
+def set_ruby_version(version:)
+  # Last ruby declaration in the gemfile wins
+  Pathname("Gemfile").write("\nruby '#{version}'", mode: "a")
+
+  # Update the Gemfile.lock to match
+  contents = Pathname("Gemfile.lock").read.concat(<<~EOF)
+
+    RUBY VERSION
+       ruby #{version}p170
+  EOF
+  Pathname("Gemfile.lock").write(contents)
+end
+
+def set_bundler_version(version:)
+  gemfile_lock = Pathname("Gemfile.lock").read
+
+  version = if version == :default
+    ""
+  else
+    "BUNDLED WITH\n   #{version}"
+  end
+  gemfile_lock.gsub!(/^BUNDLED WITH$(?:\r?\n) {2,3}(?\d+)\.(?\d+)\.\d+/m, version)
+  gemfile_lock << "\n#{version}" unless gemfile_lock.match?(/^BUNDLED WITH/)
+
+  Pathname("Gemfile.lock").write(gemfile_lock)
+end
+
+def rails_lts_config
+  {"BUNDLE_GEMS__RAILSLTS__COM" => ENV["RAILS_LTS_CREDS"]}
+end
+
+def rails_lts_stack
+  "heroku-22"
+end
+
+def dyno_status(app, ps_name = "web")
+  app
+    .api_rate_limit.call
+    .dyno
+    .list(app.name)
+    .detect { |x| x["type"] == ps_name }
+end
+
+def wait_for_dyno_boot(app, ps_name = "web", sleep_val = 1)
+  while ["starting", "restarting"].include?(dyno_status(app, ps_name)["state"])
+    sleep sleep_val
+  end
+  dyno_status(app, ps_name)
+end
+
+def web_boot_status(app)
+  wait_for_dyno_boot(app)["state"]
+end
+
+def root_dir
+  Pathname(__dir__).join("..")
+end
+
+# Helper class for capturing and comparing environment variables in test output
+# Used by both deploy tests and CI tests
+class EnvDiff
+  attr_reader :added, :modified, :path_before, :path_after, :env_hash
+
+  def initialize(output, build_dir_pattern: /BUILD_DIR: (.+)/)
+    if build_dir_pattern
+      build_dir = output.match(build_dir_pattern)&.[](1)&.strip
+      if build_dir
+        output = output.gsub(build_dir, "")
+      else
+        raise "build_dir_pattern #{build_dir_pattern.inspect} not found in output:\n#{output}"
+      end
+    end
+
+    env_sections = extract_env_sections(output)
+
+    if env_sections.size == 1
+      # Single section mode (e.g., CI tests)
+      @env_hash = env_sections[0]
+    elsif env_sections.size == 2
+      # Diff mode (before/after comparison)
+      before_hash = env_sections[0]
+      after_hash = env_sections[1]
+
+      @path_before = before_hash["PATH"]
+      @path_after = after_hash["PATH"]
+
+      non_path_before = before_hash.except("PATH")
+      non_path_after = after_hash.except("PATH")
+
+      @added = (non_path_after.keys - non_path_before.keys).sort.map { |k| "#{k}=#{non_path_after[k]}" }
+      @env_hash = after_hash
+    elsif env_sections.size > 2
+      raise "Too many print markers in output found:\n#{output}"
+    else
+      raise "Did not find any print markers in output:\n#{output}"
+    end
+  end
+
+  private def extract_env_sections(output, start_marker: "## PRINTING ENV ##", end_marker: "## PRINTING ENV DONE ##")
+    sections = []
+    in_section = false
+    current_env = {}
+
+    output.each_line do |line|
+      clean = line.gsub(/^\s*remote:\s*/, "").strip
+      case clean
+      when start_marker
+        in_section = true
+        current_env = {}
+      when end_marker
+        sections << current_env
+        in_section = false
+      else
+        if in_section && clean.include?("=")
+          key, value = clean.split("=", 2)
+          current_env[key] = value
+        end
+      end
+    end
+    sections
+  end
+end
+
+private def extract_remote_lines(output, start_marker:, end_marker:)
+  lines = []
+  in_section = false
+
+  output.each_line do |line|
+    clean = line.gsub(/^\s*remote:\s*/, "").strip
+    case clean
+    when start_marker
+      in_section = true
+    when end_marker
+      in_section = false
+    else
+      if in_section
+        lines << clean
+      end
+    end
+  end
+  lines
+end
diff --git a/spec/unit/bash_functions_spec.rb b/spec/unit/bash_functions_spec.rb
new file mode 100644
index 000000000..a0ea0fd41
--- /dev/null
+++ b/spec/unit/bash_functions_spec.rb
@@ -0,0 +1,280 @@
+require "spec_helper"
+
+describe "Bash functions" do
+  describe "output::error" do
+    it "prints" do
+      Dir.mktmpdir do |dir|
+        Pathname(dir).join("does-not-exist").expand_path
+        out = exec_with_bash_file(code: <<~EOM, file: bash_functions_file, strip_output: true)
+          output::error <&1`
+        out = out.strip if strip_output
+        success = $?.success?
+      end
+    rescue Timeout::Error
+      out = "Command timed out"
+      success = false
+    end
+
+    if raise_on_fail && !success
+      message = <<~EOM
+        Contents:
+
+        #{contents.lines.map.with_index { |line, number| "  #{number.next} #{line.chomp}" }.join("\n")}
+
+        Expected running script to succeed, but it did not. If this was expected, use `raise_on_fail: false`
+
+        Output:
+
+          #{out}
+      EOM
+
+      raise message
+    else
+      out
+    end
+  end
+
+  def exec_with_bash_functions(code, stack: "heroku-24", raise_on_fail: true)
+    exec_with_bash_file(
+      code: code,
+      file: bash_functions_file,
+      stack: stack,
+      raise_on_fail: raise_on_fail
+    )
+  end
+end
diff --git a/spec/upgrade_ruby_spec.rb b/spec/upgrade_ruby_spec.rb
deleted file mode 100644
index 975be7a2e..000000000
--- a/spec/upgrade_ruby_spec.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-require 'spec_helper'
-
-describe "Upgrading ruby apps" do
-  it "upgrades from 2.0.0 to 2.1.0" do
-    Hatchet::Runner.new("mri_200").deploy do |app|
-      expect(app.run("ruby -v")).to match("2.0.0")
-
-      `echo "" > Gemfile; rm Gemfile.lock`
-      `env BUNDLE_GEMFILE=./Gemfile bundle install`
-      `echo "ruby '2.1.0'" > Gemfile`
-      `git add . ; git commit -m update-ruby`
-      app.push!
-      expect(app.output).to match("2.1.0")
-      expect(app.run("ruby -v")).to match("2.1.0")
-    end
-  end
-end
diff --git a/spec/user_env_compile_edge_case_spec.rb b/spec/user_env_compile_edge_case_spec.rb
deleted file mode 100644
index 1af8667c9..000000000
--- a/spec/user_env_compile_edge_case_spec.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-require 'spec_helper'
-
-describe "User env compile" do
-  it "should not cause problems with warnings" do
-    app = Hatchet::Runner.new("mri_210", labs: "user-env-compile")
-    app.setup!
-    app.set_config("RUBY_HEAP_MIN_SLOTS" => "1000000")
-    app.deploy do |app|
-      expect(app.run("bundle version")).to match(LanguagePack::Ruby::BUNDLER_VERSION)
-    end
-  end
-
-  it "DATABASE_URL is present even without user-env-compile" do
-    Hatchet::Runner.new("database_url_expected_in_rakefile").deploy do |app|
-      expect(app.output).to match("Asset precompilation completed")
-    end
-  end
-
-  it "allows weird characters in the env" do
-    app = Hatchet::Runner.new("rails41_scaffold")
-    app.setup!
-    app.set_config("BAD VALUE"      => %Q{ )(*&^%$#'$'\n''@!~\'\ })
-    app.set_config(%Q{ un"matched } => "bad key" )
-    app.deploy do |app|
-      expect(app.output).to match("Launching")
-    end
-  end
-end
diff --git a/support/s3/hmac b/support/s3/hmac
deleted file mode 100755
index 5aa074a0d..000000000
--- a/support/s3/hmac
+++ /dev/null
@@ -1,79 +0,0 @@
-#!/bin/bash
-# Implement HMAC functionality on top of the OpenSSL digest functions.
-# licensed under the terms of the GNU GPL v2
-# Copyright 2007 Victor Lowther 
-
-die() {
-  echo $*
-  exit 1
-}
-
-check_deps() {
-  local res=0
-  while [ $# -ne 0 ]; do
-    which "${1}" >& /dev/null || { res=1; echo "${1} not found."; }
-    shift
-  done
-  (( res == 0 )) || die "aborting."
-}
-
-# write a byte (passed as hex) to stdout
-write_byte() {
-  # $1 = byte to write
-  printf "\\x$(printf "%x" ${1})"
-}
-
-# make an hmac pad out of a key.
-# this is not the most secure way of doing it, but it is
-# the most expedient.
-make_hmac_pad() {
-  # using key in file $1 and byte in $2, create the appropriate hmac pad
-  # Pad keys out to $3 bytes
-  # if key is longer than $3, use hash $4 to hash the key first.
-  local x y a size remainder oifs
-  (( remainder = ${3} ))
-  # in case someone else was messing with IFS.
-  for x in $(echo -n "${1}" | od -v -t u1 | cut -b 9-);
-  do
-    write_byte $((${x} ^ ${2}))
-    (( remainder -= 1 ))
-  done
-  for ((y=0; remainder - y ;y++)); do
-    write_byte $((0 ^ ${2}))
-  done
-}
-
-# utility functions for making hmac pads
-hmac_ipad() {
-  make_hmac_pad "${1}" 0x36 ${2} "${3}"
-}
-
-hmac_opad() {
-  make_hmac_pad "${1}" 0x5c ${2} "${3}"
-}
-
-# hmac something
-do_hmac() {
-  # $1 = algo to use.  Must be one that openssl knows about
-  # $2 = keyfile to use
-  # $3 = file to hash.  uses stdin if none is given.
-  # accepts input on stdin, leaves it on stdout.
-  # Output is binary, if you want something else pipe it accordingly.
-  local blocklen keysize x
-  case "${1}" in
-    sha) blocklen=64 ;;
-    sha1) blocklen=64 ;;
-    md5) blocklen=64 ;;
-    md4) blocklen=64 ;;
-    sha256) blocklen=64 ;;
-    sha512) blocklen=128 ;;
-    *) die "Unknown hash ${1} passed to hmac!" ;;
-  esac
-  cat <(hmac_ipad ${2} ${blocklen} "${1}") "${3:--}" | openssl dgst "-${1}" -binary | \
-  cat <(hmac_opad ${2} ${blocklen} "${1}") - | openssl dgst "-${1}" -binary
-}
-
-[[ ${1} ]] || die "Must pass the name of the hash function to use to ${0}".
-
-check_deps od openssl
-do_hmac "${@}"
diff --git a/support/s3/s3 b/support/s3/s3
deleted file mode 100755
index d5cf94d2a..000000000
--- a/support/s3/s3
+++ /dev/null
@@ -1,223 +0,0 @@
-#!/bin/bash
-# basic amazon s3 operations
-# Licensed under the terms of the GNU GPL v2
-# Copyright 2007 Victor Lowther 
-
-set -e
-
-basedir="$( cd -P "$( dirname "$0" )" && pwd )"
-PATH="$basedir:$PATH"
-
-# print a message and bail
-die() {
-  echo $*
-  exit 1
-}
-
-# check to see if the variable name passed exists and holds a value.
-# Die if it does not.
-check_or_die() {
-  [[ ${!1} ]] || die "Environment variable ${1} is not set."
-}
-
-# check to see if we have all the needed S3 variables defined.
-# Bail if we do not.
-check_s3() {
-  local sak x
-  for x in S3_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY; do
-    check_or_die ${x};
-  done
-  sak="$(echo -n $S3_SECRET_ACCESS_KEY | wc -c)"
-  (( ${sak%%[!0-9 ]*} == 40 )) || \
-    die "S3 Secret Access Key is not exactly 40 bytes long.  Please fix it."
-}
-# check to see if our external dependencies exist
-check_dep() {
-  local res=0
-  while [[ $# -ne 0 ]]; do
-    which "${1}" >& /dev/null || { res=1; echo "${1} not found."; }
-    shift
-  done
-  (( res == 0 )) || die "aborting."
-}
-
-check_deps() {
-  check_dep openssl date hmac cat grep curl
-  check_s3
-}
-
-urlenc() {
-  # $1 = string to url encode
-  # output is on stdout
-  # we don't urlencode everything, just enough stuff.
-  echo -n "${1}" |
-  sed 's/%/%25/g
-       s/ /%20/g
-       s/#/%23/g
-       s/\$/%24/g
-       s/\&/%26/g
-       s/+/%2b/g
-       s/,/%2c/g
-       s/:/%3a/g
-       s/;/%3b/g
-       s/?/%3f/g
-       s/@/%40/g
-       s/	/%09/g'
-}
-
-xmldec() {
-  # no parameters.
-  # accept input on stdin, put it on stdout.
-  # patches accepted to get more stuff
-  sed 's/\"/\"/g
-       s/\&/\&/g
-       s/\<//g'
-}
-
-## basic S3 functionality.  x-amz-header functionality is not implemented.
-# make an S3 signature string, which will be output on stdout.
-s3_signature_string() {
-  # $1 = HTTP verb
-  # $2 = date string, must be in UTC
-  # $3 = bucket name, if any
-  # $4 = resource path, if any
-  # $5 = content md5, if any
-  # $6 = content MIME type, if any
-  # $7 = canonicalized headers, if any
-  # signature string will be output on stdout
-  local verr="Must pass a verb to s3_signature_string!"
-  local verb="${1:?verr}"
-  local bucket="${3}"
-  local resource="${4}"
-  local derr="Must pass a date to s3_signature_string!"
-  local date="${2:?derr}"
-  local mime="${6}"
-  local md5="${5}"
-  local headers="${7}"
-  printf "%s\n%s\n%s\n%s\n%s\n%s%s" \
-    "${verb}" "${md5}" "${mime}" "${date}" \
-    "${headers}" "${bucket}" "${resource}" | \
-    hmac sha1 "${S3_SECRET_ACCESS_KEY}" | openssl base64 -e -a
-}
-
-# cheesy, but it is the best way to have multiple headers.
-curl_headers() {
-  # each arg passed will be output on its own line
-  local parms=$#
-  for ((;$#;)); do
-    echo "header = \"${1}\""
-    shift
-  done
-}
-
-s3_curl() {
-  # invoke curl to do all the heavy HTTP lifting
-  # $1 = method (one of GET, PUT, or DELETE. HEAD is not handled yet.)
-  # $2 = remote bucket.
-  # $3 = remote name
-  # $4 = local name.
-  local bucket remote date sig md5 arg inout headers
-  # header handling is kinda fugly, but it works.
-  bucket="${2:+/${2}}/" # slashify the bucket
-  remote="$(urlenc "${3}")" # if you don't, strange things may happen.
-  stdopts="--connect-timeout 10 --fail --silent"
-  [[ $CURL_S3_DEBUG == true ]] && stdopts="${stdopts} --show-error --fail"
-  case "${1}" in
-   GET) arg="-o" inout="${4:--}" # stdout if no $4
-	;;
-   PUT) [[ ${2} ]] || die "PUT can has bucket?"
-        if [[ ! ${3} ]]; then
-	  arg="-X PUT"
-	  headers[${#headers[@]}]="Content-Length: 0"
-	elif [[ -f ${4} ]]; then
-          md5="$(openssl dgst -md5 -binary "${4}"|openssl base64 -e -a)"
-	  arg="-T" inout="${4}"
-	  headers[${#headers[@]}]="x-amz-acl: public-read"
-	  headers[${#headers[@]}]="Expect: 100-continue"
-	else
-	  die "Cannot write non-existing file ${4}"
-        fi
-	;;
-   DELETE) arg="-X DELETE"
-           ;;
-   HEAD) arg="-I" ;;
-   *) die "Unknown verb ${1}.  It probably would not have worked anyways." ;;
-  esac
-  date="$(TZ=UTC date '+%a, %e %b %Y %H:%M:%S %z')"
-  sig=$(s3_signature_string ${1} "${date}" "${bucket}" "${remote}" "${md5}" "" "x-amz-acl:public-read")
-
-  headers[${#headers[@]}]="Authorization: AWS ${S3_ACCESS_KEY_ID}:${sig}"
-  headers[${#headers[@]}]="Date: ${date}"
-  [[ ${md5} ]] && headers[${#headers[@]}]="Content-MD5: ${md5}"
-    curl ${arg} "${inout}" ${stdopts} -o - -K <(curl_headers "${headers[@]}") \
-       "http://s3.amazonaws.com${bucket}${remote}"
-  return $?
-}
-
-s3_put() {
-  # $1 = remote bucket to put it into
-  # $2 = remote name to put
-  # $3 = file to put.  This must be present if $2 is.
-  s3_curl PUT "${1}" "${2}" "${3:-${2}}"
-  return $?
-}
-
-s3_get() {
-  # $1 = bucket to get file from
-  # $2 = remote file to get
-  # $3 = local file to get into. Will be overwritten if it exists.
-  #      If this contains a path, that path must exist before calling this.
-  s3_curl GET "${1}" "${2}" "${3:-${2}}"
-  return $?
-}
-
-s3_test() {
-  # same args as s3_get, but uses the HEAD verb instead of the GET verb.
-  s3_curl HEAD "${1}" "${2}" >/dev/null
-  return $?
-}
-
-# Hideously ugly, but it works well enough.
-s3_buckets() {
-  s3_get |grep -o '[^>]*' |sed 's/<[^>]*>//g' |xmldec
-  return $?
-}
-
-# this will only return the first thousand entries, alas
-# Mabye some kind soul can fix this without writing an XML parser in bash?
-# Also need to add xml entity handling.
-s3_list() {
-  # $1 = bucket to list
-  [ "x${1}" == "x" ] && return 1
-  s3_get "${1}" |grep -o '[^>]*' |sed 's/<[^>]*>//g'| xmldec
-  return $?
-}
-
-s3_delete() {
-  # $1 = bucket to delete from
-  # $2 = item to delete
-  s3_curl DELETE "${1}" "${2}"
-  return $?
-}
-
-# because this uses s3_list, it suffers from the same flaws.
-s3_rmrf() {
-  # $1 = bucket to delete everything from
-  s3_list "${1}" | while read f; do
-    s3_delete "${1}" "${f}";
-  done
-}
-
-check_deps
-case $1 in
-  put) shift; s3_put "$@" ;;
-  get) shift; s3_get "$@" ;;
-  rm) shift; s3_delete "$@" ;;
-  ls) shift; s3_list "$@" ;;
-  test) shift; s3_test "$@" ;;
-  buckets) s3_buckets ;;
-  rmrf) shift; s3_rmrf "$@" ;;
-  *) die "Unknown command ${1}."
-    ;;
-esac
diff --git a/tmp/.gitkeep b/tmp/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/vendor/dotenv.rb b/vendor/dotenv.rb
deleted file mode 100644
index cca1c077d..000000000
--- a/vendor/dotenv.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# imports contents of .env file into ENV
-file = File.expand_path("../../.env", __FILE__)
-if File.exists?(file)
-  File.read(file).split("\n").map {|x| x.split("=") }.each do |k,v|
-    ENV[k.strip] = v.strip
-  end
-end
diff --git a/vendor/lpxc.rb b/vendor/lpxc.rb
deleted file mode 100644
index 2e3ed30c0..000000000
--- a/vendor/lpxc.rb
+++ /dev/null
@@ -1,177 +0,0 @@
-require 'time'
-require 'net/http'
-require 'uri'
-require 'thread'
-require 'timeout'
-
-class Lpxc
-
-  #After parsing opts and initializing defaults, the initializer
-  #will start 2 threads. One thread for sending HTTP requests and another
-  #thread for flushing log messages to the outlet thread periodically.
-  #:hash => {}:: A data structure for grouping log messages by token.
-  #:request_queue => SizedQueue.new::  Contains HTTP requests ready for outlet thread to deliver to logplex.
-  #:default_token => nil:: You can specify a token that will be used for any call to Lpxc#puts that doesn't include a token.
-  #:structured_data => '-':: Structured-data field for syslog headers. Ignored by logplex.
-  #:msgid => '-'::  Msg ID field for syslog headers. Ignored by logplex.
-  #:procid => 'lpxc':: Proc ID field for syslog headers. This will show up in the Heroku logs tail command as: app [lpxc].
-  #:hostname => 'myhost':: Hostname field for syslog headers. Ignored by logplex.
-  #:max_reqs_per_conn => 1_000:: Number of requests before we re-establish our keep-alive connection to logplex.
-  #:conn_timeout => 2:: Number of seconds before timing out a sindle request to logplex.
-  #:batch_size => 300:: Max number of log messages inside single HTTP request.
-  #:flush_interval => 0.5:: Fractional number of seconds before flushing all log messages in buffer to logplex.
-  #:logplex_url => \'https://east.logplex.io/logs':: HTTP server that will accept our log messages.
-  #:disable_delay_flush => nil:: Force flush only batch_size is reached.
-  def initialize(opts={})
-    @hash_lock = Mutex.new
-    @hash              = opts[:hash]              || Hash.new
-    @request_queue     = opts[:request_queue]     || SizedQueue.new(1)
-    @default_token     = opts[:default_token]     || ENV['LOGPLEX_DEFAULT_TOKEN']
-    @structured_data   = opts[:structured_data]   || "-"
-    @msgid             = opts[:msgid]             || "-"
-    @procid            = opts[:procid]            || "lpxc"
-    @hostname          = opts[:hostname]          || "myhost"
-    @max_reqs_per_conn = opts[:max_reqs_per_conn] || 1_000
-    @conn_timeout      = opts[:conn_timeout]      || 2
-    @batch_size        = opts[:batch_size]        || 300
-    @flush_interval    = opts[:flush_interval]    || 0.5
-    @logplex_url       = URI(opts[:logplex_url]   || ENV["LOGPLEX_URL"] ||
-      raise("Must set logplex url."))
-
-    #Keep track of the number of requests that the outlet
-    #is processing. This value is used by the wait function.
-    @req_in_flight = 0
-
-    #Initialize the last_flush to an arbitrary time.
-    @last_flush = Time.now + @flush_interval
-
-    #Start the processing threads.
-    Thread.new {outlet}
-    Thread.new {delay_flush} if opts[:disable_delay_flush].nil?
-  end
-
-  #The interface to publish logs into the stream.
-  #This function will set the log message to the current time in UTC.
-  #If the buffer for this token's log messages is full, it will flush the buffer.
-  def puts(msg, tok=@default_token)
-    @hash_lock.synchronize do
-      #Messages are grouped by their token since 1 http request
-      #to logplex must only contain log messages belonging to a single token.
-      q = @hash[tok] ||= SizedQueue.new(@batch_size)
-      #This call will block if the queue is full.
-      #However this should never happen since the next command will flush
-      #the queue if we add the last item.
-      q.enq({:t => Time.now.utc, :token => tok, :msg => msg})
-      flush if q.size == q.max
-    end
-  end
-
-  #Wait until all of the data has been cleared from memory.
-  #This is useful if you don't want your program to exit before
-  #we are able to deliver log messages to logplex.
-  def wait
-    sleep(0.1) until
-      @hash.length.zero? &&
-      @request_queue.empty? &&
-      @req_in_flight.zero?
-  end
-
-  private
-
-  #Take a lock to read all of the buffered messages.
-  #Once we have read the messages, we make 1 http request for the batch.
-  #We pass the request off into the request queue so that the request
-  #can be sent to LOGPLEX_URL.
-  def flush
-    @hash.each do |tok, msgs|
-      #Copy the messages from the queue into the payload array.
-      payloads = []
-      msgs.size.times {payloads << msgs.deq}
-      return if payloads.nil? || payloads.empty?
-
-      #Use the payloads array to build a string that will be
-      #used as the http body for the logplex request.
-      body = ""
-      payloads.flatten.each do |payload|
-        body += "#{fmt(payload)}"
-      end
-
-      #Build a new HTTP request and place it into the queue
-      #to be processed by the HTTP connection.
-      req = Net::HTTP::Post.new(@logplex_url.path)
-      req.basic_auth("token", tok)
-      req.add_field('Content-Type', 'application/logplex-1')
-      req.body = body
-      @request_queue.enq(req)
-      @hash.delete(tok)
-      @last_flush = Time.now
-    end
-  end
-
-
-  #This method must be called in order for the messages to be sent to Logplex.
-  #This method also spawns a thread that allows the messages to be batched.
-  #Messages are flushed from memory every 500ms or when we have 300 messages,
-  #whichever comes first.
-  def delay_flush
-    loop do
-      begin
-        if interval_ready?
-          @hash_lock.synchronize {flush}
-        end
-        sleep(0.01)
-      rescue => e
-      end
-    end
-  end
-
-  def interval_ready?
-    (Time.now.to_f - @last_flush.to_f).abs >= @flush_interval
-  end
-
-  #Format the user message into RFC5425 format.
-  #This method also prepends the length to the message.
-  def fmt(data)
-    pkt = "<190>1 "
-    pkt += "#{data[:t].strftime("%Y-%m-%dT%H:%M:%S+00:00")} "
-    pkt += "#{@hostname} "
-    pkt += "#{data[:token]} "
-    pkt += "#{@procid} "
-    pkt += "#{@msgid} "
-    pkt += "#{@structured_data} "
-    pkt += data[:msg]
-    "#{pkt.size} #{pkt}"
-  end
-
-  #We use a keep-alive connection to send data to LOGPLEX_URL.
-  #Each request will contain one or more log messages.
-  def outlet
-    loop do
-      http = Net::HTTP.new(@logplex_url.host, @logplex_url.port)
-      http.use_ssl = true if @logplex_url.scheme == 'https'
-      begin
-        http.start do |conn|
-          num_reqs = 0
-          while num_reqs < @max_reqs_per_conn
-            #Blocks waiting for a request.
-            req = @request_queue.deq
-            @req_in_flight += 1
-            resp = nil
-            begin
-              Timeout::timeout(@conn_timeout) {resp = conn.request(req)}
-            rescue => e
-              next
-            ensure
-              @req_in_flight -= 1
-            end
-            num_reqs += 1
-          end
-        end
-      rescue => e
-      ensure
-        http.finish if http.started?
-      end
-    end
-  end
-
-end
diff --git a/vendor/syck_hack.rb b/vendor/syck_hack.rb
deleted file mode 100644
index 454f722c3..000000000
--- a/vendor/syck_hack.rb
+++ /dev/null
@@ -1,64 +0,0 @@
-# :stopdoc:
-
-# Hack to handle syck's DefaultKey bug
-#
-# This file is always loaded AFTER either syck or psych are already
-# loaded. It then looks at what constants are available and creates
-# a consistent view on all rubys.
-#
-# All this is so that there is always a YAML::Syck::DefaultKey
-# class no matter if the full yaml library has loaded or not.
-#
-
-$: << ENV['BUNDLER_LIB_PATH'] if ENV['BUNDLER_LIB_PATH']
-require 'bundler/psyched_yaml'
-
-module YAML
-  # In newer 1.9.2, there is a Syck toplevel constant instead of it
-  # being underneith YAML. If so, reference it back under YAML as
-  # well.
-  if defined? ::Syck
-    Syck = ::Syck
-
-  # Otherwise, if there is no YAML::Syck, then we've got just psych
-  # loaded, so lets define a stub for DefaultKey.
-  elsif !defined? YAML::Syck
-    module Syck
-      class DefaultKey
-      end
-    end
-  end
-
-  # Now that we've got something that is always here, define #to_s
-  # so when code tries to use this, it at least just shows up like it
-  # should.
-  module Syck
-    class DefaultKey
-      def to_s
-        '='
-      end
-    end
-  end
-end
-
-# Sometime in the 1.9 dev cycle, the Syck constant was moved from under YAML
-# to be a toplevel constant. So gemspecs created under these versions of Syck
-# will have references to Syck::DefaultKey.
-#
-# So we need to be sure that we reference Syck at the toplevel too so that
-# we can always load these kind of gemspecs.
-#
-if !defined?(Syck)
-  Syck = YAML::Syck
-end
-
-# Now that we've got Syck setup in all the right places, store
-# a reference to the DefaultKey class inside Gem. We do this so that
-# if later on YAML, etc are redefined, we've still got a consistent
-# place to find the DefaultKey class for comparison.
-
-module Gem
-  SyckDefaultKey = YAML::Syck::DefaultKey
-end
-
-# :startdoc: