Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 183 additions & 0 deletions cmd/release/main.go
Original file line number Diff line number Diff line change
@@ -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/<owner>/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 "<version>-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
}
149 changes: 149 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
@@ -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/<owner>/konnector:<tag>
```

There is no `:latest` or `:<sha>` 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/<owner>/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` |