git-deploy 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.markdown +109 -0
- data/Rakefile +34 -0
- data/lib/git_deploy.rb +133 -0
- data/lib/hooks/post-receive.rb +48 -0
- data/lib/hooks/post-reset.rb +73 -0
- metadata +68 -0
data/README.markdown
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
Capistrano strategy for smart git deployment
|
2
|
+
============================================
|
3
|
+
|
4
|
+
Let's set up a straightforward, [Heroku][]-style, push-based deployment, shall we? The goal is that our deployment looks like this:
|
5
|
+
|
6
|
+
$ git push origin production
|
7
|
+
|
8
|
+
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.
|
9
|
+
|
10
|
+
To get started, install the "git-deploy" gem from Gemcutter.org.
|
11
|
+
|
12
|
+
|
13
|
+
Considerations
|
14
|
+
--------------
|
15
|
+
|
16
|
+
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.
|
17
|
+
|
18
|
+
|
19
|
+
Setup steps
|
20
|
+
-----------
|
21
|
+
|
22
|
+
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).
|
23
|
+
|
24
|
+
$ git remote add origin user@example.com:/path/to/myapp
|
25
|
+
|
26
|
+
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.
|
27
|
+
|
28
|
+
2. Create/overwrite the following files in your project:
|
29
|
+
|
30
|
+
**config/deploy.rb** (entire file):
|
31
|
+
|
32
|
+
# set to the name of git remote you intend to deploy to
|
33
|
+
set :remote, "origin"
|
34
|
+
# specify the deployment branch
|
35
|
+
set :branch, "master"
|
36
|
+
# sudo will only be used to create the deployment directory
|
37
|
+
set :use_sudo, true
|
38
|
+
# the remote host is read automatically from your git remote specification
|
39
|
+
server remote_host, :app, :web, :db, :primary => true
|
40
|
+
|
41
|
+
**Capfile**:
|
42
|
+
|
43
|
+
require 'git_deploy'
|
44
|
+
load 'config/deploy'
|
45
|
+
|
46
|
+
Test it by running `cap -T`. You should see several deploy tasks listed.
|
47
|
+
|
48
|
+
3. Run the setup task:
|
49
|
+
|
50
|
+
$ cap deploy:setup
|
51
|
+
|
52
|
+
This will initialize a git repository in the target directory, install the push hook and push the branch you specified to the server.
|
53
|
+
|
54
|
+
4. Login to your server to perform necessary one-time administrative operations. This might include:
|
55
|
+
* set up the Apache/nginx virtual host for this application;
|
56
|
+
* check out the branch which you will push production code into (often this is "production");
|
57
|
+
* check your config/database.yml and create or import the production database.
|
58
|
+
|
59
|
+
|
60
|
+
Deployment
|
61
|
+
----------
|
62
|
+
|
63
|
+
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.
|
64
|
+
|
65
|
+
We've reached our goal; our deployment now looks like:
|
66
|
+
|
67
|
+
$ git push origin production
|
68
|
+
|
69
|
+
In fact, running "cap deploy" does exactly this. So what does it do?
|
70
|
+
|
71
|
+
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.
|
72
|
+
|
73
|
+
Thus, on first push your server automatically:
|
74
|
+
|
75
|
+
1. creates the "log" and "tmp" directories;
|
76
|
+
2. copies "config/database.example.yml" or "config/database.yml.example" to "config/database.yml".
|
77
|
+
|
78
|
+
On every subsequent deploy, the "post-reset" script analyzes changes and:
|
79
|
+
|
80
|
+
1. clears cached css and javascript assets if any versioned files under "public/stylesheets" and "public/javascripts" have changed, respectively;
|
81
|
+
2. runs "rake db:migrate" if new migrations have been added;
|
82
|
+
3. sync submodule urls if ".gitmodules" file has changed;
|
83
|
+
4. initialize and update submodules;
|
84
|
+
5. touches "tmp/restart.txt" if app restart is needed.
|
85
|
+
|
86
|
+
Finally, these are the conditions that dictate an app restart:
|
87
|
+
|
88
|
+
1. css/javascript assets have been cleared;
|
89
|
+
2. the database has migrated;
|
90
|
+
3. one or more files/submodules under "app", "config", "lib", "public", or "vendor" changed.
|
91
|
+
|
92
|
+
The output of "post-reset" is logged to "log/deploy.log" in your application.
|
93
|
+
|
94
|
+
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.
|
95
|
+
|
96
|
+
|
97
|
+
In the future
|
98
|
+
-------------
|
99
|
+
|
100
|
+
Next steps for this library are:
|
101
|
+
|
102
|
+
* 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
|
103
|
+
* Better configurability
|
104
|
+
* Steps forward to supporting more existing 3rd-party Capistrano tasks, like that of the EngineYard gem
|
105
|
+
* 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
|
106
|
+
* Automatic submodule conflict resolving
|
107
|
+
|
108
|
+
|
109
|
+
[heroku]: http://heroku.com/
|
data/Rakefile
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
desc "generates .gemspec file"
|
2
|
+
task :gemspec do
|
3
|
+
spec = Gem::Specification.new do |p|
|
4
|
+
p.name = 'git-deploy'
|
5
|
+
p.version = '0.2.0'
|
6
|
+
|
7
|
+
p.summary = "Simple git push-based application deployment"
|
8
|
+
p.description = "git-deploy is a tool to install useful git hooks on your remote repository to enable git push-based, Heroku-like deployment on your host."
|
9
|
+
|
10
|
+
p.author = 'Mislav Marohnić'
|
11
|
+
p.email = 'mislav.marohnic@gmail.com'
|
12
|
+
p.homepage = 'http://github.com/mislav/git-deploy'
|
13
|
+
|
14
|
+
p.add_dependency 'capistrano', '~> 2.5.9'
|
15
|
+
|
16
|
+
p.files = FileList.new('Rakefile', '{bin,lib,sample,test,spec,rails}/**/*', 'README*', 'LICENSE*', 'CHANGELOG*')
|
17
|
+
p.files &= `git ls-files -z`.split("\0")
|
18
|
+
|
19
|
+
p.executables = Dir['bin/*'].map { |f| File.basename(f) }
|
20
|
+
|
21
|
+
p.rubyforge_project = nil
|
22
|
+
p.has_rdoc = false
|
23
|
+
end
|
24
|
+
|
25
|
+
spec_string = spec.to_ruby
|
26
|
+
|
27
|
+
begin
|
28
|
+
Thread.new { eval("$SAFE = 3\n#{spec_string}", binding) }.join
|
29
|
+
rescue
|
30
|
+
abort "unsafe gemspec: #{$!}"
|
31
|
+
else
|
32
|
+
File.open("#{spec.name}.gemspec", 'w') { |file| file.write spec_string }
|
33
|
+
end
|
34
|
+
end
|
data/lib/git_deploy.rb
ADDED
@@ -0,0 +1,133 @@
|
|
1
|
+
require 'capistrano/recipes/deploy/scm/git'
|
2
|
+
require 'capistrano/recipes/deploy/strategy/checkout'
|
3
|
+
|
4
|
+
Capistrano::Configuration.instance(true).load do
|
5
|
+
def _cset(name, *args, &block)
|
6
|
+
unless exists?(name)
|
7
|
+
set(name, *args, &block)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
_cset(:application) { abort "Please specify the name of your application, set :application, 'foo'" }
|
12
|
+
_cset :remote, "origin"
|
13
|
+
_cset :branch, "master"
|
14
|
+
|
15
|
+
_cset(:multiple_hosts) { roles.values.map{ |v| v.servers}.flatten.uniq.size > 1 }
|
16
|
+
_cset(:repository) { `#{ source.local.scm('config', "remote.#{remote}.url") }`.chomp }
|
17
|
+
_cset(:remote_host) { repository.split(':', 2).first }
|
18
|
+
_cset(:deploy_to) { repository.split(':', 2).last } # "/u/apps/#{application}"
|
19
|
+
_cset(:run_method) { fetch(:use_sudo, true) ? :sudo : :run }
|
20
|
+
_cset :group_writeable, false
|
21
|
+
|
22
|
+
_cset(:current_branch) { File.read('.git/HEAD').chomp.split(' refs/heads/').last }
|
23
|
+
_cset(:revision) { branch }
|
24
|
+
_cset(:source) { Capistrano::Deploy::SCM::Git.new(self) }
|
25
|
+
_cset(:strategy) { Capistrano::Deploy::Strategy::Checkout.new(self) }
|
26
|
+
|
27
|
+
# Helper for the `deploy:check' task
|
28
|
+
def depend(location, type, *args)
|
29
|
+
deps = fetch(:dependencies, {})
|
30
|
+
deps[location] ||= {}
|
31
|
+
deps[location][type] ||= []
|
32
|
+
deps[location][type] << args
|
33
|
+
set :dependencies, deps
|
34
|
+
end
|
35
|
+
|
36
|
+
# If :run_method is :sudo (or :use_sudo is true), this executes the given command
|
37
|
+
# via +sudo+. Otherwise is uses +run+. If :as is given as a key, it will be
|
38
|
+
# passed as the user to sudo as, if using sudo. If the :as key is not given,
|
39
|
+
# it will default to whatever the value of the :admin_runner variable is,
|
40
|
+
# which (by default) is unset.
|
41
|
+
def try_sudo(*args)
|
42
|
+
options = args.last.is_a?(Hash) ? args.pop : {}
|
43
|
+
command = args.shift
|
44
|
+
raise ArgumentError, "too many arguments" if args.any?
|
45
|
+
|
46
|
+
as = options.fetch(:as, fetch(:admin_runner, nil))
|
47
|
+
via = fetch(:run_method, :sudo)
|
48
|
+
if command
|
49
|
+
invoke_command(command, :via => via, :as => as)
|
50
|
+
elsif via == :sudo
|
51
|
+
sudo(:as => as)
|
52
|
+
else
|
53
|
+
""
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
namespace :deploy do
|
58
|
+
desc "Deploys your project."
|
59
|
+
task :default do
|
60
|
+
push
|
61
|
+
end
|
62
|
+
|
63
|
+
task :push do
|
64
|
+
system source.local.scm('push', remote, "#{revision}:#{branch}")
|
65
|
+
end
|
66
|
+
|
67
|
+
desc "Prepares servers for deployment."
|
68
|
+
task :setup do
|
69
|
+
command = ["#{try_sudo} mkdir -p #{deploy_to}"]
|
70
|
+
command << "#{try_sudo} chown $USER #{deploy_to}" if fetch(:run_method, :sudo) == :sudo
|
71
|
+
command << "cd #{deploy_to}"
|
72
|
+
command << "chmod g+w ."
|
73
|
+
command << "git init #{fetch(:group_writeable) ? '--shared' : ''}"
|
74
|
+
command << "sed -i'' -e 's/master/#{branch}/' .git/HEAD" unless branch == 'master'
|
75
|
+
command << "git config --bool receive.denyNonFastForwards false" if fetch(:group_writeable)
|
76
|
+
command << "git config receive.denyCurrentBranch ignore"
|
77
|
+
run command.join(' && ')
|
78
|
+
|
79
|
+
install_hooks
|
80
|
+
push
|
81
|
+
end
|
82
|
+
|
83
|
+
task :install_hooks do
|
84
|
+
dir = File.dirname(__FILE__) + '/hooks'
|
85
|
+
remote_dir = "#{deploy_to}/.git/hooks"
|
86
|
+
|
87
|
+
top.upload "#{dir}/post-receive.rb", "#{remote_dir}/post-receive"
|
88
|
+
top.upload "#{dir}/post-reset.rb", "#{remote_dir}/post-reset"
|
89
|
+
run "chmod +x #{remote_dir}/post-receive #{remote_dir}/post-reset"
|
90
|
+
end
|
91
|
+
|
92
|
+
desc "Restarts your Passenger application."
|
93
|
+
task :restart, :roles => :app do
|
94
|
+
run "touch #{deploy_to}/tmp/restart.txt"
|
95
|
+
end
|
96
|
+
|
97
|
+
desc <<-DESC
|
98
|
+
Test deployment dependencies.
|
99
|
+
|
100
|
+
You can define your own dependencies, as well, using the `depend' method:
|
101
|
+
|
102
|
+
depend :remote, :gem, "tzinfo", ">=0.3.3"
|
103
|
+
depend :local, :command, "svn"
|
104
|
+
depend :remote, :directory, "/u/depot/files"
|
105
|
+
DESC
|
106
|
+
task :check do
|
107
|
+
dependencies = strategy.check!
|
108
|
+
|
109
|
+
other = fetch(:dependencies, {})
|
110
|
+
other.each do |location, types|
|
111
|
+
types.each do |type, calls|
|
112
|
+
if type == :gem
|
113
|
+
dependencies.send(location).command(fetch(:gem_command, "gem")).or("`gem' command could not be found. Try setting :gem_command")
|
114
|
+
end
|
115
|
+
|
116
|
+
calls.each do |args|
|
117
|
+
dependencies.send(location).send(type, *args)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
if dependencies.pass?
|
123
|
+
puts "You appear to have all necessary dependencies installed"
|
124
|
+
else
|
125
|
+
puts "The following dependencies failed. Please check them and try again:"
|
126
|
+
dependencies.reject { |d| d.pass? }.each do |d|
|
127
|
+
puts "--> #{d.message}"
|
128
|
+
end
|
129
|
+
abort
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
#!/usr/bin/ruby
|
2
|
+
if ENV['GIT_DIR'] == '.'
|
3
|
+
# this means the script has been called as a hook, not manually.
|
4
|
+
# get the proper GIT_DIR so we can descend into the working copy dir;
|
5
|
+
# if we don't then `git reset --hard` doesn't affect the working tree.
|
6
|
+
Dir.chdir('..')
|
7
|
+
ENV['GIT_DIR'] = '.git'
|
8
|
+
end
|
9
|
+
|
10
|
+
# find out the current branch
|
11
|
+
head = File.read('.git/HEAD').chomp
|
12
|
+
# abort if we're on a detached head
|
13
|
+
exit unless head.sub!('ref: ', '')
|
14
|
+
|
15
|
+
oldrev = newrev = nil
|
16
|
+
null_ref = '0' * 40
|
17
|
+
|
18
|
+
# read the STDIN to detect if this push changed the current branch
|
19
|
+
while newrev.nil? and gets
|
20
|
+
# each line of input is in form of "<oldrev> <newrev> <refname>"
|
21
|
+
revs = $_.split
|
22
|
+
oldrev, newrev = revs if head == revs.pop
|
23
|
+
end
|
24
|
+
|
25
|
+
# abort if there's no update, or in case the branch is deleted
|
26
|
+
exit if newrev.nil? or newrev == null_ref
|
27
|
+
|
28
|
+
# update the working copy
|
29
|
+
`git reset --hard`
|
30
|
+
|
31
|
+
if oldrev == null_ref
|
32
|
+
# this is the first push; this branch was just created
|
33
|
+
require 'fileutils'
|
34
|
+
FileUtils.mkdir_p %w(log tmp)
|
35
|
+
config = 'config/database.yml'
|
36
|
+
|
37
|
+
unless File.exists?(config)
|
38
|
+
# install the database config from the example file
|
39
|
+
example = ['config/database.example.yml', config + '.example'].find { |f| File.exists? f }
|
40
|
+
FileUtils.cp example, config if example
|
41
|
+
end
|
42
|
+
else
|
43
|
+
logfile = 'log/deploy.log'
|
44
|
+
# log timestamp
|
45
|
+
File.open(logfile, 'a') { |log| log.puts "==== #{Time.now} ====" }
|
46
|
+
# start the post-reset hook in background
|
47
|
+
system %(nohup .git/hooks/post-reset #{oldrev} #{newrev} 1>>#{logfile} 2>>#{logfile} &)
|
48
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
#!/usr/bin/ruby
|
2
|
+
RAILS_ENV = 'production'
|
3
|
+
oldrev, newrev = ARGV
|
4
|
+
$stdout.sync = true
|
5
|
+
|
6
|
+
# get a list of files that changed
|
7
|
+
changes = `git diff #{oldrev} #{newrev} --diff-filter=ACDMR --name-status`.split("\n")
|
8
|
+
|
9
|
+
# make a hash of files that changed and how they changed
|
10
|
+
changes_hash = changes.inject(Hash.new { |h, k| h[k] = [] }) do |hash, line|
|
11
|
+
modifier, filename = line.split("\t", 2)
|
12
|
+
hash[modifier] << filename
|
13
|
+
hash
|
14
|
+
end
|
15
|
+
|
16
|
+
# create an array of files added, copied, modified or renamed
|
17
|
+
modified_files = %w(A C M R).inject([]) { |files, bit| files.concat changes_hash[bit] }
|
18
|
+
added_files = changes_hash['A'] # added
|
19
|
+
deleted_files = changes_hash['D'] # deleted
|
20
|
+
changed_files = modified_files + deleted_files # all
|
21
|
+
puts "files changed: #{changed_files.size}"
|
22
|
+
|
23
|
+
class Array
|
24
|
+
# scans the list of files to see if any of them are under the given path
|
25
|
+
def any_in_dir?(dir)
|
26
|
+
if Array === dir
|
27
|
+
exp = %r{^(?:#{dir.join('|')})/}
|
28
|
+
any? { |file| file =~ exp }
|
29
|
+
else
|
30
|
+
dir += '/'
|
31
|
+
any? { |file| file.index(dir) == 0 }
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
cached_assets_cleared = false
|
37
|
+
|
38
|
+
# detect modified asset dirs
|
39
|
+
asset_dirs = %w(public/stylesheets public/javascripts).select do |dir|
|
40
|
+
# did any on the assets under this dir change?
|
41
|
+
changed_files.any_in_dir?(dir)
|
42
|
+
end
|
43
|
+
|
44
|
+
unless asset_dirs.empty?
|
45
|
+
# clear cached assets (unversioned/ignored files)
|
46
|
+
deleted_assets = `git ls-files -z --other -- #{asset_dirs.join(' ')} | xargs -0 rm -v`.split("\n")
|
47
|
+
unless deleted_assets.empty?
|
48
|
+
puts "cleared: #{deleted_assets.join(', ')}"
|
49
|
+
cached_assets_cleared = true
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# run migrations when new ones added
|
54
|
+
if new_migrations = added_files.any_in_dir?('db/migrate')
|
55
|
+
system %(rake db:migrate RAILS_ENV=#{RAILS_ENV})
|
56
|
+
end
|
57
|
+
|
58
|
+
if modified_files.include?('.gitmodules')
|
59
|
+
# sync submodule remote urls in case of changes
|
60
|
+
system %(git submodule sync)
|
61
|
+
# initialize new submodules
|
62
|
+
system %(git submodule init)
|
63
|
+
end
|
64
|
+
# update existing submodules
|
65
|
+
system %(git submodule update)
|
66
|
+
|
67
|
+
# determine if app restart is needed
|
68
|
+
if cached_assets_cleared or new_migrations or changed_files.any_in_dir?(%w(app config lib public vendor))
|
69
|
+
require 'fileutils'
|
70
|
+
# tell Passenger to restart this app
|
71
|
+
FileUtils.touch 'tmp/restart.txt'
|
72
|
+
puts "restarting Passenger app"
|
73
|
+
end
|
metadata
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: git-deploy
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.2.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- "Mislav Marohni\xC4\x87"
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-10-04 00:00:00 +02:00
|
13
|
+
default_executable:
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: capistrano
|
17
|
+
type: :runtime
|
18
|
+
version_requirement:
|
19
|
+
version_requirements: !ruby/object:Gem::Requirement
|
20
|
+
requirements:
|
21
|
+
- - ~>
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: 2.5.9
|
24
|
+
version:
|
25
|
+
description: git-deploy is a tool to install useful git hooks on your remote repository to enable git push-based, Heroku-like deployment on your host.
|
26
|
+
email: mislav.marohnic@gmail.com
|
27
|
+
executables: []
|
28
|
+
|
29
|
+
extensions: []
|
30
|
+
|
31
|
+
extra_rdoc_files: []
|
32
|
+
|
33
|
+
files:
|
34
|
+
- Rakefile
|
35
|
+
- lib/git_deploy.rb
|
36
|
+
- lib/hooks/post-receive.rb
|
37
|
+
- lib/hooks/post-reset.rb
|
38
|
+
- README.markdown
|
39
|
+
has_rdoc: true
|
40
|
+
homepage: http://github.com/mislav/git-deploy
|
41
|
+
licenses: []
|
42
|
+
|
43
|
+
post_install_message:
|
44
|
+
rdoc_options: []
|
45
|
+
|
46
|
+
require_paths:
|
47
|
+
- lib
|
48
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
49
|
+
requirements:
|
50
|
+
- - ">="
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
version: "0"
|
53
|
+
version:
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
55
|
+
requirements:
|
56
|
+
- - ">="
|
57
|
+
- !ruby/object:Gem::Version
|
58
|
+
version: "0"
|
59
|
+
version:
|
60
|
+
requirements: []
|
61
|
+
|
62
|
+
rubyforge_project:
|
63
|
+
rubygems_version: 1.3.5
|
64
|
+
signing_key:
|
65
|
+
specification_version: 3
|
66
|
+
summary: Simple git push-based application deployment
|
67
|
+
test_files: []
|
68
|
+
|