discharger 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25a6ab71ff65d9ada8fe82ce0a706b08d335aaa533b10c1b57a652434149cd91
4
- data.tar.gz: 524b68d60300a017c7d83bc7162f1962819b44f1be1255ec1fd8651b47740374
3
+ metadata.gz: 3b0eda964739589ae49d73ea992ff80d8e782b3de287ca19fcb1a82c605d1638
4
+ data.tar.gz: 5d86440df15d87cb9a66b5133dc1d70d8f736bd0644a7b9f57b5a4d2a3428e89
5
5
  SHA512:
6
- metadata.gz: 7b67aaf1127f81ca25f3f4f78ce3fc5884920048baa022128b46dfa24e4ab01dee3b4574894d7e3305026cdfc7eb4659351b3cf649dc509e2d8c76982207bf01
7
- data.tar.gz: 894a03d8fd806db6d0c831a1c47191332aa5cae0ab02fdf7709a040cab2e50f9e8a2ee02030d6e5015bfe2ab03d8a5e7ae806d6e79e9f0fe3f020a8f2cdc8a75
6
+ metadata.gz: 82c6912aee9e99edeb92304f26a0443983495b4aa8b3152cea7497da0da895ad892b8d392bdd25d7a1d559176c6a412589fd9b2dab0bc8fae9fb1c28019602ad
7
+ data.tar.gz: 48a5949f5177ace0de43d0788ce3f45a6dd24b558478704014b5234c6625e23737c209f831550ed224c1ca6b6869f08d0fdb8c30515f1af6efebafeb129c80db
data/CHANGELOG.md CHANGED
@@ -5,14 +5,24 @@ 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.4] - 2026-07-22
8
+ ## [0.4.0] - 2026-09-24
9
9
 
10
10
  ### Added
11
11
 
12
- - Runbook announcement on stage builds, previewing post-release steps before production (c77b552)
12
+ - pr_label option opening the finalize and version-bump PRs via gh with that label (08e1d10)
13
13
 
14
- ## [0.3.3] - 2026-07-22
14
+ ### Changed
15
+
16
+ - 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)
17
+ - Auto-deploy releases merge the release tag into the production branch (a7a1b49)
18
+
19
+ ### Fixed
20
+
21
+ - retain_changelogs reaches Reissue, so finalize writes the archived changelog file (2a90edf)
22
+ - release:prepare branches from origin, so a stale local branch no longer yields an empty changelog (8eb5fd3)
23
+
24
+ ## [0.3.5] - 2026-08-10
15
25
 
16
26
  ### Added
17
27
 
18
- - Post-release runbook announcement in the Slack release thread, from Reissue's runbook_file (0cf9620)
28
+ - github_packages setup step storing bundler credentials for a private GitHub Packages gem source via the gh CLI (2cff4b6)
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.
@@ -274,6 +284,7 @@ The `steps` array specifies which built-in setup commands to run. Available comm
274
284
  - `brew` - Install Homebrew dependencies
275
285
  - `asdf` - Setup version management with asdf
276
286
  - `git` - Configure git settings
287
+ - `github_packages` - Store bundler credentials for a private GitHub Packages gem source using the GitHub CLI (run before `bundler`; requires a `github_packages.source` config entry)
277
288
  - `bundler` - Install Ruby gems
278
289
  - `yarn` - Install JavaScript packages
279
290
  - `config` - Copy configuration files
@@ -294,7 +305,7 @@ steps:
294
305
  - yarn
295
306
  ```
296
307
 
297
- Leave the array empty or omit it entirely to run all available steps.
308
+ 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.
298
309
 
299
310
  ### Adding Custom Commands
300
311
 
@@ -321,6 +332,26 @@ Each custom step can include:
321
332
  - `description` - A description shown during setup
322
333
  - `command` - The command to execute
323
334
  - `condition` - An optional Ruby expression that must evaluate to true for the command to run
335
+ - `name` - An optional name that lets the step be scheduled in the `steps` list
336
+
337
+ Custom steps run after the built-in steps, in the order listed. To run one
338
+ earlier, give it a `name` and list that name in `steps`:
339
+
340
+ ```yaml
341
+ custom_steps:
342
+ - name: import_certs
343
+ description: "Import development certificates"
344
+ command: "bin/import-certs"
345
+
346
+ steps:
347
+ - brew
348
+ - import_certs # runs between brew and bundler
349
+ - bundler
350
+ ```
351
+
352
+ A named step listed in `steps` runs only at that position; named steps that
353
+ are not listed keep the default run-last behavior. A name that collides with
354
+ a built-in step resolves to the built-in, and a warning is logged.
324
355
 
325
356
  ### Creating Custom Command Classes
326
357
 
@@ -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
 
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_command"
4
+
5
+ module Discharger
6
+ module SetupRunner
7
+ module Commands
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.
12
+ class GithubPackagesCommand < BaseCommand
13
+ # A configured source with nothing scheduled to store its credentials
14
+ # is inert, and the only symptom is bundler failing against the
15
+ # private source. The Runner surfaces this before executing commands.
16
+ # Guarded for duck-typed configs passed to the public Runner API, and
17
+ # quiet when a custom step's command references the source (assumed
18
+ # to handle credentials itself).
19
+ def self.unscheduled_warning(config)
20
+ return unless config.respond_to?(:github_packages)
21
+
22
+ source = config.github_packages&.source
23
+ return if source.to_s.empty?
24
+ if config.respond_to?(:custom_steps) &&
25
+ config.custom_steps.any? { |step| step_handles_source?(step, source) }
26
+ return
27
+ end
28
+
29
+ "github_packages is configured but missing from steps; " \
30
+ "credentials will not be stored and bundler may fail for #{source}"
31
+ end
32
+
33
+ # A referencing step only counts as handling credentials when it will
34
+ # actually run: CustomCommand skips itself when its condition is
35
+ # false, storing nothing on this run.
36
+ def self.step_handles_source?(step, source)
37
+ return false unless step["command"].to_s.include?(source)
38
+
39
+ require_relative "../condition_evaluator"
40
+ ConditionEvaluator.evaluate(step["condition"])
41
+ end
42
+ private_class_method :step_handles_source?
43
+
44
+ def execute
45
+ unless gh_installed?
46
+ log "GitHub CLI (gh) not found; skipping. Install gh and rerun setup or `bundle install` may fail for #{source}"
47
+ return
48
+ end
49
+
50
+ unless authenticated? || login
51
+ log "Not logged in to GitHub; skipping. Run `gh auth login` and rerun setup."
52
+ return
53
+ end
54
+
55
+ username = gh("api", "user", "--jq", ".login")
56
+ token = gh("auth", "token")
57
+ if username.to_s.empty? || token.to_s.empty?
58
+ log "Could not read GitHub credentials from gh; skipping."
59
+ return
60
+ end
61
+
62
+ store_bundler_credentials(username, token)
63
+ if credentials_valid?(username, token)
64
+ log "Configured bundler credentials for #{source}"
65
+ else
66
+ log "#{source} rejected the stored credentials. Your gh token may lack " \
67
+ "the read:packages scope — run `gh auth refresh -s read:packages` and rerun setup."
68
+ end
69
+ end
70
+
71
+ def can_execute?
72
+ !source.to_s.empty?
73
+ end
74
+
75
+ def description
76
+ "Configure GitHub Packages credentials"
77
+ end
78
+
79
+ protected
80
+
81
+ def source
82
+ config.github_packages&.source
83
+ end
84
+
85
+ def gh_installed?
86
+ system_quiet("which gh")
87
+ end
88
+
89
+ def authenticated?
90
+ system_quiet("gh auth status")
91
+ end
92
+
93
+ def login
94
+ return false unless $stdin.tty?
95
+ log "Logging in to GitHub..."
96
+ system("gh", "auth", "login")
97
+ authenticated?
98
+ end
99
+
100
+ def gh(*args)
101
+ require "open3"
102
+ stdout, _stderr, status = Open3.capture3("gh", *args)
103
+ status.success? ? stdout.chomp : nil
104
+ end
105
+
106
+ # Runs bundle config directly instead of through system! so the
107
+ # token never reaches the spinner display or the debug log.
108
+ def store_bundler_credentials(username, token)
109
+ require "open3"
110
+ _stdout, stderr, status = Open3.capture3(
111
+ "bundle", "config", "set", "--local", source, "#{username}:#{token}"
112
+ )
113
+ raise "bundle config set --local #{source} failed: #{stderr}" unless status.success?
114
+ end
115
+
116
+ # Probes the source's compact index with the stored credentials.
117
+ # Only a definite 401/403 counts as invalid — network trouble must
118
+ # not fail setup over an unverifiable token.
119
+ def credentials_valid?(username, token)
120
+ require "net/http"
121
+ require "uri"
122
+ uri = URI.join("#{source.chomp("/")}/", "versions")
123
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
124
+ open_timeout: 5, read_timeout: 10) do |http|
125
+ request = Net::HTTP::Get.new(uri)
126
+ request.basic_auth(username, token)
127
+ http.request(request)
128
+ end
129
+ !%w[401 403].include?(response.code)
130
+ rescue => e
131
+ logger&.debug("Could not verify GitHub Packages credentials: #{e.message}")
132
+ true
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -5,12 +5,13 @@ require "yaml"
5
5
  module Discharger
6
6
  module SetupRunner
7
7
  class Configuration
8
- attr_accessor :app_name, :database, :redis, :services, :steps, :custom_steps, :pre_steps
8
+ attr_accessor :app_name, :database, :redis, :github_packages, :services, :steps, :custom_steps, :pre_steps
9
9
 
10
10
  def initialize
11
11
  @app_name = "Application"
12
12
  @database = DatabaseConfig.new
13
13
  @redis = nil
14
+ @github_packages = nil
14
15
  @services = []
15
16
  @steps = []
16
17
  @custom_steps = []
@@ -27,6 +28,7 @@ module Discharger
27
28
  config.app_name = yaml["app_name"] if yaml["app_name"]
28
29
  config.database.from_hash(yaml["database"]) if yaml["database"]
29
30
  config.redis = RedisConfig.new.tap { |r| r.from_hash(yaml["redis"]) } if yaml["redis"]
31
+ config.github_packages = GithubPackagesConfig.new.tap { |g| g.from_hash(yaml["github_packages"]) } if yaml["github_packages"]
30
32
  config.services = yaml["services"] || []
31
33
  config.steps = yaml["steps"] || []
32
34
  config.custom_steps = yaml["custom_steps"] || []
@@ -56,6 +58,18 @@ module Discharger
56
58
  end
57
59
  end
58
60
 
61
+ class GithubPackagesConfig
62
+ attr_accessor :source
63
+
64
+ def initialize
65
+ @source = nil
66
+ end
67
+
68
+ def from_hash(hash)
69
+ @source = hash["source"] if hash["source"]
70
+ end
71
+ end
72
+
59
73
  class RedisConfig
60
74
  attr_accessor :port, :name, :version
61
75
 
@@ -15,7 +15,7 @@ module Discharger
15
15
  @config = config
16
16
  @app_root = app_root || Dir.pwd
17
17
  @logger = logger || Logger.new($stdout)
18
- @command_factory = CommandFactory.new(config, app_root, logger)
18
+ @command_factory = CommandFactory.new(config, @app_root, @logger)
19
19
  end
20
20
 
21
21
  def run
@@ -25,6 +25,8 @@ module Discharger
25
25
  puts Rainbow("=" * 50).blue
26
26
  end
27
27
 
28
+ warn_unscheduled_commands
29
+
28
30
  FileUtils.chdir app_root do
29
31
  execute_commands
30
32
  end
@@ -76,6 +78,18 @@ module Discharger
76
78
  @commands ||= command_factory.create_all_commands
77
79
  end
78
80
 
81
+ # Checked at run time, after the customization block, so commands
82
+ # scheduled through add_command/insert_command_* count as scheduled.
83
+ def warn_unscheduled_commands
84
+ CommandRegistry.all.each do |klass|
85
+ next unless klass.respond_to?(:unscheduled_warning)
86
+ next if commands.any? { |command| command.is_a?(klass) }
87
+
88
+ message = klass.unscheduled_warning(config)
89
+ logger.warn message if message
90
+ end
91
+ end
92
+
79
93
  def execute_commands
80
94
  commands.each do |command|
81
95
  execute_command(command)
@@ -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
158
+
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?
154
163
 
155
- abort <<~ERROR.bg(:red).white
156
- HEAD is not the release commit!
164
+ sysecho("Could not merge #{tag} into #{production_branch}: #{stderr.strip}".bg(:yellow).black, output:)
165
+ nil
166
+ end
157
167
 
158
- HEAD: #{head_sha[0, 8]}
159
- Release commit: #{release_sha[0, 8]} (last commit to touch #{version_file})
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}")
160
172
 
161
- Something was merged after the release PR. Verify the branch contents and retry.
162
- ERROR
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.
@@ -258,6 +310,7 @@ module Discharger
258
310
  unless system("gh --version > /dev/null 2>&1")
259
311
  abort "Error: GitHub CLI (gh) is required for the release process but was not found. Install it: https://cli.github.com"
260
312
  end
313
+ validate_pr_label!
261
314
 
262
315
  current_version = Object.const_get(version_constant)
263
316
 
@@ -265,10 +318,16 @@ module Discharger
265
318
  # instead of staging_branch (for CI/CD pipelines that auto-deploy staging)
266
319
  release_source = auto_deploy_staging ? working_branch : staging_branch
267
320
 
321
+ release_action = if auto_deploy_staging
322
+ "This will tag the release commit on #{working_branch}, push the tag, and merge it into #{production_branch}."
323
+ else
324
+ "This will tag the current version and push it to the production branch."
325
+ end
326
+
268
327
  sysecho <<~MSG
269
328
  Releasing version #{current_version} to production.
270
329
 
271
- This will tag the current version and push it to the production branch.
330
+ #{release_action}
272
331
  Release source: #{release_source}
273
332
  MSG
274
333
  sysecho "Are you ready to continue? (Press Enter to continue, Type 'x' and Enter to exit)".bg(:yellow).black
@@ -283,48 +342,39 @@ module Discharger
283
342
 
284
343
  if auto_deploy_staging
285
344
  syscall(
286
- ["git fetch origin #{production_branch}:#{production_branch} #{working_branch}"],
345
+ ["git fetch origin #{working_branch}"],
287
346
  ["git reset --hard origin/#{working_branch}"]
288
347
  )
289
- validate_release_commit!(release_source)
348
+ tag_ref = find_release_commit!(release_source)
290
349
  else
291
350
  syscall(
292
351
  ["git fetch origin #{release_source}:#{release_source} #{production_branch}:#{production_branch}"]
293
352
  )
294
353
  validate_version_match!(staging_branch, working_branch)
295
- end
296
354
 
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
355
+ pr_ref = release_source
356
+ if pr_already_merged?(pr_ref)
357
+ sysecho "PR #{pr_ref} is already merged. Continuing..."
303
358
  else
304
359
  syscall(
305
- ["gh pr create --base #{production_branch} --head #{release_source} --title 'Release #{current_version}' --body 'Deploy #{current_version} to production.'"]
360
+ ["gh pr merge #{pr_ref} --merge"]
306
361
  )
307
362
  end
308
- end
309
363
 
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
- )
364
+ syscall(["git fetch origin #{production_branch}:#{production_branch}"])
365
+ tag_ref = production_branch
316
366
  end
317
367
 
368
+ slack_sha = auto_deploy_staging ? tag_ref[0, 8] : commit_identifier.call
318
369
  continue = syscall(
319
- ["git fetch origin #{production_branch}:#{production_branch}"],
320
- ["git tag -a v#{current_version} -m 'Release #{current_version}' #{production_branch}"],
370
+ ["git tag -a v#{current_version} -m 'Release #{current_version}' #{tag_ref}"],
321
371
  ["git push origin v#{current_version}"]
322
372
  ) do
323
- tasker["#{name}:slack"].invoke("Released #{app_name} #{current_version} (#{commit_identifier.call}) to production.", release_message_channel, ":chipmunk:")
373
+ tasker["#{name}:slack"].invoke("Released #{app_name} #{current_version} (#{slack_sha}) to production.", release_message_channel, ":chipmunk:")
324
374
  # Capture the root before replying; each post overwrites last_message_ts.
325
375
  thread_ts = last_message_ts
326
376
  if thread_ts.present?
327
- text = File.read(Rails.root.join(changelog_file))
377
+ text = git_show_at_commit(tag_ref, changelog_file) || File.read(Rails.root.join(changelog_file))
328
378
  tasker["#{name}:slack"].reenable
329
379
  tasker["#{name}:slack"].invoke(text, release_message_channel, ":log:", thread_ts)
330
380
 
@@ -335,6 +385,7 @@ module Discharger
335
385
  end
336
386
 
337
387
  abort "Release failed." unless continue
388
+ merge_tag_into_production_branch("v#{current_version}") if auto_deploy_staging
338
389
 
339
390
  sysecho <<~MSG
340
391
  Version #{current_version} released to production.
@@ -346,20 +397,26 @@ module Discharger
346
397
 
347
398
  new_version_branch = `git rev-parse --abbrev-ref HEAD`.strip
348
399
  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
400
 
352
- syscall(["git push origin #{new_version_branch} --force"]) do
353
- sysecho <<~MSG
354
- Branch #{new_version_branch} created.
401
+ if pr_label
402
+ syscall(["git push origin #{new_version_branch} --force"])
403
+ create_labeled_pr!(head: new_version_branch, title: "Bump version to #{new_version}", body: "")
404
+ else
405
+ params = {expand: 1, title: "Bump version to #{new_version}"}
406
+ pr_url = "#{pull_request_url}/compare/#{working_branch}...#{new_version_branch}?#{params.to_query}"
355
407
 
356
- Open a PR to #{working_branch} to mark the version and update the chaneglog
357
- for the next release.
408
+ syscall(["git push origin #{new_version_branch} --force"]) do
409
+ sysecho <<~MSG
410
+ Branch #{new_version_branch} created.
358
411
 
359
- Opening PR: #{pr_url}
360
- MSG
361
- end.then do |success|
362
- syscall ["open", pr_url] if success
412
+ Open a PR to #{working_branch} to mark the version and update the chaneglog
413
+ for the next release.
414
+
415
+ Opening PR: #{pr_url}
416
+ MSG
417
+ end.then do |success|
418
+ syscall ["open", pr_url] if success
419
+ end
363
420
  end
364
421
  end
365
422
 
@@ -429,12 +486,19 @@ module Discharger
429
486
  the release.
430
487
  DESC
431
488
  task prepare: [:environment] do
489
+ ensure_clean_worktree!
490
+ validate_pr_label!
491
+
432
492
  current_version = Object.const_get(version_constant)
433
493
  finish_branch = "bump/finish-#{current_version.tr(".", "-")}"
434
494
 
435
495
  syscall(
436
496
  ["git fetch origin #{working_branch}"],
437
- ["git checkout #{working_branch}"],
497
+ ["git checkout #{working_branch}"]
498
+ )
499
+ ensure_branch_not_ahead!(working_branch)
500
+ syscall(
501
+ ["git reset --hard origin/#{working_branch}"],
438
502
  ["git checkout -b #{finish_branch}"]
439
503
  )
440
504
  sysecho <<~MSG
@@ -451,34 +515,52 @@ module Discharger
451
515
 
452
516
  tasker["reissue:finalize"].invoke
453
517
 
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
518
  next_step = auto_deploy_staging ? "rake #{name}" : "rake #{name}:stage"
465
519
  next_step_desc = auto_deploy_staging ? "release to production" : "stage the release branch"
520
+ after_merge = <<~MSG.chomp
521
+ Once the PR is merged, pull down #{working_branch} and run
522
+ '#{next_step}'
523
+ to #{next_step_desc}.
524
+ MSG
466
525
 
467
- continue = syscall ["git push origin #{finish_branch} --force"] do
526
+ if pr_label
527
+ syscall ["git push origin #{finish_branch} --force"]
528
+ create_labeled_pr!(
529
+ head: finish_branch,
530
+ title: "Finish version #{current_version}",
531
+ body: "Completing development for #{current_version}."
532
+ )
468
533
  sysecho <<~MSG
469
- Branch #{finish_branch} created.
470
- Open a PR to #{working_branch} to finalize the release.
534
+ Branch #{finish_branch} pushed and its PR labeled '#{pr_label}'.
471
535
 
472
- #{pr_url}
473
-
474
- Once the PR is merged, pull down #{working_branch} and run
475
- '#{next_step}'
476
- to #{next_step_desc}.
536
+ #{after_merge}
477
537
  MSG
478
- end
479
- if continue
480
- syscall ["git checkout #{working_branch}"],
481
- ["open", pr_url]
538
+ syscall ["git checkout #{working_branch}"]
539
+ else
540
+ params = {
541
+ expand: 1,
542
+ title: "Finish version #{current_version}",
543
+ body: <<~BODY
544
+ Completing development for #{current_version}.
545
+ BODY
546
+ }
547
+
548
+ pr_url = "#{pull_request_url}/compare/#{finish_branch}?#{params.to_query}"
549
+
550
+ continue = syscall ["git push origin #{finish_branch} --force"] do
551
+ sysecho <<~MSG
552
+ Branch #{finish_branch} created.
553
+ Open a PR to #{working_branch} to finalize the release.
554
+
555
+ #{pr_url}
556
+
557
+ #{after_merge}
558
+ MSG
559
+ end
560
+ if continue
561
+ syscall ["git checkout #{working_branch}"],
562
+ ["open", pr_url]
563
+ end
482
564
  end
483
565
  end
484
566
 
@@ -1,3 +1,3 @@
1
1
  module Discharger
2
- VERSION = "0.3.4"
2
+ VERSION = "0.4.0"
3
3
  end
@@ -18,6 +18,14 @@ redis:
18
18
  name: "redis-your-app"
19
19
  version: "latest"
20
20
 
21
+ # Private GitHub Packages gem source (optional)
22
+ # Stores bundler credentials from the GitHub CLI (gh) so the Gemfile
23
+ # doesn't need an embedded token. Add "github_packages" to steps, before
24
+ # "bundler". Credentials are written to .bundle/config — keep .bundle
25
+ # gitignored.
26
+ # github_packages:
27
+ # source: "https://rubygems.pkg.github.com/your-org"
28
+
21
29
  # Pre-Rails steps (run BEFORE Rails loads)
22
30
  # These are for system dependencies and environment setup that must happen
23
31
  # before bundler/Rails can initialize. Use sparingly.
@@ -36,11 +44,13 @@ pre_steps: []
36
44
  # - postgresql_tools # Optional: for apps that use structure.sql or pg_dump/psql directly
37
45
 
38
46
  # Built-in commands to run (empty array runs all available commands)
39
- # Available commands: brew, asdf, git, bundler, yarn, config, docker, pg_tools, env, database
47
+ # Available commands: brew, asdf, git, github_packages, bundler, yarn, config, docker, pg_tools, env, database
40
48
  steps:
41
49
  - brew
42
50
  - asdf
43
51
  - git
52
+ # Uncomment with the github_packages block above. Must precede bundler.
53
+ # - github_packages
44
54
  - bundler
45
55
  - yarn
46
56
  - config
@@ -52,6 +62,11 @@ steps:
52
62
  # - pg_tools
53
63
 
54
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"
55
70
  custom_steps:
56
71
  - description: "Seed application data"
57
72
  command: "bin/rails db:seed"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: discharger
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.4
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay
@@ -122,6 +122,7 @@ files:
122
122
  - lib/discharger/setup_runner/commands/docker_command.rb
123
123
  - lib/discharger/setup_runner/commands/env_command.rb
124
124
  - lib/discharger/setup_runner/commands/git_command.rb
125
+ - lib/discharger/setup_runner/commands/github_packages_command.rb
125
126
  - lib/discharger/setup_runner/commands/pg_tools_command.rb
126
127
  - lib/discharger/setup_runner/commands/yarn_command.rb
127
128
  - lib/discharger/setup_runner/condition_evaluator.rb