afterlife 1.7.4 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b3027a754b5aeaaa15a7dbbba4b5aa895ce11fb48402dd9a4348f30b3dc8aa7
4
- data.tar.gz: 2253a73fd40b0e247cafb3b35b0bd0d75fb9b78d722b7887a7800b30d0f8faa9
3
+ metadata.gz: ded787195d0197794e9ce63aab421fd0b9f4168208bab96b1cdab4e34f22d955
4
+ data.tar.gz: f9305bba34772dbe134eedad518d67ad371e85a1e8b6f28b25785f54f8737e58
5
5
  SHA512:
6
- metadata.gz: 01a9c292bef075d314f0ce97ff3deda29705247f9e6e0227c82645292fe0ffe262a4d18d62b8abd896b6376325bcb5071a61d90f992ee47e42dba0a0828bba64
7
- data.tar.gz: 2f7763617dbd485d863afb4ea226caac63fa3493b14ab3a0c35c186d11ff22f01a8b2e895ba0b7d12ca5cc30db45e140a4be0814e557840cdffd3ff085016b99
6
+ metadata.gz: decbc189d4fc9e422ab524fdd80adc4234d919542350db532bc04a69a48cdef40220fde5331fb94fa77c1427a59df5d4328f7d454e50d8a8b20d336c239e976b
7
+ data.tar.gz: 394711b68721f95e338b558c51d86581537b6a0ddf7f4dcd31646420cf0a9cebcdfbff60ef2395f4b13acaae1e44856aac5b57b0d690402f46c51b8053a94d81
data/.rubocop.yml CHANGED
@@ -1,10 +1,10 @@
1
- require:
1
+ plugins:
2
2
  - rubocop-rspec
3
3
  - rubocop-rake
4
4
 
5
5
  AllCops:
6
6
  NewCops: enable
7
- TargetRubyVersion: 2.6
7
+ TargetRubyVersion: 3.3
8
8
 
9
9
  Lint/ConstantDefinitionInBlock:
10
10
  Enabled: false
@@ -44,3 +44,24 @@ Layout/AccessModifierIndentation:
44
44
 
45
45
  Style/SignalException:
46
46
  EnforcedStyle: semantic
47
+
48
+ Metrics/ClassLength:
49
+ Enabled: false
50
+
51
+ Metrics/CyclomaticComplexity:
52
+ Enabled: false
53
+
54
+ Metrics/MethodLength:
55
+ Enabled: false
56
+
57
+ Metrics/ParameterLists:
58
+ Enabled: false
59
+
60
+ Metrics/PerceivedComplexity:
61
+ Enabled: false
62
+
63
+ RSpec/MultipleExpectations:
64
+ Enabled: false
65
+
66
+ RSpec/MultipleMemoizedHelpers:
67
+ Enabled: false
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.0.5
1
+ 3.3.5
data/Gemfile CHANGED
@@ -14,3 +14,6 @@ gem 'rspec'
14
14
  gem 'rubocop'
15
15
  gem 'rubocop-rake'
16
16
  gem 'rubocop-rspec'
17
+
18
+ gem 'base64', '~> 0.3.0'
19
+ gem 'benchmark', '~> 0.5.0'
data/afterlife.gemspec CHANGED
@@ -10,7 +10,7 @@ Gem::Specification.new do |spec|
10
10
 
11
11
  spec.summary = 'Devops utils'
12
12
  spec.description = 'Afterlife helps you setup your development environment and deploy code easily'
13
- spec.required_ruby_version = '>= 2.6'
13
+ spec.required_ruby_version = '>= 3.3'
14
14
 
15
15
  spec.metadata['source_code_uri'] = 'https://bitbucket.org/volabit/afterlife'
16
16
  spec.metadata['changelog_uri'] = 'https://bitbucket.org/volabit/afterlife/src/master/CHANGELOG.md'
@@ -29,6 +29,7 @@ Gem::Specification.new do |spec|
29
29
  spec.require_paths = ['lib']
30
30
 
31
31
  spec.add_dependency 'faraday'
32
+ spec.add_dependency 'ostruct'
32
33
  spec.add_dependency 'racc' # rubocop dependency, cannot run `bundle exec rubocop` without it
33
34
  spec.add_dependency 'thor'
34
35
  spec.add_dependency 'zeitwerk'
data/config.ru CHANGED
@@ -3,8 +3,7 @@
3
3
  require 'logger'
4
4
  require 'rack/directory'
5
5
 
6
- logger_file = File.open(Afterlife::Cdn.log_path, 'a')
7
- logger_file.sync = true
6
+ logger = Logger.new(Afterlife::Cdn.log_path)
8
7
 
9
8
  class AssetHeaders
10
9
  def initialize(app)
@@ -19,7 +18,7 @@ class AssetHeaders
19
18
  end
20
19
 
21
20
  use AssetHeaders
22
- use Rack::CommonLogger, Logger.new(logger_file)
21
+ use Rack::CommonLogger, logger
23
22
  run do |env|
24
23
  Rack::Directory.new(Afterlife::Cdn.local_path).call(env)
25
24
  end
@@ -6,9 +6,9 @@ module Afterlife
6
6
  module_function
7
7
 
8
8
  DEFAULT_PRERELEASE_NAME = 'rc'
9
- VERSION_REGEX = /(\d+\.\d+\.\d+(?:-(?:.+))?)/.freeze
9
+ VERSION_REGEX = /(\d+\.\d+\.\d+(?:-(?:.+))?)/
10
10
 
11
- def calculate_next(part, pre = nil) # rubocop:disable Metrics/MethodLength
11
+ def calculate_next(part, pre = nil)
12
12
  x, y, z = release
13
13
  case part.to_sym
14
14
  when :major
@@ -6,6 +6,7 @@ module Afterlife
6
6
  module Cdn
7
7
  class Cli < Thor
8
8
  include BaseCli
9
+
9
10
  def self.exit_on_failure?
10
11
  true
11
12
  end
data/lib/afterlife/cli.rb CHANGED
@@ -5,6 +5,7 @@ require 'thor'
5
5
  module Afterlife
6
6
  class Cli < Thor
7
7
  include BaseCli
8
+
8
9
  def self.exit_on_failure?
9
10
  true
10
11
  end
@@ -50,6 +51,9 @@ module Afterlife
50
51
  desc 'clickup', 'ClickUp integration commands'
51
52
  subcommand 'clickup', Clickup::Cli
52
53
 
54
+ desc 'propagate', 'Propagate commands'
55
+ subcommand 'propagate', Propagate::Cli
56
+
53
57
  map %w[-v --version] => :version
54
58
  desc 'version', 'Prints afterlife version'
55
59
  def version
@@ -37,10 +37,23 @@ module Afterlife
37
37
  desc: 'Force update status of tasks even if they would not be updated due to status precedence'
38
38
  option :dry_run, type: :boolean, default: false, aliases: '-d',
39
39
  desc: 'Do not actually update the status of the tasks, just print what would be done'
40
- def update_status(target_status, *task_ids)
40
+ option :cherry_pick, type: :boolean, default: false, aliases: '-c',
41
+ desc: 'Include cherry pick status in the output'
42
+ option :verbose, type: :boolean, default: true, aliases: '-v',
43
+ desc: 'Print skipped tasks'
44
+ option :commits, type: :string,
45
+ desc: 'Commit messages used to detect reverted tasks'
46
+ def update_status(target_status, *task_ids) # rubocop:disable Metrics/AbcSize
41
47
  task_ids = $stdin.read.strip.split("\n") if task_ids.empty?
42
48
 
43
- UpdateTaskStatus.call(task_ids, target_status, force: options[:force], dry_run: options[:dry_run])
49
+ UpdateTaskStatus.call(
50
+ task_ids, target_status,
51
+ force: options[:force],
52
+ dry_run: options[:dry_run],
53
+ cherry_pick: options[:cherry_pick],
54
+ verbose: options[:verbose],
55
+ commits: options[:commits],
56
+ )
44
57
  rescue StandardError => e
45
58
  fatal!(e.message)
46
59
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Afterlife
4
+ module Clickup
5
+ class CommitInputSplitter
6
+ SUBJECT_PATTERN = /\A(?:revert\b|\w+(?:\(.+\))?!?:\s|.+#(?:CU[-_])?[a-z0-9]{8,})/i
7
+
8
+ def self.call(input)
9
+ new(input).call
10
+ end
11
+
12
+ def initialize(input)
13
+ @input = input.to_s.strip
14
+ end
15
+
16
+ def call
17
+ return [] if input.empty?
18
+
19
+ return split_github_input if input.include?('\\n')
20
+
21
+ split_real_newline_input
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :input
27
+
28
+ def split_github_input
29
+ input.lines(chomp: true).filter_map do |line|
30
+ line = line.strip
31
+ line.gsub('\\n', "\n") unless line.empty?
32
+ end
33
+ end
34
+
35
+ def split_real_newline_input
36
+ commits = []
37
+ current = []
38
+
39
+ input.lines(chomp: true).each do |line|
40
+ if subject?(line) && current.any?
41
+ commits << normalize(current)
42
+ current = []
43
+ end
44
+
45
+ current << line
46
+ end
47
+
48
+ commits << normalize(current) if current.any?
49
+ commits.reject(&:empty?)
50
+ end
51
+
52
+ def subject?(line)
53
+ return false if line.empty? || line.start_with?('*', '-', ' ')
54
+ return false if line.match?(/\AThis reverts commit\b/i)
55
+
56
+ line.match?(SUBJECT_PATTERN)
57
+ end
58
+
59
+ def normalize(lines)
60
+ lines.join("\n").strip
61
+ end
62
+ end
63
+ end
64
+ end
@@ -10,7 +10,7 @@ module Afterlife
10
10
  /CU-([a-z0-9]+)/i,
11
11
  ].freeze
12
12
 
13
- def self.call(text, convert_to_link = false) # rubocop:disable Metrics/MethodLength
13
+ def self.call(text, convert_to_link = false)
14
14
  ids = Set.new
15
15
 
16
16
  ID_PATTERNS.each do |pattern|
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Afterlife
4
+ module Clickup
5
+ class NextTaskStatusDecider
6
+ REVERT_SOURCE_STATUS = 'ready for qa'
7
+ REVERT_TARGET_STATUS = 'needs rework'
8
+
9
+ def self.call(**args)
10
+ new(**args).call
11
+ end
12
+
13
+ def initialize(clickup_task:, target_status:, force: false, reverted: false)
14
+ @clickup_task = clickup_task
15
+ @target_status = target_status
16
+ @force = force
17
+ @reverted = reverted
18
+ @precedence_checker = StatusPrecedenceChecker.new
19
+ end
20
+
21
+ def call
22
+ return if current_status.nil?
23
+
24
+ return reverted_status if reverted
25
+
26
+ skip_update? ? nil : target_status
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :clickup_task, :target_status, :force, :reverted, :precedence_checker
32
+
33
+ def current_status
34
+ @current_status ||= clickup_task&.dig('status', 'status')&.downcase
35
+ end
36
+
37
+ def reverted_status
38
+ REVERT_TARGET_STATUS if current_status == REVERT_SOURCE_STATUS
39
+ end
40
+
41
+ def skip_update?
42
+ !force && precedence_checker.should_skip_update?(current_status, target_status)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -14,7 +14,9 @@ module Afterlife
14
14
  'trunk merged',
15
15
  'ready for qa',
16
16
  'validated qa',
17
+ 'deployed',
17
18
  'completed',
19
+ 'closed',
18
20
  ].freeze
19
21
 
20
22
  def should_skip_update?(current_status, target_status)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Afterlife
4
+ module Clickup
5
+ class TaskRevertDetector
6
+ def initialize(commits)
7
+ @commits = CommitInputSplitter.call(commits)
8
+ end
9
+
10
+ def reverted?(task_id)
11
+ reverted = false
12
+
13
+ @commits.each do |commit|
14
+ next unless commit.match?(/#?(?:CU[-_])?#{Regexp.escape(task_id)}/i)
15
+
16
+ reverted = revert_count(commit, task_id).odd?
17
+ end
18
+
19
+ reverted
20
+ end
21
+
22
+ private
23
+
24
+ def revert_count(line, task_id)
25
+ before_task_id = line.split(/#?(?:CU[-_])?#{Regexp.escape(task_id)}/i, 2).first
26
+ before_task_id.scan(/\brevert\b/i).count
27
+ end
28
+ end
29
+ end
30
+ end
@@ -3,50 +3,101 @@
3
3
  module Afterlife
4
4
  module Clickup
5
5
  class UpdateTaskStatus
6
- attr_reader :target_status, :precedence_checker, :api, :force, :dry_run
6
+ attr_reader :api, :force, :dry_run, :cherry_pick, :verbose, :revert_detector, :slack_output_path
7
7
 
8
- def self.call(task_ids, target_status, force: false, dry_run: false)
9
- new(force: force, dry_run: dry_run).call(task_ids, target_status)
8
+ def self.call(
9
+ task_ids, target_status, force: false, dry_run: false, cherry_pick: false, verbose: true, commits: nil
10
+ )
11
+ new(force:, dry_run:, cherry_pick:, verbose:, commits:).call(task_ids, target_status)
10
12
  end
11
13
 
12
- def initialize(force: false, dry_run: false)
14
+ def initialize(force: false, dry_run: false, cherry_pick: false, verbose: true, commits: nil)
13
15
  @force = force
14
16
  @dry_run = dry_run
17
+ @cherry_pick = cherry_pick
18
+ @verbose = verbose
19
+ @slack_output_path = ENV.fetch('AFTERLIFE_SLACK_OUTPUT', nil)
20
+ write_slack_output('') if slack_output_path
21
+ @revert_detector = TaskRevertDetector.new(commits)
15
22
  token = ENV['CLICKUP_TOKEN'] || fail('CLICKUP_TOKEN required, put `export CLICKUP_TOKEN=...` or the equivalent in your shell') # rubocop:disable Layout/LineLength
16
- @api = Api.new(token: token)
17
- @precedence_checker = StatusPrecedenceChecker.new
23
+ @api = Api.new(token:)
18
24
  end
19
25
 
20
- def call(task_ids, target_status) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
26
+ def call(task_ids, target_status) # rubocop:disable Metrics/AbcSize
21
27
  if task_ids.empty?
22
- puts 'No tasks to update'
28
+ log 'No tasks to update'
23
29
  return []
24
30
  end
25
31
 
26
- puts 'WARNING: Forcing status updates...' if force
27
- puts 'WARNING: Dry run, no tasks will be updated' if dry_run
28
- puts "Updating #{task_ids.length} tasks to '#{target_status}'"
32
+ log 'WARNING: Forcing status updates...' if force
33
+ log 'WARNING: Dry run, no tasks will be updated' if dry_run
34
+ log "#{dry_run ? 'Would update' : 'Updating'} #{task_ids.length} tasks to '#{target_status}'"
29
35
  moved_task_ids = task_ids.map { |task_id| move_task(task_id, target_status) }.compact
30
- puts "#{dry_run ? 'Would have moved' : 'Moved'} #{moved_task_ids.length} tasks:"
36
+ log "#{dry_run ? 'Would have moved' : 'Moved'} #{moved_task_ids.length} tasks:"
31
37
  moved_task_ids.each do |task|
32
- puts "- #{task[:link]} (from '#{task[:previous_status]}' to '#{task[:new_status]}')"
38
+ cherry_pick_status = " - Cherry Pick: #{task[:is_cherry_pick] ? '' : '❌'}" if cherry_pick
39
+ log "- #{task[:link]} (from '#{task[:previous_status]}' to '#{task[:new_status]}')#{cherry_pick_status}"
33
40
  end
34
41
  moved_task_ids
35
42
  end
36
43
 
37
44
  private
38
45
 
39
- def move_task(task_id, target_status)
40
- current_status = api.get_task(task_id).dig('status', 'status')&.downcase
46
+ def move_task(task_id, target_status) # rubocop:disable Metrics/AbcSize
47
+ task = api.get_task(task_id)
48
+ current_status = task&.dig('status', 'status')&.downcase
49
+ if current_status.nil?
50
+ log "WARNING: Task #{task_id} not found", verbose: true
51
+ return
52
+ end
41
53
 
42
- return puts "WARNING: Task #{task_id} not found" if current_status.nil?
54
+ is_cherry_pick = Array(task['custom_fields']).any? do |field|
55
+ field['name'].match?(/cherry pick/i) && [true, 'true'].include?(field['value'])
56
+ end
43
57
 
44
- should_skip_update = !force && precedence_checker.should_skip_update?(current_status, target_status)
45
58
  task_link = "https://app.clickup.com/t/#{task_id}"
46
- return puts "Skipping task #{task_link} because it's already at '#{current_status}'" if should_skip_update
59
+ next_status = next_status_for(task_id, task, target_status)
60
+ return if next_status.nil?
61
+
62
+ api.update_task(task_id, { status: next_status }) unless dry_run
63
+
64
+ {
65
+ id: task_id,
66
+ link: task_link,
67
+ previous_status: current_status,
68
+ new_status: next_status,
69
+ is_cherry_pick:,
70
+ }
71
+ end
72
+
73
+ def next_status_for(task_id, task, target_status)
74
+ current_status = task.dig('status', 'status')&.downcase
75
+ reverted = revert_detector.reverted?(task_id)
76
+ next_status = NextTaskStatusDecider.call(clickup_task: task, target_status:, force:, reverted:)
77
+
78
+ if reverted && next_status
79
+ log "Moving task https://app.clickup.com/t/#{task_id} to '#{next_status}': reverted in pushed commits"
80
+ elsif !reverted && next_status.nil?
81
+ log(
82
+ "Skipping task https://app.clickup.com/t/#{task_id} because it's already at '#{current_status}'",
83
+ verbose: true,
84
+ )
85
+ end
86
+
87
+ next_status
88
+ end
89
+
90
+ def log(message, verbose: false)
91
+ return if verbose && !@verbose
92
+
93
+ puts message
94
+ write_slack_output("#{message}\n", mode: 'a') if !verbose && slack_output_path
95
+ end
47
96
 
48
- api.update_task(task_id, { status: target_status }) unless dry_run
49
- { id: task_id, link: task_link, previous_status: current_status, new_status: target_status }
97
+ def write_slack_output(message, mode: 'w')
98
+ File.write(slack_output_path, message, mode:)
99
+ rescue SystemCallError
100
+ nil
50
101
  end
51
102
  end
52
103
  end
@@ -4,6 +4,7 @@ module Afterlife
4
4
  module Config
5
5
  class Cli < Thor
6
6
  include BaseCli
7
+
7
8
  def self.exit_on_failure?
8
9
  true
9
10
  end
@@ -11,6 +11,7 @@ module Afterlife
11
11
  CLOUD_PROFILES = {
12
12
  staging: PROFILE_TESTING,
13
13
  qa: PROFILE_TESTING,
14
+ dev: PROFILE_TESTING,
14
15
  sandbox: PROFILE_PRODUCTION,
15
16
  production: PROFILE_PRODUCTION,
16
17
  }.freeze
@@ -19,7 +20,7 @@ module Afterlife
19
20
  config.key?(sym) || super
20
21
  end
21
22
 
22
- def method_missing(sym, *args, &block)
23
+ def method_missing(sym, *args, &)
23
24
  config.key?(sym) ? config[sym] : super
24
25
  end
25
26
 
@@ -4,6 +4,7 @@ module Afterlife
4
4
  module Deploy
5
5
  class Cli < Thor
6
6
  include BaseCli
7
+
7
8
  def self.exit_on_failure?
8
9
  true
9
10
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Afterlife
4
+ module Deploy
5
+ class DockerDeployment < Deployment
6
+ def setup
7
+ Afterlife.current_repo.env.set('PLATFORM' => 'linux/amd64') if deploy_stage?
8
+
9
+ Afterlife.current_repo.env.set!(
10
+ 'REGISTRY' => registry,
11
+ 'TAG' => tag,
12
+ )
13
+ end
14
+
15
+ def run
16
+ Exec.run(commands)
17
+ end
18
+
19
+ def confirmation_message
20
+ 'You are about to deploy the current directory'
21
+ end
22
+
23
+ private
24
+
25
+ def commands
26
+ [
27
+ authenticate_command,
28
+ build_command,
29
+ ].flatten.compact
30
+ end
31
+
32
+ # commands
33
+
34
+ def authenticate_command
35
+ return if options['no-auth'] || local_stage?
36
+
37
+ AwsAuth.new(registry).commands
38
+ end
39
+
40
+ def build_command
41
+ return if options['no-build']
42
+
43
+ <<-BASH
44
+ docker buildx bake --allow=ssh -f docker-bake.hcl #{targets.join(' ')} #{local_stage? ? '--load' : '--push'}
45
+ BASH
46
+ end
47
+
48
+ def targets
49
+ repo.conf.dig(:deploy, :targets) || %w[app]
50
+ end
51
+
52
+ def tag
53
+ repo.conf.dig(:deploy, :tag) || repo.current_revision
54
+ end
55
+
56
+ # utils
57
+
58
+ def local_stage?
59
+ Afterlife.current_stage.name.to_sym == :local
60
+ end
61
+
62
+ def deploy_stage?
63
+ %i[qa production staging dev].include?(Afterlife.current_stage.name.to_sym)
64
+ end
65
+
66
+ # Priority:
67
+ # 1. Uses AFTERLIFE_DEPLOY_REGISTRY from the ENV if it's defined. It can be
68
+ # either in real system ENV, or defined in the env section of the repo
69
+ # .afterlife.yml config
70
+ # 2. Uses repo .afterlife.yml config at deploy.registry
71
+ # 3. Uses stages.$current_stage.registry if exists in ~/.afterlife/config.yml
72
+ def registry
73
+ @registry ||= Afterlife.current_repo.variable('deploy.registry') || Afterlife.current_stage.registry
74
+ end
75
+
76
+ class AwsAuth
77
+ attr_reader :registry
78
+
79
+ def initialize(registry)
80
+ @registry = registry
81
+ end
82
+
83
+ def commands
84
+ [docker_login]
85
+ end
86
+
87
+ def docker_login
88
+ <<-BASH
89
+ echo "#{aws_ecr_token}" | docker login --username AWS --password-stdin #{registry}
90
+ BASH
91
+ end
92
+
93
+ def aws_ecr_token
94
+ @aws_ecr_token ||= Exec.result("aws ecr get-login-password --region #{region}")
95
+ end
96
+
97
+ def region
98
+ @region ||= registry.gsub(/[^.]+\.dkr\.ecr\.([^.]+)\.amazonaws\.com/, '\1')
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -2,16 +2,7 @@
2
2
 
3
3
  module Afterlife
4
4
  module Deploy
5
- class KubernetesDeployment < Deployment
6
- def setup
7
- Afterlife.current_repo.env.set('PLATFORM' => 'linux/amd64') if deploy_stage?
8
-
9
- Afterlife.current_repo.env.set!(
10
- 'REGISTRY' => registry,
11
- 'TAG' => repo.current_revision,
12
- )
13
- end
14
-
5
+ class KubernetesDeployment < DockerDeployment
15
6
  def run
16
7
  Exec.run(commands)
17
8
  end
@@ -24,7 +15,9 @@ module Afterlife
24
15
 
25
16
  def commands
26
17
  [
18
+ # from DockerDeployment
27
19
  authenticate_command,
20
+ # from DockerDeployment
28
21
  build_command,
29
22
  set_image_command,
30
23
  apply_kubernetes_settings,
@@ -33,20 +26,6 @@ module Afterlife
33
26
 
34
27
  # commands
35
28
 
36
- def authenticate_command
37
- return if options['no-auth'] || local_stage?
38
-
39
- AwsAuth.new(registry).commands
40
- end
41
-
42
- def build_command
43
- return if options['no-build']
44
-
45
- <<-BASH
46
- docker buildx bake --allow=ssh -f docker-bake.hcl #{targets.join(' ')} #{local_stage? ? '--load' : '--push'}
47
- BASH
48
- end
49
-
50
29
  def set_image_command
51
30
  <<-BASH
52
31
  cd #{kubelocation} &&
@@ -58,10 +37,6 @@ module Afterlife
58
37
  BASH
59
38
  end
60
39
 
61
- def targets
62
- repo.conf.dig(:deploy, :targets) || %w[app]
63
- end
64
-
65
40
  def registry_image_name(target)
66
41
  "#{registry}/#{full_image_name(target)}:#{repo.current_revision}"
67
42
  end
@@ -82,6 +57,7 @@ module Afterlife
82
57
  end
83
58
 
84
59
  def assert_correct_context
60
+ return if Afterlife.current_stage.name == 'local'
85
61
  return if current_context == expected_context
86
62
 
87
63
  fail Error, "kubectl context should be '#{expected_context}' but you are in '#{current_context}'"
@@ -97,14 +73,6 @@ module Afterlife
97
73
 
98
74
  # utils
99
75
 
100
- def local_stage?
101
- Afterlife.current_stage.name.to_sym == :local
102
- end
103
-
104
- def deploy_stage?
105
- %i[qa production staging].include?(Afterlife.current_stage.name.to_sym)
106
- end
107
-
108
76
  def kubelocation
109
77
  ".afterlife/#{Afterlife.current_stage.name}"
110
78
  end
@@ -114,42 +82,6 @@ module Afterlife
114
82
  fail Error, 'deploy.image_name for kubernetes deployments' unless result
115
83
  end
116
84
  end
117
-
118
- # Priority:
119
- # 1. Uses AFTERLIFE_DEPLOY_REGISTRY from the ENV if it's defined. It can be
120
- # either in real system ENV, or defined in the env section of the repo
121
- # .afterlife.yml config
122
- # 2. Uses repo .afterlife.yml config at deploy.registry
123
- # 3. Uses stages.$current_stage.registry if exists in ~/.afterlife/config.yml
124
- def registry
125
- @registry ||= Afterlife.current_repo.variable('deploy.registry') || Afterlife.current_stage.registry
126
- end
127
-
128
- class AwsAuth
129
- attr_reader :registry
130
-
131
- def initialize(registry)
132
- @registry = registry
133
- end
134
-
135
- def commands
136
- [docker_login]
137
- end
138
-
139
- def docker_login
140
- <<-BASH
141
- echo "#{aws_ecr_token}" | docker login --username AWS --password-stdin #{registry}
142
- BASH
143
- end
144
-
145
- def aws_ecr_token
146
- @aws_ecr_token ||= Exec.result("aws ecr get-login-password --region #{region}")
147
- end
148
-
149
- def region
150
- @region ||= registry.gsub(/[^.]+\.dkr\.ecr\.([^.]+)\.amazonaws\.com/, '\1')
151
- end
152
- end
153
85
  end
154
86
  end
155
87
  end
@@ -8,6 +8,7 @@ module Afterlife
8
8
  cdn: 'CdnDeployment',
9
9
  cdn_stenciljs: 'CdnStenciljsDeployment',
10
10
  custom: 'CustomDeployment',
11
+ docker: 'DockerDeployment',
11
12
  kubernetes: 'KubernetesDeployment',
12
13
  }.freeze
13
14
 
@@ -28,8 +28,8 @@ module Afterlife
28
28
  def send_request(repository, branch)
29
29
  uri = URI(notification_url)
30
30
  body = {
31
- branch: branch,
32
- repository: repository,
31
+ branch:,
32
+ repository:,
33
33
  }
34
34
  headers = { 'Content-Type': 'application/json' }
35
35
  Net::HTTP.post(uri, body.to_json, headers)
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Afterlife
4
+ module Propagate
5
+ class Cli < Thor
6
+ include BaseCli
7
+
8
+ def self.exit_on_failure?
9
+ true
10
+ end
11
+
12
+ desc 'propagate-qa-to-sandbox', 'Propagate QA branch to Sandbox branch (fetch, reset, push)'
13
+ option :yes, type: :boolean, aliases: '-y',
14
+ desc: 'Skip confirmation prompt'
15
+ def propagate_qa_to_sandbox
16
+ confirmation_message = <<~MSG
17
+ This command will perform the following actions:
18
+
19
+ 1. Fetch the latest changes from origin/qa
20
+ 2. Switch to the sandbox branch
21
+ 3. Reset sandbox branch to match origin/qa exactly
22
+ 4. Force push the changes to origin/sandbox
23
+ 5. Return to your original branch
24
+
25
+ This operation will completely overwrite the sandbox branch with the current state of QA.
26
+ Are you sure you want to proceed?
27
+ MSG
28
+
29
+ sure?(confirmation_message) unless options[:yes]
30
+
31
+ PropagateQaToSandbox.call
32
+ rescue StandardError => e
33
+ fatal!(e.message)
34
+ rescue Interrupt
35
+ log_interrupted
36
+ end
37
+
38
+ private
39
+
40
+ def repo
41
+ Afterlife.current_repo
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Afterlife
4
+ module Propagate
5
+ class PropagateQaToSandbox
6
+ def self.call(repo_path = nil)
7
+ new(repo_path).call
8
+ end
9
+
10
+ def initialize(repo_path = nil)
11
+ @repo_path = repo_path || Afterlife.current_repo.full_path
12
+ @original_branch = nil
13
+ end
14
+
15
+ def call
16
+ log_info "Starting QA to Sandbox propagation in #{@repo_path}"
17
+
18
+ @original_branch = current_branch
19
+ log_info "Current branch: #{@original_branch}"
20
+
21
+ fetch_qa_branch
22
+ switch_to_sandbox
23
+ reset_sandbox_to_qa
24
+ push_sandbox_changes
25
+
26
+ log_success 'Successfully propagated QA to Sandbox'
27
+
28
+ return_to_original_branch if @original_branch != 'sandbox'
29
+ rescue StandardError => e
30
+ log_error "Error during propagation: #{e.message}"
31
+ return_to_original_branch if @original_branch && @original_branch != current_branch
32
+ raise
33
+ end
34
+
35
+ private
36
+
37
+ def fetch_qa_branch
38
+ log_info 'Fetching latest changes from remote...'
39
+ `cd #{@repo_path} && git fetch origin qa`
40
+ fail 'Failed to fetch origin/qa' unless $CHILD_STATUS.success?
41
+
42
+ log_success 'Successfully fetched origin/qa'
43
+ end
44
+
45
+ def switch_to_sandbox
46
+ log_info 'Switching to sandbox branch...'
47
+ `cd #{@repo_path} && git checkout sandbox`
48
+ fail 'Failed to switch to sandbox branch' unless $CHILD_STATUS.success?
49
+
50
+ log_success 'Successfully switched to sandbox branch'
51
+ end
52
+
53
+ def reset_sandbox_to_qa
54
+ log_info 'Resetting sandbox to origin/qa...'
55
+ `cd #{@repo_path} && git reset --hard origin/qa`
56
+ fail 'Failed to reset sandbox to origin/qa' unless $CHILD_STATUS.success?
57
+
58
+ log_success 'Successfully reset sandbox to origin/qa'
59
+ end
60
+
61
+ def push_sandbox_changes
62
+ log_info 'About to push changes to remote sandbox...'
63
+ log_info 'This will force push and overwrite the remote sandbox branch'
64
+
65
+ unless yes?('Do you want to continue? [y/n]')
66
+ log_info 'Push cancelled by user. Reverting changes...'
67
+ revert_sandbox_changes
68
+ return
69
+ end
70
+
71
+ log_info 'Pushing changes to remote sandbox...'
72
+ `cd #{@repo_path} && git push origin sandbox --force-with-lease`
73
+ fail 'Failed to push to origin/sandbox' unless $CHILD_STATUS.success?
74
+
75
+ create_and_push_tag
76
+
77
+ log_success 'Successfully pushed to origin/sandbox'
78
+ end
79
+
80
+ def revert_sandbox_changes
81
+ log_info 'Reverting sandbox to previous state...'
82
+ `cd #{@repo_path} && git reset --hard HEAD@{1}`
83
+ if $CHILD_STATUS.success?
84
+ log_success 'Successfully reverted sandbox changes'
85
+ else
86
+ log_error 'Failed to revert changes. Manual intervention may be required.'
87
+ fail 'Failed to revert sandbox changes'
88
+ end
89
+ end
90
+
91
+ def return_to_original_branch
92
+ log_info "Returning to original branch: #{@original_branch}"
93
+ `cd #{@repo_path} && git checkout #{@original_branch}`
94
+ fail "Failed to return to original branch #{@original_branch}" unless $CHILD_STATUS.success?
95
+
96
+ log_success "Successfully returned to #{@original_branch}"
97
+ end
98
+
99
+ def current_branch
100
+ `cd #{@repo_path} && git rev-parse --abbrev-ref HEAD`.chomp
101
+ end
102
+
103
+ def create_and_push_tag
104
+ tag_name = generate_tag_name
105
+ log_info "Creating tag: #{tag_name}"
106
+
107
+ `cd #{@repo_path} && git tag #{tag_name}`
108
+ fail "Failed to create tag #{tag_name}" unless $CHILD_STATUS.success?
109
+
110
+ log_info 'Pushing tag to remote...'
111
+ `cd #{@repo_path} && git push origin #{tag_name}`
112
+ fail "Failed to push tag #{tag_name}" unless $CHILD_STATUS.success?
113
+
114
+ log_success "Successfully created and pushed tag: #{tag_name}"
115
+ end
116
+
117
+ def generate_tag_name
118
+ now = Time.now
119
+ year = now.year.to_s[2..3] # Last two digits of year
120
+ month = format('%02d', now.month)
121
+ day = format('%02d', now.day)
122
+ "#{year}#{month}#{day}"
123
+ end
124
+
125
+ def log_info(message)
126
+ puts "\e[36m#{message}\e[0m"
127
+ end
128
+
129
+ def log_success(message)
130
+ puts "\e[32m#{message}\e[0m"
131
+ end
132
+
133
+ def log_error(message)
134
+ puts "\e[31m#{message}\e[0m"
135
+ end
136
+
137
+ def fail(message)
138
+ fail StandardError, message
139
+ end
140
+
141
+ def yes?(question)
142
+ print "#{question} "
143
+ response = $stdin.gets.chomp.downcase
144
+ %w[y yes].include?(response)
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Afterlife
4
+ module Propagate
5
+ end
6
+ end
@@ -3,7 +3,7 @@
3
3
  module Afterlife
4
4
  module Release
5
5
  class ChangeVersion
6
- RUBY_REGEXP = /VERSION\s*=\s*['"]#{Bump::Semver::VERSION_REGEX}['"]/.freeze
6
+ RUBY_REGEXP = /VERSION\s*=\s*['"]#{Bump::Semver::VERSION_REGEX}['"]/
7
7
  REGEXPS = {
8
8
  ruby: RUBY_REGEXP,
9
9
  gem: RUBY_REGEXP,
@@ -26,7 +26,7 @@ module Afterlife
26
26
  assert_git_dependency
27
27
  fatal! "Part '#{part}' is not allowed" unless CreateHelper::PARTS.include?(part.to_sym)
28
28
 
29
- CreateHelper.call(self, options.merge(part: part))
29
+ CreateHelper.call(self, options.merge(part:))
30
30
  end
31
31
 
32
32
  desc 'hotfix', 'create a hotfix branch'
@@ -19,7 +19,7 @@ module Afterlife
19
19
  end
20
20
 
21
21
  def call
22
- confirm_creation unless cli.options[:yes]
22
+ creation_prompt unless cli.options[:yes]
23
23
  make_assertions unless cli.options[:force]
24
24
  create_branch
25
25
  ChangeVersion.call(new_version)
@@ -49,10 +49,11 @@ module Afterlife
49
49
  cli.fatal! 'Local and remote are not on the same commit'
50
50
  end
51
51
 
52
- def confirm_creation
52
+ def creation_prompt
53
53
  cli.sure? 'You are about to create the branch ' \
54
54
  "#{cli.set_color(branch_name, :bold)} " \
55
55
  "from #{cli.set_color(from, :bold)}"
56
+ nil
56
57
  end
57
58
 
58
59
  def create_branch
@@ -4,20 +4,21 @@ module Afterlife
4
4
  module Release
5
5
  class PreHelper < Helper
6
6
  def call
7
- confirm_creation
7
+ creation_prompt
8
8
  check_tag_does_not_exist
9
9
  commit
10
10
  create_tag
11
11
  push_tag if cli.options[:push]
12
12
  end
13
13
 
14
- def confirm_creation
14
+ def creation_prompt
15
15
  return if cli.options[:yes]
16
16
 
17
- cli.sure? 'You are about to create ' \
18
- "#{cli.options[:push] ? '(and push) ' : ''}" \
17
+ action = cli.options[:push] ? '(and push) ' : ''
18
+ cli.sure? "You are about to create #{action}" \
19
19
  "the tag #{cli.set_color(tag, :bold)} " \
20
20
  'in the current commit'
21
+ nil
21
22
  end
22
23
 
23
24
  def check_tag_does_not_exist
@@ -5,24 +5,24 @@ module Afterlife
5
5
  # Reads configuration from the repo,
6
6
  # either from YML or from the repo instance methods
7
7
  class Config
8
- Error = Class.new StandardError
8
+ class Error < StandardError; end
9
9
 
10
10
  attr_reader :path
11
11
 
12
- def self.read(path, &block)
13
- new(path).read(&block)
12
+ def self.read(path, &)
13
+ new(path).read(&)
14
14
  end
15
15
 
16
16
  def initialize(path)
17
17
  @path = path
18
18
  end
19
19
 
20
- def read(&block) # rubocop:disable Metrics/MethodLength
20
+ def read(&)
21
21
  case from_yml
22
22
  when Hash
23
23
  yield(JSON.generate(from_yml))
24
24
  when Array
25
- from_yml.each(&block)
25
+ from_yml.each(&)
26
26
  when String, Symbol
27
27
  yield(from_yml)
28
28
  else
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Afterlife
4
- VERSION = '1.7.4'
4
+ VERSION = '1.9.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: afterlife
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.7.4
4
+ version: 1.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Genaro Madrid
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-07-30 00:00:00.000000000 Z
11
+ date: 2026-07-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: ostruct
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: racc
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -97,9 +111,12 @@ files:
97
111
  - lib/afterlife/clickup.rb
98
112
  - lib/afterlife/clickup/api.rb
99
113
  - lib/afterlife/clickup/cli.rb
114
+ - lib/afterlife/clickup/commit_input_splitter.rb
100
115
  - lib/afterlife/clickup/get_ids_from_text.rb
101
116
  - lib/afterlife/clickup/get_range_commits.rb
117
+ - lib/afterlife/clickup/next_task_status_decider.rb
102
118
  - lib/afterlife/clickup/status_precedence_checker.rb
119
+ - lib/afterlife/clickup/task_revert_detector.rb
103
120
  - lib/afterlife/clickup/update_task_status.rb
104
121
  - lib/afterlife/config.rb
105
122
  - lib/afterlife/config/cli.rb
@@ -110,10 +127,14 @@ files:
110
127
  - lib/afterlife/deploy/cli.rb
111
128
  - lib/afterlife/deploy/custom_deployment.rb
112
129
  - lib/afterlife/deploy/deployment.rb
130
+ - lib/afterlife/deploy/docker_deployment.rb
113
131
  - lib/afterlife/deploy/kubernetes_deployment.rb
114
132
  - lib/afterlife/environment.rb
115
133
  - lib/afterlife/exec.rb
116
134
  - lib/afterlife/notify.rb
135
+ - lib/afterlife/propagate.rb
136
+ - lib/afterlife/propagate/cli.rb
137
+ - lib/afterlife/propagate/propagate_qa_to_sandbox.rb
117
138
  - lib/afterlife/release.rb
118
139
  - lib/afterlife/release/change_version.rb
119
140
  - lib/afterlife/release/cli.rb
@@ -140,7 +161,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
140
161
  requirements:
141
162
  - - ">="
142
163
  - !ruby/object:Gem::Version
143
- version: '2.6'
164
+ version: '3.3'
144
165
  required_rubygems_version: !ruby/object:Gem::Requirement
145
166
  requirements:
146
167
  - - ">="