From 0ef70186fc51e8f203c2e814e292258b765edf2e Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Tue, 5 May 2026 14:08:36 +0100 Subject: [PATCH 1/3] feat: ability to create releases without uploading them Signed-off-by: Evans Mungai --- cli/cmd/lint.go | 10 ---- cli/cmd/release_create.go | 66 ++++++++++++++++++++++++-- cli/cmd/release_create_test.go | 87 ++++++++++++++++++++++++++++++++++ cli/cmd/runner.go | 2 + 4 files changed, 150 insertions(+), 15 deletions(-) diff --git a/cli/cmd/lint.go b/cli/cmd/lint.go index 1b4662d44..ad2b2c2c2 100644 --- a/cli/cmd/lint.go +++ b/cli/cmd/lint.go @@ -21,16 +21,6 @@ import ( // release-validation-v2 feature flag. The runLint function below is still used // internally by the release lint command. -// getToolVersion extracts a tool version from config, defaulting to "latest" if not found. -func getToolVersion(config *tools.Config, tool string) string { - if config.ReplLint.Tools != nil { - if v, ok := config.ReplLint.Tools[tool]; ok { - return v - } - } - return "latest" -} - // resolveToolVersion extracts and resolves a tool version from config. // If the version is "latest" or empty, it resolves to an actual version using the resolver. // Falls back to the provided default version if resolution fails. diff --git a/cli/cmd/release_create.go b/cli/cmd/release_create.go index b167823d5..91f12661c 100644 --- a/cli/cmd/release_create.go +++ b/cli/cmd/release_create.go @@ -76,6 +76,8 @@ func (r *runners) InitReleaseCreate(parent *cobra.Command) error { cmd.Flags().BoolVar(&r.args.createReleasePromoteEnsureChannel, "ensure-channel", false, "When used with --promote , will create the channel if it doesn't exist") cmd.Flags().BoolVar(&r.args.createReleaseAutoDefaults, "auto", false, "generate default values for use in CI") cmd.Flags().BoolVarP(&r.args.createReleaseAutoDefaultsAccept, "confirm-auto", "y", false, "auto-accept the configuration generated by the --auto flag") + cmd.Flags().StringVar(&r.args.createReleaseOutputDir, "output-dir", "", "Stage the release artifacts (packaged charts and manifests) to this directory. The directory is preserved after the command completes.") + cmd.Flags().BoolVar(&r.args.createReleaseNoUpload, "no-upload", false, "Build the release locally but do not upload it. Use with --output-dir to inspect or reuse the staged artifacts. Cannot be used with --promote.") // output format cmd.Flags().StringVarP(&r.outputFormat, "output", "o", "table", "The output format to use. One of: json|table") @@ -274,9 +276,11 @@ func (r *runners) releaseCreate(cmd *cobra.Command, args []string) (err error) { return errors.Wrap(err, "resolve app type from config") } - // Defer cleanup of staging directory + // Defer cleanup of staging directory unless the user asked for the + // artifacts to be persisted (--output-dir) or to skip upload (--no-upload), + // in which case they likely want to inspect the staged files. defer func() { - if stagingDir != "" { + if stagingDir != "" && r.args.createReleaseOutputDir == "" && !r.args.createReleaseNoUpload { os.RemoveAll(stagingDir) } }() @@ -393,6 +397,37 @@ Prepared to create release with defaults: log.FinishSpinner() } + // If --output-dir was given for a non-config flow, write the release payload + // there so it can be reused via --yaml. Config flow writes its own staging + // content into output-dir already (see createReleaseFromConfig). + if r.args.createReleaseOutputDir != "" { + outDir, absErr := filepath.Abs(r.args.createReleaseOutputDir) + if absErr != nil { + outDir = r.args.createReleaseOutputDir + } + if !useConfigFlow { + payloadPath := filepath.Join(outDir, "release.json") + if err := os.MkdirAll(outDir, 0755); err != nil { + return errors.Wrapf(err, "create output-dir %s", outDir) + } + if err := os.WriteFile(payloadPath, []byte(r.args.createReleaseYaml), 0644); err != nil { + return errors.Wrapf(err, "write release payload to %s", payloadPath) + } + log.ChildActionWithoutSpinner("Release payload written to %s", payloadPath) + } else { + log.ChildActionWithoutSpinner("Release artifacts staged in %s", outDir) + } + } + + // --no-upload: stop here without calling the API. + if r.args.createReleaseNoUpload { + if r.args.createReleaseOutputDir == "" && stagingDir != "" { + log.ChildActionWithoutSpinner("Release artifacts staged in %s", stagingDir) + } + log.ChildActionWithoutSpinner("Skipping upload (--no-upload set)") + return nil + } + // if the --promote param was used make sure it identifies exactly one // channel before proceeding var promoteChanID string @@ -475,6 +510,19 @@ func (r *runners) validateReleaseCreateParams() error { return errors.New("--required can only be used with --promote ") } + // --no-upload skips the upload, so promotion flags don't make sense + if r.args.createReleaseNoUpload { + if r.args.createReleasePromote != "" { + return errors.New("--no-upload cannot be used with --promote (no release is uploaded)") + } + if r.args.createReleasePromoteEnsureChannel { + return errors.New("--no-upload cannot be used with --ensure-channel (no release is uploaded)") + } + if r.args.createReleasePromoteRequired { + return errors.New("--no-upload cannot be used with --required (no release is uploaded)") + } + } + // If no sources specified, config-based flow will be used (validated elsewhere) if numSources == 0 { return nil @@ -776,10 +824,18 @@ func collectManifests(patterns []string) ([]string, error) { } // createReleaseFromConfig creates a release from .replicated config file -// Returns the staging directory path for cleanup and the release YAML string +// Returns the staging directory path for cleanup and the release YAML string. +// If r.args.createReleaseOutputDir is set, that directory is used (and +// populated) instead of a temporary directory. func (r *runners) createReleaseFromConfig(config *tools.Config, log *logger.Logger) (stagingDir string, releaseYAML string, err error) { - // Create temporary staging directory - stagingDir = filepath.Join(os.TempDir(), fmt.Sprintf("replicated-release-%s", uuid.New().String())) + if r.args.createReleaseOutputDir != "" { + stagingDir, err = filepath.Abs(r.args.createReleaseOutputDir) + if err != nil { + return "", "", errors.Wrapf(err, "resolve output-dir %s", r.args.createReleaseOutputDir) + } + } else { + stagingDir = filepath.Join(os.TempDir(), fmt.Sprintf("replicated-release-%s", uuid.New().String())) + } if err = os.MkdirAll(stagingDir, 0755); err != nil { return "", "", errors.Wrapf(err, "create staging directory %s", stagingDir) } diff --git a/cli/cmd/release_create_test.go b/cli/cmd/release_create_test.go index 05ed05ca2..40da25d73 100644 --- a/cli/cmd/release_create_test.go +++ b/cli/cmd/release_create_test.go @@ -281,3 +281,90 @@ func TestRequiredFlagRequiresPromoteInConfigBasedFlow(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "--required can only be used with --promote") } + +func TestNoUploadRejectsPromote(t *testing.T) { + r := &runners{ + args: runnerArgs{ + createReleaseNoUpload: true, + createReleasePromote: "Unstable", + createReleaseYamlDir: "./manifests", + }, + appType: "kots", + } + + err := r.validateReleaseCreateParams() + assert.Error(t, err) + assert.Contains(t, err.Error(), "--no-upload cannot be used with --promote") +} + +func TestNoUploadRejectsEnsureChannel(t *testing.T) { + r := &runners{ + args: runnerArgs{ + createReleaseNoUpload: true, + createReleasePromoteEnsureChannel: true, + createReleaseYamlDir: "./manifests", + }, + appType: "kots", + } + + err := r.validateReleaseCreateParams() + assert.Error(t, err) + assert.Contains(t, err.Error(), "--no-upload cannot be used with --ensure-channel") +} + +func TestNoUploadRejectsRequired(t *testing.T) { + r := &runners{ + args: runnerArgs{ + createReleaseNoUpload: true, + createReleasePromoteRequired: true, + createReleasePromote: "Unstable", + createReleaseYamlDir: "./manifests", + }, + appType: "kots", + } + + err := r.validateReleaseCreateParams() + assert.Error(t, err) + assert.Contains(t, err.Error(), "--no-upload cannot be used with --promote") +} + +func TestNoUploadAloneIsValid(t *testing.T) { + r := &runners{ + args: runnerArgs{ + createReleaseNoUpload: true, + createReleaseYamlDir: "./manifests", + }, + appType: "kots", + } + + err := r.validateReleaseCreateParams() + assert.NoError(t, err) +} + +func TestOutputDirAloneIsValid(t *testing.T) { + r := &runners{ + args: runnerArgs{ + createReleaseOutputDir: "./out", + createReleaseYamlDir: "./manifests", + }, + appType: "kots", + } + + err := r.validateReleaseCreateParams() + assert.NoError(t, err) +} + +func TestOutputDirWithPromoteIsValid(t *testing.T) { + // --output-dir on its own is orthogonal to upload; it must coexist with --promote. + r := &runners{ + args: runnerArgs{ + createReleaseOutputDir: "./out", + createReleasePromote: "Unstable", + createReleaseYamlDir: "./manifests", + }, + appType: "kots", + } + + err := r.validateReleaseCreateParams() + assert.NoError(t, err) +} diff --git a/cli/cmd/runner.go b/cli/cmd/runner.go index 1f54411a4..7e026ebdd 100644 --- a/cli/cmd/runner.go +++ b/cli/cmd/runner.go @@ -91,6 +91,8 @@ type runnerArgs struct { createReleaseAutoDefaults bool createReleaseAutoDefaultsAccept bool + createReleaseOutputDir string + createReleaseNoUpload bool releaseDownloadDest string releaseDownloadChannel string From 7fa57e9aec17d268a15fda3b66e6f60c55627441 Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Tue, 5 May 2026 14:47:22 +0100 Subject: [PATCH 2/3] More updates Signed-off-by: Evans Mungai --- cli/cmd/release_create.go | 33 ++++++++++++++++++------ cli/cmd/release_create_test.go | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/cli/cmd/release_create.go b/cli/cmd/release_create.go index 91f12661c..e37d7fb94 100644 --- a/cli/cmd/release_create.go +++ b/cli/cmd/release_create.go @@ -76,7 +76,7 @@ func (r *runners) InitReleaseCreate(parent *cobra.Command) error { cmd.Flags().BoolVar(&r.args.createReleasePromoteEnsureChannel, "ensure-channel", false, "When used with --promote , will create the channel if it doesn't exist") cmd.Flags().BoolVar(&r.args.createReleaseAutoDefaults, "auto", false, "generate default values for use in CI") cmd.Flags().BoolVarP(&r.args.createReleaseAutoDefaultsAccept, "confirm-auto", "y", false, "auto-accept the configuration generated by the --auto flag") - cmd.Flags().StringVar(&r.args.createReleaseOutputDir, "output-dir", "", "Stage the release artifacts (packaged charts and manifests) to this directory. The directory is preserved after the command completes.") + cmd.Flags().StringVar(&r.args.createReleaseOutputDir, "output-dir", "", "Stage the release artifacts (packaged charts and manifests) to this directory. Existing contents of the directory are removed before each run. The directory is preserved after the command completes.") cmd.Flags().BoolVar(&r.args.createReleaseNoUpload, "no-upload", false, "Build the release locally but do not upload it. Use with --output-dir to inspect or reuse the staged artifacts. Cannot be used with --promote.") // output format @@ -406,10 +406,10 @@ Prepared to create release with defaults: outDir = r.args.createReleaseOutputDir } if !useConfigFlow { - payloadPath := filepath.Join(outDir, "release.json") - if err := os.MkdirAll(outDir, 0755); err != nil { - return errors.Wrapf(err, "create output-dir %s", outDir) + if err := resetOutputDir(outDir); err != nil { + return errors.Wrapf(err, "reset output-dir %s", outDir) } + payloadPath := filepath.Join(outDir, "release.json") if err := os.WriteFile(payloadPath, []byte(r.args.createReleaseYaml), 0644); err != nil { return errors.Wrapf(err, "write release payload to %s", payloadPath) } @@ -833,11 +833,14 @@ func (r *runners) createReleaseFromConfig(config *tools.Config, log *logger.Logg if err != nil { return "", "", errors.Wrapf(err, "resolve output-dir %s", r.args.createReleaseOutputDir) } + if err = resetOutputDir(stagingDir); err != nil { + return "", "", errors.Wrapf(err, "reset output-dir %s", stagingDir) + } } else { stagingDir = filepath.Join(os.TempDir(), fmt.Sprintf("replicated-release-%s", uuid.New().String())) - } - if err = os.MkdirAll(stagingDir, 0755); err != nil { - return "", "", errors.Wrapf(err, "create staging directory %s", stagingDir) + if err = os.MkdirAll(stagingDir, 0755); err != nil { + return "", "", errors.Wrapf(err, "create staging directory %s", stagingDir) + } } // Package all charts @@ -901,6 +904,22 @@ func (r *runners) createReleaseFromConfig(config *tools.Config, log *logger.Logg return stagingDir, releaseYAML, nil } +// resetOutputDir removes any existing contents of dir and re-creates it empty, +// so each run produces a clean staging directory. If dir does not exist it is +// created. +func resetOutputDir(dir string) error { + if dir == "" { + return errors.New("output-dir path is empty") + } + if err := os.RemoveAll(dir); err != nil { + return errors.Wrapf(err, "remove existing output-dir %s", dir) + } + if err := os.MkdirAll(dir, 0755); err != nil { + return errors.Wrapf(err, "create output-dir %s", dir) + } + return nil +} + // copyFile copies a file from src to dst func copyFile(src, dst string) error { sourceFile, err := os.Open(src) diff --git a/cli/cmd/release_create_test.go b/cli/cmd/release_create_test.go index 40da25d73..913635550 100644 --- a/cli/cmd/release_create_test.go +++ b/cli/cmd/release_create_test.go @@ -1,9 +1,12 @@ package cmd import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestUseConfigFlow_WithAutoFlag tests that --auto flag prevents config-based flow @@ -354,6 +357,49 @@ func TestOutputDirAloneIsValid(t *testing.T) { assert.NoError(t, err) } +func TestResetOutputDirCreatesMissingDir(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "out") + + err := resetOutputDir(target) + require.NoError(t, err) + + info, err := os.Stat(target) + require.NoError(t, err) + assert.True(t, info.IsDir(), "expected %s to be a directory", target) + + entries, err := os.ReadDir(target) + require.NoError(t, err) + assert.Empty(t, entries, "expected freshly created output-dir to be empty") +} + +func TestResetOutputDirClearsExistingContents(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "out") + require.NoError(t, os.MkdirAll(filepath.Join(target, "nested"), 0755)) + stale := filepath.Join(target, "stale.txt") + require.NoError(t, os.WriteFile(stale, []byte("old"), 0644)) + + err := resetOutputDir(target) + require.NoError(t, err) + + info, err := os.Stat(target) + require.NoError(t, err) + assert.True(t, info.IsDir()) + + entries, err := os.ReadDir(target) + require.NoError(t, err) + assert.Empty(t, entries, "expected output-dir contents to be cleared before each run") + + _, err = os.Stat(stale) + assert.True(t, os.IsNotExist(err), "expected previous file %s to be removed", stale) +} + +func TestResetOutputDirRejectsEmptyPath(t *testing.T) { + err := resetOutputDir("") + assert.Error(t, err) +} + func TestOutputDirWithPromoteIsValid(t *testing.T) { // --output-dir on its own is orthogonal to upload; it must coexist with --promote. r := &runners{ From e64cbd358d0a89790334933f69c2b62a62047ee5 Mon Sep 17 00:00:00 2001 From: Evans Mungai Date: Tue, 5 May 2026 17:17:39 +0100 Subject: [PATCH 3/3] Remove unreachable code Signed-off-by: Evans Mungai --- cli/cmd/release_create.go | 3 --- cli/cmd/release_create_test.go | 16 ---------------- 2 files changed, 19 deletions(-) diff --git a/cli/cmd/release_create.go b/cli/cmd/release_create.go index e37d7fb94..338a932bb 100644 --- a/cli/cmd/release_create.go +++ b/cli/cmd/release_create.go @@ -518,9 +518,6 @@ func (r *runners) validateReleaseCreateParams() error { if r.args.createReleasePromoteEnsureChannel { return errors.New("--no-upload cannot be used with --ensure-channel (no release is uploaded)") } - if r.args.createReleasePromoteRequired { - return errors.New("--no-upload cannot be used with --required (no release is uploaded)") - } } // If no sources specified, config-based flow will be used (validated elsewhere) diff --git a/cli/cmd/release_create_test.go b/cli/cmd/release_create_test.go index 913635550..94c444981 100644 --- a/cli/cmd/release_create_test.go +++ b/cli/cmd/release_create_test.go @@ -315,22 +315,6 @@ func TestNoUploadRejectsEnsureChannel(t *testing.T) { assert.Contains(t, err.Error(), "--no-upload cannot be used with --ensure-channel") } -func TestNoUploadRejectsRequired(t *testing.T) { - r := &runners{ - args: runnerArgs{ - createReleaseNoUpload: true, - createReleasePromoteRequired: true, - createReleasePromote: "Unstable", - createReleaseYamlDir: "./manifests", - }, - appType: "kots", - } - - err := r.validateReleaseCreateParams() - assert.Error(t, err) - assert.Contains(t, err.Error(), "--no-upload cannot be used with --promote") -} - func TestNoUploadAloneIsValid(t *testing.T) { r := &runners{ args: runnerArgs{