Skip to content

Commit d14ec7a

Browse files
authored
Add release tooling (#583)
Signed-off-by: Mangirdas Judeikis <mangirdas@judeikis.lt>
1 parent f7726f0 commit d14ec7a

2 files changed

Lines changed: 332 additions & 0 deletions

File tree

cmd/release/main.go

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/*
2+
Copyright 2026 The Kube Bind Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
// Command release computes and creates the next v2 release-candidate tag.
18+
//
19+
// It inspects the tags already present on an upstream remote, finds the highest
20+
// release candidate for the target version (default v2.0.0), and creates the
21+
// next one (e.g. v2.0.0-rc3 if rc2 is the highest upstream). Pushing the tag to
22+
// the remote triggers the Image workflow, so pushing is gated behind -push.
23+
//
24+
// Usage:
25+
//
26+
// go run ./cmd/release # compute + create the next rc tag locally
27+
// go run ./cmd/release -dry-run # only print what would be created
28+
// go run ./cmd/release -push # create and push the tag to the remote
29+
package main
30+
31+
import (
32+
"context"
33+
"flag"
34+
"fmt"
35+
"os"
36+
"os/exec"
37+
"os/signal"
38+
"regexp"
39+
"sort"
40+
"strconv"
41+
"strings"
42+
)
43+
44+
// options holds the release tool's flags.
45+
type options struct {
46+
remote string
47+
version string
48+
push bool
49+
dryRun bool
50+
}
51+
52+
func newFlagSet(o *options) *flag.FlagSet {
53+
fs := flag.NewFlagSet("release", flag.ContinueOnError)
54+
fs.StringVar(&o.remote, "remote", "origin", "git remote to read existing tags from and push to")
55+
fs.StringVar(&o.version, "version", "v2.0.0", "target GA version the release candidates lead up to")
56+
fs.BoolVar(&o.push, "push", false, "push the created tag to the remote (triggers the Image workflow)")
57+
fs.BoolVar(&o.dryRun, "dry-run", false, "only print the tag that would be created")
58+
return fs
59+
}
60+
61+
func main() {
62+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
63+
err := run(ctx)
64+
stop()
65+
if err != nil {
66+
fmt.Fprintln(os.Stderr, "error:", err)
67+
os.Exit(1)
68+
}
69+
}
70+
71+
func run(ctx context.Context) error {
72+
var o options
73+
fs := newFlagSet(&o)
74+
if err := fs.Parse(os.Args[1:]); err != nil {
75+
return err
76+
}
77+
78+
if !strings.HasPrefix(o.version, "v2.") {
79+
return fmt.Errorf("target version %q is not a v2 version", o.version)
80+
}
81+
82+
tags, err := remoteTags(ctx, o.remote)
83+
if err != nil {
84+
return err
85+
}
86+
87+
next := nextRC(o.version, tags)
88+
fmt.Printf("remote %q: highest rc for %s -> next tag %s\n", o.remote, o.version, next)
89+
90+
if o.dryRun {
91+
return nil
92+
}
93+
94+
if localTagExists(ctx, next) {
95+
return fmt.Errorf("tag %s already exists locally; delete it first or bump the version", next)
96+
}
97+
98+
if err := gitRun(ctx, "tag", "-a", next, "-m", next); err != nil {
99+
return fmt.Errorf("creating tag %s: %w", next, err)
100+
}
101+
fmt.Printf("created annotated tag %s on HEAD\n", next)
102+
103+
if !o.push {
104+
fmt.Printf("not pushed. To publish (triggers the Image workflow):\n\n git push %s %s\n", o.remote, next)
105+
return nil
106+
}
107+
108+
if err := gitRun(ctx, "push", o.remote, next); err != nil {
109+
return fmt.Errorf("pushing tag %s to %s: %w", next, o.remote, err)
110+
}
111+
fmt.Printf("pushed %s to %s — the Image workflow will build ghcr.io/<owner>/konnector:%s\n", next, o.remote, next)
112+
return nil
113+
}
114+
115+
// rcTagRE matches a release-candidate tag and captures the rc number, e.g.
116+
// "v2.0.0-rc3" -> "3".
117+
var rcTagRE = regexp.MustCompile(`^(v\d+\.\d+\.\d+)-rc(\d+)$`)
118+
119+
// nextRC returns the next release-candidate tag for version, given the set of
120+
// existing tags. If no rc exists for version yet it returns "<version>-rc1".
121+
func nextRC(version string, tags []string) string {
122+
highest := 0
123+
for _, t := range tags {
124+
m := rcTagRE.FindStringSubmatch(t)
125+
if m == nil || m[1] != version {
126+
continue
127+
}
128+
n, err := strconv.Atoi(m[2])
129+
if err != nil {
130+
continue
131+
}
132+
if n > highest {
133+
highest = n
134+
}
135+
}
136+
return fmt.Sprintf("%s-rc%d", version, highest+1)
137+
}
138+
139+
// remoteTags lists the tag names present on the given remote via
140+
// `git ls-remote --tags`, so no local fetch is required. Dereferenced tag refs
141+
// (the "^{}" suffix) are collapsed to their base tag name.
142+
func remoteTags(ctx context.Context, remote string) ([]string, error) {
143+
out, err := gitOutput(ctx, "ls-remote", "--tags", remote)
144+
if err != nil {
145+
return nil, fmt.Errorf("listing tags on remote %q: %w", remote, err)
146+
}
147+
seen := map[string]struct{}{}
148+
var tags []string
149+
for line := range strings.SplitSeq(strings.TrimSpace(out), "\n") {
150+
fields := strings.Fields(line)
151+
if len(fields) != 2 {
152+
continue
153+
}
154+
ref := strings.TrimPrefix(fields[1], "refs/tags/")
155+
ref = strings.TrimSuffix(ref, "^{}")
156+
if _, ok := seen[ref]; ok {
157+
continue
158+
}
159+
seen[ref] = struct{}{}
160+
tags = append(tags, ref)
161+
}
162+
sort.Strings(tags)
163+
return tags, nil
164+
}
165+
166+
// localTagExists reports whether tag already exists in the local repository.
167+
func localTagExists(ctx context.Context, tag string) bool {
168+
return gitRun(ctx, "rev-parse", "--verify", "--quiet", "refs/tags/"+tag) == nil
169+
}
170+
171+
func gitRun(ctx context.Context, args ...string) error {
172+
cmd := exec.CommandContext(ctx, "git", args...)
173+
cmd.Stdout = os.Stdout
174+
cmd.Stderr = os.Stderr
175+
return cmd.Run()
176+
}
177+
178+
func gitOutput(ctx context.Context, args ...string) (string, error) {
179+
cmd := exec.CommandContext(ctx, "git", args...)
180+
cmd.Stderr = os.Stderr
181+
out, err := cmd.Output()
182+
return string(out), err
183+
}

docs/RELEASING.md

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Releasing
2+
3+
This document describes how to cut a v2 release and how to create release
4+
branches for future maintenance.
5+
6+
## How releases work
7+
8+
Releases are driven entirely by git tags. Pushing a tag that matches `v*`
9+
triggers the [Image workflow](../.github/workflows/image.yaml), which builds the
10+
multi-arch `konnector` image and pushes it to the GitHub Container Registry:
11+
12+
```
13+
ghcr.io/<owner>/konnector:<tag>
14+
```
15+
16+
There is no `:latest` or `:<sha>` tag — the image registry is shared with the
17+
v1/`main` images, so only the exact version tag is published.
18+
19+
Version tags follow [semantic versioning](https://semver.org):
20+
21+
- `v2.0.0-rc1`, `v2.0.0-rc2`, … — release candidates (pre-releases).
22+
- `v2.0.0` — the final GA release.
23+
- `v2.0.1`, `v2.1.0`, … — patch / minor releases.
24+
25+
## Cutting a release candidate
26+
27+
Release candidates are cut directly from `v2-next` (the v2 development branch)
28+
until GA, then from the release branch (see below).
29+
30+
### The `release` helper
31+
32+
Rather than working out the next rc number by hand, use the
33+
[`cmd/release`](../cmd/release) tool. It reads the tags already on the upstream
34+
remote, finds the highest rc for the target version, and creates the next one:
35+
36+
```bash
37+
# see what the next tag would be, without creating anything
38+
go run ./cmd/release -dry-run
39+
40+
# create the next rc tag locally (e.g. v2.0.0-rc3)
41+
go run ./cmd/release
42+
43+
# create AND push it (this triggers the Image workflow)
44+
go run ./cmd/release -push
45+
```
46+
47+
Flags: `-remote` (default `origin`), `-version` (default `v2.0.0`, the GA
48+
version the candidates lead up to), `-push`, and `-dry-run`. The tag is created
49+
on your current `HEAD`, so check out the commit you intend to release first.
50+
51+
### Cutting one by hand
52+
53+
1. Make sure your local checkout is up to date and on the branch you are
54+
releasing from:
55+
56+
```bash
57+
git checkout v2-next
58+
git pull --ff-only
59+
```
60+
61+
2. Confirm CI is green for the commit you are about to tag.
62+
63+
3. Create an annotated tag and push it:
64+
65+
```bash
66+
git tag -a v2.0.0-rc1 -m "v2.0.0-rc1"
67+
git push origin v2.0.0-rc1
68+
```
69+
70+
4. The Image workflow runs automatically. When it finishes, the image is
71+
available at `ghcr.io/<owner>/konnector:v2.0.0-rc1`.
72+
73+
5. (Optional) Create a GitHub Release from the tag and mark it as a
74+
pre-release:
75+
76+
```bash
77+
gh release create v2.0.0-rc1 --prerelease --generate-notes
78+
```
79+
80+
Repeat with `-rc2`, `-rc3`, … as needed until the candidate is stable.
81+
82+
## Cutting the GA release
83+
84+
Once a release candidate is deemed stable, tag the same commit (or the tip of
85+
the release branch) as the final version:
86+
87+
```bash
88+
git tag -a v2.0.0 -m "v2.0.0"
89+
git push origin v2.0.0
90+
gh release create v2.0.0 --generate-notes
91+
```
92+
93+
## Creating a release branch
94+
95+
Once `v2.0.0` ships, create a long-lived `release-2.0` branch so that
96+
`v2-next`/`main` can move on to the next minor version while patch releases can
97+
still be cut from the stable line.
98+
99+
1. Branch from the GA tag (or the commit you released):
100+
101+
```bash
102+
git checkout -b release-2.0 v2.0.0
103+
git push origin release-2.0
104+
```
105+
106+
2. Wire the branch into CI so pushes and PRs against it run the test suite. In
107+
[.github/workflows/ci.yaml](../.github/workflows/ci.yaml), add the branch to
108+
both the `push` and `pull_request` branch lists:
109+
110+
```yaml
111+
on:
112+
push:
113+
branches:
114+
- main
115+
- v2-next
116+
- release-2.0
117+
pull_request:
118+
branches:
119+
- main
120+
- v2-next
121+
- release-2.0
122+
```
123+
124+
(The Image workflow triggers on `v*` tags regardless of branch, so it needs
125+
no change.)
126+
127+
### Patch releases from a release branch
128+
129+
Cherry-pick or merge fixes into `release-2.0`, then tag the patch version from
130+
that branch:
131+
132+
```bash
133+
git checkout release-2.0
134+
git pull --ff-only
135+
# ... land fixes here ...
136+
git tag -a v2.0.1 -m "v2.0.1"
137+
git push origin v2.0.1
138+
gh release create v2.0.1 --generate-notes
139+
```
140+
141+
## Naming conventions
142+
143+
| Kind | Example | Cut from |
144+
|------------------|----------------|-----------------|
145+
| Release candidate| `v2.0.0-rc1` | `v2-next` |
146+
| GA release | `v2.0.0` | `v2-next` |
147+
| Release branch | `release-2.0` | tag `v2.0.0` |
148+
| Patch release | `v2.0.1` | `release-2.0` |
149+
| Next minor branch| `release-2.1` | tag `v2.1.0` |

0 commit comments

Comments
 (0)