discharger 0.3.5 → 0.4.1

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: 97070aca58c88a6f43e34f7d45bec964122a6db867e9bd5333988a082bb41d47
4
- data.tar.gz: 0f39ea2c1136914a33666593af2bdaeed6f358a34364f9ffcb9b1557976c3fae
3
+ metadata.gz: 3212b79aa38f448d44523f2f83b622c7dfec1cff7bff08ed6033bbe1fea79761
4
+ data.tar.gz: 6d9d0c008a85560a666522c58a97ee08feae68ef1ea4e8f7cda86e29b0e8bd75
5
5
  SHA512:
6
- metadata.gz: 617ed8ed6727d1e1817e7859ce8121553399bd1ec0c5c70b2002d93eb0273915e112d5b30d6ba2e045e18ff0b4fd1bf075cc78d0000f0b8abdc360102d14cb0c
7
- data.tar.gz: d8470a667ff24259e5efc590ad63fb7be3884e31ce3e35a5e3b8d22fdb3a399fe4948b3e8e465d2b4aee34e24e2ef25fce86453a041083abb6947b6b3454d7f7
6
+ metadata.gz: a54728bf513a01a7f4f91507d0951f4075ab8d2fbd7a80cb01223bbd967f4f5bcc0dfba8a8842a3d3e6ab30c94995180bef978089c19a519ac07de608234f787
7
+ data.tar.gz: bf5d95387d7f861cfef6041871c39a946f986225a8c20aa3597b5dc3d5840e5f3ac16ebe5babc354da4b292e84820bcf8cc5a75b56d519414c12614e325a350b
data/CHANGELOG.md CHANGED
@@ -5,14 +5,39 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/)
6
6
  and this project adheres to [Semantic Versioning](http://semver.org/).
7
7
 
8
- ## [0.3.5] - 2026-08-10
8
+ ## [0.4.1] - 2026-09-29
9
9
 
10
10
  ### Added
11
11
 
12
- - github_packages setup step storing bundler credentials for a private GitHub Packages gem source via the gh CLI (2cff4b6)
12
+ - Setup prints each step's elapsed time and the total (063521c)
13
+ - bin/setup opens with a do-not-edit notice, and `discharger:install --setup-only` regenerates only the script (71ee122)
13
14
 
14
- ## [0.3.4] - 2026-07-22
15
+ ### Changed
16
+
17
+ - Gem author listed as Savannah Albanez (a48c8d8)
18
+
19
+ ### Removed
20
+
21
+ - Stale Qualify setup script from the repo root, never shipped in the gem (8744c43)
22
+
23
+ ### Fixed
24
+
25
+ - Releases finish when the Slack token is missing or the post fails (3cf9b6d)
26
+ - bin/setup bootstraps a fresh clone: GitHub Packages credentials, bundle install, then re-exec under bundle exec (7196fe4)
27
+ - github_packages stores bundler credentials only after the source accepts them (41fab0f)
28
+
29
+ ## [0.4.0] - 2026-09-24
15
30
 
16
31
  ### Added
17
32
 
18
- - Runbook announcement on stage builds, previewing post-release steps before production (c77b552)
33
+ - pr_label option opening the finalize and version-bump PRs via gh with that label (08e1d10)
34
+
35
+ ### Changed
36
+
37
+ - Auto-deploy releases tag the newest commit that changed the version's changelog section instead of requiring it at HEAD, and no longer open a production PR (476fe1b)
38
+ - Auto-deploy releases merge the release tag into the production branch (a7a1b49)
39
+
40
+ ### Fixed
41
+
42
+ - retain_changelogs reaches Reissue, so finalize writes the archived changelog file (2a90edf)
43
+ - release:prepare branches from origin, so a stale local branch no longer yields an empty changelog (8eb5fd3)
data/README.md CHANGED
@@ -187,6 +187,16 @@ end
187
187
 
188
188
  This allows you to use environment variables to override the default branch names, or set project-specific defaults. The `DISCHARGER_BUILD_BRANCH` environment variable (shown above) provides a runtime override specifically for the build task.
189
189
 
190
+ ### Auto-deploy Staging
191
+
192
+ For projects whose CI deploys the working branch to staging on every merge, set `task.auto_deploy_staging = true` and skip `rake release:stage`. Once the finalize PR merges, `rake release`:
193
+
194
+ 1. Tags the newest commit that changed the current version's dated changelog section, so work merged after finalizing (or re-finalizing for a hotfix) is left out.
195
+ 2. Pushes the tag. Production deploys from `v*` tags.
196
+ 3. Merges the tag into `production_branch` so it tracks production. If that push is rejected, the release still completes with a warning.
197
+
198
+ Production ships the tagged commit, which can be older than what staging last ran.
199
+
190
200
  ## Development Setup Automation
191
201
 
192
202
  Discharger includes a setup script that automates your development environment configuration. When you run the install generator, it creates a `bin/setup` script and a `config/setup.yml` configuration file.
@@ -201,6 +211,33 @@ $ bin/setup
201
211
 
202
212
  This script is idempotent - you can run it multiple times safely, and it will ensure your environment is properly configured.
203
213
 
214
+ It works on a fresh clone with no gems installed. The generated script first runs a
215
+ standard-library-only pass that stores bundler credentials for a configured
216
+ `github_packages` source (from your GitHub CLI login, when its token has the
217
+ `read:packages` scope), installs the bundle, and then
218
+ re-execs itself under `bundle exec` so default gems such as psych never clash with
219
+ `Gemfile.lock`. The `DISCHARGER_SETUP_BUNDLED` environment variable marks the second
220
+ pass; the `github_packages` step later verifies the stored credentials and warns when
221
+ the token lacks the `read:packages` scope. Rails still has to boot before the steps
222
+ run, so an app whose boot needs `.env` or `config/database.yml` copies them in
223
+ `pre_steps` (see below).
224
+
225
+ Each step prints its elapsed time when it finishes, and the closing line reports the
226
+ total, so a slow setup shows which step to look at.
227
+
228
+ ### Keeping bin/setup Generated
229
+
230
+ `bin/setup` is a verbatim copy of the gem's template and opens with a notice saying
231
+ so. Do not hand-edit it in an app: app-specific work belongs in `config/setup.yml`
232
+ (`pre_steps`, `steps`, `custom_steps`), and changes to the script itself belong in
233
+ this gem's template so every app picks them up. After bumping to a discharger
234
+ release that changed the template, regenerate the script without touching
235
+ `config/setup.yml` or the initializer:
236
+
237
+ ```bash
238
+ $ bin/rails generate discharger:install --setup-only --force
239
+ ```
240
+
204
241
  ### Configuration
205
242
 
206
243
  The setup process is configured through `config/setup.yml`. Here's an example configuration:
@@ -295,7 +332,7 @@ steps:
295
332
  - yarn
296
333
  ```
297
334
 
298
- Leave the array empty or omit it entirely to run all available steps.
335
+ Leave the array empty or omit it entirely to run all available steps, in the order listed above. Custom commands registered through `Discharger::SetupRunner.register_command` run last.
299
336
 
300
337
  ### Adding Custom Commands
301
338
 
@@ -322,6 +359,26 @@ Each custom step can include:
322
359
  - `description` - A description shown during setup
323
360
  - `command` - The command to execute
324
361
  - `condition` - An optional Ruby expression that must evaluate to true for the command to run
362
+ - `name` - An optional name that lets the step be scheduled in the `steps` list
363
+
364
+ Custom steps run after the built-in steps, in the order listed. To run one
365
+ earlier, give it a `name` and list that name in `steps`:
366
+
367
+ ```yaml
368
+ custom_steps:
369
+ - name: import_certs
370
+ description: "Import development certificates"
371
+ command: "bin/import-certs"
372
+
373
+ steps:
374
+ - brew
375
+ - import_certs # runs between brew and bundler
376
+ - bundler
377
+ ```
378
+
379
+ A named step listed in `steps` runs only at that position; named steps that
380
+ are not listed keep the default run-last behavior. A name that collides with
381
+ a built-in step resolves to the built-in, and a warning is logged.
325
382
 
326
383
  ### Creating Custom Command Classes
327
384
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "pathname"
4
+ require_relative "timing"
5
5
 
6
6
  # Unified entry point for Discharger setup.
7
7
  # Handles both pre-Rails prerequisites and post-Rails setup commands.
@@ -34,21 +34,23 @@ module Discharger
34
34
  validate_environment
35
35
  print_header
36
36
 
37
- # Phase 1: Load bundler first (activates correct gem versions)
38
- # This must happen before we parse YAML to avoid psych version conflicts
39
- load_bundler
37
+ elapsed = Timing.measure do
38
+ # Phase 1: Load bundler first (activates correct gem versions)
39
+ # This must happen before we parse YAML to avoid psych version conflicts
40
+ load_bundler
40
41
 
41
- # Phase 2: Pre-Rails setup (env vars, system dependencies)
42
- # These run AFTER bundler but BEFORE Rails loads
43
- run_prerequisites
42
+ # Phase 2: Pre-Rails setup (env vars, system dependencies)
43
+ # These run AFTER bundler but BEFORE Rails loads
44
+ run_prerequisites
44
45
 
45
- # Phase 3: Load Rails (uses env vars from phase 2)
46
- load_rails
46
+ # Phase 3: Load Rails (uses env vars from phase 2)
47
+ load_rails
47
48
 
48
- # Phase 4: Run Discharger commands (after Rails loads)
49
- run_setup_commands
49
+ # Phase 4: Run Discharger commands (after Rails loads)
50
+ run_setup_commands
51
+ end
50
52
 
51
- print_footer
53
+ print_footer(elapsed)
52
54
  end
53
55
  end
54
56
 
@@ -71,8 +73,8 @@ module Discharger
71
73
  puts "Configuration loaded from: #{config_path}"
72
74
  end
73
75
 
74
- def print_footer
75
- puts "\n== Setup completed successfully! =="
76
+ def print_footer(elapsed)
77
+ puts format("\n== Setup completed successfully! (%.2fs) ==", elapsed)
76
78
  end
77
79
 
78
80
  def load_bundler
@@ -89,8 +91,11 @@ module Discharger
89
91
  # Load Rails from the standard location
90
92
  rails_config = File.join(app_root, "config", "application.rb")
91
93
  if File.exist?(rails_config)
92
- require rails_config
93
- Rails.application.initialize!
94
+ elapsed = Timing.measure do
95
+ require rails_config
96
+ Rails.application.initialize!
97
+ end
98
+ puts format("\n== Rails loaded (%.2fs) ==", elapsed)
94
99
  else
95
100
  puts "Warning: config/application.rb not found. Skipping Rails initialization."
96
101
  end
@@ -25,32 +25,69 @@ module Discharger
25
25
 
26
26
  def create_all_commands
27
27
  commands = []
28
+ scheduled = []
28
29
 
29
- # Create built-in commands from steps
30
+ # Create built-in and named custom commands from steps
30
31
  if config.steps.any?
32
+ named_custom = named_custom_steps
31
33
  config.steps.each do |step|
32
- command = create_command(step)
33
- commands << command if command
34
+ name = step.to_s
35
+ if CommandRegistry.get(name)
36
+ if named_custom.key?(name)
37
+ logger&.warn "custom step name #{name.inspect} is shadowed by a built-in command; the built-in will run"
38
+ end
39
+ command = create_command(name)
40
+ commands << command if command
41
+ elsif (step_config = named_custom[name])
42
+ scheduled << step_config
43
+ commands << build_custom_command(step_config)
44
+ else
45
+ logger&.warn "unknown step #{name.inspect}; not a built-in or named custom step"
46
+ end
34
47
  end
35
48
  else
36
49
  # If no steps specified, create all registered commands
37
- CommandRegistry.names.each do |name|
50
+ CommandRegistry.ordered_names.each do |name|
38
51
  command = create_command(name)
39
52
  commands << command if command
40
53
  end
41
54
  end
42
55
 
43
- # Create custom commands
56
+ # Create the remaining custom commands
44
57
  if config.respond_to?(:custom_steps) && config.custom_steps.any?
45
- require_relative "commands/custom_command"
46
58
  config.custom_steps.each do |step_config|
47
- command = Commands::CustomCommand.new(config, app_root, logger, step_config)
48
- commands << command
59
+ next if scheduled.any? { |s| s.equal?(step_config) }
60
+ commands << build_custom_command(step_config)
49
61
  end
50
62
  end
51
63
 
52
64
  commands
53
65
  end
66
+
67
+ private
68
+
69
+ # Custom steps with a "name" key can be scheduled by that name in
70
+ # steps:. First entry wins a contested name; later duplicates keep the
71
+ # default run-last behavior.
72
+ def named_custom_steps
73
+ return {} unless config.respond_to?(:custom_steps)
74
+
75
+ config.custom_steps.each_with_object({}) do |step_config, named|
76
+ name = step_config["name"].to_s
77
+ next if name.empty?
78
+
79
+ if named.key?(name)
80
+ logger&.warn "duplicate custom step name #{name.inspect}; only the first entry can be scheduled"
81
+ next
82
+ end
83
+ named[name] = step_config
84
+ end
85
+ end
86
+
87
+ def build_custom_command(step_config)
88
+ require_relative "commands/custom_command"
89
+ Commands::CustomCommand.new(config, app_root, logger, step_config)
90
+ end
54
91
  end
55
92
  end
56
93
  end
@@ -3,6 +3,24 @@
3
3
  module Discharger
4
4
  module SetupRunner
5
5
  class CommandRegistry
6
+ # The order built-in steps run in when setup.yml configures no steps.
7
+ # Registration order cannot be relied on here: it comes from enumerating
8
+ # Commands.constants, which shifts with require order. Credentials and
9
+ # toolchains have to be in place before bundler resolves gems.
10
+ DEFAULT_ORDER = %w[
11
+ brew
12
+ asdf
13
+ git
14
+ github_packages
15
+ bundler
16
+ yarn
17
+ config
18
+ docker
19
+ pg_tools
20
+ env
21
+ database
22
+ ].freeze
23
+
6
24
  class << self
7
25
  def register(name, command_class)
8
26
  commands[name.to_s] = command_class
@@ -20,6 +38,12 @@ module Discharger
20
38
  commands.keys
21
39
  end
22
40
 
41
+ # Registered names in pipeline order, with anything outside
42
+ # DEFAULT_ORDER (custom commands) appended in registration order.
43
+ def ordered_names
44
+ (DEFAULT_ORDER & names) + (names - DEFAULT_ORDER)
45
+ end
46
+
23
47
  def clear
24
48
  commands.clear
25
49
  end
@@ -41,6 +65,9 @@ module Discharger
41
65
  command_class = Commands.const_get(const_name)
42
66
  next unless command_class < Commands::BaseCommand
43
67
  next if command_class == Commands::BaseCommand
68
+ # Not schedulable by name: it takes a step_config argument and is
69
+ # instantiated by CommandFactory from custom_steps entries.
70
+ next if command_class == Commands::CustomCommand
44
71
 
45
72
  # Convert class name to command name (e.g., AsdfCommand -> asdf)
46
73
  command_name = const_name.to_s.sub(/Command$/, "").gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, "")
@@ -104,12 +104,14 @@ module Discharger
104
104
  log "Checking #{name} container"
105
105
 
106
106
  if system_quiet("docker ps | grep #{name} > /dev/null 2>&1")
107
+ ensure_restart_policy(name)
107
108
  log "#{name} container is already running"
108
109
  return
109
110
  end
110
111
 
111
112
  # Check if container exists but is stopped
112
113
  if system_quiet("docker inspect #{name} > /dev/null 2>&1")
114
+ ensure_restart_policy(name)
113
115
  log "Starting existing #{name} container"
114
116
  unless system_quiet("docker start #{name}")
115
117
  log "Removing failed #{name} container"
@@ -148,7 +150,7 @@ module Discharger
148
150
  def create_container(name:, port:, image:, internal_port:, env: {}, volume: nil)
149
151
  log "Creating new #{name} container"
150
152
 
151
- cmd = ["docker", "run", "-d", "--name", name, "-p", "#{port}:#{internal_port}"]
153
+ cmd = ["docker", "run", "-d", "--restart", "unless-stopped", "--name", name, "-p", "#{port}:#{internal_port}"]
152
154
  env.each { |k, v| cmd.push("-e", "#{k}=#{v}") }
153
155
  cmd.push("-v", volume) if volume
154
156
  cmd.push(image)
@@ -156,6 +158,10 @@ module Discharger
156
158
  system!(*cmd)
157
159
  end
158
160
 
161
+ def ensure_restart_policy(name)
162
+ system!("docker", "update", "--restart", "unless-stopped", name)
163
+ end
164
+
159
165
  def docker_available?
160
166
  return true if system_quiet("which docker > /dev/null 2>&1")
161
167
 
@@ -6,10 +6,43 @@ module Discharger
6
6
  module SetupRunner
7
7
  module Commands
8
8
  # Stores bundler credentials for a private GitHub Packages gem source
9
- # using the GitHub CLI, so Gemfiles don't need embedded tokens.
10
- # Credentials are written to the app's .bundle/config — keep .bundle
11
- # gitignored. Run this step before "bundler" in setup.yml.
9
+ # using the GitHub CLI, so Gemfiles don't need embedded tokens. A token
10
+ # the source rejects is not stored, so it cannot shadow a working
11
+ # credential in the user's own bundler config. Credentials are written
12
+ # to the app's .bundle/config — keep .bundle gitignored. Run this step
13
+ # before "bundler" in setup.yml.
12
14
  class GithubPackagesCommand < BaseCommand
15
+ # A configured source with nothing scheduled to store its credentials
16
+ # is inert, and the only symptom is bundler failing against the
17
+ # private source. The Runner surfaces this before executing commands.
18
+ # Guarded for duck-typed configs passed to the public Runner API, and
19
+ # quiet when a custom step's command references the source (assumed
20
+ # to handle credentials itself).
21
+ def self.unscheduled_warning(config)
22
+ return unless config.respond_to?(:github_packages)
23
+
24
+ source = config.github_packages&.source
25
+ return if source.to_s.empty?
26
+ if config.respond_to?(:custom_steps) &&
27
+ config.custom_steps.any? { |step| step_handles_source?(step, source) }
28
+ return
29
+ end
30
+
31
+ "github_packages is configured but missing from steps; " \
32
+ "credentials will not be stored and bundler may fail for #{source}"
33
+ end
34
+
35
+ # A referencing step only counts as handling credentials when it will
36
+ # actually run: CustomCommand skips itself when its condition is
37
+ # false, storing nothing on this run.
38
+ def self.step_handles_source?(step, source)
39
+ return false unless step["command"].to_s.include?(source)
40
+
41
+ require_relative "../condition_evaluator"
42
+ ConditionEvaluator.evaluate(step["condition"])
43
+ end
44
+ private_class_method :step_handles_source?
45
+
13
46
  def execute
14
47
  unless gh_installed?
15
48
  log "GitHub CLI (gh) not found; skipping. Install gh and rerun setup or `bundle install` may fail for #{source}"
@@ -28,13 +61,14 @@ module Discharger
28
61
  return
29
62
  end
30
63
 
31
- store_bundler_credentials(username, token)
32
- if credentials_valid?(username, token)
33
- log "Configured bundler credentials for #{source}"
34
- else
35
- log "#{source} rejected the stored credentials. Your gh token may lack " \
36
- "the read:packages scope — run `gh auth refresh -s read:packages` and rerun setup."
64
+ unless credentials_valid?(username, token)
65
+ log "#{source} rejected the gh token, so bundler credentials were left alone. The token " \
66
+ "may lack the read:packages scope — run `gh auth refresh -s read:packages` and rerun setup."
67
+ return
37
68
  end
69
+
70
+ store_bundler_credentials(username, token)
71
+ log "Configured bundler credentials for #{source}"
38
72
  end
39
73
 
40
74
  def can_execute?
@@ -48,7 +82,7 @@ module Discharger
48
82
  protected
49
83
 
50
84
  def source
51
- config.github_packages&.source if config.respond_to?(:github_packages)
85
+ config.github_packages&.source
52
86
  end
53
87
 
54
88
  def gh_installed?
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "logger"
5
+ require_relative "../timing"
5
6
  require_relative "command_factory"
6
7
 
7
8
  module Discharger
@@ -15,7 +16,7 @@ module Discharger
15
16
  @config = config
16
17
  @app_root = app_root || Dir.pwd
17
18
  @logger = logger || Logger.new($stdout)
18
- @command_factory = CommandFactory.new(config, app_root, logger)
19
+ @command_factory = CommandFactory.new(config, @app_root, @logger)
19
20
  end
20
21
 
21
22
  def run
@@ -25,12 +26,16 @@ module Discharger
25
26
  puts Rainbow("=" * 50).blue
26
27
  end
27
28
 
28
- FileUtils.chdir app_root do
29
- execute_commands
29
+ warn_unscheduled_commands
30
+
31
+ elapsed = Timing.measure do
32
+ FileUtils.chdir app_root do
33
+ execute_commands
34
+ end
30
35
  end
31
36
 
32
37
  unless ENV["QUIET_SETUP"] || ENV["DISABLE_OUTPUT"]
33
- puts Rainbow("\n✅ Setup completed successfully!").bright.green
38
+ puts Rainbow(format("\n✅ Setup completed successfully! (%.2fs)", elapsed)).bright.green
34
39
  end
35
40
  rescue => e
36
41
  unless ENV["QUIET_SETUP"] || ENV["DISABLE_OUTPUT"]
@@ -76,6 +81,18 @@ module Discharger
76
81
  @commands ||= command_factory.create_all_commands
77
82
  end
78
83
 
84
+ # Checked at run time, after the customization block, so commands
85
+ # scheduled through add_command/insert_command_* count as scheduled.
86
+ def warn_unscheduled_commands
87
+ CommandRegistry.all.each do |klass|
88
+ next unless klass.respond_to?(:unscheduled_warning)
89
+ next if commands.any? { |command| command.is_a?(klass) }
90
+
91
+ message = klass.unscheduled_warning(config)
92
+ logger.warn message if message
93
+ end
94
+ end
95
+
79
96
  def execute_commands
80
97
  commands.each do |command|
81
98
  execute_command(command)
@@ -94,7 +111,10 @@ module Discharger
94
111
  unless ENV["QUIET_SETUP"] || ENV["DISABLE_OUTPUT"]
95
112
  puts Rainbow("\n▶️ #{command.description}").bright
96
113
  end
97
- command.execute
114
+ elapsed = Timing.measure { command.execute }
115
+ unless ENV["QUIET_SETUP"] || ENV["DISABLE_OUTPUT"]
116
+ puts Rainbow(format(" %s: %.2fs", command.description, elapsed)).faint
117
+ end
98
118
  rescue => e
99
119
  unless ENV["QUIET_SETUP"] || ENV["DISABLE_OUTPUT"]
100
120
  require "rainbow"
@@ -25,6 +25,7 @@ module Discharger
25
25
  reissue.clear_fragments = task.clear_fragments
26
26
  reissue.tag_pattern = task.tag_pattern
27
27
  reissue.runbook_file = task.runbook_file
28
+ reissue.retain_changelogs = task.retain_changelogs
28
29
  end
29
30
  task.define
30
31
  task
@@ -46,6 +47,7 @@ module Discharger
46
47
  attr_accessor :app_name
47
48
  attr_accessor :commit_identifier
48
49
  attr_accessor :pull_request_url
50
+ attr_accessor :pr_label
49
51
  attr_accessor :fragment_directory
50
52
  attr_accessor :fragment
51
53
  attr_accessor :clear_fragments
@@ -134,32 +136,41 @@ module Discharger
134
136
  ERROR
135
137
  end
136
138
 
137
- # Abort if HEAD is not the commit that last touched the version file
138
- def validate_release_commit!(branch, output: $stdout)
139
- head_sha = git_local_sha(branch)
140
- release_sha = git_version_file_commit(branch)
139
+ # The newest commit that changed the current version's dated changelog section
140
+ def find_release_commit!(branch, output: $stdout)
141
+ version = Object.const_get(version_constant)
142
+ section = /^## \[#{Regexp.escape(version)}\] - \d{4}-\d{2}-\d{2}.*?(?=^## \[|\z)/m
143
+ sha = git_file_commits(branch, changelog_file).find do |candidate_sha|
144
+ released = git_show_at_commit(candidate_sha, changelog_file)&.[](section)
145
+ released && released != git_show_at_commit("#{candidate_sha}^", changelog_file)&.[](section)
146
+ end
141
147
 
142
- if head_sha.nil? || release_sha.nil?
148
+ if sha.nil?
143
149
  abort <<~ERROR.bg(:red).white
144
- Could not determine release commit.
145
-
146
- HEAD: #{head_sha || "not found"}
147
- Release commit: #{release_sha || "not found"}
148
-
149
- Ensure #{branch} exists and #{version_file} has been modified.
150
+ Could not locate a release commit.
151
+ Ensure #{branch} exists and #{changelog_file} has been finalized for #{version}.
150
152
  ERROR
151
153
  end
152
154
 
153
- return sysecho("✓ HEAD is the release commit (#{head_sha[0, 8]})".bg(:green).black, output:) if head_sha == release_sha
155
+ sysecho("✓ Release commit: #{sha[0, 8]}".bg(:green).black, output:)
156
+ sha
157
+ end
154
158
 
155
- abort <<~ERROR.bg(:red).white
156
- HEAD is not the release commit!
159
+ def merge_tag_into_production_branch(tag, output: $stdout)
160
+ git = ->(*args) do
161
+ stdout, stderr, status = Open3.capture3("git", *args)
162
+ return stdout.strip if status.success?
157
163
 
158
- HEAD: #{head_sha[0, 8]}
159
- Release commit: #{release_sha[0, 8]} (last commit to touch #{version_file})
164
+ sysecho("Could not merge #{tag} into #{production_branch}: #{stderr.strip}".bg(:yellow).black, output:)
165
+ nil
166
+ end
160
167
 
161
- Something was merged after the release PR. Verify the branch contents and retry.
162
- ERROR
168
+ return false unless git.call("fetch", "origin", production_branch)
169
+ return false unless (tree = git.call("merge-tree", "--write-tree", "origin/#{production_branch}", tag))
170
+ return false unless (commit = git.call("commit-tree", tree, "-p", "origin/#{production_branch}", "-p", "#{tag}^{commit}", "-m", "Merge tag '#{tag}'"))
171
+ return false unless git.call("push", "origin", "#{commit}:refs/heads/#{production_branch}")
172
+
173
+ sysecho("✓ Merged #{tag} into #{production_branch}".bg(:green).black, output:)
163
174
  end
164
175
 
165
176
  def git_show_version(branch)
@@ -168,16 +179,57 @@ module Discharger
168
179
  content[/VERSION\s*=\s*["']([^"']+)["']/, 1]
169
180
  end
170
181
 
171
- def git_local_sha(branch)
172
- stdout, _, status = Open3.capture3("git", "rev-parse", branch)
173
- return nil unless status.success?
174
- stdout.strip
182
+ def git_file_commits(branch, path)
183
+ stdout, _, status = Open3.capture3("git", "log", "--no-merges", branch, "--format=%H", "--", path)
184
+ return [] unless status.success?
185
+ stdout.lines.map(&:strip)
175
186
  end
176
187
 
177
- def git_version_file_commit(branch)
178
- stdout, _, status = Open3.capture3("git", "log", branch, "-1", "--format=%H", "--", version_file)
188
+ def git_show_at_commit(sha, path)
189
+ stdout, _, status = Open3.capture3("git", "show", "#{sha}:#{path}")
179
190
  return nil unless status.success?
180
- stdout.strip
191
+ stdout
192
+ end
193
+
194
+ def ensure_clean_worktree!
195
+ stdout, _, status = Open3.capture3("git", "status", "--porcelain")
196
+ return true if status.success? && stdout.empty?
197
+
198
+ abort "Working tree has uncommitted changes. Commit or stash them before running rake #{name}:prepare."
199
+ end
200
+
201
+ def validate_pr_label!
202
+ return true unless pr_label
203
+
204
+ stdout, stderr, status = Open3.capture3("gh", "label", "list", "--search", pr_label, "--json", "name", "--jq", ".[].name")
205
+ return true if status.success? && stdout.lines.map(&:chomp).include?(pr_label)
206
+
207
+ abort <<~ERROR.bg(:red).white
208
+ Could not find GitHub label '#{pr_label}'.
209
+
210
+ #{stderr}
211
+ ERROR
212
+ end
213
+
214
+ def create_labeled_pr!(head:, title:, body:)
215
+ if (pr_number = existing_pr_number(working_branch, head))
216
+ return sysecho("Reusing existing PR ##{pr_number} for #{head}.")
217
+ end
218
+
219
+ syscall(
220
+ ["gh", "pr", "create", "--base", working_branch, "--head", head, "--title", title, "--body", body, "--label", pr_label]
221
+ )
222
+ end
223
+
224
+ def ensure_branch_not_ahead!(branch)
225
+ stdout, _, status = Open3.capture3("git", "rev-list", "--count", "origin/#{branch}..#{branch}")
226
+ return true if status.success? && stdout.strip == "0"
227
+
228
+ abort <<~ERROR.bg(:red).white
229
+ Local #{branch} has commits not on origin/#{branch}. Refusing to reset --hard.
230
+
231
+ Push or remove them before retrying.
232
+ ERROR
181
233
  end
182
234
 
183
235
  # The post-release steps collected from "Runbook:" commit trailers.
@@ -214,6 +266,10 @@ module Discharger
214
266
  message
215
267
  end
216
268
 
269
+ def echo_unsent_slack_message(text, channel)
270
+ sysecho "Post this to #{channel} yourself:\n\n#{text}\n"
271
+ end
272
+
217
273
  def existing_pr_number(base, head)
218
274
  stdout, _, status = Open3.capture3(
219
275
  "gh", "pr", "list",
@@ -258,6 +314,7 @@ module Discharger
258
314
  unless system("gh --version > /dev/null 2>&1")
259
315
  abort "Error: GitHub CLI (gh) is required for the release process but was not found. Install it: https://cli.github.com"
260
316
  end
317
+ validate_pr_label!
261
318
 
262
319
  current_version = Object.const_get(version_constant)
263
320
 
@@ -265,10 +322,16 @@ module Discharger
265
322
  # instead of staging_branch (for CI/CD pipelines that auto-deploy staging)
266
323
  release_source = auto_deploy_staging ? working_branch : staging_branch
267
324
 
325
+ release_action = if auto_deploy_staging
326
+ "This will tag the release commit on #{working_branch}, push the tag, and merge it into #{production_branch}."
327
+ else
328
+ "This will tag the current version and push it to the production branch."
329
+ end
330
+
268
331
  sysecho <<~MSG
269
332
  Releasing version #{current_version} to production.
270
333
 
271
- This will tag the current version and push it to the production branch.
334
+ #{release_action}
272
335
  Release source: #{release_source}
273
336
  MSG
274
337
  sysecho "Are you ready to continue? (Press Enter to continue, Type 'x' and Enter to exit)".bg(:yellow).black
@@ -283,48 +346,39 @@ module Discharger
283
346
 
284
347
  if auto_deploy_staging
285
348
  syscall(
286
- ["git fetch origin #{production_branch}:#{production_branch} #{working_branch}"],
349
+ ["git fetch origin #{working_branch}"],
287
350
  ["git reset --hard origin/#{working_branch}"]
288
351
  )
289
- validate_release_commit!(release_source)
352
+ tag_ref = find_release_commit!(release_source)
290
353
  else
291
354
  syscall(
292
355
  ["git fetch origin #{release_source}:#{release_source} #{production_branch}:#{production_branch}"]
293
356
  )
294
357
  validate_version_match!(staging_branch, working_branch)
295
- end
296
358
 
297
- pr_ref = release_source
298
- if auto_deploy_staging
299
- pr_number = existing_pr_number(production_branch, release_source)
300
- if pr_number
301
- sysecho "Reusing existing PR ##{pr_number} from #{release_source} to #{production_branch}."
302
- pr_ref = pr_number
359
+ pr_ref = release_source
360
+ if pr_already_merged?(pr_ref)
361
+ sysecho "PR #{pr_ref} is already merged. Continuing..."
303
362
  else
304
363
  syscall(
305
- ["gh pr create --base #{production_branch} --head #{release_source} --title 'Release #{current_version}' --body 'Deploy #{current_version} to production.'"]
364
+ ["gh pr merge #{pr_ref} --merge"]
306
365
  )
307
366
  end
308
- end
309
367
 
310
- if pr_already_merged?(pr_ref)
311
- sysecho "PR #{pr_ref} is already merged. Continuing..."
312
- else
313
- syscall(
314
- ["gh pr merge #{pr_ref} --merge"]
315
- )
368
+ syscall(["git fetch origin #{production_branch}:#{production_branch}"])
369
+ tag_ref = production_branch
316
370
  end
317
371
 
372
+ slack_sha = auto_deploy_staging ? tag_ref[0, 8] : commit_identifier.call
318
373
  continue = syscall(
319
- ["git fetch origin #{production_branch}:#{production_branch}"],
320
- ["git tag -a v#{current_version} -m 'Release #{current_version}' #{production_branch}"],
374
+ ["git tag -a v#{current_version} -m 'Release #{current_version}' #{tag_ref}"],
321
375
  ["git push origin v#{current_version}"]
322
376
  ) do
323
- tasker["#{name}:slack"].invoke("Released #{app_name} #{current_version} (#{commit_identifier.call}) to production.", release_message_channel, ":chipmunk:")
377
+ tasker["#{name}:slack"].invoke("Released #{app_name} #{current_version} (#{slack_sha}) to production.", release_message_channel, ":chipmunk:")
324
378
  # Capture the root before replying; each post overwrites last_message_ts.
325
379
  thread_ts = last_message_ts
326
380
  if thread_ts.present?
327
- text = File.read(Rails.root.join(changelog_file))
381
+ text = git_show_at_commit(tag_ref, changelog_file) || File.read(Rails.root.join(changelog_file))
328
382
  tasker["#{name}:slack"].reenable
329
383
  tasker["#{name}:slack"].invoke(text, release_message_channel, ":log:", thread_ts)
330
384
 
@@ -335,6 +389,7 @@ module Discharger
335
389
  end
336
390
 
337
391
  abort "Release failed." unless continue
392
+ merge_tag_into_production_branch("v#{current_version}") if auto_deploy_staging
338
393
 
339
394
  sysecho <<~MSG
340
395
  Version #{current_version} released to production.
@@ -346,20 +401,26 @@ module Discharger
346
401
 
347
402
  new_version_branch = `git rev-parse --abbrev-ref HEAD`.strip
348
403
  new_version = new_version_branch.split("/").last
349
- params = {expand: 1, title: "Bump version to #{new_version}"}
350
- pr_url = "#{pull_request_url}/compare/#{working_branch}...#{new_version_branch}?#{params.to_query}"
351
404
 
352
- syscall(["git push origin #{new_version_branch} --force"]) do
353
- sysecho <<~MSG
354
- Branch #{new_version_branch} created.
405
+ if pr_label
406
+ syscall(["git push origin #{new_version_branch} --force"])
407
+ create_labeled_pr!(head: new_version_branch, title: "Bump version to #{new_version}", body: "")
408
+ else
409
+ params = {expand: 1, title: "Bump version to #{new_version}"}
410
+ pr_url = "#{pull_request_url}/compare/#{working_branch}...#{new_version_branch}?#{params.to_query}"
411
+
412
+ syscall(["git push origin #{new_version_branch} --force"]) do
413
+ sysecho <<~MSG
414
+ Branch #{new_version_branch} created.
355
415
 
356
- Open a PR to #{working_branch} to mark the version and update the chaneglog
357
- for the next release.
416
+ Open a PR to #{working_branch} to mark the version and update the chaneglog
417
+ for the next release.
358
418
 
359
- Opening PR: #{pr_url}
360
- MSG
361
- end.then do |success|
362
- syscall ["open", pr_url] if success
419
+ Opening PR: #{pr_url}
420
+ MSG
421
+ end.then do |success|
422
+ syscall ["open", pr_url] if success
423
+ end
363
424
  end
364
425
  end
365
426
 
@@ -402,17 +463,33 @@ module Discharger
402
463
 
403
464
  desc "Send a message to Slack."
404
465
  task :slack, [:text, :channel, :emoji, :ts] => :environment do |_, args|
466
+ instance_variable_set(:@last_message_ts, nil)
405
467
  args.with_defaults(
406
468
  channel: release_message_channel,
407
469
  emoji: nil
408
470
  )
471
+ if chat_token.blank?
472
+ sysecho <<~MSG.bg(:yellow).black
473
+ Slack message not sent: chat_token is not set.
474
+ Set it with the Slack release token from your team's password manager before the next release.
475
+ MSG
476
+ echo_unsent_slack_message(args[:text], args[:channel])
477
+ next
478
+ end
479
+
409
480
  client = Slack::Web::Client.new
410
481
  options = args.to_h
411
482
  options[:icon_emoji] = options.delete(:emoji) if options[:emoji]
412
483
  options[:thread_ts] = options.delete(:ts) if options[:ts]
413
484
 
414
485
  sysecho "Sending message to Slack:".bg(:green).black + " #{args[:text]}"
415
- result = client.chat_postMessage(**options)
486
+ begin
487
+ result = client.chat_postMessage(**options)
488
+ rescue Faraday::Error => e
489
+ sysecho "Slack message not sent: #{e.message}.".bg(:yellow).black
490
+ echo_unsent_slack_message(args[:text], args[:channel])
491
+ next
492
+ end
416
493
  instance_variable_set(:@last_message_ts, result["ts"])
417
494
  sysecho %(Message sent: #{result["ts"]})
418
495
  end
@@ -429,12 +506,19 @@ module Discharger
429
506
  the release.
430
507
  DESC
431
508
  task prepare: [:environment] do
509
+ ensure_clean_worktree!
510
+ validate_pr_label!
511
+
432
512
  current_version = Object.const_get(version_constant)
433
513
  finish_branch = "bump/finish-#{current_version.tr(".", "-")}"
434
514
 
435
515
  syscall(
436
516
  ["git fetch origin #{working_branch}"],
437
- ["git checkout #{working_branch}"],
517
+ ["git checkout #{working_branch}"]
518
+ )
519
+ ensure_branch_not_ahead!(working_branch)
520
+ syscall(
521
+ ["git reset --hard origin/#{working_branch}"],
438
522
  ["git checkout -b #{finish_branch}"]
439
523
  )
440
524
  sysecho <<~MSG
@@ -451,34 +535,52 @@ module Discharger
451
535
 
452
536
  tasker["reissue:finalize"].invoke
453
537
 
454
- params = {
455
- expand: 1,
456
- title: "Finish version #{current_version}",
457
- body: <<~BODY
458
- Completing development for #{current_version}.
459
- BODY
460
- }
461
-
462
- pr_url = "#{pull_request_url}/compare/#{finish_branch}?#{params.to_query}"
463
-
464
538
  next_step = auto_deploy_staging ? "rake #{name}" : "rake #{name}:stage"
465
539
  next_step_desc = auto_deploy_staging ? "release to production" : "stage the release branch"
540
+ after_merge = <<~MSG.chomp
541
+ Once the PR is merged, pull down #{working_branch} and run
542
+ '#{next_step}'
543
+ to #{next_step_desc}.
544
+ MSG
466
545
 
467
- continue = syscall ["git push origin #{finish_branch} --force"] do
546
+ if pr_label
547
+ syscall ["git push origin #{finish_branch} --force"]
548
+ create_labeled_pr!(
549
+ head: finish_branch,
550
+ title: "Finish version #{current_version}",
551
+ body: "Completing development for #{current_version}."
552
+ )
468
553
  sysecho <<~MSG
469
- Branch #{finish_branch} created.
470
- Open a PR to #{working_branch} to finalize the release.
471
-
472
- #{pr_url}
554
+ Branch #{finish_branch} pushed and its PR labeled '#{pr_label}'.
473
555
 
474
- Once the PR is merged, pull down #{working_branch} and run
475
- '#{next_step}'
476
- to #{next_step_desc}.
556
+ #{after_merge}
477
557
  MSG
478
- end
479
- if continue
480
- syscall ["git checkout #{working_branch}"],
481
- ["open", pr_url]
558
+ syscall ["git checkout #{working_branch}"]
559
+ else
560
+ params = {
561
+ expand: 1,
562
+ title: "Finish version #{current_version}",
563
+ body: <<~BODY
564
+ Completing development for #{current_version}.
565
+ BODY
566
+ }
567
+
568
+ pr_url = "#{pull_request_url}/compare/#{finish_branch}?#{params.to_query}"
569
+
570
+ continue = syscall ["git push origin #{finish_branch} --force"] do
571
+ sysecho <<~MSG
572
+ Branch #{finish_branch} created.
573
+ Open a PR to #{working_branch} to finalize the release.
574
+
575
+ #{pr_url}
576
+
577
+ #{after_merge}
578
+ MSG
579
+ end
580
+ if continue
581
+ syscall ["git checkout #{working_branch}"],
582
+ ["open", pr_url]
583
+ end
482
584
  end
483
585
  end
484
586
 
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Discharger
4
+ module Timing
5
+ # Seconds the block took, from the monotonic clock.
6
+ def self.measure
7
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
8
+ yield
9
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
10
+ end
11
+ end
12
+ end
@@ -1,3 +1,3 @@
1
1
  module Discharger
2
- VERSION = "0.3.5"
2
+ VERSION = "0.4.1"
3
3
  end
@@ -8,7 +8,14 @@ module Discharger
8
8
  default: "bin/setup",
9
9
  desc: "Path where the setup script should be created"
10
10
 
11
+ class_option :setup_only,
12
+ type: :boolean,
13
+ default: false,
14
+ desc: "Only (re)generate the setup script, leaving config/setup.yml and the initializer alone"
15
+
11
16
  def copy_initializer
17
+ return if options[:setup_only]
18
+
12
19
  template "discharger_initializer.rb", "config/initializers/discharger.rb"
13
20
  end
14
21
 
@@ -18,6 +25,8 @@ module Discharger
18
25
  end
19
26
 
20
27
  def create_sample_setup_yml
28
+ return if options[:setup_only]
29
+
21
30
  template "setup.yml", "config/setup.yml"
22
31
  end
23
32
  end
@@ -1,30 +1,51 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- # Path to the application root.
4
- APP_ROOT = File.expand_path("..", __dir__)
5
-
6
- Dir.chdir(APP_ROOT) do
7
- # This script uses Discharger to set up your development environment automatically.
8
- # All setup steps are configured in config/setup.yml
9
- # This script is idempotent, so you can run it at any time and get an expectable outcome.
10
-
11
- # Find discharger gem path from Gemfile without loading bundler
12
- # (loading bundler early can cause gem version conflicts)
13
- gemfile = File.read("Gemfile")
14
- if (match = gemfile.match(/gem\s+["']discharger["'].*path:\s*["']([^"']+)["']/))
15
- discharger_path = File.expand_path(match[1], APP_ROOT)
16
- $LOAD_PATH.unshift(File.join(discharger_path, "lib"))
17
- else
18
- # Fall back to installed gem
19
- begin
20
- gem "discharger"
21
- rescue Gem::MissingSpecError
22
- puts "Could not find discharger gem."
23
- puts "If using a custom bundle path, try: bundle exec bin/setup"
24
- exit 1
3
+ # GENERATED BY DISCHARGER. DO NOT EDIT. Edits here are lost on regeneration.
4
+ #
5
+ # App setup belongs in config/setup.yml (pre_steps, steps, custom_steps).
6
+ # Script changes belong in the discharger gem's template,
7
+ # lib/generators/discharger/install/templates/setup; release it, bump it, then
8
+ # bin/rails generate discharger:install --setup-only --force
9
+
10
+ script = File.expand_path(__FILE__)
11
+ Dir.chdir(File.expand_path("..", __dir__))
12
+
13
+ # First pass, standard library only: the bundle may not exist yet. Re-exec
14
+ # under bundle exec before yaml loads, or a default psych clashes with Gemfile.lock.
15
+ # Mirrors the github_packages and bundler steps, which re-run under bundle exec.
16
+ unless ENV["DISCHARGER_SETUP_BUNDLED"]
17
+ def store_github_packages_credentials
18
+ require "yaml"
19
+ source = begin
20
+ YAML.safe_load_file("config/setup.yml")&.dig("github_packages", "source")
21
+ rescue SystemCallError, Psych::Exception
22
+ nil # reported by Discharger::Setup on the second pass
23
+ end
24
+ return if source.to_s.empty?
25
+ unless `gh auth status 2>&1`.include?("read:packages")
26
+ warn "Skipping #{source} credentials: the gh token lacks read:packages (gh auth refresh -s read:packages)"
27
+ return
25
28
  end
29
+
30
+ username = `gh api user --jq .login 2>/dev/null`.strip
31
+ token = `gh auth token 2>/dev/null`.strip
32
+ return if username.empty? || token.empty?
33
+
34
+ system("bundle", "config", "set", "--local", source, "#{username}:#{token}", out: File::NULL, err: File::NULL)
26
35
  end
27
36
 
28
- require "discharger/setup"
29
- Discharger::Setup.run("config/setup.yml")
37
+ unless system("bundle", "check", out: File::NULL, err: File::NULL)
38
+ store_github_packages_credentials
39
+ system("bundle", "install") || abort(<<~MSG)
40
+
41
+ == bundle install failed ==
42
+ If the Gemfile uses a private GitHub Packages source, run `gh auth login`
43
+ (or `gh auth refresh -s read:packages`) and rerun bin/setup.
44
+ MSG
45
+ end
46
+
47
+ exec({"DISCHARGER_SETUP_BUNDLED" => "1"}, "bundle", "exec", RbConfig.ruby, script, *ARGV)
30
48
  end
49
+
50
+ require "discharger/setup"
51
+ Discharger::Setup.run("config/setup.yml")
@@ -49,6 +49,8 @@ steps:
49
49
  - brew
50
50
  - asdf
51
51
  - git
52
+ # Uncomment with the github_packages block above. Must precede bundler.
53
+ # - github_packages
52
54
  - bundler
53
55
  - yarn
54
56
  - config
@@ -60,6 +62,11 @@ steps:
60
62
  # - pg_tools
61
63
 
62
64
  # Custom commands for application-specific setup (run AFTER Rails loads)
65
+ # Steps run last by default. Give a step a name and list it in steps: above
66
+ # to run it earlier, e.g.:
67
+ # - name: import_certs
68
+ # description: "Import development certificates"
69
+ # command: "bin/import-certs"
63
70
  custom_steps:
64
71
  - description: "Seed application data"
65
72
  command: "bin/rails db:seed"
metadata CHANGED
@@ -1,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: discharger
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.5
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay
8
- - Savannah Moore
8
+ - Savannah Albanez
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
  date: 1980-01-02 00:00:00.000000000 Z
@@ -135,6 +135,7 @@ files:
135
135
  - lib/discharger/setup_runner/runner.rb
136
136
  - lib/discharger/setup_runner/version.rb
137
137
  - lib/discharger/task.rb
138
+ - lib/discharger/timing.rb
138
139
  - lib/discharger/version.rb
139
140
  - lib/generators/discharger/install/install_generator.rb
140
141
  - lib/generators/discharger/install/templates/discharger_initializer.rb