github.com/letsencrypt/boulder@v0.20251208.0/tools/release/tag/main.go (about)

     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  	"strconv"
    36  	"strings"
    37  	"time"
    38  )
    39  
    40  type cmdError struct {
    41  	error
    42  	output string
    43  }
    44  
    45  func (e cmdError) Unwrap() error {
    46  	return e.error
    47  }
    48  
    49  func git(args ...string) (string, error) {
    50  	cmd := exec.Command("git", args...)
    51  	fmt.Println("Running:", cmd.String())
    52  	out, err := cmd.CombinedOutput()
    53  	if err != nil {
    54  		return string(out), cmdError{
    55  			error:  fmt.Errorf("running %q: %w", cmd.String(), err),
    56  			output: string(out),
    57  		}
    58  	}
    59  	return string(out), nil
    60  }
    61  
    62  func show(output string) {
    63  	for line := range strings.SplitSeq(strings.TrimSpace(output), "\n") {
    64  		fmt.Println("  ", line)
    65  	}
    66  }
    67  
    68  func main() {
    69  	err := tag(os.Args[1:])
    70  	if err != nil {
    71  		var cmdErr cmdError
    72  		if errors.As(err, &cmdErr) {
    73  			show(cmdErr.output)
    74  		}
    75  		fmt.Println(err.Error())
    76  		os.Exit(1)
    77  	}
    78  }
    79  
    80  func tag(args []string) error {
    81  	fs := flag.NewFlagSet("tag", flag.ContinueOnError)
    82  	var push bool
    83  	fs.BoolVar(&push, "push", false, "If set, push the resulting release tag to GitHub.")
    84  	err := fs.Parse(args)
    85  	if err != nil {
    86  		return fmt.Errorf("invalid flags: %w", err)
    87  	}
    88  
    89  	var branch string
    90  	switch len(fs.Args()) {
    91  	case 0:
    92  		branch = "main"
    93  	case 1:
    94  		branch = fs.Arg(0)
    95  		if !strings.HasPrefix(branch, "release-branch-") {
    96  			return fmt.Errorf("branch must be 'main' or 'release-branch-...', got %q", branch)
    97  		}
    98  	default:
    99  		return fmt.Errorf("too many args: %#v", fs.Args())
   100  	}
   101  
   102  	// Fetch all of the latest commits on this ref from origin, so that we can
   103  	// ensure we're tagging the tip of the upstream branch, and that we have all
   104  	// of the extant tags along this branch if its a release branch.
   105  	_, err = git("fetch", "origin", branch)
   106  	if err != nil {
   107  		return err
   108  	}
   109  
   110  	var tag string
   111  	switch branch {
   112  	case "main":
   113  		tag = fmt.Sprintf("v0.%s.0", time.Now().Format("20060102"))
   114  	default:
   115  		tag, err = nextTagOnBranch(branch)
   116  		if err != nil {
   117  			return fmt.Errorf("failed to compute next hotfix tag: %w", err)
   118  		}
   119  	}
   120  
   121  	// Produce the tag, using -s to PGP sign it. This will fail if a tag with
   122  	// that name already exists.
   123  	message := fmt.Sprintf("Release %s", tag)
   124  	_, err = git("tag", "-s", "-m", message, tag, "origin/"+branch)
   125  	if err != nil {
   126  		return err
   127  	}
   128  
   129  	// Show the result of the tagging operation, including the tag message and
   130  	// signature, and the commit hash and message, but not the diff.
   131  	out, err := git("show", "-s", tag)
   132  	if err != nil {
   133  		return err
   134  	}
   135  	show(out)
   136  
   137  	if push {
   138  		_, err = git("push", "origin", tag)
   139  		if err != nil {
   140  			return err
   141  		}
   142  	} else {
   143  		fmt.Println()
   144  		fmt.Println("Please inspect the tag above, then run:")
   145  		fmt.Printf("    git push origin %s\n", tag)
   146  	}
   147  	return nil
   148  }
   149  
   150  func nextTagOnBranch(branch string) (string, error) {
   151  	baseVersion := strings.TrimPrefix(branch, "release-branch-")
   152  	out, err := git("tag", "--list", "--no-column", baseVersion+".*")
   153  	if err != nil {
   154  		return "", fmt.Errorf("failed to list extant tags on branch %q: %w", branch, err)
   155  	}
   156  
   157  	maxPatch := 0
   158  	for tag := range strings.SplitSeq(strings.TrimSpace(out), "\n") {
   159  		parts := strings.SplitN(tag, ".", 3)
   160  		if len(parts) != 3 {
   161  			return "", fmt.Errorf("failed to parse release tag %q as semver", tag)
   162  		}
   163  
   164  		major := parts[0]
   165  		if major != "v0" {
   166  			return "", fmt.Errorf("expected major portion of prior release tag %q to be 'v0'", tag)
   167  		}
   168  
   169  		minor := parts[1]
   170  		t, err := time.Parse("20060102", minor)
   171  		if err != nil {
   172  			return "", fmt.Errorf("expected minor portion of prior release tag %q to be a date: %w", tag, err)
   173  		}
   174  		if t.Year() < 2015 {
   175  			return "", fmt.Errorf("minor portion of prior release tag %q appears to be an unrealistic date: %q", tag, t.String())
   176  		}
   177  
   178  		patch := parts[2]
   179  		patchInt, err := strconv.Atoi(patch)
   180  		if err != nil {
   181  			return "", fmt.Errorf("patch portion of prior release tag %q is not an integer: %w", tag, err)
   182  		}
   183  
   184  		if patchInt > maxPatch {
   185  			maxPatch = patchInt
   186  		}
   187  	}
   188  
   189  	return fmt.Sprintf("%s.%d", baseVersion, maxPatch+1), nil
   190  }