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
This commit is contained in:
Aaron Gable 2025-06-30 11:32:14 -07:00 committed by GitHub
parent 8aafb31347
commit 8accf18e60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 322 additions and 27 deletions

View File

@ -80,43 +80,35 @@ release is being tagged (not the date that the release is expected to be
deployed):
```sh
git tag -s -m "Boulder release $(date +%F)" -s "release-$(date +%F)"
git push origin "release-$(date +%F)"
go run github.com/letsencrypt/boulder/tools/release/tag@main
```
### Clean Hotfix Releases
This will print the newly-created tag and instructions on how to push it after
you are satisfied that it is correct. Alternately you can run the command with
the `-push` flag to push the resulting tag automatically.
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.
### Hotfix Releases
If it is still the same day as an already-tagged release, increment the letter suffix of the tag:
Sometimes it is necessary to create a new release which looks like a prior
release but with one or more additional commits added. This is usually the case
when we discover a critical bug in the currently-deployed version that needs to
be fixed, but we don't want to include other changes that have already been
merged to `main` since the currently-deployed release was tagged.
In this situation, we create a new hotfix release branch starting at the point
of the previous release tag. We then use the normal GitHub PR and code-review
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.
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:
```sh
git tag -s -m "Boulder hotfix release $(date +%F)a" -s "release-$(date +%F)a"
git push origin "release-$(date +%F)a"
go run github.com/letsencrypt/boulder/tools/release/branch@main v0.YYYYMMDD.0
```
If it is a new day, simply follow the regular release process above.
### Dirty Hotfix Release
If a hotfix release is necessary, but `main` already contains both commits that
we do and commits that we do not want to include in the hotfix release, then we
must go back and create a release branch for just the desired commits to be
cherry-picked to. Then, all subsequent hotfix releases will be tagged on this
branch.
The commands below assume that it is still the same day as the original release
tag was created (hence the use of "`date +%F`"), but this may not always be the
case. The rule is that the date in the release branch name should be identical
to the date in the original release tag. Similarly, this may not be the first
hotfix release; the rule is that the letter suffix should increment (e.g. "b",
"c", etc.) for each hotfix release with the same date.
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:
```sh
git checkout -b "release-branch-$(date +%F)" "release-$(date +%F)"
git cherry-pick baddecaf
git tag -s -m "Boulder hotfix release $(date +%F)a" "release-$(date +%F)a"
git push origin "release-branch-$(date +%F)" "release-$(date +%F)a"
go run github.com/letsencrypt/boulder/tools/release/tag@main release-branch-v0.YYYYMMDD
```
## Deploying Releases

View File

@ -0,0 +1,156 @@
/*
Branch Release creates a new Boulder hotfix release branch and pushes it to
GitHub. It ensures that the release branch has a standard name, and starts at
a previously-tagged mainline release.
The expectation is that this branch will then be the target of one or more PRs
copying (cherry-picking) commits from main to the release branch, and then a
hotfix release will be tagged on the branch using the related Tag Release tool.
Usage:
go run github.com/letsencrypt/boulder/tools/release/tag@main [-push] tagname
The provided tagname must be a pre-existing release tag which is reachable from
the "main" branch.
If the -push flag is not provided, it will simply print the details of the new
branch and then exit. If it is provided, it will initiate a push to the remote.
In all cases, it assumes that the upstream remote is named "origin".
*/
package main
import (
"errors"
"flag"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
type cmdError struct {
error
output string
}
func (e cmdError) Unwrap() error {
return e.error
}
func git(args ...string) (string, error) {
cmd := exec.Command("git", args...)
fmt.Println("Running:", cmd.String())
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), cmdError{
error: fmt.Errorf("running %q: %w", cmd.String(), err),
output: string(out),
}
}
return string(out), nil
}
func show(output string) {
for line := range strings.SplitSeq(strings.TrimSpace(output), "\n") {
fmt.Println(" ", line)
}
}
func main() {
err := branch(os.Args[1:])
if err != nil {
var cmdErr cmdError
if errors.As(err, &cmdErr) {
show(cmdErr.output)
}
fmt.Println(err.Error())
os.Exit(1)
}
}
func branch(args []string) error {
fs := flag.NewFlagSet("branch", flag.ContinueOnError)
var push bool
fs.BoolVar(&push, "push", false, "If set, push the resulting hotfix release branch to GitHub.")
err := fs.Parse(args)
if err != nil {
return fmt.Errorf("invalid flags: %w", err)
}
if len(fs.Args()) != 1 {
return fmt.Errorf("must supply exactly one argument, got %d: %#v", len(fs.Args()), fs.Args())
}
tag := fs.Arg(0)
// Confirm the reasonableness of the given tag name by inspecting each of its
// components.
parts := strings.SplitN(tag, ".", 3)
if len(parts) != 3 {
return fmt.Errorf("failed to parse patch version from release tag %q", tag)
}
major := parts[0]
if major != "v0" {
return fmt.Errorf("expected major portion of release tag to be 'v0', got %q", major)
}
minor := parts[1]
t, err := time.Parse("20060102", minor)
if err != nil {
return fmt.Errorf("expected minor portion of release tag to be a ")
}
if t.Year() < 2015 {
return fmt.Errorf("minor portion of release tag appears to be an unrealistic date: %q", t.String())
}
patch := parts[2]
if patch != "0" {
return fmt.Errorf("expected patch portion of release tag to be '0', got %q", patch)
}
// Fetch all of the latest refs from origin, so that we can get the most
// complete view of this tag and its relationship to main.
_, err = git("fetch", "origin")
if err != nil {
return err
}
_, err = git("merge-base", "--is-ancestor", tag, "origin/main")
if err != nil {
return fmt.Errorf("tag %q is not reachable from origin/main, may not have been created properly: %w", tag, err)
}
// Create the branch. We could skip this and instead push the tag directly
// to the desired ref name on the remote, but that wouldn't give the operator
// a chance to inspect it locally.
branch := fmt.Sprintf("release-branch-%s.%s", major, minor)
_, err = git("branch", branch, tag)
if err != nil {
return err
}
// Show the HEAD of the new branch, not including its diff.
out, err := git("show", "-s", branch)
if err != nil {
return err
}
show(out)
refspec := fmt.Sprintf("%s:%s", branch, branch)
if push {
_, err = git("push", "origin", refspec)
if err != nil {
return err
}
} else {
fmt.Println()
fmt.Println("Please inspect the branch above, then run:")
fmt.Printf(" git push origin %s\n", refspec)
}
return nil
}

147
tools/release/tag/main.go Normal file
View File

@ -0,0 +1,147 @@
/*
Tag Release creates a new Boulder release tag and pushes it to GitHub. It
ensures that the release tag points to the correct commit, has standardized
formatting of both the tag itself and its message, and is GPG-signed.
It always produces Semantic Versioning tags of the form v0.YYYYMMDD.N, where:
- the major version of 0 indicates that we are not committing to any
backwards-compatibility guarantees;
- the minor version of the current date provides a human-readable date for the
release, and ensures that minor versions will be monotonically increasing;
and
- the patch version is always 0 for mainline releases, and a monotonically
increasing number for hotfix releases.
Usage:
go run github.com/letsencrypt/boulder/tools/release/tag@main [-push] [branchname]
If the "branchname" argument is not provided, it assumes "main". If it is
provided, it must be either "main" or a properly-formatted release branch name.
If the -push flag is not provided, it will simply print the details of the new
tag and then exit. If it is provided, it will initiate a push to the remote.
In all cases, it assumes that the upstream remote is named "origin".
*/
package main
import (
"errors"
"flag"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
type cmdError struct {
error
output string
}
func (e cmdError) Unwrap() error {
return e.error
}
func git(args ...string) (string, error) {
cmd := exec.Command("git", args...)
fmt.Println("Running:", cmd.String())
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), cmdError{
error: fmt.Errorf("running %q: %w", cmd.String(), err),
output: string(out),
}
}
return string(out), nil
}
func show(output string) {
for line := range strings.SplitSeq(strings.TrimSpace(output), "\n") {
fmt.Println(" ", line)
}
}
func main() {
err := tag(os.Args[1:])
if err != nil {
var cmdErr cmdError
if errors.As(err, &cmdErr) {
show(cmdErr.output)
}
fmt.Println(err.Error())
os.Exit(1)
}
}
func tag(args []string) error {
fs := flag.NewFlagSet("tag", flag.ContinueOnError)
var push bool
fs.BoolVar(&push, "push", false, "If set, push the resulting release tag to GitHub.")
err := fs.Parse(args)
if err != nil {
return fmt.Errorf("invalid flags: %w", err)
}
if len(fs.Args()) > 1 {
return fmt.Errorf("too many args: %#v", fs.Args())
}
branch := "main"
if len(fs.Args()) == 1 {
branch = fs.Arg(0)
}
switch {
case branch == "main":
break
case strings.HasPrefix(branch, "release-branch-"):
return fmt.Errorf("sorry, tagging hotfix release branches is not yet supported")
default:
return fmt.Errorf("branch must be 'main' or 'release-branch-...', got %q", branch)
}
// Fetch all of the latest commits on this ref from origin, so that we can
// ensure we're tagging the tip of the upstream branch.
_, err = git("fetch", "origin", branch)
if err != nil {
return err
}
// We use semver's vMajor.Minor.Patch format, where the Major version is
// always 0 (no backwards compatibility guarantees), the Minor version is
// the date of the release, and the Patch number is zero for normal releases
// and only non-zero for hotfix releases.
minor := time.Now().Format("20060102")
version := fmt.Sprintf("v0.%s.0", minor)
message := fmt.Sprintf("Release %s", version)
// Produce the tag, using -s to PGP sign it. This will fail if a tag with
// that name already exists.
_, err = git("tag", "-s", "-m", message, version, "origin/"+branch)
if err != nil {
return err
}
// Show the result of the tagging operation, including the tag message and
// signature, and the commit hash and message, but not the diff.
out, err := git("show", "-s", version)
if err != nil {
return err
}
show(out)
if push {
_, err = git("push", "origin", version)
if err != nil {
return err
}
} else {
fmt.Println()
fmt.Println("Please inspect the tag above, then run:")
fmt.Printf(" git push origin %s\n", version)
}
return nil
}