Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d25b40c
fix: accept comma-separated values on deployment target and scope flags
NickJosevski Aug 19, 2026
073cf92
fix: report missing package versions instead of a server null reference
NickJosevski Aug 19, 2026
af509e5
fix: report unknown release versions instead of a server null reference
NickJosevski Aug 19, 2026
e083816
fix: accept IDs as well as names for --channel, --environment and --t…
NickJosevski Aug 19, 2026
ea972e2
fix: only diagnose the null reference failure, not every 5xx
NickJosevski Aug 31, 2026
66ee411
fix: honour --ignore-channel-rules and channel IDs in the diagnosis
NickJosevski Aug 31, 2026
2541136
fix: don't assert "not found" when the release lookup is ambiguous
NickJosevski Aug 31, 2026
93ab1c6
fix: resolve tenant names with a paginated exact-match lookup
NickJosevski Aug 31, 2026
8c07b92
refactor: collapse the duplicated comma-expansion block into one helper
NickJosevski Aug 31, 2026
c64ab93
fix: reject blank comma-separated values instead of silently dropping…
NickJosevski Aug 31, 2026
b77c149
fix: add a backslash escape hatch for commas in target and scope values
NickJosevski Aug 31, 2026
cf2dc4c
refactor: drop the now-unreachable web-URL release lookup
NickJosevski Aug 31, 2026
14ff2ee
fix: only a missing release aborts the deploy pre-flight
NickJosevski Aug 31, 2026
4e9ec94
refactor: call selectors.FindRelease directly from GetReleaseID
NickJosevski Aug 31, 2026
3d8333b
fix: resolve environments one identifier at a time, and share the eph…
NickJosevski Aug 31, 2026
c12edf9
fix: report the server's own error alongside the package diagnosis
NickJosevski Sep 4, 2026
05ef658
merge nj/issue-294
NickJosevski Sep 4, 2026
2b4b0e6
merge nj/issue-426
NickJosevski Sep 4, 2026
40fd24a
merge nj/issue-250
NickJosevski Sep 4, 2026
d7e9c78
merge nj/issue-556
NickJosevski Sep 4, 2026
1e69f93
test: add integration tests for the tier 1 release fixes
NickJosevski Aug 19, 2026
54072b6
test: reconcile the deploy and runbook expectations across the tier 1…
NickJosevski Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/cmd/channel/delete/delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ func TestChannelDelete(t *testing.T) {

// No DELETE request is expected; api.Close() asserts nothing further was requested.
_, err := testutil.ReceivePair(cmdReceiver)
assert.EqualError(t, err, "no channel found with name of Channels-99")
assert.EqualError(t, err, "cannot find a channel in project 'Fire Project' with the ID or name of 'Channels-99'")

assert.Equal(t, "", stdErr.String())
}},
Expand Down
4 changes: 2 additions & 2 deletions pkg/cmd/channel/view/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ func TestChannelView(t *testing.T) {
})

_, err := testutil.ReceivePair(cmdReceiver)
assert.EqualError(t, err, "no channel found with name of Channels-99")
assert.EqualError(t, err, "cannot find a channel in project 'Fire Project' with the ID or name of 'Channels-99'")

assert.Equal(t, "", stdOut.String())
assert.Equal(t, "", stdErr.String())
Expand All @@ -262,7 +262,7 @@ func TestChannelView(t *testing.T) {
})

_, err := testutil.ReceivePair(cmdReceiver)
assert.EqualError(t, err, "no channel found with name of Nonexistent")
assert.EqualError(t, err, "cannot find a channel in project 'Fire Project' with the ID or name of 'Nonexistent'")

assert.Equal(t, "", stdOut.String())
assert.Equal(t, "", stdErr.String())
Expand Down
117 changes: 116 additions & 1 deletion pkg/cmd/release/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/OctopusDeploy/cli/pkg/util/flag"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/channels"
octopusApiClient "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deployments"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/feeds"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/projects"
Expand Down Expand Up @@ -310,6 +311,14 @@ func createRun(cmd *cobra.Command, f factory.Factory, flags *CreateFlags) error
return err
}
options.ProjectName = project.GetName()

if options.ChannelName != "" { // the executions API only matches channels by name, so resolve any ID we were given
channel, err := selectors.FindChannel(octopus, project, options.ChannelName)
if err != nil {
return err
}
options.ChannelName = channel.Name
}
}
}

Expand All @@ -318,7 +327,7 @@ func createRun(cmd *cobra.Command, f factory.Factory, flags *CreateFlags) error
executor.NewTask(executor.TaskTypeCreateRelease, options),
})
if err != nil {
return err
return DiagnoseCreateReleaseFailure(octopus, options, err)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nil pointer dereference on the post-create lookup failure path (pre-existing, but this function is being touched here and the new diagnosis flow makes create failures more visible): a few lines below at the options.Response handling, when octopus.Releases.GetByID(options.Response.ReleaseID) fails, the error branch still dereferences the nil result:

newlyCreatedRelease, lookupErr := octopus.Releases.GetByID(options.Response.ReleaseID)
if lookupErr != nil {
    cmd.PrintErrf("Warning: cannot fetch release details: %v\n", lookupErr)
    printReleaseVersion(options.Response.ReleaseVersion, newlyCreatedRelease.Assembled, newlyCreatedRelease.ReleaseNotes, nil)

ReleaseService.GetByID returns nil, err on failure, so a transient server error right after a successful create panics the CLI instead of printing the warning.

}

if options.Response != nil {
Expand Down Expand Up @@ -420,6 +429,112 @@ func BuildPackageVersionBaselineForChannel(octopus *octopusApiClient.Client, dep
return result, nil
}

// DiagnoseCreateReleaseFailure replaces an opaque server-side failure with an actionable message where
// it can. The server raises a null reference exception, surfaced as a bare 500, when it can't select a
// version for a package; see https://github.com/OctopusDeploy/cli/issues/426
//
// Any 5xx is diagnosed, not just the null reference one, because the message a server sends for this
// varies by version: current servers report "no viable release plans" instead. The cost of being wrong
// is bounded, since MissingPackageVersionsError reports what the server actually said alongside the
// diagnosis.
func DiagnoseCreateReleaseFailure(octopus *octopusApiClient.Client, options *executor.TaskOptionsCreateRelease, cause error) error {
var apiError *core.APIError
if !errors.As(cause, &apiError) || apiError.StatusCode < 500 {
return cause
}

// diagnosis is best-effort; if any part of it fails we must not mask the original failure
if octopus != nil && options != nil {
if missingPackages, findErr := findPackagesWithoutVersions(octopus, options); findErr == nil && len(missingPackages) > 0 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The package diagnosis can misattribute an unrelated 5xx and hide the real cause. This branch runs for any 5xx, not just the null-reference case, and MissingPackageVersionsError.Error() does not include the original server message (it is only reachable via Unwrap). If the server 500s for an unrelated reason (timeout, genuine server bug) while the project happens to contain a package with no version in its feed — or the CLI's re-derived baseline disagrees with the server (e.g. a --package override the CLI silently failed to parse but the server accepted) — the user is told to push packages instead of seeing the actual failure.

Consider gating the missing-package diagnosis on strings.Contains(apiError.ErrorMessage, serverNullReferenceMessage) as well, and/or including the wrapped cause text in the error output.

return packages.NewMissingPackageVersionsError(missingPackages, cause)
}
}

if strings.Contains(apiError.ErrorMessage, packages.ServerNullReferenceMessage) {
return fmt.Errorf("%w\nthe server failed with an unhandled error; this usually means it could not resolve the packages, channel or git reference for the release", cause)
}
return cause
}

// findPackagesWithoutVersions repeats the package version resolution the server does when it assembles a
// release, so we can report which packages have no version available in their feed.
func findPackagesWithoutVersions(octopus *octopusApiClient.Client, options *executor.TaskOptionsCreateRelease) ([]releases.ReleaseTemplatePackage, error) {
project, err := selectors.FindProject(octopus, options.ProjectName)
if err != nil {
return nil, err
}

gitReferenceKey := ""
if project.PersistenceSettings != nil && project.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(project, gitReferenceKey)
if err != nil {
return nil, err
}

channel, err := findChannelForDiagnosis(octopus, project, options.ChannelName)
if err != nil {
return nil, err
}

deploymentProcessTemplate, err := octopus.DeploymentProcesses.GetTemplate(deploymentProcess, channel.ID, "")
if err != nil {
return nil, err
}

// mirror what the server did: with --ignore-channel-rules it selects versions without applying the
// channel's version rules, so applying them here would report packages as missing when they only
// failed the rules.
var packageVersionBaseline []*packages.StepPackageVersion
if options.IgnoreChannelRules {
packageVersionBaseline, err = packages.BuildPackageVersionBaseline(octopus, deploymentProcessTemplate.Packages, nil)
} else {
packageVersionBaseline, err = BuildPackageVersionBaselineForChannel(octopus, deploymentProcessTemplate, channel)
}
if err != nil {
return nil, err
}

overrides := packages.BuildPackageVersionOverrides(packageVersionBaseline, options.DefaultPackageVersion, options.PackageVersionOverrides)
resolvedVersions := packages.ApplyPackageOverrides(packageVersionBaseline, overrides)

return packages.FindPackagesWithoutVersions(deploymentProcessTemplate.Packages, resolvedVersions), nil
}

// findChannelForDiagnosis locates the channel the server would have used. --channel reaches the server as
// ChannelIDOrName, so we match on either. When no channel was specified we can only guess; the default
// channel is the best approximation available to us.
func findChannelForDiagnosis(octopus *octopusApiClient.Client, project *projects.Project, channelIDOrName string) (*channels.Channel, error) {
existingChannels, err := octopus.Projects.GetChannels(project)
if err != nil {
return nil, err
}

if channelIDOrName != "" {
for _, c := range existingChannels {
if strings.EqualFold(c.Name, channelIDOrName) || c.ID == channelIDOrName {
return c, nil
}
}
return nil, fmt.Errorf("no channel found with name or ID of %s", channelIDOrName)
}

if len(existingChannels) == 1 {
return existingChannels[0], nil
}
for _, c := range existingChannels {
if c.IsDefault {
return c, nil
}
}
return nil, fmt.Errorf("cannot determine the default channel for project %s", project.GetName())
}

func AskQuestions(octopus *octopusApiClient.Client, stdout io.Writer, asker question.Asker, options *executor.TaskOptionsCreateRelease) error {
if octopus == nil {
return cliErrors.NewArgumentNullOrEmptyError("octopus")
Expand Down
Loading