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.
-
#post_runbook_to_thread(version, thread_ts) ⇒ Object
Post the runbook as a reply in a Slack thread, when a runbook is configured.
- #pr_already_merged?(pr_ref) ⇒ Boolean
-
#runbook_announcement(version, items = runbook_items) ⇒ Object
Build the Slack message listing the post-release steps for a version.
-
#runbook_items ⇒ Object
The post-release steps collected from "Runbook:" commit trailers.
-
#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.
62 63 64 65 66 67 68 69 70 71 |
# File 'lib/discharger/task.rb', line 62 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.
46 47 48 |
# File 'lib/discharger/task.rb', line 46 def app_name @app_name end |
#auto_deploy_staging ⇒ Object
Returns the value of attribute auto_deploy_staging.
40 41 42 |
# File 'lib/discharger/task.rb', line 40 def auto_deploy_staging @auto_deploy_staging end |
#chat_token ⇒ Object
Returns the value of attribute chat_token.
45 46 47 |
# File 'lib/discharger/task.rb', line 45 def chat_token @chat_token end |
#clear_fragments ⇒ Object
Returns the value of attribute clear_fragments.
51 52 53 |
# File 'lib/discharger/task.rb', line 51 def clear_fragments @clear_fragments end |
#commit_identifier ⇒ Object
Returns the value of attribute commit_identifier.
47 48 49 |
# File 'lib/discharger/task.rb', line 47 def commit_identifier @commit_identifier end |
#description ⇒ Object
Returns the value of attribute description.
35 36 37 |
# File 'lib/discharger/task.rb', line 35 def description @description end |
#fragment ⇒ Object
Returns the value of attribute fragment.
50 51 52 |
# File 'lib/discharger/task.rb', line 50 def fragment @fragment end |
#fragment_directory ⇒ Object
Returns the value of attribute fragment_directory.
49 50 51 |
# File 'lib/discharger/task.rb', line 49 def fragment_directory @fragment_directory end |
#last_message_ts ⇒ Object (readonly)
Returns the value of attribute last_message_ts.
53 54 55 |
# File 'lib/discharger/task.rb', line 53 def @last_message_ts end |
#name ⇒ Object
Returns the value of attribute name.
33 34 35 |
# File 'lib/discharger/task.rb', line 33 def name @name end |
#production_branch ⇒ Object
Returns the value of attribute production_branch.
39 40 41 |
# File 'lib/discharger/task.rb', line 39 def production_branch @production_branch end |
#pull_request_url ⇒ Object
Returns the value of attribute pull_request_url.
48 49 50 |
# File 'lib/discharger/task.rb', line 48 def pull_request_url @pull_request_url end |
#release_message_channel ⇒ Object
Returns the value of attribute release_message_channel.
42 43 44 |
# File 'lib/discharger/task.rb', line 42 def @release_message_channel end |
#staging_branch ⇒ Object
Returns the value of attribute staging_branch.
38 39 40 |
# File 'lib/discharger/task.rb', line 38 def staging_branch @staging_branch end |
#version_constant ⇒ Object
Returns the value of attribute version_constant.
43 44 45 |
# File 'lib/discharger/task.rb', line 43 def version_constant @version_constant end |
#working_branch ⇒ Object
Returns the value of attribute working_branch.
37 38 39 |
# File 'lib/discharger/task.rb', line 37 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 31 |
# 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 reissue.runbook_file = task.runbook_file end task.define task end |
Instance Method Details
#define ⇒ Object
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 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 |
# File 'lib/discharger/task.rb', line 241 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:") # Capture the root before replying; each post overwrites last_message_ts. thread_ts = if thread_ts.present? text = File.read(Rails.root.join(changelog_file)) tasker["#{name}:slack"].reenable tasker["#{name}:slack"].invoke(text, , ":log:", thread_ts) post_runbook_to_thread(current_version, thread_ts) 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}.", ) post_runbook_to_thread(current_version, ) 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
217 218 219 220 221 222 223 224 225 226 227 228 229 |
# File 'lib/discharger/task.rb', line 217 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
171 172 173 174 175 |
# File 'lib/discharger/task.rb', line 171 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
165 166 167 168 169 |
# File 'lib/discharger/task.rb', line 165 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
177 178 179 180 181 |
# File 'lib/discharger/task.rb', line 177 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 |
#post_runbook_to_thread(version, thread_ts) ⇒ Object
Post the runbook as a reply in a Slack thread, when a runbook is configured. Reuses the same message on the stage build and the production release. Returns the message posted, or nil when there is no thread to reply to or no runbook to announce.
208 209 210 211 212 213 214 215 |
# File 'lib/discharger/task.rb', line 208 def post_runbook_to_thread(version, thread_ts) return unless thread_ts.present? return unless ( = runbook_announcement(version)) tasker["#{name}:slack"].reenable tasker["#{name}:slack"].invoke(, , ":clipboard:", thread_ts) end |
#pr_already_merged?(pr_ref) ⇒ Boolean
231 232 233 234 235 236 237 238 239 |
# File 'lib/discharger/task.rb', line 231 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 |
#runbook_announcement(version, items = runbook_items) ⇒ Object
Build the Slack message listing the post-release steps for a version. Returns nil when the project does not use a runbook, which is distinct from a configured runbook that happens to have no steps this release.
194 195 196 197 198 199 200 201 202 |
# File 'lib/discharger/task.rb', line 194 def runbook_announcement(version, items = runbook_items) return nil unless runbook_file header = "*Post-release runbook for #{version}*" return "#{header}\nNo runbook tasks for this release." if items.empty? "#{header} — #{items.size} step#{"s" unless items.size == 1}\n\n" + items.map { |item| "• #{item}" }.join("\n") end |
#runbook_items ⇒ Object
The post-release steps collected from "Runbook:" commit trailers. Empty when no runbook is configured or the file has yet to be written.
185 186 187 188 189 |
# File 'lib/discharger/task.rb', line 185 def runbook_items return [] unless runbook_file Reissue::Runbook.new(Rails.root.join(runbook_file).to_s).items 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.
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/discharger/task.rb', line 91 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
117 118 119 120 |
# File 'lib/discharger/task.rb', line 117 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
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 163 |
# File 'lib/discharger/task.rb', line 138 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
123 124 125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/discharger/task.rb', line 123 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 |