From 1c4b7346d6a8ab4468ecf6c2d500887d76f2cb16 Mon Sep 17 00:00:00 2001 From: Chris Fordham Date: Tue, 25 Aug 2026 10:43:53 +1000 Subject: [PATCH 1/2] 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 b284bbbb60b302eb2c46a9ab0c746fff0e622e21 Mon Sep 17 00:00:00 2001 From: Chris Fordham Date: Tue, 25 Aug 2026 10:59:26 +1000 Subject: [PATCH 2/2] feat: report the build version and aggregate the platform's [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 three ways: --version prints and exits startup log so a pod is identifiable from its logs alone GET /platform/version JSON The API is the natural place to answer "what is this cluster running?", so /platform/version aggregates: its own compiled-in build, plus the image each of the four components is actually running, read from their pod specs. Reading pod specs rather than calling each component's own /version avoids assuming the other components are healthy — which is precisely when you most want this endpoint. It is exempt from the auth middleware and from maintenance mode. Identifying a deployment is diagnostic, not privileged, and needing a session to read it defeats the purpose during an incident; it exposes nothing beyond component image tags. The maintenance exemption was broadened from /platform/config to /platform/ to cover both, since both are used to diagnose maintenance mode. 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. Note: http.go and internal/auth/config.go have pre-existing gofmt deviations, left untouched rather than mixing unrelated formatting into this change. --- .github/workflows/docker.yml | 5 ++++ Dockerfile | 11 ++++++++- cmd/kube_workspaces/http.go | 47 +++++++++++++++++++++++++++++++++++- cmd/kube_workspaces/main.go | 38 +++++++++++++++++++++++++++++ internal/auth/middleware.go | 9 +++++++ 5 files changed, 108 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7392ae7..838ecd5 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 476ec84..2e0e957 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,13 +7,22 @@ FROM --platform=$BUILDPLATFORM golang:1.26-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 kube-workspaces-api ./cmd/kube_workspaces/ + go build -a -ldflags "\ + -X main.version=${VERSION} \ + -X main.commit=${COMMIT} \ + -X main.buildDate=${BUILD_DATE}" \ + -o kube-workspaces-api ./cmd/kube_workspaces/ FROM gcr.io/distroless/static:nonroot LABEL org.opencontainers.image.source="https://github.com/kube-workspaces/api" diff --git a/cmd/kube_workspaces/http.go b/cmd/kube_workspaces/http.go index dd68a2d..e4da8e3 100644 --- a/cmd/kube_workspaces/http.go +++ b/cmd/kube_workspaces/http.go @@ -8,6 +8,7 @@ import ( "io/fs" "net/http" "net/url" + "runtime" "os" "strconv" "strings" @@ -162,6 +163,48 @@ func handleHTTPServer(ctx context.Context, u *url.URL, workspacesEndpoints *work mux.Handle("POST", "/auth/logout", oidcHandler.HandleLogout) mux.Handle("GET", "/auth/me", oidcHandler.HandleMe) + // Platform version endpoint (public). Reports this build plus the image each + // component is actually running, so "what is deployed here?" can be answered + // without cluster access. The API's own version is compiled in; the others are + // read from their pod specs, since a component cannot be asked over the + // network without assuming it is healthy. + mux.Handle("GET", "/platform/version", func(w http.ResponseWriter, r *http.Request) { + v, c, d := Version() + resp := map[string]interface{}{ + "api": map[string]string{ + "version": v, + "commit": c, + "buildDate": d, + "go": runtime.Version(), + "platform": fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), + }, + } + + ns := os.Getenv("POD_NAMESPACE") + if ns == "" { + ns = "kube-workspaces-system" + } + components := map[string]string{} + for _, comp := range []string{"controller", "api", "proxy", "frontend"} { + pods, err := coreClient.ListPods(r.Context(), ns, + "app.kubernetes.io/component="+comp) + if err != nil || pods == nil || len(pods.Items) == 0 { + continue + } + pod := pods.Items[0] + if len(pod.Spec.Containers) > 0 { + components[comp] = pod.Spec.Containers[0].Image + } + } + if len(components) > 0 { + resp["images"] = components + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) + }) + // Platform config endpoint (public, returns form locks + maintenance status) mux.Handle("GET", "/platform/config", func(w http.ResponseWriter, r *http.Request) { cfg, err := platformProvider.GetConfig(r.Context()) @@ -1609,7 +1652,9 @@ func maintenanceMiddleware(pp *platform.ConfigProvider, next http.Handler) http. exemptPrefixes := []string{ "/health", "/auth/", - "/platform/config", + // Covers both /platform/config and /platform/version: neither should be + // blocked by maintenance mode, since both are used to diagnose it. + "/platform/", "/admin/", } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/cmd/kube_workspaces/main.go b/cmd/kube_workspaces/main.go index 84683aa..e4f1111 100644 --- a/cmd/kube_workspaces/main.go +++ b/cmd/kube_workspaces/main.go @@ -8,6 +8,7 @@ import ( "net/url" "os" "os/signal" + "runtime" "sync" "syscall" "time" @@ -24,6 +25,28 @@ import ( "goa.design/clue/log" ) +// 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" +) + +// Version returns the build version. Exported so the HTTP layer can serve it. +func Version() (v, c, d string) { return version, commit, buildDate } + +// 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, runtime.GOOS, runtime.GOARCH, runtime.Version()) +} + func main() { // Define command line flags, add any other flag required to configure the // service. @@ -33,9 +56,15 @@ func main() { httpPortF = flag.String("http-port", "", "HTTP port (overrides host HTTP port specified in service design)") secureF = flag.Bool("secure", false, "Use secure scheme (https or grpcs)") dbgF = flag.Bool("debug", false, "Log request and response bodies") + versionF = flag.Bool("version", false, "Print version information and exit") ) flag.Parse() + if *versionF { + fmt.Println(versionString()) + return + } + // Setup logger. Replace logger with your own log package of choice. format := log.FormatJSON if log.IsTerminal() { @@ -46,6 +75,15 @@ func main() { ctx = log.Context(ctx, log.WithDebug()) log.Debugf(ctx, "debug logs enabled") } + // Log the build up front, so a pod can be identified from its logs alone + // without inspecting the image digest. + log.Print(ctx, + log.KV{K: "msg", V: "starting kube-workspaces-api"}, + log.KV{K: "version", V: version}, + log.KV{K: "commit", V: commit}, + log.KV{K: "buildDate", V: buildDate}, + log.KV{K: "go", V: runtime.Version()}, + log.KV{K: "platform", V: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH)}) log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) // Initialize the services. diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 5c2378f..11e647f 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -36,6 +36,15 @@ func Middleware(provider *ConfigProvider) func(http.Handler) http.Handler { return } + // Always allow the build/version endpoint. Identifying which version + // is deployed is diagnostic information, not privileged, and needing + // a session to read it defeats the purpose during an incident. It + // exposes no cluster state beyond the component image tags. + if r.URL.Path == "/platform/version" { + next.ServeHTTP(w, r) + return + } + // Get auth config cfg, err := provider.GetConfig(ctx) if err != nil {