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:
parent
8aafb31347
commit
8accf18e60
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue