From 6d3c29d22c6f2e31dfe5d679e95d4b200116ff2f Mon Sep 17 00:00:00 2001 From: William Lee Date: Mon, 1 Sep 2025 16:30:12 +0100 Subject: [PATCH 1/8] Update deployment by branches to iterate over each stage --- lib/epi_deploy/command.rb | 37 ++++++++++++------------- lib/epi_deploy/deployer.rb | 40 +++++++++++----------------- lib/epi_deploy/stages_extractor.rb | 13 ++++++++- spec/lib/epi_deploy/deployer_spec.rb | 5 ++-- spec/spec_helper.rb | 4 +++ 5 files changed, 51 insertions(+), 48 deletions(-) diff --git a/lib/epi_deploy/command.rb b/lib/epi_deploy/command.rb index 0a11380..d6a78c1 100644 --- a/lib/epi_deploy/command.rb +++ b/lib/epi_deploy/command.rb @@ -2,13 +2,13 @@ module EpiDeploy class Command - + include EpiDeploy::Helpers - + attr_accessor :options attr_accessor :args attr_accessor :release_class - + def initialize(options, args, release_class = EpiDeploy::Release) self.options = options self.args = args @@ -25,7 +25,7 @@ def release environments = self.options.to_hash[:deploy] self.deploy(environments) unless environments.nil? end - + def deploy(environments = self.args) raise Slop::InvalidArgumentError.new("No environments provided") unless environments.any? check_environments_are_valid(environments) @@ -41,13 +41,12 @@ def deploy(environments = self.args) end end end - - + private - + def prompt_for_a_release print_notice "Select a recent release (or just press enter for latest):" - + tag_list = {} self.release_class.new.release_tags_list.each_with_index do |release, i| number = i + 1 @@ -55,18 +54,15 @@ def prompt_for_a_release print_notice "#{number}: #{release}" end - selected_release = nil - while selected_release.nil? do - selected_release = STDIN.gets[/\d/] rescue nil - if selected_release.nil? - return :latest - else - unless tag_list.key?(selected_release) - print_failure_and_abort "Invalid selection '#{selected_release}'. Try again..." - selected_release = nil - end + selected_release = STDIN.gets[/\d/] rescue nil + if selected_release.nil? + return :latest + else + unless tag_list.key?(selected_release) + print_failure_and_abort "Invalid selection '#{selected_release}'. Try again..." end end + tag_list[selected_release] end @@ -79,14 +75,15 @@ def determine_release_reference(options) :latest end end + def check_environments_are_valid(environments) invalid_environments = environments.reject { |environment| stages_extractor.valid_stage?(environment) } raise Slop::InvalidArgumentError.new("Environment '#{invalid_environments.first}' does not exist") unless invalid_environments.empty? end - + def stages_extractor @stages_extractor ||= StagesExtractor.new end - + end end \ No newline at end of file diff --git a/lib/epi_deploy/deployer.rb b/lib/epi_deploy/deployer.rb index 0e693e4..21307fc 100644 --- a/lib/epi_deploy/deployer.rb +++ b/lib/epi_deploy/deployer.rb @@ -32,17 +32,14 @@ def deploy_with_timestamped_tags(stages_or_environments) print_notice 'Removing any legacy deployment branches' git_wrapper.delete_branches(stages_extractor.environments) - stages_or_environments.each do |stage_or_environment| - stages_extractor.stages_for_stage_or_environment(stage_or_environment).each do |stage| + stages_extractor.each_stage(stages_or_environments) do |stage| + completed = run_cap_deploy_to(stage) + if completed tag_name = tag_name_for_stage(stage) - - completed = run_cap_deploy_to(stage) - if completed - git_wrapper.create_or_update_tag(tag_name, @release.commit) - print_success "Created deployment tag #{tag_name} on commit #{@release.commit}" - else - print_failure_and_abort "Deployment failed - please review output before deploying again" - end + git_wrapper.create_or_update_tag(tag_name, @release.commit) + print_success "Created deployment tag #{tag_name} on commit #{@release.commit}" + else + print_failure_and_abort "Deployment failed - please review output before deploying again" end end end @@ -50,18 +47,18 @@ def deploy_with_timestamped_tags(stages_or_environments) def deploy_with_environment_branches(stages_or_environments) updated_branches = Set.new - stages_or_environments.each do |stage_or_environment| - begin - git_wrapper.pull + git_wrapper.pull - matches = StagesExtractor.match_with(stage_or_environment) + stages_extractor.each_stage(stages_or_environments) do |stage| + begin + matches = StagesExtractor.match_with(stage) # Force the tag/branch to the commit we want to deploy unless updated_branches.include? matches[:stage] git_wrapper.create_or_update_branch(matches[:stage], @release.commit) updated_branches << matches[:stage] end - completed = run_cap_deploy_to(stage_or_environment) + completed = run_cap_deploy_to(stage) if !completed print_failure_and_abort "Deployment failed - please review output before deploying again" end @@ -84,16 +81,9 @@ def tag_name_for_stage(stage) "deploy-#{stage}-#{timestamp}" end - def run_cap_deploy_to(environment) - print_notice "Deploying to #{environment}... " - - task_to_run = if stages_extractor.multi_customer_stage?(environment) - "deploy_all" - else - "deploy" - end - - Kernel.system "BRANCH=#{@release.commit} bundle exec cap #{environment} #{task_to_run}" + def run_cap_deploy_to(stage) + print_notice "Deploying to #{stage}... " + Kernel.system "BRANCH=#{@release.commit} bundle exec cap #{stage} deploy" end end end \ No newline at end of file diff --git a/lib/epi_deploy/stages_extractor.rb b/lib/epi_deploy/stages_extractor.rb index 9afef4e..19f86a3 100644 --- a/lib/epi_deploy/stages_extractor.rb +++ b/lib/epi_deploy/stages_extractor.rb @@ -44,7 +44,7 @@ def valid_stage?(stage) def stages_for_stage_or_environment(stage_or_environment) if @environment_to_stages.has_key? stage_or_environment # Environment - @environment_to_stages[stage_or_environment] + @environment_to_stages[stage_or_environment].to_a elsif self.all_stages.include? stage_or_environment # Stage [stage_or_environment] @@ -53,6 +53,17 @@ def stages_for_stage_or_environment(stage_or_environment) end end + # stages are sorted lexicographically within each stage or environment passed + def each_stage(stages_or_environments) + stages = stages_or_environments.flat_map do |stage_or_environment| + stages_for_stage_or_environment(stage_or_environment).sort + end + + stages.each do |stage| + yield stage + end + end + def environments @environment_to_stages.keys end diff --git a/spec/lib/epi_deploy/deployer_spec.rb b/spec/lib/epi_deploy/deployer_spec.rb index fa145a7..b0d3761 100644 --- a/spec/lib/epi_deploy/deployer_spec.rb +++ b/spec/lib/epi_deploy/deployer_spec.rb @@ -119,8 +119,9 @@ def deployment_stage_with_timestamp(stage) end.to_not raise_error end - it 'runs the capistrano deploy_all task for multi-customer environments' do - expect(Kernel).to receive(:system).with("BRANCH=#{release.commit} bundle exec cap production deploy_all").and_return(true) + it 'runs the capistrano deploy task for each stage for multi-customer environments' do + expect(Kernel).to receive(:system).with("BRANCH=#{release.commit} bundle exec cap production.epigenesys deploy").and_return(true) + expect(Kernel).to receive(:system).with("BRANCH=#{release.commit} bundle exec cap production.genesys deploy").and_return(true) expect do subject.deploy! %w(production) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f83e281..6bd0aa2 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,3 +5,7 @@ def run_ed(commands) run_command_and_stop "#{File.join(File.dirname(__FILE__), '../bin/epi_deploy')} #{commands}", fail_on_error: false end + +RSpec.configure do |config| + config.example_status_persistence_file_path = 'tmp/examples.txt' +end From e9d4cad9f045b771e6dd2f70bad95cb37c6c9c4d Mon Sep 17 00:00:00 2001 From: William Lee Date: Tue, 2 Sep 2025 14:35:52 +0100 Subject: [PATCH 2/8] Check REVISION path exists before command for deploy:revision Capistrano task --- lib/capistrano/tasks/multi_customers.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/capistrano/tasks/multi_customers.rb b/lib/capistrano/tasks/multi_customers.rb index a2e2f22..5d9b3a8 100644 --- a/lib/capistrano/tasks/multi_customers.rb +++ b/lib/capistrano/tasks/multi_customers.rb @@ -33,7 +33,11 @@ task :revision do on roles :app do |host| within current_path do - info "#{fetch(:user)}@#{host}: #{capture :cat, 'REVISION'}" + if test "[ -f REVISION ]" + info "#{fetch(:user)}@#{host}: #{capture :cat, 'REVISION'}" + else + info "#{fetch(:user)}@#{host}: REVISION file not found" + end end end end From 994df43b29e7a3e7e865b1965b9583a5309a1c34 Mon Sep 17 00:00:00 2001 From: William Lee Date: Tue, 2 Sep 2025 15:46:13 +0100 Subject: [PATCH 3/8] Tidy up gemspec --- epi_deploy.gemspec | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/epi_deploy.gemspec b/epi_deploy.gemspec index e0f75b5..efb8ff4 100644 --- a/epi_deploy.gemspec +++ b/epi_deploy.gemspec @@ -1,21 +1,19 @@ -# -*- encoding: utf-8 -*- -lib = File.expand_path('../lib', __FILE__) -$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) -require 'epi_deploy/version' +# frozen_string_literal: true + +require_relative 'lib/epi_deploy/version' Gem::Specification.new do |gem| gem.name = "epi_deploy" gem.version = EpiDeploy::VERSION gem.authors = ["Anthony Nettleship", "Shuo Chen", "Chris Hunt", "James Gregory", "William Lee"] gem.email = ["anthony.nettleship@epigenesys.org.uk", "shuo.chen@epigenesys.org.uk", "chris.hunt@epigenesys.org.uk", "james.gregory@epigenesys.org.uk", "william.lee@epigenesys.org.uk"] - gem.description = "A gem to facilitate deployment across multiple git branches and evironments" - gem.summary = "eD" + gem.summary = "A gem to facilitate deployment across multiple git branches and environments" gem.homepage = "https://www.epigenesys.org.uk" - gem.files = `git ls-files`.split($/) - gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } - gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) - gem.require_paths = ["lib"] + gem.files = Dir['README.md', 'LICENSE.txt', 'lib/**/*.rb', 'bin/*'] + gem.executables = gem.files.grep(/^bin/).map{ |f| File.basename(f) } + + gem.required_ruby_version = '>= 2.7', '< 3.4' gem.add_dependency('slop', '~> 3.6') gem.add_dependency('git', '~> 1.5') From 5e30e66a06df901d7cd98e817be92fdc49e79f82 Mon Sep 17 00:00:00 2001 From: William Lee Date: Tue, 2 Sep 2025 16:44:28 +0100 Subject: [PATCH 4/8] Add overwrite detector class --- lib/epi_deploy/git_wrapper.rb | 4 ++ lib/epi_deploy/overwrite_detector.rb | 37 +++++++++++ lib/epi_deploy/release.rb | 4 ++ spec/lib/epi_deploy/git_wrapper_spec.rb | 25 +++++++ .../lib/epi_deploy/overwrite_detector_spec.rb | 65 +++++++++++++++++++ spec/lib/epi_deploy/release_spec.rb | 24 +++++++ 6 files changed, 159 insertions(+) create mode 100644 lib/epi_deploy/overwrite_detector.rb create mode 100644 spec/lib/epi_deploy/overwrite_detector_spec.rb diff --git a/lib/epi_deploy/git_wrapper.rb b/lib/epi_deploy/git_wrapper.rb index 77f9509..4da7190 100644 --- a/lib/epi_deploy/git_wrapper.rb +++ b/lib/epi_deploy/git_wrapper.rb @@ -75,6 +75,10 @@ def git_object_for(ref) git.object(commit_hash_for(ref)) end + def ancestor?(reference, of:) + system("git merge-base #{reference} #{of} --is-ancestor") + end + private def git diff --git a/lib/epi_deploy/overwrite_detector.rb b/lib/epi_deploy/overwrite_detector.rb new file mode 100644 index 0000000..79e21f9 --- /dev/null +++ b/lib/epi_deploy/overwrite_detector.rb @@ -0,0 +1,37 @@ +module EpiDeploy + class OverwriteDetector + attr_reader :release + + def initialize(release) + @release = release + end + + def release_overwrites?(stage) + @overwrites ||= {} + @overwrites[stage] ||= calculate_overwrite(stage) + end + + private + + def calculate_overwrite(stage) + revision = extract_revision(stage) + return nil if revision.nil? + + !release.has_ancestor?(revision) + end + + # returns nil if the regex does not match for two reasons: + # 1. the command failed to execute + # 2. the REVISION file was not found on the remote + def extract_revision(stage) + output = `bundle exec cap #{stage} deploy:revision` + match = output.match(/^\s*.*?@.*?: (?[A-Za-z0-9]{40})/) + + if match && match[:hash] + match[:hash] + else + nil + end + end + end +end diff --git a/lib/epi_deploy/release.rb b/lib/epi_deploy/release.rb index cd76d4b..3906dd2 100644 --- a/lib/epi_deploy/release.rb +++ b/lib/epi_deploy/release.rb @@ -47,6 +47,10 @@ def release_tags_list end end + def has_ancestor?(reference) + git_wrapper.ancestor?(reference, of: commit) + end + def git_wrapper(klass = EpiDeploy::GitWrapper) @git_wrapper ||= klass.new end diff --git a/spec/lib/epi_deploy/git_wrapper_spec.rb b/spec/lib/epi_deploy/git_wrapper_spec.rb index 9c42049..cc425d8 100644 --- a/spec/lib/epi_deploy/git_wrapper_spec.rb +++ b/spec/lib/epi_deploy/git_wrapper_spec.rb @@ -121,4 +121,29 @@ end end end + + describe '#ancestor?' do + let(:reference) { '275cf7e295b879a62526b13b4fb78e7b04935fe1' } + let(:of) { 'ae4ff8053b6beb3a9e57b8c4d59038edf7d4c8f9' } + + context 'if the command exits with a status code of 0' do + before do + allow(subject).to receive(:system).with("git merge-base #{reference} #{of} --is-ancestor").and_return(true) + end + + specify 'it returns true' do + expect(subject).to be_ancestor reference, of: + end + end + + context 'if the command exits with a status code of 0' do + before do + allow(subject).to receive(:system).with("git merge-base #{reference} #{of} --is-ancestor").and_return(false) + end + + specify 'it returns false' do + expect(subject).to_not be_ancestor reference, of: + end + end + end end diff --git a/spec/lib/epi_deploy/overwrite_detector_spec.rb b/spec/lib/epi_deploy/overwrite_detector_spec.rb new file mode 100644 index 0000000..41f3dea --- /dev/null +++ b/spec/lib/epi_deploy/overwrite_detector_spec.rb @@ -0,0 +1,65 @@ +require 'spec_helper' + +require 'epi_deploy/overwrite_detector' + +RSpec.describe EpiDeploy::OverwriteDetector do + let(:stage) { 'demo' } + let(:deployed_revision) { '015b275d2ee4371456991ebfe9bb5f7fb6148aba' } + let(:release) { double('Release', reference: 'features/example', commit: 'ce2bba813f2e7debb232e81d1d0a8d971b05160c') } + + subject { described_class.new(release) } + + describe '#release_overwrites?' do + context 'if the REVISION file was found' do + before do + allow(subject).to receive(:`).with("bundle exec cap #{stage} deploy:revision").and_return <<~EOF + 00:00 deploy:revision + user@epigenesys.test: #{deployed_revision} + EOF + end + + context 'and the deployed revision is an ancestor of the release' do + before do + allow(release).to receive(:has_ancestor?).with(deployed_revision).and_return(true) + end + + specify 'it returns false' do + expect(subject.release_overwrites?(stage)).to eq false + end + end + + context 'and the deployed revision is not an ancestor of the release' do + before do + allow(release).to receive(:has_ancestor?).with(deployed_revision).and_return(false) + end + + specify 'it returns false' do + expect(subject.release_overwrites?(stage)).to eq true + end + end + end + + context 'if the REVISION file was not found' do + before do + allow(subject).to receive(:`).with("bundle exec cap #{stage} deploy:revision").and_return <<~EOF + 00:00 deploy:revision + user@epigenesys.test: REVISION file not found + EOF + end + + specify 'it returns nil' do + expect(subject.release_overwrites?(stage)).to be_nil + end + end + + context 'if the command did not produce any standard output' do + before do + allow(subject).to receive(:`).with("bundle exec cap #{stage} deploy:revision").and_return '' + end + + specify 'it returns nil' do + expect(subject.release_overwrites?(stage)).to be_nil + end + end + end +end \ No newline at end of file diff --git a/spec/lib/epi_deploy/release_spec.rb b/spec/lib/epi_deploy/release_spec.rb index 49488ee..d96c8f4 100644 --- a/spec/lib/epi_deploy/release_spec.rb +++ b/spec/lib/epi_deploy/release_spec.rb @@ -109,4 +109,28 @@ def delete_branches(branches); end expect(subject.create!).to eq false end end + + describe '#has_ancestor?' do + let(:reference) { 'ca831be73f04f22998f4b47cca7f7379de89c410' } + + context 'if the reference is an ancestor of the release commit' do + before do + allow(git_wrapper).to receive(:ancestor?).with(reference, of: subject.commit).and_return(true) + end + + specify 'it returns true' do + expect(subject).to have_ancestor reference + end + end + + context 'if the reference is not an ancestor of the release commit' do + before do + allow(git_wrapper).to receive(:ancestor?).with(reference, of: subject.commit).and_return(false) + end + + specify 'it returns false' do + expect(subject).to_not have_ancestor reference + end + end + end end From 8703220712a5618375b389a0711ed53ab95f984c Mon Sep 17 00:00:00 2001 From: William Lee Date: Tue, 2 Sep 2025 16:45:06 +0100 Subject: [PATCH 5/8] Remove Ruby 2.6 and add 3.4 to GitHub workflow --- .github/workflows/ruby.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 9195486..e0ebad0 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby-version: ['2.6', '2.7', '3.0', '3.1', '3.2', '3.3'] + ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4'] steps: - uses: actions/checkout@v2 From 2e7d14232f9f4b8067d6567c1cbc6f1fcbbbc29b Mon Sep 17 00:00:00 2001 From: William Lee Date: Wed, 3 Sep 2025 10:34:18 +0100 Subject: [PATCH 6/8] Allow gem to be installed on Ruby 3.4 --- epi_deploy.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/epi_deploy.gemspec b/epi_deploy.gemspec index efb8ff4..b9fc203 100644 --- a/epi_deploy.gemspec +++ b/epi_deploy.gemspec @@ -13,7 +13,7 @@ Gem::Specification.new do |gem| gem.files = Dir['README.md', 'LICENSE.txt', 'lib/**/*.rb', 'bin/*'] gem.executables = gem.files.grep(/^bin/).map{ |f| File.basename(f) } - gem.required_ruby_version = '>= 2.7', '< 3.4' + gem.required_ruby_version = '>= 2.7', '< 3.5' gem.add_dependency('slop', '~> 3.6') gem.add_dependency('git', '~> 1.5') From 129dab8a60a3c5bc1944dbf1d68a02abcdd64cfd Mon Sep 17 00:00:00 2001 From: William Lee Date: Wed, 3 Sep 2025 10:34:44 +0100 Subject: [PATCH 7/8] Set fail-fast to false for workflow tests --- .github/workflows/ruby.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index e0ebad0..9e62361 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -20,6 +20,7 @@ jobs: strategy: matrix: ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4'] + fail-fast: false steps: - uses: actions/checkout@v2 From 1ef55f9ab0ca1657c21b72625265fa204206773e Mon Sep 17 00:00:00 2001 From: William Lee Date: Wed, 3 Sep 2025 10:37:54 +0100 Subject: [PATCH 8/8] Fill in blank keyword arguments to maintain compatibility with Ruby < 3.1 --- spec/lib/epi_deploy/git_wrapper_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/lib/epi_deploy/git_wrapper_spec.rb b/spec/lib/epi_deploy/git_wrapper_spec.rb index cc425d8..bdb40ab 100644 --- a/spec/lib/epi_deploy/git_wrapper_spec.rb +++ b/spec/lib/epi_deploy/git_wrapper_spec.rb @@ -132,7 +132,7 @@ end specify 'it returns true' do - expect(subject).to be_ancestor reference, of: + expect(subject).to be_ancestor reference, of: of end end @@ -142,7 +142,7 @@ end specify 'it returns false' do - expect(subject).to_not be_ancestor reference, of: + expect(subject).to_not be_ancestor reference, of: of end end end