carlos / platform Public

release: stable promotion cuts the version tag

PromoteResult gains Tag/TagWarning, and PromoteOnLadder calls a new
stableTag helper on both paths that end with the version on stable: the
successful promote, and the NoOp early return (so re-running the command
heals a stable release whose tag write failed the first time).

A tag failure is never an error. The pointer has already moved by the
time the tag is cut, so EnsureTag's errors land in TagWarning and the
promotion reports the success that actually happened.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 44cc445c8d98b687eb2747ae5dc53be807f2b11e parent 5896ef2
2 files changed, +247 −0
  • internal/release/ladder_test.go +216 −0
  • internal/release/release.go +31 −0
diff --git a/internal/release/ladder_test.go b/internal/release/ladder_test.go
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)
+ }
+}
diff --git a/internal/release/release.go b/internal/release/release.go
index b75385103..d159061ca 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -729,6 +729,32 @@ type PromoteResult struct {
NoOp bool
// Hotfix is whether the ordering check was bypassed.
Hotfix bool
+ // Tag is the semver tag stable promotion cut (or found already cut) for
+ // this version — "" off stable, and "" for versions that are not
+ // iterations. TagWarning says why a stable promotion carries no tag, or
+ // that cutting one failed; it is never an error, because the pointer
+ // has already moved and a promotion that happened must report success.
+ Tag string
+ TagWarning string
+}
+
+// stableTag fills res.Tag/TagWarning after version landed on (or was already
+// on) stable. Failures degrade to the warning: the promotion is real whether
+// or not its name-cutting worked, and a caller that turned this into an error
+// would tell the operator a deploy failed while the fleet moved to the build.
+func stableTag(ctx context.Context, s Store, acct, app, version, actor string, res *PromoteResult) {
+ if res.To != RungStable {
+ return
+ }
+ tag, _, err := EnsureTag(ctx, s, acct, app, version, actor, time.Now().UTC())
+ switch {
+ case err != nil:
+ res.TagWarning = "promoted, but cutting the tag failed: " + err.Error()
+ case tag == nil:
+ res.TagWarning = "no tag cut: " + version + " is not a <target>-<n> iteration"
+ default:
+ res.Tag = tag.Version
+ }
}
// PromoteOnLadder is Promote with the channel ladder enforced: it works out
@@ -782,6 +808,10 @@ func (sg Signer) PromoteOnLadder(ctx context.Context, s Store, acct, app, channe
if p, verr := verifyPointer(pub, cur, app, channel); verr == nil && p.Version == version {
res.NoOp = true
res.From, _ = Rung(channel)
+ // A repeat still reports the tag — and cuts it if a previous
+ // attempt's write failed, so re-running the same command heals a
+ // stable release that ended up nameless.
+ stableTag(ctx, s, acct, app, version, opt.Actor, &res)
return res, nil
}
} else if !errors.Is(err, os.ErrNotExist) {
@@ -826,6 +856,7 @@ func (sg Signer) PromoteOnLadder(ctx context.Context, s Store, acct, app, channe
if err := sg.Promote(ctx, s, acct, app, channel, version, rec); err != nil {
return res, err
}
+ stableTag(ctx, s, acct, app, version, opt.Actor, &res)
return res, nil
}