Class: Discharger::Task
- Inherits:
-
Rake::TaskLib
- Object
- Rake::TaskLib
- Discharger::Task
- Defined in:
- lib/discharger/task.rb
Instance Attribute Summary collapse
-
#app_name ⇒ Object
Returns the value of attribute app_name.
-
#auto_deploy_staging ⇒ Object
Returns the value of attribute auto_deploy_staging.
-
#chat_token ⇒ Object
Returns the value of attribute chat_token.
-
#clear_fragments ⇒ Object
Returns the value of attribute clear_fragments.
-
#commit_identifier ⇒ Object
Returns the value of attribute commit_identifier.
-
#description ⇒ Object
Returns the value of attribute description.
-
#fragment ⇒ Object
Returns the value of attribute fragment.
-
#fragment_directory ⇒ Object
Returns the value of attribute fragment_directory.
-
#last_message_ts ⇒ Object
readonly
Returns the value of attribute last_message_ts.
-
#name ⇒ Object
Returns the value of attribute name.
-
#production_branch ⇒ Object
Returns the value of attribute production_branch.
-
#pull_request_url ⇒ Object
Returns the value of attribute pull_request_url.
-
#release_message_channel ⇒ Object
Returns the value of attribute release_message_channel.
-
#staging_branch ⇒ Object
Returns the value of attribute staging_branch.
-
#version_constant ⇒ Object
Returns the value of attribute version_constant.
-
#working_branch ⇒ Object
Returns the value of attribute working_branch.
Class Method Summary collapse
Instance Method Summary collapse
- #define ⇒ Object
- #existing_pr_number(base, head) ⇒ Object
- #git_local_sha(branch) ⇒ Object
- #git_show_version(branch) ⇒ Object
- #git_version_file_commit(branch) ⇒ Object
-
#initialize(name = :release, tasker: Rake::Task) ⇒ Task
constructor
A new instance of Task.
- #pr_already_merged?(pr_ref) ⇒ Boolean
-
#syscall(*steps, output: $stdout, error: $stderr) ⇒ Boolean
Run a multiple system commands and return true if all commands succeed If any command fails, the method will return false and stop executing any further commands.
- #sysecho(message, output: $stdout) ⇒ Object
-
#validate_release_commit!(branch, output: $stdout) ⇒ Object
Abort if HEAD is not the commit that last touched the version file.
-
#validate_version_match!(staging, working, output: $stdout) ⇒ Object
Abort if staging branch has different VERSION than working branch.
Constructor Details
#initialize(name = :release, tasker: Rake::Task) ⇒ Task
Returns a new instance of Task.
61 62 63 64 65 66 67 68 69 70 |
# File 'lib/discharger/task.rb', line 61 def initialize(name = :release, tasker: Rake::Task) @name = name @tasker = tasker @working_branch = "develop" @staging_branch = "stage" @production_branch = "main" @description = "Release the current version to #{staging_branch}" @clear_fragments = true @auto_deploy_staging = false end |
Instance Attribute Details
#app_name ⇒ Object
Returns the value of attribute app_name.
45 46 47 |
# File 'lib/discharger/task.rb', line 45 def app_name @app_name end |
#auto_deploy_staging ⇒ Object
Returns the value of attribute auto_deploy_staging.
39 40 41 |
# File 'lib/discharger/task.rb', line 39 def auto_deploy_staging @auto_deploy_staging end |
#chat_token ⇒ Object
Returns the value of attribute chat_token.
44 45 46 |
# File 'lib/discharger/task.rb', line 44 def chat_token @chat_token end |
#clear_fragments ⇒ Object
Returns the value of attribute clear_fragments.
50 51 52 |
# File 'lib/discharger/task.rb', line 50 def clear_fragments @clear_fragments end |
#commit_identifier ⇒ Object
Returns the value of attribute commit_identifier.
46 47 48 |
# File 'lib/discharger/task.rb', line 46 def commit_identifier @commit_identifier end |
#description ⇒ Object
Returns the value of attribute description.
34 35 36 |
# File 'lib/discharger/task.rb', line 34 def description @description end |
#fragment ⇒ Object
Returns the value of attribute fragment.
49 50 51 |
# File 'lib/discharger/task.rb', line 49 def fragment @fragment end |
#fragment_directory ⇒ Object
Returns the value of attribute fragment_directory.
48 49 50 |
# File 'lib/discharger/task.rb', line 48 def fragment_directory @fragment_directory end |
#last_message_ts ⇒ Object (readonly)
Returns the value of attribute last_message_ts.
52 53 54 |
# File 'lib/discharger/task.rb', line 52 def @last_message_ts end |
#name ⇒ Object
Returns the value of attribute name.
32 33 34 |
# File 'lib/discharger/task.rb', line 32 def name @name end |
#production_branch ⇒ Object
Returns the value of attribute production_branch.
38 39 40 |
# File 'lib/discharger/task.rb', line 38 def production_branch @production_branch end |
#pull_request_url ⇒ Object
Returns the value of attribute pull_request_url.
47 48 49 |
# File 'lib/discharger/task.rb', line 47 def pull_request_url @pull_request_url end |
#release_message_channel ⇒ Object
Returns the value of attribute release_message_channel.
41 42 43 |
# File 'lib/discharger/task.rb', line 41 def @release_message_channel end |
#staging_branch ⇒ Object
Returns the value of attribute staging_branch.
37 38 39 |
# File 'lib/discharger/task.rb', line 37 def staging_branch @staging_branch end |
#version_constant ⇒ Object
Returns the value of attribute version_constant.
42 43 44 |
# File 'lib/discharger/task.rb', line 42 def version_constant @version_constant end |
#working_branch ⇒ Object
Returns the value of attribute working_branch.
36 37 38 |
# File 'lib/discharger/task.rb', line 36 def working_branch @working_branch end |
Class Method Details
.create(name = :release, tasker: Rake::Task, &block) ⇒ Object
9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
# File 'lib/discharger/task.rb', line 9 def self.create(name = :release, tasker: Rake::Task, &block) task = new(name, tasker:) task.instance_eval(&block) if block Reissue::Task.create do |reissue| reissue.version_file = task.version_file reissue.version_limit = task.version_limit reissue.version_redo_proc = task.version_redo_proc reissue.changelog_file = task.changelog_file reissue.updated_paths = task.updated_paths reissue.commit = task.commit reissue.commit_finalize = task.commit_finalize if task.fragment_directory warn "fragment_directory is deprecated, use fragment instead" task.fragment = task.fragment_directory end reissue.fragment = task.fragment reissue.clear_fragments = task.clear_fragments reissue.tag_pattern = task.tag_pattern end task.define task end |
Instance Method Details
#define ⇒ Object
206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 |
# File 'lib/discharger/task.rb', line 206 def define require "slack-ruby-client" Slack.configure do |config| config.token = chat_token end desc <<~DESC ---------- STEP 3 ---------- Release the current version to production This task merges the release branch into production via a GitHub pull request and tags the current version. After the release is complete, a new branch will be created to bump the version for the next release. DESC task "#{name}": [:environment] do unless system("gh --version > /dev/null 2>&1") abort "Error: GitHub CLI (gh) is required for the release process but was not found. Install it: https://cli.github.com" end current_version = Object.const_get(version_constant) # When auto_deploy_staging is enabled, release directly from working_branch # instead of staging_branch (for CI/CD pipelines that auto-deploy staging) release_source = auto_deploy_staging ? working_branch : staging_branch sysecho <<~MSG Releasing version #{current_version} to production. This will tag the current version and push it to the production branch. Release source: #{release_source} MSG sysecho "Are you ready to continue? (Press Enter to continue, Type 'x' and Enter to exit)".bg(:yellow).black input = $stdin.gets exit if input.chomp.match?(/^x/i) syscall( ["git checkout #{working_branch}"], ["git branch -D #{staging_branch} 2>/dev/null || true"], ["git branch -D #{production_branch} 2>/dev/null || true"] ) if auto_deploy_staging syscall( ["git fetch origin #{production_branch}:#{production_branch} #{working_branch}"], ["git reset --hard origin/#{working_branch}"] ) validate_release_commit!(release_source) else syscall( ["git fetch origin #{release_source}:#{release_source} #{production_branch}:#{production_branch}"] ) validate_version_match!(staging_branch, working_branch) end pr_ref = release_source if auto_deploy_staging pr_number = existing_pr_number(production_branch, release_source) if pr_number sysecho "Reusing existing PR ##{pr_number} from #{release_source} to #{production_branch}." pr_ref = pr_number else syscall( ["gh pr create --base #{production_branch} --head #{release_source} --title 'Release #{current_version}' --body 'Deploy #{current_version} to production.'"] ) end end if pr_already_merged?(pr_ref) sysecho "PR #{pr_ref} is already merged. Continuing..." else syscall( ["gh pr merge #{pr_ref} --merge"] ) end continue = syscall( ["git fetch origin #{production_branch}:#{production_branch}"], ["git tag -a v#{current_version} -m 'Release #{current_version}' #{production_branch}"], ["git push origin v#{current_version}"] ) do tasker["#{name}:slack"].invoke("Released #{app_name} #{current_version} (#{commit_identifier.call}) to production.", , ":chipmunk:") if .present? text = File.read(Rails.root.join(changelog_file)) tasker["#{name}:slack"].reenable tasker["#{name}:slack"].invoke(text, , ":log:", ) end # Signal success — no branch switch needed since we stay on working_branch throughout true end abort "Release failed." unless continue sysecho <<~MSG Version #{current_version} released to production. Preparing to bump the version for the next release. MSG tasker["reissue"].invoke new_version_branch = `git rev-parse --abbrev-ref HEAD`.strip new_version = new_version_branch.split("/").last params = {expand: 1, title: "Bump version to #{new_version}"} pr_url = "#{pull_request_url}/compare/#{working_branch}...#{new_version_branch}?#{params.to_query}" syscall(["git push origin #{new_version_branch} --force"]) do sysecho <<~MSG Branch #{new_version_branch} created. Open a PR to #{working_branch} to mark the version and update the chaneglog for the next release. Opening PR: #{pr_url} MSG end.then do |success| syscall ["open", pr_url] if success end end namespace name do desc "Echo the configuration settings." task :config do sysecho "-- Discharger Configuration --".bg(:green).black sysecho "SHA: #{commit_identifier.call}".bg(:red).black instance_variables.sort.each do |var| value = instance_variable_get(var) value = value.call if value.is_a?(Proc) && value.arity.zero? sysecho "#{var.to_s.sub("@", "").ljust(24)}: #{value}".bg(:yellow).black end sysecho "----------------------------------".bg(:green).black end desc description task build: :environment do if auto_deploy_staging sysecho "Note: auto_deploy_staging is enabled. Staging deploys automatically from #{working_branch}.".bg(:yellow).black end # Allow overriding the working branch via environment variable build_branch = ENV["DISCHARGER_BUILD_BRANCH"] || working_branch syscall( ["git fetch origin #{build_branch}"], ["git checkout #{build_branch}"], ["git reset --hard origin/#{build_branch}"], ["git branch -D #{staging_branch} 2>/dev/null || true"], ["git checkout -b #{staging_branch}"], ["git push origin #{staging_branch} --force"] ) do current_version = Object.const_get(version_constant) tasker["#{name}:slack"].invoke("Building #{app_name} #{current_version} (#{commit_identifier.call}) on #{staging_branch}.", ) syscall ["git checkout #{build_branch}"] end end desc "Send a message to Slack." task :slack, [:text, :channel, :emoji, :ts] => :environment do |_, args| args.with_defaults( channel: , emoji: nil ) client = Slack::Web::Client.new = args.to_h [:icon_emoji] = .delete(:emoji) if [:emoji] [:thread_ts] = .delete(:ts) if [:ts] sysecho "Sending message to Slack:".bg(:green).black + " #{args[:text]}" result = client.chat_postMessage(**) instance_variable_set(:@last_message_ts, result["ts"]) sysecho %(Message sent: #{result["ts"]}) end desc <<~DESC ---------- STEP 1 ---------- Prepare the current version for release to production (#{production_branch}) This task will create a new branch to prepare the release. The CHANGELOG will be updated and the version will be bumped. The branch will be pushed to the remote repository. After the branch is created, open a PR to #{working_branch} to finalize the release. DESC task prepare: [:environment] do current_version = Object.const_get(version_constant) finish_branch = "bump/finish-#{current_version.tr(".", "-")}" syscall( ["git fetch origin #{working_branch}"], ["git checkout #{working_branch}"], ["git checkout -b #{finish_branch}"] ) sysecho <<~MSG Branch #{finish_branch} created. Check the contents of the CHANGELOG and ensure that the text is correct. If you need to make changes, edit the CHANGELOG and save the file. Then return here to continue with this commit. MSG sysecho "Are you ready to continue? (Press Enter to continue, Type 'x' and Enter to exit)".bg(:yellow).black input = $stdin.gets exit if input.chomp.match?(/^x/i) tasker["reissue:finalize"].invoke params = { expand: 1, title: "Finish version #{current_version}", body: <<~BODY Completing development for #{current_version}. BODY } pr_url = "#{pull_request_url}/compare/#{finish_branch}?#{params.to_query}" next_step = auto_deploy_staging ? "rake #{name}" : "rake #{name}:stage" next_step_desc = auto_deploy_staging ? "release to production" : "stage the release branch" continue = syscall ["git push origin #{finish_branch} --force"] do sysecho <<~MSG Branch #{finish_branch} created. Open a PR to #{working_branch} to finalize the release. #{pr_url} Once the PR is merged, pull down #{working_branch} and run '#{next_step}' to #{next_step_desc}. MSG end if continue syscall ["git checkout #{working_branch}"], ["open", pr_url] end end desc <<~DESC ---------- STEP 2 ---------- Stage the release branch This task will update Stage, open a PR, and instruct you on the next steps. NOTE: If you just want to update the stage environment but aren't ready to release, run: bin/rails #{name}:build DESC task stage: [:environment] do if auto_deploy_staging sysecho <<~MSG.bg(:yellow).black Note: auto_deploy_staging is enabled. Staging is handled automatically when code is pushed to #{working_branch}. To release to production, run: 'rake #{name}' MSG next end tasker["build"].invoke current_version = Object.const_get(version_constant) params = { expand: 1, title: "Stage to Main", body: <<~BODY Deploy #{current_version} to production. BODY } pr_url = "#{pull_request_url}/compare/#{production_branch}...#{staging_branch}?#{params.to_query}" sysecho <<~MSG Branch #{staging_branch} updated. Open a PR to #{production_branch} to release the version. Opening PR: #{pr_url} Once the PR is **approved**, run 'rake release' to release the version. MSG syscall ["open", pr_url] end end end |
#existing_pr_number(base, head) ⇒ Object
182 183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/discharger/task.rb', line 182 def existing_pr_number(base, head) stdout, _, status = Open3.capture3( "gh", "pr", "list", "--base", base, "--head", head, "--state", "open", "--json", "number", "--jq", ".[0].number // empty" ) return nil unless status.success? pr = stdout.strip pr.empty? ? nil : pr end |
#git_local_sha(branch) ⇒ Object
170 171 172 173 174 |
# File 'lib/discharger/task.rb', line 170 def git_local_sha(branch) stdout, _, status = Open3.capture3("git", "rev-parse", branch) return nil unless status.success? stdout.strip end |
#git_show_version(branch) ⇒ Object
164 165 166 167 168 |
# File 'lib/discharger/task.rb', line 164 def git_show_version(branch) content, _, status = Open3.capture3("git", "show", "origin/#{branch}:#{version_file}") return nil unless status.success? content[/VERSION\s*=\s*["']([^"']+)["']/, 1] end |
#git_version_file_commit(branch) ⇒ Object
176 177 178 179 180 |
# File 'lib/discharger/task.rb', line 176 def git_version_file_commit(branch) stdout, _, status = Open3.capture3("git", "log", branch, "-1", "--format=%H", "--", version_file) return nil unless status.success? stdout.strip end |
#pr_already_merged?(pr_ref) ⇒ Boolean
196 197 198 199 200 201 202 203 204 |
# File 'lib/discharger/task.rb', line 196 def pr_already_merged?(pr_ref) stdout, _, status = Open3.capture3( "gh", "pr", "view", pr_ref.to_s, "--json", "state", "--jq", ".state" ) return false unless status.success? stdout.strip == "MERGED" end |
#syscall(*steps, output: $stdout, error: $stderr) ⇒ Boolean
Run a multiple system commands and return true if all commands succeed If any command fails, the method will return false and stop executing any further commands.
Provide a block to evaluate the output of the command and return true if the command was successful. If the block returns false, the method will return false and stop executing any further commands.
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 |
# File 'lib/discharger/task.rb', line 90 def syscall(*steps, output: $stdout, error: $stderr) success = false stdout, stderr, status = nil steps.each do |cmd| puts cmd.join(" ").bg(:green).black stdout, stderr, status = Open3.capture3(*cmd) if status.success? output.puts stdout success = true else error.puts stderr success = false exit(status.exitstatus) end end if block_given? success = !!yield(stdout, stderr, status) # If the error reports that a rule was bypassed, consider the command successful # because we are bypassing the rule intentionally when merging the release branch # to the production branch. success = true if stderr.match?(/bypassed rule violations/i) abort(stderr) unless success end success end |
#sysecho(message, output: $stdout) ⇒ Object
116 117 118 119 |
# File 'lib/discharger/task.rb', line 116 def sysecho(, output: $stdout) output.puts true end |
#validate_release_commit!(branch, output: $stdout) ⇒ Object
Abort if HEAD is not the commit that last touched the version file
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/discharger/task.rb', line 137 def validate_release_commit!(branch, output: $stdout) head_sha = git_local_sha(branch) release_sha = git_version_file_commit(branch) if head_sha.nil? || release_sha.nil? abort <<~ERROR.bg(:red).white Could not determine release commit. HEAD: #{head_sha || "not found"} Release commit: #{release_sha || "not found"} Ensure #{branch} exists and #{version_file} has been modified. ERROR end return sysecho("✓ HEAD is the release commit (#{head_sha[0, 8]})".bg(:green).black, output:) if head_sha == release_sha abort <<~ERROR.bg(:red).white HEAD is not the release commit! HEAD: #{head_sha[0, 8]} Release commit: #{release_sha[0, 8]} (last commit to touch #{version_file}) Something was merged after the release PR. Verify the branch contents and retry. ERROR end |
#validate_version_match!(staging, working, output: $stdout) ⇒ Object
Abort if staging branch has different VERSION than working branch
122 123 124 125 126 127 128 129 130 131 132 133 134 |
# File 'lib/discharger/task.rb', line 122 def validate_version_match!(staging, working, output: $stdout) staging_v = git_show_version(staging) working_v = git_show_version(working) return sysecho("✓ Versions match (#{working_v})".bg(:green).black, output:) if staging_v == working_v abort <<~ERROR.bg(:red).white VERSION mismatch: #{staging}=#{staging_v || "not found"}, #{working}=#{working_v || "not found"} Run: rake #{name}:stage Then retry: rake #{name} ERROR end |