diff --git a/Makefile b/Makefile index 185d7f1ad8..38ec6ba582 100644 --- a/Makefile +++ b/Makefile @@ -559,7 +559,7 @@ endif ## Create a release for the specified RELEASE_TAG. release-tag: var-require-all-RELEASE_TAG-GITHUB_TOKEN $(MAKE) release VERSION=$(RELEASE_TAG) - REPO=$(REPO) CREATE_GITHUB_RELEASE=true $(MAKE) release-publish VERSION=$(RELEASE_TAG) + REPO=$(REPO) $(MAKE) release-publish VERSION=$(RELEASE_TAG) ## Generate release notes for the specified VERSION. release-notes: hack/bin/release var-require-all-VERSION-GITHUB_TOKEN diff --git a/hack/release/build.go b/hack/release/build.go index 1057b5ee7f..0010129c2f 100644 --- a/hack/release/build.go +++ b/hack/release/build.go @@ -16,6 +16,7 @@ package main import ( "context" + "errors" "fmt" "os" "path" @@ -78,22 +79,37 @@ var buildCommand = &cli.Command{ calicoImagePathFlag, calicoVersionsConfigFlag, calicoDirFlag, + calicoGitRepoFlag, + calicoGitBranchFlag, enterpriseVersionFlag, enterpriseRegistryFlag, enterpriseImagePathFlag, enterpriseVersionsConfigFlag, enterpriseDirFlag, + enterpriseGitRepoFlag, + enterpriseGitBranchFlag, hashreleaseFlag, skipValidationFlag, + extensionTimeoutFlag, }, Before: buildBefore, Action: buildAction, + After: buildAfter, } +// buildCleanupFns collects cleanup functions to run after the build completes (e.g., git reset, temp dir removal). +// Functions are run in reverse order (LIFO) and all errors are collected. +var buildCleanupFns []func(ctx context.Context) error + // Pre-action for release build command. var buildBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (context.Context, error) { configureLogging(c) + // Start with a clean slate for build cleanup functions. + buildCleanupFns = nil + + var err error + // Determine build types for Calico and Enterprise if ver := c.String(calicoVersionsConfigFlag.Name); ver != "" { ctx = context.WithValue(ctx, calicoBuildCtxKey, versionsBuild) @@ -112,8 +128,8 @@ var buildBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (cont logrus.Debug("Enterprise build using specific version selected") } - // Run verison validations. This is a mandatory check. - ctx, err := checkVersion(ctx, c) + // Run version validations. This is a mandatory check. + ctx, err = checkVersion(ctx, c) if err != nil { return ctx, err } @@ -136,8 +152,10 @@ var buildBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (cont } // 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 version build is selected, setup the dir for CRDs either by: + // - using the provided dir for CRDs if specified, or + // - cloning the corresponding repo at the git hash for the specific version and using the CRDs from there. + // // 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) @@ -145,16 +163,34 @@ var buildBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (cont 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(calicoDirFlag.Name) == "" { - return ctx, fmt.Errorf("directory to calico repo must be specified for hashreleases built from calico version using %s flag", calicoDirFlag.Name) + if calicoBuildType == versionBuild { + repo := hashreleaseRepo{ + Product: "calico", + DirFlag: calicoDirFlag, + RepoFlag: calicoGitRepoFlag, + BranchFlag: calicoGitBranchFlag, + VersionFlag: calicoVersionFlag, + } + if err := repo.Setup(c); err != nil { + return ctx, fmt.Errorf("setting up Calico repo for hashrelease: %w", err) + } } if c.String(calicoDirFlag.Name) == "" { logrus.Warn("Calico directory not specified for hashrelease build, getting CRDs from default location may not be appropriate") } } if enterpriseBuildOk { - if enterpriseBuildType == versionBuild && c.String(enterpriseDirFlag.Name) == "" { - return ctx, fmt.Errorf("directory to enterprise repo must be specified for hashreleases built from enterprise version using %s flag", enterpriseDirFlag.Name) + if enterpriseBuildType == versionBuild { + repo := hashreleaseRepo{ + Product: "enterprise", + DirFlag: enterpriseDirFlag, + RepoFlag: enterpriseGitRepoFlag, + BranchFlag: enterpriseGitBranchFlag, + VersionFlag: enterpriseVersionFlag, + } + if err := repo.Setup(c); err != nil { + return ctx, fmt.Errorf("setting up Enterprise repo for hashrelease: %w", err) + } } if c.String(enterpriseDirFlag.Name) == "" { logrus.Warn("Enterprise directory not specified for hashrelease build, getting CRDs from default location may not be appropriate") @@ -174,6 +210,16 @@ var buildAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error version := c.String(versionFlag.Name) buildLog := logrus.WithField("version", version) + // For hashrelease builds, skip if image is already published. + if c.Bool(hashreleaseFlag.Name) { + if published, err := operatorImagePublished(c); err != nil { + buildLog.WithError(err).Warn("Failed to check if image is already published, proceeding with build") + } else if published { + buildLog.Warn("Image is already published, skipping build") + return nil + } + } + // Prepare build environment variables buildEnv := append(os.Environ(), fmt.Sprintf("VERSION=%s", version)) if arches := c.StringSlice(archFlag.Name); len(arches) > 0 { @@ -197,11 +243,14 @@ 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 := 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 { + buildCleanupFns = append(buildCleanupFns, func(ctx context.Context) error { + if out, err := gitInDir(repoRootDir, append([]string{"checkout", "-f"}, changedFiles...)...); err != nil { + logrus.Error(out) + return fmt.Errorf("resetting git state in repo after hashrelease build: %w", err) + } + return nil + }) + if err := setupHashreleaseBuild(ctx, c, repoRootDir); err != nil { return fmt.Errorf("preparing hashrelease build environment: %w", err) } } else { @@ -222,6 +271,32 @@ var buildAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error return nil }) +// runBuildCleanup runs all registered cleanup functions in reverse order (LIFO), +// logging each failure individually. It returns the joined errors and resets the slice. +func runBuildCleanup(ctx context.Context) error { + var errs []error + for i := len(buildCleanupFns) - 1; i >= 0; i-- { + if err := buildCleanupFns[i](ctx); err != nil { + logrus.WithError(err).Error("Build cleanup failed") + errs = append(errs, err) + } + } + buildCleanupFns = nil + return errors.Join(errs...) +} + +// buildAfter runs all registered cleanup functions after the build completes. +// Cleanup errors are logged but intentionally not returned to the CLI framework +// as the build result (success or failure) is what matters. +var buildAfter = cli.AfterFunc(func(ctx context.Context, c *cli.Command) error { + cleanupCtx, cancel := context.WithTimeout(ctx, c.Duration(extensionTimeoutFlag.Name)) + defer cancel() + if err := runBuildCleanup(cleanupCtx); err != nil { + logrus.WithError(err).Error("One or more build cleanup functions failed") + } + 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) @@ -257,45 +332,40 @@ func assertOperatorImageVersion(registry, image, expectedVersion string) error { return nil } -// 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) - } - } +// setupHashreleaseBuild modifies component image config and versions for hashrelease builds. +// It registers a cleanup function to reset git state after the build completes. +var setupHashreleaseBuild = func(ctx context.Context, c *cli.Command, repoRootDir string) error { image := c.String(imageFlag.Name) if image != defaultImageName { imageParts := strings.SplitN(c.String(imageFlag.Name), "/", 2) - if err := modifyComponentImageConfig(repoRootDir, operatorImagePathConfigKey, addTrailingSlash(imageParts[0])); err != nil { - return repoReset, fmt.Errorf("updating Operator image path: %w", err) + if err := modifyComponentImageConfig(repoRootDir, componentImageConfigRelPath, operatorImagePathConfigKey, addTrailingSlash(imageParts[0])); err != nil { + return fmt.Errorf("updating Operator image path: %w", err) } } registry := c.String(registryFlag.Name) if registry != "" && registry != quayRegistry { - if err := modifyComponentImageConfig(repoRootDir, operatorRegistryConfigKey, addTrailingSlash(registry)); err != nil { - return repoReset, fmt.Errorf("updating Operator registry: %w", err) + if err := modifyComponentImageConfig(repoRootDir, componentImageConfigRelPath, operatorRegistryConfigKey, addTrailingSlash(registry)); err != nil { + return fmt.Errorf("updating Operator registry: %w", err) } } if registry := c.String(calicoRegistryFlag.Name); registry != "" { - if err := modifyComponentImageConfig(repoRootDir, calicoRegistryConfigKey, addTrailingSlash(registry)); err != nil { - return repoReset, fmt.Errorf("updating Calico registry: %w", err) + if err := modifyComponentImageConfig(repoRootDir, componentImageConfigRelPath, calicoRegistryConfigKey, addTrailingSlash(registry)); err != nil { + return fmt.Errorf("updating Calico registry: %w", err) } } if imagePath := c.String(calicoImagePathFlag.Name); imagePath != "" { - if err := modifyComponentImageConfig(repoRootDir, calicoImagePathConfigKey, imagePath); err != nil { - return repoReset, fmt.Errorf("updating Calico image path: %w", err) + if err := modifyComponentImageConfig(repoRootDir, componentImageConfigRelPath, calicoImagePathConfigKey, imagePath); err != nil { + return 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 repoReset, fmt.Errorf("updating Enterprise registry: %w", err) + if err := modifyComponentImageConfig(repoRootDir, componentImageConfigRelPath, enterpriseRegistryConfigKey, addTrailingSlash(registry)); err != nil { + return fmt.Errorf("updating Enterprise registry: %w", err) } } if imagePath := c.String(enterpriseImagePathFlag.Name); imagePath != "" { - if err := modifyComponentImageConfig(repoRootDir, enterpriseImagePathConfigKey, imagePath); err != nil { - return repoReset, fmt.Errorf("updating Enterprise image path: %w", err) + if err := modifyComponentImageConfig(repoRootDir, componentImageConfigRelPath, enterpriseImagePathConfigKey, imagePath); err != nil { + return fmt.Errorf("updating Enterprise image path: %w", err) } } @@ -313,7 +383,7 @@ func setupHashreleaseBuild(ctx context.Context, c *cli.Command, repoRootDir stri switch bt { case versionBuild: if err := updateConfigVersions(repoRootDir, calicoConfig, c.String(calicoVersionFlag.Name)); err != nil { - return repoReset, fmt.Errorf("updating Calico config versions: %w", err) + return fmt.Errorf("updating Calico config versions: %w", err) } case versionsBuild: genEnv = append(genEnv, fmt.Sprintf("OS_VERSIONS=%s", c.String(calicoVersionsConfigFlag.Name))) @@ -324,7 +394,7 @@ func setupHashreleaseBuild(ctx context.Context, c *cli.Command, repoRootDir stri switch bt { case versionBuild: if err := updateConfigVersions(repoRootDir, enterpriseConfig, c.String(enterpriseVersionFlag.Name)); err != nil { - return repoReset, fmt.Errorf("updating Enterprise config versions: %w", err) + return fmt.Errorf("updating Enterprise config versions: %w", err) } case versionsBuild: genEnv = append(genEnv, fmt.Sprintf("EE_VERSIONS=%s", c.String(enterpriseVersionsConfigFlag.Name))) @@ -332,24 +402,132 @@ func setupHashreleaseBuild(ctx context.Context, c *cli.Command, repoRootDir stri } if out, err := makeInDir(repoRootDir, strings.Join(genMakeTargets, " "), genEnv...); err != nil { logrus.Error(out) - return repoReset, fmt.Errorf("generating versions: %w", err) + return fmt.Errorf("generating versions: %w", err) } - return repoReset, nil + return nil } -// Modify variables in pkg/components/images.go -func modifyComponentImageConfig(repoRootDir, configKey, newValue string) error { +// Modify variables in the specified component image config file. +func modifyComponentImageConfig(repoRootDir, imageConfigRelPath, 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) } - logrus.WithField("repoDir", repoRootDir).WithField(configKey, newValue).Infof("Updating %s in %s", desc, componentImageConfigRelPath) + logrus.WithField("repoDir", repoRootDir).WithField(configKey, newValue).Infof("Updating %s in %s", desc, imageConfigRelPath) - 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 { + if out, err := runCommandInDir(repoRootDir, "sed", []string{"-i", fmt.Sprintf(`s|%[1]s.*=.*".*"|%[1]s = "%[2]s"|`, regexp.QuoteMeta(configKey), regexp.QuoteMeta(newValue)), imageConfigRelPath}, nil); err != nil { logrus.Error(out) - return fmt.Errorf("failed to update %s in %s: %w", desc, componentImageConfigRelPath, err) + return fmt.Errorf("failed to update %s in %s: %w", desc, imageConfigRelPath, err) + } + return nil +} + +// extractGitHashFromVersion extracts the git hash from a version string. +// The version format is not strict, so long as it ends with g<12-char-hash>. +func extractGitHashFromVersion(version string) (string, error) { + if strings.HasSuffix(version, "-dirty") { + return "", fmt.Errorf("version %s indicates a dirty git state, cannot extract git hash", version) + } + re, err := regexp.Compile(`g([a-f0-9]{12})?$`) + if err != nil { + return "", fmt.Errorf("compiling git hash regex: %w", err) + } + matches := re.FindStringSubmatch(version) + if len(matches) < 2 { + return "", fmt.Errorf("no git hash found in version %s", version) + } + return matches[1], nil +} + +type hashreleaseRepo struct { + Product string + RepoFlag *cli.StringFlag + BranchFlag *cli.StringFlag + VersionFlag *cli.StringFlag + DirFlag *cli.StringFlag + repo string + branch string + version string +} + +func (r *hashreleaseRepo) Setup(c *cli.Command) error { + if dir := c.String(r.DirFlag.Name); dir != "" { + logrus.WithField("dir", dir).Infof("%s directory provided, skipping clone", r.Product) + return nil + } + r.repo = c.String(r.RepoFlag.Name) + r.version = c.String(r.VersionFlag.Name) + r.branch = c.String(r.BranchFlag.Name) + var errStack error + if r.branch == "" { + errStack = errors.Join(errStack, fmt.Errorf("%s git branch not provided. Either set the %s dir or provide a branch", r.Product, r.Product)) + } + if r.version == "" { + errStack = errors.Join(errStack, fmt.Errorf("%s version not provided. Either set the %s dir or provide a version", r.Product, r.Product)) + } + if errStack != nil { + return errStack + } + dir, err := r.clone() + if err != nil { + return fmt.Errorf("cloning %s repo: %w", r.Product, err) + } + if err := c.Set(r.DirFlag.Name, dir); err != nil { + return fmt.Errorf("setting %s dir flag: %w", r.Product, err) } return nil } + +// cloneHashreleaseRepo clones the repo at the git hash that corresponds to the hashrelease version. +func (r *hashreleaseRepo) clone() (string, error) { + // Validate repo format (owner/repo) + repoPattern, err := regexp.Compile(`^[\w-]+/[\w.-]+$`) + if err != nil { + return "", fmt.Errorf("compiling repo name regex: %w", err) + } + if !repoPattern.MatchString(r.repo) { + return "", fmt.Errorf("invalid repo format %s, expected format owner/repo", r.repo) + } + + // Extract git hash from version to know which commit we need. + gitHash, err := extractGitHashFromVersion(r.version) + if err != nil { + return "", fmt.Errorf("extracting git hash from version: %w", err) + } + if gitHash == "" { + return "", fmt.Errorf("no git hash found in version %s", r.version) + } + + // Create a temp directory for cloning the repo. Cleaned up by buildCleanupFns. + repoTmpDir, err := os.MkdirTemp("", r.Product+"-*") + if err != nil { + return "", fmt.Errorf("creating temp directory for %s repo: %w", r.Product, err) + } + buildCleanupFns = append(buildCleanupFns, func(ctx context.Context) error { + if err := os.RemoveAll(repoTmpDir); err != nil { + return fmt.Errorf("removing temp directory %s for %s repo: %w", repoTmpDir, r.Product, err) + } + return nil + }) + remote := "origin" + logrus.WithFields(logrus.Fields{ + "version": r.version, + "gitHash": gitHash, + "remote": remote, + "dir": repoTmpDir, + }).Infof("Cloning %s repo at git hash", r.Product) + + // Create a treeless clone that gives access to the commit history without downloading all the blobs. + if _, err := git("clone", "--filter=tree:0", "--no-checkout", "-b", r.branch, fmt.Sprintf("git@github.com:%s.git", r.repo), repoTmpDir); err != nil { + return "", fmt.Errorf("cloning %s git repo intotemp dir: %w", r.Product, err) + } + + // Detached checkout of the commit we want; this will automatically fetch whatever blobs we need + if _, err := gitInDir(repoTmpDir, "switch", "--detach", gitHash); err != nil { + return "", fmt.Errorf("switching %s repo to detached commit %s: %w", r.Product, gitHash, err) + } + logrus.WithField("dir", repoTmpDir).Debugf("Successfully cloned %s repo", r.Product) + return repoTmpDir, nil +} diff --git a/hack/release/build_test.go b/hack/release/build_test.go new file mode 100644 index 0000000000..802672bebf --- /dev/null +++ b/hack/release/build_test.go @@ -0,0 +1,157 @@ +// Copyright (c) 2026 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" + "slices" + "strings" + "testing" +) + +func TestExtractGitHashFromVersion(t *testing.T) { + t.Parallel() + + cases := []struct { + version string + want string + wantErr bool + }{ + { + version: "v3.22.1", + wantErr: true, + }, + { + version: "v3.22.0-1.0", + wantErr: true, + }, + { + version: "v3.22.0-gshorthash", + wantErr: true, + }, + { + version: "v3.22.0-glonghashthatisnotvalid", + wantErr: true, + }, + { + version: "v3.22.1-25-g997f6be93484-extra", + wantErr: true, + }, + { + version: "v3.22.1-25-g997f6be93484-dirty", + wantErr: true, + }, + { + version: "v3.22.1-948-g997f6be93484", + want: "997f6be93484", + }, + { + version: "v3.22.1-calient-0.dev-948-g1234567890ab", + want: "1234567890ab", + }, + { + version: "v3.23.0-2.0-calient-0.dev-948-gabcdef123456", + want: "abcdef123456", + }, + } + + for _, tc := range cases { + t.Run(tc.version, func(t *testing.T) { + t.Parallel() + got, err := extractGitHashFromVersion(tc.version) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got none") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("extractGitHashFromVersion(%q) = %q, want %q", tc.version, got, tc.want) + } + }) + } +} + +// Tests below must NOT be parallel since they mutate package-level vars. + +func TestRunBuildCleanup(t *testing.T) { + t.Run("LIFO order and error collection", func(t *testing.T) { + buildCleanupFns = nil + defer func() { buildCleanupFns = nil }() + + var order []int + buildCleanupFns = append(buildCleanupFns, func(ctx context.Context) error { + order = append(order, 1) + return errors.New("cleanup-1 failed") + }) + buildCleanupFns = append(buildCleanupFns, func(ctx context.Context) error { + order = append(order, 2) + return nil + }) + buildCleanupFns = append(buildCleanupFns, func(ctx context.Context) error { + order = append(order, 3) + return errors.New("cleanup-3 failed") + }) + + err := runBuildCleanup(context.Background()) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "cleanup-1 failed") { + t.Fatalf("missing cleanup-1 error: %v", err) + } + if !strings.Contains(err.Error(), "cleanup-3 failed") { + t.Fatalf("missing cleanup-3 error: %v", err) + } + if !slices.Equal(order, []int{3, 2, 1}) { + t.Fatalf("expected LIFO order [3, 2, 1], got %v", order) + } + if buildCleanupFns != nil { + t.Fatal("expected buildCleanupFns to be nil after cleanup") + } + }) + + t.Run("empty slice is no-op", func(t *testing.T) { + buildCleanupFns = nil + if err := runBuildCleanup(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("propagates context cancellation", func(t *testing.T) { + buildCleanupFns = nil + defer func() { buildCleanupFns = nil }() + + buildCleanupFns = append(buildCleanupFns, func(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + return nil + } + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := runBuildCleanup(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got: %v", err) + } + }) +} diff --git a/hack/release/checks.go b/hack/release/checks.go index 264a330735..fbb5bd8dd1 100644 --- a/hack/release/checks.go +++ b/hack/release/checks.go @@ -65,7 +65,7 @@ var checkVersionFormat = func(ctx context.Context, c *cli.Command) (context.Cont return ctx, nil } checkLog.Debug("Checking version format") - if isRelease, err := isReleaseVersionFormat(version); err != nil { + if isRelease, err := isValidReleaseVersion(version); err != nil { return ctx, fmt.Errorf("checking version format: %w", err) } else if !isRelease { return ctx, fmt.Errorf("provided version %q is not a valid release version. \n"+ diff --git a/hack/release/flags.go b/hack/release/flags.go index 86cf160e3c..eeb414ebb3 100644 --- a/hack/release/flags.go +++ b/hack/release/flags.go @@ -23,6 +23,7 @@ import ( "regexp" "slices" "strings" + "time" "github.com/urfave/cli/v3" ) @@ -33,6 +34,13 @@ var debugFlag = &cli.BoolFlag{ Sources: cli.EnvVars("DEBUG"), } +var extensionTimeoutFlag = &cli.DurationFlag{ + Name: "timeout", + Usage: "Timeout duration for extension execution", + Sources: cli.EnvVars("EXTENSION_TIMEOUT"), + Value: 5 * time.Minute, +} + // Git/GitHub related flags. var ( githubFlagCategory = "GitHub Options" @@ -73,7 +81,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") @@ -121,7 +129,7 @@ var ( // No need to validate version for hashrelease return nil } - if valid, err := isReleaseVersionFormat(s); err != nil { + if valid, err := isValidReleaseVersion(s); err != nil { return fmt.Errorf("error validating version format: %w", err) } else if !valid { return fmt.Errorf("version %q is not a valid release version", s) @@ -314,6 +322,19 @@ var ( Sources: cli.EnvVars("CALICO_DIR"), Action: dirFlagCheck, } + calicoGitRepoFlag = &cli.StringFlag{ + Name: "calico-repo", + Category: calicoFlagCategory, + Usage: "The git repository to clone Calico from. Used when no Calico dir for CRDs is provided (development and testing purposes only)", + Sources: cli.EnvVars("CALICO_REPO"), + Value: "projectcalico/calico", + } + calicoGitBranchFlag = &cli.StringFlag{ + Name: "calico-branch", + Category: calicoFlagCategory, + Usage: "The git branch to clone Calico from. Used when no Calico dir for CRDs is provided (development and testing purposes only)", + Sources: cli.EnvVars("CALICO_BRANCH"), + } ) // Enterprise related flags. @@ -377,6 +398,19 @@ var ( Sources: cli.EnvVars("ENTERPRISE_DIR"), Action: dirFlagCheck, } + enterpriseGitRepoFlag = &cli.StringFlag{ + Name: "enterprise-repo", + Category: enterpriseFlagCategory, + Usage: "The git repository to clone Enterprise from. Used when no Enterprise dir for CRDs is provided (development and testing purposes only)", + Sources: cli.EnvVars("ENTERPRISE_REPO"), + Value: "tigera/calico-private", + } + enterpriseGitBranchFlag = &cli.StringFlag{ + Name: "enterprise-branch", + Category: enterpriseFlagCategory, + Usage: "The git branch to clone Enterprise from. Use in place of specifying the Enterprise dir for CRDs (development and testing purposes only)", + Sources: cli.EnvVars("ENTERPRISE_BRANCH", "CALICO_ENTERPRISE_BRANCH"), + } exceptEnterpriseFlag = &cli.StringSliceFlag{ Name: "except-calico-enterprise", Category: enterpriseFlagCategory, diff --git a/hack/release/prep.go b/hack/release/prep.go index c7da7407af..aff3f5920a 100644 --- a/hack/release/prep.go +++ b/hack/release/prep.go @@ -173,7 +173,7 @@ var prepAction = cli.ActionFunc(func(ctx context.Context, c *cli.Command) error // Update registry for Enterprise if eRegistry := c.String(enterpriseRegistryFlag.Name); eRegistry != "" { logrus.Debugf("Updating Enterprise registry to %s", eRegistry) - if err := modifyComponentImageConfig(repoRootDir, enterpriseRegistryConfigKey, eRegistry); err != nil { + if err := modifyComponentImageConfig(repoRootDir, componentImageConfigRelPath, enterpriseRegistryConfigKey, eRegistry); err != nil { return err } } diff --git a/hack/release/publish.go b/hack/release/publish.go index f36a4654e2..97594d890e 100644 --- a/hack/release/publish.go +++ b/hack/release/publish.go @@ -54,12 +54,13 @@ var publishBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (co configureLogging(c) var err error + ctx, err = addRepoInfoToCtx(ctx, c.String(gitRepoFlag.Name)) if err != nil { return ctx, err } - // Run verison validations. This is a mandatory check. + // Run version validations. This is a mandatory check. ctx, err = checkVersion(ctx, c) if err != nil { return ctx, err @@ -71,12 +72,7 @@ var publishBefore = cli.BeforeFunc(func(ctx context.Context, c *cli.Command) (co 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 !c.Bool(createGithubReleaseFlag.Name) { + if c.Bool(hashreleaseFlag.Name) || !c.Bool(createGithubReleaseFlag.Name) { return ctx, nil } @@ -116,9 +112,9 @@ 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. +// publishImages publishes the operator images to the specified registry. // If the images are already published, it skips publishing. -func publishImages(c *cli.Command, repoRootDir string) error { +var publishImages = func(c *cli.Command, repoRootDir string) error { version := c.String(versionFlag.Name) log := logrus.WithField("version", version) // Check if images are already published diff --git a/hack/release/utils.go b/hack/release/utils.go index 8a8c83c2fb..7d2daf235d 100644 --- a/hack/release/utils.go +++ b/hack/release/utils.go @@ -198,6 +198,10 @@ func calicoVersions(repo, rootDir, operatorVersion string, local bool) (map[stri return versions, nil } +// isValidReleaseVersion validates the operator release version format. +// It defaults to standard release format (vX.Y.Z) but can be overridden if a different format is needed. +var isValidReleaseVersion = isReleaseVersionFormat + // isReleaseVersionFormat checks if the version in the format vX.Y.Z. func isReleaseVersionFormat(version string) (bool, error) { releaseRegex, err := regexp.Compile(releaseFormat) diff --git a/hack/release/utils_test.go b/hack/release/utils_test.go index 0fd666db27..e0843a715b 100644 --- a/hack/release/utils_test.go +++ b/hack/release/utils_test.go @@ -282,6 +282,35 @@ func TestAddTrailingSlash(t *testing.T) { } } +func TestIsValidReleaseVersionOverride(t *testing.T) { + orig := isValidReleaseVersion + defer func() { isValidReleaseVersion = orig }() + + t.Run("set to isReleaseVersionFormat", func(t *testing.T) { + isValidReleaseVersion = isReleaseVersionFormat + ok, err := isValidReleaseVersion("v1.2.3") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected v1.2.3 to be valid with default validator") + } + }) + + t.Run("can be overridden", func(t *testing.T) { + // Override to accept enterprise format (vX.Y.Z-A.B) + isValidReleaseVersion = isEnterpriseReleaseVersionFormat + + ok, err := isValidReleaseVersion("v1.2.3-1.0") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Fatal("expected v1.2.3-1.0 to be valid with enterprise validator") + } + }) +} + func TestIsPrereleaseEnterpriseVersion(t *testing.T) { t.Parallel()