fletcherm-git-deploy 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2009 Mislav Marohnić
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7
+ the Software, and to permit persons to whom the Software is furnished to do so,
8
+ subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.markdown ADDED
@@ -0,0 +1,111 @@
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
+ # specify the RAILS_ENV used by db:migrate. defaults to production
37
+ set :environment, "staging"
38
+ # sudo will only be used to create the deployment directory
39
+ set :use_sudo, true
40
+ # the remote host is read automatically from your git remote specification
41
+ server remote_host, :app, :web, :db, :primary => true
42
+
43
+ **Capfile**:
44
+
45
+ require 'git_deploy'
46
+ load 'config/deploy'
47
+
48
+ Test it by running `cap -T`. You should see several deploy tasks listed.
49
+
50
+ 3. Run the setup task:
51
+
52
+ $ cap deploy:setup
53
+
54
+ This will initialize a git repository in the target directory, install the push hook and push the branch you specified to the server.
55
+
56
+ 4. Login to your server to perform necessary one-time administrative operations. This might include:
57
+ * set up the Apache/nginx virtual host for this application;
58
+ * check out the branch which you will push production code into (often this is "production");
59
+ * check your config/database.yml and create or import the production database.
60
+
61
+
62
+ Deployment
63
+ ----------
64
+
65
+ 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.
66
+
67
+ We've reached our goal; our deployment now looks like:
68
+
69
+ $ git push origin production
70
+
71
+ In fact, running "cap deploy" does exactly this. So what does it do?
72
+
73
+ 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.
74
+
75
+ Thus, on first push your server automatically:
76
+
77
+ 1. creates the "log" and "tmp" directories;
78
+ 2. copies "config/database.example.yml" or "config/database.yml.example" to "config/database.yml".
79
+
80
+ On every subsequent deploy, the "post-reset" script analyzes changes and:
81
+
82
+ 1. clears cached css and javascript assets if any versioned files under "public/stylesheets" and "public/javascripts" have changed, respectively;
83
+ 2. runs "rake db:migrate" if new migrations have been added;
84
+ 3. sync submodule urls if ".gitmodules" file has changed;
85
+ 4. initialize and update submodules;
86
+ 5. touches "tmp/restart.txt" if app restart is needed.
87
+
88
+ Finally, these are the conditions that dictate an app restart:
89
+
90
+ 1. css/javascript assets have been cleared;
91
+ 2. the database has migrated;
92
+ 3. one or more files/submodules under "app", "config", "lib", "public", or "vendor" changed.
93
+
94
+ The output of "post-reset" is logged to "log/deploy.log" in your application.
95
+
96
+ 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.
97
+
98
+
99
+ In the future
100
+ -------------
101
+
102
+ Next steps for this library are:
103
+
104
+ * 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
105
+ * Better configurability
106
+ * Steps forward to supporting more existing 3rd-party Capistrano tasks, like that of the EngineYard gem
107
+ * 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
108
+ * Automatic submodule conflict resolving
109
+
110
+
111
+ [heroku]: http://heroku.com/
data/lib/git_deploy.rb ADDED
@@ -0,0 +1,126 @@
1
+ require 'capistrano/recipes/deploy/scm/git'
2
+
3
+ Capistrano::Configuration.instance(true).load do
4
+ def _cset(name, *args, &block)
5
+ unless exists?(name)
6
+ set(name, *args, &block)
7
+ end
8
+ end
9
+
10
+ _cset(:application) { abort "Please specify the name of your application, set :application, 'foo'" }
11
+ _cset :remote, "origin"
12
+ _cset :branch, "master"
13
+ _cset :environment, "production"
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 }
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
+
26
+
27
+ # If :run_method is :sudo (or :use_sudo is true), this executes the given command
28
+ # via +sudo+. Otherwise is uses +run+. If :as is given as a key, it will be
29
+ # passed as the user to sudo as, if using sudo. If the :as key is not given,
30
+ # it will default to whatever the value of the :admin_runner variable is,
31
+ # which (by default) is unset.
32
+ def try_sudo(*args)
33
+ options = args.last.is_a?(Hash) ? args.pop : {}
34
+ command = args.shift
35
+ raise ArgumentError, "too many arguments" if args.any?
36
+
37
+ as = options.fetch(:as, fetch(:admin_runner, nil))
38
+
39
+ if command
40
+ invoke_command(command, :via => run_method, :as => as)
41
+ elsif :sudo == run_method
42
+ sudo(:as => as)
43
+ else
44
+ ""
45
+ end
46
+ end
47
+
48
+ namespace :deploy do
49
+ desc "Deploys your project."
50
+ task :default do
51
+ unless multiple_hosts
52
+ push
53
+ else
54
+ code
55
+ command = ["cd #{deploy_to}"]
56
+ command << ".git/hooks/post-reset `cat .git/ORIG_HEAD` HEAD 2>&1 | tee -a log/deploy.log"
57
+
58
+ run command.join(' && ')
59
+ end
60
+ end
61
+
62
+ task :push do
63
+ system source.local.scm('push', remote, "#{revision}:#{branch}")
64
+ end
65
+
66
+ task :code do
67
+ command = ["cd #{deploy_to}"]
68
+ command << source.scm('fetch', remote, "+refs/heads/#{branch}:refs/remotes/origin/#{branch}")
69
+ command << source.scm('reset', '--hard', "origin/#{branch}")
70
+
71
+ run command.join(' && ')
72
+ end
73
+
74
+ desc "Prepares servers for deployment."
75
+ task :setup do
76
+ shared = fetch(:group_writeable)
77
+
78
+ command = ["#{try_sudo} mkdir -p #{deploy_to}"]
79
+ command << "#{try_sudo} chown $USER #{deploy_to}" if :sudo == run_method
80
+ command << "cd #{deploy_to}"
81
+ command << "chmod g+w ." if shared
82
+ command << "git init #{shared ? '--shared' : ''}"
83
+ command << "sed -i'' -e 's/master/#{branch}/' .git/HEAD" unless branch == 'master'
84
+ command << "git config --bool receive.denyNonFastForwards false" if shared
85
+ command << "git config receive.denyCurrentBranch ignore"
86
+ run command.join(' && ')
87
+
88
+ install_hooks
89
+ push
90
+ end
91
+
92
+ task :install_hooks do
93
+ dir = File.dirname(__FILE__) + '/hooks'
94
+ remote_dir = "#{deploy_to}/.git/hooks"
95
+
96
+ top.upload "#{dir}/post-reset.rb", "#{remote_dir}/post-reset"
97
+ top.upload "#{dir}/post-receive.rb", "#{remote_dir}/post-receive"
98
+ run "chmod +x #{remote_dir}/post-receive #{remote_dir}/post-reset"
99
+
100
+ # Rewrite the post-reset hook to use the configured environment
101
+ run "sed -i'' 's/^environment_placeholder$/RAILS_ENV = \"#{environment}\"/' #{remote_dir}/post-reset"
102
+ end
103
+
104
+ desc "Restarts your Passenger application."
105
+ task :restart, :roles => :app do
106
+ run "touch #{deploy_to}/tmp/restart.txt"
107
+ end
108
+
109
+ desc <<-DESC
110
+ Copy files to the currently deployed version. Use a comma-separated \
111
+ list in FILES to specify which files to upload.
112
+
113
+ Note that unversioned files on your server are likely to be \
114
+ overwritten by the next push. Always persist your changes by committing.
115
+
116
+ $ cap deploy:upload FILES=templates,controller.rb
117
+ $ cap deploy:upload FILES='config/apache/*.conf'
118
+ DESC
119
+ task :upload do
120
+ files = (ENV["FILES"] || "").split(",").map { |f| Dir[f.strip] }.flatten
121
+ abort "Please specify at least one file or directory to update (via the FILES environment variable)" if files.empty?
122
+
123
+ files.each { |file| top.upload(file, File.join(deploy_to, file)) }
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env 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,121 @@
1
+ #!/usr/bin/env ruby
2
+ environment_placeholder
3
+ oldrev, newrev = ARGV
4
+ $stdout.sync = true
5
+
6
+ def parse_configuration(file)
7
+ config = {}
8
+ current = nil
9
+
10
+ File.open(file).each_line do |line|
11
+ case line
12
+ when /^\[(\w+)(?: "(.+)")\]/
13
+ key, subkey = $1, $2
14
+ current = (config[key] ||= {})
15
+ current = (current[subkey] ||= {}) if subkey
16
+ else
17
+ key, value = line.strip.split(' = ')
18
+ current[key] = value
19
+ end
20
+ end
21
+
22
+ config
23
+ end
24
+
25
+ class Array
26
+ # scans the list of files to see if any of them are under the given path
27
+ def any_in_dir?(dir)
28
+ if Array === dir
29
+ exp = %r{^(?:#{dir.join('|')})/}
30
+ any? { |file| file =~ exp }
31
+ else
32
+ dir += '/'
33
+ any? { |file| file.index(dir) == 0 }
34
+ end
35
+ end
36
+ end
37
+
38
+ # get a list of files that changed
39
+ changes = `git diff #{oldrev} #{newrev} --diff-filter=ACDMR --name-status`.split("\n")
40
+
41
+ # make a hash of files that changed and how they changed
42
+ changes_hash = changes.inject(Hash.new { |h, k| h[k] = [] }) do |hash, line|
43
+ modifier, filename = line.split("\t", 2)
44
+ hash[modifier] << filename
45
+ hash
46
+ end
47
+
48
+ # create an array of files added, copied, modified or renamed
49
+ modified_files = %w(A C M R).inject([]) { |files, bit| files.concat changes_hash[bit] }
50
+ added_files = changes_hash['A'] # added
51
+ deleted_files = changes_hash['D'] # deleted
52
+ changed_files = modified_files + deleted_files # all
53
+ puts "files changed: #{changed_files.size}"
54
+
55
+ cached_assets_cleared = false
56
+
57
+ # detect modified asset dirs
58
+ asset_dirs = %w(public/stylesheets public/javascripts).select do |dir|
59
+ # did any on the assets under this dir change?
60
+ changed_files.any_in_dir?(dir)
61
+ end
62
+
63
+ unless asset_dirs.empty?
64
+ # clear cached assets (unversioned/ignored files)
65
+ system %(git clean -x -f -- #{asset_dirs.join(' ')})
66
+ cached_assets_cleared = true
67
+ end
68
+
69
+ # run migrations when new ones added
70
+ if new_migrations = added_files.any_in_dir?('db/migrate')
71
+ system %(rake db:migrate RAILS_ENV=#{RAILS_ENV})
72
+ end
73
+
74
+ if modified_files.include?('.gitmodules')
75
+ # initialize new submodules
76
+ system %(git submodule init)
77
+ # sync submodule remote urls in case of changes
78
+ config = parse_configuration('.gitmodules')
79
+
80
+ if config['submodule']
81
+ config['submodule'].values.each do |submodule|
82
+ path = submodule['path']
83
+ subconf = "#{path}/.git/config"
84
+
85
+ if File.exists? subconf
86
+ old_url = `git config -f "#{subconf}" remote.origin.url`.chomp
87
+ new_url = submodule['url']
88
+ unless old_url == new_url
89
+ puts "changing #{path.inspect} URL:\n #{old_url.inspect} → #{new_url.inspect}"
90
+ `git config -f "#{subconf}" remote.origin.url "#{new_url}"`
91
+ end
92
+ else
93
+ $stderr.puts "a submodule in #{path.inspect} doesn't exist"
94
+ end
95
+ end
96
+ end
97
+ end
98
+ # update existing submodules
99
+ system %(git submodule update)
100
+
101
+ # have bundler unpack new gems. Honor config/build_options.yml if it is present.
102
+ if modified_files.include?("Gemfile")
103
+ base_command = "gem bundle --cached"
104
+ build_options = "config/build_options.yml"
105
+ if File.exists? build_options
106
+ system %(#{base_command} --build-options #{build_options})
107
+ else
108
+ system base_command
109
+ end
110
+ end
111
+
112
+ # clean unversioned files from vendor (e.g. old submodules)
113
+ system %(git clean -d -f vendor)
114
+
115
+ # determine if app restart is needed
116
+ if cached_assets_cleared or new_migrations or changed_files.any_in_dir?(%w(app config lib public vendor))
117
+ require 'fileutils'
118
+ # tell Passenger to restart this app
119
+ FileUtils.touch 'tmp/restart.txt'
120
+ puts "restarting Passenger app"
121
+ end
metadata ADDED
@@ -0,0 +1,68 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: fletcherm-git-deploy
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - "Mislav Marohni\xC4\x87"
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-12-09 00:00:00 -05: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: A tool to install useful git hooks on your remote repository to enable 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
+ - lib/git_deploy.rb
35
+ - lib/hooks/post-receive.rb
36
+ - lib/hooks/post-reset.rb
37
+ - README.markdown
38
+ - LICENSE
39
+ has_rdoc: false
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
+