Skip to content

fix: report missing package versions instead of a server null reference - #695

Draft
NickJosevski wants to merge 6 commits into
mainfrom
nj/issue-426
Draft

NickJosevski wants to merge 6 commits into
mainfrom
nj/issue-426

Conversation

@NickJosevski

@NickJosevski NickJosevski commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #426

Root cause

octopus release create --no-prompt does no package version resolution. It builds the V1 command and POSTs it straight to the server:

  • pkg/cmd/release/create/create.go:263 — AskQuestions (which is the only thing that calls BuildPackageVersionBaselineForChannel) is skipped entirely when prompting is disabled; the else branch at create.go:308-316 only resolves the project name.
  • pkg/executor/release.go:42-91 — releaseCreate maps the options onto releases.CreateReleaseCommandV1 and calls releases.CreateReleaseV1. Packages is only populated from --package; nothing is validated.
  • The server then does the package selection itself, and when a referenced package has no version in its feed it throws an unhandled NullReferenceException. That comes back as a bare 500 with ErrorMessage = "Object reference not set to an instance of an object.".
  • go-octopusdeploy/v2/pkg/core/api_error.go:21 formats that as Octopus API error: %v %+v %v, which is the exact string in the issue: Octopus API error: Object reference not set to an instance of an object. [].

So this is not the CLI sending a malformed request, and it is not the CLI failing to parse a good error. It's a genuine server-side crash where the server should be returning a validation error, and the CLI has no client-side pre-check that would have caught it first. Interactive mode never hits this: packages.AskPackageOverrideLoop (pkg/packages/packages.go) prints unknown in yellow for an unresolved version and forces the user to type one in before it will proceed.

What changed

DiagnoseCreateReleaseFailure (pkg/cmd/release/create/create.go) now sits on the error path out of executor.ProcessTasks. On a 5xx *core.APIError it:

  1. Repeats the resolution the server does — project, deployment process, channel, deployment process template, then BuildPackageVersionBaselineForChannel — applies any --package / --package-version overrides, and reports every resolvable, non-fixed package left without a version:

    cannot create release; no version could be found for the following packages:
      - 'acme-web' in step 'Deploy Website' (feed 'Octopus Server (built-in)')
    push the package(s) to the feed, or supply a version with --package or --package-version
    
  2. Falls back to a hint when it can't pin down a specific package but the server message is a null reference, so the user at least knows where to look.

The diagnosis is entirely best-effort and runs only after a failure — the happy path costs nothing, and any error during diagnosis returns the original server error untouched.

Supporting changes in pkg/packages/packages.go:

  • FindPackagesWithoutVersions — matches template packages against resolved versions, skipping FixedVersion and !IsResolvable packages, which legitimately have no version at release creation time.
  • MissingPackageVersionsError — the new error type; Unwrap() returns the original server error.
  • BuildPackageVersionOverrides — extracted from AskPackageOverrideLoop so both the interactive flow and the diagnosis apply command-line overrides identically.

Test evidence

New tests in pkg/cmd/release/create/create_test.go, using the existing testutil.MockHttpServer / fixtures patterns:

  • TestReleaseCreate_FindPackagesWithoutVersions — reports a package with no version; ignores packages that have one; ignores fixed-version and non-resolvable packages; matches on step + package reference, not just package ID.
  • TestReleaseCreate_MissingPackageVersionsError — message formatting, feed-ID fallback when FeedName is absent, and Unwrap.
  • TestReleaseCreate_DiagnoseCreateReleaseFailure — plain errors and 4xx API errors pass through untouched (no extra round trips).
  • TestReleaseCreate_AutomationMode_MissingPackageDiagnosis — full command run: 500 null reference from POST /releases/create/v1, then the diagnosis round trips, then the clear error. Second case covers the fallback hint when the diagnosis itself can't complete.
go build ./...                 # clean
go test ./pkg/...              # all packages ok, no failures

Decisions — settled on existing convention

  • Post-failure diagnosis, not pre-flight validation. Pre-flight (resolving packages before every automation-mode release create) would give a better message and fail faster, but it adds ~5 round trips plus one per package to every CI release creation, duplicates work the server already does, and introduces new failure modes — e.g. an API key that can create releases but can't read feeds. Post-failure costs nothing on the happy path.
  • Guess the channel when --channel isn't supplied — the project's only channel, or the default one. On a multi-channel project this could in principle mis-attribute, but only where creation already failed for an unrelated reason and the default channel's rules exclude every version. The alternative — "does this package have any version at all", ignoring channel rules — has no false positives but misses packages excluded by rules, which is a real cause of this failure.
  • Keep the server's message in the output. Every wrapped error in the CLI uses fmt.Errorf("<context>: %w", err), so the underlying message survives into what the user sees (pkg/cmd/ephemeralenvironment/create/create.go:117 and friends). MissingPackageVersionsError.Error() appends the server reported: <message> for the same reason — the diagnosis is inferred from a failure the server doesn't describe, so it can be wrong. The one exception is the bare null-reference message itself, which is suppressed because it says nothing the diagnosis doesn't already say better.

Notes

🤖 Generated with Claude Code

Comment thread pkg/cmd/release/create/create.go Outdated
// version for a package; see https://github.com/OctopusDeploy/cli/issues/426
func DiagnoseCreateReleaseFailure(octopus *octopusApiClient.Client, options *executor.TaskOptionsCreateRelease, cause error) error {
var apiError *core.APIError
if !errors.As(cause, &apiError) || apiError.StatusCode < 500 {

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.

This gate runs the package diagnosis for any APIError with a 5xx status, not just the null-reference failure this PR targets. That has two consequences:

  1. Misattribution: on an unrelated 500 (or a 502/503 that still parses as an APIError), if some package coincidentally has no version in its feed, the CLI replaces the real server error with MissingPackageVersionsError — whose Error() doesn't include the cause text, so the actual server error is hidden from the user (it's only reachable via errors.Unwrap).
  2. Fan-out against a degraded server: a transient 5xx now triggers ~6 extra API requests (project, deployment process, channels, template, feeds, package search) against a server that's already failing.

Since serverNullReferenceMessage already exists (it gates the fallback hint below), consider requiring it before running the package diagnosis too:

if octopus != nil && options != nil && strings.Contains(apiError.ErrorMessage, serverNullReferenceMessage) {

Both new automation-mode tests still pass with that gate, since their mock responses carry the null-ref message.

@NickJosevski NickJosevski Sep 14, 2026 •

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.

Both consequences are real and are now addressed, but not with the suggested gate — it turns out requiring serverNullReferenceMessage would switch the fix off on a current server.

Your test claim holds. I applied the suggested gate verbatim on top of the branch and ran go test ./pkg/cmd/release/create/... ./pkg/packages/... — everything passes, including all the automation-mode cases, because every mock response carries the null-ref text.

But the trigger can't be the message. I reproduced #426 against a live server (local dev build, /api reports 0.0.0-local): scratch project Projects-309, one Octopus.TentaclePackage step referencing a built-in-feed package ID with no versions, then POST /api/Spaces-1/releases/create/v1. Result is HTTP 500 with:

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   Deploy Website   ERROR     Cannot resolve   Allow any version
 []

No null reference anywhere. #426 reported the null-ref text on Server 2024.4.2267, so the message a server sends for this varies by version and can't be relied on as the trigger. The suggested gate would have made this PR a no-op against a current server — and the tests would not have caught it, precisely because every mock carries the null-ref text. (Scratch project deleted afterwards; Projects-309 if you want to trace it.)

So I attacked the two consequences directly instead:

1. Misattribution — fixed in c12edf9. You're right that the cause was invisible: cmd.PrintErr(err) in cmd/octopus/main.go prints Error() only, so the wrapped server error was reachable via errors.Unwrap and nowhere else. MissingPackageVersionsError.Error() now appends the server reported: <cause>, suppressed only when the cause is the null-ref message (which explains nothing the diagnosis doesn't say better). A wrong diagnosis now costs the user a misleading paragraph instead of the real cause. Covered by TestMissingPackageVersionsError_Error/reports what the server said alongside the diagnosis and /omits the null reference message, which explains nothing in pkg/packages/packages_test.go.

2. Fan-out — narrowed in aceb313. Gate is now apiError.StatusCode != http.StatusInternalServerError rather than < 500. This failure is always raised by the API itself as a 500 (verified above), so a 502/503/504 is something in front of the server and never worth replaying. Covered by TestReleaseCreate_DiagnoseCreateReleaseFailure/passes through 5xx codes which aren't the API's own failure, which feeds 502/503/504 carrying the null-ref message so it's the status code doing the work rather than the text.

Worth noting the fan-out is also partly self-limiting in the case you were worried about: against a genuinely degraded server the replay aborts at the first failing call (selectors.FindProject), so it's one extra request, not six. Six only happens when the server is healthy enough to answer everything and returned an unrelated 500.

Residual I haven't fixed: on a healthy server returning an unrelated 500, if some package genuinely has no version in its feed, the output still leads with the package diagnosis and puts the server's own text underneath. The real cause is visible but not the headline. Fixing that properly needs a positive signal that package resolution is what failed, which the server doesn't give us on either version. Happy to leave it as is — or should the ordering be inverted, server text first and the package diagnosis as a follow-on hint?

Same reasoning applies to the near-identical comment on #703; I've left that branch alone.

Comment thread pkg/cmd/release/create/create.go Outdated
return nil, err
}

packageVersionBaseline, err := BuildPackageVersionBaselineForChannel(octopus, deploymentProcessTemplate, channel)

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 diagnosis doesn't honour options.IgnoreChannelRules. When the user passed --ignore-channel-rules, the server resolves package versions without applying channel version rules, but this replay always applies them (BuildPackageVersionBaselineForChannel injects the channel's VersionRange/Tag/VersionTagRegex into the feed query). So a package that has versions in its feed — just none satisfying the rules — would be reported as "no version could be found", misdiagnosing whatever actually failed.

When options.IgnoreChannelRules is set, skip the rule filter, e.g.:

if options.IgnoreChannelRules {
    packageVersionBaseline, err = packages.BuildPackageVersionBaseline(octopus, deploymentProcessTemplate.Packages, nil)
} else {
    packageVersionBaseline, err = BuildPackageVersionBaselineForChannel(octopus, deploymentProcessTemplate, channel)
}

Related, smaller blind spot in the same replay: --channel reaches the server as ChannelIDOrName, but findChannelForDiagnosis → selectors.FindChannel matches by name only, so a user who passed a channel ID silently loses the package diagnosis (it falls back to the generic hint). Fine to leave as best-effort, but worth a comment if not handled.

@NickJosevski NickJosevski Sep 14, 2026 •

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.

Actioned in 66ee411, and I handled the channel-ID blind spot rather than commenting it. Test for the ID case added in 23b87a5.

--ignore-channel-rules: taken as suggested — findPackagesWithoutVersions now builds the baseline with packages.BuildPackageVersionBaseline(octopus, deploymentProcessTemplate.Packages, nil) when the flag is set, so the setAdditionalFeedQueryParameters hook that injects VersionRange/Tag/VersionTagRegex is never called. Covered by TestReleaseCreate_AutomationMode_MissingPackageDiagnosis/doesn't apply channel version rules when --ignore-channel-rules was specified, which gives the channel a rule for the package (Tag: "^pre$", VersionRange: "[5.0,6.0)") and then expects the feed query as ?packageId=acme-web&take=1 — no versionRange, no preReleaseTag. Because the mock matches the URL exactly, applying the rules fails the test rather than silently passing.

Channel by ID: handled instead of left as best-effort. --channel does reach the server unresolved (pkg/executor/release.go:72 assigns params.ChannelName straight to createReleaseParams.ChannelIDOrName), so an ID is a legitimate input and losing the diagnosis for it isn't a corner case worth living with. findChannelForDiagnosis now takes channelIDOrName and matches strings.EqualFold(c.Name, ...) || c.ID == ... over the project's channels, which is why it no longer delegates to selectors.FindChannel. That costs nothing in requests — selectors.FindChannel was already doing Projects.GetChannels and looping client-side for exactly the same reason (the server has no exact-name channel search).

I deliberately didn't push the ID match down into selectors.FindChannel itself; that's used by the interactive paths where the argument really is a name from a prompt, and widening it there is a behaviour change well outside this PR.

23b87a5 adds /finds the channel when --channel was given as an ID rather than a name to the same table. I checked it actually bites: reverting the match to name-only makes that case fail (the channel lookup errors, the diagnosis degrades to the generic hint, and the EqualError on the package message fails).

NickJosevski and others added 6 commits September 15, 2026 16:55
`release create --no-prompt` sends the create request straight to the server
without resolving package versions first. When a package has no version in its
feed the server raises a null reference exception, which surfaces as
"Octopus API error: Object reference not set to an instance of an object. []".

On a 5xx failure the CLI now repeats the package version resolution the server
does, and reports the packages, steps and feeds that have no version available.
Where it can't identify a specific package, an unhandled server error now
carries a hint about the likely causes.

Fixes #426

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The package diagnosis ran for any APIError with a 5xx status. On an
unrelated server error that had the side effect of (a) replacing a real
server message with MissingPackageVersionsError, whose Error() doesn't
include the cause, and (b) firing ~6 extra requests at a server that is
already failing.

Require the null reference message before diagnosing, which is the only
failure this code knows how to explain. The fallback hint no longer needs
its own check, since reaching it now implies the message matched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two ways the replay could diverge from what the server actually did:

- With --ignore-channel-rules the server resolves package versions without
  applying the channel's version rules, but the replay always applied them.
  A package with versions in its feed, none satisfying the rules, would be
  reported as "no version could be found", misdiagnosing the real failure.
  Build the baseline without the rule filter in that case.

- --channel reaches the server as ChannelIDOrName, but the lookup matched
  on name only, so passing a channel ID silently dropped the diagnosis to
  the generic hint. Match on either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ea972e2 narrowed the diagnosis to failures carrying the server's null
reference message, to stop an unrelated 5xx being reported as a package
problem. That works, but it also switches the fix off on current servers:
the #426 path there fails with "There are no viable release plans in any
channels", not a null reference, so the message the server sends for this
is version-dependent and can't be relied on as the trigger.

Address the underlying complaint instead. MissingPackageVersionsError now
prints what the server actually said, so a misattributed diagnosis costs
the user a misleading paragraph rather than the real cause, which was
previously reachable only via Unwrap and never printed (main.go prints
err.Error() alone). With nothing hidden, the trigger widens back to any
5xx and keeps working across server versions.

The null reference message itself is still suppressed from that output --
it says nothing the diagnosis doesn't say better -- so the integration
test's guard against it resurfacing stays valid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The replay can't be gated on the server's message -- verified against a
current server, the #426 scenario comes back as a 500 carrying "There are
no viable release plans in any channels", not the null reference message
the issue reported -- so the trigger stays message-independent. It can be
gated on the status code, though: this failure is always raised by the API
itself as a 500, so a 502/503/504 is something in front of the server and
is never worth ~6 extra requests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
66ee411 made findChannelForDiagnosis match on channel ID as well as name,
but nothing exercised it. Reverting that match to name-only now fails this
case, which is the point of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unhelpful output when attempting to create a release with a package that doesn't exist

1 participant