package release import ( "context" "crypto/ed25519" "errors" "fmt" "os" "strings" "testing" ) func TestRungOfChannel(t *testing.T) { cases := []struct { channel string rung string ok bool }{ {"stable", RungStable, true}, {"beta", RungBeta, true}, {"edge", RungEdge, true}, {"canary", RungCanary, true}, {"canary/keymail", RungCanary, true}, {"canary/platform-adoption", RungCanary, true}, {"canary/rehearsal", RungCanary, true}, {"canary/x", RungCanary, true}, // Only canary nests, and only names in the frozen vocabulary are rungs. {"beta/x", "", false}, {"stable/x", "", false}, {"dev", "", false}, {"wip", "", false}, {"", "", false}, {"canary/", "", false}, {"canary/a/b", "", false}, } for _, c := range cases { got, ok := Rung(c.channel) if got != c.rung || ok != c.ok { t.Errorf("Rung(%q) = (%q, %v), want (%q, %v)", c.channel, got, ok, c.rung, c.ok) } } } func TestRungIndexOrdersTheLadder(t *testing.T) { if RungIndex(RungCanary) != 0 || RungIndex(RungEdge) != 1 || RungIndex(RungBeta) != 2 || RungIndex(RungStable) != 3 { t.Fatalf("ladder order wrong: canary=%d edge=%d beta=%d stable=%d", RungIndex(RungCanary), RungIndex(RungEdge), RungIndex(RungBeta), RungIndex(RungStable)) } for _, unknown := range []string{"", "dev", "prod", "canary/x"} { if got := RungIndex(unknown); got != -1 { t.Errorf("RungIndex(%q) = %d, want -1", unknown, got) } } } func TestPromotionAllowedLegalTransitions(t *testing.T) { legal := []struct{ from, to string }{ // The two ordered steps, which is the whole ladder above edge. {RungEdge, RungBeta}, {RungBeta, RungStable}, // A fresh build enters the ladder at edge (merging put it there). {"", RungEdge}, // Canary has no gate in: production runs on canary/* channels today // and shipping to one is never refused, whatever the build is on. {"", RungCanary}, {RungCanary, RungCanary}, {RungEdge, RungCanary}, {RungBeta, RungCanary}, {RungStable, RungCanary}, } for _, c := range legal { if err := PromotionAllowed(c.from, c.to); err != nil { t.Errorf("PromotionAllowed(%q, %q) = %v, want nil", c.from, c.to, err) } } } func TestPromotionAllowedRefusesSkipsAndNamesHotfix(t *testing.T) { refused := []struct{ from, to string }{ {RungEdge, RungStable}, // the skip the ladder exists to stop {"", RungBeta}, // straight onto beta, having been nowhere {"", RungStable}, // straight onto stable, having been nowhere {RungCanary, RungEdge}, // nothing is promoted from canary to edge {RungCanary, RungBeta}, // ... nor past it {RungCanary, RungStable}, // {RungStable, RungBeta}, // no going back down the ladder {RungBeta, RungEdge}, // {RungEdge, RungEdge}, // same-rung re-promotion of another build {RungBeta, RungBeta}, // {RungStable, RungStable}, // {RungEdge, "dev"}, // not a rung at all {RungEdge, ""}, // } for _, c := range refused { err := PromotionAllowed(c.from, c.to) if err == nil { t.Errorf("PromotionAllowed(%q, %q) = nil, want a refusal", c.from, c.to) continue } if !strings.Contains(err.Error(), "-hotfix") { t.Errorf("PromotionAllowed(%q, %q) = %q, want the refusal to name --hotfix", c.from, c.to, err) } } } // --- 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) } } // A second iteration under a target already tagged by an earlier one must not // claim the earlier tag as its own. 0.5.0 got tagged at 0.5.0-1; when 0.5.0-2 // later reaches stable, EnsureTag finds that existing tag (it names the same // target) and hands it back unchanged — the promotion is real, but it did not // cut or move the tag, so res.Tag must stay empty and the operator must be // told which iteration the tag actually names. // // Mutation-mindedness: report res.Tag = tag.Version regardless of tag.Release // and this is the test that catches it — a false "tagged 0.5.0" for a // promotion the tag does not describe. func TestStablePromoteAfterEarlierTagWarnsInsteadOfClaimingIt(t *testing.T) { s := NewDirStore(t.TempDir()) 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") if _, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.5.0-1", PromoteOptions{}); err != nil { t.Fatal(err) } shipLabelledIteration(t, s, "bab", "km", "0.5.0-2", "second", "- two") 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{}) if err != nil { t.Fatal(err) } if res.Tag != "" { t.Fatalf("res.Tag = %q; a tag belonging to a different iteration must not be claimed", res.Tag) } if !strings.Contains(res.TagWarning, "already tagged at 0.5.0-1") { t.Fatalf("TagWarning = %q; want it to name the iteration the tag actually carries", res.TagWarning) } // The promotion itself is real regardless of the tag warning. p, err := Channel(ctx, s, pub, "bab", "km", "stable") if err != nil || p.Version != "0.5.0-2" { t.Fatalf("stable pointer = %+v, %v", p, err) } } // A hotfix straight to stable — no rungs climbed — still cuts the version's // tag: the ladder gate is bypassed, but stable promotion's tag-cutting is not // gated on how the version got there. func TestHotfixToStableCutsTag(t *testing.T) { s := NewDirStore(t.TempDir()) ctx := context.Background() priv, _ := testKeys(t) shipLabelledIteration(t, s, "bab", "km", "0.7.0-1", "urgent fix", "- patched the leak") res, err := PromoteOnLadder(ctx, s, priv, "bab", "km", "stable", "0.7.0-1", PromoteOptions{Hotfix: true, Actor: "paul@example.com"}) if err != nil { t.Fatal(err) } if !res.Hotfix { t.Fatalf("res.Hotfix = false for a hotfix promotion") } if res.Tag != "0.7.0" { t.Fatalf("res.Tag = %q, TagWarning = %q; want 0.7.0 cut even via hotfix", res.Tag, res.TagWarning) } tag, err := GetTag(ctx, s, "bab", "km", "0.7.0") if err != nil || tag.Release != "0.7.0-1" { t.Fatalf("tag after hotfix promote: %+v, %v", tag, err) } }