Files
headscale/tools/bump/main.go
T
Kristoffer Dalby 08c7ca0fa2 tools/bump: bump the remaining container base images
Debian is resolved from the numeric tags, which exist only for released
versions: forky-slim is published today and is testing. Distroless
follows that same release rather than its own repository names, since
gcr answers for base-debian99 as readily as for base-debian13.
2026-09-25 15:52:26 +02:00

204 lines
5.1 KiB
Go

// Command bump keeps headscale's pinned versions current and puts the result
// in front of a maintainer as a single reviewable pull request.
//
// The pins are interlocked. flake.nix asks nixpkgs for the newest Go, so a lock
// update moves the compiler and every devShell tool at once. Two Dockerfiles
// compile a tailscale tree cloned from an unpinned branch, so their builder
// image has to keep up with upstream's go directive. go.mod carries two pairs
// that must move together. The capability-version table is scraped from
// tailscale's published tags, so it goes stale without anyone touching the
// repository.
//
// Each of those is an area: applied, gated, and committed on its own, so one
// failure costs one commit rather than the whole pull request.
//
// bump plan resolve every source of truth and print what would change
// bump run apply, gate, and open or update the pull request
// bump verify assert the pins are mutually consistent
package main
import (
"context"
"fmt"
"log"
"os"
"strings"
"github.com/creachadair/command"
"github.com/creachadair/flax"
)
type runFlags struct {
DryRun bool `flag:"dry-run,default=false,Rebuild the branch locally and report, but run no final gate and touch no remote"`
NoPR bool `flag:"no-pr,default=false,Push nothing and open no pull request"`
Areas string `flag:"areas,Comma-separated areas to run (default: all)"`
Skip string `flag:"skip,Comma-separated areas to skip"`
Force bool `flag:"force,default=false,Open a pull request even if an identical one was closed unmerged"`
Branch string `flag:"branch,default=automation/version-bump,Branch to push"`
Base string `flag:"base,default=main,Base branch"`
Remote string `flag:"remote,default=origin,Git remote"`
Repo string `flag:"repo,GitHub repository (default: the one the job runs in)"`
Gate string `flag:"gate,default=full,Final gate level: none, quick or full"`
}
var runCfg runFlags
func main() {
log.SetFlags(0)
log.SetPrefix("bump: ")
root := command.C{
Name: "bump",
Help: "Keep headscale's pinned versions current",
Commands: []*command.C{
{
Name: "plan",
Help: "Resolve every source of truth and print what would change",
Run: func(env *command.Env) error { return cmdPlan(env.Context()) },
},
{
Name: "run",
Help: "Apply, gate, and open or update the pull request",
SetFlags: command.Flags(flax.MustBind, &runCfg),
Run: func(env *command.Env) error { return cmdRun(env.Context()) },
},
{
Name: "verify",
Help: "Assert the pins are mutually consistent",
Run: func(env *command.Env) error { return cmdVerify(env.Context()) },
},
command.HelpCommand(nil),
},
}
command.RunOrFail(root.NewEnv(nil), os.Args[1:])
}
// selector turns the --areas and --skip flags into a predicate.
func selector(only, skip string) func(string) bool {
set := func(s string) map[string]bool {
m := map[string]bool{}
for part := range strings.SplitSeq(s, ",") {
if part = strings.TrimSpace(part); part != "" {
m[part] = true
}
}
return m
}
wanted, skipped := set(only), set(skip)
return func(name string) bool {
if skipped[name] {
return false
}
return len(wanted) == 0 || wanted[name]
}
}
func cmdRun(ctx context.Context) error {
gate := runCfg.Gate
noPR := runCfg.NoPR
if runCfg.DryRun {
gate, noPR = gateNone, true
}
r, err := openRepo(ctx)
if err != nil {
return err
}
if err := startBranch(ctx, r, runCfg.Remote, runCfg.Base, runCfg.Branch); err != nil { //nolint:noinlineerr
return err
}
results, err := runAreas(ctx, r, allAreas(), selector(runCfg.Areas, runCfg.Skip))
if err != nil {
return err
}
results, err = enforceFinalGate(ctx, r, results, gate)
if err != nil {
return err
}
tree, err := treeSHA(ctx, r)
if err != nil {
return err
}
head, err := headSHA(ctx, r)
if err != nil {
return err
}
body := renderBody(results, markerOf(results, tree, head), gate)
if err := writeStepSummary(body); err != nil { //nolint:noinlineerr
return err
}
if !anyApplied(results) {
log.Print("nothing moved")
return nil
}
if noPR {
fmt.Print(body)
return nil
}
slug, err := currentSlug(ctx, r, runCfg.Repo)
if err != nil {
return err
}
return publish(ctx, r, publishOptions{
Slug: slug,
Remote: runCfg.Remote,
Branch: runCfg.Branch,
Base: runCfg.Base,
Title: "all: bump pinned versions",
Gate: gate,
Force: runCfg.Force,
}, results)
}
func anyApplied(results []result) bool {
for _, res := range results {
if res.Commit != "" {
return true
}
}
return false
}
// writeStepSummary mirrors the report into the workflow run page, so a run that
// opens no pull request still says why.
func writeStepSummary(body string) error {
path := os.Getenv("GITHUB_STEP_SUMMARY")
if path == "" {
return nil
}
//nolint:gosec // the path is the workflow runner's own summary file
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("opening step summary: %w", err)
}
defer f.Close()
if _, err := f.WriteString(body); err != nil { //nolint:noinlineerr
return fmt.Errorf("writing step summary: %w", err)
}
return nil
}