Browse files

big rewrite to not depend on Capistrano anymore

  • Loading branch information...
1 parent 925c146 commit 6884ebd9d107c83ca032e521e6bed35779cb0007 @mislav committed Sep 30, 2011
Showing with 211 additions and 178 deletions.
  1. +32 −69 README.markdown
  2. +3 −0 bin/git-deploy
  3. +1 −1 deps.rip
  4. +6 −6 git-deploy.gemspec
  5. +169 −102 lib/git_deploy.rb
View
101 README.markdown
@@ -1,110 +1,73 @@
-Capistrano strategy for smart git deployment
-============================================
+Easy git deployment
+===================
-Let's set up a straightforward, [Heroku][]-style, push-based deployment, shall we? The goal is that our deployment looks like this:
+Straightforward, [Heroku][]-style, push-based deployment. Your deploys will look like this:
- $ git push origin production
+ $ git push production master
-Assumptions are that you are using git for your Rails app and Passenger on the server. For now, we're going to deploy on a single host.
+Assumptions are that you are deploying to a single host. Also, that you have Phusion Passenger on the server running your application.
-To get started, install the "git-deploy" gem from Gemcutter.org.
-
-
-Considerations
---------------
-
-This is not "yet another Capistrano strategy". Capistrano is only used for setup, after which it's all git hooks (see detailed description in "Deployment"). This actually replaces the default Capistrano recipe (which is not loaded) with only a basic set of tasks. If you have more advanced deployment (multiple hosts, many "after deploy" hooks) then this library cannot suit your needs at present.
+To get started, install the "git-deploy" gem.
Setup steps
-----------
-1. Create a git remote for where you'll push the code on your server. The name of this remote in the examples is "origin", but it can be whatever you wish ("online", "website", or other).
+1. Create a git remote for where you'll push the code on your server. The name of this remote in the examples is "production", but it can be whatever you wish ("online", "website", or other).
- $ git remote add origin user@example.com:/path/to/myapp
+ $ git remote add production user@example.com:/path/to/myapp
The "/path/to/myapp" is the directory where your code will reside. It doesn't have to exist; it will be created for you during this setup.
-2. Create/overwrite the following files in your project:
-
- **config/deploy.rb** (entire file):
-
- # set to the name of git remote you intend to deploy to
- set :remote, "origin"
- # specify the deployment branch
- set :branch, "master"
- # sudo will only be used to create the deployment directory
- set :use_sudo, true
- # the remote host is read automatically from your git remote specification
- server remote_host, :app, :web, :db, :primary => true
-
- **Capfile**:
-
- require 'git_deploy'
- load 'config/deploy'
-
- Test it by running `cap -T`. You should see several deploy tasks listed.
-
-3. Run the setup task:
+2. Run the setup task:
- $ cap deploy:setup
+ $ git deploy setup -r production
- This will initialize a git repository in the target directory, install the push hook and push the branch you specified to the server.
+ This will initialize the remote git repository in the target directory ("/path/to/myapp" in the above example), install the remote git hooks and push the master branch to the server.
-4. Login to your server to perform necessary one-time administrative operations. This might include:
+3. Login to your server and manually perform necessary one-time administrative operations. This might include:
* set up the Apache/nginx virtual host for this application;
- * check out the branch which you will push production code into (often this is "production");
- * check your config/database.yml and create or import the production database.
+ * check your "config/database.yml" and create the production database.
Deployment
----------
-After you've set everything up, visiting "http://example.com" in your browser should show your app up and running. Subsequent deployments are done simply by pushing to the branch that is currently checked out on our server (see step 4.). The branch is by default "master", but it's suggested to have production code in another branch like "production" or other. This, of course, depends on your git workflow.
+After you've set everything up, visiting "http://example.com" in your browser should show your app up and running.
-We've reached our goal; our deployment now looks like:
+Now, subsequent deployments are done simply by pushing to the branch that is currently checked out on the remote:
- $ git push origin production
+ $ git push production master
-In fact, running "cap deploy" does exactly this. So what does it do?
+Deployments are logged to "log/deploy.log" in your application.
-The "deploy:setup" task installed a couple of hooks in the remote git repository: "post-receive" and "post-reset". The former is a git hook which is invoked after every push to your server, while the latter is a *custom* hook that's called asynchronously by "post-receive" when we updated the deployment branch. This is how your working copy on the server is kept up-to-date.
-
-Thus, on first push your server automatically:
+On first push your server automatically:
1. creates the "log" and "tmp" directories;
-2. copies "config/database.example.yml" or "config/database.yml.example" to "config/database.yml".
+2. copies "config/database.example.yml" to "config/database.yml".
On every subsequent deploy, the "post-reset" script analyzes changes and:
-1. clears cached css and javascript assets if any versioned files under "public/stylesheets" and "public/javascripts" have changed, respectively;
-2. runs "bundle install --deployment" if Gemfile or Gemfile.lock have been changed
-3. runs "rake db:migrate" if new migrations have been added;
4. sync submodule urls if ".gitmodules" file has changed;
5. initialize and update submodules;
-6. touches "tmp/restart.txt" if app restart is needed.
-
-Finally, these are the conditions that dictate an app restart:
-
-1. css/javascript assets have been cleared;
-2. the database has migrated;
-3. one or more files/submodules under "app", "config", "lib", "public", or "vendor" changed.
+1. clears cached CSS/JS assets if any versioned files under "public/stylesheets" and "public/javascripts" have changed;
+2. runs `bundle install --deployment` if Gemfile or Gemfile.lock have been changed
+3. runs `rake db:migrate` if new migrations have been added;
+6. `touch tmp/restart.txt` if app restart is needed.
-The output of "post-reset" is logged to "log/deploy.log" in your application.
+Finally, these are the conditions that trigger the app restart:
-It's worth remembering that "post-reset" is done asynchronously from your push operation. This is because migrating the database and updating submodules might take a long time and we don't want to wait for all that while we're doing a git push. But, this means that when the push is done, the server has not yet restarted. You might need to wait a few seconds or a minute, depending on what you pushed.
+1. some CSS/JS assets have been cleared;
+2. the database schema has been migrated;
+3. one or more files/submodules under "app", "config", "lib", "public", or "vendor" have changed.
-In the future
--------------
+How it works
+------------
-Next steps for this library are:
+The "setup" task installed a couple of hooks in the remote git repository: "post-receive" and "post-reset". The former is a git hook which is invoked after every push to your server, while the latter is a *custom* hook that's called asynchronously by "post-receive" when we updated the deployment branch. This is how your working copy on the server is kept up-to-date.
-* Support for deployment on multiple hosts. This is a slightly different strategy based on git pull instead of push; something in-between regular "remote cache" strategy and the aforementioned
-* Better configurability
-* Steps forward to supporting more existing 3rd-party Capistrano tasks, like that of the EngineYard gem
-* Support for multiple environments on the same server (production, staging, continuous integration, etc.) sharing the same git repo, so you don't have to push same objects twice
-* Automatic submodule conflict resolving
+It's worth knowing that "post-reset" is done **asynchronously from your push operation**. This is because migrating the database and updating submodules might take a long time and we don't want to wait for all that while we're doing a git push. But, this means that when the push is done, the server has *not yet restarted*. You might need to wait a few seconds or a minute.
-[heroku]: http://heroku.com/
+ [heroku]: http://heroku.com/
View
3 bin/git-deploy
@@ -0,0 +1,3 @@
+#!/usr/bin/env ruby
+require 'git_deploy'
+GitDeploy.start
View
2 deps.rip
@@ -1 +1 @@
-git://github.com/capistrano/capistrano.git 2.5.9
+git://github.com/wycats/thor.git 0.14.6
View
12 git-deploy.gemspec
@@ -1,19 +1,19 @@
+# encoding: utf-8
Gem::Specification.new do |gem|
gem.name = 'git-deploy'
gem.version = '0.4.1'
- gem.date = Date.today.to_s
+ gem.executables = %w[ git-deploy ]
- gem.add_dependency 'capistrano', '~> 2.5.9'
+ gem.add_dependency 'thor'
+ gem.add_dependency 'net-ssh'
+ gem.add_dependency 'net-scp'
gem.summary = "Simple git push-based application deployment"
gem.description = "A tool to install useful git hooks on your remote repository to enable push-based, Heroku-like deployment on your host."
gem.authors = ['Mislav Marohnić']
gem.email = 'mislav.marohnic@gmail.com'
- gem.homepage = 'http://github.com/mislav/git-deploy'
-
- gem.rubyforge_project = nil
- gem.has_rdoc = false
+ gem.homepage = 'https://github.com/mislav/git-deploy'
gem.files = Dir['Rakefile', '{bin,lib,man,test,spec}/**/*', 'README*', 'LICENSE*'] & `git ls-files`.split("\n")
end
View
271 lib/git_deploy.rb
@@ -1,121 +1,188 @@
-require 'capistrano/recipes/deploy/scm/git'
+require 'thor'
+require 'net/ssh'
+require 'net/scp'
-Capistrano::Configuration.instance(true).load do
- def _cset(name, *args, &block)
- unless exists?(name)
- set(name, *args, &block)
+class GitDeploy < Thor
+ LOCAL_DIR = File.expand_path('..', __FILE__)
+
+ class_option :remote, :aliases => '-r', :type => :string, :default => 'origin'
+ class_option :noop, :aliases => '-n', :type => :boolean, :default => false
+
+ desc "setup", "Create the remote git repository, install git hooks, push the code"
+ method_option :shared, :aliases => '-g', :type => :boolean, :default => true
+ method_option :sudo, :aliases => '-s', :type => :boolean, :default => true
+ def setup
+ sudo_cmd = options.sudo? ? 'sudo' : ''
+
+ run ["#{sudo_cmd} mkdir -p #{deploy_to}"] do |cmd|
+ cmd << "#{sudo_cmd} chown $USER #{deploy_to}" if options.sudo?
+ cmd << "chmod g+ws #{deploy_to}" if options.shared?
+ cmd << "cd #{deploy_to}"
+ cmd << "git init #{options.shared? ? '--shared' : ''}"
+ cmd << "sed -i'' -e 's/master/#{branch}/' .git/HEAD" unless branch == 'master'
+ cmd << "git config --bool receive.denyNonFastForwards false" if options.shared?
+ cmd << "git config receive.denyCurrentBranch ignore"
end
+
+ invoke :hooks
+ system 'git', 'push', options[:remote], branch
end
- _cset(:application) { abort "Please specify the name of your application, set :application, 'foo'" }
- _cset :remote, "origin"
- _cset :branch, "master"
-
- _cset(:multiple_hosts) { roles.values.map{ |v| v.servers}.flatten.uniq.size > 1 }
- _cset(:repository) { `#{ source.local.scm('config', "remote.#{remote}.url") }`.chomp }
- _cset(:remote_host) { repository.split(':', 2).first }
- _cset(:deploy_to) { repository.split(':', 2).last }
- _cset(:run_method) { fetch(:use_sudo, true) ? :sudo : :run }
- _cset :group_writeable, false
-
- _cset(:current_branch) { File.read('.git/HEAD').chomp.split(' refs/heads/').last }
- _cset(:revision) { branch }
- _cset(:source) { Capistrano::Deploy::SCM::Git.new(self) }
-
- # If :run_method is :sudo (or :use_sudo is true), this executes the given command
- # via +sudo+. Otherwise is uses +run+. If :as is given as a key, it will be
- # passed as the user to sudo as, if using sudo. If the :as key is not given,
- # it will default to whatever the value of the :admin_runner variable is,
- # which (by default) is unset.
- def try_sudo(*args)
- options = args.last.is_a?(Hash) ? args.pop : {}
- command = args.shift
- raise ArgumentError, "too many arguments" if args.any?
-
- as = options.fetch(:as, fetch(:admin_runner, nil))
-
- if command
- invoke_command(command, :via => run_method, :as => as)
- elsif :sudo == run_method
- sudo(:as => as)
+ desc "hooks", "Installs git hooks to the remote repository"
+ def hooks
+ hooks_dir = File.join(LOCAL_DIR, 'hooks')
+ remote_dir = "#{deploy_to}/.git/hooks"
+
+ scp_upload "#{hooks_dir}/post-receive.rb" => "#{remote_dir}/post-receive",
+ "#{hooks_dir}/post-reset.rb" => "#{remote_dir}/post-reset"
+
+ run "chmod +x #{remote_dir}/post-receive #{remote_dir}/post-reset"
+ end
+
+ desc "restart", "Restarts the application"
+ def restart
+ run "touch #{deploy_to}/tmp/restart.txt"
+ end
+
+ desc "upload <files>", "Copy local files to the remote app"
+ def upload(*files)
+ files = files.map { |f| Dir[f.strip] }.flatten
+ abort "Error: Specify at least one file to upload" if files.empty?
+
+ scp_upload files.inject({}) { |all, file|
+ all[file] = File.join(deploy_to, file)
+ all
+ }
+ end
+
+ private
+
+ def host
+ extract_host_and_user unless defined? @host
+ @host
+ end
+
+ def remote_user
+ extract_host_and_user unless defined? @user
+ @user
+ end
+
+ def extract_host_and_user
+ info = remote_url.split(':').first.split('@')
+ if info.size < 2
+ @user, @host = `whoami`.chomp, info.first
else
- ""
+ @user, @host = *info
end
end
-
- namespace :deploy do
- desc "Deploys your project."
- task :default do
- unless multiple_hosts
- push
+
+ def deploy_to
+ @deploy_to ||= remote_url.split(':').last
+ end
+
+ def branch
+ 'master'
+ end
+
+ def run(cmd = nil)
+ cmd = yield(cmd) if block_given?
+ cmd = cmd.join(' && ') if Array === cmd
+ ssh_exec cmd
+ end
+
+ def system(*args)
+ puts "[local] $ " + args.join(' ').gsub(' && ', " && \\\n ")
+ super unless options.noop?
+ end
+
+ def ssh_exec(cmd)
+ puts "[#{options[:remote]}] $ " + cmd.gsub(' && ', " && \\\n ")
+
+ ssh_connection.exec!(cmd) do |channel, stream, data|
+ case stream
+ when :stdout then $stdout.puts data
+ when :stderr then $stderr.puts data
else
- code
- command = ["cd #{deploy_to}"]
- command << ".git/hooks/post-reset `cat .git/ORIG_HEAD` HEAD 2>&1 | tee -a log/deploy.log"
-
- run command.join(' && ')
+ raise "unknown stream: #{stream.inspect}"
end
+ end unless options.noop?
+ end
+
+ def scp_upload(files)
+ channels = []
+ files.each do |local, remote|
+ puts "FILE: [local] #{local.sub(LOCAL_DIR + '/', '')} -> [#{options[:remote]}] #{remote}"
+ channels << ssh_connection.scp.upload(local, remote) unless options.noop?
end
-
- task :push do
- system source.local.scm('push', remote, "#{revision}:#{branch}")
- end
-
- task :code do
- command = ["cd #{deploy_to}"]
- command << source.scm('fetch', remote, "+refs/heads/#{branch}:refs/remotes/origin/#{branch}")
- command << source.scm('reset', '--hard', "origin/#{branch}")
-
- run command.join(' && ')
+ channels.each { |c| c.wait }
+ end
+
+ def ssh_connection
+ @ssh ||= begin
+ ssh = Net::SSH.start(host, remote_user)
+ at_exit { ssh.close }
+ ssh
end
-
- desc "Prepares servers for deployment."
- task :setup do
- shared = fetch(:group_writeable)
-
- command = ["#{try_sudo} mkdir -p #{deploy_to}"]
- command << "#{try_sudo} chown $USER #{deploy_to}" if :sudo == run_method
- command << "chmod g+ws #{deploy_to}" if shared
- command << "cd #{deploy_to}"
- command << "git init #{shared ? '--shared' : ''}"
- command << "sed -i'' -e 's/master/#{branch}/' .git/HEAD" unless branch == 'master'
- command << "git config --bool receive.denyNonFastForwards false" if shared
- command << "git config receive.denyCurrentBranch ignore"
- run command.join(' && ')
-
- install_hooks
- push
+ end
+
+ def git_config
+ @git_config ||= Hash.new do |cache, cmd|
+ git = ENV['GIT'] || 'git'
+ out = `#{git} #{cmd}`
+ if $?.success? then cache[cmd] = out.chomp
+ else cache[cmd] = nil
+ end
+ cache[cmd]
end
-
- task :install_hooks do
- dir = File.dirname(__FILE__) + '/hooks'
- remote_dir = "#{deploy_to}/.git/hooks"
-
- top.upload "#{dir}/post-receive.rb", "#{remote_dir}/post-receive"
- top.upload "#{dir}/post-reset.rb", "#{remote_dir}/post-reset"
- run "chmod +x #{remote_dir}/post-receive #{remote_dir}/post-reset"
+ end
+
+ def remote_urls(remote)
+ git_config["config --get-all remote.#{remote}.url"].to_s.split("\n")
+ end
+
+ def remote_url(remote = options[:remote])
+ @remote_url ||= {}
+ @remote_url[remote] ||= begin
+ url = remote_urls(remote).first
+ if url.nil?
+ abort "Error: Remote url not found for remote #{remote.inspect}"
+ elsif url =~ /\bgithub\.com\b/
+ abort "Error: Remote url for #{remote.inspect} points to GitHub. Can't deploy there!"
+ end
+ url
end
+ end
- desc "Restarts your Passenger application."
- task :restart, :roles => :app do
- run "touch #{deploy_to}/tmp/restart.txt"
- end
+ def current_branch
+ git_config['symbolic-ref -q HEAD']
+ end
- desc <<-DESC
- Copy files to the currently deployed version. Use a comma-separated \
- list in FILES to specify which files to upload.
+ def tracked_branch
+ branch = current_branch && tracked_for(current_branch)
+ normalize_branch(branch) if branch
+ end
- Note that unversioned files on your server are likely to be \
- overwritten by the next push. Always persist your changes by committing.
+ def normalize_branch(branch)
+ branch.sub('refs/heads/', '')
+ end
- $ cap deploy:upload FILES=templates,controller.rb
- $ cap deploy:upload FILES='config/apache/*.conf'
- DESC
- task :upload do
- files = (ENV["FILES"] || "").split(",").map { |f| Dir[f.strip] }.flatten
- abort "Please specify at least one file or directory to update (via the FILES environment variable)" if files.empty?
+ def remote_for(branch)
+ git_config['config branch.%s.remote' % normalize_branch(branch)]
+ end
- files.each { |file| top.upload(file, File.join(deploy_to, file)) }
- end
+ def tracked_for(branch)
+ git_config['config branch.%s.merge' % normalize_branch(branch)]
end
-end
+end
+
+__END__
+Multiple hosts:
+# deploy:
+ invoke :code
+ command = ["cd #{deploy_to}"]
+ command << ".git/hooks/post-reset `cat .git/ORIG_HEAD` HEAD 2>&1 | tee -a log/deploy.log"
+
+# code:
+command = ["cd #{deploy_to}"]
+command << source.scm('fetch', remote, "+refs/heads/#{branch}:refs/remotes/origin/#{branch}")
+command << source.scm('reset', '--hard', "origin/#{branch}")

0 comments on commit 6884ebd

Please sign in to comment.