From 88c9febe526469cf6985d499a2d25582828a1186 Mon Sep 17 00:00:00 2001 From: Chris Fordham Date: Tue, 25 Aug 2026 10:43:53 +1000 Subject: [PATCH 1/3] ci: add release-notes automation [main] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of kube-workspaces/deploy#2. Adds a release workflow and the .github/release.yml categories used to generate notes, both identical across the five repositories so a reader moving between them sees the same structure. The note preamble comes from a shared generator in the deploy repo, which classifies the commits since the last tag. That matters here because this component often has nothing but CI and docs changes in a cycle and still gets tagged to hold the platform version line — in that case the notes say so explicitly rather than leaving someone to infer it from a list of CI commits. The workflow defaults to a dry run so the notes can be reviewed before anything is tagged. --- .github/release.yml | 52 ++++++++++++++++ .github/workflows/release.yaml | 106 +++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 .github/release.yml create mode 100644 .github/workflows/release.yaml diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..abff286 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,52 @@ +# Configuration for GitHub's automatic release notes. +# +# Used by `gh release create --generate-notes` and by the "Generate release +# notes" button in the UI. Categories are matched in order, so the first match +# wins — which is why `breaking` sits at the top. +# +# Kept identical across the five kube-workspaces repositories so a reader moving +# between them sees the same structure. If you change it here, change it +# everywhere (scripts/check-release-config.sh in the deploy repo enforces this). +changelog: + exclude: + labels: + - duplicate + - invalid + - wontfix + - question + + categories: + - title: ⚠️ Breaking changes + labels: + - breaking + + - title: 🔒 Security + labels: + - security + + - title: ✨ Features + labels: + - enhancement + - feature + + - title: 🐛 Fixes + labels: + - bug + + - title: 📚 Documentation + labels: + - documentation + + - title: 🔧 CI and tooling + labels: + - ci + - chore + + - title: ⬆️ Dependencies + labels: + - dependencies + + # Anything unlabelled still appears, rather than being silently dropped. + - title: Other changes + labels: + - "*" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..ec451f0 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,106 @@ +name: Release + +# Cut a release: tag the commit, generate notes, publish. +# +# Run from the Actions tab with the version to release. Tagging triggers the +# Docker workflow, which publishes the versioned image. +# +# Release the four component repositories BEFORE kube-workspaces/deploy: the +# chart's appVersion pins these images and must name ones that already exist. +# See https://github.com/kube-workspaces/deploy/blob/main/docs/releasing.md + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release, e.g. v0.3.0' + type: string + required: true + dry_run: + description: 'Print the notes without tagging or publishing' + type: boolean + default: true + +permissions: + contents: write + +jobs: + release: + name: Release ${{ inputs.version }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + # Full history: the notes are generated from the commit range since the + # previous tag. + fetch-depth: 0 + + - name: Validate the version + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if ! printf '%s' "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::'$VERSION' is not a vX.Y.Z version" + exit 1 + fi + if git rev-parse "$VERSION" >/dev/null 2>&1; then + echo "::error::tag $VERSION already exists" + exit 1 + fi + + # The generator lives in the deploy repo so all five repositories classify + # commits the same way. + - name: Fetch the release-notes generator + run: | + curl -fsSLo /tmp/release-notes.sh \ + https://raw.githubusercontent.com/kube-workspaces/deploy/main/scripts/release-notes.sh + chmod +x /tmp/release-notes.sh + + - name: Generate release notes + id: notes + run: | + set -euo pipefail + /tmp/release-notes.sh \ + --repo "${GITHUB_REPOSITORY##*/}" \ + --from "$(git describe --tags --abbrev=0)" \ + --to HEAD > /tmp/preamble.md + cat /tmp/preamble.md + { + echo 'body<> "$GITHUB_OUTPUT" + + - name: Summary + run: | + { + echo "### Release notes preview for ${{ inputs.version }}" + echo + cat /tmp/preamble.md + } >> "$GITHUB_STEP_SUMMARY" + + - name: Create the tag and release + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git tag -a "$VERSION" -m "$VERSION" + git push origin "$VERSION" + + # --generate-notes appends the categorised commit list, grouped per + # .github/release.yml, beneath our preamble. + gh release create "$VERSION" \ + --title "$VERSION" \ + --notes "${{ steps.notes.outputs.body }}" \ + --generate-notes \ + --verify-tag + + - name: Dry run notice + if: ${{ inputs.dry_run }} + run: | + echo "::notice::dry run — nothing was tagged or published. Re-run with dry_run=false to release." From 58b4dc1293fc6c1a575a725d334090be66617dc5 Mon Sep 17 00:00:00 2001 From: Chris Fordham Date: Tue, 25 Aug 2026 10:59:09 +1000 Subject: [PATCH 2/3] feat: report the build version at runtime [main] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2. Nothing recorded which version was running. The Dockerfile built with no -ldflags, there was no version variable, and no endpoint reported one — so the only signal was the image tag, which is `latest` in the default manifests and therefore says nothing. Version, commit and build date are now compiled in, defaulting to dev/unknown so a plain `go build` still works, and surfaced two ways: --version prints and exits startup log structured, via setupLog, before the manager is constructed Note the stdlib runtime package is imported as goruntime here: the k8s apimachinery runtime is already imported under that name. debug.ReadBuildInfo() is not a substitute: it reports "(devel)" for a build not driven by `go install module@version`, which is the case for the container build. The workflow passes VERSION from docker/metadata-action rather than reconstructing it, so the image tag and the reported version cannot drift. A metric label was considered and deliberately left out for now — it would mean registering a custom collector, and the startup log plus the aggregated /platform/version endpoint in the api cover the immediate need. --- .github/workflows/docker.yml | 5 +++++ Dockerfile | 11 ++++++++++- cmd/main.go | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f5a8c26..7a77233 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -61,6 +61,11 @@ jobs: # runners. Buildx was already set up; the platforms list was simply # never passed. platforms: linux/amd64,linux/arm64 + # Compiled into the binary, so a running pod can report what it is. + build-args: | + VERSION=${{ steps.meta.outputs.version }} + COMMIT=${{ github.sha }} + BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index e8c1b1c..b3884a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,13 +7,22 @@ FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder ARG TARGETOS ARG TARGETARCH +# Build information, surfaced by --version, the startup log and /version. +ARG VERSION=dev +ARG COMMIT=unknown +ARG BUILD_DATE=unknown + WORKDIR /workspace COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \ - go build -a -o manager ./cmd/main.go + go build -a -ldflags "\ + -X main.version=${VERSION} \ + -X main.commit=${COMMIT} \ + -X main.buildDate=${BUILD_DATE}" \ + -o manager ./cmd/main.go FROM gcr.io/distroless/static:nonroot LABEL org.opencontainers.image.source="https://github.com/kube-workspaces/controller" diff --git a/cmd/main.go b/cmd/main.go index 3e04350..373dcb5 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -19,8 +19,11 @@ package main import ( "crypto/tls" "flag" + "fmt" "os" "path/filepath" + // Aliased: the k8s apimachinery "runtime" is already imported below. + goruntime "runtime" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. @@ -55,6 +58,25 @@ func init() { } // nolint:gocyclo +// Build information, injected at link time: +// +// go build -ldflags "-X main.version=v1.2.3 -X main.commit=abc1234 -X main.buildDate=..." +// +// runtime/debug.ReadBuildInfo cannot substitute for this: it reports "(devel)" +// for a build that is not driven by `go install module@version`, which is the +// case for the container build. +var ( + version = "dev" + commit = "unknown" + buildDate = "unknown" +) + +// versionString renders the build information for logs and the -version flag. +func versionString() string { + return fmt.Sprintf("%s (commit %s, built %s, %s/%s, %s)", + version, commit, buildDate, goruntime.GOOS, goruntime.GOARCH, goruntime.Version()) +} + func main() { var metricsAddr string var metricsCertPath, metricsCertName, metricsCertKey string @@ -85,10 +107,23 @@ func main() { Development: true, } opts.BindFlags(flag.CommandLine) + showVersion := flag.Bool("version", false, "print version information and exit") flag.Parse() + if *showVersion { + fmt.Println(versionString()) + return + } + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + // Log the build up front, so a pod can be identified from its logs alone + // without inspecting the image digest. + setupLog.Info("starting kube-workspaces-controller", + "version", version, "commit", commit, "buildDate", buildDate, + "go", goruntime.Version(), + "platform", fmt.Sprintf("%s/%s", goruntime.GOOS, goruntime.GOARCH)) + // if the enable-http2 flag is false (the default), http/2 should be disabled // due to its vulnerabilities. More specifically, disabling http/2 will // prevent from being vulnerable to the HTTP/2 Stream Cancellation and From d172110ae327de6309fdf4cf4be55eb63e4e9969 Mon Sep 17 00:00:00 2001 From: Chris Fordham Date: Tue, 25 Aug 2026 11:21:31 +1000 Subject: [PATCH 3/3] fix: satisfy goimports on the aliased runtime import [main] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit golangci-lint failed with: cmd/main.go:24:1: File is not properly formatted (goimports) goimports wants a blank line before a comment-led import within a group. gofmt accepts it either way, which is why this passed locally — the repo lints with goimports, not plain gofmt. Verified with goimports -l and golangci-lint run (0 issues). --- cmd/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/main.go b/cmd/main.go index 373dcb5..cbe410c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -22,6 +22,7 @@ import ( "fmt" "os" "path/filepath" + // Aliased: the k8s apimachinery "runtime" is already imported below. goruntime "runtime"