From c1754164d56f1ff61904402654e45703c7ff9daf Mon Sep 17 00:00:00 2001 From: Benjamin Yang Date: Wed, 13 May 2026 14:34:56 -0400 Subject: [PATCH 1/5] add --wait-for-airgap to release promote --- cli/cmd/release_create.go | 13 ++++- cli/cmd/release_promote.go | 91 ++++++++++++++++++++++++++++++++- cli/cmd/runner.go | 12 +++-- cli/print/channel_releases.go | 18 ++++--- cli/print/release.go | 5 ++ client/release.go | 7 +-- pact/kotsclient/release_test.go | 2 +- pkg/kotsclient/release.go | 24 +++++++-- pkg/types/release.go | 20 ++++++++ 9 files changed, 168 insertions(+), 24 deletions(-) diff --git a/cli/cmd/release_create.go b/cli/cmd/release_create.go index 338a932bb..19a2f954a 100644 --- a/cli/cmd/release_create.go +++ b/cli/cmd/release_create.go @@ -74,6 +74,8 @@ func (r *runners) InitReleaseCreate(parent *cobra.Command) error { cmd.Flags().BoolVar(&r.args.createReleaseLint, "lint", false, "Lint a manifests directory prior to creation of the KOTS Release.") cmd.Flags().BoolVar(&r.args.createReleasePromoteRequired, "required", false, "When used with --promote , marks this release as required during upgrades.") cmd.Flags().BoolVar(&r.args.createReleasePromoteEnsureChannel, "ensure-channel", false, "When used with --promote , will create the channel if it doesn't exist") + cmd.Flags().BoolVar(&r.args.createReleasePromoteWaitForAirgap, "wait-for-airgap", false, "When used with --promote , wait for airgap bundle builds to complete (KOTS apps only)") + cmd.Flags().DurationVar(&r.args.createReleasePromoteWaitForAirgapTimeout, "wait-for-airgap-timeout", 30*time.Minute, "Timeout for waiting on airgap bundle builds") cmd.Flags().BoolVar(&r.args.createReleaseAutoDefaults, "auto", false, "generate default values for use in CI") cmd.Flags().BoolVarP(&r.args.createReleaseAutoDefaultsAccept, "confirm-auto", "y", false, "auto-accept the configuration generated by the --auto flag") cmd.Flags().StringVar(&r.args.createReleaseOutputDir, "output-dir", "", "Stage the release artifacts (packaged charts and manifests) to this directory. Existing contents of the directory are removed before each run. The directory is preserved after the command completes.") @@ -469,7 +471,7 @@ Prepared to create release with defaults: if promoteChanID != "" { log.ActionWithSpinner("Promoting") - if err = r.api.PromoteRelease( + promoteResp, err := r.api.PromoteRelease( r.appID, r.appType, release.Sequence, @@ -477,7 +479,8 @@ Prepared to create release with defaults: r.args.createReleasePromoteNotes, r.args.createReleasePromoteRequired, promoteChanID, - ); err != nil { + ) + if err != nil { log.FinishSpinnerWithError() return errors.Wrap(err, "promote release") } @@ -485,6 +488,12 @@ Prepared to create release with defaults: // ignore error since operation was successful log.ChildActionWithoutSpinner("Channel %s successfully set to release %d\n", promoteChanID, release.Sequence) + + if r.appType == "kots" && r.args.createReleasePromoteWaitForAirgap { + if err := r.waitForAirgapBuilds(promoteResp, r.args.createReleasePromoteWaitForAirgapTimeout, log); err != nil { + return err + } + } } return nil diff --git a/cli/cmd/release_promote.go b/cli/cmd/release_promote.go index 5b700e88f..aa3e84bb3 100644 --- a/cli/cmd/release_promote.go +++ b/cli/cmd/release_promote.go @@ -3,9 +3,12 @@ package cmd import ( "fmt" "strconv" + "time" "github.com/pkg/errors" "github.com/replicatedhq/replicated/client" + "github.com/replicatedhq/replicated/pkg/logger" + "github.com/replicatedhq/replicated/pkg/types" "github.com/spf13/cobra" ) @@ -24,10 +27,32 @@ func (r *runners) InitReleasePromote(parent *cobra.Command) { cmd.Flags().BoolVar(&r.args.releaseOptional, "optional", false, "If set, this release can be skipped") cmd.Flags().BoolVar(&r.args.releaseRequired, "required", false, "If set, this release can't be skipped") cmd.Flags().StringVar(&r.args.releaseVersion, "version", "", "A version label for the release in this channel") + cmd.Flags().BoolVar(&r.args.releasePromoteWaitForAirgap, "wait-for-airgap", false, "Wait for airgap bundle builds to complete (KOTS apps only)") + cmd.Flags().DurationVar(&r.args.releasePromoteWaitForAirgapTimeout, "wait-for-airgap-timeout", 30*time.Minute, "Timeout for waiting on airgap bundle builds") cmd.RunE = r.releasePromote } +var inFlightAirgapStates = map[string]bool{ + "pending": true, + "building": true, + "building_bundle": true, + "metadata": true, + "unknown": true, +} + +// terminalFailureStates are the airgap-build outcomes that should fail the +// --wait-for-airgap waiter. "warn" is included because the airgap-builder uses +// it for builds that completed with non-fatal warnings vendors still need to +// see — silently treating it as success would reproduce the misleading-success +// bug this feature is meant to fix. +var terminalFailureStates = map[string]bool{ + "failed": true, + "failed_with_metadata": true, + "cancelled": true, + "warn": true, +} + func (r *runners) releasePromote(cmd *cobra.Command, args []string) (err error) { defer func() { printIfError(cmd, err) @@ -68,13 +93,75 @@ func (r *runners) releasePromote(cmd *cobra.Command, args []string) (err error) required = r.args.releaseRequired } - if err = r.api.PromoteRelease(r.appID, r.appType, seq, r.args.releaseVersion, r.args.releaseNotes, required, newID); err != nil { + promoteResp, err := r.api.PromoteRelease(r.appID, r.appType, seq, r.args.releaseVersion, r.args.releaseNotes, required, newID) + if err != nil { return errors.Wrapf(err, "failed to promote release") } - // ignore error since operation was successful fmt.Fprintf(r.w, "Channel %s successfully set to release %d\n", channelName, seq) + + if r.appType == "kots" && r.args.releasePromoteWaitForAirgap { + log := logger.NewLogger(r.w).SetIsTerminal(r.stdoutIsTTY) + if err := r.waitForAirgapBuilds(promoteResp, r.args.releasePromoteWaitForAirgapTimeout, log); err != nil { + return err + } + } + r.w.Flush() return nil } + +func (r *runners) waitForAirgapBuilds(promoteResp *types.PromoteReleaseResponse, timeout time.Duration, log *logger.Logger) error { + // The vandoor API always returns airgapBuilds[] for promoted channels (metadata + // generation runs for every channel-release). This branch only fires when talking + // to an older server that did not populate the field. + if promoteResp != nil && len(promoteResp.AirgapBuilds) == 0 { + log.ActionWithoutSpinner("No airgap build status reported by the API (older server, or no channels were promoted)") + return nil + } + + log.ActionWithSpinner("Waiting for airgap builds") + + start := time.Now() + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + if time.Since(start) > timeout { + log.FinishSpinnerWithError() + return errors.New("timed out waiting for airgap builds") + } + + allTerminal := true + var failures []string + for _, build := range promoteResp.AirgapBuilds { + status, err := r.kotsAPI.GetAirgapBuildStatus(r.appID, build.ChannelID, build.ChannelSequence) + if err != nil { + log.FinishSpinnerWithError() + return errors.Wrapf(err, "failed to get airgap build status for channel %s", build.ChannelName) + } + + if inFlightAirgapStates[status.AirgapBuildStatus] { + allTerminal = false + } else if terminalFailureStates[status.AirgapBuildStatus] { + failures = append(failures, fmt.Sprintf("channel %s (%s): %s", status.ChannelName, status.AirgapBuildStatus, status.AirgapBuildError)) + } + } + + if allTerminal { + if len(failures) > 0 { + log.FinishSpinnerWithError() + for _, f := range failures { + log.ChildActionWithoutSpinner("%s", f) + } + return errors.New("one or more airgap builds failed") + } + log.FinishSpinner() + log.ChildActionWithoutSpinner("Airgap builds complete") + return nil + } + + <-ticker.C + } +} diff --git a/cli/cmd/runner.go b/cli/cmd/runner.go index 1ab110ad6..d891214a3 100644 --- a/cli/cmd/runner.go +++ b/cli/cmd/runner.go @@ -71,10 +71,12 @@ type runnerArgs struct { lintReleaseChart string lintReleaseFailOn string lintVerbose bool - releaseOptional bool - releaseRequired bool - releaseNotes string - releaseVersion string + releaseOptional bool + releaseRequired bool + releaseNotes string + releaseVersion string + releasePromoteWaitForAirgap bool + releasePromoteWaitForAirgapTimeout time.Duration updateReleaseYaml string updateReleaseYamlDir string updateReleaseYamlFile string @@ -97,6 +99,8 @@ type runnerArgs struct { createReleaseAutoDefaultsAccept bool createReleaseOutputDir string createReleaseNoUpload bool + createReleasePromoteWaitForAirgap bool + createReleasePromoteWaitForAirgapTimeout time.Duration releaseDownloadDest string releaseDownloadChannel string diff --git a/cli/print/channel_releases.go b/cli/print/channel_releases.go index 03e9f08aa..fee98bf3a 100644 --- a/cli/print/channel_releases.go +++ b/cli/print/channel_releases.go @@ -40,9 +40,9 @@ func ChannelReleases(outputFormat string, w *tabwriter.Writer, releases []channe return w.Flush() } -var kotsChannelReleasesTmplSrc = `CHANNEL_SEQUENCE RELEASE_SEQUENCE VERSION CREATED RELEASED STATE +var kotsChannelReleasesTmplSrc = `CHANNEL_SEQUENCE RELEASE_SEQUENCE VERSION CREATED RELEASED STATE AIRGAP_STATUS AIRGAP_ERROR {{ range . -}} -{{ .ChannelSequence }} {{ .Sequence }} {{ .Semver }} {{ time .Created }} {{ time .ReleasedAt }} {{ .State }} +{{ .ChannelSequence }} {{ .Sequence }} {{ .Semver }} {{ time .Created }} {{ time .ReleasedAt }} {{ .State }} {{ .AirgapBuildStatus }} {{ .AirgapBuildError }} {{ end }}` var kotsChannelReleasesTmpl = template.Must(template.New("KotsChannelReleases").Funcs(funcs).Parse(kotsChannelReleasesTmplSrc)) @@ -70,12 +70,14 @@ func KotsChannelReleases(outputFormat string, w *tabwriter.Writer, releases []*t state = "demoted" } rows[i] = map[string]interface{}{ - "ChannelSequence": r.ChannelSequence, - "Sequence": r.Sequence, - "Semver": r.Semver, - "Created": r.Created, - "ReleasedAt": r.ReleasedAt, - "State": state, + "ChannelSequence": r.ChannelSequence, + "Sequence": r.Sequence, + "Semver": r.Semver, + "Created": r.Created, + "ReleasedAt": r.ReleasedAt, + "State": state, + "AirgapBuildStatus": r.AirgapBuildStatus, + "AirgapBuildError": r.AirgapBuildError, } } diff --git a/cli/print/release.go b/cli/print/release.go index 35f512dc3..f71e3877a 100644 --- a/cli/print/release.go +++ b/cli/print/release.go @@ -17,6 +17,11 @@ EDITED: {{ time .EditedAt }} DISTRIBUTION VERSION SUCCESS_AT SUCCESS_NOTES FAILURE_AT FAILURE_NOTES {{ range .CompatibilityResults -}} {{ .Distribution }} {{ .Version }} {{if .SuccessAt}}{{ time .SuccessAt }}{{else}}-{{end}} {{ .SuccessNotes }} {{if .FailureAt}}{{ time .FailureAt }}{{else}}-{{end}} {{ .FailureNotes }} + {{ end }}{{end}}{{if .AirgapBuilds}} +AIRGAP BUNDLES: + CHANNEL STATUS ERROR + {{ range .AirgapBuilds -}} + {{ .ChannelName }} {{ .AirgapBuildStatus }} {{if .AirgapBuildError}}{{ .AirgapBuildError }}{{else}}-{{end}} {{ end }}{{end}} CONFIG: {{ .Config }} diff --git a/client/release.go b/client/release.go index 958a24df8..dd63fcbb0 100644 --- a/client/release.go +++ b/client/release.go @@ -118,14 +118,15 @@ func (c *Client) GetRelease(appID string, appType string, sequence int64) (*type return nil, errors.Errorf("unknown app type %q", appType) } -func (c *Client) PromoteRelease(appID string, appType string, sequence int64, label string, notes string, required bool, channelIDs ...string) error { +func (c *Client) PromoteRelease(appID string, appType string, sequence int64, label string, notes string, required bool, channelIDs ...string) (*types.PromoteReleaseResponse, error) { if appType == "platform" { - return c.PlatformClient.PromoteRelease(appID, sequence, label, notes, required, channelIDs...) + err := c.PlatformClient.PromoteRelease(appID, sequence, label, notes, required, channelIDs...) + return nil, err } else if appType == "kots" { return c.KotsClient.PromoteRelease(appID, sequence, label, notes, required, channelIDs...) } - return errors.Errorf("unknown app type %q", appType) + return nil, errors.Errorf("unknown app type %q", appType) } // data is a []byte describing a tarred yaml-dir, created by tarYAMLDir() diff --git a/pact/kotsclient/release_test.go b/pact/kotsclient/release_test.go index 929e163f4..8a3b1ac6b 100644 --- a/pact/kotsclient/release_test.go +++ b/pact/kotsclient/release_test.go @@ -197,7 +197,7 @@ func Test_PromoteRelease(t *testing.T) { api := platformclient.NewHTTPClient(u, "replicated-cli-promote-release-token") client := realkotsclient.VendorV3Client{HTTPClient: *api} - err = client.PromoteRelease("replicated-cli-promote-release-app", 1, "v0.0.1", "releasenotes", false, "replicated-cli-promote-release-unstable") + _, err = client.PromoteRelease("replicated-cli-promote-release-app", 1, "v0.0.1", "releasenotes", false, "replicated-cli-promote-release-unstable") assert.NoError(t, err) return nil diff --git a/pkg/kotsclient/release.go b/pkg/kotsclient/release.go index cb2a8f161..cf482c197 100644 --- a/pkg/kotsclient/release.go +++ b/pkg/kotsclient/release.go @@ -63,6 +63,7 @@ func (c *VendorV3Client) GetRelease(appID string, sequence int64) (*types.AppRel CompatibilityResults: resp.Release.CompatibilityResults, Channels: resp.Release.Channels, IsHelmOnly: resp.Release.IsHelmOnly, + AirgapBuilds: resp.Release.AirgapBuilds, } return &appRelease, nil @@ -174,7 +175,7 @@ func (c *VendorV3Client) ListReleases(appID string) ([]types.ReleaseInfo, error) return allReleases, nil } -func (c *VendorV3Client) PromoteRelease(appID string, sequence int64, label string, notes string, required bool, channelIDs ...string) error { +func (c *VendorV3Client) PromoteRelease(appID string, sequence int64, label string, notes string, required bool, channelIDs ...string) (*types.PromoteReleaseResponse, error) { request := types.KotsPromoteReleaseRequest{ ReleaseNotes: notes, VersionLabel: label, @@ -183,11 +184,26 @@ func (c *VendorV3Client) PromoteRelease(appID string, sequence int64, label stri OmitDetailsInResponse: true, } + resp := types.PromoteReleaseResponse{} path := fmt.Sprintf("/v3/app/%s/release/%v/promote", appID, sequence) - err := c.DoJSON(context.TODO(), "POST", path, http.StatusOK, request, nil) + err := c.DoJSON(context.TODO(), "POST", path, http.StatusOK, request, &resp) if err != nil { - return err + return nil, err } - return nil + return &resp, nil +} + +// GetAirgapBuildStatus polls the dedicated airgap build status endpoint for a +// single channel-release. The server returns "pending" (synthesized) until the +// airgap-builder writes its first row, real status after that, and 404 if the +// channel-release does not exist. +func (c *VendorV3Client) GetAirgapBuildStatus(appID string, channelID string, channelSequence int64) (*types.AirgapBuildSummary, error) { + resp := types.AirgapBuildSummary{} + path := fmt.Sprintf("/v3/app/%s/channel/%s/release/%d/airgap/status", appID, channelID, channelSequence) + err := c.DoJSON(context.TODO(), "GET", path, http.StatusOK, nil, &resp) + if err != nil { + return nil, errors.Wrap(err, "failed to get airgap build status") + } + return &resp, nil } diff --git a/pkg/types/release.go b/pkg/types/release.go index d78365836..03ab39251 100644 --- a/pkg/types/release.go +++ b/pkg/types/release.go @@ -74,6 +74,24 @@ type KotsPromoteReleaseRequest struct { OmitDetailsInResponse bool `json:"omitDetailsInResponse"` } +type AirgapBuildSummary struct { + ChannelID string `json:"channelId"` + ChannelSequence int64 `json:"channelSequence"` + ChannelName string `json:"channelName"` + AirgapBuildStatus string `json:"airgapBuildStatus"` + AirgapBuildError string `json:"airgapBuildError,omitempty"` + // FullAirgapBuild is true when the channel has automatic airgap builds enabled + // (the worker will produce a full bundle). When false, the worker still runs + // metadata generation for the channel-release and that step can fail — so + // callers should still surface this entry's status to vendors. + FullAirgapBuild bool `json:"fullAirgapBuild"` +} + +type PromoteReleaseResponse struct { + Release KotsAppRelease `json:"release"` + AirgapBuilds []AirgapBuildSummary `json:"airgapBuilds"` +} + type KotsAppRelease struct { AppID string `json:"appId"` Sequence int64 `json:"sequence"` @@ -86,6 +104,7 @@ type KotsAppRelease struct { Charts []Chart `json:"charts"` CompatibilityResults []CompatibilityResult `json:"compatibilityResults"` IsHelmOnly bool `json:"isHelmOnly"` + AirgapBuilds []AirgapBuildSummary `json:"airgapBuilds,omitempty"` } type Chart struct { @@ -120,4 +139,5 @@ type AppRelease struct { CompatibilityResults []CompatibilityResult `json:"compatibilityResults,omitempty"` Channels []*Channel `json:"channels,omitempty"` IsHelmOnly bool `json:"isHelmOnly,omitempty"` + AirgapBuilds []AirgapBuildSummary `json:"airgapBuilds,omitempty"` } From 50c18c0a259d51b1eb9cee7e482a92db79a1bb47 Mon Sep 17 00:00:00 2001 From: Benjamin Yang Date: Wed, 13 May 2026 14:50:28 -0400 Subject: [PATCH 2/5] bugbot --- cli/cmd/release_create.go | 3 +++ cli/cmd/release_promote.go | 38 ++++++++++++++++++++---------- cli/cmd/runner.go | 30 +++++++++++------------ cli/print/channel_releases_test.go | 33 ++++++++++++++++++++++++++ pact/kotsclient/release_test.go | 7 ++++++ 5 files changed, 84 insertions(+), 27 deletions(-) diff --git a/cli/cmd/release_create.go b/cli/cmd/release_create.go index 19a2f954a..8362b6e14 100644 --- a/cli/cmd/release_create.go +++ b/cli/cmd/release_create.go @@ -233,6 +233,9 @@ func (r *runners) releaseCreate(cmd *cobra.Command, args []string) (err error) { defer func() { printIfError(cmd, err) }() + // Flush the tabwriter on every return path so buffered output (including + // airgap waiter messages on the error path) actually reaches stdout. + defer r.w.Flush() log := logger.NewLogger(r.w).SetIsTerminal(r.stdoutIsTTY) if r.outputFormat == "json" { diff --git a/cli/cmd/release_promote.go b/cli/cmd/release_promote.go index aa3e84bb3..584e1127a 100644 --- a/cli/cmd/release_promote.go +++ b/cli/cmd/release_promote.go @@ -42,21 +42,24 @@ var inFlightAirgapStates = map[string]bool{ } // terminalFailureStates are the airgap-build outcomes that should fail the -// --wait-for-airgap waiter. "warn" is included because the airgap-builder uses -// it for builds that completed with non-fatal warnings vendors still need to -// see — silently treating it as success would reproduce the misleading-success -// bug this feature is meant to fix. +// --wait-for-airgap waiter. "warn" is NOT a failure — the bundle exists with +// soft warnings about unresolvable image references — and falls through to +// the success path. var terminalFailureStates = map[string]bool{ "failed": true, "failed_with_metadata": true, "cancelled": true, - "warn": true, } func (r *runners) releasePromote(cmd *cobra.Command, args []string) (err error) { defer func() { printIfError(cmd, err) }() + // Flush the tabwriter on every return path. Without this, returning early + // from waitForAirgapBuilds (e.g. on terminal failure) would discard the + // "Channel successfully set" line and the per-channel failure messages + // the waiter wrote, leaving the user with only the generic error output. + defer r.w.Flush() if !r.hasApp() { return errors.New("no app specified") @@ -107,16 +110,16 @@ func (r *runners) releasePromote(cmd *cobra.Command, args []string) (err error) } } - r.w.Flush() - return nil } func (r *runners) waitForAirgapBuilds(promoteResp *types.PromoteReleaseResponse, timeout time.Duration, log *logger.Logger) error { // The vandoor API always returns airgapBuilds[] for promoted channels (metadata - // generation runs for every channel-release). This branch only fires when talking - // to an older server that did not populate the field. - if promoteResp != nil && len(promoteResp.AirgapBuilds) == 0 { + // generation runs for every channel-release). This branch fires when talking to + // an older server that did not populate the field, or if the response object is + // nil for any reason — both must short-circuit before the range below to avoid + // a nil-pointer dereference. + if promoteResp == nil || len(promoteResp.AirgapBuilds) == 0 { log.ActionWithoutSpinner("No airgap build status reported by the API (older server, or no channels were promoted)") return nil } @@ -135,17 +138,23 @@ func (r *runners) waitForAirgapBuilds(promoteResp *types.PromoteReleaseResponse, allTerminal := true var failures []string + var warnings []string for _, build := range promoteResp.AirgapBuilds { status, err := r.kotsAPI.GetAirgapBuildStatus(r.appID, build.ChannelID, build.ChannelSequence) if err != nil { - log.FinishSpinnerWithError() - return errors.Wrapf(err, "failed to get airgap build status for channel %s", build.ChannelName) + // Log the error but continue polling other channels — one channel's + // transient API failure shouldn't mask the status of the rest. + log.ChildActionWithoutSpinner("Warning: could not check airgap status for channel %s: %v", build.ChannelName, err) + allTerminal = false + continue } if inFlightAirgapStates[status.AirgapBuildStatus] { allTerminal = false } else if terminalFailureStates[status.AirgapBuildStatus] { failures = append(failures, fmt.Sprintf("channel %s (%s): %s", status.ChannelName, status.AirgapBuildStatus, status.AirgapBuildError)) + } else if status.AirgapBuildStatus == "warn" { + warnings = append(warnings, fmt.Sprintf("channel %s (warn): %s", status.ChannelName, status.AirgapBuildError)) } } @@ -158,6 +167,11 @@ func (r *runners) waitForAirgapBuilds(promoteResp *types.PromoteReleaseResponse, return errors.New("one or more airgap builds failed") } log.FinishSpinner() + if len(warnings) > 0 { + for _, w := range warnings { + log.ChildActionWithoutSpinner("%s", w) + } + } log.ChildActionWithoutSpinner("Airgap builds complete") return nil } diff --git a/cli/cmd/runner.go b/cli/cmd/runner.go index d891214a3..00dac30b2 100644 --- a/cli/cmd/runner.go +++ b/cli/cmd/runner.go @@ -66,21 +66,21 @@ type runnerArgs struct { createReleasePromoteVersion string createReleasePromoteEnsureChannel bool // Add Create Release Lint - createReleaseLint bool - lintReleaseYamlDir string - lintReleaseChart string - lintReleaseFailOn string - lintVerbose bool + createReleaseLint bool + lintReleaseYamlDir string + lintReleaseChart string + lintReleaseFailOn string + lintVerbose bool releaseOptional bool releaseRequired bool releaseNotes string releaseVersion string - releasePromoteWaitForAirgap bool + releasePromoteWaitForAirgap bool releasePromoteWaitForAirgapTimeout time.Duration - updateReleaseYaml string - updateReleaseYamlDir string - updateReleaseYamlFile string - updateReleaseChart string + updateReleaseYaml string + updateReleaseYamlDir string + updateReleaseYamlFile string + updateReleaseChart string instanceInspectCustomer string instanceInspectInstance string @@ -95,11 +95,11 @@ type runnerArgs struct { createInstallerPromote string createInstallerPromoteEnsureChannel bool - createReleaseAutoDefaults bool - createReleaseAutoDefaultsAccept bool - createReleaseOutputDir string - createReleaseNoUpload bool - createReleasePromoteWaitForAirgap bool + createReleaseAutoDefaults bool + createReleaseAutoDefaultsAccept bool + createReleaseOutputDir string + createReleaseNoUpload bool + createReleasePromoteWaitForAirgap bool createReleasePromoteWaitForAirgapTimeout time.Duration releaseDownloadDest string diff --git a/cli/print/channel_releases_test.go b/cli/print/channel_releases_test.go index 13f755362..6316c52fd 100644 --- a/cli/print/channel_releases_test.go +++ b/cli/print/channel_releases_test.go @@ -72,6 +72,39 @@ func TestKotsChannelReleases_JSON(t *testing.T) { assert.Contains(t, out.String(), `"demotedAt": null`) } +func TestKotsChannelReleases_AirgapColumns(t *testing.T) { + releases := []*types.ChannelRelease{ + { + ChannelSequence: 5, + Sequence: 12, + Semver: "1.2.0", + Created: time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC), + ReleasedAt: time.Date(2026, 2, 2, 0, 0, 0, 0, time.UTC), + AirgapBuildStatus: "failed", + AirgapBuildError: "unauthorized to pull image nginx:latest", + }, + { + ChannelSequence: 4, + Sequence: 11, + Semver: "1.1.0", + Created: time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC), + ReleasedAt: time.Date(2026, 1, 16, 0, 0, 0, 0, time.UTC), + AirgapBuildStatus: "built", + }, + } + + var out bytes.Buffer + w := tabwriter.NewWriter(&out, 0, 8, 4, ' ', tabwriter.TabIndent) + require.NoError(t, KotsChannelReleases("table", w, releases)) + + got := out.String() + assert.Contains(t, got, "AIRGAP_STATUS") + assert.Contains(t, got, "AIRGAP_ERROR") + assert.Contains(t, got, "failed") + assert.Contains(t, got, "unauthorized to pull image nginx:latest") + assert.Contains(t, got, "built") +} + func TestKotsChannelReleases_Empty(t *testing.T) { var out bytes.Buffer w := tabwriter.NewWriter(&out, 0, 8, 4, ' ', tabwriter.TabIndent) diff --git a/pact/kotsclient/release_test.go b/pact/kotsclient/release_test.go index 8a3b1ac6b..206205492 100644 --- a/pact/kotsclient/release_test.go +++ b/pact/kotsclient/release_test.go @@ -226,6 +226,13 @@ func Test_PromoteRelease(t *testing.T) { }). WillRespondWith(dsl.Response{ Status: 200, + Body: map[string]interface{}{ + "release": map[string]interface{}{ + "appId": "replicated-cli-promote-release-app", + "sequence": int64(1), + }, + "airgapBuilds": []interface{}{}, + }, }) if err := pact.Verify(test); err != nil { From 990ee802f8af8ba2a5f3e00eb3cea1be5c9e2891 Mon Sep 17 00:00:00 2001 From: Benjamin Yang Date: Wed, 13 May 2026 15:12:19 -0400 Subject: [PATCH 3/5] bugbot --- cli/cmd/release_promote.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cli/cmd/release_promote.go b/cli/cmd/release_promote.go index 584e1127a..10a964c53 100644 --- a/cli/cmd/release_promote.go +++ b/cli/cmd/release_promote.go @@ -42,13 +42,12 @@ var inFlightAirgapStates = map[string]bool{ } // terminalFailureStates are the airgap-build outcomes that should fail the -// --wait-for-airgap waiter. "warn" is NOT a failure — the bundle exists with -// soft warnings about unresolvable image references — and falls through to -// the success path. +// --wait-for-airgap waiter. var terminalFailureStates = map[string]bool{ "failed": true, "failed_with_metadata": true, "cancelled": true, + "warn": true, } func (r *runners) releasePromote(cmd *cobra.Command, args []string) (err error) { From da30d3e2ca1b23250404b3f12322d061aecde953 Mon Sep 17 00:00:00 2001 From: Benjamin Yang Date: Wed, 13 May 2026 15:19:23 -0400 Subject: [PATCH 4/5] bugbot --- cli/cmd/release_promote.go | 6 +- pacts/replicated-cli-vendor-api.json | 234 ++++++++++++++------------- 2 files changed, 125 insertions(+), 115 deletions(-) diff --git a/cli/cmd/release_promote.go b/cli/cmd/release_promote.go index 10a964c53..996fa5b31 100644 --- a/cli/cmd/release_promote.go +++ b/cli/cmd/release_promote.go @@ -42,12 +42,14 @@ var inFlightAirgapStates = map[string]bool{ } // terminalFailureStates are the airgap-build outcomes that should fail the -// --wait-for-airgap waiter. +// --wait-for-airgap waiter. "warn" is intentionally NOT in this map — the +// bundle still exists, just with soft warnings about unresolvable image +// references; the waiter surfaces those messages but exits 0. See the +// dedicated "warn" branch in waitForAirgapBuilds. var terminalFailureStates = map[string]bool{ "failed": true, "failed_with_metadata": true, "cancelled": true, - "warn": true, } func (r *runners) releasePromote(cmd *cobra.Command, args []string) (err error) { diff --git a/pacts/replicated-cli-vendor-api.json b/pacts/replicated-cli-vendor-api.json index 006b95fa7..4623839ab 100644 --- a/pacts/replicated-cli-vendor-api.json +++ b/pacts/replicated-cli-vendor-api.json @@ -6,119 +6,6 @@ "name": "vendor-api" }, "interactions": [ - { - "description": "A request to create a new release for cli-create-release-app-id", - "providerState": "Create a release for cli-create-release-app-id", - "request": { - "method": "POST", - "path": "/v1/app/cli-create-release-app-id/release", - "headers": { - "Authorization": "cli-create-release-auth", - "Content-Type": "application/json" - }, - "body": { - "source": "latest", - "sourcedata": 0 - } - }, - "response": { - "status": 201, - "headers": { - }, - "body": { - "Config": "", - "CreatedAt": "2006-01-02T15:04:05Z", - "Editable": true, - "EditedAt": "2006-01-02T15:04:05Z", - "Sequence": 10 - }, - "matchingRules": { - "$.body.Config": { - "match": "type" - }, - "$.body.CreatedAt": { - "match": "type" - }, - "$.body.EditedAt": { - "match": "type" - }, - "$.body.Sequence": { - "match": "type" - } - } - } - }, - { - "description": "An empty request to create a new release for cli-create-release-app-id", - "providerState": "Empty create a release for cli-create-release-app-id", - "request": { - "method": "POST", - "path": "/v1/app/cli-create-release-app-id/release", - "headers": { - "Authorization": "cli-create-release-auth" - } - }, - "response": { - "status": 201, - "headers": { - }, - "body": { - "Config": "", - "CreatedAt": "2006-01-02T15:04:05Z", - "Editable": true, - "EditedAt": "2006-01-02T15:04:05Z", - "Sequence": 10 - }, - "matchingRules": { - "$.body.Config": { - "match": "type" - }, - "$.body.CreatedAt": { - "match": "type" - }, - "$.body.EditedAt": { - "match": "type" - }, - "$.body.Sequence": { - "match": "type" - } - } - } - }, - { - "description": "A request to get an existing release for cli-create-release-app-id", - "providerState": "Get a release for cli-create-release-app-id", - "request": { - "method": "GET", - "path": "/v1/app/cli-create-release-app-id/2/properties", - "headers": { - "Authorization": "cli-create-release-auth" - } - }, - "response": { - "status": 200, - "headers": { - }, - "body": { - "Config": "there might be a config here", - "CreatedAt": "2006-01-02T15:04:05Z", - "Editable": true, - "EditedAt": "2006-01-02T15:04:05Z", - "Sequence": 2 - }, - "matchingRules": { - "$.body.Config": { - "match": "type" - }, - "$.body.CreatedAt": { - "match": "type" - }, - "$.body.EditedAt": { - "match": "type" - } - } - } - }, { "description": "A request to add a kots app", "providerState": "Add a kots app", @@ -1881,6 +1768,14 @@ "response": { "status": 200, "headers": { + }, + "body": { + "airgapBuilds": [ + ], + "release": { + "appId": "replicated-cli-promote-release-app", + "sequence": 1 + } } } }, @@ -1933,6 +1828,119 @@ } } } + }, + { + "description": "A request to create a new release for cli-create-release-app-id", + "providerState": "Create a release for cli-create-release-app-id", + "request": { + "method": "POST", + "path": "/v1/app/cli-create-release-app-id/release", + "headers": { + "Authorization": "cli-create-release-auth", + "Content-Type": "application/json" + }, + "body": { + "source": "latest", + "sourcedata": 0 + } + }, + "response": { + "status": 201, + "headers": { + }, + "body": { + "Config": "", + "CreatedAt": "2006-01-02T15:04:05Z", + "Editable": true, + "EditedAt": "2006-01-02T15:04:05Z", + "Sequence": 10 + }, + "matchingRules": { + "$.body.Config": { + "match": "type" + }, + "$.body.CreatedAt": { + "match": "type" + }, + "$.body.EditedAt": { + "match": "type" + }, + "$.body.Sequence": { + "match": "type" + } + } + } + }, + { + "description": "An empty request to create a new release for cli-create-release-app-id", + "providerState": "Empty create a release for cli-create-release-app-id", + "request": { + "method": "POST", + "path": "/v1/app/cli-create-release-app-id/release", + "headers": { + "Authorization": "cli-create-release-auth" + } + }, + "response": { + "status": 201, + "headers": { + }, + "body": { + "Config": "", + "CreatedAt": "2006-01-02T15:04:05Z", + "Editable": true, + "EditedAt": "2006-01-02T15:04:05Z", + "Sequence": 10 + }, + "matchingRules": { + "$.body.Config": { + "match": "type" + }, + "$.body.CreatedAt": { + "match": "type" + }, + "$.body.EditedAt": { + "match": "type" + }, + "$.body.Sequence": { + "match": "type" + } + } + } + }, + { + "description": "A request to get an existing release for cli-create-release-app-id", + "providerState": "Get a release for cli-create-release-app-id", + "request": { + "method": "GET", + "path": "/v1/app/cli-create-release-app-id/2/properties", + "headers": { + "Authorization": "cli-create-release-auth" + } + }, + "response": { + "status": 200, + "headers": { + }, + "body": { + "Config": "there might be a config here", + "CreatedAt": "2006-01-02T15:04:05Z", + "Editable": true, + "EditedAt": "2006-01-02T15:04:05Z", + "Sequence": 2 + }, + "matchingRules": { + "$.body.Config": { + "match": "type" + }, + "$.body.CreatedAt": { + "match": "type" + }, + "$.body.EditedAt": { + "match": "type" + } + } + } } ], "metadata": { From ffcd15b9a96d69944a83a04799f7274eb408915b Mon Sep 17 00:00:00 2001 From: Benjamin Yang Date: Wed, 13 May 2026 15:46:39 -0400 Subject: [PATCH 5/5] addressing feedback --- cli/cmd/release_promote.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/cli/cmd/release_promote.go b/cli/cmd/release_promote.go index 996fa5b31..37798c9a0 100644 --- a/cli/cmd/release_promote.go +++ b/cli/cmd/release_promote.go @@ -115,13 +115,10 @@ func (r *runners) releasePromote(cmd *cobra.Command, args []string) (err error) } func (r *runners) waitForAirgapBuilds(promoteResp *types.PromoteReleaseResponse, timeout time.Duration, log *logger.Logger) error { - // The vandoor API always returns airgapBuilds[] for promoted channels (metadata - // generation runs for every channel-release). This branch fires when talking to - // an older server that did not populate the field, or if the response object is - // nil for any reason — both must short-circuit before the range below to avoid - // a nil-pointer dereference. + // Short-circuit before the range below if the response is nil or carries no + // airgap builds — both would otherwise nil-pointer-deref or no-op the loop. if promoteResp == nil || len(promoteResp.AirgapBuilds) == 0 { - log.ActionWithoutSpinner("No airgap build status reported by the API (older server, or no channels were promoted)") + log.ActionWithoutSpinner("No airgap builds reported for this promotion") return nil }