Our Git Deployment Workflow
Like most Rails developers, we’re using git for source control. However, our first attempt at setting up a deployment system made a huge mess so we went looking for a better way.
I liked the ideas set forth by Bryan Helmkamp, but there was one flaw (mentioned by Jesse Andrews in the comments) I just couldn’t get past. It seemed entirely too easy for Kevin or I to erase working commits the other had made in production code.
I took Bryan’s rake task and built upon it to help prevent this sort of issue. Here’s the goals:
- All code deployed to either the staging or production environments must be in GitHub. Using Capistrano for deployment helps enforce this, in that no code from a local repository can ever get deployed to the server.
- All changes start by branching off the current production head. This ensures we always have a stable place to start working, regardless of whether its for a quick bugfix or a month-long feature update.
- Any developer can deploy their current development branch to staging at any time. Remember, their development branch is based off of something that was a stable production release at one time. However, updates may have been made to production since then that are not included in their development branch. We consider this to be okay.
- Any developer can merge their current development branch back into production. However, their merge must be a direct fast-forward (which guarantees no conflicts). If changes have been committed to production since their branch was created, they must first rebase their branch to start from the current production head.
- All code merged into production is ready to be deployed at any time. Sounds obvious, but our first strategy failed on this because commits made to staging weren’t always ready to go to production in that order.
- Lastly, all this needs to be automatic, safe, and simple.
In our solution, we treat production as a real branch and merge code into it. This prevents commits from being lost by rewriting history. However, we borrowed the idea of creating staging as a branch, but treating it like a tag. I like this because it lets any developer tear down staging to use themselves at any time. By not making commits to staging, I never have to worry about my buggy code affecting someone else’s testing. (Or more realistically, someone else’s buggy code screwing up my testing.)
So, without further ado, here’s the rake tasks:
class GitCommands
# Shows a diff of the current production and staging branches
# (ie, what would change if staging was deployed to production)
def self.diff_staging
`git fetch`
puts `git diff origin/production origin/staging`
end
# Pushes the specified branch to the remote origin, then
# resets the staging branch to be a copy of that branch.
def self.push_staging(branch_name)
verify_working_directory_clean
`git push origin #{branch_name}`
`git fetch`
`git branch -f staging origin/staging`
`git checkout staging`
`git reset --hard origin/#{branch_name}`
`git push -f origin staging`
`git checkout #{branch_name}`
`git branch -D staging`
`cap deploy`
unless is_fast_forward_of_production(branch_name)
puts("nnWARNING: NON-FAST-FORWARD!n"+
"The production branch has been updated since this branchn"+
"was created (or you've made more than 100 commits). Stagingn"+
"has been updated, but you'll need to run the followingn"+
"before attempting to push to production.nn"+
" git rebase -i origin/productionn"+
" git push -f origin #{branch_name}nn"+
"You may want to do this now before testing on staging.nn"
)
end
end
# Pushes the specified branch to the remote origin, then
# merges its changes into the production branch.
def self.push_production(branch_name)
verify_working_directory_clean
`git push origin #{branch_name}`
`git fetch`
verify_fast_forward_of_production(branch_name)
# Update master to be the previous production
`git checkout master`
`git reset --hard origin/production`
`git push origin master`
# Merge the current branch into production
`git branch -f production origin/production`
`git checkout production`
`git merge #{branch_name}`
`git push origin production`
`git checkout #{branch_name}`
`git branch -D production`
puts("nnYour branch has been merged into production, but hasn"+
"NOT YET been deployed. Call the appropriate capistranon"+
"tasks to deploy code, migrate the database, then restartn"+
"passenger to make your changes live.nn"+
" cap production deploy:updaten"+
" cap production deploy:migraten"+
" cap production deploy:restartnn"
)
end
# Creates a new branch using the current production branch
# as a base.
def self.create_branch(branch_name)
verify_working_directory_clean
`git fetch`
`git branch -f production origin/production`
`git checkout production`
`git branch #{branch_name}`
`git checkout #{branch_name}`
`git push origin #{branch_name}`
end
# Creates a new local branch tracking a remote branch
def self.track_branch(branch_name)
verify_working_directory_clean
`git fetch`
`git branch -f #{branch_name} origin/#{branch_name}`
`git checkout #{branch_name}`
end
# Creates a new branch using the current production branch
# as a base.
def self.destroy_branch(branch_name)
verify_working_directory_clean
`git fetch`
`git checkout master`
`git branch -D #{branch_name}`
`git push origin :#{branch_name}`
end
# Determines the name of the current working branch.
def self.get_branch_name
branch = `git status`.scan(/A# On branch (.+)$/)
raise "Could not determine which branch to use. Perhaps use BRANCH=?" if branch.blank? || branch.first.blank? || branch.first.first.blank?
return branch.first.first
end
protected
# Ensures that there are no pending changes in the working directory.
# This was edited to allow untracked (and unignored) files, but that
# may need to be removed for 100% functionality.
def self.verify_working_directory_clean
output = `git status`
return if output =~ /working directory clean/ || output =~ /nothing added to commit but untracked files present/
raise "Must have clean working directory"
end
# Verifies that the specified branch is a direct fast-forward of
# the production branch. This ensures that merges will be smooth
# and conflict-free when running the git:push:production task.
def self.verify_fast_forward_of_production(branch_name)
unless is_fast_forward_of_production(branch_name)
raise(
"Branch #{branch_name} is not a fast-forward of the production branchn"+
"(or there's more than 100 commits since the branch).n"+
"Run: "git rebase -i origin/production" and resolve all conflicts,n"+
"then: "git push -f origin #{branch_name}" to fix this."
)
end
return
end
# Determines if the specified branch is a direct fast-forward of
# the production branch.
def self.is_fast_forward_of_production(branch_name)
production_last_hex = `git log -1 origin/production`.scan(/Acommit ([da-f]{40})$/).first.first
branch_log = `git log -100 --pretty=oneline #{branch_name}`.scan(/^[da-f]{40}/)
return branch_log.include?(production_last_hex)
end
end
namespace :git do
namespace :push do
desc "Push the current branch to origin/staging for testing"
task :staging do
branch_name = ENV['BRANCH'] || GitCommands.get_branch_name
GitCommands.push_staging(branch_name)
end
desc "Safely merge the current branch back into origin/production"
task :production do # => ['diff:staging'] do
branch_name = ENV['BRANCH'] || GitCommands.get_branch_name
GitCommands.push_production(branch_name)
end
end
desc "Show the differences between the origin/staging branch and the origin/production branch"
task :diff do
GitCommands.diff_staging
end
namespace :branch do
desc "Create a branch for a feature or bug fix. Specify BRANCH=name"
task :create do
branch_name = ENV['BRANCH']
raise "You must specify a branch name using BRANCH=name" if branch_name.blank?
GitCommands.create_branch(branch_name)
end
desc "Creates a local branch tracking an already-created remote branch. Specify BRANCH=name"
task :track do
branch_name = ENV['BRANCH']
raise "You must specify a branch name using BRANCH=name" if branch_name.blank?
GitCommands.track_branch(branch_name)
end
desc "Removes a remote branch from the origin. Specify BRANCH=name"
task :destroy do
branch_name = ENV['BRANCH']
raise "You must specify a branch name using BRANCH=name" if branch_name.blank?
GitCommands.destroy_branch(branch_name)
end
end
end
We’ve been using this for the past several weeks and it’s been working great. So, did we do any better? Anyone see any flaws or have any other ideas?

