From 694fba3ea35c7156148a17be3534b87add16a257 Mon Sep 17 00:00:00 2001 From: Karl Matthias Date: Wed, 4 Apr 2012 10:15:53 +0100 Subject: [PATCH] Initial check-in. --- Gemfile | 9 +++ Gemfile.lock | 45 +++++++++++++++ LICENSE | 26 +++++++++ README.md | 55 ++++++++++++++++++ Rakefile | 20 +++++++ capistrano-deploytags.gemspec | 14 +++++ lib/capistrano/deploy_tags.rb | 84 ++++++++++++++++++++++++++++ spec/capistrano_deploy_tags_spec.rb | 74 ++++++++++++++++++++++++ spec/fixtures/git-fixture.tar.gz | Bin 0 -> 7046 bytes 9 files changed, 327 insertions(+) create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Rakefile create mode 100644 capistrano-deploytags.gemspec create mode 100644 lib/capistrano/deploy_tags.rb create mode 100644 spec/capistrano_deploy_tags_spec.rb create mode 100644 spec/fixtures/git-fixture.tar.gz diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..a9d8e26 --- /dev/null +++ b/Gemfile @@ -0,0 +1,9 @@ +source "https://rubygems.org" + +group :test do + gem 'rake' + gem 'rspec' + gem 'capistrano' + gem 'capistrano-ext' + gem 'capistrano-spec', :git => 'git://github.com/mydrive/capistrano-spec.git' +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..6459018 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,45 @@ +GIT + remote: git://github.com/mydrive/capistrano-spec.git + revision: dcc0908dc00872272b1d495bcce70e82240fa8f7 + specs: + capistrano-spec (0.1.0) + +GEM + remote: https://rubygems.org/ + specs: + capistrano (2.11.2) + highline + net-scp (>= 1.0.0) + net-sftp (>= 2.0.0) + net-ssh (>= 2.0.14) + net-ssh-gateway (>= 1.1.0) + capistrano-ext (1.2.1) + capistrano (>= 1.0.0) + diff-lcs (1.1.3) + highline (1.6.11) + net-scp (1.0.4) + net-ssh (>= 1.99.1) + net-sftp (2.0.5) + net-ssh (>= 2.0.9) + net-ssh (2.3.0) + net-ssh-gateway (1.1.0) + net-ssh (>= 1.99.1) + rake (0.9.2.2) + rspec (2.8.0) + rspec-core (~> 2.8.0) + rspec-expectations (~> 2.8.0) + rspec-mocks (~> 2.8.0) + rspec-core (2.8.0) + rspec-expectations (2.8.0) + diff-lcs (~> 1.1.2) + rspec-mocks (2.8.0) + +PLATFORMS + ruby + +DEPENDENCIES + capistrano + capistrano-ext + capistrano-spec! + rake + rspec diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9276fd8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2012 MyDrive Solutions Limited, All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS +FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN +ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb6b89f --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +Capistrano Deployment Tags +========================== +This plugin to Capistrano will add tags for each deployment that +you make to the project. It is intnded to be used with the multistage +plugin and will tag each release by environment. Each successful +deployment will result in a new tag as well as moving an existing +tag that points to the last deployment. + +What It Does +------------ +If I were to issue the command: + +`cap production deploy` + +This would result in one new git tag with the environment and +timestamp: + +`production-2012.04.02-203155` + +It would also result in moving or creating this tag: + +`production-latest` + +These tags can be used for any number of useful things including +generating statistics about deployments per day/week/year, tracking +code size over a period of time, detecting Rails migrations, and +probably a thousand other things I haven't thought of. + +Usage +----- +If you use Bundler, be sure to add the gem to your Gemfile. +In your Capistrano `config/deploy.rb` you should add: + +`require 'capistrano_deploytags'` + +This will create two tasks, one that runs before deployment and one +that runs after. + +NOTE: You will be creating and pushing tags from the version of the +code in the current checkout. This plugin needs to be run from a +clean checkout of your codebase. You should be deploying from a +clean checkout anyway, so in most cases this is not a restriction +on how you already do things. The plugin will check if your code +is clean and complain if it is not. + +Credits +------- +This software was written by [Karl Matthias](https://github.com/relistan) +with help from [Gavin Heavyside](https://github.com/hgavin) and the +support of [MyDrive Solutions Limited](http://mydrivesolutions.com). + +License +------- +This plugin is released under the BSD two clause license which is +available in both the Ruby Gem and the source repository. diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..9e9effd --- /dev/null +++ b/Rakefile @@ -0,0 +1,20 @@ +require 'rake' + +$:.unshift(File.expand_path("lib")) + +begin + require 'rspec/core/rake_task' + + RSpec::Core::RakeTask.new(:spec) do |t| + t.rspec_opts = %w[--color --format=documentation] + t.pattern = "spec/**/*_spec.rb" + end + + task :default => [:spec] +rescue LoadError + # don't generate Rspec tasks if we don't have it installed +end + +task :build do + system "gem build capistrano-deploytags.gemspec" +end diff --git a/capistrano-deploytags.gemspec b/capistrano-deploytags.gemspec new file mode 100644 index 0000000..c1bad72 --- /dev/null +++ b/capistrano-deploytags.gemspec @@ -0,0 +1,14 @@ +Gem::Specification.new do |s| + s.name = 'capistrano_deploytags' + s.version = '0.5' + s.date = '2012-04-02' + s.summary = "Add dated, environment-specific tags to your git repo at each deployment." + s.description = s.summary + s.authors = ["Karl Matthias"] + s.email = 'relistan@gmail.com' + s.files = Dir.glob("lib/**/*") + %w{ README.md LICENSE } + s.homepage = 'http://github.com/mydrive/capistrano_deploytags' + s.add_dependency 'capistrano' + s.add_dependency 'capistrano-ext' + s.require_path = 'lib' +end diff --git a/lib/capistrano/deploy_tags.rb b/lib/capistrano/deploy_tags.rb new file mode 100644 index 0000000..a2bf486 --- /dev/null +++ b/lib/capistrano/deploy_tags.rb @@ -0,0 +1,84 @@ +module Capistrano + module DeployTags + def pending_git_changes? + # Do we have any changes vs HEAD on deployment branch? + !(`git fetch && git diff #{branch} --shortstat`.strip.empty?) + end + + def git_tag_for(stage) + "#{stage}-#{Time.now.strftime("%Y.%m.%d-%H%M%S")}" + end + + def last_git_tag_for(stage) + "#{stage}-latest" + end + + def safe_run(*args) + raise "#{args.join(" ")} failed!" unless system(*args) + end + + def validate_git_vars + unless exists?(:branch) && exists?(:stage) + logger.log Capistrano::Logger::IMPORTANT, "Capistrano Deploytags requires that :branch and :stage be defined." + raise 'define :branch or :stage' + end + end + + def git_tag?(tag) + !`git tag -l #{tag}`.strip.empty? + end + + def has_remote? + !`git remote`.strip.empty? + end + + def self.load_into(configuration) + configuration.load do + before :deploy, 'git:prepare_tree' + after :deploy, 'git:deploytags' + + desc 'prepare git tree so we can tag on successful deployment' + namespace :git do + task :prepare_tree, :except => { :no_release => true } do + cdt.validate_git_vars + + logger.log Capistrano::Logger::IMPORTANT, "Preparing to deploy HEAD from branch '#{branch}' to '#{stage}'" + + if cdt.pending_git_changes? + logger.log Capistrano::Logger::IMPORTANT, "Whoa there, partner. Dirty trees can't deploy. Git yerself clean first." + raise 'Dirty git tree' + end + + cdt.safe_run "git", "checkout", branch + cdt.safe_run "git", "pull", "origin", branch if cdt.has_remote? + end + + desc 'add git tags for each successful deployment' + task :tagdeploy, :except => { :no_release => true } do + cdt.validate_git_vars + + current_sha = `git rev-parse #{branch} HEAD`.strip[0..8] + logger.log Capistrano::Logger::INFO, "Tagging #{current_sha} for deployment" + + tag_user = (ENV['USER'] || ENV['USERNAME']).strip + cdt.safe_run "git", "tag", "-a", cdt.git_tag_for(stage), "-m", "#{tag_user} deployed #{current_sha} to #{stage}" + if cdt.git_tag?(cdt.last_git_tag_for(stage)) + cdt.safe_run "git", "tag", "-d", cdt.last_git_tag_for(stage) + cdt.safe_run "git", "push", "--tags" if cdt.has_remote? + end + + cdt.safe_run "git", "tag", "-a", cdt.last_git_tag_for(stage), "-m", "#{tag_user} deployed #{current_sha} to #{stage}" + cdt.safe_run "git", "push", "--tags" if cdt.has_remote? + end + end + + end + end + end +end + +Capistrano.plugin :cdt, Capistrano::DeployTags + +if Capistrano::Configuration.instance + Capistrano::DeployTags.load_into(Capistrano::Configuration.instance(:must_exist)) +end diff --git a/spec/capistrano_deploy_tags_spec.rb b/spec/capistrano_deploy_tags_spec.rb new file mode 100644 index 0000000..2eddc43 --- /dev/null +++ b/spec/capistrano_deploy_tags_spec.rb @@ -0,0 +1,74 @@ +require 'capistrano' +require 'capistrano-spec' +require 'fileutils' +mypath = File.expand_path(File.dirname(__FILE__)) +require File.expand_path(File.join(mypath, '..', 'lib', 'capistrano', 'deploy_tags')) + +describe Capistrano::DeployTags do + let(:configuration) { Capistrano::Configuration.new } + let(:tmpdir) { "/tmp/#{$$}" } + let(:mypath) { mypath } + + before :each do + Capistrano::DeployTags.load_into(configuration) + end + + def with_clean_repo(&block) + FileUtils.rm_rf tmpdir + FileUtils.mkdir tmpdir + FileUtils.chdir tmpdir + raise unless system("/usr/bin/tar xzf #{File.join(mypath, 'fixtures', 'git-fixture.tar.gz')}") + FileUtils.chdir "#{tmpdir}/git-fixture" + yield + FileUtils.rm_rf tmpdir + end + + context "prepare_tree" do + it "raises an error when not in a git tree" do + FileUtils.chdir '/tmp' + configuration.set(:branch, 'master') + configuration.set(:stage, 'test') + lambda { configuration.find_and_execute_task('git:prepare_tree') }.should raise_error('git checkout master failed!') + end + + context "with a clean git tree" do + it "raises an error if :stage or :branch are undefined" do + with_clean_repo do + lambda { configuration.find_and_execute_task('git:prepare_tree') }.should raise_error('define :branch or :stage') + end + end + + it "does not raise an error when run from a clean tree" do + with_clean_repo do + configuration.set(:branch, 'master') + configuration.set(:stage, 'test') + lambda { configuration.find_and_execute_task('git:prepare_tree') }.should_not raise_error + end + end + end + end + + context "tagdeploy" do + before :each do + configuration.set(:branch, 'master') + configuration.set(:stage, 'test') + end + + it "does not raise an error when run from a clean tree" do + with_clean_repo do + lambda { configuration.find_and_execute_task('git:tagdeploy') }.should_not raise_error + end + end + + it "adds appropriate git tags" do + with_clean_repo do + configuration.find_and_execute_task('git:tagdeploy') + + tags = `git tag -l`.split(/\n/).sort + tags.should have(2).items + tags.first.should =~ /^test-\d{4}\.\d{2}\.\d{2}/ + tags.last.should == 'test-latest' + end + end + end +end diff --git a/spec/fixtures/git-fixture.tar.gz b/spec/fixtures/git-fixture.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..728b04aa8a1f94b9f3e0065d99f0d419862d98d7 GIT binary patch literal 7046 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