release_flow 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c7b889dd512fd0e5aa6af560b92a2f6fe0e58d1c087c4731738405204e903037
4
+ data.tar.gz: 03a63137a76d8b4f030814e740cce67498b0a94a8c2c5c259df445eaab1339b2
5
+ SHA512:
6
+ metadata.gz: 98fc8c51ed01ba688cfbabfbe47ddd4aa44ed484200000fef5329a726150086db3942c608a32f6b78198259f207e10d1180b68ad05a196ae58d52a5b1300371b
7
+ data.tar.gz: 28a47976be60cfacc64414f4d56f9285dff442520bd951ae5ca928da474ab27ff38243a326cfee5c4cf9776707f5ddf9a380977a78b95c91ff549e3587c368f6
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## 0.1.0
6
+
7
+ - First release: `bin/release prepare`, `cut`, `ship`, `merge-back` and `check`, extracted from ModelRails; `release_flow init` installs the binstub, the release workflow, the release PR template and the `changelog.d/` README.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Dave Chmura
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # release_flow
2
+
3
+ Release a GitHub repository through review on staging, so production always
4
+ runs a commit someone checked, tagged and ready to roll back to.
5
+
6
+ ```text
7
+ your branch ──merge──▶ next ──cut──▶ release/<name> ──ship──▶ main
8
+ you test it CI checks it staging; people review it production; tagged
9
+ ▲ │
10
+ └──────────────── merge-back ───────────────┘
11
+ ```
12
+
13
+ - **Changelog entries never conflict.** Each PR adds its entry as a file of
14
+ its own in `changelog.d/`. A release gathers them into `CHANGELOG.md`.
15
+ - **A release is exactly what its notes say.** `cut` takes the integration
16
+ branch as it stood the last time no entry was waiting, so work merged
17
+ after the release was named waits for the next one.
18
+ - **Production runs the reviewed commit.** `ship` finds the release PR and
19
+ the commit staging last deployed, asks before shipping, and merges with
20
+ GitHub's head-commit check. If the branch moved since, nothing ships.
21
+ - **One release at a time**, with a GitHub Release and tag for each.
22
+
23
+ It runs on `git` and the [GitHub CLI](https://cli.github.com) (`gh`), and
24
+ has no dependencies beyond Ruby 3.3. Rails isn't required.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ bundle add release_flow --group development
30
+ bundle exec release_flow init
31
+ ```
32
+
33
+ `init` writes these files, and keeps any that already exist:
34
+
35
+ | File | What it's for |
36
+ | --- | --- |
37
+ | `bin/release` | the command you run |
38
+ | `.github/workflows/next_release.yml` | starts releases on its own, once switched on |
39
+ | `.github/PULL_REQUEST_TEMPLATE/release.md` | the release PR's body |
40
+ | `changelog.d/README.md` | how a PR adds its changelog entry |
41
+ | `CHANGELOG.md` | started with an `## [Unreleased]` section if you have none |
42
+
43
+ Then:
44
+
45
+ 1. Add `bin/release check` to your CI workflow. It fails a PR that edits the
46
+ notes of a release already shipped.
47
+ 2. Ask for a changelog file in your PR template, for example
48
+ "a file of its own in `changelog.d/`".
49
+ 3. Create the `next` branch from `main`, and make it the branch PRs target.
50
+ 4. Deploy `release/**` pushes to staging and `main` pushes to production.
51
+ `ship` reads your staging workflow's runs to find the commit staging ran.
52
+ 5. Protect `next`, `release/*` and `main` so changes arrive by PR with
53
+ passing checks, and turn on "Automatically delete head branches":
54
+ `bin/release` tells that a release is under way by its branch existing.
55
+
56
+ ## Settings
57
+
58
+ Everything defaults to the names above. To use others, add a
59
+ `.release_flow.yml` at the repository's root, then run `init` (or edit the
60
+ files it wrote):
61
+
62
+ ```yaml
63
+ integration_branch: develop # default: next
64
+ production_branch: trunk # default: main
65
+ ci_workflow: test.yml # default: ci.yml, the workflow cut waits on
66
+ staging_workflow: staging.yml # default: deploy.yml, whose deploys ship reads
67
+ guide: https://example.com/releasing # where --help and the templates point
68
+ template: me/template # the GitHub repository this one forks, if any
69
+ ```
70
+
71
+ A setting it doesn't know stops `bin/release` with the list of those it does.
72
+
73
+ ## Release
74
+
75
+ ```bash
76
+ bin/release prepare # name the release: gather changelog.d/ into CHANGELOG.md, by PR into next
77
+ bin/release cut # push release/<name>, which deploys staging, and open its PR into main
78
+ bin/release ship # merge the reviewed commit, publish v<name>, merge main back into next
79
+ ```
80
+
81
+ Every command takes `--dry-run`. A release is named for the day,
82
+ `2026.11.02`, and a second that day is `2026.11.02-2`.
83
+
84
+ **Review on staging.** The release PR's "What ships" is its changelog
85
+ section; work through each PR in it on staging. A fix found in review goes
86
+ into the release branch by PR, with its changelog line under that release's
87
+ `## v<name>` section, not in `changelog.d/`, which would wait for the next
88
+ release.
89
+
90
+ **Hold back a PR** by reverting it on the release branch, by PR, and
91
+ deleting its changelog line. To bring it back later, revert the revert on
92
+ `next` and add its entry to `changelog.d/` again.
93
+
94
+ **Ship.** `bin/release ship` shows the PR and the commit it found and asks
95
+ `Ship abc1234? [y/N]`. With no terminal, as in CI, name both:
96
+ `bin/release ship <PR> <commit>`. If any step stops partway, run it again; it
97
+ skips what's done.
98
+
99
+ **Hotfix.** A fix production can't wait for goes to `main` by PR, with its
100
+ entry in `changelog.d/`. Then run `bin/release merge-back`.
101
+
102
+ ## Starting releases on their own
103
+
104
+ With the workflow switched on, a release is named whenever a PR merges into
105
+ `next` and none is under way, and cut once CI passes there. If CI fails
106
+ where it would cut, the run fails and says so. Shipping stays a person's
107
+ call.
108
+
109
+ ### Set up the release token
110
+
111
+ GitHub runs no checks on a PR opened with a workflow's built-in token, so
112
+ the changelog PR would never merge. The workflow needs a token of its own:
113
+
114
+ 1. On GitHub, open **Settings → Developer settings → Personal access tokens →
115
+ Fine-grained tokens → Generate new token**.
116
+ 2. Under **Repository access**, choose **Only select repositories** and pick
117
+ this repository.
118
+ 3. Under **Permissions**, set **Contents** and **Pull requests** to **Read and
119
+ write**.
120
+ 4. Choose an expiry, and note it: once it expires, releases stop starting on
121
+ their own until you replace it.
122
+ 5. Store it as a secret. The command prompts for it, so it stays out of your
123
+ shell history:
124
+
125
+ ```bash
126
+ gh secret set RELEASE_TOKEN --repo <owner>/<repo>
127
+ ```
128
+
129
+ 6. Switch the workflow on:
130
+
131
+ ```bash
132
+ gh variable set AUTO_RELEASE --body true --repo <owner>/<repo>
133
+ ```
134
+
135
+ ## A changelog of your own
136
+
137
+ `ReleaseFlow::Changelog` holds the changelog format and the release-name
138
+ convention, and `bin/release` reads them only through its public methods. A
139
+ project that keeps its changelog another way can reopen that module in
140
+ `bin/release`, after `require "release_flow"`, keeping the method
141
+ signatures.
142
+
143
+ ## License
144
+
145
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/exe/release_flow ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "release_flow"
5
+
6
+ exit ReleaseFlow::Installer.start(ARGV, root: Dir.pwd)
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReleaseFlow
4
+ # The release PR bin/release ship finds when it isn't named one, and the commit it would ship.
5
+ module Candidate
6
+ class Error < ReleaseFlow::Error; end
7
+
8
+ BRANCH = %r{\Arelease/(#{Changelog::NAME})\z}
9
+
10
+ module_function
11
+
12
+ # prs: gh pr list's number, headRefName, state and headRefOid; shipped: whether a release name was published.
13
+ def waiting(prs, usage:, &shipped)
14
+ waiting = prs.select do |pr|
15
+ name = pr["headRefName"][BRANCH, 1]
16
+ name && (pr["state"] == "OPEN" || (pr["state"] == "MERGED" && !shipped.call(name)))
17
+ end
18
+ raise Error, "No release PR is waiting to ship: bin/release cut opens one" if waiting.empty?
19
+ raise Error, "#{waiting.map { |pr| "##{pr["number"]}" }.join(" and ")} are waiting to ship: name one, #{usage}" if waiting.size > 1
20
+
21
+ waiting.first
22
+ end
23
+
24
+ # runs: gh run list's headSha and conclusion for the release branch's staging deploys, newest first.
25
+ def found(pr, runs)
26
+ head = pr["headRefOid"]
27
+ deployed = runs.find { |run| run["conclusion"] == "success" }&.dig("headSha")
28
+ return "the branch's head; no successful staging deploy found for it" unless deployed
29
+ return "the commit staging's last deploy ran" if deployed == head
30
+
31
+ raise Error, "staging last ran #{deployed[0, 7]}, but #{pr["headRefName"]} is at #{head[0, 7]}: review its deploy, " \
32
+ "or name the commit you reviewed, bin/release ship #{pr["number"]} <commit>"
33
+ end
34
+
35
+ def confirmed?(pr, head)
36
+ raise Error, "No terminal to confirm on: name the PR and commit, bin/release ship #{pr} #{head[0, 7]}" unless $stdin.tty?
37
+
38
+ print("Ship #{head[0, 7]}? [y/N] ")
39
+ $stdin.gets.to_s.strip.casecmp?("y")
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReleaseFlow
4
+ # The changelog, its changelog.d fragments, and the release-name convention bin/release reads through this module.
5
+ # A project that keeps its changelog another way reopens this module and keeps the signatures.
6
+ module Changelog
7
+ class Error < ReleaseFlow::Error; end
8
+
9
+ NAME = '\d{4}\.\d{2}\.\d{2}(?:-\d+)?'
10
+ UNRELEASED = "## [Unreleased]\n"
11
+ FRAGMENTS = "changelog.d"
12
+
13
+ module_function
14
+
15
+ def names(today)
16
+ date = today.strftime("%Y.%m.%d")
17
+ (1..).lazy.map { |n| (n == 1) ? date : "#{date}-#{n}" }
18
+ end
19
+
20
+ def name_release(text, name, fragments = [])
21
+ head, rest = text.split(UNRELEASED, 2)
22
+ raise Error, "CHANGELOG.md has no #{UNRELEASED.strip} section" if rest.nil?
23
+
24
+ body, tail = rest.split(/^(?=## )/, 2)
25
+ sections = fold([body, *fragments])
26
+ raise Error, "no entries under #{UNRELEASED.strip} or in #{FRAGMENTS}/; there is nothing to release" if sections.empty?
27
+
28
+ released = sections.map { |title, lines| "### #{title}\n\n#{lines.join("\n")}\n" }.join("\n")
29
+ "#{head}#{UNRELEASED}\n## v#{name}\n\n#{released}\n#{tail}"
30
+ end
31
+
32
+ def fragment?(path) = path.match?(%r{\A#{FRAGMENTS}/[^/]+\.md\z}o) && File.basename(path) != "README.md"
33
+ def fragment(title, entries) = "### #{title}\n\n#{entries.join("\n")}\n"
34
+
35
+ def named_release(text)
36
+ text[/^## \[Unreleased\]\n.*?^## v(#{NAME})$/mo, 1]
37
+ end
38
+
39
+ def unreleased(text, fragments = [])
40
+ body = text.split(UNRELEASED, 2).last.to_s.split(/^(?=## )/, 2).first.to_s
41
+ fold([body, *fragments]).values.flatten
42
+ end
43
+
44
+ def section(text, name)
45
+ text[/^## v#{Regexp.escape(name)}\n(.*?)(?=^## |\z)/m, 1]&.strip
46
+ end
47
+
48
+ # tagged: each shipped release's name and the CHANGELOG.md it was tagged with.
49
+ def changed_releases(text, tagged)
50
+ tagged.filter_map do |name, at_tag|
51
+ shipped, now = [at_tag, text].map { |t| section(t, name)&.lines(chomp: true) }
52
+ next unless shipped && now && now != shipped
53
+
54
+ lines = {"added" => now - shipped, "removed" => shipped - now}.flat_map { |how, ls| ls.map { |l| " #{how}: #{l}" } }
55
+ "v#{name} has shipped, so its notes in CHANGELOG.md stay as tagged:\n#{lines.join("\n")}\nA new entry belongs in #{FRAGMENTS}/."
56
+ end
57
+ end
58
+
59
+ def fold(bodies)
60
+ bodies.each_with_object({}) do |body, sections|
61
+ current = "Changed"
62
+ body.each_line(chomp: true) do |line|
63
+ if (title = line[/\A### (.+)/, 1])
64
+ current = title
65
+ elsif !line.strip.empty?
66
+ (sections[current] ||= []) << line
67
+ end
68
+ end
69
+ end
70
+ end
71
+ private_class_method :fold
72
+ end
73
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module ReleaseFlow
6
+ # What bin/release runs: parses its options and hands the command to Release.
7
+ module CLI
8
+ module_function
9
+
10
+ def start(argv, root:)
11
+ config = Config.load(root)
12
+ options = {}
13
+ args = OptionParser.new do |o|
14
+ o.banner = "#{Release::USAGE} (see #{config.guide})"
15
+ o.on("--dry-run", "Print the plan without changing anything") { options[:dry_run] = true }
16
+ o.on("--if-needed", "prepare, cut: when there's nothing to do yet, say so and stop without failing") { options[:if_needed] = true }
17
+ end.parse(argv)
18
+ Release.new(options, root: root, config: config).run(*args)
19
+ 0
20
+ rescue Error, OptionParser::ParseError => e
21
+ warn("bin/release: #{e.message}")
22
+ 1
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module ReleaseFlow
6
+ FILE = ".release_flow.yml"
7
+ DEFAULTS = {integration_branch: "next", production_branch: "main", ci_workflow: "ci.yml",
8
+ staging_workflow: "deploy.yml", guide: "https://github.com/dschmura/release_flow#readme", template: nil}.freeze
9
+
10
+ # The names the flow reads from an optional .release_flow.yml at the repository's root.
11
+ Config = Data.define(*DEFAULTS.keys) do
12
+ def self.load(root)
13
+ path = File.join(root, FILE)
14
+ settings = File.exist?(path) ? YAML.safe_load_file(path).to_h.transform_keys(&:to_sym) : {}
15
+ unknown = settings.keys - members
16
+ raise Error, "#{FILE} has no setting #{unknown.join(", ")}; it takes #{members.join(", ")}" if unknown.any?
17
+
18
+ new(**DEFAULTS, **settings)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Cleared before every git spawn: a hook's GIT_DIR beats `git -C`.
4
+ module ReleaseFlow
5
+ module GitEnv
6
+ HASH = {
7
+ "GIT_DIR" => nil,
8
+ "GIT_WORK_TREE" => nil,
9
+ "GIT_INDEX_FILE" => nil,
10
+ "GIT_COMMON_DIR" => nil,
11
+ "GIT_OBJECT_DIRECTORY" => nil,
12
+ "GIT_NAMESPACE" => nil,
13
+ "GIT_CONFIG" => nil,
14
+ "GIT_CONFIG_GLOBAL" => nil,
15
+ "GIT_CONFIG_SYSTEM" => nil,
16
+ "GIT_CONFIG_COUNT" => nil
17
+ }.freeze
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module ReleaseFlow
6
+ # The GitHub repository a checkout's origin names. gh prefers a remote called upstream,
7
+ # so the flow asks for origin by name instead.
8
+ module GithubOrigin
9
+ URL = %r{github\.com[:/](?<repo>[^/\s]+/[^/\s]+?)(?:\.git)?/?\z}
10
+
11
+ module_function
12
+
13
+ def repo(root)
14
+ url, status = Open3.capture2(GitEnv::HASH, "git", "-C", root, "config", "--get", "remote.origin.url", err: File::NULL)
15
+ url.strip[URL, :repo] if status.success?
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+ require "yaml"
6
+
7
+ module ReleaseFlow
8
+ # release_flow init: writes the files the flow runs on, keeping any that already exist.
9
+ class Installer
10
+ TEMPLATES = File.expand_path("../../templates", __dir__)
11
+ FILES = {
12
+ "bin/release" => 0o755,
13
+ ".github/workflows/next_release.yml" => 0o644,
14
+ ".github/PULL_REQUEST_TEMPLATE/release.md" => 0o644,
15
+ "changelog.d/README.md" => 0o644
16
+ }.freeze
17
+ CHANGELOG = "# Changelog\n\n#{Changelog::UNRELEASED}"
18
+
19
+ def self.start(argv, root:, out: $stdout)
20
+ raise Error, "usage: release_flow init" unless argv == ["init"]
21
+
22
+ new(root, out: out).install
23
+ 0
24
+ rescue Error => e
25
+ warn("release_flow: #{e.message}")
26
+ 1
27
+ end
28
+
29
+ def self.render(path, config, ci_name: "CI")
30
+ ERB.new(File.read(File.join(TEMPLATES, "#{path}.erb")), trim_mode: "-").result_with_hash(config: config, ci_name: ci_name)
31
+ end
32
+
33
+ def initialize(root, out: $stdout)
34
+ @root = root
35
+ @out = out
36
+ @config = Config.load(root)
37
+ end
38
+
39
+ def install
40
+ ci_name = ci_workflow_name
41
+ FILES.each { |path, mode| write(path, mode) { self.class.render(path, @config, ci_name: ci_name) } }
42
+ write("CHANGELOG.md", 0o644) { CHANGELOG }
43
+ unless File.read(File.join(@root, "CHANGELOG.md")).include?(Changelog::UNRELEASED)
44
+ @out.puts(" ! CHANGELOG.md has no #{Changelog::UNRELEASED.strip} heading; add one above your first release")
45
+ end
46
+ @out.puts(<<~NEXT)
47
+ Then: add `bin/release check` to #{@config.ci_workflow}, and to start releases on their own,
48
+ a RELEASE_TOKEN secret and an AUTO_RELEASE variable set to true (#{@config.guide}).
49
+ NEXT
50
+ end
51
+
52
+ private
53
+
54
+ def ci_workflow_name
55
+ path = File.join(@root, ".github/workflows", @config.ci_workflow)
56
+ unless File.exist?(path)
57
+ raise Error, "no .github/workflows/#{@config.ci_workflow}: set ci_workflow in #{FILE} to the workflow that runs your checks"
58
+ end
59
+
60
+ YAML.safe_load_file(path).to_h.fetch("name") { raise Error, "#{@config.ci_workflow} has no name: for the release workflow to wait on" }
61
+ end
62
+
63
+ def write(path, mode)
64
+ target = File.join(@root, path)
65
+ return @out.puts(" kept #{path}, which already exists") if File.exist?(target)
66
+
67
+ FileUtils.mkdir_p(File.dirname(target))
68
+ File.write(target, yield)
69
+ File.chmod(mode, target)
70
+ @out.puts(" wrote #{path}")
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "fileutils"
5
+ require "json"
6
+ require "open3"
7
+ require "tmpdir"
8
+
9
+ # Walks a release from the integration branch to production. Commits through a temporary index,
10
+ # so the checkout and working tree are never touched.
11
+ module ReleaseFlow
12
+ class Release
13
+ class Error < ReleaseFlow::Error; end
14
+
15
+ CLEAN_GIT_ENV = GitEnv::HASH
16
+ TEMPLATE = ".github/PULL_REQUEST_TEMPLATE/release.md"
17
+ SHIP_USAGE = "bin/release ship [<PR> <commit reviewed on staging>]"
18
+ USAGE = "usage: bin/release prepare | cut | #{SHIP_USAGE.delete_prefix("bin/release ")} | merge-back | check"
19
+ ONCE_READY = "once its checks pass and any required approval is given"
20
+ BODY_LIMIT = 60_000
21
+
22
+ def initialize(options, root: Dir.pwd, config: nil)
23
+ @opts = options
24
+ @root = File.expand_path(root)
25
+ @config = config || Config.load(@root)
26
+ end
27
+
28
+ def run(command, *args)
29
+ @repo = GithubOrigin.repo(@root) or raise Error, "origin isn't a GitHub repository, so there's no telling which one to act on"
30
+ case command
31
+ when "prepare" then prepare
32
+ when "cut" then cut
33
+ when "ship" then ship(*args)
34
+ when "merge-back" then merge_back_after_hotfix
35
+ when "check" then check
36
+ else raise Error, USAGE
37
+ end
38
+ end
39
+
40
+ def prepare
41
+ fetch
42
+ changelog = next_changelog
43
+ fragments = fragments_at("origin/#{integration}")
44
+ if (under_way = release_under_way(changelog))
45
+ return say("#{under_way}, so the next release waits for it.") if @opts[:if_needed]
46
+
47
+ raise Error, "#{under_way}: finish that release before starting another"
48
+ end
49
+ return say("No changelog entries are waiting on #{integration}, so no release to start.") if @opts[:if_needed] && Changelog.unreleased(changelog, fragments.values).empty?
50
+
51
+ name = Changelog.names(@opts.fetch(:today) { Date.today })
52
+ .find { |n| !tagged?(n) && !template_tags.key?(n) && !branch?("release/#{n}") }
53
+ say("== Prepare v#{name} in #{@repo} ==")
54
+ return dry("push changelog/#{name} (CHANGELOG.md naming v#{name}) and queue its PR into #{integration}") if dry_run?
55
+
56
+ named = Changelog.name_release(changelog + "\n", name, fragments.values)
57
+ commit = commit_on("origin/#{integration}", {"CHANGELOG.md" => named, **fragments.transform_values { nil }},
58
+ message: "The changelog names release #{name}")
59
+ url = propose("changelog/#{name}", at: commit, base: integration, title: "Changelog for release #{name}",
60
+ body: "Gathers `changelog.d/` and `[Unreleased]` into v#{name} so `bin/release cut` can cut it.\n", auto_merge: true)
61
+ say(" → #{url}, merging into #{integration} #{ONCE_READY}")
62
+ say("Then: bin/release cut, once it has merged.")
63
+ end
64
+
65
+ def cut
66
+ fetch
67
+ changelog = next_changelog
68
+ name = Changelog.named_release(changelog)
69
+ return say("Nothing to cut: #{integration} names no release waiting to be cut.") if @opts[:if_needed] && !waiting_to_cut?(name)
70
+ raise Error, "#{integration}'s CHANGELOG.md names no release yet: run bin/release prepare and let its PR merge" unless name
71
+ raise Error, "v#{name} already shipped: run bin/release prepare for the next one" if shipped?(name)
72
+
73
+ branch = "release/#{name}"
74
+ open = remote_refs.find { |ref| ref.start_with?("refs/heads/release/") && ref != "refs/heads/#{branch}" }
75
+ raise Error, "#{open.delete_prefix("refs/heads/")} is still open: ship it or delete it before cutting another" if open
76
+ commit, waiting = cut_point(name)
77
+ raise Error, "#{production} isn't in #{integration} yet: merge the open merge-back PR first" unless ancestor?("origin/#{production}", commit)
78
+ require_passing_ci(commit, name)
79
+
80
+ say("== Cut #{branch} in #{@repo} ==")
81
+ say(" at #{commit[0, 7]}; #{waiting} later commit#{"s" unless waiting == 1} on next wait#{"s" if waiting == 1} for the next release") if waiting.positive?
82
+ return dry("push #{branch} at #{commit[0, 7]} and open its PR into #{production}") if dry_run?
83
+
84
+ url = propose(branch, at: commit, base: production, title: "Release #{name}",
85
+ body: release_body(branch, Changelog.section(git("show", "#{commit}:CHANGELOG.md"), name)))
86
+ say(" → #{url}")
87
+ say("Then: review it on staging, and run bin/release ship.")
88
+ end
89
+
90
+ def ship(pr = nil, sha = nil)
91
+ raise Error, "usage: #{SHIP_USAGE}" if pr.nil? != sha.nil? || (sha && !sha.match?(/\A\h{7,40}\z/))
92
+
93
+ pr, sha, found = waiting_release unless pr
94
+ info = pr_info(pr)
95
+ name = release_name(info)
96
+ head = info["headRefOid"]
97
+ say("== Ship v#{name} in #{@repo} ==")
98
+ say(" PR ##{pr}, #{info["headRefName"]} into #{production}, at #{head[0, 7]}: #{found}") if found
99
+ return dry("merge PR #{pr} at #{head[0, 7]}, publish release v#{name}, and queue the merge-back into #{integration}") if dry_run?
100
+ return say("Not shipped.") if found && !Candidate.confirmed?(pr, head)
101
+ raise Error, "PR #{pr} #{(info["state"] == "MERGED") ? "was merged" : "is"} at #{head[0, 7]}, not #{sha}, the commit reviewed on staging" unless head.start_with?(sha)
102
+
103
+ if info["state"] == "MERGED"
104
+ say(" ✓ PR #{pr} already merged")
105
+ else
106
+ gh("pr", "merge", pr, "--merge", "--match-head-commit", head)
107
+ info = pr_info(pr)
108
+ end
109
+ publish(name, info.dig("mergeCommit", "oid"))
110
+ merge_back(name, after: "release #{name}")
111
+ end
112
+
113
+ def merge_back_after_hotfix
114
+ fetch
115
+ merge_back("#{production}-#{git("rev-parse", "--short=7", "origin/#{production}")}", after: "a hotfix")
116
+ end
117
+
118
+ def merge_back(label, after:)
119
+ fetch
120
+ branch = "merge-back/#{label}"
121
+ say("== Merge #{production} back into #{integration} in #{@repo} ==")
122
+ return dry("push #{branch} at #{production} and queue its PR into #{integration}") if dry_run?
123
+
124
+ url = propose(branch, at: git("rev-parse", "origin/#{production}"), base: integration, title: "Merge #{production} back into #{integration} after #{after}",
125
+ body: "Brings `#{production}` back into `#{integration}` after #{after}. Merge with a merge commit.\n", auto_merge: true)
126
+ out, updated = gh("api", "-X", "PUT", "repos/{owner}/{repo}/pulls/#{url.split("/").last}/update-branch", status: true)
127
+ already_current = out.include?("no new commits on the base branch")
128
+ say(" ! GitHub couldn't bring #{url} up to date; press Update branch on it") unless updated || already_current
129
+ say(" → #{url}, merging into #{integration} #{ONCE_READY}")
130
+ end
131
+
132
+ private
133
+
134
+ def check
135
+ tagged = git("tag", "--list", "v*").lines(chomp: true).filter_map { |tag| tag[/\Av(#{Changelog::NAME})\z/o, 1] }
136
+ changed = Changelog.changed_releases(git("show", "HEAD:CHANGELOG.md"), tagged.to_h { |name| [name, git("show", "v#{name}:CHANGELOG.md")] })
137
+ raise Error, changed.join("\n\n") if changed.any?
138
+
139
+ say("Shipped release notes are unchanged.")
140
+ end
141
+
142
+ def dry_run? = @opts[:dry_run]
143
+ def say(msg) = puts(msg)
144
+ def dry(msg) = say(" [dry-run] would #{msg}")
145
+ def next_changelog = git("show", "origin/#{integration}:CHANGELOG.md")
146
+ def integration = @config.integration_branch
147
+ def production = @config.production_branch
148
+
149
+ def fragments_at(commit)
150
+ git("ls-tree", "--name-only", commit, "#{Changelog::FRAGMENTS}/").lines(chomp: true)
151
+ .select { |path| Changelog.fragment?(path) }.to_h { |path| [path, git("show", "#{commit}:#{path}")] }
152
+ end
153
+
154
+ def tagged?(name) = remote_refs.include?("refs/tags/v#{name}")
155
+ def branch?(name) = remote_refs.include?("refs/heads/#{name}")
156
+ def remote_refs = @remote_refs ||= git("ls-remote", "--heads", "--tags", "origin").lines.map { |l| l.split.last }
157
+
158
+ # A fork's changelog carries the template's releases, which only the template tagged.
159
+ def shipped?(name) = tagged?(name) || template_tags[name]&.then { |commit| ancestor?(commit, "origin/#{integration}") }
160
+
161
+ def template_tags
162
+ @template_tags ||= if (template = template_repo)
163
+ out, err, status = Open3.capture3(CLEAN_GIT_ENV, "git", "-C", @root, "ls-remote", "--tags", "https://github.com/#{template}.git")
164
+ raise Error, "can't read the tags of #{template}, the template this repository forks: #{err.strip}" unless status.success?
165
+
166
+ out.lines.to_h { |line| line.split.reverse }.transform_keys { |ref| ref[%r{\Arefs/tags/v(.+?)(?:\^\{\})?\z}, 1] }
167
+ else
168
+ {}
169
+ end
170
+ end
171
+
172
+ def template_repo = @config.template
173
+ def pr_info(pr) = JSON.parse(gh("pr", "view", pr, "--json", "state,headRefName,headRefOid,baseRefName,mergeCommit"))
174
+
175
+ def release_under_way(changelog)
176
+ named = Changelog.named_release(changelog)
177
+ return "v#{named} is named but hasn't shipped" if named && !shipped?(named)
178
+
179
+ changelog_branch = remote_refs.find { |ref| ref.start_with?("refs/heads/changelog/") }
180
+ "#{changelog_branch.delete_prefix("refs/heads/")} hasn't merged" if changelog_branch
181
+ end
182
+
183
+ def waiting_to_cut?(name) = name && !shipped?(name) && !branch?("release/#{name}")
184
+
185
+ def require_passing_ci(commit, name)
186
+ runs = JSON.parse(gh("run", "list", "--workflow", @config.ci_workflow, "--commit", commit, "--event", "push", "--json", "status,conclusion"))
187
+ unless runs.any? && runs.all? { |run| run["status"] == "completed" }
188
+ raise Error, "CI hasn't passed on #{commit[0, 7]} yet: run cut again once it has"
189
+ end
190
+ return if runs.all? { |run| run["conclusion"] == "success" }
191
+
192
+ raise Error, "CI failed on #{commit[0, 7]}, where v#{name} would be cut. If it was a flake, re-run its failed jobs; otherwise " \
193
+ "fix it by a PR into #{integration}, its changelog line under ## v#{name}. Then cut again (AUTO_RELEASE does once CI passes)"
194
+ end
195
+
196
+ def fetch
197
+ @remote_refs = nil
198
+ git("fetch", "-q", "--prune", "--tags", "origin")
199
+ end
200
+
201
+ def cut_point(name)
202
+ since_named = []
203
+ git("rev-list", "--first-parent", "origin/#{integration}").each_line(chomp: true) do |commit|
204
+ changelog = git("show", "#{commit}:CHANGELOG.md")
205
+ break unless Changelog.named_release(changelog) == name
206
+
207
+ since_named << [commit, Changelog.unreleased(changelog, fragments_at(commit).values)]
208
+ end
209
+ later = since_named.index { |_, unreleased| unreleased.empty? }
210
+ return [since_named[later].first, later] if later
211
+
212
+ raise Error, "these changelog entries landed before v#{name} was named, so they'd ship without a line in " \
213
+ "What ships:\n#{since_named.first.last.join("\n")}\nMove them under ## v#{name} by a PR into #{integration}, then run cut again"
214
+ end
215
+
216
+ def waiting_release
217
+ pr = Candidate.waiting(JSON.parse(gh("pr", "list", "--base", production, "--state", "all", "--limit", "30", "--json", "number,headRefName,state,headRefOid")), usage: SHIP_USAGE) { |name| tagged?(name) }
218
+ runs, listed = gh("run", "list", "--workflow", @config.staging_workflow, "--branch", pr["headRefName"], "--event", "push",
219
+ "--json", "headSha,conclusion", status: true)
220
+ [pr["number"].to_s, pr["headRefOid"], Candidate.found(pr, listed ? JSON.parse(runs) : [])]
221
+ end
222
+
223
+ def release_name(info)
224
+ name = info["headRefName"].to_s[Candidate::BRANCH, 1]
225
+ raise Error, "that PR is not a release/* branch into #{production}" unless name && info["baseRefName"] == production
226
+
227
+ name
228
+ end
229
+
230
+ def publish(name, commit)
231
+ raise Error, "GitHub reports no merge commit for release #{name}; check its PR" unless commit
232
+
233
+ fetch
234
+ return say(" ✓ v#{name} already published") if tagged?(name)
235
+
236
+ notes = fit(Changelog.section(git("show", "#{commit}:CHANGELOG.md"), name).to_s,
237
+ BODY_LIMIT, "the full list is in CHANGELOG.md at v#{name}")
238
+ gh("release", "create", "v#{name}", "--target", commit, "--title", "Release #{name}", "--notes-file", "-", stdin: notes)
239
+ say(" → merged and published v#{name}")
240
+ end
241
+
242
+ # Pushes the branch unless a stopped run already did, then opens its PR or finds the open one.
243
+ def propose(branch, at:, base:, title:, body:, auto_merge: false)
244
+ push(at, branch) unless branch?(branch)
245
+ url, created = gh("pr", "create", "--base", base, "--head", branch, "--title", title, "--body-file", "-",
246
+ stdin: body, status: true)
247
+ raise Error, "gh pr create failed: #{url}" unless created || url.include?("already exists")
248
+
249
+ url = gh("pr", "view", branch, "--json", "url", "--jq", ".url") unless created
250
+ gh("pr", "merge", url, "--auto", "--merge") if auto_merge
251
+ url
252
+ end
253
+
254
+ # A commit already on origin passed CI's required checks on its way there; the hook adds nothing.
255
+ def push(at, branch)
256
+ ref = "#{at}:refs/heads/#{branch}"
257
+ return git("push", "-q", "--no-verify", "origin", ref) unless git("for-each-ref", "--contains", at, "refs/remotes/origin").empty?
258
+
259
+ say(" pushing #{branch}; the pre-push hook checks it first, which can take a few minutes") if pre_push_hook?
260
+ out, err, status = Open3.capture3(CLEAN_GIT_ENV, "git", "-C", @root, "push", "-q", "origin", ref)
261
+ return if status.success?
262
+
263
+ output = "#{out}#{err}".gsub(/\e\[[\d;]*m/, "")
264
+ log = File.join(@root, "tmp/release-push.log")
265
+ FileUtils.mkdir_p(File.dirname(log))
266
+ File.write(log, output)
267
+ raise Error, "git push of #{branch} failed. The end of what it said:\n#{output.lines.last(15).join.rstrip}\n" \
268
+ "All of it is in #{log.delete_prefix("#{@root}/")}"
269
+ end
270
+
271
+ def pre_push_hook? = File.executable?(File.expand_path(git("rev-parse", "--git-path", "hooks/pre-push"), @root))
272
+
273
+ def release_body(branch, section)
274
+ template = File.read(File.join(@root, TEMPLATE)).sub("`release/YYYY.MM.DD`", "`#{branch}`")
275
+ room = BODY_LIMIT - template.length
276
+ template.sub(/(## What ships\n\n)<!--.*?-->/m) { "#{$1}#{fit(section, room, "the full list is in CHANGELOG.md on #{branch}")}" }
277
+ end
278
+
279
+ def fit(text, limit, rest)
280
+ note = "\n\n…and more: #{rest}."
281
+ return text if text.length <= limit
282
+
283
+ text[0, limit - note.length].sub(/\n[^\n]*\z/, "") + note
284
+ end
285
+
286
+ def commit_on(parent, files, message:)
287
+ Dir.mktmpdir do |dir|
288
+ env = CLEAN_GIT_ENV.merge("GIT_INDEX_FILE" => File.join(dir, "index"))
289
+ git("read-tree", parent, env: env)
290
+ files.each do |path, content|
291
+ next git("update-index", "--force-remove", path, env: env) if content.nil?
292
+
293
+ git("update-index", "--add", "--cacheinfo", "100644,#{git("hash-object", "-w", "--stdin", stdin: content)},#{path}", env: env)
294
+ end
295
+ git("commit-tree", git("write-tree", env: env), "-p", parent, "-m", message)
296
+ end
297
+ end
298
+
299
+ def ancestor?(older, newer)
300
+ _, _, status = Open3.capture3(CLEAN_GIT_ENV, "git", "-C", @root, "merge-base", "--is-ancestor", older, newer)
301
+ status.success?
302
+ end
303
+
304
+ def git(*args, env: CLEAN_GIT_ENV, stdin: nil)
305
+ out, err, status = Open3.capture3(env, "git", "-C", @root, *args, stdin_data: stdin.to_s)
306
+ raise Error, "git #{args.first} failed: #{err.strip}" unless status.success?
307
+
308
+ out.strip
309
+ end
310
+
311
+ def gh(*args, stdin: nil, status: false)
312
+ out, err, result = Open3.capture3(CLEAN_GIT_ENV.merge("GH_REPO" => @repo), "gh", *args, stdin_data: stdin.to_s, chdir: @root)
313
+ return [result.success? ? out.strip : err.strip, result.success?] if status
314
+ raise Error, "gh #{args.first(2).join(" ")} failed: #{err.strip}" unless result.success?
315
+
316
+ out.strip
317
+ rescue Errno::ENOENT
318
+ raise Error, "bin/release needs the GitHub CLI (gh), signed in"
319
+ end
320
+ end
321
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReleaseFlow
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ReleaseFlow
4
+ class Error < StandardError; end
5
+ end
6
+
7
+ require_relative "release_flow/version"
8
+ require_relative "release_flow/git_env"
9
+ require_relative "release_flow/config"
10
+ require_relative "release_flow/github_origin"
11
+ require_relative "release_flow/changelog"
12
+ require_relative "release_flow/candidate"
13
+ require_relative "release_flow/release"
14
+ require_relative "release_flow/installer"
15
+ require_relative "release_flow/cli"
@@ -0,0 +1,22 @@
1
+ <!-- A release/<name> branch into <%= config.production_branch %>. The guide: <%= config.guide %> -->
2
+
3
+ ## Release
4
+
5
+ - Branch: `release/YYYY.MM.DD`
6
+ - Commit reviewed on staging: <!-- the full SHA from the staging deployment record -->
7
+
8
+ ## What ships
9
+
10
+ <!-- bin/release cut fills this with the release's CHANGELOG.md section. -->
11
+
12
+ ## Held back
13
+
14
+ <!-- PRs reverted on this branch, each with its revert commit, so <%= config.integration_branch %> can restore them after the merge-back. Delete if none. -->
15
+
16
+ ## Ship it
17
+
18
+ The merge button doesn't check what was reviewed. This does: it finds this PR and the commit staging last deployed, asks before shipping, refuses if the branch moved since, then publishes the release and merges `<%= config.production_branch %>` back into `<%= config.integration_branch %>`:
19
+
20
+ ```bash
21
+ bin/release ship
22
+ ```
@@ -0,0 +1,98 @@
1
+ name: Next release
2
+
3
+ # Starts the next release when staging is free: <%= config.guide %>
4
+ on:
5
+ pull_request:
6
+ types: [ closed ]
7
+ branches: [ <%= config.integration_branch %> ]
8
+ # Safe: cut acts only on a push to <%= config.integration_branch %>, checks it out by name, and reads only the run's event and result.
9
+ workflow_run: # jactionlint ignore=dangerous-triggers
10
+ workflows: [ <%= ci_name %> ]
11
+ types: [ completed ]
12
+ branches: [ <%= config.integration_branch %> ]
13
+
14
+ permissions:
15
+ contents: read
16
+
17
+ jobs:
18
+ prepare:
19
+ if: >-
20
+ vars.AUTO_RELEASE == 'true' &&
21
+ github.event_name == 'pull_request' &&
22
+ github.event.pull_request.merged &&
23
+ github.event.pull_request.head.repo.full_name == github.repository
24
+ concurrency:
25
+ group: next-release-prepare
26
+ cancel-in-progress: false
27
+ runs-on: ubuntu-latest
28
+ timeout-minutes: 10
29
+ steps:
30
+ - name: Require the release token
31
+ env:
32
+ RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
33
+ run: |
34
+ if [ -z "$RELEASE_TOKEN" ]; then
35
+ echo "::error title=RELEASE_TOKEN isn't set::AUTO_RELEASE is on, but this repository has no RELEASE_TOKEN secret, so no release can start. Add one as <%= config.guide %> says under \"Set up the release token\", or set AUTO_RELEASE to false."
36
+ exit 1
37
+ fi
38
+
39
+ - name: Checkout code
40
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
41
+ with:
42
+ ref: <%= config.integration_branch %>
43
+ fetch-depth: 0
44
+ persist-credentials: false
45
+
46
+ - name: Set up Ruby
47
+ uses: ruby/setup-ruby@7f797815d8adceb1c412e7abefdd1ab4abc47a5f # v1.335.0
48
+ with:
49
+ bundler-cache: true
50
+
51
+ - name: Name the next release
52
+ env:
53
+ GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
54
+ run: |
55
+ gh auth setup-git
56
+ git config user.name "github-actions[bot]"
57
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
58
+ bin/release prepare --if-needed
59
+
60
+ cut:
61
+ if: >-
62
+ vars.AUTO_RELEASE == 'true' &&
63
+ github.event_name == 'workflow_run' &&
64
+ github.event.workflow_run.event == 'push' &&
65
+ (github.event.workflow_run.conclusion == 'success' || github.event.workflow_run.conclusion == 'failure')
66
+ concurrency:
67
+ group: next-release-cut
68
+ cancel-in-progress: false
69
+ runs-on: ubuntu-latest
70
+ timeout-minutes: 10
71
+ steps:
72
+ - name: Require the release token
73
+ env:
74
+ RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
75
+ run: |
76
+ if [ -z "$RELEASE_TOKEN" ]; then
77
+ echo "::error title=RELEASE_TOKEN isn't set::AUTO_RELEASE is on, but this repository has no RELEASE_TOKEN secret, so no release can start. Add one as <%= config.guide %> says under \"Set up the release token\", or set AUTO_RELEASE to false."
78
+ exit 1
79
+ fi
80
+
81
+ - name: Checkout code
82
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
83
+ with:
84
+ ref: <%= config.integration_branch %>
85
+ fetch-depth: 0
86
+ persist-credentials: false
87
+
88
+ - name: Set up Ruby
89
+ uses: ruby/setup-ruby@7f797815d8adceb1c412e7abefdd1ab4abc47a5f # v1.335.0
90
+ with:
91
+ bundler-cache: true
92
+
93
+ - name: Cut the release
94
+ env:
95
+ GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
96
+ run: |
97
+ gh auth setup-git
98
+ bin/release cut --if-needed
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Walks a release from <%= config.integration_branch %> to <%= config.production_branch %>: <%= config.guide %>
5
+ root = File.expand_path("..", __dir__)
6
+ require "bundler/setup" if File.exist?(File.join(root, "Gemfile"))
7
+ require "release_flow"
8
+
9
+ exit ReleaseFlow::CLI.start(ARGV, root: root)
@@ -0,0 +1,24 @@
1
+ # Changelog entries
2
+
3
+ Each pull request adds its changelog entry here as a file of its own, such as
4
+ `changelog.d/fix-login.md`. Two PRs never edit the same lines, so their
5
+ entries can't conflict.
6
+
7
+ Write one line saying what changed for someone using the app, under a heading
8
+ if one fits:
9
+
10
+ ```markdown
11
+ ### Fixed
12
+
13
+ - Signing in no longer loops after a password reset.
14
+ ```
15
+
16
+ An entry without a heading goes under "Changed". `bin/release prepare` gathers
17
+ every file here into the release's section of `CHANGELOG.md` and deletes
18
+ them; this README stays.
19
+
20
+ A fix for the release under review is the exception: its line goes under that
21
+ release's `## v<name>` section in `CHANGELOG.md`, since a file here would wait
22
+ for the next release.
23
+
24
+ The full flow: <%= config.guide %>
metadata ADDED
@@ -0,0 +1,66 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: release_flow
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Dave Chmura
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: A next → release/<name> → main release flow on git and the GitHub CLI.
13
+ Changelog entries live in changelog.d/ so they never conflict; a release is named,
14
+ cut to staging, reviewed, and shipped as exactly the commit staging ran, tagged
15
+ with its notes.
16
+ email:
17
+ - dschmura@humbledaisy.com
18
+ executables:
19
+ - release_flow
20
+ extensions: []
21
+ extra_rdoc_files: []
22
+ files:
23
+ - CHANGELOG.md
24
+ - LICENSE.txt
25
+ - README.md
26
+ - exe/release_flow
27
+ - lib/release_flow.rb
28
+ - lib/release_flow/candidate.rb
29
+ - lib/release_flow/changelog.rb
30
+ - lib/release_flow/cli.rb
31
+ - lib/release_flow/config.rb
32
+ - lib/release_flow/git_env.rb
33
+ - lib/release_flow/github_origin.rb
34
+ - lib/release_flow/installer.rb
35
+ - lib/release_flow/release.rb
36
+ - lib/release_flow/version.rb
37
+ - templates/.github/PULL_REQUEST_TEMPLATE/release.md.erb
38
+ - templates/.github/workflows/next_release.yml.erb
39
+ - templates/bin/release.erb
40
+ - templates/changelog.d/README.md.erb
41
+ homepage: https://github.com/dschmura/release_flow
42
+ licenses:
43
+ - MIT
44
+ metadata:
45
+ homepage_uri: https://github.com/dschmura/release_flow
46
+ changelog_uri: https://github.com/dschmura/release_flow/blob/main/CHANGELOG.md
47
+ rubygems_mfa_required: 'true'
48
+ rdoc_options: []
49
+ require_paths:
50
+ - lib
51
+ required_ruby_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 3.3.0
56
+ required_rubygems_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ requirements: []
62
+ rubygems_version: 4.0.20
63
+ specification_version: 4
64
+ summary: 'Release a GitHub repository through staging review: each release is the
65
+ commit someone checked'
66
+ test_files: []