From af509e5b63b4cb4edf1b754af1a67fc45561ed46 Mon Sep 17 00:00:00 2001 From: Nick Josevski Date: Wed, 19 Aug 2026 11:45:14 +1000 Subject: [PATCH] fix: report unknown release versions instead of a server null reference `release deploy` passed --version straight to the executions API, which answers an unknown version with "Object reference not set to an instance of an object". Resolve the release before deploying so a version that doesn't exist is reported by name, and call out `latest` explicitly since it is not a supported alias. Refs #294 Co-Authored-By: Claude Opus 5 (1M context) --- pkg/cmd/release/deploy/deploy.go | 12 +++- pkg/cmd/release/deploy/deploy_test.go | 76 +++++++++++++------- pkg/cmd/release/progression/shared/shared.go | 11 +-- pkg/question/selectors/releases.go | 45 ++++++++++++ 4 files changed, 109 insertions(+), 35 deletions(-) create mode 100644 pkg/question/selectors/releases.go diff --git a/pkg/cmd/release/deploy/deploy.go b/pkg/cmd/release/deploy/deploy.go index 0df6d614..cc314649 100644 --- a/pkg/cmd/release/deploy/deploy.go +++ b/pkg/cmd/release/deploy/deploy.go @@ -317,6 +317,16 @@ func deployRun(cmd *cobra.Command, f factory.Factory, flags *DeployFlags) error return err } options.ProjectName = project.GetName() + + if options.ReleaseVersion != "" { + // resolve the release up front; the executions API reports an unknown version as an + // unhelpful null reference error, and having the ID saves looking it up again later + release, err := selectors.FindRelease(octopus, f.GetCurrentSpace().ID, project, options.ReleaseVersion) + if err != nil { + return err + } + options.ReleaseID = release.ID + } } } @@ -426,7 +436,7 @@ func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker ques return err } } else { - selectedRelease, err = releases.GetReleaseInProject(octopus, space.ID, selectedProject.ID, options.ReleaseVersion) + selectedRelease, err = selectors.FindRelease(octopus, space.ID, selectedProject, options.ReleaseVersion) if err != nil { return err } diff --git a/pkg/cmd/release/deploy/deploy_test.go b/pkg/cmd/release/deploy/deploy_test.go index fde01017..5baade63 100644 --- a/pkg/cmd/release/deploy/deploy_test.go +++ b/pkg/cmd/release/deploy/deploy_test.go @@ -1594,6 +1594,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.9").RespondWith(release10) _, err := testutil.ReceivePair(cmdReceiver) assert.EqualError(t, err, "environment(s) must be specified") @@ -1602,6 +1603,45 @@ func TestDeployCreate_AutomationMode(t *testing.T) { assert.Equal(t, "", stdErr.String()) }}, + {"release deploy reports a release version that doesn't exist", 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", "deploy", "--project", fireProject.Name, "--version", "9.9", "--environment", "dev"}) + 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/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/9.9"). + RespondWithStatus(404, "404 Not Found", &core.APIError{ErrorMessage: "The resource you requested was not found."}) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "cannot find a release with version '9.9' in project 'Fire Project'") + + assert.Equal(t, "", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + + {"release deploy explains that 'latest' is not a supported release 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", "deploy", "--project", fireProject.Name, "--version", "latest", "--environment", "dev"}) + 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/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/latest").RespondWithStatus(404, "NotFound", nil) + + _, err := testutil.ReceivePair(cmdReceiver) + assert.EqualError(t, err, "cannot find a release with version 'latest' in project 'Fire Project'; 'latest' is not a supported alias, specify an exact version. Run 'octopus release list --project \"Fire Project\"' to see the available versions") + + assert.Equal(t, "", stdOut.String()) + assert.Equal(t, "", stdErr.String()) + }}, + {"release deploy specifying project, version, env only (bare minimum) assuming untenanted", 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() @@ -1612,6 +1652,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") @@ -1634,12 +1675,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { }, }) - // now it's going to try and look up the project/version to generate the web URL - 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{fireProject}, - }) - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) + // no lookup to generate the web URL; the release was already resolved before deploying _, err = testutil.ReceivePair(cmdReceiver) assert.Nil(t, err) @@ -1662,6 +1698,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/2.1").RespondWith(release10) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") @@ -1684,12 +1721,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { }, }) - // now it's going to try and look up the project/version to generate the web URL - 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{fireProject}, - }) - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/2.1").RespondWith(release10) + // no lookup to generate the web URL; the release was already resolved before deploying _, err = testutil.ReceivePair(cmdReceiver) assert.Nil(t, err) @@ -1712,6 +1744,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1").RespondWith(&deployments.CreateDeploymentResponseV1{ @@ -1742,6 +1775,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted serverTasks := []*deployments.DeploymentServerTask{ @@ -1773,6 +1807,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1") requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body) @@ -1794,12 +1829,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { }, }) - // now it's going to try and look up the project/version to generate the web URL - 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{fireProject}, - }) - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) + // no lookup to generate the web URL; the release was already resolved before deploying _, err = testutil.ReceivePair(cmdReceiver) assert.Nil(t, err) @@ -1822,6 +1852,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1") requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body) @@ -1843,12 +1874,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { }, }) - // now it's going to try and look up the project/version to generate the web URL - 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{fireProject}, - }) - api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) + // no lookup to generate the web URL; the release was already resolved before deploying _, err = testutil.ReceivePair(cmdReceiver) assert.Nil(t, err) @@ -1888,6 +1914,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) // Note: because we didn't specify --tenant or --tenant-tag, automation-mode code is going to assume untenanted req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/untenanted/v1") @@ -1962,6 +1989,7 @@ func TestDeployCreate_AutomationMode(t *testing.T) { api.ExpectRequest(t, "GET", "/api/").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1").RespondWith(rootResource) api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProject.GetName()).RespondWith(fireProject) + api.ExpectRequest(t, "GET", "/api/Spaces-1/projects/"+fireProjectID+"/releases/1.0").RespondWith(release10) req := api.ExpectRequest(t, "POST", "/api/Spaces-1/deployments/create/tenanted/v1") requestBody, err := testutil.ReadJson[deployments.CreateDeploymentTenantedCommandV1](req.Request.Body) diff --git a/pkg/cmd/release/progression/shared/shared.go b/pkg/cmd/release/progression/shared/shared.go index 94f669b3..a181a771 100644 --- a/pkg/cmd/release/progression/shared/shared.go +++ b/pkg/cmd/release/progression/shared/shared.go @@ -40,14 +40,5 @@ func SelectRelease(octopus *client.Client, project *projects.Project, ask questi } func FindRelease(octopus *client.Client, project *projects.Project, version string) (*releases.Release, error) { - existingRelease, err := releases.GetReleaseInProject(octopus, octopus.GetSpaceID(), project.GetID(), version) - if err != nil { - return nil, err - } - - if existingRelease == nil { - return nil, fmt.Errorf("unable to locate a release with version/release number '%s'", version) - } - - return existingRelease, nil + return selectors.FindRelease(octopus, octopus.GetSpaceID(), project, version) } diff --git a/pkg/question/selectors/releases.go b/pkg/question/selectors/releases.go new file mode 100644 index 00000000..04e44cb0 --- /dev/null +++ b/pkg/question/selectors/releases.go @@ -0,0 +1,45 @@ +package selectors + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/OctopusDeploy/cli/pkg/constants" + octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/releases" +) + +// latestReleaseAlias is the value the old `octo` CLI accepted to mean "the newest release". +// This CLI has no equivalent, so it is called out explicitly when the lookup fails. +const latestReleaseAlias = "latest" + +// FindRelease looks up a release by version within a project. A version that doesn't exist is +// reported here, because the executions API answers one with a null reference error instead. +func FindRelease(octopus *octopusApiClient.Client, spaceID string, project *projects.Project, releaseVersion string) (*releases.Release, error) { + release, err := releases.GetReleaseInProject(octopus, spaceID, project.GetID(), releaseVersion) + if err != nil { + var apiError *core.APIError + if errors.As(err, &apiError) && apiError.StatusCode == http.StatusNotFound { + return nil, releaseNotFoundError(project, releaseVersion) + } + return nil, err + } + // a 404 with an empty body doesn't reach the error path above; it decodes as an empty release + if release == nil || release.GetID() == "" { + return nil, releaseNotFoundError(project, releaseVersion) + } + + return release, nil +} + +func releaseNotFoundError(project *projects.Project, releaseVersion string) error { + if strings.EqualFold(releaseVersion, latestReleaseAlias) { + return fmt.Errorf("cannot find a release with version '%s' in project '%s'; '%s' is not a supported alias, specify an exact version. Run '%s release list --project \"%s\"' to see the available versions", + releaseVersion, project.GetName(), releaseVersion, constants.ExecutableName, project.GetName()) + } + return fmt.Errorf("cannot find a release with version '%s' in project '%s'", releaseVersion, project.GetName()) +}