From 0df5cf4b83dd22f05a426edd7b25b136cd3da2b3 Mon Sep 17 00:00:00 2001 From: Mangirdas Judeikis Date: Mon, 6 Jul 2026 09:35:55 +0300 Subject: [PATCH] Add release tooling Signed-off-by: Mangirdas Judeikis --- cmd/release/main.go | 183 ++++++++++++++++++++++++++++++++++++++++++++ docs/RELEASING.md | 149 ++++++++++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+) create mode 100644 cmd/release/main.go create mode 100644 docs/RELEASING.md diff --git a/cmd/release/main.go b/cmd/release/main.go new file mode 100644 index 000000000..450857a53 --- /dev/null +++ b/cmd/release/main.go @@ -0,0 +1,183 @@ +/* +Copyright 2026 The Kube Bind Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Command release computes and creates the next v2 release-candidate tag. +// +// It inspects the tags already present on an upstream remote, finds the highest +// release candidate for the target version (default v2.0.0), and creates the +// next one (e.g. v2.0.0-rc3 if rc2 is the highest upstream). Pushing the tag to +// the remote triggers the Image workflow, so pushing is gated behind -push. +// +// Usage: +// +// go run ./cmd/release # compute + create the next rc tag locally +// go run ./cmd/release -dry-run # only print what would be created +// go run ./cmd/release -push # create and push the tag to the remote +package main + +import ( + "context" + "flag" + "fmt" + "os" + "os/exec" + "os/signal" + "regexp" + "sort" + "strconv" + "strings" +) + +// options holds the release tool's flags. +type options struct { + remote string + version string + push bool + dryRun bool +} + +func newFlagSet(o *options) *flag.FlagSet { + fs := flag.NewFlagSet("release", flag.ContinueOnError) + fs.StringVar(&o.remote, "remote", "origin", "git remote to read existing tags from and push to") + fs.StringVar(&o.version, "version", "v2.0.0", "target GA version the release candidates lead up to") + fs.BoolVar(&o.push, "push", false, "push the created tag to the remote (triggers the Image workflow)") + fs.BoolVar(&o.dryRun, "dry-run", false, "only print the tag that would be created") + return fs +} + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + err := run(ctx) + stop() + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } +} + +func run(ctx context.Context) error { + var o options + fs := newFlagSet(&o) + if err := fs.Parse(os.Args[1:]); err != nil { + return err + } + + if !strings.HasPrefix(o.version, "v2.") { + return fmt.Errorf("target version %q is not a v2 version", o.version) + } + + tags, err := remoteTags(ctx, o.remote) + if err != nil { + return err + } + + next := nextRC(o.version, tags) + fmt.Printf("remote %q: highest rc for %s -> next tag %s\n", o.remote, o.version, next) + + if o.dryRun { + return nil + } + + if localTagExists(ctx, next) { + return fmt.Errorf("tag %s already exists locally; delete it first or bump the version", next) + } + + if err := gitRun(ctx, "tag", "-a", next, "-m", next); err != nil { + return fmt.Errorf("creating tag %s: %w", next, err) + } + fmt.Printf("created annotated tag %s on HEAD\n", next) + + if !o.push { + fmt.Printf("not pushed. To publish (triggers the Image workflow):\n\n git push %s %s\n", o.remote, next) + return nil + } + + if err := gitRun(ctx, "push", o.remote, next); err != nil { + return fmt.Errorf("pushing tag %s to %s: %w", next, o.remote, err) + } + fmt.Printf("pushed %s to %s — the Image workflow will build ghcr.io//konnector:%s\n", next, o.remote, next) + return nil +} + +// rcTagRE matches a release-candidate tag and captures the rc number, e.g. +// "v2.0.0-rc3" -> "3". +var rcTagRE = regexp.MustCompile(`^(v\d+\.\d+\.\d+)-rc(\d+)$`) + +// nextRC returns the next release-candidate tag for version, given the set of +// existing tags. If no rc exists for version yet it returns "-rc1". +func nextRC(version string, tags []string) string { + highest := 0 + for _, t := range tags { + m := rcTagRE.FindStringSubmatch(t) + if m == nil || m[1] != version { + continue + } + n, err := strconv.Atoi(m[2]) + if err != nil { + continue + } + if n > highest { + highest = n + } + } + return fmt.Sprintf("%s-rc%d", version, highest+1) +} + +// remoteTags lists the tag names present on the given remote via +// `git ls-remote --tags`, so no local fetch is required. Dereferenced tag refs +// (the "^{}" suffix) are collapsed to their base tag name. +func remoteTags(ctx context.Context, remote string) ([]string, error) { + out, err := gitOutput(ctx, "ls-remote", "--tags", remote) + if err != nil { + return nil, fmt.Errorf("listing tags on remote %q: %w", remote, err) + } + seen := map[string]struct{}{} + var tags []string + for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + ref := strings.TrimPrefix(fields[1], "refs/tags/") + ref = strings.TrimSuffix(ref, "^{}") + if _, ok := seen[ref]; ok { + continue + } + seen[ref] = struct{}{} + tags = append(tags, ref) + } + sort.Strings(tags) + return tags, nil +} + +// localTagExists reports whether tag already exists in the local repository. +func localTagExists(ctx context.Context, tag string) bool { + return gitRun(ctx, "rev-parse", "--verify", "--quiet", "refs/tags/"+tag) == nil +} + +func gitRun(ctx context.Context, args ...string) error { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func gitOutput(ctx context.Context, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Stderr = os.Stderr + out, err := cmd.Output() + return string(out), err +} diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 000000000..bd51cdb27 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,149 @@ +# Releasing + +This document describes how to cut a v2 release and how to create release +branches for future maintenance. + +## How releases work + +Releases are driven entirely by git tags. Pushing a tag that matches `v*` +triggers the [Image workflow](../.github/workflows/image.yaml), which builds the +multi-arch `konnector` image and pushes it to the GitHub Container Registry: + +``` +ghcr.io//konnector: +``` + +There is no `:latest` or `:` tag — the image registry is shared with the +v1/`main` images, so only the exact version tag is published. + +Version tags follow [semantic versioning](https://semver.org): + +- `v2.0.0-rc1`, `v2.0.0-rc2`, … — release candidates (pre-releases). +- `v2.0.0` — the final GA release. +- `v2.0.1`, `v2.1.0`, … — patch / minor releases. + +## Cutting a release candidate + +Release candidates are cut directly from `v2-next` (the v2 development branch) +until GA, then from the release branch (see below). + +### The `release` helper + +Rather than working out the next rc number by hand, use the +[`cmd/release`](../cmd/release) tool. It reads the tags already on the upstream +remote, finds the highest rc for the target version, and creates the next one: + +```bash +# see what the next tag would be, without creating anything +go run ./cmd/release -dry-run + +# create the next rc tag locally (e.g. v2.0.0-rc3) +go run ./cmd/release + +# create AND push it (this triggers the Image workflow) +go run ./cmd/release -push +``` + +Flags: `-remote` (default `origin`), `-version` (default `v2.0.0`, the GA +version the candidates lead up to), `-push`, and `-dry-run`. The tag is created +on your current `HEAD`, so check out the commit you intend to release first. + +### Cutting one by hand + +1. Make sure your local checkout is up to date and on the branch you are + releasing from: + + ```bash + git checkout v2-next + git pull --ff-only + ``` + +2. Confirm CI is green for the commit you are about to tag. + +3. Create an annotated tag and push it: + + ```bash + git tag -a v2.0.0-rc1 -m "v2.0.0-rc1" + git push origin v2.0.0-rc1 + ``` + +4. The Image workflow runs automatically. When it finishes, the image is + available at `ghcr.io//konnector:v2.0.0-rc1`. + +5. (Optional) Create a GitHub Release from the tag and mark it as a + pre-release: + + ```bash + gh release create v2.0.0-rc1 --prerelease --generate-notes + ``` + +Repeat with `-rc2`, `-rc3`, … as needed until the candidate is stable. + +## Cutting the GA release + +Once a release candidate is deemed stable, tag the same commit (or the tip of +the release branch) as the final version: + +```bash +git tag -a v2.0.0 -m "v2.0.0" +git push origin v2.0.0 +gh release create v2.0.0 --generate-notes +``` + +## Creating a release branch + +Once `v2.0.0` ships, create a long-lived `release-2.0` branch so that +`v2-next`/`main` can move on to the next minor version while patch releases can +still be cut from the stable line. + +1. Branch from the GA tag (or the commit you released): + + ```bash + git checkout -b release-2.0 v2.0.0 + git push origin release-2.0 + ``` + +2. Wire the branch into CI so pushes and PRs against it run the test suite. In + [.github/workflows/ci.yaml](../.github/workflows/ci.yaml), add the branch to + both the `push` and `pull_request` branch lists: + + ```yaml + on: + push: + branches: + - main + - v2-next + - release-2.0 + pull_request: + branches: + - main + - v2-next + - release-2.0 + ``` + + (The Image workflow triggers on `v*` tags regardless of branch, so it needs + no change.) + +### Patch releases from a release branch + +Cherry-pick or merge fixes into `release-2.0`, then tag the patch version from +that branch: + +```bash +git checkout release-2.0 +git pull --ff-only +# ... land fixes here ... +git tag -a v2.0.1 -m "v2.0.1" +git push origin v2.0.1 +gh release create v2.0.1 --generate-notes +``` + +## Naming conventions + +| Kind | Example | Cut from | +|------------------|----------------|-----------------| +| Release candidate| `v2.0.0-rc1` | `v2-next` | +| GA release | `v2.0.0` | `v2-next` | +| Release branch | `release-2.0` | tag `v2.0.0` | +| Patch release | `v2.0.1` | `release-2.0` | +| Next minor branch| `release-2.1` | tag `v2.1.0` |