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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +145 -0
- data/exe/release_flow +6 -0
- data/lib/release_flow/candidate.rb +42 -0
- data/lib/release_flow/changelog.rb +73 -0
- data/lib/release_flow/cli.rb +25 -0
- data/lib/release_flow/config.rb +21 -0
- data/lib/release_flow/git_env.rb +19 -0
- data/lib/release_flow/github_origin.rb +18 -0
- data/lib/release_flow/installer.rb +73 -0
- data/lib/release_flow/release.rb +321 -0
- data/lib/release_flow/version.rb +5 -0
- data/lib/release_flow.rb +15 -0
- data/templates/.github/PULL_REQUEST_TEMPLATE/release.md.erb +22 -0
- data/templates/.github/workflows/next_release.yml.erb +98 -0
- data/templates/bin/release.erb +9 -0
- data/templates/changelog.d/README.md.erb +24 -0
- metadata +66 -0
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,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
|
data/lib/release_flow.rb
ADDED
|
@@ -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: []
|