Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save stevenharman/98576bf49b050b9e59fb26626b7cceff to your computer and use it in GitHub Desktop.
Save stevenharman/98576bf49b050b9e59fb26626b7cceff to your computer and use it in GitHub Desktop.
Heroku Release Phase script for managing Rails DB migrations, and playing nice with Review Apps and postdeploy scripts

Heroku Release Phase + Review Apps + Rails

This is a simplified, but fairly thorough, set of scripts and configuration to enable Heroku Release Phase for Rails apps. Further, this particular set up plays nicely with Heroku Review Apps in that the release phase script will:

  1. Fail, loudly, if the DB does not yet exist.
  2. Load the DB schema if the current schema version (as determined by bin/rails db:version) is 0.
  3. Run DB migrations otherwise.

For a "normal" app that usually means it will run the DB migrations. For a Review App, on the first deploy the release phase will bin/rails db:schema:load. And then the postdeploy script will seed data. During subsequent deploys to the Review App, the release phase will bin/rails db:migrate.

{
"name": "your-app-name",
"description": "Configuration for per-Pull-Request Reviews Apps on Heroku.",
"scripts": {
"postdeploy": "LOG_LEVEL=INFO bin/rake review_app:seed"
},
"stack": "heroku-16",
"formation": [
{ "process": "web", "quantity": 1 },
{ "process": "worker", "quantity": 1 }
],
"addons": [
"heroku-postgresql:hobby-dev"
],
"buildpacks": [
{
"url": "heroku/ruby"
}
],
"env": {
"Lots of other Env Vars...": "as you need",
"HEROKU_APP_NAME": {
"required": true
},
"HEROKU_PARENT_APP_NAME": {
"required": true
}
}
}
#!/usr/bin/env bash
#
# Usage: bin/heroku_deploy
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NO_COLOR='\033[0m'
set -euo pipefail
schema_version=$(bin/rails db:version | { grep "^Current version: [0-9]\\+$" || true; } | tr -s ' ' | cut -d ' ' -f3)
if [ -z "$schema_version" ]; then
printf "💀${RED} [Release Phase]: Database schema version could not be determined. Does the database exist?${NO_COLOR}\n"
exit 1
fi
if [ "$schema_version" -eq "0" ]; then
printf "\n⏳${YELLOW} [Release Phase]: Loading the database schema.${NO_COLOR}\n"
bin/rails db:schema:load
else
printf "\n⏳${YELLOW} [Release Phase]: Running database migrations.${NO_COLOR}\n"
bin/rails db:migrate
fi
printf "\n🎉${GREEN} [Release Phase]: Database is up to date.${NO_COLOR}\n"
web: bundle exec puma -C ./config/puma.rb
worker: DB_POOL_SIZE=${WORKER_CONCURRENCY:-20} bundle exec sidekiq -c ${WORKER_CONCURRENCY:-20} -t ${WORKER_TIMEOUT:-25} -q default,1 -q mailers,1
# === Heroku Release Phase, ignored by `heroku local`. See: https://devcenter.heroku.com/articles/release-phase
release: bin/heroku_release
# frozen_string_literal: true
namespace :review_app do
desc 'Ensure environment is one we shish to spread seed in'
task :ensure_review_app do
abort 'This is not a Heroku Review App' unless review_app?
end
desc 'Seeds a review app with a subset of realistic-looking data'
task :seed, [] => %w[
ensure_review_app
environment
db:seed
seed:administrator
seed:widgets
] do
Rails.logger.tagged('Seed App') { |l| l.info("Finished seeding new Review App: #{ENV['HEROKU_APP_NAME']}") }
end
def review_app?
!!ENV['HEROKU_PARENT_APP_NAME']
end
end
@tbonz
Copy link

tbonz commented Apr 24, 2020

Since the new Dynos aren't started until after Release Phase has run, they don't need to be restarted.

Thank you @stevenharman ! That's what I was hoping happened during the release phase.

@pebneter
Copy link

@stevenharman yes, fully agreed. It happens in my case due to the special setup I have.

@eri-b
Copy link

eri-b commented May 29, 2020

Hi @stevenharman this is really helpful, thanks! Wondering if there's a reason you don't seed the database immediately after the schema load in heroku_release.sh?

@stevenharman
Copy link
Author

👋 @eri-b. Thanks for the kind words!

Seeding the DB in so far as Heroku deploys are concerned is only done for Review Apps. And to handle that, we leverage the app.json's postdeploy script:

 "scripts": {
    "postdeploy": "LOG_LEVEL=INFO bin/rake review_app:seed"
  },

For a Review App, on the first deploy the release phase will bin/rails db:schema:load. And then the postdeploy script will seed data. During subsequent deploys to the Review App, the release phase will bin/rails db:migrate.

This ensures the seeding only happens once, only happens for Review Apps, and is consistent with how you'd seed a Review App DB without a Release Phase setup. Hope that helps. Please let me know if you have more questions.

@eri-b
Copy link

eri-b commented May 29, 2020

@stevenharman I see, thanks!

This part helps clarify:

and is consistent with how you'd seed a Review App DB without a Release Phase setup

because it seemed that once you went through the trouble of ensuring the schema:load happens only once in the release phase (by checking the schema_version), why not seed the database at the same time (if it's a Review App)? Seems like packing them together helps readability a bit, but as you mention, this relies on a Release Phase setup.

I was thinking maybe it also made sense to keep the seeding out of the Release Phase, because a potential problem during seeding would cause the app creation to fail.

@stevenharman
Copy link
Author

keep the seeding out of the Release Phase, because a potential problem during seeding would cause the app creation to fail.

That's an excellent point, and reason enough to keep schema loading and data seeding in the Release Phase and post-deploy, respectively. Thank you!

@toobulkeh
Copy link

I wish Heroku would put the Release phase into app.json's scripts instead of Procfile. Feels dirty to show release phase in Procfile.

@stevenharman
Copy link
Author

@toobulkeh I can understand that. Though since the release phase command runs in a one-off Dynoe, I also understand why it's in the Procfile.

@jcohenho
Copy link

jcohenho commented Feb 10, 2022

@stevenharman is bin-heroku_release.sh saved in the bin/ directory or the root folder? Shouldn't it be named heroku_release.sh since the command in the Procfile is bin/heroku_release? I'm having trouble getting my review apps to correctly run the release phase and execute this file.

EDIT: I was finally able to get this to work. The filename has to match what is called in the Procfile release command.
i.e. file name and path: bin/heroku_release.sh, Procfile: release: bin/heroku_release.sh

The above config has the file named as bin-heroku_release.sh and then called with release: bin/heroku_release in the Procfile, this should probably be updated to reflect the correct naming. Thanks for all of this config, it's 💯!

@stevenharman
Copy link
Author

@jcohenho Yes, sorry for the confusion. Gists only allow a flat file structure, so no directories. The actual file layout I use is:

./                            # repo root
./app.json
./Procfile
./bin/heroku_release          # be sure to `chmod +x` this file so its executable
./lib/tasks/review_app.rake

@supairish
Copy link

@stevenharman thanks for this gist

One issue i'm having though is in bin/heroku_release when run Heroku errors with

bin/heroku_release: 11: set: Illegal option -o pipefail

Just me?

@stevenharman
Copy link
Author

@supairish

bin/heroku_release: 11: set: Illegal option -o pipefail

That's certainly a new one! Is your shebang line using bash? Like so: #!/usr/bin/env bash

@supairish
Copy link

@stevenharman yep its in there. I've opened a support ticket with Heroku, I'll post what they say and see if that flag isn't allowed anymore or something...

@supairish
Copy link

Just to circle back on my above problem... turned out I had a newline before the Shebang line and that was causing my issues... doh!

@thefotios
Copy link

Is the shell script even necessary? Now app.json calls rake heroku:review_app:postdeploy and the Procfile calls rake heroku:release.

Personally, I think this has a few benefits

  • Everything is in a single place making it easier to understand the order of operations
  • It happens in a single rake call, cutting down on time spent calling it multiple disparate times
  • It can be easily extended (For instance, we use the multi-procfile buildpack to separate review, staging, and production. They could each call their own heroku:<ENV>:release)
namespace :heroku do
  task ensure_schema: %w[environment] do
    # This determines if there's been a migration or not
    #   - `current_version` fails the same way `db:version` does if there's something seriously wrong
    #   - 0 means a newly created DB
    #   - Anything else means there have been migrations run
    Rake::Task['db:schema:load'].invoke if ActiveRecord::Base.connection.migration_context.current_version.zero?
  end
  task release: %w[environment ensure_schema db:migrate]

  namespace :review_app do
    task ensure_review_app: :environment do
      abort 'This must only be run in a review app' unless review_app?
    end

    ##
    # We're guaranteed that the schema has been loaded/migrated at this point since 'postdeploy' runs after 'release'
    # This is only run the first time an app is deployed
    #
    # For more information on the release process, see https://devcenter.heroku.com/articles/github-integration-review-apps#the-postdeploy-script
    desc 'This is run after the initial deploy (from app.json)'
    task postdeploy: %w[ensure_review_app db:seed]
  end

  def review_app?
    !!ENV['HEROKU_PARENT_APP_NAME']
  end
end

@thefotios
Copy link

Also, for those asking about running heroku restart. I'm not sure that's strictly necessary. I did run into problems when running tasks that relied on the new DB schema (eg, %w[db:migrate db:seed]). There's no problem if you run them as discrete rake commands, since the application code is reloaded, but in the same task, you'll need something like

  ##
  # This needs to be run after db:migrate if you plan on running any other tasks in the same rake process
  #  It will force AR to re-read the columns from the DB; otherwise you'll get errors trying to access new attributes
  task reset_column_information: :environment do
    ActiveRecord::Base.descendants.each(&:reset_column_information)
  end

@pebneter
Copy link

Interesting comment, @thefotios .
It's been a while since I have setup the bash script. Would it be worth migrating?

@softwaregravy
Copy link

@thefotios A side comment. In your review_app? method, I don't have a HEROKU_PARENT_APP_NAME available in my env. Maybe this is an older stack? However, I do something very similar, I just use the presence of HEROKU_PR_NUMBER.

if ENV.has_key? "HEROKU_PR_NUMBER"

@thefotios
Copy link

@pebneter It's up to you; they're functionally equivalent for this use case so it really all comes down to your use case and preference. We have a few more complicated things that run during our release and found it much easier to manage that in the Rakefile based approach for a few reasons

  1. Everything is in Ruby already, so it just felt more natural
  2. It was much easier to also tie it into our local development workflow (eg, something like rake local:setup can share the same steps)
  3. It was more easily testable

@softwaregravy Thanks for pointing that out. We don't actually use that logic, I was just trying to keep it consistent with the original gist. But I think you're right that some of that changed between the "old" and "new" review apps.

@stevenharman
Copy link
Author

@softwaregravy The HEROKU_PARENT_APP_NAME Config Var is defined as required in the app.json, meaning it inherited it from the parent app. So in the parent app (e.g., we always used our staging as the parent for Review Apps, so we'd set that Config Var there). As @thefotios said, all of this was build off of Review Apps v1 and I'm sure some things have changed in v2, but I've not updated this to reflect that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment