Class: Discharger::Task

Inherits:
Rake::TaskLib
  • Object
show all
Defined in:
lib/discharger/task.rb

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

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_nameObject

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_stagingObject

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_tokenObject

Returns the value of attribute chat_token.



44
45
46
# File 'lib/discharger/task.rb', line 44

def chat_token
  @chat_token
end

#clear_fragmentsObject

Returns the value of attribute clear_fragments.



50
51
52
# File 'lib/discharger/task.rb', line 50

def clear_fragments
  @clear_fragments
end

#commit_identifierObject

Returns the value of attribute commit_identifier.



46
47
48
# File 'lib/discharger/task.rb', line 46

def commit_identifier
  @commit_identifier
end

#descriptionObject

Returns the value of attribute description.



34
35
36
# File 'lib/discharger/task.rb', line 34

def description
  @description
end

#fragmentObject

Returns the value of attribute fragment.



49
50
51
# File 'lib/discharger/task.rb', line 49

def fragment
  @fragment
end

#fragment_directoryObject

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_tsObject (readonly)

Returns the value of attribute last_message_ts.



52
53
54
# File 'lib/discharger/task.rb', line 52

def last_message_ts
  @last_message_ts
end

#nameObject

Returns the value of attribute name.



32
33
34
# File 'lib/discharger/task.rb', line 32

def name
  @name
end

#production_branchObject

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_urlObject

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_channelObject

Returns the value of attribute release_message_channel.



41
42
43
# File 'lib/discharger/task.rb', line 41

def release_message_channel
  @release_message_channel
end

#staging_branchObject

Returns the value of attribute staging_branch.



37
38
39
# File 'lib/discharger/task.rb', line 37

def staging_branch
  @staging_branch
end

#version_constantObject

Returns the value of attribute version_constant.



42
43
44
# File 'lib/discharger/task.rb', line 42

def version_constant
  @version_constant
end

#working_branchObject

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

#defineObject



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.", release_message_channel, ":chipmunk:")
      if last_message_ts.present?
        text = File.read(Rails.root.join(changelog_file))
        tasker["#{name}:slack"].reenable
        tasker["#{name}:slack"].invoke(text, release_message_channel, ":log:", last_message_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}.", release_message_channel)
        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: release_message_channel,
        emoji: nil
      )
      client = Slack::Web::Client.new
      options = args.to_h
      options[:icon_emoji] = options.delete(:emoji) if options[:emoji]
      options[:thread_ts] = options.delete(:ts) if options[:ts]

      sysecho "Sending message to Slack:".bg(:green).black + " #{args[:text]}"
      result = client.chat_postMessage(**options)
      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

Returns:

  • (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.

Examples:

syscall(
  ["echo Hello, World!"],
  ["ls -l"]
)

Parameters:

  • *steps (Array<Array<String>>)

    an array of commands to run

  • block (Proc)

    a block to evaluate the output of the command

Returns:

  • (Boolean)

    true if all commands succeed, false otherwise



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(message, output: $stdout)
  output.puts message
  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