From a2d81d94d9638b0b285d4c63c6290835530bdec4 Mon Sep 17 00:00:00 2001 From: tuti Date: Tue, 2 Dec 2025 14:25:58 -0800 Subject: [PATCH 01/20] feat: add release build command --- Makefile | 22 +-- hack/release/README.md | 61 +++++++- hack/release/build.go | 289 +++++++++++++++++++++++++++++++++++++ hack/release/flags.go | 138 +++++++++++++++++- hack/release/main.go | 1 + hack/release/prep.go | 13 +- hack/release/utils.go | 11 ++ hack/release/utils_test.go | 23 +++ 8 files changed, 534 insertions(+), 24 deletions(-) create mode 100644 hack/release/build.go diff --git a/Makefile b/Makefile index 8934b37192..66301a34a8 100644 --- a/Makefile +++ b/Makefile @@ -573,29 +573,19 @@ release-notes: hack/bin/release var-require-all-VERSION-GITHUB_TOKEN REPO=$(REPO) hack/bin/release notes ## Tags and builds a release from start to finish. -release: release-prereqs -ifneq ($(VERSION), $(GIT_VERSION)) - $(error Attempt to build $(VERSION) from $(GIT_VERSION)) -endif - $(MAKE) release-build - $(MAKE) release-verify - - @echo "" - @echo "Release build complete. Next, push the produced images." - @echo "" - @echo " make VERSION=$(VERSION) release-publish" - @echo "" +release: clean hack/bin/release + hack/bin/release build ## Produces a clean build of release artifacts at the specified version. -release-build: release-prereqs clean +release-build: release-prereqs var-require-all-VERSION-GIT_VERSION # Check that the correct code is checked out. ifneq ($(VERSION), $(GIT_VERSION)) $(error Attempt to build $(VERSION) from $(GIT_VERSION)) endif $(MAKE) image-all - $(MAKE) tag-images-all RELEASE=true IMAGETAG=$(VERSION) + $(MAKE) tag-images-all IMAGETAG=$(VERSION) # Generate the `latest` images. - $(MAKE) tag-images-all RELEASE=true IMAGETAG=latest + $(MAKE) tag-images-all IMAGETAG=latest ## Verifies the release artifacts produces by `make release-build` are correct. release-verify: release-prereqs @@ -647,7 +637,7 @@ release-from: hack/bin/release var-require-all-VERSION-OPERATOR_BASE_VERSION var # release-prereqs checks that the environment is configured properly to create a release. release-prereqs: ifndef VERSION - $(error VERSION is undefined - run using make release VERSION=vX.Y.Z) + $(error VERSION is undefined - specify using "VERSION=vX.Y.Z" with make target(s)) endif ifdef LOCAL_BUILD $(error LOCAL_BUILD must not be set for a release) diff --git a/hack/release/README.md b/hack/release/README.md index c5088fba4b..83fc8cc65d 100644 --- a/hack/release/README.md +++ b/hack/release/README.md @@ -6,12 +6,14 @@ - [Installation](#installation) - [Usage](#usage) - [Commands](#commands) - - [release prep](#release-prep) + - [release build](#release-build) - [Examples](#examples) - - [release notes](#release-notes) + - [release prep](#release-prep) - [Examples](#examples-1) - - [release from](#release-from) + - [release notes](#release-notes) - [Examples](#examples-2) + - [release from](#release-from) + - [Examples](#examples-3) ## Installation @@ -34,6 +36,59 @@ release --help ## Commands +### release build + +This command builds the operator image for a specific operator version. + +To build the operator image, use the following command: + +```sh +release build --version +``` + +For hashrelease, use the `--hashrelease` flag and provide either the Calico or Calico Enterprise version or versions file. + +```sh +release build --version --hashrelease \ + [--calico-version | --calico-versions |--enterprise-version | --enterprise-versions ] +``` + +#### Examples + +1. To build the operator image for operator version `v1.36.0` + + ```sh + release build --version v1.36.0 + ``` + +2. To build hashrelease operator image for Calico v3.30 + + 1. Using Calico versions file + + ```sh + release build --hashrelease --version v1.36.0-0.dev-259-g25c811f78fbd-v3.30.0-0.dev-338-gca80474016a5 --calico-versions hashrelease-versions.yaml + ``` + + 2. Specifying version directly + + ```sh + release build --hashrelease --version v1.36.0-0.dev-259-g25c811f78fbd-v3.30.0-0.dev-338-gca80474016a5 --calico-version v3.30.0-0.dev-338-gca80474016a5 + ``` + +3. To build hashrelease operator image for Calico Enterprise v3.22 + + 1. Using Enterprise versions file + + ```sh + release build --hashrelease --version v1.36.0-0.dev-259-g25c811f78fbd-v3.22.0-calient-0.dev-100-gabcdef123456 --enterprise-versions hashrelease-versions.yaml + ``` + + 2. Specifying version directly + + ```sh + release build --hashrelease --version v1.36.0-0.dev-259-g25c811f78fbd-v3.22.0-calient-0.dev-100-gabcdef123456 --enterprise-version v3.22.0-calient-0.dev-100-gabcdef123456 + ``` + ### release prep This command prepares the repo for a new release. diff --git a/hack/release/build.go b/hack/release/build.go new file mode 100644 index 0000000000..fa5df8e5c9 --- /dev/null +++ b/hack/release/build.go @@ -0,0 +1,289 @@ +// Copyright (c) 2025 Tigera, Inc. All rights reserved. + +// 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 ( + "context" + "fmt" + "os" + "regexp" + "strings" + + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v3" +) + +// Component image variables +const ( + calicoRegistryConfigKey = "CalicoRegistry" + calicoImagePathConfigKey = "CalicoImagePath" + enterpriseRegistryConfigKey = "TigeraRegistry" + enterpriseImagePathConfigKey = "TigeraImagePath" + operatorRegistryConfigKey = "OperatorRegistry" + operatorImagePathConfigKey = "OperatorImagePath" +) + +// Mapping of component image keys to descriptions +var componentImageConfigMap = map[string]string{ + calicoRegistryConfigKey: "Calico Registry", + calicoImagePathConfigKey: "Calico Image Path", + enterpriseRegistryConfigKey: "Enterprise Registry", + enterpriseImagePathConfigKey: "Enterprise Image Path", + operatorRegistryConfigKey: "Operator Registry", + operatorImagePathConfigKey: "Operator Image Path", +} + +// Build context keys +const ( + calicoBuildCtxKey contextKey = "calico-build-type" + enterpriseBuildCtxKey contextKey = "enterprise-build-type" +) + +// Build types +const ( + versionsBuild buildType = "versions-file" + versionBuild buildType = "version" +) + +// type of build being performed. Either using the Calico/Enterprise version or its corresponding versions file. +type buildType string + +// Command to build release artifacts. +var buildCommand = &cli.Command{ + Name: "build", + Usage: "Build release artifacts", + Flags: []cli.Flag{ + versionFlag, + imageFlag, + archFlag, + registryFlag, + calicoVersionFlag, + calicoRegistryFlag, + calicoImagePathFlag, + calicoVersionsConfigFlag, + calicoCRDsDirFlag, + enterpriseVersionFlag, + enterpriseRegistryFlag, + enterpriseImagePathFlag, + enterpriseVersionsConfigFlag, + enterpriseCRDsDirFlag, + hashreleaseFlag, + skipValidationFlag, + }, + Before: buildBefore, + Action: buildAction, +} + +// Pre-action for release build command. +var buildBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (context.Context, error) { + configureLogging(c) + + // Determine build types for Calico and Enterprise + if ver := c.String(calicoVersionsConfigFlag.Name); ver != "" { + ctx = context.WithValue(ctx, calicoBuildCtxKey, versionsBuild) + logrus.Debug("Calico build using versions file selected") + } + if ver := c.String(calicoVersionFlag.Name); ver != "" { + ctx = context.WithValue(ctx, calicoBuildCtxKey, versionBuild) + logrus.Debug("Calico build using specific version selected") + } + if ver := c.String(enterpriseVersionsConfigFlag.Name); ver != "" { + ctx = context.WithValue(ctx, enterpriseBuildCtxKey, versionsBuild) + logrus.Debug("Enterprise build using versions file selected") + } + if ver := c.String(enterpriseVersionFlag.Name); ver != "" { + ctx = context.WithValue(ctx, enterpriseBuildCtxKey, versionBuild) + logrus.Debug("Enterprise build using specific version selected") + } + + // Skip validations if requested + if c.Bool(skipValidationFlag.Name) { + return ctx, nil + } + + // Ensure that git working tree is clean + ctx, err := checkGitClean(ctx) + if err != nil { + return ctx, err + } + + isHashrelease := c.Bool(hashreleaseFlag.Name) + + // If not a hashrelease build, ensure version format is valid + if valid, _ := isReleaseVersionFormat(c.String(versionFlag.Name)); !valid && !isHashrelease { + return ctx, fmt.Errorf("for non-release builds, the %s flag must be set", hashreleaseFlag.Name) + } + + // No further checks for release builds + if !isHashrelease { + return ctx, nil + } + + // For hashrelease builds, ensure at least one of Calico or Enterprise version or versions file is specified. + // If Calico/Enterprise version build is selected, ensure CRDs directory is specified + // as the version will likely not exist as a tag/branch in the corresponding Calico/Enterprise repos. + // If Calico/Enterprise is built using versions file, log a warning if CRDs directory is not specified. + calicoBuildType, calicoBuildOk := ctx.Value(calicoBuildCtxKey).(buildType) + enterpriseBuildType, enterpriseBuildOk := ctx.Value(enterpriseBuildCtxKey).(buildType) + if !calicoBuildOk && !enterpriseBuildOk { + return ctx, fmt.Errorf("for hashrelease builds, at least one of Calico or Enterprise version or versions file must be specified") + } + if calicoBuildOk { + if calicoBuildType == versionBuild && c.String(calicoCRDsDirFlag.Name) == "" { + return ctx, fmt.Errorf("Calico CRDs directory must be specified for hashrelease builds using calico-version flag") + } + logrus.Warn("Calico CRDs directory not specified for hashrelease build, default CRDs may not be appropriate") + } + if enterpriseBuildOk { + if enterpriseBuildType == versionBuild && c.String(enterpriseCRDsDirFlag.Name) == "" { + return ctx, fmt.Errorf("Enterprise CRDs directory must be specified for hashrelease builds using enterprise-version flag") + } + logrus.Warn("Enterprise CRDs directory not specified for hashrelease build, default CRDs may not be appropriate") + } + + return ctx, nil +}) + +// Action for release build command. +var buildAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error { + repoRootDir, err := gitDir() + if err != nil { + return fmt.Errorf("error getting git directory: %w", err) + } + + // Prepare build environment variables + buildEnv := append(os.Environ(), fmt.Sprintf("VERSION=%s", c.String(versionFlag.Name))) + arches := c.StringSlice(archFlag.Name) + if len(arches) > 0 { + buildEnv = append(buildEnv, fmt.Sprintf("ARCHES=%s", strings.Join(arches, " "))) + } + if c.Bool(hashreleaseFlag.Name) { + hashreleaseEnv, resetFn, err := hashrеleaseBuildConfig(ctx, c, repoRootDir) + defer resetFn() + if err != nil { + return fmt.Errorf("error preparing hashrelease build environment: %w", err) + } + buildEnv = append(buildEnv, hashreleaseEnv...) + } else { + buildEnv = append(buildEnv, "RELEASE=true") + } + + // Build the Operator and verify the build + if out, err := makeInDir(repoRootDir, "release-build", buildEnv...); err != nil { + logrus.Error(out) + return fmt.Errorf("error building Operator: %w", err) + } + + return nil +}) + +func hashrеleaseBuildConfig(ctx context.Context, c *cli.Command, repoRootDir string) ([]string, func(), error) { + repoReset := func() { + if out, err := gitInDir(repoRootDir, append([]string{"checkout", "-f"}, changedFiles...)...); err != nil { + logrus.WithError(err).Errorf("error resetting git state: %s", out) + } + } + buildEnv := []string{fmt.Sprintf("GIT_VERSION=%s", c.String(versionFlag.Name))} + image := c.String(imageFlag.Name) + if image != defaultImageName { + buildEnv = append(buildEnv, fmt.Sprintf("BUILD_IMAGE=%s", image)) + buildEnv = append(buildEnv, fmt.Sprintf("BUILD_INIT_IMAGE=%s-init", image)) + imageParts := strings.SplitN(c.String(imageFlag.Name), "/", 2) + if err := modifyComponentImageConfig(repoRootDir, operatorImagePathConfigKey, addTrailingSlash(imageParts[0])); err != nil { + return buildEnv, repoReset, fmt.Errorf("error updating Operator image path: %w", err) + } + } + registry := c.String(registryFlag.Name) + if registry != "" && registry != quayRegistry { + buildEnv = append(buildEnv, + fmt.Sprintf("IMAGE_REGISTRY=%s", registry), + fmt.Sprintf("PUSH_IMAGE_PREFIXES=%s", addTrailingSlash(registry))) + if err := modifyComponentImageConfig(repoRootDir, operatorRegistryConfigKey, addTrailingSlash(registry)); err != nil { + return buildEnv, repoReset, fmt.Errorf("error updating Operator registry: %w", err) + } + } + if registry := c.String(calicoRegistryFlag.Name); registry != "" { + if err := modifyComponentImageConfig(repoRootDir, calicoRegistryConfigKey, addTrailingSlash(registry)); err != nil { + return buildEnv, repoReset, fmt.Errorf("error updating Calico registry: %w", err) + } + } + if imagePath := c.String(calicoImagePathFlag.Name); imagePath != "" { + if err := modifyComponentImageConfig(repoRootDir, calicoImagePathConfigKey, imagePath); err != nil { + return buildEnv, repoReset, fmt.Errorf("error updating Calico image path: %w", err) + } + } + if registry := c.String(enterpriseRegistryFlag.Name); registry != "" { + if err := modifyComponentImageConfig(repoRootDir, enterpriseRegistryConfigKey, addTrailingSlash(registry)); err != nil { + return buildEnv, repoReset, fmt.Errorf("error updating Enterprise registry: %w", err) + } + } + if imagePath := c.String(enterpriseImagePathFlag.Name); imagePath != "" { + if err := modifyComponentImageConfig(repoRootDir, enterpriseImagePathConfigKey, imagePath); err != nil { + return buildEnv, repoReset, fmt.Errorf("error updating Enterprise image path: %w", err) + } + } + + // Update versions and CRDs + genEnv := os.Environ() + genMakeTargets := []string{} + if dir := c.String(calicoCRDsDirFlag.Name); dir != "" { + genEnv = append(genEnv, fmt.Sprintf("CALICO_CRDS_DIR=%s", dir)) + } + if dir := c.String(enterpriseCRDsDirFlag.Name); dir != "" { + genEnv = append(genEnv, fmt.Sprintf("ENTERPRISE_CRDS_DIR=%s", dir)) + } + if bt, ok := ctx.Value(calicoBuildCtxKey).(buildType); ok { + genMakeTargets = append(genMakeTargets, "gen-versions-calico") + switch bt { + case versionBuild: + if err := updateConfigVersions(repoRootDir, calicoConfig, c.String(calicoVersionFlag.Name)); err != nil { + return buildEnv, repoReset, fmt.Errorf("error updating Calico config versions: %w", err) + } + case versionsBuild: + genEnv = append(genEnv, fmt.Sprintf("OS_VERSIONS=%s", c.String(calicoVersionsConfigFlag.Name))) + } + } + if bt, ok := ctx.Value(enterpriseBuildCtxKey).(buildType); ok { + genMakeTargets = append(genMakeTargets, "gen-versions-enterprise") + switch bt { + case versionBuild: + if err := updateConfigVersions(repoRootDir, enterpriseConfig, c.String(enterpriseVersionFlag.Name)); err != nil { + return buildEnv, repoReset, fmt.Errorf("error updating Enterprise config versions: %w", err) + } + case versionsBuild: + genEnv = append(genEnv, fmt.Sprintf("EE_VERSIONS=%s", c.String(enterpriseVersionsConfigFlag.Name))) + } + } + if out, err := makeInDir(repoRootDir, strings.Join(genMakeTargets, " "), genEnv...); err != nil { + logrus.Error(out) + return buildEnv, repoReset, fmt.Errorf("error generating versions: %w", err) + } + return buildEnv, repoReset, nil +} + +// Modify variables in pkg/components/images.go +func modifyComponentImageConfig(repoRootDir, configKey, newValue string) error { + // Check the configKey is valid + desc, ok := componentImageConfigMap[configKey] + if !ok { + return fmt.Errorf("invalid component image config key: %s", configKey) + } + + if out, err := runCommandInDir(repoRootDir, "sed", []string{"-i", fmt.Sprintf(`s|%[1]s.*=.*".*"|%[1]s = "%[2]s"|`, configKey, regexp.QuoteMeta(newValue)), "pkg/components/images.go"}, nil); err != nil { + logrus.Error(out) + return fmt.Errorf("failed to update %s in pkg/components/images.go: %w", desc, err) + } + return nil +} diff --git a/hack/release/flags.go b/hack/release/flags.go index f71552d41e..f0fa2ab1a8 100644 --- a/hack/release/flags.go +++ b/hack/release/flags.go @@ -16,7 +16,10 @@ package main import ( "context" + "errors" "fmt" + "io/fs" + "os" "regexp" "slices" "strings" @@ -85,6 +88,10 @@ var ( Sources: cli.EnvVars("OPERATOR_VERSION", "VERSION"), Required: true, Action: func(ctx context.Context, c *cli.Command, s string) error { + if c.Bool(hashreleaseFlag.Name) { + // No need to validate version for hashrelease + return nil + } if valid, err := isReleaseVersionFormat(s); err != nil { return fmt.Errorf("error validating version format: %w", err) } else if !valid { @@ -172,6 +179,39 @@ var skipValidationFlag = &cli.BoolFlag{ Value: false, } +// Flag Action to check value is a valid directory. +func dirFlagCheck(_ context.Context, _ *cli.Command, path string) error { + if path == "" { + return nil + } + // Check if the directory exists + info, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("directory %q does not exist", path) + } + if !info.IsDir() { + return fmt.Errorf("%q is not a directory", path) + } + return nil +} + +// Flag Action to check value is a valid directory. +func fileFlagCheck(_ context.Context, _ *cli.Command, path string) error { + if path == "" { + return nil + } + // Check if the file exists + info, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("file %q does not exist", path) + } + if info.IsDir() { + return fmt.Errorf("%q is a directory, expected a file", path) + } + return nil +} + +// Calico related flags. var ( calicoFlagCategory = "Calico Options" calicoVersionFlag = &cli.StringFlag{ @@ -180,6 +220,10 @@ var ( Usage: "The Calico version to use for the release", Sources: cli.EnvVars("CALICO_VERSION"), Action: func(ctx context.Context, c *cli.Command, s string) error { + if c.Bool(hashreleaseFlag.Name) { + // No need to validate Calico version for hashrelease + return nil + } if valid, err := isReleaseVersionFormat(s); err != nil { return fmt.Errorf("error validating Calico version format: %w", err) } else if !valid { @@ -195,6 +239,52 @@ var ( Sources: cli.EnvVars("OS_IMAGES_VERSIONS"), Action: validateOverrides, } + calicoRegistryFlag = &cli.StringFlag{ + Name: "calico-registry", + Category: calicoFlagCategory, + Usage: "The registry Calico images are hosted in.", + Sources: cli.EnvVars("CALICO_REGISTRY"), + Action: func(ctx context.Context, c *cli.Command, s string) error { + if s != "" && !c.Bool(hashreleaseFlag.Name) { + return fmt.Errorf("calico-registry can only be set for hashreleases") + } + return nil + }, + } + calicoImagePathFlag = &cli.StringFlag{ + Name: "calico-image-path", + Category: calicoFlagCategory, + Usage: "The path to the Calico images file.", + Sources: cli.EnvVars("CALICO_IMAGE_PATH"), + Action: func(ctx context.Context, c *cli.Command, s string) error { + if s != "" && !c.Bool(hashreleaseFlag.Name) { + return fmt.Errorf("calico-image-path can only be set for hashreleases") + } + return nil + }, + } + calicoVersionsConfigFlag = &cli.StringFlag{ + Name: "calico-versions", + Category: calicoFlagCategory, + Usage: "The path to the Calico versions config file.", + Sources: cli.EnvVars("CALICO_VERSIONS"), + Action: func(ctx context.Context, c *cli.Command, s string) error { + if s != "" && !c.Bool(hashreleaseFlag.Name) { + return fmt.Errorf("calico-versions can only be set for hashreleases") + } + if s != "" && c.String(calicoVersionFlag.Name) != "" { + return fmt.Errorf("calico-versions and calico-version cannot both be set") + } + return fileFlagCheck(ctx, c, s) + }, + } + calicoCRDsDirFlag = &cli.StringFlag{ + Name: "calico-crds-dir", + Category: calicoFlagCategory, + Usage: "The directory containing the Calico CRDs to bundle with the operator", + Sources: cli.EnvVars("CALICO_DIR"), + Action: dirFlagCheck, + } ) // Enterprise related flags. @@ -206,6 +296,10 @@ var ( Usage: "The Calico Enterprise version to use for the release", Sources: cli.EnvVars("ENTERPRISE_VERSION"), Action: func(ctx context.Context, c *cli.Command, s string) error { + if c.Bool(hashreleaseFlag.Name) { + // No need to validate Enterprise version for hashrelease + return nil + } if valid, err := isEnterpriseReleaseVersionFormat(s); err != nil { return fmt.Errorf("error validating Enterprise version format: %w", err) } else if !valid { @@ -219,7 +313,40 @@ var ( Category: enterpriseFlagCategory, Usage: "The registry Enterprise images are hosted in.", Sources: cli.EnvVars("ENTERPRISE_REGISTRY"), - Value: quayRegistry, + } + enterpriseImagePathFlag = &cli.StringFlag{ + Name: "enterprise-image-path", + Category: enterpriseFlagCategory, + Usage: "The path to the Enterprise images file.", + Sources: cli.EnvVars("ENTERPRISE_IMAGE_PATH"), + Action: func(ctx context.Context, c *cli.Command, s string) error { + if s != "" && !c.Bool(hashreleaseFlag.Name) { + return fmt.Errorf("enterprise-image-path can only be set for hashreleases") + } + return nil + }, + } + enterpriseVersionsConfigFlag = &cli.StringFlag{ + Name: "enterprise-versions", + Category: enterpriseFlagCategory, + Usage: "The path to the Enterprise versions config file.", + Sources: cli.EnvVars("ENTERPRISE_VERSIONS"), + Action: func(ctx context.Context, c *cli.Command, s string) error { + if s != "" && !c.Bool(hashreleaseFlag.Name) { + return fmt.Errorf("enterprise-versions can only be set for hashreleases") + } + if s != "" && c.String(enterpriseVersionFlag.Name) != "" { + return fmt.Errorf("enterprise-versions and enterprise-version cannot both be set") + } + return fileFlagCheck(ctx, c, s) + }, + } + enterpriseCRDsDirFlag = &cli.StringFlag{ + Name: "enterprise-crds-dir", + Category: enterpriseFlagCategory, + Usage: "The directory containing the Enterprise CRDs to bundle with the operator", + Sources: cli.EnvVars("ENTERPRISE_DIR"), + Action: dirFlagCheck, } exceptEnterpriseFlag = &cli.StringSliceFlag{ Name: "except-calico-enterprise", @@ -228,9 +355,16 @@ var ( Sources: cli.EnvVars("EE_IMAGES_VERSIONS"), Action: func(ctx context.Context, c *cli.Command, values []string) error { if len(values) == 0 && len(c.StringSlice("except-calico")) == 0 { - return fmt.Errorf("at least one of --except-calico or --except-enterprise must be set") + return fmt.Errorf("at least one of --except-calico or --except-calico-enterprise must be set") } return validateOverrides(ctx, c, values) }, } ) + +var hashreleaseFlag = &cli.BoolFlag{ + Name: "hashrelease", + Usage: "Indicates if this is a hashrelease", + Sources: cli.EnvVars("HASHRELEASE"), + Value: false, +} diff --git a/hack/release/main.go b/hack/release/main.go index b5982de071..02b0be13d1 100644 --- a/hack/release/main.go +++ b/hack/release/main.go @@ -43,6 +43,7 @@ func app(version string) *cli.Command { Usage: "CLI tool for releasing operator", Version: version, Commands: []*cli.Command{ + buildCommand, prepCommand, releaseNotesCommand, releaseFromCommand, diff --git a/hack/release/prep.go b/hack/release/prep.go index 33b4e5d74a..af40449a5b 100644 --- a/hack/release/prep.go +++ b/hack/release/prep.go @@ -33,6 +33,13 @@ var excludedComponentsPatterns = []string{ `^eck-.*`, } +var changedFiles = []string{ + calicoConfig, + enterpriseConfig, + "pkg/components", + "pkg/crds", +} + // Command to prepare repo for a new release. var prepCommand = &cli.Command{ Name: "prep", @@ -152,8 +159,8 @@ var prepAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error eRegistry := c.String(enterpriseRegistryFlag.Name) if eRegistry != "" { logrus.Debugf("Updating Enterprise registry to %s", eRegistry) - if _, err := runCommandInDir(repoRootDir, "sed", []string{"-i", fmt.Sprintf(`s|TigeraRegistry.*=.*".*"|TigeraRegistry = "%s/"|`, regexp.QuoteMeta(eRegistry)), "pkg/components/images.go"}, nil); err != nil { - return fmt.Errorf("failed to update Enterprise registry in pkg/components/images.go: %w", err) + if err := modifyComponentImageConfig(repoRootDir, enterpriseRegistryConfigKey, eRegistry); err != nil { + return err } } } @@ -164,7 +171,7 @@ var prepAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error } // Commit changes - if _, err := gitInDir(repoRootDir, "add", calicoConfig, enterpriseConfig, "pkg/components", "pkg/crds"); err != nil { + if _, err := gitInDir(repoRootDir, append([]string{"add"}, changedFiles...)...); err != nil { return fmt.Errorf("error staging git changes: %w", err) } if _, err := git("commit", "-m", fmt.Sprintf("build: %s release", version)); err != nil { diff --git a/hack/release/utils.go b/hack/release/utils.go index 11ac2fa7d5..926506cefa 100644 --- a/hack/release/utils.go +++ b/hack/release/utils.go @@ -205,3 +205,14 @@ func isEnterpriseReleaseVersionFormat(version string) (bool, error) { } return releaseRegex.MatchString(version), nil } + +// Ensure string ends with a slash, if empty string returns empty string. +func addTrailingSlash(registry string) string { + if registry == "" { + return "" + } + if strings.HasSuffix(registry, "/") { + return registry + } + return registry + "/" +} diff --git a/hack/release/utils_test.go b/hack/release/utils_test.go index c5d82279bc..5c654c57a7 100644 --- a/hack/release/utils_test.go +++ b/hack/release/utils_test.go @@ -258,3 +258,26 @@ func checkReleaseVersions(t testing.TB, got map[string]string, wantCalicoVer, wa t.Fatalf("releaseVersions() mismatch (-want +got):\n%s", diff) } } + +func TestAddTrailingSlash(t *testing.T) { + t.Parallel() + cases := []struct { + input string + expected string + }{ + {"docker.io", "docker.io/"}, + {"quay.io/", "quay.io/"}, + {"gcr.io/some-repo", "gcr.io/some-repo/"}, + {"", ""}, + } + + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + got := addTrailingSlash(tc.input) + if got != tc.expected { + t.Errorf("expected %q, got %q", tc.expected, got) + } + }) + } +} From 2061cfbb6c5969bcde793fc8b074589bdb6544b5 Mon Sep 17 00:00:00 2001 From: tuti Date: Wed, 3 Dec 2025 12:05:14 -0800 Subject: [PATCH 02/20] feat: add release publish command --- Makefile | 9 ++- go.mod | 7 ++ go.sum | 18 +++++- hack/release/main.go | 1 + hack/release/publish.go | 137 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 hack/release/publish.go diff --git a/Makefile b/Makefile index 66301a34a8..ce00fa62be 100644 --- a/Makefile +++ b/Makefile @@ -565,7 +565,7 @@ release-tag: var-require-all-RELEASE_TAG-GITHUB_TOKEN fi $(MAKE) release VERSION=$(RELEASE_TAG) - $(MAKE) release-publish-images VERSION=$(RELEASE_TAG) + $(MAKE) release-publish VERSION=$(RELEASE_TAG) $(MAKE) release-github VERSION=$(RELEASE_TAG) @@ -601,9 +601,12 @@ release-check-image-exists: release-prereqs echo "Image tag check passed; image does not already exist"; \ fi -release-publish-images: release-prereqs release-check-image-exists +release-publish: hack/bin/release + hack/bin/release publish + +release-publish-images: release-prereqs release-check-image-exists var-require-all-VERSION # Push images. - $(MAKE) push-all push-manifests push-non-manifests RELEASE=true IMAGETAG=$(VERSION) + $(MAKE) push-all push-manifests push-non-manifests IMAGETAG=$(VERSION) release-github: release-notes hack/bin/gh @echo "Creating github release for $(VERSION)" diff --git a/go.mod b/go.mod index 66b95f37da..4ea7f294fb 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/go-ldap/ldap v3.0.3+incompatible github.com/go-logr/logr v1.4.3 github.com/google/go-cmp v0.7.0 + github.com/google/go-containerregistry v0.20.6 github.com/google/go-github/v53 v53.2.0 github.com/hashicorp/go-version v1.7.0 github.com/olivere/elastic/v7 v7.0.32 @@ -73,8 +74,12 @@ require ( github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.17.0 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/docker/cli v28.3.3+incompatible // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/elastic/go-sysinfo v1.13.1 // indirect github.com/elastic/go-ucfg v0.8.8 // indirect github.com/elastic/go-windows v1.0.1 // indirect @@ -125,6 +130,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/spdystream v0.5.0 // indirect @@ -153,6 +159,7 @@ require ( github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/pflag v1.0.7 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/vbatts/tar-split v0.12.1 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect diff --git a/go.sum b/go.sum index d010e1342f..f35db51268 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/containerd/stargz-snapshotter/estargz v0.17.0 h1:+TyQIsR/zSFI1Rm31EQBwpAA1ovYgIKHy7kctL3sLcE= +github.com/containerd/stargz-snapshotter/estargz v0.17.0/go.mod h1:s06tWAiJcXQo9/8AReBCIo/QxcXFZ2n4qfsRnpl71SM= github.com/containernetworking/cni v1.2.3 h1:hhOcjNVUQTnzdRJ6alC5XF+wd9mfGIUaj8FuJbEslXM= github.com/containernetworking/cni v1.2.3/go.mod h1:DuLgF+aPd3DzcTQTtp/Nvl1Kim23oFKdm2okJzBQA5M= github.com/corazawaf/coraza-coreruleset/v4 v4.7.0 h1:j02CDxQYHVFZfBxbKLWYg66jSLbPmZp1GebyMwzN9Z0= @@ -87,6 +89,10 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/cli v28.3.3+incompatible h1:fp9ZHAr1WWPGdIWBM1b3zLtgCF+83gRdVMTJsUeiyAo= +github.com/docker/cli v28.3.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= @@ -183,6 +189,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= +github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y= github.com/google/go-github/v53 v53.2.0 h1:wvz3FyF53v4BK+AsnvCmeNhf8AkTaeh2SoYu/XUvTtI= github.com/google/go-github/v53 v53.2.0/go.mod h1:XhFRObz+m/l+UCm9b7KSIC3lT3NWSXGt7mOsAWEloao= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -278,6 +286,8 @@ github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -403,6 +413,8 @@ github.com/tigera/api v0.0.0-20251017180206-9d7c2da4f711 h1:A75XdvxO3SlR5qydLSf+ github.com/tigera/api v0.0.0-20251017180206-9d7c2da4f711/go.mod h1:5vkALOm1TWUzg3ElTWnTE3O6wkNB3F8cTkZtio7eFGw= github.com/urfave/cli/v3 v3.0.0-beta1 h1:6DTaaUarcM0wX7qj5Hcvs+5Dm3dyUTBbEwIWAjcw9Zg= github.com/urfave/cli/v3 v3.0.0-beta1/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y= +github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo= +github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= @@ -445,8 +457,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0= go.opentelemetry.io/otel/exporters/prometheus v0.59.1 h1:HcpSkTkJbggT8bjYP+BjyqPWlD17BH9C5CYNKeDzmcA= go.opentelemetry.io/otel/exporters/prometheus v0.59.1/go.mod h1:0FJL+gjuUoM07xzik3KPBaN+nz/CoB15kV6WLMiXZag= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU= @@ -596,6 +608,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o= +gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g= helm.sh/helm/v3 v3.18.6 h1:S/2CqcYnNfLckkHLI0VgQbxgcDaU3N4A/46E3n9wSNY= helm.sh/helm/v3 v3.18.6/go.mod h1:L/dXDR2r539oPlFP1PJqKAC1CUgqHJDLkxKpDGrWnyg= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= diff --git a/hack/release/main.go b/hack/release/main.go index 02b0be13d1..a8bbc01b5b 100644 --- a/hack/release/main.go +++ b/hack/release/main.go @@ -44,6 +44,7 @@ func app(version string) *cli.Command { Version: version, Commands: []*cli.Command{ buildCommand, + publishCommand, prepCommand, releaseNotesCommand, releaseFromCommand, diff --git a/hack/release/publish.go b/hack/release/publish.go new file mode 100644 index 0000000000..4a50eedb2e --- /dev/null +++ b/hack/release/publish.go @@ -0,0 +1,137 @@ +// Copyright (c) 2025 Tigera, Inc. All rights reserved. + +// 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 ( + "context" + "fmt" + "os" + "path" + "strings" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v3" +) + +// Command to publish release to remote. +var publishCommand = &cli.Command{ + Name: "publish", + Usage: "Publish release to remote", + Flags: []cli.Flag{ + versionFlag, + imageFlag, + archFlag, + registryFlag, + hashreleaseFlag, + skipValidationFlag, + }, + Before: publishBefore, + Action: publishAction, +} + +// Pre-action for publish command. +// It configures logging and performs validations. +var publishBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (context.Context, error) { + configureLogging(c) + + // Skip validations if requested + if c.Bool(skipValidationFlag.Name) { + return ctx, nil + } + + // If not a hashrelease build, ensure version format is valid + if valid, _ := isReleaseVersionFormat(c.String(versionFlag.Name)); !valid && !c.Bool(hashreleaseFlag.Name) { + return ctx, fmt.Errorf("for non-release builds, the %s flag must be set", hashreleaseFlag.Name) + } + + return ctx, nil +}) + +var publishAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error { + // Check if images are already published + if published, err := operatorImagePublished(c); err != nil { + return fmt.Errorf("error checking if images are already published: %w", err) + } else if published { + logrus.Infof("Images for version %s are already published", c.String(versionFlag.Name)) + return nil + } + + repoRootDir, err := gitDir() + if err != nil { + return fmt.Errorf("error getting git directory: %w", err) + } + + // Set up environment variables for publish + publishEnv := append(os.Environ(), + fmt.Sprintf("VERSION=%s", c.String(versionFlag.Name)), + ) + arches := c.StringSlice(archFlag.Name) + if len(arches) > 0 { + publishEnv = append(publishEnv, fmt.Sprintf("ARCHES=%s", strings.Join(arches, " "))) + } + if c.Bool(hashreleaseFlag.Name) { + hashreleaseEnv, err := hashreleasePublishEnv(c) + if err != nil { + return fmt.Errorf("error preparing hashrelease publish: %w", err) + } + publishEnv = append(publishEnv, hashreleaseEnv...) + } else { + publishEnv = append(publishEnv, "RELEASE=true") + } + + if out, err := makeInDir(repoRootDir, "release-publish-images", publishEnv...); err != nil { + logrus.Error(out) + return fmt.Errorf("error publishing images: %w", err) + } + return nil +}) + +func hashreleasePublishEnv(c *cli.Command) ([]string, error) { + publishEnv := []string{fmt.Sprintf("GIT_VERSION=%s", c.String(versionFlag.Name))} + + image := c.String(imageFlag.Name) + if image != defaultImageName { + publishEnv = append(publishEnv, fmt.Sprintf("BUILD_IMAGE=%s", image)) + publishEnv = append(publishEnv, fmt.Sprintf("BUILD_INIT_IMAGE=%s-init", image)) + } + registry := c.String(registryFlag.Name) + if registry != "" && registry != quayRegistry { + publishEnv = append(publishEnv, + fmt.Sprintf("IMAGE_REGISTRY=%s", registry), + fmt.Sprintf("PUSH_IMAGE_PREFIXES=%s", addTrailingSlash(registry))) + } + return publishEnv, nil +} + +func operatorImagePublished(c *cli.Command) (bool, error) { + registry := c.String(registryFlag.Name) + if registry == "" { + registry = quayRegistry + } + fqImage := fmt.Sprintf("%s:%s", path.Join(registry, c.String(imageFlag.Name)), c.String(versionFlag.Name)) + ref, err := name.ParseReference(fqImage) + if err != nil { + return false, fmt.Errorf("failed to parse image reference for %s: %w", fqImage, err) + } + + _, err = remote.Head(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + return false, nil + } + return true, nil +} From d0c65501bf6f1ba110692aad7f481054f1cfa259 Mon Sep 17 00:00:00 2001 From: tuti Date: Thu, 11 Dec 2025 16:35:24 -0800 Subject: [PATCH 03/20] fix: update github token flag also adjust how skip validation flag since validation is needed for github token --- hack/release/build.go | 1 + hack/release/flags.go | 1 - hack/release/from.go | 2 +- hack/release/prep.go | 5 +++++ hack/release/publish.go | 1 + hack/release/releasenotes.go | 10 ++++++++++ 6 files changed, 18 insertions(+), 2 deletions(-) diff --git a/hack/release/build.go b/hack/release/build.go index fa5df8e5c9..ef01d08cdc 100644 --- a/hack/release/build.go +++ b/hack/release/build.go @@ -110,6 +110,7 @@ var buildBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (cont // Skip validations if requested if c.Bool(skipValidationFlag.Name) { + logrus.Warnf("Skipping %s validation as requested.", c.Name) return ctx, nil } diff --git a/hack/release/flags.go b/hack/release/flags.go index f0fa2ab1a8..76931fc34f 100644 --- a/hack/release/flags.go +++ b/hack/release/flags.go @@ -53,7 +53,6 @@ var ( Category: githubFlagCategory, Usage: "GitHub token to use for interacting with the GitHub API", Sources: cli.EnvVars("GITHUB_TOKEN"), - Required: true, } skipMilestoneFlag = &cli.BoolFlag{ Name: "skip-milestone", diff --git a/hack/release/from.go b/hack/release/from.go index d383d89fb4..64bffe36ce 100644 --- a/hack/release/from.go +++ b/hack/release/from.go @@ -52,7 +52,7 @@ var releaseFromBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) configureLogging(c) if c.Bool(skipValidationFlag.Name) { - logrus.Warn("Skipping pre-release validation as requested.") + logrus.Warnf("Skipping %s validation as requested.", c.Name) return ctx, nil } diff --git a/hack/release/prep.go b/hack/release/prep.go index af40449a5b..5acea78b7a 100644 --- a/hack/release/prep.go +++ b/hack/release/prep.go @@ -77,6 +77,7 @@ var prepBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (conte // Skip validations if requested if c.Bool(skipValidationFlag.Name) { + logrus.Warnf("Skipping %s validation as requested.", c.Name) return ctx, nil } @@ -86,6 +87,10 @@ var prepBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (conte return ctx, err } + if token := c.String(githubTokenFlag.Name); token == "" && !c.Bool(localFlag.Name) { + return ctx, fmt.Errorf("GitHub token must be provided via --%s flag or GITHUB_TOKEN environment variable", githubTokenFlag.Name) + } + // One of Calico or Enterprise version must be specified. if c.String(calicoVersionFlag.Name) == "" && c.String(enterpriseVersionFlag.Name) == "" { return ctx, fmt.Errorf("at least one of %s or %s must be specified", calicoVersionFlag.Name, enterpriseVersionFlag.Name) diff --git a/hack/release/publish.go b/hack/release/publish.go index 4a50eedb2e..b9bfcd33fd 100644 --- a/hack/release/publish.go +++ b/hack/release/publish.go @@ -51,6 +51,7 @@ var publishBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (co // Skip validations if requested if c.Bool(skipValidationFlag.Name) { + logrus.Warnf("Skipping %s validation as requested.", c.Name) return ctx, nil } diff --git a/hack/release/releasenotes.go b/hack/release/releasenotes.go index f706c0b4ca..fddf48ab02 100644 --- a/hack/release/releasenotes.go +++ b/hack/release/releasenotes.go @@ -35,6 +35,7 @@ Otherwise, use --local flag to generate release notes based on local versions fi versionFlag, githubTokenFlag, localFlag, + skipValidationFlag, }, Before: releaseNotesBefore, Action: releaseNotesAction, @@ -49,6 +50,15 @@ var releaseNotesBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command if err != nil { return ctx, err } + + if c.Bool(skipValidationFlag.Name) { + logrus.Warnf("Skipping %s validation as requested.", c.Name) + return ctx, nil + } + + if token := c.String(githubTokenFlag.Name); token == "" { + return ctx, fmt.Errorf("GitHub token must be provided via --%s flag or GITHUB_TOKEN environment variable", githubTokenFlag.Name) + } return ctx, nil }) From ba273916244fc72b6e967e7e196f267b38df2b16 Mon Sep 17 00:00:00 2001 From: tuti Date: Thu, 11 Dec 2025 16:48:23 -0800 Subject: [PATCH 04/20] feat(release): add creating github releases --- Makefile | 10 ++----- hack/release/flags.go | 20 +++++++++++++ hack/release/github.go | 55 +++++++++++++++++++++++++++++++++- hack/release/publish.go | 60 ++++++++++++++++++++++++++++++++++++++ hack/release/utils.go | 23 +++++++++++++++ hack/release/utils_test.go | 50 +++++++++++++++++++++++++++++++ 6 files changed, 209 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index ce00fa62be..ce93337b15 100644 --- a/Makefile +++ b/Makefile @@ -565,8 +565,7 @@ release-tag: var-require-all-RELEASE_TAG-GITHUB_TOKEN fi $(MAKE) release VERSION=$(RELEASE_TAG) - $(MAKE) release-publish VERSION=$(RELEASE_TAG) - $(MAKE) release-github VERSION=$(RELEASE_TAG) + REPO=$(REPO) CREATE_GITHUB_RELEASE=true $(MAKE) release-publish VERSION=$(RELEASE_TAG) release-notes: hack/bin/release var-require-all-VERSION-GITHUB_TOKEN @@ -601,18 +600,13 @@ release-check-image-exists: release-prereqs echo "Image tag check passed; image does not already exist"; \ fi -release-publish: hack/bin/release +release-publish: hack/bin/release var-require-all-VERSION hack/bin/release publish release-publish-images: release-prereqs release-check-image-exists var-require-all-VERSION # Push images. $(MAKE) push-all push-manifests push-non-manifests IMAGETAG=$(VERSION) -release-github: release-notes hack/bin/gh - @echo "Creating github release for $(VERSION)" - hack/bin/gh release create $(VERSION) --title $(VERSION) --draft --notes-file $(VERSION)-release-notes.md - @echo "$(VERSION) GitHub release created in draft state. Please review and publish: https://github.com/tigera/operator/releases/tag/$(VERSION) ." - GITHUB_CLI_VERSION?=2.62.0 hack/bin/gh: mkdir -p hack/bin diff --git a/hack/release/flags.go b/hack/release/flags.go index 76931fc34f..c820d684a0 100644 --- a/hack/release/flags.go +++ b/hack/release/flags.go @@ -68,6 +68,26 @@ var ( return nil }, } + createGithubReleaseFlag = &cli.BoolFlag{ + Name: "create-github-release", + Category: githubFlagCategory, + Usage: "Create a GitHub release", + Sources: cli.EnvVars("CREATE_GITHUB_RELEASE"), + Value: false, + Action: func(ctx context.Context, c *cli.Command, b bool) error { + if b && c.String(githubTokenFlag.Name) == "" { + return fmt.Errorf("github-token is required to create GitHub releases") + } + return nil + }, + } + draftGithubReleaseFlag = &cli.BoolFlag{ + Name: "draft-github-release", + Category: githubFlagCategory, + Usage: fmt.Sprintf("Create the GitHub release as a draft. Only applicable if --%s is set.", createGithubReleaseFlag.Name), + Sources: cli.EnvVars("DRAFT_GITHUB_RELEASE"), + Value: true, + } ) // Operator flags diff --git a/hack/release/github.go b/hack/release/github.go index 5ca599b2a1..79cfea72db 100644 --- a/hack/release/github.go +++ b/hack/release/github.go @@ -16,6 +16,7 @@ package main import ( "context" + "errors" "fmt" "html/template" "io" @@ -45,6 +46,8 @@ const ( allState = "all" ) +var ErrGitHubReleaseExists = errors.New("GitHub release already exists") + type issueKind string // ReleaseNoteItem represents a single release note extracted from an issue or PR. @@ -77,6 +80,11 @@ type GithubRelease struct { milestone *github.Milestone // Cached milestone for the release version } +// Get the URL to edit this release on GitHub. +func (r *GithubRelease) EditURL() string { + return fmt.Sprintf("https://github.com/%s/%s/releases/edit/%s", r.Org, r.Repo, r.Version) +} + // Setup the GitHub client for this release using the provided token. func (r *GithubRelease) setupClient(ctx context.Context, token string) error { if r.githubClient != nil { @@ -131,6 +139,10 @@ func (r *GithubRelease) closeMilestone(ctx context.Context) error { return nil } +func releaseNotesFilePath(outputDir, version string) string { + return fmt.Sprintf("%s/%s-release-notes.md", outputDir, version) +} + // Generate release notes for this release and write them to the specified output directory. // If outputDir is empty, write to stdout. // If useLocal is true, generate release notes based on local versions files @@ -147,7 +159,7 @@ func (r *GithubRelease) GenerateNotes(ctx context.Context, outputDir string, use logrus.WithError(err).Errorf("Failed to create release notes folder %s", outputDir) return err } - f, err := os.Create(fmt.Sprintf("%s/%s-release-notes.md", outputDir, r.Version)) + f, err := os.Create(releaseNotesFilePath(outputDir, r.Version)) if err != nil { logrus.WithError(err).Errorf("Failed to create release notes file in %s", outputDir) return err @@ -339,6 +351,47 @@ func (r *GithubRelease) collectReleaseNotes(ctx context.Context, local bool) (*R return data, nil } +// Create a new GitHub release. +func (r *GithubRelease) Create(ctx context.Context, isDraft, isPrerelease bool) error { + // Check if release already exists + release, resp, err := r.githubClient.Repositories.GetReleaseByTag(ctx, r.Org, r.Repo, r.Version) + if err != nil { + return err + } + if resp.StatusCode == 200 && release != nil { + return ErrGitHubReleaseExists + } + + // Generate release notes + tmpDir := fmt.Sprintf(os.TempDir(), fmt.Sprintf("operator-%s", r.Version)) + if err := r.GenerateNotes(ctx, tmpDir, false); err != nil { + return fmt.Errorf("generating release notes for %s: %w", r.Version, err) + } + relNotesBytes, err := os.ReadFile(releaseNotesFilePath(tmpDir, r.Version)) + if err != nil { + return fmt.Errorf("reading release notes file for %s: %w", r.Version, err) + } + + release = &github.RepositoryRelease{ + TagName: github.String(r.Version), + Name: github.String(r.Version), + Body: github.String(string(relNotesBytes)), + Draft: github.Bool(isDraft), + Prerelease: github.Bool(isPrerelease), + } + release, _, err = r.githubClient.Repositories.CreateRelease(ctx, r.Org, r.Repo, release) + if err != nil { + return fmt.Errorf("creating GitHub release for %s: %w", r.Version, err) + } + log := logrus.WithField("version", r.Version) + if isDraft { + log.Infof("GitHub release created in draft state, review and publish it manually on GitHub: %s", r.EditURL()) + return nil + } + log.Infof("GitHub release created: %s", release.GetHTMLURL()) + return nil +} + // Helper function to list GitHub issues based on the provided options and optional filter function. // The filter may return an error which will be propagated to the caller. func githubIssues(ctx context.Context, client *github.Client, org, repo string, opts *github.IssueListByRepoOptions, filter func(*github.Issue) (bool, error)) ([]*github.Issue, error) { diff --git a/hack/release/publish.go b/hack/release/publish.go index b9bfcd33fd..7e9f12e54a 100644 --- a/hack/release/publish.go +++ b/hack/release/publish.go @@ -16,6 +16,7 @@ package main import ( "context" + "errors" "fmt" "os" "path" @@ -39,6 +40,9 @@ var publishCommand = &cli.Command{ registryFlag, hashreleaseFlag, skipValidationFlag, + createGithubReleaseFlag, + githubTokenFlag, + draftGithubReleaseFlag, }, Before: publishBefore, Action: publishAction, @@ -49,12 +53,33 @@ var publishCommand = &cli.Command{ var publishBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (context.Context, error) { configureLogging(c) + var err error + ctx, err = addRepoInfoToCtx(ctx, c.String(gitRepoFlag.Name)) + if err != nil { + return ctx, err + } + // Skip validations if requested if c.Bool(skipValidationFlag.Name) { logrus.Warnf("Skipping %s validation as requested.", c.Name) return ctx, nil } + // If building a hashrelease, publishGithubRelease must be false + if c.Bool(hashreleaseFlag.Name) && c.Bool(createGithubReleaseFlag.Name) { + return ctx, fmt.Errorf("cannot publish GitHub release for hashrelease builds") + } + + // If publishing a GitHub release, ideally it should be in draft mode with a token provided. + if c.Bool(createGithubReleaseFlag.Name) { + if c.Bool(draftGithubReleaseFlag.Name) { + logrus.Warnf("Publishing GitHub release in non-draft mode.") + } + if c.String(githubTokenFlag.Name) == "" { + return ctx, fmt.Errorf("GitHub token must be provided via --%s flag or GITHUB_TOKEN environment variable", githubTokenFlag.Name) + } + } + // If not a hashrelease build, ensure version format is valid if valid, _ := isReleaseVersionFormat(c.String(versionFlag.Name)); !valid && !c.Bool(hashreleaseFlag.Name) { return ctx, fmt.Errorf("for non-release builds, the %s flag must be set", hashreleaseFlag.Name) @@ -99,6 +124,11 @@ var publishAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) err logrus.Error(out) return fmt.Errorf("error publishing images: %w", err) } + + if !c.Bool(hashreleaseFlag.Name) { + return publishGithubRelease(ctx, c, repoRootDir) + } + return nil }) @@ -136,3 +166,33 @@ func operatorImagePublished(c *cli.Command) (bool, error) { } return true, nil } + +func publishGithubRelease(ctx context.Context, c *cli.Command, repoRootDir string) error { + if !c.Bool(createGithubReleaseFlag.Name) { + return nil + } + + prerelease, err := isPrereleaseEnterpriseVersion(repoRootDir) + if err != nil { + return fmt.Errorf("error determining if version is prerelease: %w", err) + } + + r := &GithubRelease{ + Org: ctx.Value(githubOrgCtxKey).(string), + Repo: ctx.Value(githubRepoCtxKey).(string), + Version: c.String(versionFlag.Name), + } + if err := r.setupClient(ctx, c.String(githubTokenFlag.Name)); err != nil { + return fmt.Errorf("error setting up GitHub client: %s", err) + } + + // Create the GitHub release in draft mode. If it is a prerelease, mark it as such. + if err := r.Create(ctx, c.Bool(draftGithubReleaseFlag.Name), prerelease); errors.Is(err, ErrGitHubReleaseExists) { + logrus.Warnf("GitHub release for version %s already exists", c.String(versionFlag.Name)) + logrus.Infof("To update the release, please edit it manually on GitHub: %s", r.EditURL()) + } else if err != nil { + return fmt.Errorf("error publishing GitHub release: %s", err) + } + + return nil +} diff --git a/hack/release/utils.go b/hack/release/utils.go index 926506cefa..4cd05166b2 100644 --- a/hack/release/utils.go +++ b/hack/release/utils.go @@ -26,6 +26,7 @@ import ( "regexp" "strings" + "github.com/Masterminds/semver/v3" "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" ) @@ -206,6 +207,28 @@ func isEnterpriseReleaseVersionFormat(version string) (bool, error) { return releaseRegex.MatchString(version), nil } +// Check if the Enterprise version is a prerelease. +// First, it checks if the version matches the release format. +// If it does, it then checks if there is a prerelease segment in the version. +func isPrereleaseEnterpriseVersion(rootDir string) (bool, error) { + enterpriseVer, err := calicoConfigVersions(rootDir, enterpriseConfig) + if err != nil { + return false, fmt.Errorf("retrieving Enterprise version: %s", err) + } + release, err := isEnterpriseReleaseVersionFormat(enterpriseVer.Title) + if err != nil { + return false, fmt.Errorf("checking Enterprise version format: %s", err) + } + if !release { + return false, nil + } + ver, err := semver.NewVersion(enterpriseVer.Title) + if err != nil { + return false, fmt.Errorf("parsing Enterprise version (%s): %s", enterpriseVer.Title, err) + } + return ver.Prerelease() != "", nil +} + // Ensure string ends with a slash, if empty string returns empty string. func addTrailingSlash(registry string) string { if registry == "" { diff --git a/hack/release/utils_test.go b/hack/release/utils_test.go index 5c654c57a7..c70af2a09c 100644 --- a/hack/release/utils_test.go +++ b/hack/release/utils_test.go @@ -281,3 +281,53 @@ func TestAddTrailingSlash(t *testing.T) { }) } } + +func TestIsPrereleaseEnterpriseVersion(t *testing.T) { + t.Parallel() + + cases := []struct { + version string + want bool + }{ + { + version: "master", + want: false, + }, + { + version: "release-v3.25", + want: false, + }, + { + version: "release-calient-v3.25", + want: false, + }, + { + version: "v3.25.0", + want: true, + }, + { + version: "v3.25.0-rc1", + want: false, + }, + { + version: "v3.25.0-1.0", + want: true, + }, + { + version: "v3.25.0-2.0", + want: true, + }, + } + for _, tc := range cases { + t.Run(tc.version, func(t *testing.T) { + t.Parallel() + got, err := isEnterpriseReleaseVersionFormat(tc.version) + if err != nil { + t.Fatalf("isEnterpriseReleaseVersionFormat(%q) unexpected error: %v", tc.version, err) + } + if got != tc.want { + t.Fatalf("isEnterpriseReleaseVersionFormat(%q) = %v, want %v", tc.version, got, tc.want) + } + }) + } +} From 7659a01a7365a73c8efee708af255111fbce7479 Mon Sep 17 00:00:00 2001 From: tuti Date: Fri, 12 Dec 2025 10:20:39 -0800 Subject: [PATCH 05/20] fix: incorrect spelling and format --- hack/release/build.go | 4 ++-- hack/release/github.go | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/hack/release/build.go b/hack/release/build.go index ef01d08cdc..39b07ebf33 100644 --- a/hack/release/build.go +++ b/hack/release/build.go @@ -171,7 +171,7 @@ var buildAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error buildEnv = append(buildEnv, fmt.Sprintf("ARCHES=%s", strings.Join(arches, " "))) } if c.Bool(hashreleaseFlag.Name) { - hashreleaseEnv, resetFn, err := hashrеleaseBuildConfig(ctx, c, repoRootDir) + hashreleaseEnv, resetFn, err := hashreleaseBuildConfig(ctx, c, repoRootDir) defer resetFn() if err != nil { return fmt.Errorf("error preparing hashrelease build environment: %w", err) @@ -190,7 +190,7 @@ var buildAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error return nil }) -func hashrеleaseBuildConfig(ctx context.Context, c *cli.Command, repoRootDir string) ([]string, func(), error) { +func hashreleaseBuildConfig(ctx context.Context, c *cli.Command, repoRootDir string) ([]string, func(), error) { repoReset := func() { if out, err := gitInDir(repoRootDir, append([]string{"checkout", "-f"}, changedFiles...)...); err != nil { logrus.WithError(err).Errorf("error resetting git state: %s", out) diff --git a/hack/release/github.go b/hack/release/github.go index 79cfea72db..dfaf6780e2 100644 --- a/hack/release/github.go +++ b/hack/release/github.go @@ -21,6 +21,7 @@ import ( "html/template" "io" "os" + "path/filepath" "regexp" "strconv" "strings" @@ -363,7 +364,10 @@ func (r *GithubRelease) Create(ctx context.Context, isDraft, isPrerelease bool) } // Generate release notes - tmpDir := fmt.Sprintf(os.TempDir(), fmt.Sprintf("operator-%s", r.Version)) + tmpDir := filepath.Join(os.TempDir(), fmt.Sprintf("operator-%s", r.Version)) + if err := os.MkdirAll(tmpDir, os.ModePerm); err != nil { + return fmt.Errorf("creating temporary directory for release notes: %w", err) + } if err := r.GenerateNotes(ctx, tmpDir, false); err != nil { return fmt.Errorf("generating release notes for %s: %w", r.Version, err) } From 5e4254325647d46887a722ac8f5eef892bcbfe38 Mon Sep 17 00:00:00 2001 From: tuti Date: Fri, 12 Dec 2025 10:42:23 -0800 Subject: [PATCH 06/20] fix: update development and testing flags --- hack/release/flags.go | 19 +++++++++++++------ hack/release/prep.go | 24 ++++++++++++++++++------ 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/hack/release/flags.go b/hack/release/flags.go index c820d684a0..f232b29996 100644 --- a/hack/release/flags.go +++ b/hack/release/flags.go @@ -57,12 +57,12 @@ var ( skipMilestoneFlag = &cli.BoolFlag{ Name: "skip-milestone", Category: githubFlagCategory, - Usage: "Skip updating GitHub milestones", + Usage: "Skip updating GitHub milestones (development and testing purposes only)", Sources: cli.EnvVars("SKIP_MILESTONE"), Value: false, Action: func(ctx context.Context, c *cli.Command, skipMilestone bool) error { - // If not on the main repo, skip-milestone must be true - if c.String(gitRepoFlag.Name) != mainRepo && !skipMilestone { + // If not on the main repo, skip-milestone must be true if skip-git-repo-check is not set + if c.String(gitRepoFlag.Name) != mainRepo && !skipMilestone && !c.Bool(skipRepoCheckFlag.Name) { return fmt.Errorf("skip-milestone is required when using a forked repo") } return nil @@ -193,7 +193,7 @@ var localFlag = &cli.BoolFlag{ var skipValidationFlag = &cli.BoolFlag{ Name: "skip-validation", - Usage: "Skip validation", + Usage: "Skip various validation steps (development and testing purposes only)", Sources: cli.EnvVars("SKIP_VALIDATION"), Value: false, } @@ -300,7 +300,7 @@ var ( calicoCRDsDirFlag = &cli.StringFlag{ Name: "calico-crds-dir", Category: calicoFlagCategory, - Usage: "The directory containing the Calico CRDs to bundle with the operator", + Usage: "The directory containing the Calico CRDs to bundle with the operator (development and testing purposes only)", Sources: cli.EnvVars("CALICO_DIR"), Action: dirFlagCheck, } @@ -363,7 +363,7 @@ var ( enterpriseCRDsDirFlag = &cli.StringFlag{ Name: "enterprise-crds-dir", Category: enterpriseFlagCategory, - Usage: "The directory containing the Enterprise CRDs to bundle with the operator", + Usage: "The directory containing the Enterprise CRDs to bundle with the operator (development and testing purposes only)", Sources: cli.EnvVars("ENTERPRISE_DIR"), Action: dirFlagCheck, } @@ -387,3 +387,10 @@ var hashreleaseFlag = &cli.BoolFlag{ Sources: cli.EnvVars("HASHRELEASE"), Value: false, } + +var skipRepoCheckFlag = &cli.BoolFlag{ + Name: "skip-repo-check", + Usage: fmt.Sprintf("Skip checking that the git repository is %s (development and testing purposes only)", mainRepo), + Sources: cli.EnvVars("SKIP_REPO_CHECK"), + Value: false, +} diff --git a/hack/release/prep.go b/hack/release/prep.go index 5acea78b7a..7903d8f9ae 100644 --- a/hack/release/prep.go +++ b/hack/release/prep.go @@ -53,10 +53,13 @@ to point to local repositories for Calico and Enterprise respectively.`, Flags: []cli.Flag{ versionFlag, calicoVersionFlag, + calicoCRDsDirFlag, enterpriseVersionFlag, + enterpriseCRDsDirFlag, enterpriseRegistryFlag, skipValidationFlag, skipMilestoneFlag, + skipRepoCheckFlag, githubTokenFlag, localFlag, }, @@ -133,6 +136,7 @@ var prepAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error }() makeTargets := []string{"fix"} + prepEnv := os.Environ() repoRootDir, err := gitDir() if err != nil { @@ -153,6 +157,11 @@ var prepAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error if err := updateConfigVersions(repoRootDir, calicoConfig, calico); err != nil { return fmt.Errorf("error modifying Calico config: %w", err) } + // Set CALICO_CRDS_DIR if specified + if crdsDir := c.String(calicoCRDsDirFlag.Name); crdsDir != "" { + logrus.Warnf("Using local Calico CRDs from %s", crdsDir) + prepEnv = append(prepEnv, fmt.Sprintf("CALICO_CRDS_DIR=%s", crdsDir)) + } } enterprise := c.String(enterpriseVersionFlag.Name) if enterprise != "" { @@ -161,17 +170,21 @@ var prepAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error return fmt.Errorf("error modifying Enterprise config: %w", err) } // Update registry for Enterprise - eRegistry := c.String(enterpriseRegistryFlag.Name) - if eRegistry != "" { + if eRegistry := c.String(enterpriseRegistryFlag.Name); eRegistry != "" { logrus.Debugf("Updating Enterprise registry to %s", eRegistry) if err := modifyComponentImageConfig(repoRootDir, enterpriseRegistryConfigKey, eRegistry); err != nil { return err } } + // Set ENTERPRISE_CRDS_DIR if specified + if crdsDir := c.String(enterpriseCRDsDirFlag.Name); crdsDir != "" { + logrus.Warnf("Using local Enterprise CRDs from %s", crdsDir) + prepEnv = append(prepEnv, fmt.Sprintf("ENTERPRISE_CRDS_DIR=%s", crdsDir)) + } } // Run make target to ensure files are formatted correctly and generated files are up to date. - if _, err := makeInDir(repoRootDir, strings.Join(makeTargets, " ")); err != nil { + if _, err := makeInDir(repoRootDir, strings.Join(makeTargets, " "), prepEnv...); err != nil { return fmt.Errorf("error running \"make fix gen-versions\": %w", err) } @@ -242,9 +255,8 @@ var prepAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error // Skip milestone management if requested or if using a forked repo if c.Bool(skipMilestoneFlag.Name) { return nil - } else if c.String(gitRepoFlag.Name) != mainRepo { - logrus.Warn("Cannot manage milestones when using a forked repo, skipping milestone management") - return nil + } else if c.String(gitRepoFlag.Name) != mainRepo && !c.Bool(skipRepoCheckFlag.Name) { + return fmt.Errorf("cannot manage milestones when forked repo (%s); either use the main repo (%s) or set flag to skip repo check", c.String(gitRepoFlag.Name), mainRepo) } return manageStreamMilestone(ctx, c.String(githubTokenFlag.Name)) }) From a5d0839bc8930cafef67de16dc6e8c8fbcd13d1d Mon Sep 17 00:00:00 2001 From: tuti Date: Fri, 12 Dec 2025 11:18:36 -0800 Subject: [PATCH 07/20] fix: updates to allow building and publishing release for testing purposes --- hack/release/build.go | 74 +++++++++++++--------- hack/release/from.go | 76 +++++++++++----------- hack/release/github.go | 118 ++++++++++++++++++++--------------- hack/release/publish.go | 95 +++++++++++++++------------- hack/release/releasenotes.go | 9 ++- hack/release/utils.go | 33 +++++----- hack/release/utils_test.go | 12 ++-- 7 files changed, 234 insertions(+), 183 deletions(-) diff --git a/hack/release/build.go b/hack/release/build.go index 39b07ebf33..2ea7b2d252 100644 --- a/hack/release/build.go +++ b/hack/release/build.go @@ -35,6 +35,8 @@ const ( operatorImagePathConfigKey = "OperatorImagePath" ) +var componentImageConfigRelPath = "pkg/components/images.go" + // Mapping of component image keys to descriptions var componentImageConfigMap = map[string]string{ calicoRegistryConfigKey: "Calico Registry", @@ -161,78 +163,90 @@ var buildBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (cont var buildAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error { repoRootDir, err := gitDir() if err != nil { - return fmt.Errorf("error getting git directory: %w", err) + return fmt.Errorf("getting git directory: %w", err) } + version := c.String(versionFlag.Name) + log := logrus.WithField("version", version) + // Prepare build environment variables - buildEnv := append(os.Environ(), fmt.Sprintf("VERSION=%s", c.String(versionFlag.Name))) - arches := c.StringSlice(archFlag.Name) - if len(arches) > 0 { + buildEnv := append(os.Environ(), fmt.Sprintf("VERSION=%s", version)) + if arches := c.StringSlice(archFlag.Name); len(arches) > 0 { + log = log.WithField("arches", arches) buildEnv = append(buildEnv, fmt.Sprintf("ARCHES=%s", strings.Join(arches, " "))) } + if image := c.String(imageFlag.Name); image != defaultImageName { + log = log.WithField("image", image) + buildEnv = append(buildEnv, + fmt.Sprintf("BUILD_IMAGE=%s", image), + fmt.Sprintf("BUILD_INIT_IMAGE=%s-init", image)) + } + if registry := c.String(registryFlag.Name); registry != "" && registry != quayRegistry { + log = log.WithField("registry", registry) + buildEnv = append(buildEnv, + fmt.Sprintf("IMAGE_REGISTRY=%s", registry), + fmt.Sprintf("PUSH_IMAGE_PREFIXES=%s", addTrailingSlash(registry))) + } if c.Bool(hashreleaseFlag.Name) { - hashreleaseEnv, resetFn, err := hashreleaseBuildConfig(ctx, c, repoRootDir) + log = log.WithField("hashrelease", true) + buildEnv = append(buildEnv, fmt.Sprintf("GIT_VERSION=%s", c.String(versionFlag.Name))) + resetFn, err := hashreleaseBuildConfig(ctx, c, repoRootDir) defer resetFn() if err != nil { - return fmt.Errorf("error preparing hashrelease build environment: %w", err) + return fmt.Errorf("preparing hashrelease build environment: %w", err) } - buildEnv = append(buildEnv, hashreleaseEnv...) } else { + log = log.WithField("release", true) buildEnv = append(buildEnv, "RELEASE=true") } // Build the Operator and verify the build + log.Info("Building Operator") if out, err := makeInDir(repoRootDir, "release-build", buildEnv...); err != nil { - logrus.Error(out) - return fmt.Errorf("error building Operator: %w", err) + log.Error(out) + return fmt.Errorf("building Operator: %w", err) } return nil }) -func hashreleaseBuildConfig(ctx context.Context, c *cli.Command, repoRootDir string) ([]string, func(), error) { +func hashreleaseBuildConfig(ctx context.Context, c *cli.Command, repoRootDir string) (func(), error) { repoReset := func() { if out, err := gitInDir(repoRootDir, append([]string{"checkout", "-f"}, changedFiles...)...); err != nil { - logrus.WithError(err).Errorf("error resetting git state: %s", out) + logrus.WithError(err).Errorf("resetting git state: %s", out) } } - buildEnv := []string{fmt.Sprintf("GIT_VERSION=%s", c.String(versionFlag.Name))} image := c.String(imageFlag.Name) if image != defaultImageName { - buildEnv = append(buildEnv, fmt.Sprintf("BUILD_IMAGE=%s", image)) - buildEnv = append(buildEnv, fmt.Sprintf("BUILD_INIT_IMAGE=%s-init", image)) imageParts := strings.SplitN(c.String(imageFlag.Name), "/", 2) if err := modifyComponentImageConfig(repoRootDir, operatorImagePathConfigKey, addTrailingSlash(imageParts[0])); err != nil { - return buildEnv, repoReset, fmt.Errorf("error updating Operator image path: %w", err) + return repoReset, fmt.Errorf("updating Operator image path: %w", err) } } registry := c.String(registryFlag.Name) if registry != "" && registry != quayRegistry { - buildEnv = append(buildEnv, - fmt.Sprintf("IMAGE_REGISTRY=%s", registry), - fmt.Sprintf("PUSH_IMAGE_PREFIXES=%s", addTrailingSlash(registry))) if err := modifyComponentImageConfig(repoRootDir, operatorRegistryConfigKey, addTrailingSlash(registry)); err != nil { - return buildEnv, repoReset, fmt.Errorf("error updating Operator registry: %w", err) + return repoReset, fmt.Errorf("updating Operator registry: %w", err) } } if registry := c.String(calicoRegistryFlag.Name); registry != "" { if err := modifyComponentImageConfig(repoRootDir, calicoRegistryConfigKey, addTrailingSlash(registry)); err != nil { - return buildEnv, repoReset, fmt.Errorf("error updating Calico registry: %w", err) + return repoReset, fmt.Errorf("updating Calico registry: %w", err) } } if imagePath := c.String(calicoImagePathFlag.Name); imagePath != "" { if err := modifyComponentImageConfig(repoRootDir, calicoImagePathConfigKey, imagePath); err != nil { - return buildEnv, repoReset, fmt.Errorf("error updating Calico image path: %w", err) + return repoReset, fmt.Errorf("updating Calico image path: %w", err) } } if registry := c.String(enterpriseRegistryFlag.Name); registry != "" { if err := modifyComponentImageConfig(repoRootDir, enterpriseRegistryConfigKey, addTrailingSlash(registry)); err != nil { - return buildEnv, repoReset, fmt.Errorf("error updating Enterprise registry: %w", err) + return repoReset, fmt.Errorf("updating Enterprise registry: %w", err) } } if imagePath := c.String(enterpriseImagePathFlag.Name); imagePath != "" { if err := modifyComponentImageConfig(repoRootDir, enterpriseImagePathConfigKey, imagePath); err != nil { - return buildEnv, repoReset, fmt.Errorf("error updating Enterprise image path: %w", err) + return repoReset, fmt.Errorf("updating Enterprise image path: %w", err) } } @@ -250,7 +264,7 @@ func hashreleaseBuildConfig(ctx context.Context, c *cli.Command, repoRootDir str switch bt { case versionBuild: if err := updateConfigVersions(repoRootDir, calicoConfig, c.String(calicoVersionFlag.Name)); err != nil { - return buildEnv, repoReset, fmt.Errorf("error updating Calico config versions: %w", err) + return repoReset, fmt.Errorf("updating Calico config versions: %w", err) } case versionsBuild: genEnv = append(genEnv, fmt.Sprintf("OS_VERSIONS=%s", c.String(calicoVersionsConfigFlag.Name))) @@ -261,7 +275,7 @@ func hashreleaseBuildConfig(ctx context.Context, c *cli.Command, repoRootDir str switch bt { case versionBuild: if err := updateConfigVersions(repoRootDir, enterpriseConfig, c.String(enterpriseVersionFlag.Name)); err != nil { - return buildEnv, repoReset, fmt.Errorf("error updating Enterprise config versions: %w", err) + return repoReset, fmt.Errorf("updating Enterprise config versions: %w", err) } case versionsBuild: genEnv = append(genEnv, fmt.Sprintf("EE_VERSIONS=%s", c.String(enterpriseVersionsConfigFlag.Name))) @@ -269,9 +283,9 @@ func hashreleaseBuildConfig(ctx context.Context, c *cli.Command, repoRootDir str } if out, err := makeInDir(repoRootDir, strings.Join(genMakeTargets, " "), genEnv...); err != nil { logrus.Error(out) - return buildEnv, repoReset, fmt.Errorf("error generating versions: %w", err) + return repoReset, fmt.Errorf("generating versions: %w", err) } - return buildEnv, repoReset, nil + return repoReset, nil } // Modify variables in pkg/components/images.go @@ -282,9 +296,11 @@ func modifyComponentImageConfig(repoRootDir, configKey, newValue string) error { return fmt.Errorf("invalid component image config key: %s", configKey) } - if out, err := runCommandInDir(repoRootDir, "sed", []string{"-i", fmt.Sprintf(`s|%[1]s.*=.*".*"|%[1]s = "%[2]s"|`, configKey, regexp.QuoteMeta(newValue)), "pkg/components/images.go"}, nil); err != nil { + logrus.WithField("repoDir", repoRootDir).WithField(configKey, newValue).Infof("Updating %s in %s", desc, componentImageConfigRelPath) + + if out, err := runCommandInDir(repoRootDir, "sed", []string{"-i", fmt.Sprintf(`s|%[1]s.*=.*".*"|%[1]s = "%[2]s"|`, configKey, regexp.QuoteMeta(newValue)), componentImageConfigRelPath}, nil); err != nil { logrus.Error(out) - return fmt.Errorf("failed to update %s in pkg/components/images.go: %w", desc, err) + return fmt.Errorf("failed to update %s in %s: %w", desc, componentImageConfigRelPath, err) } return nil } diff --git a/hack/release/from.go b/hack/release/from.go index 64bffe36ce..4e9ee65abd 100644 --- a/hack/release/from.go +++ b/hack/release/from.go @@ -65,14 +65,14 @@ var releaseFromBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) return ctx, fmt.Errorf("base version and new version cannot be the same") } if isRelease, err := isReleaseVersionFormat(version); err != nil { - return ctx, fmt.Errorf("error determining if version is a release: %s", err) + return ctx, fmt.Errorf("determining if version is a release: %s", err) } else if isRelease && c.Bool(publishFlag.Name) { logrus.Warn("You are about to publish a release version. Ensure this is intended.") return ctx, nil } hashreleaseRegex, err := regexp.Compile(fmt.Sprintf(hashreleaseFormat, c.String(devTagSuffixFlag.Name))) if err != nil { - return ctx, fmt.Errorf("error compiling hashrelease regex: %s", err) + return ctx, fmt.Errorf("compiling hashrelease regex: %s", err) } if !hashreleaseRegex.MatchString(version) { if c.Bool(publishFlag.Name) && c.String(registryFlag.Name) == quayRegistry && c.String(imageFlag.Name) == defaultImageName { @@ -88,23 +88,23 @@ var releaseFromAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) // get root directory of operator git repo repoRootDir, err := gitDir() if err != nil { - return fmt.Errorf("error getting git root directory: %s", err) + return fmt.Errorf("getting git root directory: %s", err) } // fetch config from the base version of the operator - if err := retrieveBaseVersionConfig(c.String(baseOperatorFlag.Name), repoRootDir); err != nil { - return fmt.Errorf("error getting base version config: %s", err) + if err := retrieveBaseVersionConfig(c.String(gitRepoFlag.Name), c.String(baseOperatorFlag.Name), repoRootDir); err != nil { + return fmt.Errorf("getting base version config: %s", err) } // Apply new version overrides if calicoOverrides := c.StringSlice(exceptCalicoFlag.Name); len(calicoOverrides) > 0 { if err := modifyComponentConfig(repoRootDir, calicoConfig, calicoOverrides); err != nil { - return fmt.Errorf("error overriding calico config: %s", err) + return fmt.Errorf("overriding calico config: %s", err) } } if enterpriseOverrides := c.StringSlice(exceptEnterpriseFlag.Name); len(enterpriseOverrides) > 0 { if err := modifyComponentConfig(repoRootDir, enterpriseConfig, enterpriseOverrides); err != nil { - return fmt.Errorf("error overriding enterprise config: %s", err) + return fmt.Errorf("overriding enterprise config: %s", err) } } @@ -112,7 +112,7 @@ var releaseFromAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) version := c.String(versionFlag.Name) isReleaseVersion, err := isReleaseVersionFormat(version) if err != nil { - return fmt.Errorf("error determining if version is a release: %s", err) + return fmt.Errorf("determining if version is a release: %s", err) } else if isReleaseVersion { return newOperator(repoRootDir, version, c.String(gitRemoteFlag.Name), c.Bool(publishFlag.Name)) } @@ -126,15 +126,15 @@ var releaseFromAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) func newOperator(dir, version, remote string, publish bool) error { if out, err := gitInDir(dir, "add", "config/"); err != nil { logrus.Error(out) - return fmt.Errorf("error adding changes in git: %s", err) + return fmt.Errorf("adding changes in git: %s", err) } if out, err := git("commit", "-m", fmt.Sprintf("Release %s", version)); err != nil { logrus.Error(out) - return fmt.Errorf("error committing changes in git: %s", err) + return fmt.Errorf("committing changes in git: %s", err) } if _out, err := git("tag", version); err != nil { logrus.Error(_out) - return fmt.Errorf("error tagging release in git: %s", err) + return fmt.Errorf("tagging release in git: %s", err) } if !publish { logrus.Info("skip pushing tag to git for publishing release") @@ -142,7 +142,7 @@ func newOperator(dir, version, remote string, publish bool) error { } if out, err := git("push", remote, version); err != nil { logrus.Error(out) - return fmt.Errorf("error pushing tag in git: %s", err) + return fmt.Errorf("pushing tag in git: %s", err) } logrus.Warn("Ensure that the changes are merged into the main branch as well.") logrus.Info("Follow the release progress in CI.") @@ -155,11 +155,11 @@ func newHashreleaseOperator(dir, version, imageName, registry string, arches []s defer func() { if out, err := gitInDir(dir, "checkout", "config/"); err != nil { logrus.Error(out) - logrus.WithError(err).Error("error reverting changes in config/") + logrus.WithError(err).Error("reverting changes in config/") } }() if err := buildHashreleaseOperator(dir, version, imageName, registry, arches); err != nil { - return fmt.Errorf("error building operator: %s", err) + return fmt.Errorf("building operator: %s", err) } if !publish { logrus.Info("skip publishing images to registry") @@ -176,7 +176,7 @@ func buildHashreleaseOperator(dir, version, imageName, registry string, arches [ env = append(env, fmt.Sprintf("BUILD_IMAGE=%s", imageName)) if out, err := makeInDir(dir, "image-all", env...); err != nil { logrus.Error(out) - return fmt.Errorf("error building operator images: %w", err) + return fmt.Errorf("building operator images: %w", err) } for _, arch := range arches { tag := fmt.Sprintf("%s/%s:%s-%s", registry, imageName, version, arch) @@ -186,7 +186,7 @@ func buildHashreleaseOperator(dir, version, imageName, registry string, arches [ tag, }, env); err != nil { logrus.Error(out) - return fmt.Errorf("error tagging operator %s image: %w", arch, err) + return fmt.Errorf("tagging operator %s image: %w", arch, err) } logrus.WithField("tag", tag).Debug("Built image") } @@ -198,7 +198,7 @@ func buildHashreleaseOperator(dir, version, imageName, registry string, arches [ env = append(env, fmt.Sprintf("BUILD_INIT_IMAGE=%s", initImageName)) if out, err := makeInDir(dir, "image-init", env...); err != nil { logrus.Error(out) - return fmt.Errorf("error building init image: %w", err) + return fmt.Errorf("building init image: %w", err) } initTag := fmt.Sprintf("%s/%s:%s", registry, initImageName, version) @@ -208,7 +208,7 @@ func buildHashreleaseOperator(dir, version, imageName, registry string, arches [ fmt.Sprintf("%s/%s:%s", registry, initImageName, version), }, env); err != nil { logrus.Error(out) - return fmt.Errorf("error tagging init image: %w", err) + return fmt.Errorf("tagging init image: %w", err) } logrus.WithField("tag", initTag).Debug("Built init image") return nil @@ -221,7 +221,7 @@ func publishHashreleaseOperator(version, imageName, registry string, archs []str tag := fmt.Sprintf("%s/%s:%s-%s", registry, imageName, version, arch) if out, err := runCommand("docker", []string{"push", tag}, nil); err != nil { logrus.Error(out) - return fmt.Errorf("error pushing %s image %s: %w", arch, tag, err) + return fmt.Errorf("pushing %s image %s: %w", arch, tag, err) } logrus.WithField("tag", tag).Debug("Pushed image") multiArchTags = append(multiArchTags, tag) @@ -233,18 +233,18 @@ func publishHashreleaseOperator(version, imageName, registry string, archs []str } if out, err := runCommand("docker", cmd, nil); err != nil { logrus.Error(out) - return fmt.Errorf("error creating manifest for image %s: %w", image, err) + return fmt.Errorf("creating manifest for image %s: %w", image, err) } if out, err := runCommand("docker", []string{"manifest", "push", "--purge", image}, nil); err != nil { logrus.Error(out) - return fmt.Errorf("error pushing manifest: %w", err) + return fmt.Errorf("pushing manifest: %w", err) } logrus.WithField("image", image).Debug("Pushed manifest") initImage := fmt.Sprintf("%s/%s-init:%s", registry, imageName, version) if out, err := runCommand("docker", []string{"push", initImage}, nil); err != nil { logrus.Error(out) - return fmt.Errorf("error pushing init image: %w", err) + return fmt.Errorf("pushing init image: %w", err) } logrus.WithField("image", initImage).Debug("Pushed init image") return nil @@ -256,9 +256,9 @@ func modifyComponentConfig(repoRootDir, configFile string, updates []string) err localFilePath := fmt.Sprintf("%s/%s", repoRootDir, configFile) var root yaml.Node if data, err := os.ReadFile(localFilePath); err != nil { - return fmt.Errorf("error reading local file %s: %s", configFile, err) + return fmt.Errorf("reading local file %s: %s", configFile, err) } else if err = yaml.Unmarshal(data, &root); err != nil { - return fmt.Errorf("error unmarshalling local file %s: %s", configFile, err) + return fmt.Errorf("unmarshalling local file %s: %s", configFile, err) } for _, override := range updates { @@ -266,7 +266,7 @@ func modifyComponentConfig(repoRootDir, configFile string, updates []string) err component := parts[0] version := parts[1] if err := updateComponentVersion(&root, []string{"components", component, "version"}, version); err != nil { - return fmt.Errorf("error updating component %s to %s: %s", component, version, err) + return fmt.Errorf("updating component %s to %s: %s", component, version, err) } } @@ -275,13 +275,13 @@ func modifyComponentConfig(repoRootDir, configFile string, updates []string) err encoder := yaml.NewEncoder(&buf) encoder.SetIndent(2) if err := encoder.Encode(&root); err != nil { - return fmt.Errorf("error encoding updated config: %s", err) + return fmt.Errorf("encoding updated config: %s", err) } if err := encoder.Close(); err != nil { - return fmt.Errorf("error closing encoder: %s", err) + return fmt.Errorf("closing encoder: %s", err) } if err := os.WriteFile(localFilePath, buf.Bytes(), 0o644); err != nil { - return fmt.Errorf("error overwriting local file %s: %s", configFile, err) + return fmt.Errorf("overwriting local file %s: %s", configFile, err) } return nil } @@ -325,24 +325,28 @@ func updateComponentVersion(node *yaml.Node, path []string, version string) erro // retrieveBaseVersionConfig gets the config to use as a base for the new operator // from the base version of the operator. -func retrieveBaseVersionConfig(baseVersion, repoRootDir string) error { +func retrieveBaseVersionConfig(repo, baseVersion, repoRootDir string) error { gitHashOrTag, err := extractGitHashOrTag(baseVersion) if err != nil { - return fmt.Errorf("error extracting git hash or tag from %q: %s", baseVersion, err) + return fmt.Errorf("extracting git hash or tag from %q: %s", baseVersion, err) } for _, configFilePath := range []string{calicoConfig, enterpriseConfig} { localFilePath := fmt.Sprintf("%s/%s", repoRootDir, configFilePath) - url := fmt.Sprintf(sourceGitHubURL, gitHashOrTag, configFilePath) + url := strings.NewReplacer([]string{ + "{gitRepo}", repo, + "{gitHashOrTag}", gitHashOrTag, + "{filePath}", configFilePath, + }...).Replace(tmplGithubFileURL) logrus.WithFields(logrus.Fields{ "file": configFilePath, "localPath": localFilePath, "downloadPath": url, }).Debug("Replacing local file with downloaded file") - if out, err := runCommand("curl", []string{"-L", "-o", localFilePath, url}, nil); err != nil { + if out, err := runCommand("curl", []string{"-fsSL", "-o", localFilePath, url}, nil); err != nil { logrus.Error(out) - return fmt.Errorf("error downloading %s from %s: %w", configFilePath, url, err) + return fmt.Errorf("downloading %s from %s: %w", configFilePath, url, err) } } return nil @@ -353,18 +357,18 @@ func retrieveBaseVersionConfig(baseVersion, repoRootDir string) error { func extractGitHashOrTag(baseVersion string) (string, error) { isReleaseVersion, err := isReleaseVersionFormat(baseVersion) if err != nil { - return "", fmt.Errorf("error determining if version is a release: %s", err) + return "", fmt.Errorf("determining if version is a release: %s", err) } if isReleaseVersion { return baseVersion, nil } gitHashRegex, err := regexp.Compile(`g([0-9a-f]{12})`) if err != nil { - return "", fmt.Errorf("error compiling git hash regex: %s", err) + return "", fmt.Errorf("compiling git hash regex: %s", err) } matches := gitHashRegex.FindStringSubmatch(baseVersion) if len(matches) > 1 { return matches[1], nil } - return "", fmt.Errorf("error finding git hash in base version") + return "", fmt.Errorf("finding git hash in base version") } diff --git a/hack/release/github.go b/hack/release/github.go index dfaf6780e2..3e2ae7c1f3 100644 --- a/hack/release/github.go +++ b/hack/release/github.go @@ -21,7 +21,6 @@ import ( "html/template" "io" "os" - "path/filepath" "regexp" "strconv" "strings" @@ -81,11 +80,6 @@ type GithubRelease struct { milestone *github.Milestone // Cached milestone for the release version } -// Get the URL to edit this release on GitHub. -func (r *GithubRelease) EditURL() string { - return fmt.Sprintf("https://github.com/%s/%s/releases/edit/%s", r.Org, r.Repo, r.Version) -} - // Setup the GitHub client for this release using the provided token. func (r *GithubRelease) setupClient(ctx context.Context, token string) error { if r.githubClient != nil { @@ -135,12 +129,12 @@ func (r *GithubRelease) closeMilestone(ctx context.Context) error { State: github.String(closedState), }) if err != nil { - return fmt.Errorf("error closing %s milestone (%d): %w", milestone.GetTitle(), milestone.GetNumber(), err) + return fmt.Errorf("closing %s milestone (%d): %w", milestone.GetTitle(), milestone.GetNumber(), err) } return nil } -func releaseNotesFilePath(outputDir, version string) string { +func ReleaseNotesFilePath(outputDir, version string) string { return fmt.Sprintf("%s/%s-release-notes.md", outputDir, version) } @@ -160,7 +154,7 @@ func (r *GithubRelease) GenerateNotes(ctx context.Context, outputDir string, use logrus.WithError(err).Errorf("Failed to create release notes folder %s", outputDir) return err } - f, err := os.Create(releaseNotesFilePath(outputDir, r.Version)) + f, err := os.Create(ReleaseNotesFilePath(outputDir, r.Version)) if err != nil { logrus.WithError(err).Errorf("Failed to create release notes file in %s", outputDir) return err @@ -171,7 +165,7 @@ func (r *GithubRelease) GenerateNotes(ctx context.Context, outputDir string, use } noteData, err := r.collectReleaseNotes(ctx, useLocal) if err != nil { - return fmt.Errorf("error collecting release notes: %s", err) + return fmt.Errorf("collecting release notes: %s", err) } tmpl, err := template.New("release-note").Parse(releaseNoteTemplate) if err != nil { @@ -183,8 +177,6 @@ func (r *GithubRelease) GenerateNotes(ctx context.Context, outputDir string, use logrus.WithError(err).Error("Failed to execute release note template") return err } - writeLogger.Info("Review release notes for accuracy and format appropriately") - logrus.Infof("Visit https://github.com/%s/%s/releases/new?tag=%s to publish", r.Org, r.Repo, r.Version) return nil } @@ -192,7 +184,7 @@ func (r *GithubRelease) GenerateNotes(ctx context.Context, outputDir string, use func (r *GithubRelease) releaseNoteIssues(ctx context.Context) ([]*github.Issue, error) { milestone, err := r.getMilestone(ctx) if err != nil { - return nil, fmt.Errorf("error retrieving milestone for version %s: %w", r.Version, err) + return nil, fmt.Errorf("retrieving milestone for version %s: %w", r.Version, err) } if milestone.GetState() != string(closedState) { logrus.WithField("milestone", milestone.GetTitle()).Warn("Milestone is not closed") @@ -211,13 +203,13 @@ func (r *GithubRelease) releaseNoteIssues(ctx context.Context) ([]*github.Issue, } pr, _, err := r.githubClient.PullRequests.Get(ctx, r.Org, r.Repo, issue.GetNumber()) if err != nil { - return false, fmt.Errorf("error retrieving PR for issue %d: %w", issue.GetNumber(), err) + return false, fmt.Errorf("retrieving PR for issue %d: %w", issue.GetNumber(), err) } return pr.Merged != nil && *pr.Merged, nil } relIssues, err := githubIssues(ctx, r.githubClient, r.Org, r.Repo, opts, filter) if err != nil { - return nil, fmt.Errorf("error retrieving release note issues: %w", err) + return nil, fmt.Errorf("retrieving release note issues: %w", err) } return relIssues, nil } @@ -226,7 +218,7 @@ func (r *GithubRelease) releaseNoteIssues(ctx context.Context) ([]*github.Issue, func (r *GithubRelease) openIssues(ctx context.Context, filter func(*github.Issue) (bool, error)) ([]*github.Issue, error) { milestone, err := r.getMilestone(ctx) if err != nil { - return nil, fmt.Errorf("error retrieving milestone for version %s: %w", r.Version, err) + return nil, fmt.Errorf("retrieving milestone for version %s: %w", r.Version, err) } opts := &github.IssueListByRepoOptions{ Milestone: strconv.Itoa(milestone.GetNumber()), @@ -300,11 +292,11 @@ func (r *GithubRelease) collectReleaseNotes(ctx context.Context, local bool) (*R } dir, err := gitDir() if err != nil { - return data, fmt.Errorf("error getting git directory: %s", err) + return data, fmt.Errorf("getting git directory: %s", err) } - versions, err := calicoVersions(dir, r.Version, local) + versions, err := calicoVersions(fmt.Sprintf("%s/%s", r.Org, r.Repo), dir, r.Version, local) if err != nil { - return data, fmt.Errorf("error retrieving release versions: %s", err) + return data, fmt.Errorf("retrieving release versions: %s", err) } data.Versions = versions log := logrus.WithField("org", r.Org).WithField("repo", r.Repo).WithField("version", r.Version) @@ -352,48 +344,74 @@ func (r *GithubRelease) collectReleaseNotes(ctx context.Context, local bool) (*R return data, nil } +// Check if a GitHub release already exists for this version. +// Since the release could already exist in draft, it uses the ListReleases instead of GetReleaseByTag. +func (r *GithubRelease) repoRelease(ctx context.Context) (*github.RepositoryRelease, error) { + opts := &github.ListOptions{PerPage: 100} + for { + release, resp, err := r.githubClient.Repositories.ListReleases(ctx, r.Org, r.Repo, opts) + if err != nil { + return nil, fmt.Errorf("listing releases: %w", err) + } + for _, rel := range release { + if rel.GetTagName() == r.Version { + return rel, nil + } + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + return nil, nil +} + // Create a new GitHub release. -func (r *GithubRelease) Create(ctx context.Context, isDraft, isPrerelease bool) error { +func (r *GithubRelease) Create(ctx context.Context, isDraft, isPrerelease bool) (*github.RepositoryRelease, error) { // Check if release already exists - release, resp, err := r.githubClient.Repositories.GetReleaseByTag(ctx, r.Org, r.Repo, r.Version) - if err != nil { - return err - } - if resp.StatusCode == 200 && release != nil { - return ErrGitHubReleaseExists + if got, err := r.repoRelease(ctx); err != nil { + return nil, fmt.Errorf("checking if release exists: %w", err) + } else if got != nil { + return got, ErrGitHubReleaseExists } + logrus.WithFields(logrus.Fields{ + "version": r.Version, + "draft": isDraft, + "prerelease": isPrerelease, + }).Info("Creating GitHub release") + // Generate release notes - tmpDir := filepath.Join(os.TempDir(), fmt.Sprintf("operator-%s", r.Version)) - if err := os.MkdirAll(tmpDir, os.ModePerm); err != nil { - return fmt.Errorf("creating temporary directory for release notes: %w", err) + tmpDir, err := os.MkdirTemp("", fmt.Sprintf("operator-%s-*", r.Version)) + if err != nil { + return nil, fmt.Errorf("creating temporary directory for release notes: %w", err) } + defer func() { _ = os.RemoveAll(tmpDir) }() if err := r.GenerateNotes(ctx, tmpDir, false); err != nil { - return fmt.Errorf("generating release notes for %s: %w", r.Version, err) + return nil, fmt.Errorf("generating release notes for %s: %w", r.Version, err) } - relNotesBytes, err := os.ReadFile(releaseNotesFilePath(tmpDir, r.Version)) + relNotesBytes, err := os.ReadFile(ReleaseNotesFilePath(tmpDir, r.Version)) if err != nil { - return fmt.Errorf("reading release notes file for %s: %w", r.Version, err) + return nil, fmt.Errorf("reading release notes file for %s: %w", r.Version, err) } - release = &github.RepositoryRelease{ + release, _, err := r.githubClient.Repositories.CreateRelease(ctx, r.Org, r.Repo, &github.RepositoryRelease{ TagName: github.String(r.Version), Name: github.String(r.Version), Body: github.String(string(relNotesBytes)), Draft: github.Bool(isDraft), Prerelease: github.Bool(isPrerelease), - } - release, _, err = r.githubClient.Repositories.CreateRelease(ctx, r.Org, r.Repo, release) + }) if err != nil { - return fmt.Errorf("creating GitHub release for %s: %w", r.Version, err) + return nil, fmt.Errorf("creating GitHub release for %s: %w", r.Version, err) } - log := logrus.WithField("version", r.Version) + log := logrus.WithField("release", r.Version).WithField("url", release.GetHTMLURL()) if isDraft { - log.Infof("GitHub release created in draft state, review and publish it manually on GitHub: %s", r.EditURL()) - return nil + log.Info("GitHub release created in draft state, review and publish it manually") + return release, nil } - log.Infof("GitHub release created: %s", release.GetHTMLURL()) - return nil + log.Info("GitHub release created") + return release, nil } // Helper function to list GitHub issues based on the provided options and optional filter function. @@ -437,7 +455,7 @@ func newGithubMilestone(ctx context.Context, githubClient *github.Client, org, r State: github.String(openState), }) if err != nil { - return nil, fmt.Errorf("error creating next milestone %s: %w", name, err) + return nil, fmt.Errorf("creating next milestone %s: %w", name, err) } logrus.Debugf("Created new milestone %s (%d)", milestone.GetTitle(), milestone.GetNumber()) return milestone, nil @@ -491,25 +509,25 @@ func manageStreamMilestone(ctx context.Context, githubToken string) error { Version: version, } if err := r.setupClient(ctx, githubToken); err != nil { - return fmt.Errorf("error setting up GitHub client: %w", err) + return fmt.Errorf("setting up GitHub client: %w", err) } // Get milestone for the release version milestone, err := r.getMilestone(ctx) if err != nil { - return fmt.Errorf("error retrieving milestone for version %s: %w", version, err) + return fmt.Errorf("retrieving milestone for version %s: %w", version, err) } semVersion, err := semver.Parse(strings.TrimPrefix(version, "v")) if err != nil { - return fmt.Errorf("error parsing semantic version from %s: %w", version, err) + return fmt.Errorf("parsing semantic version from %s: %w", version, err) } if err := semVersion.IncrementPatch(); err != nil { - return fmt.Errorf("error getting next version for %s: %w", version, err) + return fmt.Errorf("getting next version for %s: %w", version, err) } nextVersion := fmt.Sprintf("v%s", semVersion.String()) nextMilestone, err := newGithubMilestone(ctx, r.githubClient, r.Org, r.Repo, nextVersion) if err != nil { - return fmt.Errorf("error creating next milestone %s: %w", nextVersion, err) + return fmt.Errorf("creating next milestone %s: %w", nextVersion, err) } var filter func(*github.Issue) (bool, error) if headBranch := ctx.Value(headBranchCtxKey).(string); headBranch != "" { @@ -519,14 +537,14 @@ func manageStreamMilestone(ctx context.Context, githubToken string) error { } pr, _, err := r.githubClient.PullRequests.Get(ctx, r.Org, r.Repo, issue.GetNumber()) if err != nil { - return false, fmt.Errorf("error retrieving PR for issue %d: %w", issue.GetNumber(), err) + return false, fmt.Errorf("retrieving PR for issue %d: %w", issue.GetNumber(), err) } return pr.Head.GetRef() != headBranch, nil } } issues, err := r.openIssues(ctx, filter) if err != nil { - return fmt.Errorf("error retrieving open issues in %s milestone: %w", milestone.GetTitle(), err) + return fmt.Errorf("retrieving open issues in %s milestone: %w", milestone.GetTitle(), err) } if len(issues) == 0 { return r.closeMilestone(ctx) @@ -534,7 +552,7 @@ func manageStreamMilestone(ctx context.Context, githubToken string) error { if err := updateGitHubIssues(ctx, r.githubClient, r.Org, r.Repo, issues, &github.IssueRequest{ Milestone: github.Int(nextMilestone.GetNumber()), }); err != nil { - return fmt.Errorf("error moving issues from milestone %s to %s: %w", milestone.GetTitle(), nextMilestone.GetTitle(), err) + return fmt.Errorf("moving issues from milestone %s to %s: %w", milestone.GetTitle(), nextMilestone.GetTitle(), err) } return r.closeMilestone(ctx) } diff --git a/hack/release/publish.go b/hack/release/publish.go index 7e9f12e54a..d23491f620 100644 --- a/hack/release/publish.go +++ b/hack/release/publish.go @@ -72,7 +72,7 @@ var publishBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (co // If publishing a GitHub release, ideally it should be in draft mode with a token provided. if c.Bool(createGithubReleaseFlag.Name) { - if c.Bool(draftGithubReleaseFlag.Name) { + if !c.Bool(draftGithubReleaseFlag.Name) { logrus.Warnf("Publishing GitHub release in non-draft mode.") } if c.String(githubTokenFlag.Name) == "" { @@ -89,40 +89,13 @@ var publishBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (co }) var publishAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error { - // Check if images are already published - if published, err := operatorImagePublished(c); err != nil { - return fmt.Errorf("error checking if images are already published: %w", err) - } else if published { - logrus.Infof("Images for version %s are already published", c.String(versionFlag.Name)) - return nil - } - repoRootDir, err := gitDir() if err != nil { - return fmt.Errorf("error getting git directory: %w", err) + return fmt.Errorf("getting git directory: %w", err) } - // Set up environment variables for publish - publishEnv := append(os.Environ(), - fmt.Sprintf("VERSION=%s", c.String(versionFlag.Name)), - ) - arches := c.StringSlice(archFlag.Name) - if len(arches) > 0 { - publishEnv = append(publishEnv, fmt.Sprintf("ARCHES=%s", strings.Join(arches, " "))) - } - if c.Bool(hashreleaseFlag.Name) { - hashreleaseEnv, err := hashreleasePublishEnv(c) - if err != nil { - return fmt.Errorf("error preparing hashrelease publish: %w", err) - } - publishEnv = append(publishEnv, hashreleaseEnv...) - } else { - publishEnv = append(publishEnv, "RELEASE=true") - } - - if out, err := makeInDir(repoRootDir, "release-publish-images", publishEnv...); err != nil { - logrus.Error(out) - return fmt.Errorf("error publishing images: %w", err) + if err := publishImages(c, repoRootDir); err != nil { + return err } if !c.Bool(hashreleaseFlag.Name) { @@ -132,21 +105,51 @@ var publishAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) err return nil }) -func hashreleasePublishEnv(c *cli.Command) ([]string, error) { - publishEnv := []string{fmt.Sprintf("GIT_VERSION=%s", c.String(versionFlag.Name))} +func publishImages(c *cli.Command, repoRootDir string) error { + version := c.String(versionFlag.Name) + log := logrus.WithField("version", version) + // Check if images are already published + if published, err := operatorImagePublished(c); err != nil { + return fmt.Errorf("checking if images are already published: %w", err) + } else if published { + log.Warn("Images are already published") + return nil + } - image := c.String(imageFlag.Name) - if image != defaultImageName { + // Set up environment variables for publish + publishEnv := append(os.Environ(), + fmt.Sprintf("VERSION=%s", version), + ) + if arches := c.StringSlice(archFlag.Name); len(arches) > 0 { + log = log.WithField("arches", arches) + publishEnv = append(publishEnv, fmt.Sprintf("ARCHES=%s", strings.Join(arches, " "))) + } + if image := c.String(imageFlag.Name); image != defaultImageName { + log = log.WithField("image", image) publishEnv = append(publishEnv, fmt.Sprintf("BUILD_IMAGE=%s", image)) publishEnv = append(publishEnv, fmt.Sprintf("BUILD_INIT_IMAGE=%s-init", image)) } - registry := c.String(registryFlag.Name) - if registry != "" && registry != quayRegistry { + if registry := c.String(registryFlag.Name); registry != "" && registry != quayRegistry { + log = log.WithField("registry", registry) publishEnv = append(publishEnv, fmt.Sprintf("IMAGE_REGISTRY=%s", registry), fmt.Sprintf("PUSH_IMAGE_PREFIXES=%s", addTrailingSlash(registry))) } - return publishEnv, nil + if c.Bool(hashreleaseFlag.Name) { + log = log.WithField("hashrelease", true) + publishEnv = append(publishEnv, fmt.Sprintf("GIT_VERSION=%s", version)) + } else { + log = log.WithField("release", true) + publishEnv = append(publishEnv, "RELEASE=true") + } + + log.Info("Publishing Operator images") + if out, err := makeInDir(repoRootDir, "release-publish-images", publishEnv...); err != nil { + log.Error(out) + return fmt.Errorf("publishing images: %w", err) + } + log.Info("Successfully published Operator images") + return nil } func operatorImagePublished(c *cli.Command) (bool, error) { @@ -172,26 +175,28 @@ func publishGithubRelease(ctx context.Context, c *cli.Command, repoRootDir strin return nil } + version := c.String(versionFlag.Name) + log := logrus.WithField("version", version) + prerelease, err := isPrereleaseEnterpriseVersion(repoRootDir) if err != nil { - return fmt.Errorf("error determining if version is prerelease: %w", err) + return fmt.Errorf("determining if version is prerelease: %w", err) } r := &GithubRelease{ Org: ctx.Value(githubOrgCtxKey).(string), Repo: ctx.Value(githubRepoCtxKey).(string), - Version: c.String(versionFlag.Name), + Version: version, } if err := r.setupClient(ctx, c.String(githubTokenFlag.Name)); err != nil { - return fmt.Errorf("error setting up GitHub client: %s", err) + return fmt.Errorf("setting up GitHub client: %s", err) } // Create the GitHub release in draft mode. If it is a prerelease, mark it as such. - if err := r.Create(ctx, c.Bool(draftGithubReleaseFlag.Name), prerelease); errors.Is(err, ErrGitHubReleaseExists) { - logrus.Warnf("GitHub release for version %s already exists", c.String(versionFlag.Name)) - logrus.Infof("To update the release, please edit it manually on GitHub: %s", r.EditURL()) + if release, err := r.Create(ctx, c.Bool(draftGithubReleaseFlag.Name), prerelease); errors.Is(err, ErrGitHubReleaseExists) { + log.Warnf("GitHub release already exists, update manually: %s", *release.HTMLURL) } else if err != nil { - return fmt.Errorf("error publishing GitHub release: %s", err) + return fmt.Errorf("publishing GitHub release: %s", err) } return nil diff --git a/hack/release/releasenotes.go b/hack/release/releasenotes.go index fddf48ab02..3804201f89 100644 --- a/hack/release/releasenotes.go +++ b/hack/release/releasenotes.go @@ -80,5 +80,12 @@ var releaseNotesAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command if err != nil { return fmt.Errorf("error getting git root directory: %s", err) } - return release.GenerateNotes(ctx, repoRootDir, c.Bool(localFlag.Name)) + if err := release.GenerateNotes(ctx, repoRootDir, c.Bool(localFlag.Name)); err != nil { + return fmt.Errorf("error generating release notes: %s", err) + } + + logrus.WithField("release-notes-file", ReleaseNotesFilePath(repoRootDir, ver)).Info("Review release notes for accuracy and format appropriately") + logrus.Infof("Visit https://github.com/%s/%s/releases/new?tag=%s to create a new release", release.Org, release.Repo, release.Version) + + return nil }) diff --git a/hack/release/utils.go b/hack/release/utils.go index 4cd05166b2..663bbb90dc 100644 --- a/hack/release/utils.go +++ b/hack/release/utils.go @@ -41,7 +41,7 @@ const ( mainRepo = "tigera/operator" defaultImageName = "tigera/operator" - sourceGitHubURL = `https://github.com/` + mainRepo + `/raw/%s/%s` + tmplGithubFileURL = `https://github.com/{gitRepo}/raw/{gitHashOrTag}/{filePath}` configDir = "config" calicoConfig = configDir + "/calico_versions.yml" @@ -141,38 +141,39 @@ func calicoConfigVersions(dir, filePath string) (CalicoVersion, error) { fullPath := fmt.Sprintf("%s/%s", dir, filePath) data, err := os.ReadFile(fullPath) if err != nil { - return CalicoVersion{}, fmt.Errorf("error reading version file %s: %w", fullPath, err) + return CalicoVersion{}, fmt.Errorf("reading version file %s: %w", fullPath, err) } var version CalicoVersion if err := yaml.Unmarshal(data, &version); err != nil { - return CalicoVersion{}, fmt.Errorf("error unmarshaling version file %s: %w", fullPath, err) + return CalicoVersion{}, fmt.Errorf("unmarshaling version file %s: %w", fullPath, err) } return version, nil } // Retrieves the Calico and Calico Enterprise versions included in this release. -func calicoVersions(rootDir, operatorVersion string, local bool) (map[string]string, error) { +func calicoVersions(repo, rootDir, operatorVersion string, local bool) (map[string]string, error) { versions := make(map[string]string) if local && rootDir == "" { return versions, fmt.Errorf("rootDir must be specified when using local flag") } else if !local { - rootDir = filepath.Join(os.TempDir(), fmt.Sprintf("operator-%s", operatorVersion)) - err := os.MkdirAll(filepath.Join(rootDir, configDir), os.ModePerm) + tmpDir, err := os.MkdirTemp("", fmt.Sprintf("operator-%s-*", operatorVersion)) if err != nil { - return versions, fmt.Errorf("error creating config directory: %s", err) + return versions, fmt.Errorf("creating temp directory: %s", err) } - defer func() { - _ = os.RemoveAll(rootDir) - }() - if err := retrieveBaseVersionConfig(operatorVersion, rootDir); err != nil { - return versions, fmt.Errorf("error retrieving version config: %s", err) + defer func() { _ = os.RemoveAll(tmpDir) }() + if err := os.MkdirAll(filepath.Join(tmpDir, configDir), os.ModePerm); err != nil { + return versions, fmt.Errorf("creating config directory (%s) in %s: %w", configDir, tmpDir, err) + } + rootDir = tmpDir + if err := retrieveBaseVersionConfig(repo, operatorVersion, rootDir); err != nil { + return versions, fmt.Errorf("retrieving version config: %s", err) } } calicoVer, err := calicoConfigVersions(rootDir, calicoConfig) if err != nil { - return versions, fmt.Errorf("error retrieving Calico version: %s", err) + return versions, fmt.Errorf("retrieving Calico version: %s", err) } if isReleaseVersion, err := isReleaseVersionFormat(calicoVer.Title); err == nil && isReleaseVersion { versions["Calico"] = calicoVer.Title @@ -181,7 +182,7 @@ func calicoVersions(rootDir, operatorVersion string, local bool) (map[string]str } enterpriseVer, err := calicoConfigVersions(rootDir, enterpriseConfig) if err != nil { - return versions, fmt.Errorf("error retrieving Enterprise version: %s", err) + return versions, fmt.Errorf("retrieving Enterprise version: %s", err) } if isReleaseVersion, err := isEnterpriseReleaseVersionFormat(enterpriseVer.Title); err == nil && isReleaseVersion { versions["Calico Enterprise"] = enterpriseVer.Title @@ -193,7 +194,7 @@ func calicoVersions(rootDir, operatorVersion string, local bool) (map[string]str func isReleaseVersionFormat(version string) (bool, error) { releaseRegex, err := regexp.Compile(releaseFormat) if err != nil { - return false, fmt.Errorf("error compiling release regex: %s", err) + return false, fmt.Errorf("compiling release regex: %s", err) } return releaseRegex.MatchString(version), nil } @@ -202,7 +203,7 @@ func isReleaseVersionFormat(version string) (bool, error) { func isEnterpriseReleaseVersionFormat(version string) (bool, error) { releaseRegex, err := regexp.Compile(enterpriseReleaseFormat) if err != nil { - return false, fmt.Errorf("error compiling release regex: %s", err) + return false, fmt.Errorf("compiling release regex: %s", err) } return releaseRegex.MatchString(version), nil } diff --git a/hack/release/utils_test.go b/hack/release/utils_test.go index c70af2a09c..7d9cc745fc 100644 --- a/hack/release/utils_test.go +++ b/hack/release/utils_test.go @@ -53,7 +53,7 @@ func TestCalicoConfigVersions(t *testing.T) { if err == nil { t.Fatalf("expected error reading nonexistent file, got nil") } - if !strings.Contains(err.Error(), "error reading version file") { + if !strings.Contains(err.Error(), "reading version file") { t.Fatalf("unexpected error message: %v", err) } }) @@ -72,7 +72,7 @@ func TestCalicoConfigVersions(t *testing.T) { if err == nil { t.Fatalf("expected unmarshal error, got nil") } - if !strings.Contains(err.Error(), "error unmarshaling version file") { + if !strings.Contains(err.Error(), "unmarshaling version file") { t.Fatalf("unexpected error message: %v", err) } }) @@ -87,7 +87,7 @@ func TestReleaseVersions(t *testing.T) { enterpriseVer := "v3.25.0-1.0" dir := fakeOperatorRepo(t, calicoVer, enterpriseVer) - versions, err := calicoVersions(dir, "v1.2.3", true) + versions, err := calicoVersions(mainRepo, dir, "v1.2.3", true) if err != nil { t.Fatalf("expected no error, got: %v", err) } @@ -97,7 +97,7 @@ func TestReleaseVersions(t *testing.T) { t.Run("local with no rootDir", func(t *testing.T) { t.Parallel() - _, err := calicoVersions("", "v1.2.3", true) + _, err := calicoVersions(mainRepo, "", "v1.2.3", true) if err == nil { t.Fatalf("expected error for missing rootDir, got nil") } @@ -110,7 +110,7 @@ func TestReleaseVersions(t *testing.T) { t.Parallel() dir := fakeOperatorRepo(t, "master", "master") - _, err := calicoVersions(dir, "v1.2.3", true) + _, err := calicoVersions(mainRepo, dir, "v1.2.3", true) if err == nil { t.Fatalf("expected error for invalid calico version, got nil") } @@ -125,7 +125,7 @@ func TestReleaseVersions(t *testing.T) { enterpriseVer := "release-calient-v3.20" dir := fakeOperatorRepo(t, calicoVer, enterpriseVer) - versions, err := calicoVersions(dir, "v1.2.3", true) + versions, err := calicoVersions(mainRepo, dir, "v1.2.3", true) if err != nil { t.Fatalf("expected no error when enterprise missing, got: %v", err) } From c76f5041299f74fd8f7ccb9f01615724c3da4a82 Mon Sep 17 00:00:00 2001 From: tuti Date: Fri, 12 Dec 2025 14:00:25 -0800 Subject: [PATCH 08/20] docs: update docs and pipeline names --- .semaphore/push_images.yml | 2 +- .semaphore/release.yml | 2 +- Makefile | 15 ++-------- RELEASING.md | 38 +++++++++--------------- hack/release/README.md | 59 +++++++++++++++++++++++++++++++------- 5 files changed, 67 insertions(+), 49 deletions(-) diff --git a/.semaphore/push_images.yml b/.semaphore/push_images.yml index 3024b4abfc..66612f8d19 100644 --- a/.semaphore/push_images.yml +++ b/.semaphore/push_images.yml @@ -41,7 +41,7 @@ global_job_config: - 'cache restore go-mod-cache-s390x-${SEMAPHORE_GIT_SHA}' blocks: - - name: Push Images / Maybe Release + - name: Push Images task: secrets: - name: quay-robot-semaphore_v2 diff --git a/.semaphore/release.yml b/.semaphore/release.yml index ed5ff994ea..5b4c246b46 100644 --- a/.semaphore/release.yml +++ b/.semaphore/release.yml @@ -1,6 +1,6 @@ version: v1.0 -name: Operator CD +name: Operator Release execution_time_limit: hours: 4 diff --git a/Makefile b/Makefile index ce93337b15..0ee2094733 100644 --- a/Makefile +++ b/Makefile @@ -553,25 +553,16 @@ endif ############################################################################### # Release ############################################################################### -VERSION_REGEX := ^v[0-9]+\.[0-9]+\.[0-9]+$$ +## Create a release for the specified RELEASE_TAG. release-tag: var-require-all-RELEASE_TAG-GITHUB_TOKEN - $(eval VALID_TAG := $(shell echo $(RELEASE_TAG) | grep -Eq "$(VERSION_REGEX)" && echo true)) - $(if $(VALID_TAG),,$(error $(RELEASE_TAG) is not a valid version. Please use a version in the format vX.Y.Z)) - -# Skip releasing if the image already exists. - @if !$(MAKE) VERSION=$(RELEASE_TAG) release-check-image-exists; then \ - echo "Images for $(RELEASE_TAG) already exists"; \ - exit 0; \ - fi - $(MAKE) release VERSION=$(RELEASE_TAG) REPO=$(REPO) CREATE_GITHUB_RELEASE=true $(MAKE) release-publish VERSION=$(RELEASE_TAG) - +## Generate release notes for the specified VERSION. release-notes: hack/bin/release var-require-all-VERSION-GITHUB_TOKEN REPO=$(REPO) hack/bin/release notes -## Tags and builds a release from start to finish. +## Build a release from start to finish. release: clean hack/bin/release hack/bin/release build diff --git a/RELEASING.md b/RELEASING.md index 640da52ede..f4f1c09910 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -56,27 +56,15 @@ This ensures that new PRs against master will be automatically given the correct ## Preparing for the release -- Create any new milestones that should exist - - Create the next patch version - - If a new minor was released (`.0`), also ensure the next minor has been created (this should have already been created as part of [Preparing a new release branch](#preparing-a-new-release-branch)) -- Review the milestone for this release and ensure it is accurate. https://github.com/tigera/operator/milestones - - Move any open PRs to a new milestone (*likely* the newly created one) - - Close the milestone for the release - -## Updating versions - Checkout the branch from which you want to release. Ensure that you are using the correct -operator version for the version of Calico or Calient that you are releasing. If in doubt, +operator version for the version of Calico or Enterprise that you are releasing. If in doubt, check [the releases page](https://github.com/tigera/operator/releases) to find the most -recent Operator release for your Calico or Calient minor version. - -Make sure pins are updated in `go.mod` +recent Operator release for your Calico or Enterprise minor version. Run the following command: ```sh -make release-prep GIT_PR_BRANCH_BASE= GIT_REPO_SLUG=tigera/operator CONFIRM=true \ - VERSION= CALICO_VERSION= CALICO_ENTERPRISE_VERSION= COMMON_VERSION= +make release-prep VERSION= CALICO_VERSION= ENTERPRISE_VERSION= ``` The command does the following: @@ -86,21 +74,23 @@ format `vX.Y.Z` for each of the following files: 1. `config/calico_versions.yml` (Calico OSS version) 2. `config/enterprise_versions.yml` (Calico Enterprise version) -- It updates the registry reference to `quay.io` from `gcr.io` for each of the following files: +- It updates the registry reference to `quay.io` from `gcr.io` in the following files: 1. `TigeraRegistry` in `pkg/components/images.go` - 2. `defaultEnterpriseRegistry` in `hack/gen-versions/main.go` - It ensures `make gen-versions` is run and the resulting updates committed. - It creates a PR with all the changes +- It manages the milestones on GitHub for the release stream associated with the new release, + which involves creating a new milestone for the next patch version and closing the current milestone + for the release version. All open issues and pull requests associated with the current milestone + are moved to the new milestone. -Go to the PR created and: - -1. Ensure tests pass -2. Update the labels in the PR to include `docs-not-required` and `release-note-not-required` +Go to the PR created and it is reviewed and merged. ## Releasing +Once the PR from [the previous step](#preparing-for-the-release) is merged, follow these steps to create the release: + 1. Merge your PR to the release branch 1. Create a git tag `` for the new commit on the release branch and push it: @@ -110,11 +100,9 @@ Go to the PR created and: git push # e.g git push origin v1.30.2 ``` -1. Log in to semaphore and find the new build for the release branch commit, and - click 'Rerun'. When Semaphore starts the rebuild, it will notice the new tag and - build and publish an operator release. +1. Log in to semaphore and run the [release task](https://tigera.semaphoreci.com/projects/operator/schedulers/fcc7fc6c-fb81-4a07-b312-138befbeb111). -1. Go to [releases](https://github.com/tigera/operator/releases) and edit the draft release for the release tag +1. Once the semaphore run is done, go to [releases](https://github.com/tigera/operator/releases) and edit the draft release for the release tag 1. Publish the release. diff --git a/hack/release/README.md b/hack/release/README.md index 83fc8cc65d..971ca247db 100644 --- a/hack/release/README.md +++ b/hack/release/README.md @@ -1,6 +1,6 @@ # release -`release` is a tool designed to streamline the process of creating and releasing a new operator version. +`release` is a tool designed to streamline the process of creating and releasing a new operator. - [release](#release) - [Installation](#installation) @@ -8,12 +8,14 @@ - [Commands](#commands) - [release build](#release-build) - [Examples](#examples) + - [release publish](#release-publish) + - [Examples](#examples-1) - [release prep](#release-prep) - - [Examples](#examples-1) - - [release notes](#release-notes) - [Examples](#examples-2) - - [release from](#release-from) + - [release notes](#release-notes) - [Examples](#examples-3) + - [release from](#release-from) + - [Examples](#examples-4) ## Installation @@ -61,7 +63,7 @@ release build --version --hashrelease \ release build --version v1.36.0 ``` -2. To build hashrelease operator image for Calico v3.30 +1. To build hashrelease operator image for Calico v3.30 1. Using Calico versions file @@ -69,13 +71,13 @@ release build --version --hashrelease \ release build --hashrelease --version v1.36.0-0.dev-259-g25c811f78fbd-v3.30.0-0.dev-338-gca80474016a5 --calico-versions hashrelease-versions.yaml ``` - 2. Specifying version directly + 1. Specifying version directly ```sh release build --hashrelease --version v1.36.0-0.dev-259-g25c811f78fbd-v3.30.0-0.dev-338-gca80474016a5 --calico-version v3.30.0-0.dev-338-gca80474016a5 ``` -3. To build hashrelease operator image for Calico Enterprise v3.22 +1. To build hashrelease operator image for Calico Enterprise v3.22 1. Using Enterprise versions file @@ -83,12 +85,49 @@ release build --version --hashrelease \ release build --hashrelease --version v1.36.0-0.dev-259-g25c811f78fbd-v3.22.0-calient-0.dev-100-gabcdef123456 --enterprise-versions hashrelease-versions.yaml ``` - 2. Specifying version directly + 1. Specifying version directly ```sh release build --hashrelease --version v1.36.0-0.dev-259-g25c811f78fbd-v3.22.0-calient-0.dev-100-gabcdef123456 --enterprise-version v3.22.0-calient-0.dev-100-gabcdef123456 ``` +### release publish + +This commands publishes the operator image for a specific operator version to a container registry. + +```sh +release publish --version +``` + +For hashrelease, use the `--hashrelease` flag + +```sh +release publish --version --hashrelease +``` + +If this is a release version (i.e. vX.Y.Z) and the `--create-github-release` flag is set to true, +it creates a draft release on the [Releases](https://github.com/tigera/operator/releases) page. + +### Examples + +1. To publish the operator version `v1.36.0` + + ```sh + release publish --version v1.36.0 + ``` + +1. To publish the hashrelease operator image for operator version `v1.36.0-0.dev-259-g25c811f78fbd-v3.30.0-0.dev-338-gca80474016a5` + + ```sh + release publish --version v1.36.0-0.dev-259-g25c811f78fbd-v3.30.0-0.dev-338-gca80474016a5 --hashrelease + ``` + +1. To publish the operator version `v1.36.0` and create a GitHub release + + ```sh + release publish --version v1.36.0 --create-github-release + ``` + ### release prep This command prepares the repo for a new release. @@ -101,8 +140,8 @@ which involves creating a new milestone for the next patch version and closing t for the release version. All open issues and pull requests associated with the current milestone are moved to the new milestone. - > [!NOTE] - > At least one of Calico or Calico Enterprise version must be specified. + > [!IMPORTANT] + > One of Calico or Calico Enterprise version must be specified. To prepare for a new release, use the following command: From dede09d619ba1f93f6c3c79142db8cf747df3e20 Mon Sep 17 00:00:00 2001 From: tuti Date: Fri, 12 Dec 2025 14:16:51 -0800 Subject: [PATCH 09/20] fix: add setting if github release is latest or not --- hack/release/github.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/hack/release/github.go b/hack/release/github.go index 3e2ae7c1f3..5a2fb39106 100644 --- a/hack/release/github.go +++ b/hack/release/github.go @@ -395,12 +395,20 @@ func (r *GithubRelease) Create(ctx context.Context, isDraft, isPrerelease bool) return nil, fmt.Errorf("reading release notes file for %s: %w", r.Version, err) } + latest := "false" + // Have GitHub determine if this is the latest release only if it is not a draft or prerelease + // by using "legacy" option. See https://docs.github.com/en/rest/releases/releases#create-a-release + if !isDraft && !isPrerelease { + latest = "legacy" + } + release, _, err := r.githubClient.Repositories.CreateRelease(ctx, r.Org, r.Repo, &github.RepositoryRelease{ TagName: github.String(r.Version), Name: github.String(r.Version), Body: github.String(string(relNotesBytes)), Draft: github.Bool(isDraft), Prerelease: github.Bool(isPrerelease), + MakeLatest: github.String(latest), }) if err != nil { return nil, fmt.Errorf("creating GitHub release for %s: %w", r.Version, err) From a7e2fceeab182b76a4185cdcd9ae9fe12c20a4b6 Mon Sep 17 00:00:00 2001 From: tuti Date: Fri, 12 Dec 2025 14:24:11 -0800 Subject: [PATCH 10/20] fix: update make target for release --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0ee2094733..e5fbba14fa 100644 --- a/Makefile +++ b/Makefile @@ -563,7 +563,7 @@ release-notes: hack/bin/release var-require-all-VERSION-GITHUB_TOKEN REPO=$(REPO) hack/bin/release notes ## Build a release from start to finish. -release: clean hack/bin/release +release: clean hack/bin/release var-require-all-VERSION hack/bin/release build ## Produces a clean build of release artifacts at the specified version. From 02afc3e2f17374435998857ac35b308e128c820f Mon Sep 17 00:00:00 2001 From: tuti Date: Fri, 12 Dec 2025 14:33:02 -0800 Subject: [PATCH 11/20] fix: update warning logged for build --- hack/release/build.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/hack/release/build.go b/hack/release/build.go index 2ea7b2d252..1db59e13fb 100644 --- a/hack/release/build.go +++ b/hack/release/build.go @@ -145,15 +145,19 @@ var buildBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (cont } if calicoBuildOk { if calicoBuildType == versionBuild && c.String(calicoCRDsDirFlag.Name) == "" { - return ctx, fmt.Errorf("Calico CRDs directory must be specified for hashrelease builds using calico-version flag") + return ctx, fmt.Errorf("Calico directory must be specified for hashrelease builds using calico-version flag") + } + if c.String(calicoCRDsDirFlag.Name) == "" { + logrus.Warn("Calico directory not specified for hashrelease build, getting CRDs from default location may not be appropriate") } - logrus.Warn("Calico CRDs directory not specified for hashrelease build, default CRDs may not be appropriate") } if enterpriseBuildOk { if enterpriseBuildType == versionBuild && c.String(enterpriseCRDsDirFlag.Name) == "" { - return ctx, fmt.Errorf("Enterprise CRDs directory must be specified for hashrelease builds using enterprise-version flag") + return ctx, fmt.Errorf("Enterprise directory must be specified for hashrelease builds using enterprise version") + } + if c.String(enterpriseCRDsDirFlag.Name) == "" { + logrus.Warn("Enterprise directory not specified for hashrelease build, getting CRDs from default location may not be appropriate") } - logrus.Warn("Enterprise CRDs directory not specified for hashrelease build, default CRDs may not be appropriate") } return ctx, nil From 4d66d546dfd1e1117319f48af83eb171c2879cd3 Mon Sep 17 00:00:00 2001 From: tuti Date: Fri, 12 Dec 2025 15:00:28 -0800 Subject: [PATCH 12/20] feat: verify image being built + list images in debug mode --- Makefile | 5 ---- cmd/main.go | 2 +- hack/release/build.go | 62 +++++++++++++++++++++++++++++++++++-------- 3 files changed, 52 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index e5fbba14fa..4c8aecd2e6 100644 --- a/Makefile +++ b/Makefile @@ -577,11 +577,6 @@ endif # Generate the `latest` images. $(MAKE) tag-images-all IMAGETAG=latest -## Verifies the release artifacts produces by `make release-build` are correct. -release-verify: release-prereqs - # Check the reported version is correct for each release artifact. - if ! docker run $(IMAGE_REGISTRY)/$(BUILD_IMAGE):$(VERSION)-$(ARCH) --version | grep '^Operator: $(VERSION)$$'; then echo "Reported version:" `docker run $(IMAGE_REGISTRY)/$(BUILD_IMAGE):$(VERSION)-$(ARCH) --version ` "\nExpected version: $(VERSION)"; false; else echo "\nVersion check passed\n"; fi - release-check-image-exists: release-prereqs @echo "Checking if $(IMAGE_REGISTRY)/$(BUILD_IMAGE):$(VERSION) exists already"; \ if docker manifest inspect $(IMAGE_REGISTRY)/$(BUILD_IMAGE):$(VERSION) >/dev/null; \ diff --git a/cmd/main.go b/cmd/main.go index 7144c866e1..c6201bddd1 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -140,7 +140,7 @@ If a value other than 'all' is specified, the first CRD with a prefix of the spe ctrl.SetLogger(zap.New(zap.WriteTo(os.Stdout), zap.UseFlagOptions(&opts))) if showVersion { - // If the following line is updated then it might be necessary to update the release-verify target in the Makefile + // If the following line is updated then it might be necessary to update the assertOperatorImageVersion in hack/release/build.go fmt.Println("Operator:", version.VERSION) fmt.Println("Calico:", components.CalicoRelease) fmt.Println("Enterprise:", components.EnterpriseRelease) diff --git a/hack/release/build.go b/hack/release/build.go index 1db59e13fb..bbe0dc6526 100644 --- a/hack/release/build.go +++ b/hack/release/build.go @@ -18,7 +18,9 @@ import ( "context" "fmt" "os" + "path" "regexp" + "runtime" "strings" "github.com/sirupsen/logrus" @@ -171,28 +173,30 @@ var buildAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error } version := c.String(versionFlag.Name) - log := logrus.WithField("version", version) + buildLog := logrus.WithField("version", version) // Prepare build environment variables buildEnv := append(os.Environ(), fmt.Sprintf("VERSION=%s", version)) if arches := c.StringSlice(archFlag.Name); len(arches) > 0 { - log = log.WithField("arches", arches) + buildLog = buildLog.WithField("arches", arches) buildEnv = append(buildEnv, fmt.Sprintf("ARCHES=%s", strings.Join(arches, " "))) } - if image := c.String(imageFlag.Name); image != defaultImageName { - log = log.WithField("image", image) + image := c.String(imageFlag.Name) + if image != defaultImageName { + buildLog = buildLog.WithField("image", image) buildEnv = append(buildEnv, fmt.Sprintf("BUILD_IMAGE=%s", image), fmt.Sprintf("BUILD_INIT_IMAGE=%s-init", image)) } - if registry := c.String(registryFlag.Name); registry != "" && registry != quayRegistry { - log = log.WithField("registry", registry) + registry := c.String(registryFlag.Name) + if registry != "" && registry != quayRegistry { + buildLog = buildLog.WithField("registry", registry) buildEnv = append(buildEnv, fmt.Sprintf("IMAGE_REGISTRY=%s", registry), fmt.Sprintf("PUSH_IMAGE_PREFIXES=%s", addTrailingSlash(registry))) } if c.Bool(hashreleaseFlag.Name) { - log = log.WithField("hashrelease", true) + buildLog = buildLog.WithField("hashrelease", true) buildEnv = append(buildEnv, fmt.Sprintf("GIT_VERSION=%s", c.String(versionFlag.Name))) resetFn, err := hashreleaseBuildConfig(ctx, c, repoRootDir) defer resetFn() @@ -200,20 +204,56 @@ var buildAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error return fmt.Errorf("preparing hashrelease build environment: %w", err) } } else { - log = log.WithField("release", true) + buildLog = buildLog.WithField("release", true) buildEnv = append(buildEnv, "RELEASE=true") } // Build the Operator and verify the build - log.Info("Building Operator") + buildLog.Info("Building Operator") if out, err := makeInDir(repoRootDir, "release-build", buildEnv...); err != nil { - log.Error(out) + buildLog.Error(out) return fmt.Errorf("building Operator: %w", err) } - + if err := assertOperatorImageVersion(registry, image, version); err != nil { + return fmt.Errorf("asserting operator image version: %w", err) + } + listImages(registry, image, version) return nil }) +func listImages(registry, image, version string) { + fqImage := fmt.Sprintf("%s:%s-%s", path.Join(registry, image), version, runtime.GOARCH) + out, err := runCommand("docker", []string{"run", "--rm", fqImage, "--print-images", "list"}, nil) + if err != nil { + logrus.Error(out) + logrus.Errorf("listing images: %v", err) + return + } + logrus.Debug(out) +} + +func assertOperatorImageVersion(registry, image, expectedVersion string) error { + fqImage := fmt.Sprintf("%s:%s-%s", path.Join(registry, image), expectedVersion, runtime.GOARCH) + out, err := runCommand("docker", []string{"run", "--rm", fqImage, "--version"}, nil) + if err != nil { + logrus.Error(out) + return fmt.Errorf("getting operator image version: %w", err) + } + logrus.Info(out) + var imageVersion string + for _, line := range strings.Split(strings.TrimSpace(out), "\n") { + if strings.HasPrefix(line, "Operator:") { + parts := strings.SplitAfterN(line, ":", 2) + imageVersion = strings.TrimSpace(parts[1]) + break + } + } + if imageVersion != expectedVersion { + return fmt.Errorf("built operator version %s does not match expected version %s", imageVersion, expectedVersion) + } + return nil +} + func hashreleaseBuildConfig(ctx context.Context, c *cli.Command, repoRootDir string) (func(), error) { repoReset := func() { if out, err := gitInDir(repoRootDir, append([]string{"checkout", "-f"}, changedFiles...)...); err != nil { From 891b0b97668778a7cd4a829cbe208cd5b59e9159 Mon Sep 17 00:00:00 2001 From: tuti Date: Tue, 16 Dec 2025 13:38:13 -0800 Subject: [PATCH 13/20] feat: add command for publishing github releases --- hack/release/README.md | 29 +++++++++++++- hack/release/flags.go | 14 ++++++- hack/release/github.go | 88 +++++++++++++++++++++++++++++------------ hack/release/main.go | 1 + hack/release/public.go | 88 +++++++++++++++++++++++++++++++++++++++++ hack/release/publish.go | 24 +++++------ 6 files changed, 202 insertions(+), 42 deletions(-) create mode 100644 hack/release/public.go diff --git a/hack/release/README.md b/hack/release/README.md index 971ca247db..a3fafce097 100644 --- a/hack/release/README.md +++ b/hack/release/README.md @@ -14,8 +14,10 @@ - [Examples](#examples-2) - [release notes](#release-notes) - [Examples](#examples-3) - - [release from](#release-from) + - [release github](#release-github) - [Examples](#examples-4) + - [release from](#release-from) + - [Examples](#examples-5) ## Installation @@ -205,6 +207,31 @@ To get the versions file from the local working directory instead of the tagged release notes --version v1.36.0 --local ``` +### release github + +This command creates or updates a GitHub release for a specific operator version. +To create or update a GitHub release, use the following command: + +```sh +release github --version +``` + +By default, the GitHub release is not created in draft mode. To create a draft release, use the `--draft` flag. + +#### Examples + +1. To create or update a GitHub release for operator version `v1.36.0` + + ```sh + release github --version v1.36.0 + ``` + +1. To create or update a draft GitHub release for operator version `v1.36.0` + + ```sh + release github --version v1.36.0 --draft + ``` + ### release from This command creates a new operator version based on a previous operator version. diff --git a/hack/release/flags.go b/hack/release/flags.go index f232b29996..f2080fdd3a 100644 --- a/hack/release/flags.go +++ b/hack/release/flags.go @@ -73,7 +73,7 @@ var ( Category: githubFlagCategory, Usage: "Create a GitHub release", Sources: cli.EnvVars("CREATE_GITHUB_RELEASE"), - Value: false, + Value: true, Action: func(ctx context.Context, c *cli.Command, b bool) error { if b && c.String(githubTokenFlag.Name) == "" { return fmt.Errorf("github-token is required to create GitHub releases") @@ -81,13 +81,23 @@ var ( return nil }, } + // Draft GitHub release flag for publish command. It defaults to true. draftGithubReleaseFlag = &cli.BoolFlag{ Name: "draft-github-release", Category: githubFlagCategory, - Usage: fmt.Sprintf("Create the GitHub release as a draft. Only applicable if --%s is set.", createGithubReleaseFlag.Name), + Usage: "Whether to create the GitHub release in draft mode", Sources: cli.EnvVars("DRAFT_GITHUB_RELEASE"), Value: true, } + // Draft GitHub release flag for public command. It defaults to false. + draftGithubReleasePublicFlag = &cli.BoolFlag{ + Name: "draft", + Aliases: []string{draftGithubReleaseFlag.Name}, + Category: draftGithubReleaseFlag.Category, + Usage: draftGithubReleaseFlag.Usage, + Sources: draftGithubReleaseFlag.Sources, + Value: false, + } ) // Operator flags diff --git a/hack/release/github.go b/hack/release/github.go index 5a2fb39106..5aaaf7efb2 100644 --- a/hack/release/github.go +++ b/hack/release/github.go @@ -46,7 +46,10 @@ const ( allState = "all" ) -var ErrGitHubReleaseExists = errors.New("GitHub release already exists") +var ( + ErrGitHubReleaseExists = errors.New("GitHub release already exists") + ErrNoGitHubReleaseExists = errors.New("GitHub release does not exist") +) type issueKind string @@ -73,11 +76,12 @@ type ReleaseNoteData struct { // GithubRelease represents a GitHub-hosted release of the operator. type GithubRelease struct { - Org string // GitHub organization - Repo string // GitHub repository - Version string // Release version - githubClient *github.Client // GitHub API client - milestone *github.Milestone // Cached milestone for the release version + Org string // GitHub organization + Repo string // GitHub repository + Version string // Release version + githubClient *github.Client // GitHub API client + githubRepoRelease *github.RepositoryRelease // Cached GitHub release + milestone *github.Milestone // Cached GitHub milestone } // Setup the GitHub client for this release using the provided token. @@ -346,7 +350,11 @@ func (r *GithubRelease) collectReleaseNotes(ctx context.Context, local bool) (*R // Check if a GitHub release already exists for this version. // Since the release could already exist in draft, it uses the ListReleases instead of GetReleaseByTag. +// Cache the result for future calls. func (r *GithubRelease) repoRelease(ctx context.Context) (*github.RepositoryRelease, error) { + if r.githubRepoRelease != nil { + return r.githubRepoRelease, nil + } opts := &github.ListOptions{PerPage: 100} for { release, resp, err := r.githubClient.Repositories.ListReleases(ctx, r.Org, r.Repo, opts) @@ -355,7 +363,8 @@ func (r *GithubRelease) repoRelease(ctx context.Context) (*github.RepositoryRele } for _, rel := range release { if rel.GetTagName() == r.Version { - return rel, nil + r.githubRepoRelease = rel + break } } if resp.NextPage == 0 { @@ -363,16 +372,17 @@ func (r *GithubRelease) repoRelease(ctx context.Context) (*github.RepositoryRele } opts.Page = resp.NextPage } - return nil, nil + return r.githubRepoRelease, nil } // Create a new GitHub release. -func (r *GithubRelease) Create(ctx context.Context, isDraft, isPrerelease bool) (*github.RepositoryRelease, error) { +func (r *GithubRelease) Create(ctx context.Context, isDraft, isPrerelease bool) error { // Check if release already exists if got, err := r.repoRelease(ctx); err != nil { - return nil, fmt.Errorf("checking if release exists: %w", err) + return fmt.Errorf("checking if release exists: %w", err) } else if got != nil { - return got, ErrGitHubReleaseExists + logrus.Warnf("GitHub release already exists, update manually: %s", got.GetHTMLURL()) + return ErrGitHubReleaseExists } logrus.WithFields(logrus.Fields{ @@ -384,22 +394,15 @@ func (r *GithubRelease) Create(ctx context.Context, isDraft, isPrerelease bool) // Generate release notes tmpDir, err := os.MkdirTemp("", fmt.Sprintf("operator-%s-*", r.Version)) if err != nil { - return nil, fmt.Errorf("creating temporary directory for release notes: %w", err) + return fmt.Errorf("creating temporary directory for release notes: %w", err) } defer func() { _ = os.RemoveAll(tmpDir) }() if err := r.GenerateNotes(ctx, tmpDir, false); err != nil { - return nil, fmt.Errorf("generating release notes for %s: %w", r.Version, err) + return fmt.Errorf("generating release notes for %s: %w", r.Version, err) } relNotesBytes, err := os.ReadFile(ReleaseNotesFilePath(tmpDir, r.Version)) if err != nil { - return nil, fmt.Errorf("reading release notes file for %s: %w", r.Version, err) - } - - latest := "false" - // Have GitHub determine if this is the latest release only if it is not a draft or prerelease - // by using "legacy" option. See https://docs.github.com/en/rest/releases/releases#create-a-release - if !isDraft && !isPrerelease { - latest = "legacy" + return fmt.Errorf("reading release notes file for %s: %w", r.Version, err) } release, _, err := r.githubClient.Repositories.CreateRelease(ctx, r.Org, r.Repo, &github.RepositoryRelease{ @@ -408,18 +411,53 @@ func (r *GithubRelease) Create(ctx context.Context, isDraft, isPrerelease bool) Body: github.String(string(relNotesBytes)), Draft: github.Bool(isDraft), Prerelease: github.Bool(isPrerelease), - MakeLatest: github.String(latest), + MakeLatest: makeLatest(isDraft, isPrerelease), }) if err != nil { - return nil, fmt.Errorf("creating GitHub release for %s: %w", r.Version, err) + return fmt.Errorf("creating GitHub release for %s: %w", r.Version, err) } log := logrus.WithField("release", r.Version).WithField("url", release.GetHTMLURL()) if isDraft { log.Info("GitHub release created in draft state, review and publish it manually") - return release, nil + return nil } log.Info("GitHub release created") - return release, nil + return nil +} + +// Helper function to determine the value for MakeLatest option for GitHub releases. +// Ideally, we want GitHub to determine if this is the latest release only if it is not a draft or prerelease +// using "legacy" option. See https://docs.github.com/en/rest/releases/releases#create-a-release +func makeLatest(isDraft, isPrerelease bool) *string { + latest := "false" + if !isDraft && !isPrerelease { + latest = "legacy" + } + return github.String(latest) +} + +// Update an existing GitHub release. +func (r *GithubRelease) Update(ctx context.Context, isDraft, isPrerelease bool) error { + if rel, err := r.repoRelease(ctx); err != nil { + return fmt.Errorf("checking if release exists: %w", err) + } else if rel == nil { + return ErrNoGitHubReleaseExists + } + release, _, err := r.githubClient.Repositories.EditRelease(ctx, r.Org, r.Repo, r.githubRepoRelease.GetID(), &github.RepositoryRelease{ + Draft: github.Bool(isDraft), + Prerelease: github.Bool(isPrerelease), + MakeLatest: makeLatest(isDraft, isPrerelease), + }) + if err != nil { + return fmt.Errorf("updating GitHub release for %s: %w", r.Version, err) + } + log := logrus.WithField("release", r.Version).WithField("url", release.GetHTMLURL()) + if isDraft { + log.Info("GitHub release updated in draft state, review and publish it manually") + return nil + } + log.Info("GitHub release updated") + return nil } // Helper function to list GitHub issues based on the provided options and optional filter function. diff --git a/hack/release/main.go b/hack/release/main.go index a8bbc01b5b..9fd78ec612 100644 --- a/hack/release/main.go +++ b/hack/release/main.go @@ -46,6 +46,7 @@ func app(version string) *cli.Command { buildCommand, publishCommand, prepCommand, + publicCommand, releaseNotesCommand, releaseFromCommand, }, diff --git a/hack/release/public.go b/hack/release/public.go new file mode 100644 index 0000000000..6746e5227c --- /dev/null +++ b/hack/release/public.go @@ -0,0 +1,88 @@ +// Copyright (c) 2025 Tigera, Inc. All rights reserved. + +// 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 ( + "context" + "errors" + "fmt" + + "github.com/urfave/cli/v3" +) + +// Command to publish release to GitHub. +var publicCommand = &cli.Command{ + Name: "github", + Aliases: []string{"public"}, + Usage: "Publish release to GitHub", + Flags: []cli.Flag{ + versionFlag, + draftGithubReleasePublicFlag, + githubTokenFlag, + skipValidationFlag, + }, + Before: publicBefore, + Action: publicAction, +} + +var publicBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (context.Context, error) { + configureLogging(c) + + var err error + ctx, err = addRepoInfoToCtx(ctx, c.String(gitRepoFlag.Name)) + if err != nil { + return ctx, err + } + + if c.Bool(skipValidationFlag.Name) { + return ctx, nil + } + + // Check that images exist for the given version. + if published, err := operatorImagePublished(c); err != nil { + return ctx, fmt.Errorf("checking if images are published: %w", err) + } else if !published { + return ctx, fmt.Errorf("images for version %s are not published; please publish them before creating a GitHub release", c.String(versionFlag.Name)) + } + + return ctx, nil +}) + +var publicAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error { + repoRootDir, err := gitDir() + if err != nil { + return fmt.Errorf("getting repo root dir: %w", err) + } + prerelease, err := isPrereleaseEnterpriseVersion(repoRootDir) + if err != nil { + return fmt.Errorf("determining if version is prerelease: %w", err) + } + r := &GithubRelease{ + Org: ctx.Value(githubOrgCtxKey).(string), + Repo: ctx.Value(githubRepoCtxKey).(string), + Version: c.String(versionFlag.Name), + } + if err := r.setupClient(ctx, c.String(githubTokenFlag.Name)); err != nil { + return fmt.Errorf("setting up GitHub client: %s", err) + } + + if err := r.Update(ctx, c.Bool(draftGithubReleasePublicFlag.Name), prerelease); err != nil && errors.Is(err, ErrNoGitHubReleaseExists) { + // If the release does not exist, create it. + return r.Create(ctx, c.Bool(draftGithubReleasePublicFlag.Name), prerelease) + } else if err != nil { + return fmt.Errorf("updating GitHub release: %w", err) + } + return nil +}) diff --git a/hack/release/publish.go b/hack/release/publish.go index d23491f620..7e40c7b86a 100644 --- a/hack/release/publish.go +++ b/hack/release/publish.go @@ -93,16 +93,17 @@ var publishAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) err if err != nil { return fmt.Errorf("getting git directory: %w", err) } - if err := publishImages(c, repoRootDir); err != nil { return err } - - if !c.Bool(hashreleaseFlag.Name) { - return publishGithubRelease(ctx, c, repoRootDir) + if c.Bool(hashreleaseFlag.Name) { + return nil } - - return nil + if !c.Bool(createGithubReleaseFlag.Name) { + logrus.Warnf("Skipping GitHub release creation. Either use %q to create a GitHub release or create manually.", publicCommand.FullName()) + return nil + } + return publishGithubRelease(ctx, c, repoRootDir) }) func publishImages(c *cli.Command, repoRootDir string) error { @@ -174,15 +175,11 @@ func publishGithubRelease(ctx context.Context, c *cli.Command, repoRootDir strin if !c.Bool(createGithubReleaseFlag.Name) { return nil } - version := c.String(versionFlag.Name) - log := logrus.WithField("version", version) - prerelease, err := isPrereleaseEnterpriseVersion(repoRootDir) if err != nil { return fmt.Errorf("determining if version is prerelease: %w", err) } - r := &GithubRelease{ Org: ctx.Value(githubOrgCtxKey).(string), Repo: ctx.Value(githubRepoCtxKey).(string), @@ -191,13 +188,12 @@ func publishGithubRelease(ctx context.Context, c *cli.Command, repoRootDir strin if err := r.setupClient(ctx, c.String(githubTokenFlag.Name)); err != nil { return fmt.Errorf("setting up GitHub client: %s", err) } - // Create the GitHub release in draft mode. If it is a prerelease, mark it as such. - if release, err := r.Create(ctx, c.Bool(draftGithubReleaseFlag.Name), prerelease); errors.Is(err, ErrGitHubReleaseExists) { - log.Warnf("GitHub release already exists, update manually: %s", *release.HTMLURL) + if err := r.Create(ctx, c.Bool(draftGithubReleaseFlag.Name), prerelease); errors.Is(err, ErrGitHubReleaseExists) { + // Do not error out if the release already exists. + return nil } else if err != nil { return fmt.Errorf("publishing GitHub release: %s", err) } - return nil } From 7c2e59e0beefa25027cc502167fcd979b9e74f06 Mon Sep 17 00:00:00 2001 From: tuti Date: Tue, 16 Dec 2025 15:02:48 -0800 Subject: [PATCH 14/20] fix: update based on review and fix tests --- hack/release/build.go | 10 ++++++---- hack/release/flags.go | 2 +- hack/release/from.go | 4 ++-- hack/release/public.go | 9 +++++---- hack/release/publish.go | 11 +++++++---- hack/release/utils.go | 18 +++++++++++------- hack/release/utils_test.go | 10 +++++----- 7 files changed, 37 insertions(+), 27 deletions(-) diff --git a/hack/release/build.go b/hack/release/build.go index bbe0dc6526..2ccae24081 100644 --- a/hack/release/build.go +++ b/hack/release/build.go @@ -127,7 +127,9 @@ var buildBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (cont isHashrelease := c.Bool(hashreleaseFlag.Name) // If not a hashrelease build, ensure version format is valid - if valid, _ := isReleaseVersionFormat(c.String(versionFlag.Name)); !valid && !isHashrelease { + if valid, err := isReleaseVersionFormat(c.String(versionFlag.Name)); err != nil { + return ctx, fmt.Errorf("checking version format: %w", err) + } else if !valid && !isHashrelease { return ctx, fmt.Errorf("for non-release builds, the %s flag must be set", hashreleaseFlag.Name) } @@ -147,7 +149,7 @@ var buildBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (cont } if calicoBuildOk { if calicoBuildType == versionBuild && c.String(calicoCRDsDirFlag.Name) == "" { - return ctx, fmt.Errorf("Calico directory must be specified for hashrelease builds using calico-version flag") + return ctx, fmt.Errorf("directory to calico repo must be specified for hashreleases built from calico version using %s flag", calicoCRDsDirFlag.Name) } if c.String(calicoCRDsDirFlag.Name) == "" { logrus.Warn("Calico directory not specified for hashrelease build, getting CRDs from default location may not be appropriate") @@ -155,7 +157,7 @@ var buildBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (cont } if enterpriseBuildOk { if enterpriseBuildType == versionBuild && c.String(enterpriseCRDsDirFlag.Name) == "" { - return ctx, fmt.Errorf("Enterprise directory must be specified for hashrelease builds using enterprise version") + return ctx, fmt.Errorf("directory to enterprise repo must be specified for hashreleases built from enterprise version using %s flag", enterpriseCRDsDirFlag.Name) } if c.String(enterpriseCRDsDirFlag.Name) == "" { logrus.Warn("Enterprise directory not specified for hashrelease build, getting CRDs from default location may not be appropriate") @@ -342,7 +344,7 @@ func modifyComponentImageConfig(repoRootDir, configKey, newValue string) error { logrus.WithField("repoDir", repoRootDir).WithField(configKey, newValue).Infof("Updating %s in %s", desc, componentImageConfigRelPath) - if out, err := runCommandInDir(repoRootDir, "sed", []string{"-i", fmt.Sprintf(`s|%[1]s.*=.*".*"|%[1]s = "%[2]s"|`, configKey, regexp.QuoteMeta(newValue)), componentImageConfigRelPath}, nil); err != nil { + if out, err := runCommandInDir(repoRootDir, "sed", []string{"-i", fmt.Sprintf(`s|%[1]s.*=.*".*"|%[1]s = "%[2]s"|`, regexp.QuoteMeta(configKey), regexp.QuoteMeta(newValue)), componentImageConfigRelPath}, nil); err != nil { logrus.Error(out) return fmt.Errorf("failed to update %s in %s: %w", desc, componentImageConfigRelPath, err) } diff --git a/hack/release/flags.go b/hack/release/flags.go index f2080fdd3a..571b045685 100644 --- a/hack/release/flags.go +++ b/hack/release/flags.go @@ -224,7 +224,7 @@ func dirFlagCheck(_ context.Context, _ *cli.Command, path string) error { return nil } -// Flag Action to check value is a valid directory. +// Flag Action to check value is a valid file. func fileFlagCheck(_ context.Context, _ *cli.Command, path string) error { if path == "" { return nil diff --git a/hack/release/from.go b/hack/release/from.go index 4e9ee65abd..543b6f3398 100644 --- a/hack/release/from.go +++ b/hack/release/from.go @@ -333,11 +333,11 @@ func retrieveBaseVersionConfig(repo, baseVersion, repoRootDir string) error { for _, configFilePath := range []string{calicoConfig, enterpriseConfig} { localFilePath := fmt.Sprintf("%s/%s", repoRootDir, configFilePath) - url := strings.NewReplacer([]string{ + url := strings.NewReplacer( "{gitRepo}", repo, "{gitHashOrTag}", gitHashOrTag, "{filePath}", configFilePath, - }...).Replace(tmplGithubFileURL) + ).Replace(tmplGithubFileURL) logrus.WithFields(logrus.Fields{ "file": configFilePath, "localPath": localFilePath, diff --git a/hack/release/public.go b/hack/release/public.go index 6746e5227c..fe685ee041 100644 --- a/hack/release/public.go +++ b/hack/release/public.go @@ -65,10 +65,11 @@ var publicAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) erro if err != nil { return fmt.Errorf("getting repo root dir: %w", err) } - prerelease, err := isPrereleaseEnterpriseVersion(repoRootDir) + isPrerelease, err := isPrereleaseVersion(repoRootDir) if err != nil { - return fmt.Errorf("determining if version is prerelease: %w", err) + return fmt.Errorf("determining if this is a prerelease: %w", err) } + r := &GithubRelease{ Org: ctx.Value(githubOrgCtxKey).(string), Repo: ctx.Value(githubRepoCtxKey).(string), @@ -78,9 +79,9 @@ var publicAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) erro return fmt.Errorf("setting up GitHub client: %s", err) } - if err := r.Update(ctx, c.Bool(draftGithubReleasePublicFlag.Name), prerelease); err != nil && errors.Is(err, ErrNoGitHubReleaseExists) { + if err := r.Update(ctx, c.Bool(draftGithubReleasePublicFlag.Name), isPrerelease); err != nil && errors.Is(err, ErrNoGitHubReleaseExists) { // If the release does not exist, create it. - return r.Create(ctx, c.Bool(draftGithubReleasePublicFlag.Name), prerelease) + return r.Create(ctx, c.Bool(draftGithubReleasePublicFlag.Name), isPrerelease) } else if err != nil { return fmt.Errorf("updating GitHub release: %w", err) } diff --git a/hack/release/publish.go b/hack/release/publish.go index 7e40c7b86a..868e5c5b1b 100644 --- a/hack/release/publish.go +++ b/hack/release/publish.go @@ -81,7 +81,9 @@ var publishBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (co } // If not a hashrelease build, ensure version format is valid - if valid, _ := isReleaseVersionFormat(c.String(versionFlag.Name)); !valid && !c.Bool(hashreleaseFlag.Name) { + if valid, err := isReleaseVersionFormat(c.String(versionFlag.Name)); err != nil { + return ctx, fmt.Errorf("checking release version format: %w", err) + } else if !valid && !c.Bool(hashreleaseFlag.Name) { return ctx, fmt.Errorf("for non-release builds, the %s flag must be set", hashreleaseFlag.Name) } @@ -176,10 +178,11 @@ func publishGithubRelease(ctx context.Context, c *cli.Command, repoRootDir strin return nil } version := c.String(versionFlag.Name) - prerelease, err := isPrereleaseEnterpriseVersion(repoRootDir) + isPrerelease, err := isPrereleaseVersion(repoRootDir) if err != nil { - return fmt.Errorf("determining if version is prerelease: %w", err) + return fmt.Errorf("determining if this is a prerelease: %w", err) } + r := &GithubRelease{ Org: ctx.Value(githubOrgCtxKey).(string), Repo: ctx.Value(githubRepoCtxKey).(string), @@ -189,7 +192,7 @@ func publishGithubRelease(ctx context.Context, c *cli.Command, repoRootDir strin return fmt.Errorf("setting up GitHub client: %s", err) } // Create the GitHub release in draft mode. If it is a prerelease, mark it as such. - if err := r.Create(ctx, c.Bool(draftGithubReleaseFlag.Name), prerelease); errors.Is(err, ErrGitHubReleaseExists) { + if err := r.Create(ctx, c.Bool(draftGithubReleaseFlag.Name), isPrerelease); errors.Is(err, ErrGitHubReleaseExists) { // Do not error out if the release already exists. return nil } else if err != nil { diff --git a/hack/release/utils.go b/hack/release/utils.go index 663bbb90dc..0306d07dfd 100644 --- a/hack/release/utils.go +++ b/hack/release/utils.go @@ -208,24 +208,28 @@ func isEnterpriseReleaseVersionFormat(version string) (bool, error) { return releaseRegex.MatchString(version), nil } -// Check if the Enterprise version is a prerelease. -// First, it checks if the version matches the release format. -// If it does, it then checks if there is a prerelease segment in the version. -func isPrereleaseEnterpriseVersion(rootDir string) (bool, error) { +func isPrereleaseVersion(rootDir string) (bool, error) { enterpriseVer, err := calicoConfigVersions(rootDir, enterpriseConfig) if err != nil { return false, fmt.Errorf("retrieving Enterprise version: %s", err) } - release, err := isEnterpriseReleaseVersionFormat(enterpriseVer.Title) + return isPrereleaseEnterpriseVersion(enterpriseVer.Title) +} + +// Check if the Enterprise version is a prerelease version. +// First, it has to be in the release format, otherwise it returns false. +// Then it converts to semver and returns true if there is a prerelease component. +func isPrereleaseEnterpriseVersion(enterpriseVer string) (bool, error) { + release, err := isEnterpriseReleaseVersionFormat(enterpriseVer) if err != nil { return false, fmt.Errorf("checking Enterprise version format: %s", err) } if !release { return false, nil } - ver, err := semver.NewVersion(enterpriseVer.Title) + ver, err := semver.NewVersion(enterpriseVer) if err != nil { - return false, fmt.Errorf("parsing Enterprise version (%s): %s", enterpriseVer.Title, err) + return false, fmt.Errorf("parsing Enterprise version (%s): %s", enterpriseVer, err) } return ver.Prerelease() != "", nil } diff --git a/hack/release/utils_test.go b/hack/release/utils_test.go index 7d9cc745fc..4f8ea8acea 100644 --- a/hack/release/utils_test.go +++ b/hack/release/utils_test.go @@ -303,7 +303,7 @@ func TestIsPrereleaseEnterpriseVersion(t *testing.T) { }, { version: "v3.25.0", - want: true, + want: false, }, { version: "v3.25.0-rc1", @@ -320,13 +320,13 @@ func TestIsPrereleaseEnterpriseVersion(t *testing.T) { } for _, tc := range cases { t.Run(tc.version, func(t *testing.T) { - t.Parallel() - got, err := isEnterpriseReleaseVersionFormat(tc.version) + // t.Parallel() + got, err := isPrereleaseEnterpriseVersion(tc.version) if err != nil { - t.Fatalf("isEnterpriseReleaseVersionFormat(%q) unexpected error: %v", tc.version, err) + t.Fatalf("isPrereleaseEnterpriseVersion(%q) unexpected error: %v", tc.version, err) } if got != tc.want { - t.Fatalf("isEnterpriseReleaseVersionFormat(%q) = %v, want %v", tc.version, got, tc.want) + t.Fatalf("isPrereleaseEnterpriseVersion(%q) = %v, want %v", tc.version, got, tc.want) } }) } From a28a5204246a0a9b82d8ff5a73b5ac5ac48b798e Mon Sep 17 00:00:00 2001 From: tuti Date: Thu, 18 Dec 2025 09:56:45 -0800 Subject: [PATCH 15/20] ci: fix condition for running release tooling jobs --- .semaphore/semaphore.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.semaphore/semaphore.yml b/.semaphore/semaphore.yml index 8d8e842383..e3ccea8b70 100644 --- a/.semaphore/semaphore.yml +++ b/.semaphore/semaphore.yml @@ -154,7 +154,7 @@ blocks: dependencies: - Pre-commit verification run: - when: change_in('hack/release/') + when: change_in('/hack/release/') task: jobs: - name: "Run tests" From 771dc40a4015c5efdcbd2fba678625742e2f828a Mon Sep 17 00:00:00 2001 From: tuti Date: Mon, 29 Dec 2025 11:42:21 -0800 Subject: [PATCH 16/20] fix: check versions for release in tool before calling make also attempted to improve error description when running commands. --- hack/release/build.go | 19 +++++++++++-------- hack/release/checks.go | 35 ++++++++++++++++++++++++++++++++++- hack/release/flags.go | 15 +++++++++------ hack/release/publish.go | 38 +++++++++++++++++++++++++------------- hack/release/utils.go | 6 +++++- 5 files changed, 84 insertions(+), 29 deletions(-) diff --git a/hack/release/build.go b/hack/release/build.go index 2ccae24081..5639e04248 100644 --- a/hack/release/build.go +++ b/hack/release/build.go @@ -124,17 +124,14 @@ var buildBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (cont return ctx, err } - isHashrelease := c.Bool(hashreleaseFlag.Name) - - // If not a hashrelease build, ensure version format is valid - if valid, err := isReleaseVersionFormat(c.String(versionFlag.Name)); err != nil { - return ctx, fmt.Errorf("checking version format: %w", err) - } else if !valid && !isHashrelease { - return ctx, fmt.Errorf("for non-release builds, the %s flag must be set", hashreleaseFlag.Name) + // Ensure that provided version matches git version for release + ctx, err = checkVersionMatchesGitVersion(ctx, c) + if err != nil { + return ctx, err } // No further checks for release builds - if !isHashrelease { + if !c.Bool(hashreleaseFlag.Name) { return ctx, nil } @@ -177,6 +174,12 @@ var buildAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error version := c.String(versionFlag.Name) buildLog := logrus.WithField("version", version) + // Sanity check to ensure that provided version matches git version for release in case validations were skipped. + ctx, err = checkVersionMatchesGitVersion(ctx, c) + if err != nil { + return err + } + // Prepare build environment variables buildEnv := append(os.Environ(), fmt.Sprintf("VERSION=%s", version)) if arches := c.StringSlice(archFlag.Name); len(arches) > 0 { diff --git a/hack/release/checks.go b/hack/release/checks.go index af094a1fd3..3d6edff3e4 100644 --- a/hack/release/checks.go +++ b/hack/release/checks.go @@ -18,9 +18,16 @@ import ( "context" "fmt" "strings" + + "github.com/sirupsen/logrus" + "github.com/urfave/cli/v3" +) + +const ( + checkedVersionCtxKey contextKey = "checked-version" ) -// checkGitClean ensures that the git working tree is clean. +// check that the git working tree is clean. var checkGitClean = func(ctx context.Context) (context.Context, error) { version, err := gitVersion() if err != nil { @@ -31,3 +38,29 @@ var checkGitClean = func(ctx context.Context) (context.Context, error) { } return ctx, nil } + +// check that the provided version matches the git version. +// This is required for releases, but skipped for hashreleases. +var checkVersionMatchesGitVersion = func(ctx context.Context, c *cli.Command) (context.Context, error) { + if val, ok := ctx.Value(checkedVersionCtxKey).(bool); ok && val { + return ctx, nil + } + ctx = context.WithValue(ctx, checkedVersionCtxKey, true) + version := c.String(versionFlag.Name) + checkLog := logrus.WithField("version", version) + if c.Bool(hashreleaseFlag.Name) { + checkLog.Debug("Skipping version check for hashrelease") + return ctx, nil + } + gitVer, err := gitVersion() + if err != nil { + return ctx, fmt.Errorf("getting git version: %w", err) + } + checkLog.WithField("git-version", gitVer).Debug("Checking version matches git version") + checkLog.Info("Using versions") + if version != gitVer { + return ctx, fmt.Errorf("provided version %s does not match git version %s. "+ + "If building a hashrelease, use either the --%s flag or set %s environment variable to true", version, gitVer, hashreleaseFlag.Name, hashreleaseFlagEnvVar) + } + return ctx, nil +} diff --git a/hack/release/flags.go b/hack/release/flags.go index 571b045685..819d9700c2 100644 --- a/hack/release/flags.go +++ b/hack/release/flags.go @@ -391,12 +391,15 @@ var ( } ) -var hashreleaseFlag = &cli.BoolFlag{ - Name: "hashrelease", - Usage: "Indicates if this is a hashrelease", - Sources: cli.EnvVars("HASHRELEASE"), - Value: false, -} +var ( + hashreleaseFlagEnvVar = "HASHRELEASE" + hashreleaseFlag = &cli.BoolFlag{ + Name: "hashrelease", + Usage: "Indicates if this is a hashrelease", + Sources: cli.EnvVars(hashreleaseFlagEnvVar), + Value: false, + } +) var skipRepoCheckFlag = &cli.BoolFlag{ Name: "skip-repo-check", diff --git a/hack/release/publish.go b/hack/release/publish.go index 868e5c5b1b..34a70c9aa4 100644 --- a/hack/release/publish.go +++ b/hack/release/publish.go @@ -65,26 +65,26 @@ var publishBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (co return ctx, nil } + // Ensure that provided version matches git version for release builds + ctx, err = checkVersionMatchesGitVersion(ctx, c) + if err != nil { + return ctx, err + } + // If building a hashrelease, publishGithubRelease must be false if c.Bool(hashreleaseFlag.Name) && c.Bool(createGithubReleaseFlag.Name) { return ctx, fmt.Errorf("cannot publish GitHub release for hashrelease builds") } // If publishing a GitHub release, ideally it should be in draft mode with a token provided. - if c.Bool(createGithubReleaseFlag.Name) { - if !c.Bool(draftGithubReleaseFlag.Name) { - logrus.Warnf("Publishing GitHub release in non-draft mode.") - } - if c.String(githubTokenFlag.Name) == "" { - return ctx, fmt.Errorf("GitHub token must be provided via --%s flag or GITHUB_TOKEN environment variable", githubTokenFlag.Name) - } + if !c.Bool(createGithubReleaseFlag.Name) { + return ctx, nil } - - // If not a hashrelease build, ensure version format is valid - if valid, err := isReleaseVersionFormat(c.String(versionFlag.Name)); err != nil { - return ctx, fmt.Errorf("checking release version format: %w", err) - } else if !valid && !c.Bool(hashreleaseFlag.Name) { - return ctx, fmt.Errorf("for non-release builds, the %s flag must be set", hashreleaseFlag.Name) + if !c.Bool(draftGithubReleaseFlag.Name) { + logrus.Warnf("Publishing GitHub release in non-draft mode.") + } + if c.String(githubTokenFlag.Name) == "" { + return ctx, fmt.Errorf("GitHub token must be provided via --%s flag or GITHUB_TOKEN environment variable", githubTokenFlag.Name) } return ctx, nil @@ -95,12 +95,24 @@ var publishAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) err if err != nil { return fmt.Errorf("getting git directory: %w", err) } + + // Sanity check to ensure that provided version matches git version for release incase validations were skipped. + ctx, err = checkVersionMatchesGitVersion(ctx, c) + if err != nil { + return err + } + + // Publish images if err := publishImages(c, repoRootDir); err != nil { return err } + + // Only images are published for hashrelease builds. if c.Bool(hashreleaseFlag.Name) { return nil } + + // Publish GitHub release if requested if !c.Bool(createGithubReleaseFlag.Name) { logrus.Warnf("Skipping GitHub release creation. Either use %q to create a GitHub release or create manually.", publicCommand.FullName()) return nil diff --git a/hack/release/utils.go b/hack/release/utils.go index 0306d07dfd..58be1ebff3 100644 --- a/hack/release/utils.go +++ b/hack/release/utils.go @@ -119,7 +119,11 @@ func runCommandInDir(dir, name string, args, env []string) (string, error) { }).Debugf("Running %s command", name) err := cmd.Run() if err != nil { - err = fmt.Errorf("%s: %s", err, strings.TrimSpace(errb.String())) + errDesc := fmt.Sprintf(`running command "%s %s"`, name, strings.Join(args, " ")) + if dir != "" { + errDesc += fmt.Sprintf(" in directory %s", dir) + } + err = fmt.Errorf("%s: %w: %s", errDesc, err, strings.TrimSpace(errb.String())) } return strings.TrimSpace(outb.String()), err } From ab35db011c15121e581f95d638d436f1fe8a380d Mon Sep 17 00:00:00 2001 From: tuti Date: Mon, 29 Dec 2025 12:03:01 -0800 Subject: [PATCH 17/20] docs: improved comments for build & publish related fns --- hack/release/build.go | 10 ++++++++-- hack/release/publish.go | 5 +++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/hack/release/build.go b/hack/release/build.go index 5639e04248..9cfa3e74e9 100644 --- a/hack/release/build.go +++ b/hack/release/build.go @@ -203,7 +203,9 @@ var buildAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error if c.Bool(hashreleaseFlag.Name) { buildLog = buildLog.WithField("hashrelease", true) buildEnv = append(buildEnv, fmt.Sprintf("GIT_VERSION=%s", c.String(versionFlag.Name))) - resetFn, err := hashreleaseBuildConfig(ctx, c, repoRootDir) + resetFn, err := setupHashreleaseBuild(ctx, c, repoRootDir) + // Ensure git state is reset after build. + // If there was an error preparing the build, reset any partial changes first before returning the error. defer resetFn() if err != nil { return fmt.Errorf("preparing hashrelease build environment: %w", err) @@ -226,6 +228,7 @@ var buildAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error return nil }) +// List images in the built operator image for debugging purposes. func listImages(registry, image, version string) { fqImage := fmt.Sprintf("%s:%s-%s", path.Join(registry, image), version, runtime.GOARCH) out, err := runCommand("docker", []string{"run", "--rm", fqImage, "--print-images", "list"}, nil) @@ -237,6 +240,7 @@ func listImages(registry, image, version string) { logrus.Debug(out) } +// Verify that the built operator image contains the expected version. func assertOperatorImageVersion(registry, image, expectedVersion string) error { fqImage := fmt.Sprintf("%s:%s-%s", path.Join(registry, image), expectedVersion, runtime.GOARCH) out, err := runCommand("docker", []string{"run", "--rm", fqImage, "--version"}, nil) @@ -259,7 +263,9 @@ func assertOperatorImageVersion(registry, image, expectedVersion string) error { return nil } -func hashreleaseBuildConfig(ctx context.Context, c *cli.Command, repoRootDir string) (func(), error) { +// Modify component images config and versions for hashrelease builds as needed. +// Returns a function to reset the git state and any error encountered. +func setupHashreleaseBuild(ctx context.Context, c *cli.Command, repoRootDir string) (func(), error) { repoReset := func() { if out, err := gitInDir(repoRootDir, append([]string{"checkout", "-f"}, changedFiles...)...); err != nil { logrus.WithError(err).Errorf("resetting git state: %s", out) diff --git a/hack/release/publish.go b/hack/release/publish.go index 34a70c9aa4..a42ce9cce9 100644 --- a/hack/release/publish.go +++ b/hack/release/publish.go @@ -90,6 +90,7 @@ var publishBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (co return ctx, nil }) +// Action for publish command. var publishAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error { repoRootDir, err := gitDir() if err != nil { @@ -120,6 +121,8 @@ var publishAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) err return publishGithubRelease(ctx, c, repoRootDir) }) +// Publish the operator images to the specified registry. +// If the images are already published, it skips publishing. func publishImages(c *cli.Command, repoRootDir string) error { version := c.String(versionFlag.Name) log := logrus.WithField("version", version) @@ -167,6 +170,7 @@ func publishImages(c *cli.Command, repoRootDir string) error { return nil } +// Check if the operator image is already published. func operatorImagePublished(c *cli.Command) (bool, error) { registry := c.String(registryFlag.Name) if registry == "" { @@ -185,6 +189,7 @@ func operatorImagePublished(c *cli.Command) (bool, error) { return true, nil } +// Publish a GitHub release for the operator if requested. func publishGithubRelease(ctx context.Context, c *cli.Command, repoRootDir string) error { if !c.Bool(createGithubReleaseFlag.Name) { return nil From a1b07b7fda27a85d8bf87600728e38582254542d Mon Sep 17 00:00:00 2001 From: tuti Date: Mon, 29 Dec 2025 12:14:36 -0800 Subject: [PATCH 18/20] chore: update go mod --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 900b1baabb..cc246b99f9 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/tigera/operator go 1.25.3 require ( + github.com/Masterminds/semver/v3 v3.4.0 github.com/aws/aws-sdk-go v1.55.5 github.com/blang/semver/v4 v4.0.0 github.com/cloudflare/cfssl v1.6.5 @@ -60,7 +61,6 @@ require ( github.com/BurntSushi/toml v1.5.0 // indirect github.com/MakeNowJust/heredoc v1.0.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect From 29079b1b37c9123e3be4c811458d13299bd8ca48 Mon Sep 17 00:00:00 2001 From: tuti Date: Mon, 29 Dec 2025 12:19:44 -0800 Subject: [PATCH 19/20] style: update formatting for logging --- hack/release/logger.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/hack/release/logger.go b/hack/release/logger.go index aa39f4412a..c02a4079fd 100644 --- a/hack/release/logger.go +++ b/hack/release/logger.go @@ -40,6 +40,10 @@ func configureLogging(c *cli.Command) { logrus.SetFormatter(&logrus.TextFormatter{ DisableLevelTruncation: true, CallerPrettyfier: logPrettifier, + ForceColors: true, + PadLevelText: true, + DisableQuote: true, + DisableSorting: true, }) filename := strings.ReplaceAll(c.FullName(), " ", "-") + ".log" @@ -54,6 +58,7 @@ func configureLogging(c *cli.Command) { DisableColors: true, DisableLevelTruncation: true, CallerPrettyfier: logPrettifier, + DisableSorting: true, }, }) if err != nil { From 90245a141b4c5511649f532d7a771c5bd5acac00 Mon Sep 17 00:00:00 2001 From: tuti Date: Mon, 29 Dec 2025 12:21:41 -0800 Subject: [PATCH 20/20] style: update error messages --- hack/release/checks.go | 4 ++-- hack/release/utils.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hack/release/checks.go b/hack/release/checks.go index 3d6edff3e4..0515df3095 100644 --- a/hack/release/checks.go +++ b/hack/release/checks.go @@ -59,8 +59,8 @@ var checkVersionMatchesGitVersion = func(ctx context.Context, c *cli.Command) (c checkLog.WithField("git-version", gitVer).Debug("Checking version matches git version") checkLog.Info("Using versions") if version != gitVer { - return ctx, fmt.Errorf("provided version %s does not match git version %s. "+ - "If building a hashrelease, use either the --%s flag or set %s environment variable to true", version, gitVer, hashreleaseFlag.Name, hashreleaseFlagEnvVar) + return ctx, fmt.Errorf("provided version %s does not match git version %s. This is required for releases. \n"+ + "If building a hashrelease, use either the --%s flag or set environment variable %s=true", version, gitVer, hashreleaseFlag.Name, hashreleaseFlagEnvVar) } return ctx, nil } diff --git a/hack/release/utils.go b/hack/release/utils.go index 58be1ebff3..431dfe8daa 100644 --- a/hack/release/utils.go +++ b/hack/release/utils.go @@ -123,7 +123,7 @@ func runCommandInDir(dir, name string, args, env []string) (string, error) { if dir != "" { errDesc += fmt.Sprintf(" in directory %s", dir) } - err = fmt.Errorf("%s: %w: %s", errDesc, err, strings.TrimSpace(errb.String())) + err = fmt.Errorf("%s: %w \n%s", errDesc, err, strings.TrimSpace(errb.String())) } return strings.TrimSpace(outb.String()), err }