diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fbf884b..8f0c61a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,6 +41,9 @@ jobs: go-version-file: go.mod cache: false + - name: Verify release metadata + run: make verify-release-metadata + # Gate the release on reachable vulnerabilities (incl. stdlib/toolchain) # before we build or push anything. - name: Run govulncheck diff --git a/Makefile b/Makefile index 5617378..c6eb62b 100644 --- a/Makefile +++ b/Makefile @@ -97,6 +97,10 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration govulncheck: govulncheck-tool ## Run govulncheck to scan for known, reachable vulnerabilities (incl. stdlib/toolchain). "$(GOVULNCHECK)" ./... +.PHONY: verify-release-metadata +verify-release-metadata: ## Verify metadata.yaml contains the release series required by TAG. + go run ./hack/verify-release-metadata --tag "$(TAG)" --metadata metadata.yaml + ##@ Dependencies ## Tool Binaries @@ -114,8 +118,8 @@ CLUSTERCTL ?= clusterctl DEV_KIND_CLUSTER ?= capcs-mgmt # example.invalid never resolves, so imagePullPolicy: IfNotPresent can only use the kind-loaded image. DEV_IMG ?= example.invalid/capcs/manager:dev -# Must match a release series in metadata.yaml (currently 1.0 / v1beta2). -DEV_PROVIDER_VERSION ?= v1.0.99 +# Must match a release series in metadata.yaml (currently 1.1 / v1beta2). +DEV_PROVIDER_VERSION ?= v1.1.99 # clusterctl reads its overrides layer from $$XDG_CONFIG_HOME/cluster-api/overrides and falls back # to ~/.cluster-api/overrides when that directory does not exist. Careful: the XDG library clusterctl # uses resolves $$XDG_CONFIG_HOME to ~/Library/Application Support on macOS (NOT ~/.config), so the diff --git a/docs/releasing.md b/docs/releasing.md index 8fee895..55d2e1f 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -7,6 +7,7 @@ This document describes how to create a new release of the Cluster API Provider Releases follow [Semantic Versioning](https://semver.org/). The current release series is defined in `metadata.yaml`: - **1.0.x** — initial stable release series with `v1beta2` API contract +- **1.1.x** — current stable release series with `v1beta2` API contract When introducing a new API version or breaking changes, add a new release series to `metadata.yaml`. diff --git a/hack/verify-release-metadata/main.go b/hack/verify-release-metadata/main.go new file mode 100644 index 0000000..dfd36bf --- /dev/null +++ b/hack/verify-release-metadata/main.go @@ -0,0 +1,94 @@ +/* +Copyright 2026 cloudscale.ch. + +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. +*/ + +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "regexp" + "strconv" + + "k8s.io/apimachinery/pkg/util/yaml" +) + +var releaseTagPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`) + +type providerMetadata struct { + ReleaseSeries []releaseSeries `json:"releaseSeries"` +} + +type releaseSeries struct { + Major int `json:"major"` + Minor int `json:"minor"` +} + +func main() { + tag := flag.String("tag", "", "release tag to verify") + metadataPath := flag.String("metadata", "metadata.yaml", "path to the clusterctl provider metadata") + flag.Parse() + + metadata, err := os.ReadFile(*metadataPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading metadata: %v\n", err) + os.Exit(1) + } + if err := verifyReleaseSeries(*tag, metadata); err != nil { + fmt.Fprintf(os.Stderr, "Release metadata verification failed: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Verified that %s contains the release series for %s\n", *metadataPath, *tag) +} + +func verifyReleaseSeries(tag string, metadataYAML []byte) error { + matches := releaseTagPattern.FindStringSubmatch(tag) + if matches == nil { + return fmt.Errorf("tag %q is not a valid semantic version prefixed with v", tag) + } + + major, err := strconv.Atoi(matches[1]) + if err != nil { + return fmt.Errorf("parse tag major version: %w", err) + } + minor, err := strconv.Atoi(matches[2]) + if err != nil { + return fmt.Errorf("parse tag minor version: %w", err) + } + + metadataJSON, err := yaml.ToJSON(metadataYAML) + if err != nil { + return fmt.Errorf("parse metadata: %w", err) + } + metadata := providerMetadata{} + if err := json.Unmarshal(metadataJSON, &metadata); err != nil { + return fmt.Errorf("parse metadata: %w", err) + } + if len(metadata.ReleaseSeries) == 0 { + return errors.New("metadata contains no release series") + } + + for _, series := range metadata.ReleaseSeries { + if series.Major == major && series.Minor == minor { + return nil + } + } + + return fmt.Errorf("metadata does not contain release series %d.%d required by tag %s", major, minor, tag) +} diff --git a/hack/verify-release-metadata/main_test.go b/hack/verify-release-metadata/main_test.go new file mode 100644 index 0000000..f242b05 --- /dev/null +++ b/hack/verify-release-metadata/main_test.go @@ -0,0 +1,95 @@ +/* +Copyright 2026 cloudscale.ch. + +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. +*/ + +package main + +import ( + "strings" + "testing" +) + +func TestVerifyReleaseSeries(t *testing.T) { + tests := []struct { + name string + tag string + metadata string + wantError string + }{ + { + name: "stable release has a matching series", + tag: "v1.1.0", + metadata: `apiVersion: clusterctl.cluster.x-k8s.io/v1alpha3 +kind: Metadata +releaseSeries: +- major: 1 + minor: 1 + contract: v1beta2 +`, + }, + { + name: "prerelease has a matching series", + tag: "v1.2.0-beta.1", + metadata: `releaseSeries: +- major: 1 + minor: 2 + contract: v1beta2 +`, + }, + { + name: "matching major with wrong minor fails", + tag: "v1.1.0", + metadata: `releaseSeries: +- major: 1 + minor: 0 + contract: v1beta2 +`, + wantError: "release series 1.1", + }, + { + name: "invalid tag fails", + tag: "dev", + metadata: "releaseSeries: []\n", + wantError: "valid semantic version", + }, + { + name: "invalid metadata fails", + tag: "v1.1.0", + metadata: "releaseSeries: [\n", + wantError: "parse metadata", + }, + { + name: "empty release series fails", + tag: "v1.0.0", + metadata: "releaseSeries: []\n", + wantError: "no release series", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := verifyReleaseSeries(tt.tag, []byte(tt.metadata)) + if tt.wantError == "" { + if err != nil { + t.Fatalf("verifyReleaseSeries() error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantError) { + t.Fatalf("verifyReleaseSeries() error = %v, want an error containing %q", err, tt.wantError) + } + }) + } +}