| index 496b38dfa..9014735b4 100644 |
| --- a/internal/release/ladder_test.go |
| +++ b/internal/release/ladder_test.go |
| @@ -1,6 +1,11 @@ |
| package release |
| |
| import ( |
| + "context" |
| + "crypto/ed25519" |
| + "errors" |
| + "fmt" |
| + "os" |
| "strings" |
| "testing" |
| ) |
| @@ -98,3 +103,214 @@ func TestPromotionAllowedRefusesSkipsAndNamesHotfix(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// --- Stable promotion cuts the version tag (release versioning spec) --- |
| + |
| +// shipLabelledIteration ships a real one-artifact release carrying the human |
| +// half of a manifest, so a tag cut from it has a non-empty changelog. It goes |
| +// through Ship (not a hand-written manifest) so the artifact sha256s are the |
| +// real thing and validateManifest is exercised. |
| +func shipLabelledIteration(t *testing.T, s Store, acct, app, version, label, notes string) { |
| + t.Helper() |
| + m := Manifest{ |
| + App: app, Version: version, Kind: "binary", |
| + Commit: "c-" + version, Label: label, Notes: notes, |
| + Artifacts: map[string]Artifact{"linux-arm64": {}}, |
| + } |
| + files := map[string][]byte{"linux-arm64": []byte("build " + version)} |
| + if err := Ship(context.Background(), s, acct, m, files); err != nil { |
| + t.Fatalf("Ship %s/%s: %v", app, version, err) |
| + } |
| +} |
| + |
| +// climbToBeta walks a version up the ladder the way an operator does, so the |
| +// stable promote under test meets a real edge->beta->stable history. |
| +func climbToBeta(t *testing.T, ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, version string) { |
| + t.Helper() |
| + for _, ch := range []string{"edge", "beta"} { |
| + if _, err := PromoteOnLadder(ctx, s, priv, acct, app, ch, version, PromoteOptions{}); err != nil { |
| + t.Fatalf("promote %s to %s: %v", version, ch, err) |
| + } |
| + } |
| +} |
| + |
| +// A promotion to stable cuts the iteration's tag, reports it, and stays |
| +// re-runnable: the repeat is a no-op that still names the tag. |
| +func TestStablePromoteCutsTag(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, _ := testKeys(t) |
| + |
| + shipLabelledIteration(t, s, "bab", "km", "0.5.0-1", "fixing modals", "- focus trap fixed") |
| + shipLabelledIteration(t, s, "bab", "km", "0.5.0-2", "polish", "- rounded corners") |
| + climbToBeta(t, ctx, s, priv, "bab", "km", "0.5.0-2") |
| + |
| + res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.5.0-2", |
| + PromoteOptions{Actor: "paul@example.com"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if res.Tag != "0.5.0" || res.TagWarning != "" { |
| + t.Fatalf("res.Tag=%q TagWarning=%q; want 0.5.0, empty", res.Tag, res.TagWarning) |
| + } |
| + tag, err := GetTag(ctx, s, "bab", "km", "0.5.0") |
| + if err != nil || tag.Release != "0.5.0-2" { |
| + t.Fatalf("tag after promote: %+v, %v", tag, err) |
| + } |
| + if tag.Actor != "paul@example.com" || tag.Commit != "c-0.5.0-2" { |
| + t.Fatalf("tag fields: %+v", tag) |
| + } |
| + // The changelog is compiled from BOTH iterations, oldest first — proof the |
| + // hook ran with the whole line in view, not just the promoted build. |
| + if !strings.Contains(tag.Changelog, "0.5.0-1") || !strings.Contains(tag.Changelog, "rounded corners") { |
| + t.Fatalf("changelog does not carry the line: %q", tag.Changelog) |
| + } |
| + |
| + // Repeat promote: NoOp, and the tag is still reported. |
| + res2, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.5.0-2", |
| + PromoteOptions{Actor: "paul@example.com"}) |
| + if err != nil || !res2.NoOp || res2.Tag != "0.5.0" { |
| + t.Fatalf("repeat: %+v, %v", res2, err) |
| + } |
| + if res2.TagWarning != "" { |
| + t.Fatalf("repeat warned: %q", res2.TagWarning) |
| + } |
| +} |
| + |
| +// The no-op path is a HEAL, not just a report: a promotion whose tag write |
| +// failed the first time is repaired by re-running the same command. |
| +// |
| +// Mutation-mindedness: drop the stableTag call from the NoOp early return and |
| +// this is the only test that notices — and the operator would have no way back |
| +// to a tag, since the pointer never moves again. |
| +func TestRepeatStablePromoteHealsAMissingTag(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, _ := testKeys(t) |
| + |
| + shipLabelledIteration(t, s, "bab", "km", "0.6.0-1", "first", "- one") |
| + climbToBeta(t, ctx, s, priv, "bab", "km", "0.6.0-1") |
| + if _, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.6.0-1", PromoteOptions{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Simulate the tag write having failed on the first attempt. |
| + if err := s.Delete(ctx, TagKey("bab", "km", "0.6.0")); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.6.0-1", |
| + PromoteOptions{Actor: "paul@example.com"}) |
| + if err != nil || !res.NoOp { |
| + t.Fatalf("repeat: %+v, %v", res, err) |
| + } |
| + if res.Tag != "0.6.0" || res.TagWarning != "" { |
| + t.Fatalf("no-op did not heal the tag: Tag=%q warning=%q", res.Tag, res.TagWarning) |
| + } |
| + if tag, err := GetTag(ctx, s, "bab", "km", "0.6.0"); err != nil || tag.Release != "0.6.0-1" { |
| + t.Fatalf("tag after heal: %+v, %v", tag, err) |
| + } |
| +} |
| + |
| +// A sha-named build reaching stable cuts nothing — no invented history — and |
| +// says so, so the operator knows why the release has no name. |
| +func TestStablePromoteShaVersionNoTag(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + |
| + shipRelease(t, s, "bab", "km", "de2766f8", "linux-arm64", []byte("build")) |
| + climbToBeta(t, ctx, s, priv, "bab", "km", "de2766f8") |
| + |
| + res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "de2766f8", PromoteOptions{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if res.Tag != "" { |
| + t.Fatalf("sha promote cut tag %q", res.Tag) |
| + } |
| + if res.TagWarning == "" { |
| + t.Fatal("sha promote to stable should warn that no tag was cut") |
| + } |
| + if !strings.Contains(res.TagWarning, "de2766f8") { |
| + t.Fatalf("warning does not name the version: %q", res.TagWarning) |
| + } |
| + // The promotion itself is real, warning or not. |
| + p, err := Channel(ctx, s, pub, "bab", "km", "stable") |
| + if err != nil || p.Version != "de2766f8" { |
| + t.Fatalf("stable pointer = %+v, %v", p, err) |
| + } |
| +} |
| + |
| +// Nothing below stable touches tags: edge and beta are staging rungs, and a |
| +// build that never reaches stable never earns a name. |
| +func TestBetaPromoteNoTag(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, _ := testKeys(t) |
| + |
| + shipLabelledIteration(t, s, "bab", "km", "0.5.0-1", "first", "- one") |
| + if _, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "edge", "0.5.0-1", PromoteOptions{}); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "beta", "0.5.0-1", PromoteOptions{}) |
| + if err != nil || res.Tag != "" || res.TagWarning != "" { |
| + t.Fatalf("beta promote touched tags: %+v, %v", res, err) |
| + } |
| + if _, err := GetTag(ctx, s, "bab", "km", "0.5.0"); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("beta promote wrote a tag: %v", err) |
| + } |
| + // A canary promotion of the same iteration is equally tag-free. |
| + res, err = PromoteOnLadder(ctx, s, priv, "bab", "km", "canary/x", "0.5.0-1", PromoteOptions{}) |
| + if err != nil || res.Tag != "" || res.TagWarning != "" { |
| + t.Fatalf("canary promote touched tags: %+v, %v", res, err) |
| + } |
| +} |
| + |
| +// tagFailStore fails every read under an app's tags/ prefix with an error that |
| +// is NOT os.ErrNotExist — the shape of a bucket that is up but unhappy. |
| +type tagFailStore struct { |
| + Store |
| + fail error |
| +} |
| + |
| +func (s tagFailStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + if strings.Contains(key, "/tags/") { |
| + return nil, "", s.fail |
| + } |
| + return s.Store.Get(ctx, key) |
| +} |
| + |
| +// The promotion has already happened when the tag is cut, so a tag failure can |
| +// only ever be a warning. Returning an error here would tell the operator the |
| +// deploy failed when the fleet is already moving to the new build. |
| +// |
| +// Mutation-mindedness: make stableTag return its error and have PromoteOnLadder |
| +// propagate it, and this is the test that fails — with a stable pointer that |
| +// moved under a "promote failed" message. |
| +func TestStablePromoteTagFailureIsAWarningNotAnError(t *testing.T) { |
| + dir := NewDirStore(t.TempDir()) |
| + s := tagFailStore{Store: dir, fail: fmt.Errorf("bucket said no")} |
| + ctx := context.Background() |
| + priv, pub := testKeys(t) |
| + |
| + shipLabelledIteration(t, s, "bab", "km", "0.5.0-1", "first", "- one") |
| + climbToBeta(t, ctx, s, priv, "bab", "km", "0.5.0-1") |
| + |
| + res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.5.0-1", |
| + PromoteOptions{Actor: "paul@example.com"}) |
| + if err != nil { |
| + t.Fatalf("a failed tag write failed the promotion: %v", err) |
| + } |
| + if res.Tag != "" { |
| + t.Fatalf("res.Tag = %q after a failed tag read", res.Tag) |
| + } |
| + if !strings.Contains(res.TagWarning, "bucket said no") { |
| + t.Fatalf("warning does not carry the cause: %q", res.TagWarning) |
| + } |
| + // And the pointer really moved. |
| + p, err := Channel(ctx, s, pub, "bab", "km", "stable") |
| + if err != nil || p.Version != "0.5.0-1" { |
| + t.Fatalf("stable pointer = %+v, %v", p, err) |
| + } |
| +} |