Skip to content

Commit 8accf18

Browse files
authored
Create tools/release.go to automate release tagging (#6731)
Create tools/release/tag and tools/release/branch, a pair of small Go scripts which automates the creation of release tags and hotfix branches. It also updates our release documentation to provide instructions on how to use these new tools. ### //tools/release/tag/main.go: In its primary mode, this script creates a new release tag pointing at the current tip of `main`. It assumes that you have "github.com/letsencrypt/boulder" (i.e. this repo) set as your "origin" remote. The new tag is always of the format "v0.YYYYMMDD.0", so that the major version does not make any backwards-compatibility guarantees, the minor version continues our tradition of date-stamping our version numbers, and the patch version can be incremented by hotfix releases. It only pushes the newly-created tag if passed the "-push" flag; otherwise it just creates the new tag locally and exits, allowing the user to inspect it and push it themselves. This tag naming system is superior to our current "release-YYYY-MM-DD[a]" system for a few reasons. First, by virtue of being a Semver, we get access to tools (like pkg.go.dev) which understand semver. It shortens our tags, making them easier to read in horizontally-constrained environments like github's tag dropdowns and releases sidebar. And it provides a dedicated place (the patch version) for us to indicate hotfix tags, rather than our ad-hoc letter-based suffix system. Eventually, it will also support a mode where you supply a hotfix release branch name, and it tags the tip of that branch instead of the tip of main. This mode does not yet exist, to ensure that we can land the this MVP. ### //tools/release/branch/main.go: This script tags an existing tag name as input, and produces a new release branch starting at that tag. The new branch has the name "release-branch-foo", where "foo" is the major and minor of the base tag's semantic version number. The intention is that commits will then be merged to that release branch using the standard pull-request workflow, and then the as-yet-unimplemented code path of the tagging tool (see above) will be used to tag the hotfix release itself. Fixes #5726
1 parent 8aafb31 commit 8accf18

File tree

3 files changed

+322
-27
lines changed

3 files changed

+322
-27
lines changed

docs/release.md

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -80,43 +80,35 @@ release is being tagged (not the date that the release is expected to be
8080
deployed):
8181

8282
```sh
83-
git tag -s -m "Boulder release $(date +%F)" -s "release-$(date +%F)"
84-
git push origin "release-$(date +%F)"
83+
go run github.com/letsencrypt/boulder/tools/release/tag@main
8584
```
8685

87-
### Clean Hotfix Releases
86+
This will print the newly-created tag and instructions on how to push it after
87+
you are satisfied that it is correct. Alternately you can run the command with
88+
the `-push` flag to push the resulting tag automatically.
8889

89-
If a hotfix release is necessary, and the desired hotfix commits are the **only** commits which have landed on `main` since the initial release was cut (i.e. there are not any commits on `main` which we want to exclude from the hotfix release), then the hotfix tag can be created much like a normal release tag.
90+
### Hotfix Releases
9091

91-
If it is still the same day as an already-tagged release, increment the letter suffix of the tag:
92+
Sometimes it is necessary to create a new release which looks like a prior
93+
release but with one or more additional commits added. This is usually the case
94+
when we discover a critical bug in the currently-deployed version that needs to
95+
be fixed, but we don't want to include other changes that have already been
96+
merged to `main` since the currently-deployed release was tagged.
9297

93-
```sh
94-
git tag -s -m "Boulder hotfix release $(date +%F)a" -s "release-$(date +%F)a"
95-
git push origin "release-$(date +%F)a"
96-
```
97-
98-
If it is a new day, simply follow the regular release process above.
98+
In this situation, we create a new hotfix release branch starting at the point
99+
of the previous release tag. We then use the normal GitHub PR and code-review
100+
process to merge the necessary fix(es) to the branch. Finally we create a new release tag at the tip of the release branch instead of the tip of main.
99101

100-
### Dirty Hotfix Release
102+
To create the new release branch, substitute the name of the release tag which you want to use as the starting point into this command:
101103

102-
If a hotfix release is necessary, but `main` already contains both commits that
103-
we do and commits that we do not want to include in the hotfix release, then we
104-
must go back and create a release branch for just the desired commits to be
105-
cherry-picked to. Then, all subsequent hotfix releases will be tagged on this
106-
branch.
104+
```sh
105+
go run github.com/letsencrypt/boulder/tools/release/branch@main v0.YYYYMMDD.0
106+
```
107107

108-
The commands below assume that it is still the same day as the original release
109-
tag was created (hence the use of "`date +%F`"), but this may not always be the
110-
case. The rule is that the date in the release branch name should be identical
111-
to the date in the original release tag. Similarly, this may not be the first
112-
hotfix release; the rule is that the letter suffix should increment (e.g. "b",
113-
"c", etc.) for each hotfix release with the same date.
108+
This will create a release branch named `release-branch-v0.YYYYMMDD`. When all necessary PRs have been merged into that branch, create the new tag by substituting the branch name into this command:
114109

115110
```sh
116-
git checkout -b "release-branch-$(date +%F)" "release-$(date +%F)"
117-
git cherry-pick baddecaf
118-
git tag -s -m "Boulder hotfix release $(date +%F)a" "release-$(date +%F)a"
119-
git push origin "release-branch-$(date +%F)" "release-$(date +%F)a"
111+
go run github.com/letsencrypt/boulder/tools/release/tag@main release-branch-v0.YYYYMMDD
120112
```
121113

122114
## Deploying Releases

tools/release/branch/main.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
Branch Release creates a new Boulder hotfix release branch and pushes it to
3+
GitHub. It ensures that the release branch has a standard name, and starts at
4+
a previously-tagged mainline release.
5+
6+
The expectation is that this branch will then be the target of one or more PRs
7+
copying (cherry-picking) commits from main to the release branch, and then a
8+
hotfix release will be tagged on the branch using the related Tag Release tool.
9+
10+
Usage:
11+
12+
go run github.com/letsencrypt/boulder/tools/release/tag@main [-push] tagname
13+
14+
The provided tagname must be a pre-existing release tag which is reachable from
15+
the "main" branch.
16+
17+
If the -push flag is not provided, it will simply print the details of the new
18+
branch and then exit. If it is provided, it will initiate a push to the remote.
19+
20+
In all cases, it assumes that the upstream remote is named "origin".
21+
*/
22+
package main
23+
24+
import (
25+
"errors"
26+
"flag"
27+
"fmt"
28+
"os"
29+
"os/exec"
30+
"strings"
31+
"time"
32+
)
33+
34+
type cmdError struct {
35+
error
36+
output string
37+
}
38+
39+
func (e cmdError) Unwrap() error {
40+
return e.error
41+
}
42+
43+
func git(args ...string) (string, error) {
44+
cmd := exec.Command("git", args...)
45+
fmt.Println("Running:", cmd.String())
46+
out, err := cmd.CombinedOutput()
47+
if err != nil {
48+
return string(out), cmdError{
49+
error: fmt.Errorf("running %q: %w", cmd.String(), err),
50+
output: string(out),
51+
}
52+
}
53+
return string(out), nil
54+
}
55+
56+
func show(output string) {
57+
for line := range strings.SplitSeq(strings.TrimSpace(output), "\n") {
58+
fmt.Println(" ", line)
59+
}
60+
}
61+
62+
func main() {
63+
err := branch(os.Args[1:])
64+
if err != nil {
65+
var cmdErr cmdError
66+
if errors.As(err, &cmdErr) {
67+
show(cmdErr.output)
68+
}
69+
fmt.Println(err.Error())
70+
os.Exit(1)
71+
}
72+
}
73+
74+
func branch(args []string) error {
75+
fs := flag.NewFlagSet("branch", flag.ContinueOnError)
76+
var push bool
77+
fs.BoolVar(&push, "push", false, "If set, push the resulting hotfix release branch to GitHub.")
78+
err := fs.Parse(args)
79+
if err != nil {
80+
return fmt.Errorf("invalid flags: %w", err)
81+
}
82+
83+
if len(fs.Args()) != 1 {
84+
return fmt.Errorf("must supply exactly one argument, got %d: %#v", len(fs.Args()), fs.Args())
85+
}
86+
87+
tag := fs.Arg(0)
88+
89+
// Confirm the reasonableness of the given tag name by inspecting each of its
90+
// components.
91+
parts := strings.SplitN(tag, ".", 3)
92+
if len(parts) != 3 {
93+
return fmt.Errorf("failed to parse patch version from release tag %q", tag)
94+
}
95+
96+
major := parts[0]
97+
if major != "v0" {
98+
return fmt.Errorf("expected major portion of release tag to be 'v0', got %q", major)
99+
}
100+
101+
minor := parts[1]
102+
t, err := time.Parse("20060102", minor)
103+
if err != nil {
104+
return fmt.Errorf("expected minor portion of release tag to be a ")
105+
}
106+
if t.Year() < 2015 {
107+
return fmt.Errorf("minor portion of release tag appears to be an unrealistic date: %q", t.String())
108+
}
109+
110+
patch := parts[2]
111+
if patch != "0" {
112+
return fmt.Errorf("expected patch portion of release tag to be '0', got %q", patch)
113+
}
114+
115+
// Fetch all of the latest refs from origin, so that we can get the most
116+
// complete view of this tag and its relationship to main.
117+
_, err = git("fetch", "origin")
118+
if err != nil {
119+
return err
120+
}
121+
122+
_, err = git("merge-base", "--is-ancestor", tag, "origin/main")
123+
if err != nil {
124+
return fmt.Errorf("tag %q is not reachable from origin/main, may not have been created properly: %w", tag, err)
125+
}
126+
127+
// Create the branch. We could skip this and instead push the tag directly
128+
// to the desired ref name on the remote, but that wouldn't give the operator
129+
// a chance to inspect it locally.
130+
branch := fmt.Sprintf("release-branch-%s.%s", major, minor)
131+
_, err = git("branch", branch, tag)
132+
if err != nil {
133+
return err
134+
}
135+
136+
// Show the HEAD of the new branch, not including its diff.
137+
out, err := git("show", "-s", branch)
138+
if err != nil {
139+
return err
140+
}
141+
show(out)
142+
143+
refspec := fmt.Sprintf("%s:%s", branch, branch)
144+
145+
if push {
146+
_, err = git("push", "origin", refspec)
147+
if err != nil {
148+
return err
149+
}
150+
} else {
151+
fmt.Println()
152+
fmt.Println("Please inspect the branch above, then run:")
153+
fmt.Printf(" git push origin %s\n", refspec)
154+
}
155+
return nil
156+
}

tools/release/tag/main.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
Tag Release creates a new Boulder release tag and pushes it to GitHub. It
3+
ensures that the release tag points to the correct commit, has standardized
4+
formatting of both the tag itself and its message, and is GPG-signed.
5+
6+
It always produces Semantic Versioning tags of the form v0.YYYYMMDD.N, where:
7+
- the major version of 0 indicates that we are not committing to any
8+
backwards-compatibility guarantees;
9+
- the minor version of the current date provides a human-readable date for the
10+
release, and ensures that minor versions will be monotonically increasing;
11+
and
12+
- the patch version is always 0 for mainline releases, and a monotonically
13+
increasing number for hotfix releases.
14+
15+
Usage:
16+
17+
go run github.com/letsencrypt/boulder/tools/release/tag@main [-push] [branchname]
18+
19+
If the "branchname" argument is not provided, it assumes "main". If it is
20+
provided, it must be either "main" or a properly-formatted release branch name.
21+
22+
If the -push flag is not provided, it will simply print the details of the new
23+
tag and then exit. If it is provided, it will initiate a push to the remote.
24+
25+
In all cases, it assumes that the upstream remote is named "origin".
26+
*/
27+
package main
28+
29+
import (
30+
"errors"
31+
"flag"
32+
"fmt"
33+
"os"
34+
"os/exec"
35+
"strings"
36+
"time"
37+
)
38+
39+
type cmdError struct {
40+
error
41+
output string
42+
}
43+
44+
func (e cmdError) Unwrap() error {
45+
return e.error
46+
}
47+
48+
func git(args ...string) (string, error) {
49+
cmd := exec.Command("git", args...)
50+
fmt.Println("Running:", cmd.String())
51+
out, err := cmd.CombinedOutput()
52+
if err != nil {
53+
return string(out), cmdError{
54+
error: fmt.Errorf("running %q: %w", cmd.String(), err),
55+
output: string(out),
56+
}
57+
}
58+
return string(out), nil
59+
}
60+
61+
func show(output string) {
62+
for line := range strings.SplitSeq(strings.TrimSpace(output), "\n") {
63+
fmt.Println(" ", line)
64+
}
65+
}
66+
67+
func main() {
68+
err := tag(os.Args[1:])
69+
if err != nil {
70+
var cmdErr cmdError
71+
if errors.As(err, &cmdErr) {
72+
show(cmdErr.output)
73+
}
74+
fmt.Println(err.Error())
75+
os.Exit(1)
76+
}
77+
}
78+
79+
func tag(args []string) error {
80+
fs := flag.NewFlagSet("tag", flag.ContinueOnError)
81+
var push bool
82+
fs.BoolVar(&push, "push", false, "If set, push the resulting release tag to GitHub.")
83+
err := fs.Parse(args)
84+
if err != nil {
85+
return fmt.Errorf("invalid flags: %w", err)
86+
}
87+
88+
if len(fs.Args()) > 1 {
89+
return fmt.Errorf("too many args: %#v", fs.Args())
90+
}
91+
92+
branch := "main"
93+
if len(fs.Args()) == 1 {
94+
branch = fs.Arg(0)
95+
}
96+
97+
switch {
98+
case branch == "main":
99+
break
100+
case strings.HasPrefix(branch, "release-branch-"):
101+
return fmt.Errorf("sorry, tagging hotfix release branches is not yet supported")
102+
default:
103+
return fmt.Errorf("branch must be 'main' or 'release-branch-...', got %q", branch)
104+
}
105+
106+
// Fetch all of the latest commits on this ref from origin, so that we can
107+
// ensure we're tagging the tip of the upstream branch.
108+
_, err = git("fetch", "origin", branch)
109+
if err != nil {
110+
return err
111+
}
112+
113+
// We use semver's vMajor.Minor.Patch format, where the Major version is
114+
// always 0 (no backwards compatibility guarantees), the Minor version is
115+
// the date of the release, and the Patch number is zero for normal releases
116+
// and only non-zero for hotfix releases.
117+
minor := time.Now().Format("20060102")
118+
version := fmt.Sprintf("v0.%s.0", minor)
119+
message := fmt.Sprintf("Release %s", version)
120+
121+
// Produce the tag, using -s to PGP sign it. This will fail if a tag with
122+
// that name already exists.
123+
_, err = git("tag", "-s", "-m", message, version, "origin/"+branch)
124+
if err != nil {
125+
return err
126+
}
127+
128+
// Show the result of the tagging operation, including the tag message and
129+
// signature, and the commit hash and message, but not the diff.
130+
out, err := git("show", "-s", version)
131+
if err != nil {
132+
return err
133+
}
134+
show(out)
135+
136+
if push {
137+
_, err = git("push", "origin", version)
138+
if err != nil {
139+
return err
140+
}
141+
} else {
142+
fmt.Println()
143+
fmt.Println("Please inspect the tag above, then run:")
144+
fmt.Printf(" git push origin %s\n", version)
145+
}
146+
return nil
147+
}

0 commit comments

Comments
 (0)