cli/third-party/github.com/letsencrypt/boulder/tools/release/branch/main.go
2025-07-02 14:56:58 +02:00

156 lines
4 KiB
Go

/*
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
}