File: create-release

package info (click to toggle)
ruby-git 1.13.1-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm, forky, sid, trixie
  • size: 4,124 kB
  • sloc: ruby: 5,385; sh: 507; perl: 64; makefile: 6
file content (506 lines) | stat: -rwxr-xr-x 12,436 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
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
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
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
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
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
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
#!/usr/bin/env ruby

# Run this script while in the root directory of the project with the default
# branch checked out.

require 'bump'
require 'English'
require 'fileutils'
require 'optparse'
require 'tempfile'

# TODO: Right now the default branch and the remote name are hard coded

class Options
  attr_accessor :current_version, :next_version, :tag, :current_tag, :next_tag, :branch, :quiet

  def initialize
    yield self if block_given?
  end

  def release_type
    raise "release_type not set" if @release_type.nil?
    @release_type
  end

  VALID_RELEASE_TYPES = %w(major minor patch)

  def release_type=(release_type)
    raise 'release_type must be one of: ' + VALID_RELEASE_TYPES.join(', ') unless VALID_RELEASE_TYPES.include?(release_type)
    @release_type = release_type
  end

  def quiet
    @quiet = false unless instance_variable_defined?(:@quiet)
    @quiet
  end

  def current_version
    @current_version ||= Bump::Bump.current
  end

  def next_version
    current_version # Save the current version before bumping
    @next_version ||= Bump::Bump.next_version(release_type)
  end

  def tag
    @tag ||= "v#{next_version}"
  end

  def current_tag
    @current_tag ||= "v#{current_version}"
  end

  def next_tag
    tag
  end

  def branch
    @branch ||= "release-#{tag}"
  end

  def default_branch
    @default_branch ||= `git remote show '#{remote}'`.match(/HEAD branch: (.*?)$/)[1]
  end

  def remote
    @remote ||= 'origin'
  end

  def to_s
    <<~OUTPUT
      release_type='#{release_type}'
      current_version='#{current_version}'
      next_version='#{next_version}'
      tag='#{tag}'
      branch='#{branch}'
      quiet=#{quiet}
    OUTPUT
  end
end

class CommandLineParser
  attr_reader :options

  def initialize
    @option_parser = OptionParser.new
    define_options
    @options = Options.new
  end

  def parse(args)
    option_parser.parse!(remaining_args = args.dup)
    parse_remaining_args(remaining_args)
    # puts options unless options.quiet
    options
  end

  private

  attr_reader :option_parser

  def parse_remaining_args(remaining_args)
    error_with_usage('No release type specified') if remaining_args.empty?
    @options.release_type = remaining_args.shift || nil
    error_with_usage('Too many args') unless remaining_args.empty?
  end

  def error_with_usage(message)
    warn <<~MESSAGE
      ERROR: #{message}
      #{option_parser}
    MESSAGE
    exit 1
  end

  def define_options
    option_parser.banner = 'Usage: create_release --help | release-type'
    option_parser.separator ''
    option_parser.separator 'Options:'

    define_quiet_option
    define_help_option
  end

  def define_quiet_option
    option_parser.on('-q', '--[no-]quiet', 'Do not show output') do |quiet|
      options.quiet = quiet
    end
  end

  def define_help_option
    option_parser.on_tail('-h', '--help', 'Show this message') do
      puts option_parser
      exit 0
    end
  end
end

class ReleaseAssertions
  attr_reader :options

  def initialize(options)
    @options = options
  end

  def make_assertions
    bundle_is_up_to_date
    in_git_repo
    in_repo_toplevel_directory
    on_default_branch
    no_uncommitted_changes
    local_and_remote_on_same_commit
    tag_does_not_exist
    branch_does_not_exist
    docker_is_running
    changelog_docker_container_exists
    gh_command_exists
  end

  private

  def gh_command_exists
    print "Checking that the gh command exists..."
    `which gh > /dev/null 2>&1`
    if $CHILD_STATUS.success?
      puts "OK"
    else
      error "The gh command was not found"
    end
  end

  def docker_is_running
    print "Checking that docker is installed and running..."
    `docker info > /dev/null 2>&1`
    if $CHILD_STATUS.success?
      puts "OK"
    else
      error "Docker is not installed or not running"
    end
  end


  def changelog_docker_container_exists
    print "Checking that the changelog docker container exists (might take time to build)..."
    `docker build --file Dockerfile.changelog-rs --tag changelog-rs . 1>/dev/null`
    if $CHILD_STATUS.success?
      puts "OK"
    else
      error "Failed to build the changelog-rs docker container"
    end
  end

  def bundle_is_up_to_date
    print "Checking that the bundle is up to date..."
    if File.exist?('Gemfile.lock')
      print "Running bundle update..."
      `bundle update --quiet`
      if $CHILD_STATUS.success?
        puts "OK"
      else
        error "bundle update failed"
      end
    else
      print "Running bundle install..."
      `bundle install --quiet`
      if $CHILD_STATUS.success?
        puts "OK"
      else
        error "bundle install failed"
      end
    end
  end

  def in_git_repo
    print "Checking that you are in a git repo..."
    `git rev-parse --is-inside-work-tree --quiet > /dev/null 2>&1`
    if $CHILD_STATUS.success?
      puts "OK"
    else
      error "You are not in a git repo"
    end
  end

  def in_repo_toplevel_directory
    print "Checking that you are in the repo's toplevel directory..."
    toplevel_directory = `git rev-parse --show-toplevel`.chomp
    if toplevel_directory == FileUtils.pwd
      puts "OK"
    else
      error "You are not in the repo's toplevel directory"
    end
  end

  def on_default_branch
    print "Checking that you are on the default branch..."
    current_branch = `git branch --show-current`.chomp
    if current_branch == options.default_branch
      puts "OK"
    else
      error "You are not on the default branch '#{default_branch}'"
    end
  end

  def no_uncommitted_changes
    print "Checking that there are no uncommitted changes..."
    if `git status --porcelain | wc -l`.to_i == 0
      puts "OK"
    else
      error "There are uncommitted changes"
    end
  end

  def no_staged_changes
    print "Checking that there are no staged changes..."
    if `git diff --staged --name-only | wc -l`.to_i == 0
      puts "OK"
    else
      error "There are staged changes"
    end
  end

  def local_and_remote_on_same_commit
    print "Checking that local and remote are on the same commit..."
    local_commit = `git rev-parse HEAD`.chomp
    remote_commit = `git ls-remote '#{options.remote}' '#{options.default_branch}' | cut -f 1`.chomp
    if local_commit == remote_commit
      puts "OK"
    else
      error "Local and remote are not on the same commit"
    end
  end

  def local_tag_does_not_exist
    print "Checking that local tag '#{options.tag}' does not exist..."

    tags = `git tag --list "#{options.tag}"`.chomp
    error 'Could not list tags' unless $CHILD_STATUS.success?

    if tags.split.empty?
      puts 'OK'
    else
      error "'#{options.tag}' already exists"
    end
  end

  def remote_tag_does_not_exist
    print "Checking that the remote tag '#{options.tag}' does not exist..."
    `git ls-remote --tags --exit-code '#{options.remote}' #{options.tag} >/dev/null 2>&1`
    unless $CHILD_STATUS.success?
      puts "OK"
    else
      error "'#{options.tag}' already exists"
    end
  end

  def tag_does_not_exist
    local_tag_does_not_exist
    remote_tag_does_not_exist
  end

  def local_branch_does_not_exist
    print "Checking that local branch '#{options.branch}' does not exist..."

    if `git branch --list "#{options.branch}" | wc -l`.to_i.zero?
      puts "OK"
    else
      error "'#{options.branch}' already exists."
    end
  end

  def remote_branch_does_not_exist
    print "Checking that the remote branch '#{options.branch}' does not exist..."
    `git ls-remote --heads --exit-code '#{options.remote}' '#{options.branch}' >/dev/null 2>&1`
    unless $CHILD_STATUS.success?
      puts "OK"
    else
      error "'#{options.branch}' already exists"
    end
  end

  def branch_does_not_exist
    local_branch_does_not_exist
    remote_branch_does_not_exist
  end

  private

  def print(*args)
    super unless options.quiet
  end

  def puts(*args)
    super unless options.quiet
  end

  def error(message)
    warn "ERROR: #{message}"
    exit 1
  end
end

class ReleaseCreator
  attr_reader :options

  def initialize(options)
    @options = options
  end

  def create_release
    create_branch
    update_changelog
    update_version
    make_release_commit
    create_tag
    push_release_commit_and_tag
    create_github_release
    create_release_pull_request
  end

  private

  def create_branch
    print "Creating branch '#{options.branch}'..."
    `git checkout -b "#{options.branch}" > /dev/null 2>&1`
    if $CHILD_STATUS.success?
      puts "OK"
    else
      error "Could not create branch '#{options.branch}'" unless $CHILD_STATUS.success?
    end
  end

  def update_changelog
    print 'Updating CHANGELOG.md...'
    changelog_lines = File.readlines('CHANGELOG.md')
    first_entry = changelog_lines.index { |e| e =~ /^## / }
    error "Could not find changelog insertion point" unless first_entry
    FileUtils.rm('CHANGELOG.md')
    File.write('CHANGELOG.md', <<~CHANGELOG.chomp)
      #{changelog_lines[0..first_entry - 1].join}## #{options.tag}

      See https://github.com/ruby-git/ruby-git/releases/tag/#{options.tag}

      #{changelog_lines[first_entry..].join}
    CHANGELOG
    `git add CHANGELOG.md`
    if $CHILD_STATUS.success?
      puts 'OK'
    else
      error 'Could not stage changes to CHANGELOG.md'
    end
  end

  def update_version
    print 'Updating version...'
    message, status = Bump::Bump.run(options.release_type, commit: false)
    error 'Could not bump version' unless status == 0
    `git add lib/git/version.rb`
    if $CHILD_STATUS.success?
      puts 'OK'
    else
      error 'Could not stage changes to lib/git/version.rb'
    end
  end

  def make_release_commit
    print 'Making release commit...'
    `git commit -s -m 'Release #{options.tag}'`
    error 'Could not make release commit' unless $CHILD_STATUS.success?
  end

  def create_tag
    print "Creating tag '#{options.tag}'..."
    `git tag '#{options.tag}'`
    if $CHILD_STATUS.success?
      puts 'OK'
    else
      error "Could not create tag '#{options.tag}'"
    end
  end

  def push_release_commit_and_tag
    print "Pushing branch '#{options.branch}' to remote..."
    `git push --tags --set-upstream '#{options.remote}' '#{options.branch}' > /dev/null 2>&1`
    if $CHILD_STATUS.success?
      puts 'OK'
    else
      error 'Could not push release commit'
    end
  end

  def changelog
    @changelog ||= begin
      print "Generating changelog..."
      pwd = FileUtils.pwd
      from = options.current_tag
      to = options.next_tag
      command = "docker run --rm --volume '#{pwd}:/worktree' changelog-rs '#{from}' '#{to}'"
      changelog = `#{command}`
      if $CHILD_STATUS.success?
        puts 'OK'
        changelog.rstrip.lines[1..].join
      else
        error 'Could not generate the changelog'
      end
    end
  end

  def create_github_release
    Tempfile.create do |f|
      f.write changelog
      f.close

      print "Creating GitHub release '#{options.tag}'..."
      tag = options.tag
      `gh release create #{tag} --title 'Release #{tag}' --notes-file '#{f.path}' --target #{options.default_branch}`
      if $CHILD_STATUS.success?
        puts 'OK'
      else
        error 'Could not create release'
      end
    end
  end

  def create_release_pull_request
    Tempfile.create do |f|
      f.write <<~PR
        ### Your checklist for this pull request
        🚨Please review the [guidelines for contributing](https://github.com/ruby-git/ruby-git/blob/#{options.default_branch}/CONTRIBUTING.md) to this repository.

        - [X] Ensure all commits include DCO sign-off.
        - [X] Ensure that your contributions pass unit testing.
        - [X] Ensure that your contributions contain documentation if applicable.

        ### Description
        #{changelog}
      PR
      f.close

      print "Creating GitHub pull request..."
      `gh pr create --title 'Release #{options.tag}' --body-file '#{f.path}' --base '#{options.default_branch}'`
      if $CHILD_STATUS.success?
        puts 'OK'
      else
        error 'Could not create release pull request'
      end
    end
  end

  def error(message)
    warn "ERROR: #{message}"
    exit 1
  end

  def print(*args)
    super unless options.quiet
  end

  def puts(*args)
    super unless options.quiet
  end
end

options = CommandLineParser.new.parse(ARGV)
ReleaseAssertions.new(options).make_assertions
ReleaseCreator.new(options).create_release