From a219f1796b6a90eb64659ea01899d7621421b249 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 19 Aug 2026 11:50:51 +1000 Subject: [PATCH 01/13] feat: add --dry-run to release create and release delete Declares --dry-run per command rather than persistently, so a command that hasn't implemented it rejects the flag instead of silently ignoring it. A client-level guard refuses any non-read-only request once a dry run is under way, so a half-implemented dry run fails loudly. Refs #63 Co-Authored-By: Claude Opus 5 (1M context) --- pkg/apiclient/client_factory.go | 27 +++ pkg/apiclient/client_factory_test.go | 32 ++++ pkg/cmd/release/create/create.go | 247 ++++++++++++++++++++++++-- pkg/cmd/release/create/create_test.go | 152 ++++++++++++++++ pkg/cmd/release/delete/delete.go | 51 ++++-- pkg/cmd/release/delete/delete_test.go | 54 ++++++ pkg/cmd/root/root.go | 10 +- pkg/constants/constants.go | 1 + pkg/dryrun/dryrun.go | 95 ++++++++++ pkg/dryrun/dryrun_test.go | 101 +++++++++++ pkg/packages/packages.go | 28 +-- 11 files changed, 760 insertions(+), 38 deletions(-) create mode 100644 pkg/dryrun/dryrun.go create mode 100644 pkg/dryrun/dryrun_test.go diff --git a/pkg/apiclient/client_factory.go b/pkg/apiclient/client_factory.go index 93c73abe..12c10ea7 100644 --- a/pkg/apiclient/client_factory.go +++ b/pkg/apiclient/client_factory.go @@ -9,6 +9,7 @@ import ( "github.com/MakeNowJust/heredoc/v2" "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/dryrun" "github.com/OctopusDeploy/cli/pkg/output" "github.com/OctopusDeploy/cli/pkg/question" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" @@ -45,6 +46,12 @@ type ClientFactory interface { // GetHttpClient returns a raw http client which can be used to query Octopus GetHttpClient() (*http.Client, error) + + // SetDryRun puts the client into dry-run mode, where any request that would change + // server state is refused before it is sent. It backstops the per-command --dry-run + // implementations; a command which hasn't finished implementing dry run fails loudly + // rather than mutating Octopus while claiming it did not. + SetDryRun(enabled bool) } type Client struct { @@ -73,6 +80,9 @@ type Client struct { ActiveSpace *spaces.Space Ask question.AskProvider + + // true once the dry-run guard has been installed on HttpClient + dryRun bool } func NewClientFactory(httpClient *http.Client, host string, credentials octopusApiClient.ICredential, spaceNameOrID string, ask question.AskProvider) (ClientFactory, error) { @@ -257,6 +267,21 @@ func (c *Client) GetHttpClient() (*http.Client, error) { return c.HttpClient, nil } +// SetDryRun wraps the transport in the dry-run guard. It must be called before the +// space-scoped or system clients are created, which is why the root command arms it +// from PersistentPreRun; both clients are built lazily during RunE. +func (c *Client) SetDryRun(enabled bool) { + if !enabled || c.dryRun { + return + } + c.dryRun = true + + if c.HttpClient == nil { + c.HttpClient = &http.Client{} + } + c.HttpClient.Transport = dryrun.NewGuardRoundTripper(c.HttpClient.Transport) +} + func (c *Client) SetSpaceNameOrId(spaceNameOrId string) { // technically don't need to nil out the SystemClient, but it's cleaner that way // because a SpaceScopedClient can also be a SystemClient @@ -408,3 +433,5 @@ func (s *stubClientFactory) GetHostUrl() string { return "" } func (s *stubClientFactory) GetHttpClient() (*http.Client, error) { return nil, nil } + +func (s *stubClientFactory) SetDryRun(_ bool) {} diff --git a/pkg/apiclient/client_factory_test.go b/pkg/apiclient/client_factory_test.go index 4cbc542e..892d35c5 100644 --- a/pkg/apiclient/client_factory_test.go +++ b/pkg/apiclient/client_factory_test.go @@ -1,6 +1,9 @@ package apiclient_test import ( + "bytes" + "io" + "net/http" "testing" "github.com/OctopusDeploy/cli/pkg/apiclient" @@ -67,3 +70,32 @@ func TestNewClientFactory_WhenHostAndAccessTokenAreSupplied_ReturnsClientFactory testutil.RequireSuccess(t, err) assert.NotNil(t, factory) } + +type recordingRoundTripper struct { + Requests []*http.Request +} + +func (r *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + r.Requests = append(r.Requests, req) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(nil))}, nil +} + +func TestClientFactory_SetDryRun_RefusesMutatingRequests(t *testing.T) { + transport := &recordingRoundTripper{} + apiKeyCredential, _ := client.NewApiKey(apiKey) + clientFactory, err := apiclient.NewClientFactory(&http.Client{Transport: transport}, hostUrl, apiKeyCredential, "", qa) + testutil.RequireSuccess(t, err) + + clientFactory.SetDryRun(true) + + httpClient, err := clientFactory.GetHttpClient() + testutil.RequireSuccess(t, err) + + _, err = httpClient.Post(hostUrl+"/api/Spaces-1/releases/create/v1", "application/json", nil) + assert.ErrorContains(t, err, "dry run blocked a POST request to /api/Spaces-1/releases/create/v1") + assert.Empty(t, transport.Requests, "a mutating request must not reach the server") + + _, err = httpClient.Get(hostUrl + "/api/Spaces-1/projects/all") + assert.Nil(t, err) + assert.Len(t, transport.Requests, 1, "read-only requests still go through") +} diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index b4b50caa..8b84757d 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -15,6 +15,7 @@ import ( "github.com/MakeNowJust/heredoc/v2" "github.com/OctopusDeploy/cli/pkg/cmd/release/list" "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/dryrun" cliErrors "github.com/OctopusDeploy/cli/pkg/errors" "github.com/OctopusDeploy/cli/pkg/executor" "github.com/OctopusDeploy/cli/pkg/factory" @@ -32,6 +33,7 @@ import ( "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/feeds" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/spaces" "github.com/spf13/cobra" ) @@ -127,6 +129,7 @@ type CreateFlags struct { PackageVersionSpec *flag.Flag[[]string] GitResourceRefsSpec *flag.Flag[[]string] CustomFields *flag.Flag[[]string] + DryRun *flag.Flag[bool] } func NewCreateFlags() *CreateFlags { @@ -144,6 +147,7 @@ func NewCreateFlags() *CreateFlags { PackageVersionSpec: flag.New[[]string](FlagPackageVersionSpec, false), GitResourceRefsSpec: flag.New[[]string](FlagGitResourceRefSpec, false), CustomFields: flag.New[[]string](FlagCustomField, false), + DryRun: flag.New[bool](constants.FlagDryRun, false), } } @@ -160,6 +164,7 @@ func NewCmdCreate(f factory.Factory) *cobra.Command { %[1]s release create -p MyProject -c default --package "utils:1.2.3" --package "utils:InstallOnly:5.6.7" %[1]s release create -p MyProject --package "com.example\:my-artifact:1.0" %[1]s release create -p MyProject -c Beta --no-prompt + %[1]s release create -p MyProject -c Beta --dry-run `, constants.ExecutableName), RunE: func(cmd *cobra.Command, args []string) error { return createRun(cmd, f, createFlags) }, } @@ -179,6 +184,7 @@ func NewCmdCreate(f factory.Factory) *cobra.Command { flags.StringArrayVarP(&createFlags.PackageVersionSpec.Value, createFlags.PackageVersionSpec.Name, "", []string{}, "Version specification for a specific package. You may specify this multiple times.\nFormat as {package}:{version}, {step}:{version} or {package-ref-name}:{packageOrStep}:{version}\nIf the package ID or step name contains a colon, slash, or equals sign (such as Maven coordinates like com.example:my-artifact), escape that character with a backslash:\n --package \"com.example\\:my-artifact:1.0\"\nThis escape syntax requires Octopus CLI 2.21.2 or later and Octopus Server 2025.4.10680 or later.") flags.StringArrayVarP(&createFlags.GitResourceRefsSpec.Value, createFlags.GitResourceRefsSpec.Name, "", []string{}, "Git reference for a specific Git resource.\nFormat as {step}:{git-ref}, {step}:{git-resource-name}:{git-ref}\nYou may specify this multiple times") flags.StringArrayVarP(&createFlags.CustomFields.Value, createFlags.CustomFields.Name, "", []string{}, "Custom field value to set on the release.\nFormat as {name}:{value}. You may specify multiple times") + dryrun.AddFlag(flags, &createFlags.DryRun.Value) // we want the help text to display in the above order, rather than alphabetical flags.SortFlags = false @@ -258,6 +264,9 @@ func createRun(cmd *cobra.Command, f factory.Factory, flags *CreateFlags) error return err } + // only populated in automation mode; the interactive Q&A resolves everything as it goes + var resolvedProject *projects.Project + if f.IsPromptEnabled() { err = AskQuestions(octopus, cmd.OutOrStdout(), f.Ask, options) if err != nil { @@ -310,9 +319,18 @@ func createRun(cmd *cobra.Command, f factory.Factory, flags *CreateFlags) error return err } options.ProjectName = project.GetName() + resolvedProject = project } } + if flags.DryRun.Value { + preview, err := buildReleasePreview(octopus, f.GetCurrentSpace(), options, resolvedProject) + if err != nil { + return err + } + return printReleasePreview(cmd, preview, outputFormat) + } + // the executor will raise errors if any required options are missing err = executor.ProcessTasks(octopus, f.GetCurrentSpace(), []*executor.Task{ executor.NewTask(executor.TaskTypeCreateRelease, options), @@ -382,6 +400,220 @@ func createRun(cmd *cobra.Command, f factory.Factory, flags *CreateFlags) error return nil } +// resolveVersioningStrategy loads the project's versioning strategy. Config-as-code projects +// don't inline it in the project resource, so it has to come from the deployment settings. +func resolveVersioningStrategy(octopus *octopusApiClient.Client, project *projects.Project, gitReferenceKey string) (*projects.VersioningStrategy, error) { + if project.VersioningStrategy != nil { + return project.VersioningStrategy, nil + } + + deploymentSettings, err := octopus.Deployments.GetDeploymentSettings(project, gitReferenceKey) + if err != nil { + return nil, err + } + if deploymentSettings.VersioningStrategy == nil { // not sure if this should ever happen, but best to be defensive + return nil, cliErrors.NewInvalidResponseError(fmt.Sprintf("cannot determine versioning strategy for project %s", project.Name)) + } + return deploymentSettings.VersioningStrategy, nil +} + +// ReleasePreview is the release that a dry run would have created, resolved as far as the +// CLI can resolve it. An empty Channel or Version means the Octopus Server decides it. +type ReleasePreview struct { + // always true; it marks machine-readable output as a plan rather than a result + DryRun bool + Space string + Project string + Channel string + GitReference string `json:",omitempty"` + GitCommit string `json:",omitempty"` + Version string + ReleaseNotes string `json:",omitempty"` + PackageVersions []*packages.StepPackageVersion `json:",omitempty"` + PackageOverrides []string `json:",omitempty"` + GitResources []string `json:",omitempty"` + CustomFields map[string]string `json:",omitempty"` + IgnoreExisting bool + IgnoreChannelRules bool +} + +// buildReleasePreview describes the release that would be created, without creating it. +// In interactive mode the Q&A has already resolved everything, so resolvedProject is nil +// and the options are the answer. In automation mode only the project has been resolved, +// so we go back to the server (read-only) for the channel, packages and version. +func buildReleasePreview(octopus *octopusApiClient.Client, space *spaces.Space, options *executor.TaskOptionsCreateRelease, resolvedProject *projects.Project) (*ReleasePreview, error) { + if options.ProjectName == "" { + return nil, errors.New("project must be specified") + } + + preview := &ReleasePreview{ + DryRun: true, + Project: options.ProjectName, + Channel: options.ChannelName, + GitReference: options.GitReference, + GitCommit: options.GitCommit, + Version: options.Version, + ReleaseNotes: options.ReleaseNotes, + PackageOverrides: options.PackageVersionOverrides, + GitResources: options.GitResourceRefs, + CustomFields: options.CustomFields, + IgnoreExisting: options.IgnoreIfAlreadyExists, + IgnoreChannelRules: options.IgnoreChannelRules, + } + if space != nil { + preview.Space = space.GetName() + } + + if resolvedProject == nil { + return preview, nil + } + + // without an explicit channel the server picks one by applying the channel rules, and + // both the package versions and the release version follow from that choice; guessing + // which channel it would pick risks showing a plan that doesn't match what happens + if options.ChannelName == "" { + return preview, nil + } + + channel, err := selectors.FindChannel(octopus, resolvedProject, options.ChannelName) + if err != nil { + return nil, err + } + preview.Channel = channel.Name + + gitReferenceKey := "" + if resolvedProject.PersistenceSettings.Type() == projects.PersistenceSettingsTypeVersionControlled { + gitReferenceKey = options.GitReference + if options.GitCommit != "" { // prefer a specific git commit if one was specified + gitReferenceKey = options.GitCommit + } + } + + deploymentProcess, err := octopus.DeploymentProcesses.Get(resolvedProject, gitReferenceKey) + if err != nil { + return nil, err + } + + deploymentProcessTemplate, err := octopus.DeploymentProcesses.GetTemplate(deploymentProcess, channel.ID, "") + if err != nil { + return nil, err + } + + baseline, err := BuildPackageVersionBaselineForChannel(octopus, deploymentProcessTemplate, channel) + if err != nil { + return nil, err + } + overrides := packages.BuildPackageVersionOverrides(baseline, options.DefaultPackageVersion, options.PackageVersionOverrides) + preview.PackageVersions = packages.ApplyPackageOverrides(baseline, overrides) + + if preview.Version == "" { + preview.Version, err = determineReleaseVersion(octopus, resolvedProject, gitReferenceKey, deploymentProcessTemplate, preview.PackageVersions) + if err != nil { + return nil, err + } + } + + return preview, nil +} + +// determineReleaseVersion works out the version the server would assign, following the same +// rules as the interactive prompt but without asking anything. An empty string means the +// version can't be determined up front. +func determineReleaseVersion(octopus *octopusApiClient.Client, project *projects.Project, gitReferenceKey string, deploymentProcessTemplate *deployments.DeploymentProcessTemplate, packageVersions []*packages.StepPackageVersion) (string, error) { + versioningStrategy, err := resolveVersioningStrategy(octopus, project, gitReferenceKey) + if err != nil { + return "", err + } + + if donor := versioningStrategy.DonorPackage; donor != nil { + for _, pkg := range packageVersions { + if pkg.PackageReferenceName == donor.PackageReference && pkg.ActionName == donor.DeploymentAction { + return pkg.Version, nil + } + } + return "", nil + } + if versioningStrategy.DonorPackageStepID != nil { // a donor step with no package reference; nothing to read a version from + return "", nil + } + + if versioningStrategy.Template != "" { + return deploymentProcessTemplate.NextVersionIncrement, nil + } + + return "", nil +} + +func printReleasePreview(cmd *cobra.Command, preview *ReleasePreview, outputFormat string) error { + if outputFormat == constants.OutputFormatJson { + data, err := json.Marshal(preview) + if err != nil { + return err + } + _, _ = cmd.OutOrStdout().Write(data) + cmd.Println() + return nil + } + + dryrun.Header(cmd) + cmd.Printf("Would create a release with:\n") + + byServer := output.Dim("(determined by the Octopus Server)") + rows := []*output.DataRow{ + output.NewDataRow("Space", preview.Space), + output.NewDataRow("Project", preview.Project), + output.NewDataRow("Channel", orDefault(preview.Channel, byServer)), + output.NewDataRow("Version", orDefault(preview.Version, byServer)), + } + if preview.GitReference != "" { + rows = append(rows, output.NewDataRow("Git Reference", preview.GitReference)) + } + if preview.GitCommit != "" { + rows = append(rows, output.NewDataRow("Git Commit", preview.GitCommit)) + } + rows = append(rows, output.NewDataRow("Release Notes", orDefault(preview.ReleaseNotes, output.Dim("(none)")))) + for _, ref := range preview.GitResources { + rows = append(rows, output.NewDataRow("Git Resource", ref)) + } + for name, value := range preview.CustomFields { + rows = append(rows, output.NewDataRow("Custom Field", fmt.Sprintf("%s: %s", name, value))) + } + if preview.IgnoreExisting { + rows = append(rows, output.NewDataRow("Ignore Existing", "true")) + } + if preview.IgnoreChannelRules { + rows = append(rows, output.NewDataRow("Ignore Channel Rules", "true")) + } + output.PrintRows(rows, cmd.OutOrStdout()) + + if len(preview.PackageVersions) > 0 { + cmd.Printf("\nPackages:\n") + t := output.NewTable(cmd.OutOrStdout()) + t.AddRow(output.Bold("PACKAGE"), output.Bold("VERSION"), output.Bold("STEP NAME/PACKAGE REFERENCE")) + for _, pkg := range preview.PackageVersions { + t.AddRow(pkg.PackageID, orDefault(pkg.Version, output.Yellow("unknown")), fmt.Sprintf("%s/%s", pkg.ActionName, pkg.PackageReferenceName)) + } + if err := t.Print(); err != nil { + return err + } + } else if len(preview.PackageOverrides) > 0 { + cmd.Printf("\nPackage overrides:\n") + for _, ov := range preview.PackageOverrides { + cmd.Printf(" %s\n", ov) + } + } + + dryrun.Footer(cmd, "no release was created.") + return nil +} + +func orDefault(value string, fallback string) string { + if value == "" { + return fallback + } + return value +} + // BuildPackageVersionBaselineForChannel loads the deployment process template from the server, and for each step+package therein, // finds the latest available version satisfying the channel version rules. Result is the list of step+package+versions // to use as a baseline. The package version override process takes this as an input and layers on top of it @@ -567,18 +799,9 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques // - but we must allow the user to override package versions first. // If the project's VersioningStrategy is null, it means this is a Config-as-code project and we need to // additionally load the deployment settings because the API doesn't inline the strategy in the main project resource for some reason - var versioningStrategy *projects.VersioningStrategy - if selectedProject.VersioningStrategy != nil { - versioningStrategy = selectedProject.VersioningStrategy - } else { - deploymentSettings, err := octopus.Deployments.GetDeploymentSettings(selectedProject, gitReferenceKey) - if err != nil { - return err - } - versioningStrategy = deploymentSettings.VersioningStrategy - } - if versioningStrategy == nil { // not sure if this should ever happen, but best to be defensive - return cliErrors.NewInvalidResponseError(fmt.Sprintf("cannot determine versioning strategy for project %s", selectedProject.Name)) + versioningStrategy, err := resolveVersioningStrategy(octopus, selectedProject, gitReferenceKey) + if err != nil { + return err } if versioningStrategy.DonorPackageStepID != nil || versioningStrategy.DonorPackage != nil { diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 65ee97c7..6681e5ad 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -2829,3 +2829,155 @@ func TestReleaseCreate_ApplyPackageOverride(t *testing.T) { }, result) }) } + +func TestReleaseCreate_DryRun(t *testing.T) { + const spaceID = "Spaces-1" + const fireProjectID = "Projects-22" + + space1 := fixtures.NewSpace(spaceID, "Default Space") + depProcess := fixtures.NewDeploymentProcessForProject(spaceID, fireProjectID) + fireProject := fixtures.NewProject(spaceID, fireProjectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", depProcess.ID) + defaultChannel := fixtures.NewChannel(spaceID, "Channels-1", "Fire Project Default Channel", fireProjectID) + + tests := []struct { + name string + run func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) + }{ + {"dry run without a channel says what the server would decide, and creates nothing", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "create", "--project", fireProject.Name, "--dry-run"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + + // note the absence of a POST to /releases/create/v1; an unexpected request + // would leave the mock server with nothing to respond to it + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, 0, api.GetPendingMessageCount()) + + assert.Equal(t, heredoc.Doc(` + DRY RUN: no changes will be made in Octopus. + + Would create a release with: + Space Default Space + Project Fire Project + Channel (determined by the Octopus Server) + Version (determined by the Octopus Server) + Release Notes (none) + + DRY RUN: no release was created. + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"dry run with a channel resolves the version and package versions, and creates nothing", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "create", + "--project", fireProject.Name, + "--channel", defaultChannel.Name, + "--package", "pterm:9.9", + "--release-notes", "Some notes", + "--dry-run", + }) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/channels"). + RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{defaultChannel}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/deploymentprocesses/deploymentprocess-"+fireProjectID).RespondWith(depProcess) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/deploymentprocesses/template?channel=Channels-1"). + RespondWith(&deployments.DeploymentProcessTemplate{ + Packages: []releases.ReleaseTemplatePackage{ + { + ActionName: "Install", + FeedID: "feeds-builtin", + PackageID: "pterm", + PackageReferenceName: "pterm-on-install", + IsResolvable: true, + }, + }, + NextVersionIncrement: "27.9.33", + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/feeds?ids=feeds-builtin&take=1").RespondWith(&feeds.Feeds{Items: []feeds.IFeed{ + &feeds.FeedResource{Name: "Builtin", FeedType: feeds.FeedTypeBuiltIn, Resource: resources.Resource{ + ID: "feeds-builtin", + Links: map[string]string{ + constants.LinkSearchPackageVersionsTemplate: "/api/Spaces-1/feeds/feeds-builtin/packages/versions{?packageId,take,skip,includePreRelease,versionRange,preReleaseTag,filter,includeReleaseNotes}", + }}}, + }}) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/feeds/feeds-builtin/packages/versions?packageId=pterm&take=1").RespondWith(&resources.Resources[*octopusPackages.PackageVersion]{ + Items: []*octopusPackages.PackageVersion{{PackageID: "pterm", Version: "0.12.51"}}, + }) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, 0, api.GetPendingMessageCount()) + + assert.Equal(t, heredoc.Doc(` + DRY RUN: no changes will be made in Octopus. + + Would create a release with: + Space Default Space + Project Fire Project + Channel Fire Project Default Channel + Version 27.9.33 + Release Notes Some notes + + Packages: + PACKAGE VERSION STEP NAME/PACKAGE REFERENCE + pterm 9.9 Install/pterm-on-install + + DRY RUN: no release was created. + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"dry run with json output emits a machine readable plan flagged as a dry run", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "create", "--project", fireProject.Name, "--dry-run", "--output-format", "json"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, 0, api.GetPendingMessageCount()) + + assert.Equal(t, `{"DryRun":true,"Space":"Default Space","Project":"Fire Project","Channel":"","Version":"","IgnoreExisting":false,"IgnoreChannelRules":false}`+"\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + api := testutil.NewMockHttpServer() + + rootCmd := cmdRoot.NewCmdRoot(testutil.NewMockFactoryWithSpace(api, space1), nil, nil) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + + test.run(t, api, rootCmd, stdout, stderr) + }) + } +} diff --git a/pkg/cmd/release/delete/delete.go b/pkg/cmd/release/delete/delete.go index a387a270..7aa5e118 100644 --- a/pkg/cmd/release/delete/delete.go +++ b/pkg/cmd/release/delete/delete.go @@ -9,6 +9,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc/v2" "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/dryrun" "github.com/OctopusDeploy/cli/pkg/factory" "github.com/OctopusDeploy/cli/pkg/output" "github.com/OctopusDeploy/cli/pkg/question" @@ -30,12 +31,14 @@ const ( type Flags struct { Project *flag.Flag[string] Version *flag.Flag[[]string] + DryRun *flag.Flag[bool] } func NewFlags() *Flags { return &Flags{ Project: flag.New[string](FlagProject, false), Version: flag.New[[]string](FlagVersion, false), + DryRun: flag.New[bool](constants.FlagDryRun, false), } } @@ -49,6 +52,7 @@ func NewCmdDelete(f factory.Factory) *cobra.Command { %[1]s release delete myProject 2.0 %[1]s release delete --project myProject --version 2.0 %[1]s release rm "Other Project" -v 2.0 + %[1]s release delete --project myProject --version 2.0 --dry-run `, constants.ExecutableName), Aliases: []string{"del", "rm"}, RunE: func(cmd *cobra.Command, args []string) error { @@ -59,6 +63,7 @@ func NewCmdDelete(f factory.Factory) *cobra.Command { flags := cmd.Flags() flags.StringVarP(&cmdFlags.Project.Value, cmdFlags.Project.Name, "p", "", "Name or ID of the project to delete releases in") flags.StringArrayVarP(&cmdFlags.Version.Value, cmdFlags.Version.Name, "v", make([]string, 0), "Release version to delete, can be specified multiple times") + dryrun.AddFlag(flags, &cmdFlags.DryRun.Value) return cmd } @@ -126,23 +131,25 @@ func deleteRun(cmd *cobra.Command, f factory.Factory, flags *Flags, args []strin return nil // no work to do, just exit } - // prompt for confirmation - cmd.Printf("You are about to delete the following releases:\n") - for _, r := range releasesToDelete { - cmd.Printf("%s\n", r.Version) - } + // a dry run never deletes anything, so there is nothing to confirm; the plan + // printed below says what would have happened instead + if !flags.DryRun.Value { + cmd.Printf("You are about to delete the following releases:\n") + for _, r := range releasesToDelete { + cmd.Printf("%s\n", r.Version) + } - var isConfirmed bool - if err = f.Ask(&survey.Confirm{ - Message: fmt.Sprintf("Confirm delete of %d release(s)", len(releasesToDelete)), - Default: false, - }, &isConfirmed); err != nil { - return err - } - if !isConfirmed { - return nil // nothing to be done here + var isConfirmed bool + if err = f.Ask(&survey.Confirm{ + Message: fmt.Sprintf("Confirm delete of %d release(s)", len(releasesToDelete)), + Default: false, + }, &isConfirmed); err != nil { + return err + } + if !isConfirmed { + return nil // nothing to be done here + } } - } else { // we don't have the executions API backing us and allowing NameOrID; we need to do the lookups ourselves releasesToDelete, err = findReleases(octopus, selectedProject, versionsToDelete) if err != nil { @@ -155,6 +162,11 @@ func deleteRun(cmd *cobra.Command, f factory.Factory, flags *Flags, args []strin return nil } + if flags.DryRun.Value { + printDeletePlan(cmd, selectedProject, releasesToDelete) + return nil + } + var releaseDeleteErrors = &multierror.Error{} for _, r := range releasesToDelete { err = octopus.Releases.DeleteByID(r.ID) @@ -178,6 +190,15 @@ func deleteRun(cmd *cobra.Command, f factory.Factory, flags *Flags, args []strin return releaseDeleteErrors.ErrorOrNil() } +func printDeletePlan(cmd *cobra.Command, project *projects.Project, releasesToDelete []*releases.Release) { + dryrun.Header(cmd) + cmd.Printf("Would delete %d release(s) from project %s:\n", len(releasesToDelete), output.Cyan(project.GetName())) + for _, r := range releasesToDelete { + cmd.Printf(" %s\n", r.Version) + } + dryrun.Footer(cmd, "no releases were deleted.") +} + func selectReleases(octopus *octopusApiClient.Client, project *projects.Project, ask question.Asker) ([]*releases.Release, error) { existingReleases, err := octopus.Projects.GetReleases(project) // gets all of them, no paging if err != nil { diff --git a/pkg/cmd/release/delete/delete_test.go b/pkg/cmd/release/delete/delete_test.go index 3e77fda2..6b780b4e 100644 --- a/pkg/cmd/release/delete/delete_test.go +++ b/pkg/cmd/release/delete/delete_test.go @@ -176,6 +176,60 @@ func TestReleaseDelete(t *testing.T) { assert.Equal(t, "", stdErr.String()) }}, + // ----- dry run ------ + + {"noprompt: dry run reports what would be deleted and doesn't delete anything", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "delete", "--project", fireProject.Name, "--version", "2.0", "--version", "2.1", "--no-prompt", "--dry-run"}) + return rootCmd.ExecuteC() + }) + + // note the absence of any DELETE requests; an unexpected request would leave the + // mock server with nothing to respond to it + standardDeleteTestBody(api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, 0, api.GetPendingMessageCount()) + + assert.Equal(t, heredoc.Doc(` + DRY RUN: no changes will be made in Octopus. + + Would delete 2 release(s) from project Fire Project: + 2.1 + 2.0 + + DRY RUN: no releases were deleted. + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"interactive: dry run doesn't ask for confirmation and doesn't delete anything", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "delete", fireProject.Name, "2.1", "--dry-run"}) + return rootCmd.ExecuteC() + }) + + standardDeleteTestBody(api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, 0, api.GetPendingMessageCount()) + + assert.Equal(t, heredoc.Doc(` + Project Fire Project + DRY RUN: no changes will be made in Octopus. + + Would delete 1 release(s) from project Fire Project: + 2.1 + + DRY RUN: no releases were deleted. + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + // ----- failure modes ------ {"noprompt: error when deleting 1 release and it fails", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 05106062..b859e0ef 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -25,6 +25,7 @@ import ( workerCmd "github.com/OctopusDeploy/cli/pkg/cmd/worker" workerPoolCmd "github.com/OctopusDeploy/cli/pkg/cmd/workerpool" "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/dryrun" "github.com/OctopusDeploy/cli/pkg/factory" "github.com/OctopusDeploy/cli/pkg/question" "github.com/spf13/cobra" @@ -116,7 +117,7 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro // if we attempt to check the flags before Execute is called, cobra hasn't parsed anything yet, // so we'll get bad values. PersistentPreRun is a convenient callback for setting up our // environment after parsing but before execution. - cmd.PersistentPreRun = func(_ *cobra.Command, _ []string) { + cmd.PersistentPreRun = func(executedCmd *cobra.Command, _ []string) { // map flag alias values for k, v := range flagAliases { for _, aliasName := range v { @@ -138,6 +139,13 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro if spaceNameOrId := viper.GetString(constants.ConfigSpace); spaceNameOrId != "" { clientFactory.SetSpaceNameOrId(spaceNameOrId) } + + // --dry-run is declared by the individual commands that support it, not here. + // Arming the client guard means that if such a command still reaches a mutating + // endpoint, the request is refused rather than quietly going through. + if clientFactory != nil && dryrun.IsEnabled(executedCmd) { + clientFactory.SetDryRun(true) + } } cmd.RunE = func(cmd *cobra.Command, args []string) error { diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 39b2ccf0..a2ab4f39 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -12,6 +12,7 @@ const ( FlagOutputFormatLegacy = "outputFormat" FlagNoPrompt = "no-prompt" FlagEnableServiceMessages = "enable-service-messages" + FlagDryRun = "dry-run" ) // flags for storing things in the go context diff --git a/pkg/dryrun/dryrun.go b/pkg/dryrun/dryrun.go new file mode 100644 index 00000000..4552e8f4 --- /dev/null +++ b/pkg/dryrun/dryrun.go @@ -0,0 +1,95 @@ +// Package dryrun provides the shared pieces of the --dry-run flag: declaring it, +// detecting it, the banners a dry run prints, and the guard that keeps it honest. +// +// The flag is declared per command rather than persistently on the root command. +// A persistent flag would be accepted everywhere, including by the commands which +// have not implemented it, and silently mutating Octopus while the caller believes +// the run was a rehearsal is worse than having no flag at all. Declaring it locally +// means `--dry-run` on an unsupported command fails with "unknown flag". +// +// GuardRoundTripper is the second half of that guarantee. Once a dry run is under +// way, any request that would change server state is refused before it is sent, so +// a partially implemented dry run fails loudly instead of quietly mutating. +package dryrun + +import ( + "fmt" + "net/http" + "strings" + + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/output" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const FlagDescription = "Show what would happen, without making any changes in Octopus" + +// AddFlag declares --dry-run on a command that genuinely supports it. +func AddFlag(flags *pflag.FlagSet, value *bool) { + flags.BoolVar(value, constants.FlagDryRun, false, FlagDescription) +} + +// IsEnabled reports whether the command being executed declared --dry-run and it was set. +func IsEnabled(cmd *cobra.Command) bool { + if cmd == nil { + return false + } + enabled, err := cmd.Flags().GetBool(constants.FlagDryRun) + if err != nil { // the command doesn't declare the flag + return false + } + return enabled +} + +// Header opens a dry run's output. +func Header(cmd *cobra.Command) { + cmd.Printf("%s no changes will be made in Octopus.\n\n", output.Yellow("DRY RUN:")) +} + +// Footer closes a dry run's output, restating that nothing happened. +func Footer(cmd *cobra.Command, summary string) { + cmd.Printf("\n%s %s\n", output.Yellow("DRY RUN:"), summary) +} + +// BlockedError is returned when a dry run attempts a request that would change server state. +type BlockedError struct { + Method string + URL string +} + +func (e *BlockedError) Error() string { + return fmt.Sprintf("dry run blocked a %s request to %s; this command does not fully support --dry-run, please raise an issue", e.Method, e.URL) +} + +// GuardRoundTripper refuses to send anything other than a read-only request. +type GuardRoundTripper struct { + Next http.RoundTripper +} + +func NewGuardRoundTripper(next http.RoundTripper) *GuardRoundTripper { + if next == nil { + next = http.DefaultTransport + } + return &GuardRoundTripper{Next: next} +} + +func (g *GuardRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { + if !isReadOnly(r.Method) { + url := "" + if r.URL != nil { + url = r.URL.Path + } + return nil, &BlockedError{Method: strings.ToUpper(r.Method), URL: url} + } + return g.Next.RoundTrip(r) +} + +func isReadOnly(method string) bool { + switch strings.ToUpper(method) { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return true + default: + return false + } +} diff --git a/pkg/dryrun/dryrun_test.go b/pkg/dryrun/dryrun_test.go new file mode 100644 index 00000000..578d829c --- /dev/null +++ b/pkg/dryrun/dryrun_test.go @@ -0,0 +1,101 @@ +package dryrun_test + +import ( + "bytes" + "io" + "net/http" + "testing" + + cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root" + "github.com/OctopusDeploy/cli/pkg/constants" + "github.com/OctopusDeploy/cli/pkg/dryrun" + "github.com/OctopusDeploy/cli/test/fixtures" + "github.com/OctopusDeploy/cli/test/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +type recordingRoundTripper struct { + Requests []*http.Request +} + +func (r *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + r.Requests = append(r.Requests, req) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(nil))}, nil +} + +func TestGuardRoundTripper(t *testing.T) { + tests := []struct { + method string + blocked bool + }{ + {http.MethodGet, false}, + {http.MethodHead, false}, + {http.MethodOptions, false}, + {http.MethodPost, true}, + {http.MethodPut, true}, + {http.MethodPatch, true}, + {http.MethodDelete, true}, + } + + for _, test := range tests { + t.Run(test.method, func(t *testing.T) { + next := &recordingRoundTripper{} + guard := dryrun.NewGuardRoundTripper(next) + + request, err := http.NewRequest(test.method, "http://server/api/Spaces-1/releases/Releases-1", nil) + assert.Nil(t, err) + + response, err := guard.RoundTrip(request) + + if test.blocked { + assert.Nil(t, response) + assert.EqualError(t, err, "dry run blocked a "+test.method+" request to /api/Spaces-1/releases/Releases-1; this command does not fully support --dry-run, please raise an issue") + assert.Empty(t, next.Requests, "the request must not reach the server") + } else { + assert.Nil(t, err) + assert.NotNil(t, response) + assert.Len(t, next.Requests, 1) + } + }) + } +} + +func TestIsEnabled(t *testing.T) { + t.Run("false when the command doesn't declare the flag", func(t *testing.T) { + cmd := &cobra.Command{Use: "thing"} + assert.False(t, dryrun.IsEnabled(cmd)) + }) + + t.Run("false when the flag is declared but not set", func(t *testing.T) { + cmd := &cobra.Command{Use: "thing"} + value := false + dryrun.AddFlag(cmd.Flags(), &value) + assert.False(t, dryrun.IsEnabled(cmd)) + }) + + t.Run("true when the flag is set", func(t *testing.T) { + cmd := &cobra.Command{Use: "thing"} + value := false + dryrun.AddFlag(cmd.Flags(), &value) + assert.Nil(t, cmd.Flags().Set(constants.FlagDryRun, "true")) + assert.True(t, dryrun.IsEnabled(cmd)) + assert.True(t, value) + }) +} + +// --dry-run must never be silently accepted by a command that hasn't implemented it, +// or the caller would believe a mutation was skipped when it wasn't. +func TestUnsupportedCommandRejectsDryRun(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + api := testutil.NewMockHttpServer() + space1 := fixtures.NewSpace("Spaces-1", "Default Space") + + rootCmd := cmdRoot.NewCmdRoot(testutil.NewMockFactoryWithSpace(api, space1), nil, nil) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + rootCmd.SetArgs([]string{"release", "list", "--project", "Fire Project", "--dry-run"}) + + _, err := rootCmd.ExecuteC() + assert.EqualError(t, err, "unknown flag: --dry-run") +} diff --git a/pkg/packages/packages.go b/pkg/packages/packages.go index 3eff889a..36037129 100644 --- a/pkg/packages/packages.go +++ b/pkg/packages/packages.go @@ -533,32 +533,40 @@ func printPackageVersions(ioWriter io.Writer, packages []*StepPackageVersion) er return t.Print() } -func AskPackageOverrideLoop( - packageVersionBaseline []*StepPackageVersion, - defaultPackageVersion string, // the --package-version command line flag - initialPackageOverrideFlags []string, // the --package command line flag (multiple occurrences) - asker question.Asker, - stdout io.Writer) ([]*StepPackageVersion, []*PackageVersionOverride, error) { +// BuildPackageVersionOverrides resolves the package specifications that arrived on the +// command line (--package-version and --package) against a baseline. Anything that can't +// be parsed or resolved is silently ignored. +func BuildPackageVersionOverrides(packageVersionBaseline []*StepPackageVersion, defaultPackageVersion string, packageOverrideFlags []string) []*PackageVersionOverride { packageVersionOverrides := make([]*PackageVersionOverride, 0) - // pickup any partial package specifications that may have arrived on the commandline if defaultPackageVersion != "" { // blind apply to everything packageVersionOverrides = append(packageVersionOverrides, &PackageVersionOverride{Version: defaultPackageVersion}) } - for _, s := range initialPackageOverrideFlags { + for _, s := range packageOverrideFlags { ambOverride, err := ParsePackageOverrideString(s) if err != nil { - continue // silently ignore anything that wasn't parseable (should we emit a warning?) + continue } resolvedOverride, err := ResolvePackageOverride(ambOverride, packageVersionBaseline) if err != nil { - continue // silently ignore anything that wasn't parseable (should we emit a warning?) + continue } packageVersionOverrides = append(packageVersionOverrides, resolvedOverride) } + return packageVersionOverrides +} + +func AskPackageOverrideLoop( + packageVersionBaseline []*StepPackageVersion, + defaultPackageVersion string, // the --package-version command line flag + initialPackageOverrideFlags []string, // the --package command line flag (multiple occurrences) + asker question.Asker, + stdout io.Writer) ([]*StepPackageVersion, []*PackageVersionOverride, error) { + packageVersionOverrides := BuildPackageVersionOverrides(packageVersionBaseline, defaultPackageVersion, initialPackageOverrideFlags) + overriddenPackageVersions := ApplyPackageOverrides(packageVersionBaseline, packageVersionOverrides) outerLoop: From 4986b5c3b9d29dcb1717d9e620dfec100883bc94 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:06:37 +1000 Subject: [PATCH 02/13] fix: pin the git ref when previewing a config-as-code release `buildReleasePreview` left `gitReferenceKey` empty when neither --git-ref nor --git-commit was given. `DeploymentProcesses.Get` tolerates that (the SDK falls back to the project's default branch) but `Deployments.GetDeploymentSettings` does not: for a version-controlled project the DeploymentSettings link is git-templated, and expanding it with no gitRef yields a malformed path. So `release create -p CacProject -c SomeChannel --dry-run` failed while the real create succeeded. Mirror the SDK and default to the project's default branch, which also stops the process template and the deployment settings being read from different refs. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/create/create.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 8b84757d..a4a10126 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -482,11 +482,20 @@ func buildReleasePreview(octopus *octopusApiClient.Client, space *spaces.Space, preview.Channel = channel.Name gitReferenceKey := "" - if resolvedProject.PersistenceSettings.Type() == projects.PersistenceSettingsTypeVersionControlled { + if resolvedProject.PersistenceSettings != nil && resolvedProject.PersistenceSettings.Type() == projects.PersistenceSettingsTypeVersionControlled { gitReferenceKey = options.GitReference if options.GitCommit != "" { // prefer a specific git commit if one was specified gitReferenceKey = options.GitCommit } + if gitReferenceKey == "" { + // DeploymentProcesses.Get falls back to the default branch for us, but + // GetDeploymentSettings doesn't: for a config-as-code project its link is + // git-templated, and expanding it without a gitRef produces a bad path. Pin the + // ref here so the process template and the deployment settings agree, too. + if gitSettings, ok := resolvedProject.PersistenceSettings.(projects.GitPersistenceSettings); ok { + gitReferenceKey = gitSettings.DefaultBranch() + } + } } deploymentProcess, err := octopus.DeploymentProcesses.Get(resolvedProject, gitReferenceKey) From 1a5c0e05465b9573fc848aa4093b069181035ca3 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:06:57 +1000 Subject: [PATCH 03/13] fix: don't panic on a donor step with no donor package `AskQuestions` entered the donor branch whenever `DonorPackageStepID` was set, then dereferenced `versioningStrategy.DonorPackage` unconditionally - a nil pointer panic in interactive mode for a project whose versioning strategy names a donor step but no package reference. Branch on `DonorPackage` instead, and treat a bare `DonorPackageStepID` the same way `determineReleaseVersion` already does: there is nothing to read a version from, so leave it to the server. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/create/create.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index a4a10126..110b81bd 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -813,7 +813,7 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques return err } - if versioningStrategy.DonorPackageStepID != nil || versioningStrategy.DonorPackage != nil { + if versioningStrategy.DonorPackage != nil { // we've already done the package version work so we can just ask the donor package which version it has selected var donorPackage *packages.StepPackageVersion for _, pkg := range overriddenPackageVersions { @@ -837,6 +837,9 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques } else { options.Version = fmt.Sprintf("%s+%s", donorPackage.Version, versionMetadata) } + } else if versioningStrategy.DonorPackageStepID != nil { + // a donor step with no package reference; there's nothing to read a version from, so + // leave options.Version blank and let the server work it out } else if versioningStrategy.Template != "" { // we already loaded the deployment process template when we were looking for packages options.Version, err = askVersion(asker, deploymentProcessTemplate.NextVersionIncrement) From 78e97723e25520e9f40d17c04936f87d7811c5ef Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:07:14 +1000 Subject: [PATCH 04/13] fix: echo --dry-run in the automation command during a dry run In interactive mode the "Automation Command:" line is printed before the dry-run check, and it omitted --dry-run. The natural reading of "here's the equivalent automation command" is that it reproduces what just ran; pasting it into CI would have created a real release instead. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/create/create.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 110b81bd..83d50bc6 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -287,6 +287,9 @@ func createRun(cmd *cobra.Command, f factory.Factory, flags *CreateFlags) error resolvedFlags.ReleaseNotes.Value = options.ReleaseNotes resolvedFlags.IgnoreExisting.Value = options.IgnoreIfAlreadyExists resolvedFlags.IgnoreChannelRules.Value = options.IgnoreChannelRules + // carry --dry-run through, so the echoed command reproduces the run that was just + // performed rather than silently promoting a rehearsal into a real create + resolvedFlags.DryRun.Value = flags.DryRun.Value if len(options.CustomFields) > 0 { for k, v := range options.CustomFields { resolvedFlags.CustomFields.Value = append(resolvedFlags.CustomFields.Value, fmt.Sprintf("%s: %s", k, v)) @@ -309,6 +312,7 @@ func createRun(cmd *cobra.Command, f factory.Factory, flags *CreateFlags) error resolvedFlags.GitResourceRefsSpec, resolvedFlags.CustomFields, resolvedFlags.Version, + resolvedFlags.DryRun, ) cmd.Printf("\nAutomation Command: %s\n", autoCmd) } From 5f1a06dc339195a73e532d3a540943502a8990be Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:07:49 +1000 Subject: [PATCH 05/13] fix: flag the channel-rule caveat in a --ignore-channel-rules dry run `BuildPackageVersionBaselineForChannel` always applies the channel's version rules, so with --ignore-channel-rules the previewed package versions (and a donor-derived Version) can differ from what the real create ends up with. Say so in the output rather than presenting the filtered plan as certain. Left the resolution itself alone: skipping the rule filter here would guess at how the server resolves versions under that flag, and a wrong guess makes the preview less accurate, not more. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/create/create.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 83d50bc6..7b6e327e 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -609,6 +609,11 @@ func printReleasePreview(cmd *cobra.Command, preview *ReleasePreview, outputForm if err := t.Print(); err != nil { return err } + if preview.IgnoreChannelRules { + // the baseline is always resolved with the channel's version rules applied, but the + // real create is told to ignore them, so the server may land on different versions + cmd.Printf("%s\n", output.Dim("Note: resolved using the channel's version rules; --ignore-channel-rules means the server may pick different versions.")) + } } else if len(preview.PackageOverrides) > 0 { cmd.Printf("\nPackage overrides:\n") for _, ov := range preview.PackageOverrides { From 7f427cf4367dd1f5dd68338cc9c8f8b08dd8dcb8 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:08:19 +1000 Subject: [PATCH 06/13] fix: print custom fields in a deterministic order Ranging a map shuffled the "Custom Field" rows in the dry-run preview run-to-run, which is noisy for anyone diffing two dry runs and can't be asserted on exactly. Sort the keys. The automation-command line had the same problem, so it gets the same treatment. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/create/create.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 7b6e327e..abdd1103 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "sort" "strings" "time" @@ -290,10 +291,8 @@ func createRun(cmd *cobra.Command, f factory.Factory, flags *CreateFlags) error // carry --dry-run through, so the echoed command reproduces the run that was just // performed rather than silently promoting a rehearsal into a real create resolvedFlags.DryRun.Value = flags.DryRun.Value - if len(options.CustomFields) > 0 { - for k, v := range options.CustomFields { - resolvedFlags.CustomFields.Value = append(resolvedFlags.CustomFields.Value, fmt.Sprintf("%s: %s", k, v)) - } + for _, k := range sortedKeys(options.CustomFields) { // stable order, so the same answers give the same command + resolvedFlags.CustomFields.Value = append(resolvedFlags.CustomFields.Value, fmt.Sprintf("%s: %s", k, options.CustomFields[k])) } spaceName := "" @@ -588,8 +587,8 @@ func printReleasePreview(cmd *cobra.Command, preview *ReleasePreview, outputForm for _, ref := range preview.GitResources { rows = append(rows, output.NewDataRow("Git Resource", ref)) } - for name, value := range preview.CustomFields { - rows = append(rows, output.NewDataRow("Custom Field", fmt.Sprintf("%s: %s", name, value))) + for _, name := range sortedKeys(preview.CustomFields) { // map iteration order would shuffle the rows run-to-run + rows = append(rows, output.NewDataRow("Custom Field", fmt.Sprintf("%s: %s", name, preview.CustomFields[name]))) } if preview.IgnoreExisting { rows = append(rows, output.NewDataRow("Ignore Existing", "true")) @@ -625,6 +624,15 @@ func printReleasePreview(cmd *cobra.Command, preview *ReleasePreview, outputForm return nil } +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + func orDefault(value string, fallback string) string { if value == "" { return fallback From 83b7c5663da42f6efc851cbad67c9cca40e182ae Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:09:39 +1000 Subject: [PATCH 07/13] feat: json output and an explicit empty plan for release delete --dry-run --output-format is a persistent root flag, so `release delete --dry-run -f json` was accepted but emitted ANSI-coloured prose. Add a DeletePlan JSON branch mirroring the release create preview, with DryRun as the first field. Also print the plan before the "nothing matched" early return, so a dry run that matched no versions reports "Would delete 0 release(s)" instead of exiting 0 with no output, which was indistinguishable from the flag being ignored. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/delete/delete.go | 61 ++++++++++++++++++++------- pkg/cmd/release/delete/delete_test.go | 40 ++++++++++++++++++ 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/release/delete/delete.go b/pkg/cmd/release/delete/delete.go index 7aa5e118..4cf6b50a 100644 --- a/pkg/cmd/release/delete/delete.go +++ b/pkg/cmd/release/delete/delete.go @@ -1,6 +1,7 @@ package delete import ( + "encoding/json" "errors" "fmt" @@ -127,13 +128,10 @@ func deleteRun(cmd *cobra.Command, f factory.Factory, flags *Flags, args []strin } } - if len(releasesToDelete) == 0 { - return nil // no work to do, just exit - } - // a dry run never deletes anything, so there is nothing to confirm; the plan - // printed below says what would have happened instead - if !flags.DryRun.Value { + // printed below says what would have happened instead. Nothing to confirm when + // nothing matched, either - the check below handles that case. + if len(releasesToDelete) > 0 && !flags.DryRun.Value { cmd.Printf("You are about to delete the following releases:\n") for _, r := range releasesToDelete { cmd.Printf("%s\n", r.Version) @@ -157,13 +155,18 @@ func deleteRun(cmd *cobra.Command, f factory.Factory, flags *Flags, args []strin } } - if len(releasesToDelete) == 0 { - // no work to do, just exit - return nil + // deliberately before the empty check: a rehearsal that matched nothing still needs to + // say so, or the caller can't tell it from a flag that was ignored + if flags.DryRun.Value { + outputFormat, err := cmd.Flags().GetString(constants.FlagOutputFormat) + if err != nil { + outputFormat = constants.OutputFormatTable + } + return printDeletePlan(cmd, selectedProject, releasesToDelete, outputFormat) } - if flags.DryRun.Value { - printDeletePlan(cmd, selectedProject, releasesToDelete) + if len(releasesToDelete) == 0 { + // no work to do, just exit return nil } @@ -190,13 +193,41 @@ func deleteRun(cmd *cobra.Command, f factory.Factory, flags *Flags, args []strin return releaseDeleteErrors.ErrorOrNil() } -func printDeletePlan(cmd *cobra.Command, project *projects.Project, releasesToDelete []*releases.Release) { - dryrun.Header(cmd) - cmd.Printf("Would delete %d release(s) from project %s:\n", len(releasesToDelete), output.Cyan(project.GetName())) +// DeletePlan is what a dry run would have deleted. It mirrors the release create preview: +// DryRun is always true, so a machine-readable consumer cannot mistake a plan for a result. +type DeletePlan struct { + DryRun bool + Project string + Versions []string +} + +func printDeletePlan(cmd *cobra.Command, project *projects.Project, releasesToDelete []*releases.Release, outputFormat string) error { + versions := make([]string, 0, len(releasesToDelete)) for _, r := range releasesToDelete { - cmd.Printf(" %s\n", r.Version) + versions = append(versions, r.Version) + } + + if outputFormat == constants.OutputFormatJson { + data, err := json.Marshal(&DeletePlan{DryRun: true, Project: project.GetName(), Versions: versions}) + if err != nil { + return err + } + _, _ = cmd.OutOrStdout().Write(data) + cmd.Println() + return nil + } + + dryrun.Header(cmd) + if len(versions) == 0 { + cmd.Printf("Would delete 0 release(s) from project %s: no releases matched.\n", output.Cyan(project.GetName())) + } else { + cmd.Printf("Would delete %d release(s) from project %s:\n", len(versions), output.Cyan(project.GetName())) + for _, v := range versions { + cmd.Printf(" %s\n", v) + } } dryrun.Footer(cmd, "no releases were deleted.") + return nil } func selectReleases(octopus *octopusApiClient.Client, project *projects.Project, ask question.Asker) ([]*releases.Release, error) { diff --git a/pkg/cmd/release/delete/delete_test.go b/pkg/cmd/release/delete/delete_test.go index 6b780b4e..d21c67b7 100644 --- a/pkg/cmd/release/delete/delete_test.go +++ b/pkg/cmd/release/delete/delete_test.go @@ -230,6 +230,46 @@ func TestReleaseDelete(t *testing.T) { assert.Equal(t, "", stdErr.String()) }}, + {"noprompt: dry run with json output emits a machine readable plan flagged as a dry run", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "delete", "--project", fireProject.Name, "--version", "2.0", "--no-prompt", "--dry-run", "--output-format", "json"}) + return rootCmd.ExecuteC() + }) + + standardDeleteTestBody(api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, 0, api.GetPendingMessageCount()) + + assert.Equal(t, `{"DryRun":true,"Project":"Fire Project","Versions":["2.0"]}`+"\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"noprompt: dry run says so when nothing matches", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "delete", "--project", fireProject.Name, "--version", "9.9", "--no-prompt", "--dry-run"}) + return rootCmd.ExecuteC() + }) + + standardDeleteTestBody(api) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, 0, api.GetPendingMessageCount()) + + assert.Equal(t, heredoc.Doc(` + DRY RUN: no changes will be made in Octopus. + + Would delete 0 release(s) from project Fire Project: no releases matched. + + DRY RUN: no releases were deleted. + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + // ----- failure modes ------ {"noprompt: error when deleting 1 release and it fails", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { From eca0a57df264b46e28cdae048443bc8794b217b0 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:10:11 +1000 Subject: [PATCH 08/13] refactor: rename SetDryRun to EnableDryRunGuard `SetDryRun(false)` was a silent no-op: once armed, the guard short-circuits every later call, so a caller trying to turn it off would have been left with every mutation blocked and nothing to say why. The guard is deliberately one-way for the lifetime of the process, so make the API say that instead of promising a toggle it doesn't deliver. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/apiclient/client_factory.go | 23 +++++++++++++---------- pkg/apiclient/client_factory_test.go | 4 ++-- pkg/cmd/root/root.go | 2 +- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/pkg/apiclient/client_factory.go b/pkg/apiclient/client_factory.go index 12c10ea7..7aaa56f3 100644 --- a/pkg/apiclient/client_factory.go +++ b/pkg/apiclient/client_factory.go @@ -47,11 +47,14 @@ type ClientFactory interface { // GetHttpClient returns a raw http client which can be used to query Octopus GetHttpClient() (*http.Client, error) - // SetDryRun puts the client into dry-run mode, where any request that would change - // server state is refused before it is sent. It backstops the per-command --dry-run - // implementations; a command which hasn't finished implementing dry run fails loudly - // rather than mutating Octopus while claiming it did not. - SetDryRun(enabled bool) + // EnableDryRunGuard puts the client into dry-run mode, where any request that would + // change server state is refused before it is sent. It backstops the per-command + // --dry-run implementations; a command which hasn't finished implementing dry run fails + // loudly rather than mutating Octopus while claiming it did not. + // + // The guard is one-way for the lifetime of the process: there is no way to disable it + // again, because nothing should ever want to. + EnableDryRunGuard() } type Client struct { @@ -267,11 +270,11 @@ func (c *Client) GetHttpClient() (*http.Client, error) { return c.HttpClient, nil } -// SetDryRun wraps the transport in the dry-run guard. It must be called before the -// space-scoped or system clients are created, which is why the root command arms it +// EnableDryRunGuard wraps the transport in the dry-run guard. It must be called before +// the space-scoped or system clients are created, which is why the root command arms it // from PersistentPreRun; both clients are built lazily during RunE. -func (c *Client) SetDryRun(enabled bool) { - if !enabled || c.dryRun { +func (c *Client) EnableDryRunGuard() { + if c.dryRun { return } c.dryRun = true @@ -434,4 +437,4 @@ func (s *stubClientFactory) GetHttpClient() (*http.Client, error) { return nil, nil } -func (s *stubClientFactory) SetDryRun(_ bool) {} +func (s *stubClientFactory) EnableDryRunGuard() {} diff --git a/pkg/apiclient/client_factory_test.go b/pkg/apiclient/client_factory_test.go index 892d35c5..6971ddfb 100644 --- a/pkg/apiclient/client_factory_test.go +++ b/pkg/apiclient/client_factory_test.go @@ -80,13 +80,13 @@ func (r *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, er return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(nil))}, nil } -func TestClientFactory_SetDryRun_RefusesMutatingRequests(t *testing.T) { +func TestClientFactory_EnableDryRunGuard_RefusesMutatingRequests(t *testing.T) { transport := &recordingRoundTripper{} apiKeyCredential, _ := client.NewApiKey(apiKey) clientFactory, err := apiclient.NewClientFactory(&http.Client{Transport: transport}, hostUrl, apiKeyCredential, "", qa) testutil.RequireSuccess(t, err) - clientFactory.SetDryRun(true) + clientFactory.EnableDryRunGuard() httpClient, err := clientFactory.GetHttpClient() testutil.RequireSuccess(t, err) diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index b859e0ef..c1867c23 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -144,7 +144,7 @@ func NewCmdRoot(f factory.Factory, clientFactory apiclient.ClientFactory, askPro // Arming the client guard means that if such a command still reaches a mutating // endpoint, the request is refused rather than quietly going through. if clientFactory != nil && dryrun.IsEnabled(executedCmd) { - clientFactory.SetDryRun(true) + clientFactory.EnableDryRunGuard() } } From 9b571f62119dfbf1f142ef48cb6e0ae3e44f2de3 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 31 Aug 2026 12:10:43 +1000 Subject: [PATCH 09/13] test: hoist recordingRoundTripper into testutil It was defined identically in pkg/dryrun and pkg/apiclient; put one copy next to MockHttpServer so the next dry-run-covered command's tests don't make a third. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/apiclient/client_factory_test.go | 13 +------------ pkg/dryrun/dryrun_test.go | 12 +----------- test/testutil/testutil.go | 12 ++++++++++++ 3 files changed, 14 insertions(+), 23 deletions(-) diff --git a/pkg/apiclient/client_factory_test.go b/pkg/apiclient/client_factory_test.go index 6971ddfb..fa9ebe4f 100644 --- a/pkg/apiclient/client_factory_test.go +++ b/pkg/apiclient/client_factory_test.go @@ -1,8 +1,6 @@ package apiclient_test import ( - "bytes" - "io" "net/http" "testing" @@ -71,17 +69,8 @@ func TestNewClientFactory_WhenHostAndAccessTokenAreSupplied_ReturnsClientFactory assert.NotNil(t, factory) } -type recordingRoundTripper struct { - Requests []*http.Request -} - -func (r *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - r.Requests = append(r.Requests, req) - return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(nil))}, nil -} - func TestClientFactory_EnableDryRunGuard_RefusesMutatingRequests(t *testing.T) { - transport := &recordingRoundTripper{} + transport := &testutil.RecordingRoundTripper{} apiKeyCredential, _ := client.NewApiKey(apiKey) clientFactory, err := apiclient.NewClientFactory(&http.Client{Transport: transport}, hostUrl, apiKeyCredential, "", qa) testutil.RequireSuccess(t, err) diff --git a/pkg/dryrun/dryrun_test.go b/pkg/dryrun/dryrun_test.go index 578d829c..9f2b2c3d 100644 --- a/pkg/dryrun/dryrun_test.go +++ b/pkg/dryrun/dryrun_test.go @@ -2,7 +2,6 @@ package dryrun_test import ( "bytes" - "io" "net/http" "testing" @@ -15,15 +14,6 @@ import ( "github.com/stretchr/testify/assert" ) -type recordingRoundTripper struct { - Requests []*http.Request -} - -func (r *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - r.Requests = append(r.Requests, req) - return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(nil))}, nil -} - func TestGuardRoundTripper(t *testing.T) { tests := []struct { method string @@ -40,7 +30,7 @@ func TestGuardRoundTripper(t *testing.T) { for _, test := range tests { t.Run(test.method, func(t *testing.T) { - next := &recordingRoundTripper{} + next := &testutil.RecordingRoundTripper{} guard := dryrun.NewGuardRoundTripper(next) request, err := http.NewRequest(test.method, "http://server/api/Spaces-1/releases/Releases-1", nil) diff --git a/test/testutil/testutil.go b/test/testutil/testutil.go index 21ac771b..890b0dd4 100644 --- a/test/testutil/testutil.go +++ b/test/testutil/testutil.go @@ -142,3 +142,15 @@ func CaptureConsoleOutput(f func()) string { return buf.String() } + +// RecordingRoundTripper is a stand-in http.RoundTripper that records the requests it is +// given and answers each with an empty 200. Useful for asserting that a request either did +// or did not make it as far as the transport. +type RecordingRoundTripper struct { + Requests []*http.Request +} + +func (r *RecordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + r.Requests = append(r.Requests, req) + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(nil))}, nil +} From a8a4f5063201d438610618cea77ac2f6e922ed20 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 14 Sep 2026 22:49:19 +1000 Subject: [PATCH 10/13] fix: carry --package-version into the dry-run preview `buildReleasePreview` never copied `DefaultPackageVersion`, and the no-channel branch returns before it would have been applied to a package baseline, so `release create -p Example --package-version 9.9.42 --no-prompt --dry-run` produced exactly the same plan as omitting the flag even though the real request sends it. Carry it as its own preview field and show it in both output formats whenever it is set, including when the channel (and so the package selection) is left to the server. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/create/create.go | 62 +++++++++-------- pkg/cmd/release/create/create_test.go | 95 +++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 26 deletions(-) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index abdd1103..f0fb5b72 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -424,20 +424,24 @@ func resolveVersioningStrategy(octopus *octopusApiClient.Client, project *projec // CLI can resolve it. An empty Channel or Version means the Octopus Server decides it. type ReleasePreview struct { // always true; it marks machine-readable output as a plan rather than a result - DryRun bool - Space string - Project string - Channel string - GitReference string `json:",omitempty"` - GitCommit string `json:",omitempty"` - Version string - ReleaseNotes string `json:",omitempty"` - PackageVersions []*packages.StepPackageVersion `json:",omitempty"` - PackageOverrides []string `json:",omitempty"` - GitResources []string `json:",omitempty"` - CustomFields map[string]string `json:",omitempty"` - IgnoreExisting bool - IgnoreChannelRules bool + DryRun bool + Space string + Project string + Channel string + GitReference string `json:",omitempty"` + GitCommit string `json:",omitempty"` + Version string + // the --package-version default; kept separately from PackageVersions because it is what + // was asked for, and it is the only package information we have when the channel (and so + // the package selection) is left to the server + DefaultPackageVersion string `json:",omitempty"` + ReleaseNotes string `json:",omitempty"` + PackageVersions []*packages.StepPackageVersion `json:",omitempty"` + PackageOverrides []string `json:",omitempty"` + GitResources []string `json:",omitempty"` + CustomFields map[string]string `json:",omitempty"` + IgnoreExisting bool + IgnoreChannelRules bool } // buildReleasePreview describes the release that would be created, without creating it. @@ -450,18 +454,19 @@ func buildReleasePreview(octopus *octopusApiClient.Client, space *spaces.Space, } preview := &ReleasePreview{ - DryRun: true, - Project: options.ProjectName, - Channel: options.ChannelName, - GitReference: options.GitReference, - GitCommit: options.GitCommit, - Version: options.Version, - ReleaseNotes: options.ReleaseNotes, - PackageOverrides: options.PackageVersionOverrides, - GitResources: options.GitResourceRefs, - CustomFields: options.CustomFields, - IgnoreExisting: options.IgnoreIfAlreadyExists, - IgnoreChannelRules: options.IgnoreChannelRules, + DryRun: true, + Project: options.ProjectName, + Channel: options.ChannelName, + GitReference: options.GitReference, + GitCommit: options.GitCommit, + Version: options.Version, + DefaultPackageVersion: options.DefaultPackageVersion, + ReleaseNotes: options.ReleaseNotes, + PackageOverrides: options.PackageVersionOverrides, + GitResources: options.GitResourceRefs, + CustomFields: options.CustomFields, + IgnoreExisting: options.IgnoreIfAlreadyExists, + IgnoreChannelRules: options.IgnoreChannelRules, } if space != nil { preview.Space = space.GetName() @@ -577,6 +582,11 @@ func printReleasePreview(cmd *cobra.Command, preview *ReleasePreview, outputForm output.NewDataRow("Channel", orDefault(preview.Channel, byServer)), output.NewDataRow("Version", orDefault(preview.Version, byServer)), } + if preview.DefaultPackageVersion != "" { + // shown even when the packages themselves are left to the server, so a dry run with + // --package-version doesn't look identical to one without it + rows = append(rows, output.NewDataRow("Default Package Version", preview.DefaultPackageVersion)) + } if preview.GitReference != "" { rows = append(rows, output.NewDataRow("Git Reference", preview.GitReference)) } diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 6681e5ad..5690a745 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -2966,6 +2966,101 @@ func TestReleaseCreate_DryRun(t *testing.T) { assert.Equal(t, `{"DryRun":true,"Space":"Default Space","Project":"Fire Project","Channel":"","Version":"","IgnoreExisting":false,"IgnoreChannelRules":false}`+"\n", stdOut.String()) assert.Equal(t, "", stdErr.String()) }}, + + {"dry run without a channel still reports the default package version", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "create", "--project", fireProject.Name, "--package-version", "9.9.42", "--dry-run"}) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, 0, api.GetPendingMessageCount()) + + assert.Equal(t, heredoc.Doc(` + DRY RUN: no changes will be made in Octopus. + + Would create a release with: + Space Default Space + Project Fire Project + Channel (determined by the Octopus Server) + Version (determined by the Octopus Server) + Default Package Version 9.9.42 + Release Notes (none) + + DRY RUN: no release was created. + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"dry run without a channel reports the default package version alongside per-package overrides", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "create", + "--project", fireProject.Name, + "--package-version", "9.9.42", + "--package", "pterm:1.2", + "--dry-run", + }) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, 0, api.GetPendingMessageCount()) + + assert.Equal(t, heredoc.Doc(` + DRY RUN: no changes will be made in Octopus. + + Would create a release with: + Space Default Space + Project Fire Project + Channel (determined by the Octopus Server) + Version (determined by the Octopus Server) + Default Package Version 9.9.42 + Release Notes (none) + + Package overrides: + pterm:1.2 + + DRY RUN: no release was created. + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"dry run json output without a channel carries the default package version and overrides", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "create", + "--project", fireProject.Name, + "--package-version", "9.9.42", + "--package", "pterm:1.2", + "--dry-run", + "--output-format", "json", + }) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, 0, api.GetPendingMessageCount()) + + assert.Equal(t, `{"DryRun":true,"Space":"Default Space","Project":"Fire Project","Channel":"","Version":"","DefaultPackageVersion":"9.9.42","PackageOverrides":["pterm:1.2"],"IgnoreExisting":false,"IgnoreChannelRules":false}`+"\n", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, } for _, test := range tests { From 7192c01d491f501580e87bcca07cde883eea870b Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 14 Sep 2026 22:49:37 +1000 Subject: [PATCH 11/13] test: cover the donor-step panic and the echoed dry-run automation command Two regression tests for fixes that landed without coverage: - a versioning strategy naming a donor step with no donor package used to panic in AskQuestions; the test reproduces the nil dereference against the old condition and now asserts the version is left for the server to assign. - the interactive path prints "Automation Command:" before the dry-run preview, and the echoed command has to include --dry-run so pasting it into CI doesn't turn a rehearsal into a real create. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/create/create_test.go | 152 ++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 5690a745..33e00d89 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -13,6 +13,7 @@ import ( cmdRoot "github.com/OctopusDeploy/cli/pkg/cmd/root" "github.com/OctopusDeploy/cli/pkg/executor" "github.com/OctopusDeploy/cli/pkg/packages" + "github.com/OctopusDeploy/cli/pkg/question" "github.com/OctopusDeploy/cli/pkg/surveyext" "github.com/OctopusDeploy/cli/test/fixtures" "github.com/OctopusDeploy/cli/test/testutil" @@ -437,6 +438,84 @@ func TestReleaseCreate_AskQuestions_RegularProject(t *testing.T) { assert.Equal(t, "Fire Project Default Channel", options.ChannelName) assert.Equal(t, "6.2.1", options.Version) }}, + + {"a donor step with no donor package leaves the version to the server rather than panicking", func(t *testing.T, api *testutil.MockHttpServer, qa *testutil.AskMocker, stdout *bytes.Buffer) { + options := &executor.TaskOptionsCreateRelease{ + ProjectName: "fire project", + ChannelName: "fire project default channel", + ReleaseNotes: "-", + } + + errReceiver := testutil.GoBegin(func() error { + defer testutil.Close(api, qa) + octopus, _ := octopusApiClient.NewClient(testutil.NewMockHttpClientWithTransport(api), serverUrl, placeholderApiKey, "") + return create.AskQuestions(octopus, stdout, qa.AsAsker(), options) + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/spaces").RespondWith(rootResource) + + donorStepID := "00000000-0000-0000-0000-000000000001" + var fireProject2 = *fireProject // clone the struct value + // a strategy which names a donor step but no package reference; the server can end up + // in this state, and dereferencing DonorPackage here used to panic + fireProject2.VersioningStrategy = &projects.VersioningStrategy{ + DonorPackageStepID: &donorStepID, + } + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/fire project").RespondWithStatus(404, "NotFound", nil) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects?partialName=fire+project"). + RespondWith(resources.Resources[*projects.Project]{ + Items: []*projects.Project{&fireProject2}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/deploymentprocesses/deploymentprocess-"+fireProjectID).RespondWith(depProcess) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/channels"). + RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{defaultChannel}, + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/deploymentprocesses/template?channel=Channels-1"). + RespondWith(&deployments.DeploymentProcessTemplate{ + Packages: []releases.ReleaseTemplatePackage{ + { + ActionName: "Verify", + FeedID: "feeds-builtin", + PackageID: "NuGet.CommandLine", + PackageReferenceName: "nuget-on-verify", + IsResolvable: true, + }, + }, + NextVersionIncrement: "27.9.33", // ignored; the strategy has no Template + }) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/feeds?ids=feeds-builtin&take=1").RespondWith(&feeds.Feeds{Items: []feeds.IFeed{ + &feeds.FeedResource{Name: "Builtin", FeedType: feeds.FeedTypeBuiltIn, Resource: resources.Resource{ + ID: "feeds-builtin", + Links: map[string]string{ + constants.LinkSearchPackageVersionsTemplate: "/api/Spaces-1/feeds/feeds-builtin/packages/versions{?packageId,take,skip,includePreRelease,versionRange,preReleaseTag,filter,includeReleaseNotes}", + }}}, + }}) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/feeds/feeds-builtin/packages/versions?packageId=NuGet.CommandLine&take=1").RespondWith(&resources.Resources[*octopusPackages.PackageVersion]{ + Items: []*octopusPackages.PackageVersion{{PackageID: "NuGet.CommandLine", Version: "6.2.1"}}, + }) + + _ = qa.ExpectQuestion(t, &survey.Input{ + Message: packageOverrideQuestion, + Default: "", + }).AnswerWith("y") + + // no version question at all; nothing was asked after the package loop + + err := <-errReceiver + assert.Nil(t, err) + + assert.Equal(t, "Fire Project", options.ProjectName) + assert.Equal(t, "Fire Project Default Channel", options.ChannelName) + assert.Equal(t, "", options.Version) // left blank, so the server assigns it + }}, } for _, test := range tests { @@ -3076,3 +3155,76 @@ func TestReleaseCreate_DryRun(t *testing.T) { }) } } + +// the interactive path prints an "Automation Command" line before the dry-run preview; it has +// to carry --dry-run through, or copying that line into CI turns a rehearsal into a real create +func TestReleaseCreate_DryRun_Interactive(t *testing.T) { + const spaceID = "Spaces-1" + const fireProjectID = "Projects-22" + + space1 := fixtures.NewSpace(spaceID, "Default Space") + depProcess := fixtures.NewDeploymentProcessForProject(spaceID, fireProjectID) + fireProject := fixtures.NewProject(spaceID, fireProjectID, "Fire Project", "Lifecycles-1", "ProjectGroups-1", depProcess.ID) + defaultChannel := fixtures.NewChannel(spaceID, "Channels-1", "Fire Project Default Channel", fireProjectID) + + api, qa := testutil.NewMockServerAndAsker() + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + askProvider := question.NewAskProvider(qa.AsAsker()) + + rootCmd := cmdRoot.NewCmdRoot(testutil.NewMockFactoryWithSpaceAndPrompt(api, space1, askProvider), nil, askProvider) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + + // everything is supplied on the command line, so the Q&A has nothing to ask; what matters + // here is what gets printed afterwards + receiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer testutil.Close(api, qa) + rootCmd.SetArgs([]string{"release", "create", + "--project", fireProject.Name, + "--channel", defaultChannel.Name, + "--version", "1.2.3", + "--release-notes", "Some notes", + "--dry-run", + }) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/deploymentprocesses/deploymentprocess-"+fireProjectID).RespondWith(depProcess) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/channels"). + RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{defaultChannel}, + }) + + // no packages, so no feed lookups and no package override loop + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/deploymentprocesses/template?channel=Channels-1"). + RespondWith(&deployments.DeploymentProcessTemplate{}) + + // note the absence of a POST to /releases/create/v1 + _, err := testutil.ReceivePair(receiver) + assert.Nil(t, err) + assert.Equal(t, 0, api.GetPendingMessageCount()) + + assert.Equal(t, heredoc.Doc(` + Project Fire Project + Channel Fire Project Default Channel + Version 1.2.3 + + Automation Command: octopus release create --space 'Default Space' --project 'Fire Project' --channel 'Fire Project Default Channel' --release-notes 'Some notes' --version '1.2.3' --dry-run --no-prompt + DRY RUN: no changes will be made in Octopus. + + Would create a release with: + Space Default Space + Project Fire Project + Channel Fire Project Default Channel + Version 1.2.3 + Release Notes Some notes + + DRY RUN: no release was created. + `), stdout.String()) + assert.Equal(t, "", stderr.String()) +} From 040d231ae3123019a76e8c1f397bd5c70d236ad7 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 14 Sep 2026 22:51:02 +1000 Subject: [PATCH 12/13] test: cover the config-as-code dry run reading from the default branch Locks in the git-ref pin: without it the deployment settings request comes out as /api/Spaces-1/projects/Projects-87//deploymentsettings, which is the 404 that made `release create -p CacProject -c SomeChannel --dry-run` fail while the real create succeeded. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/create/create_test.go | 57 +++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 33e00d89..e129fb02 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -3027,6 +3027,63 @@ func TestReleaseCreate_DryRun(t *testing.T) { assert.Equal(t, "", stdErr.String()) }}, + {"dry run for a config-as-code project without --git-ref reads everything from the default branch", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + const cacProjectID = "Projects-87" + cacDepProcess := fixtures.NewDeploymentProcessForVersionControlledProject(spaceID, cacProjectID, "main") + // NewVersionControlledProject's default branch; note VersioningStrategy is nil, as the + // server reports it for a CaC project, so the strategy comes from the deployment settings + cacProject := fixtures.NewVersionControlledProject(spaceID, cacProjectID, "CaC Project", "Lifecycles-1", "ProjectGroups-1", cacDepProcess.ID) + cacChannel := fixtures.NewChannel(spaceID, "Channels-34", "CaC Project Default Channel", cacProjectID) + cacDepSettings := fixtures.NewDeploymentSettingsForProject(spaceID, cacProjectID, &projects.VersioningStrategy{ + Template: "#{Octopus.Version.LastMajor}.#{Octopus.Version.LastMinor}.#{Octopus.Version.NextPatch}", + }) + + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + rootCmd.SetArgs([]string{"release", "create", + "--project", cacProject.Name, + "--channel", cacChannel.Name, + "--release-notes", "Some notes", + "--dry-run", + }) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/CaC Project").RespondWith(cacProject) + + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+cacProjectID+"/channels"). + RespondWith(resources.Resources[*channels.Channel]{ + Items: []*channels.Channel{cacChannel}, + }) + + // the git ref is pinned to the project's default branch; without that these two URLs + // would be missing their gitRef segment, and the deploymentsettings one would 404 + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+cacProjectID+"/main/deploymentprocesses").RespondWith(cacDepProcess) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+cacProjectID+"/main/deploymentprocesses/template?channel="+cacChannel.ID). + RespondWith(&deployments.DeploymentProcessTemplate{NextVersionIncrement: "27.9.3"}) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+cacProjectID+"/main/deploymentsettings").RespondWith(cacDepSettings) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, 0, api.GetPendingMessageCount()) + + assert.Equal(t, heredoc.Doc(` + DRY RUN: no changes will be made in Octopus. + + Would create a release with: + Space Default Space + Project CaC Project + Channel CaC Project Default Channel + Version 27.9.3 + Release Notes Some notes + + DRY RUN: no release was created. + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + {"dry run with json output emits a machine readable plan flagged as a dry run", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { defer api.Close() From 78d28b3e396364e9e88b58795a42de06a6f7b98b Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Mon, 14 Sep 2026 22:52:41 +1000 Subject: [PATCH 13/13] test: assert the dry-run custom field rows come out sorted Three fields supplied out of order, asserted as an exact table; map iteration order would have made this flaky before the sort. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/create/create_test.go | 41 +++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index e129fb02..daab1c4e 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -3027,6 +3027,47 @@ func TestReleaseCreate_DryRun(t *testing.T) { assert.Equal(t, "", stdErr.String()) }}, + {"dry run prints custom fields in a stable order", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { + cmdReceiver := testutil.GoBegin2(func() (*cobra.Command, error) { + defer api.Close() + // supplied in an order which isn't the sorted one, and map iteration would shuffle + // them anyway + rootCmd.SetArgs([]string{"release", "create", + "--project", fireProject.Name, + "--custom-field", "Ticket: JIRA-1", + "--custom-field", "Approver: Alice", + "--custom-field", "Reason: because", + "--dry-run", + }) + return rootCmd.ExecuteC() + }) + + api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/Fire Project").RespondWith(fireProject) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.Nil(t, err) + assert.Equal(t, 0, api.GetPendingMessageCount()) + + assert.Equal(t, heredoc.Doc(` + DRY RUN: no changes will be made in Octopus. + + Would create a release with: + Space Default Space + Project Fire Project + Channel (determined by the Octopus Server) + Version (determined by the Octopus Server) + Release Notes (none) + Custom Field Approver: Alice + Custom Field Reason: because + Custom Field Ticket: JIRA-1 + + DRY RUN: no release was created. + `), stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + {"dry run for a config-as-code project without --git-ref reads everything from the default branch", func(t *testing.T, api *testutil.MockHttpServer, rootCmd *cobra.Command, stdOut *bytes.Buffer, stdErr *bytes.Buffer) { const cacProjectID = "Projects-87" cacDepProcess := fixtures.NewDeploymentProcessForVersionControlledProject(spaceID, cacProjectID, "main")