Make delivery asset outcomes disjoint

This commit is contained in:
2026-10-06 17:54:22 +00:00
parent 0eec4a30ed
commit c9afc8dd12
10 changed files with 322 additions and 32 deletions

View File

@@ -230,6 +230,7 @@ func runDelivery(ctx context.Context, invocation cli.Invocation, stdout, stderr
if err != nil {
fmt.Fprintf(stderr, "delivery state=%s version=%s commit=%s url=%s: %v\n", result.State, result.Version, result.CandidateCommit, result.ReleaseURL, err)
writeDeliveryAssets(stderr, result)
writeDeliveryRecovery(stderr, result)
if errors.Is(err, releasekit.ErrInvalidVersion) {
return 2
}
@@ -257,6 +258,24 @@ func writeDeliveryAssets(output io.Writer, result releasekit.DeliveryResult) {
}
}
func writeDeliveryRecovery(output io.Writer, result releasekit.DeliveryResult) {
switch result.State {
case releasekit.DeliveryNotAttempted:
fmt.Fprintln(output, "recovery: fix the local or preflight failure before retrying; no remote write was attempted")
case releasekit.DeliveryPartial:
fmt.Fprintln(output, "recovery: fix the reported cause, then retry this version")
case releasekit.DeliveryConflict:
fmt.Fprintln(output, "recovery: investigate conflicting hosted content; use a new version if the source or published contents need correction")
case releasekit.DeliveryUnknown:
fmt.Fprintln(output, "recovery: resolve the remote state before retrying")
default:
return
}
if len(result.Matching)+len(result.Uploaded)+len(result.Conflicting)+len(result.Uncertain) != 0 {
fmt.Fprintln(output, "if rebuilding could change asset bytes already on the server, retain the original build artifacts and retry with deliver --assets DIR")
}
}
func buildResultFromReceipt(receipt cli.BuildReceipt) *releasekit.BuildResult {
result := &releasekit.BuildResult{
RepositoryDir: receipt.RepositoryDir, CandidateCommit: receipt.CandidateCommit, SourceVerified: receipt.SourceVerified,

View File

@@ -317,6 +317,82 @@ func TestDeliverBuildsTaggedArtifactsAndReusesAssets(t *testing.T) {
}
}
func TestDeliverFailureReportsRecoveryForPartialConflictAndUnknownStates(t *testing.T) {
for _, test := range []struct {
name string
state string
status int
recovery []string
assetObservation string
missingObservation string
}{
{
name: "partial",
status: 1,
recovery: []string{"recovery: fix the reported cause", "retry this version"},
assetObservation: "pending helper-v1.2.3-linux-amd64",
},
{
name: "conflict",
status: 1,
recovery: []string{"investigate conflicting hosted content", "new version", "retain the original build artifacts"},
assetObservation: "conflicting helper-v1.2.3-linux-amd64",
missingObservation: "pending SHA256SUMS",
},
{
name: "unknown",
status: 3,
recovery: []string{"resolve the remote state before retrying"},
assetObservation: "uncertain helper-v1.2.3-linux-amd64",
missingObservation: "pending SHA256SUMS",
},
} {
t.Run(test.name, func(t *testing.T) {
repository := newCLIRepository(t)
state := &deliveryServerState{}
server := newDeliveryServer(t, state)
defer server.Close()
build := cli.BuildSettings{MainPackage: ".", BinaryName: "helper", Targets: []string{"linux/amd64"}}
writeDeliveryProfile(t, repository, cli.Settings{
Product: "Example Product",
Build: &build,
Delivery: &cli.DeliverySettings{BaseURL: server.URL, Owner: "owner", Repository: "example"},
})
state.commit = cliGitOutput(t, repository, "rev-parse", "HEAD")
cliGitRun(t, repository, "tag", "v1.2.3")
assetsDir, _ := writeDeliveryReceipt(t, repository, build)
switch test.name {
case "partial":
state.failAssetName = "helper-v1.2.3-linux-amd64"
case "conflict":
state.assets["helper-v1.2.3-linux-amd64"] = []byte("different hosted bytes")
case "unknown":
state.ambiguousAssetName = "helper-v1.2.3-linux-amd64"
}
t.Setenv("RELEASEKIT_GITEA_TOKEN", "fixture-token")
var stdout, stderr bytes.Buffer
status := run(context.Background(), []string{"deliver", "--repo", repository, "--assets", assetsDir, "v1.2.3"}, &stdout, &stderr)
if status != test.status || !strings.Contains(stderr.String(), "state="+test.name) {
t.Fatalf("status=%d output=%q want status=%d state=%s", status, stderr.String(), test.status, test.name)
}
for _, phrase := range test.recovery {
if !strings.Contains(stderr.String(), phrase) {
t.Errorf("delivery output does not include recovery guidance %q: %s", phrase, stderr.String())
}
}
if !strings.Contains(stderr.String(), test.assetObservation) {
t.Errorf("delivery output does not include asset observation %q: %s", test.assetObservation, stderr.String())
}
if test.missingObservation != "" && !strings.Contains(stderr.String(), test.missingObservation) {
t.Errorf("delivery output does not include later missing asset %q: %s", test.missingObservation, stderr.String())
}
if test.name == "unknown" && strings.Contains(stderr.String(), "pending helper-v1.2.3-linux-amd64") {
t.Errorf("uncertain asset also appears pending: %s", stderr.String())
}
})
}
}
func TestDeliverRejectsWrongHostedTagBeforeReleaseWrite(t *testing.T) {
repository := newCLIRepository(t)
state := &deliveryServerState{remoteCommit: strings.Repeat("0", 40)}
@@ -443,6 +519,8 @@ type deliveryServerState struct {
malformedCreate bool
failAssetListing bool
failAssetDownloads bool
failAssetName string
ambiguousAssetName string
commit string
product string
remoteCommit string
@@ -543,6 +621,14 @@ func newDeliveryServer(t *testing.T, state *deliveryServerState) *httptest.Serve
if _, exists := state.assets[name]; exists {
t.Errorf("asset %q uploaded more than once", name)
}
if name == state.failAssetName {
http.Error(w, "upload failed", http.StatusBadRequest)
return
}
if name == state.ambiguousAssetName {
http.Error(w, "upload outcome ambiguous", http.StatusInternalServerError)
return
}
state.assets[name] = content
state.assetUploads++
_ = json.NewEncoder(w).Encode(map[string]any{"id": state.assetUploads, "name": name, "size": len(content)})
@@ -751,6 +837,9 @@ func TestUnreadableAssetsDoNotClaimAnUnknownWrite(t *testing.T) {
if status != 1 || !strings.Contains(stderr.String(), "state="+expectedState) || state.assetUploads != 0 || state.releaseCreates != expectedCreates {
t.Fatalf("status=%d creates=%d uploads=%d %s", status, state.releaseCreates, state.assetUploads, stderr.String())
}
if existingPage && !strings.Contains(stderr.String(), "recovery: fix the local or preflight failure") {
t.Fatalf("not-attempted recovery guidance missing: %s", stderr.String())
}
})
}
}

View File

@@ -33,6 +33,7 @@ func TestReleaseWrapperPreservesNativeExitStatuses(t *testing.T) {
for _, path := range []string{
"check.go", "publish.go", "internal/candidate/check.go", "internal/publish/publish.go",
"build_test.go", "publish_test.go", "internal/candidate/check_test.go",
"internal/gitea/upload.go", "internal/gitea/upload_test.go",
"cmd/releasekit/main_test.go", "cmd/releasekit/release_wrapper_test.go",
"cmd/releasekit/internal/cli/config_test.go", "examples/releasekit.json",
} {