Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
954e06e
fix: report missing package versions instead of a server null reference
NickJosevski Aug 19, 2026
e417d72
fix: only diagnose the null reference failure, not every 5xx
NickJosevski Aug 31, 2026
2367871
fix: honour --ignore-channel-rules and channel IDs in the diagnosis
NickJosevski Aug 31, 2026
9201499
fix: report the server's own error alongside the package diagnosis
NickJosevski Sep 4, 2026
8159197
fix: only replay the diagnosis for a 500, not any 5xx
NickJosevski Sep 14, 2026
8278287
test: cover resolving the diagnosis channel by ID
NickJosevski Sep 14, 2026
b6dc414
fix: report unknown release versions instead of a server null reference
NickJosevski Aug 19, 2026
f1f1607
fix: don't assert "not found" when the release lookup is ambiguous
NickJosevski Aug 31, 2026
a44de49
refactor: drop the now-unreachable web-URL release lookup
NickJosevski Aug 31, 2026
fcde44c
fix: only a missing release aborts the deploy pre-flight
NickJosevski Aug 31, 2026
b4ec69a
refactor: call selectors.FindRelease directly from GetReleaseID
NickJosevski Aug 31, 2026
e18d9e5
test: expect the release pre-flight lookup in the --priority cases
NickJosevski Sep 15, 2026
8907e0e
fix: accept IDs as well as names for --channel, --environment and --t…
NickJosevski Aug 19, 2026
aee96fe
fix: resolve tenant names with a paginated exact-match lookup
NickJosevski Aug 31, 2026
88aba55
fix: resolve environments one identifier at a time, and share the eph…
NickJosevski Aug 31, 2026
4ba0de5
fix: keep the resolved environment identity for later runbook lookups
NickJosevski Sep 14, 2026
de14d35
test: expect the environment lookup in the --priority cases
NickJosevski Sep 15, 2026
266fe8c
fix: accept comma-separated values on deployment target and scope flags
NickJosevski Aug 19, 2026
cf47112
refactor: collapse the duplicated comma-expansion block into one helper
NickJosevski Aug 31, 2026
3174a9a
fix: reject blank comma-separated values instead of silently dropping…
NickJosevski Aug 31, 2026
788222a
fix: add a backslash escape hatch for commas in target and scope values
NickJosevski Aug 31, 2026
d33f2b8
merge issue-426
NickJosevski Sep 15, 2026
dab4e16
merge issue-250
NickJosevski Sep 15, 2026
d7ecd14
merge issue-556
NickJosevski Sep 15, 2026
2517c55
test: add integration tests for the tier 1 release fixes
NickJosevski Aug 19, 2026
2761779
test: reconcile the deploy and runbook expectations across the tier 1…
NickJosevski Sep 4, 2026
8ddd0af
fix: only a confirmed missing release aborts the deploy pre-flight
NickJosevski Sep 14, 2026
f020d53
test: prove the requested channel ID is honoured, not just accepted
NickJosevski Sep 14, 2026
0bce93b
test: reconcile the diagnosis channel-by-ID case with the identifier …
NickJosevski Sep 15, 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
119 changes: 118 additions & 1 deletion pkg/cmd/release/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
Expand All @@ -28,6 +29,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 +312,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 +328,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.

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 finding holds on this branch. pkg/cmd/release/create/create.go:366-369 still reads newlyCreatedRelease.Assembled and .ReleaseNotes on the lookupErr != nil branch, and ReleaseService.GetByID returns nil, err, so a transient failure right after a successful create panics.

Not fixing it here, though: #722 ("fix: don't dereference a nil release when the post-create lookup fails") exists for exactly these lines, and its diff is the fix — it also covers the lookupErr == nil && newlyCreatedRelease == nil case, prints time.Time{}, "" instead of reading off the nil, and has a release creation warns, rather than panicking, when the post-create lookup fails unit test. A second edit to the same four lines on this branch would just be a conflict for whichever lands second.

The dereference is pre-existing on main and reaches this branch unchanged — nothing in the tier-1 merges touched it — so this PR doesn't have to carry it. Happy to be told otherwise if you'd rather it ride along with the integration tests, since the diagnosis flow does make create failures more visible.

}

if options.Response != nil {
Expand Down Expand Up @@ -420,6 +430,113 @@ 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 500 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. Other 5xx codes are excluded: the failure we are looking for is always raised by the API
// itself as a 500, so a 502/503/504 is something in front of the server and never worth replaying.
func DiagnoseCreateReleaseFailure(octopus *octopusApiClient.Client, options *executor.TaskOptionsCreateRelease, cause error) error {
var apiError *core.APIError
if !errors.As(cause, &apiError) || apiError.StatusCode != http.StatusInternalServerError {
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.

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.

Half of this is already done on this branch, and the other half was tried and deliberately reverted — both on nj/issue-426 (#695), which owns the code, so nothing changed here.

The wrapped cause is now reported. MissingPackageVersionsError.Error() appends the server reported: <cause> unless the cause text is the null-reference message, which says nothing the diagnosis lines don't already say better (pkg/packages/packages.go:241-248, commit c12edf9). So the "told to push packages instead of seeing the actual failure" outcome no longer happens silently: the real 5xx message is in the output alongside the diagnosis.

The gate on serverNullReferenceMessage did land (ea972e2) and was then taken back out, with the reason left in the doc comment on DiagnoseCreateReleaseFailure: the message for this failure varies by server version — current servers answer with "no viable release plans" rather than a null reference — so gating on that string loses the diagnosis on the servers people actually run. What survives of the gate is the fallback: if the diagnosis finds nothing but the message is the null reference, the error is annotated with "the server failed with an unhandled error; this usually means it could not resolve the packages, channel or git reference".

Live check on this branch, which settles the version-variance argument. Ran TestReleaseCreateMissingPackageVersion against a local Octopus instance with the CLI's stderr dumped. Full output:

cannot create release; no version could be found for the following packages:
  - 'package-9baebdb7-...' in step 'step-9baebdb7-...' (feed 'Octopus Server (built-in)')
push the package(s) to the feed, or supply a version with --package or --package-version
the server reported: Octopus API error: There are no viable release plans in any channels using the provided arguments. The following release plans were considered:
Channel: 'Default' (this is the default channel)
  #   Name                Version   Source           Version rules
  1   step-9baebdb7-...   ERROR     Cannot resolve   Allow any version

So on this server the 500 carries "no viable release plans", not "Object reference not set" — a strings.Contains(apiError.ErrorMessage, serverNullReferenceMessage) gate would have suppressed the diagnosis entirely here. And the wrapped cause is visibly in the output, which is the part that addresses "hide the real cause".

So: real risk, now bounded rather than eliminated. If you want it narrower, the shape I'd suggest to #695 is a gate on the union of the known opaque messages (null reference, "no viable release plans") rather than the null reference alone — but that's a decision for that PR.

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