carlos / platform Public

release: EnsureTag compiles the changelog and advances the target

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 1ae7f3fe4af81772673882574ae1d75fde1db001 parent 67fc6e3
2 files changed, +145 −0
  • internal/release/tag.go +74 −0
  • internal/release/tag_test.go +71 −0
diff --git a/internal/release/tag.go b/internal/release/tag.go
index f00e8b909..a647fb73c 100644
--- a/internal/release/tag.go
+++ b/internal/release/tag.go
@@ -96,3 +96,77 @@ func writeTag(ctx context.Context, s Store, acct, app string, t *Tag) error {
}
return putImmutable(ctx, s, TagKey(acct, app, t.Version), raw)
}
+
+// EnsureTag is the promote-to-stable hook: given the release version that
+// just landed on stable, make sure its tag exists. Three outcomes, none of
+// which may fail a promotion that already happened:
+//
+// not an iteration -> (nil, false, nil): sha builds and explicit
+// -version ships never grow invented history.
+// tag exists -> (tag, false, nil): promote is re-runnable, so
+// this must be too.
+// tag cut -> (tag, true, nil), changelog compiled from every
+// iteration of the target, oldest first; then the
+// target auto-advances to the next patch —
+// best-effort, and never past a target the
+// operator already raised.
+func EnsureTag(ctx context.Context, s Store, acct, app, releaseVersion, actor string, now time.Time) (*Tag, bool, error) {
+ target, n, ok := SplitIteration(releaseVersion)
+ if !ok {
+ return nil, false, nil
+ }
+ if existing, err := GetTag(ctx, s, acct, app, target.String()); err == nil {
+ return existing, false, nil
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return nil, false, err
+ }
+
+ tag := &Tag{Schema: 1, Version: target.String(), Release: releaseVersion, TaggedAt: now, Actor: actor}
+ var b strings.Builder
+ for i := 1; i <= n; i++ {
+ ver := IterationVersion(target, i)
+ m, err := GetManifest(ctx, s, acct, app, ver)
+ if err != nil {
+ continue // an unshipped or unreadable iteration is a gap, not a failure
+ }
+ if ver == releaseVersion {
+ tag.Commit = m.Commit
+ }
+ if b.Len() > 0 {
+ b.WriteString("\n")
+ }
+ b.WriteString("## " + ver)
+ if m.Label != "" {
+ b.WriteString(" — " + m.Label)
+ }
+ b.WriteString("\n")
+ if m.Notes != "" {
+ b.WriteString("\n" + strings.TrimRight(m.Notes, "\n") + "\n")
+ }
+ }
+ tag.Changelog = b.String()
+ if err := writeTag(ctx, s, acct, app, tag); err != nil {
+ return nil, false, err
+ }
+
+ // Advance the target — best-effort, and only forward: an unset target
+ // stays unset only if the next patch would collide with a HIGHER
+ // existing tag (a hotfix line under a fleet already past it).
+ next := target.NextPatch()
+ cur, _, err := GetTarget(ctx, s, acct, app)
+ if err == nil && (cur == nil || func() bool { c, ok := ParseSemVer(cur.Target); return ok && c.Compare(target) <= 0 }()) {
+ if higher, lerr := ListTags(ctx, s, acct, app); lerr == nil {
+ blocked := false
+ for _, ht := range higher {
+ if hv, ok := ParseSemVer(ht.Version); ok && next.Compare(hv) <= 0 {
+ blocked = true
+ break
+ }
+ }
+ if !blocked {
+ _ = setTargetUnchecked(ctx, s, acct, app, next, actor, now) // advisory; the tag is already cut
+ }
+ }
+ }
+ return tag, true, nil
+}
diff --git a/internal/release/tag_test.go b/internal/release/tag_test.go
index 91462ef11..a20df48b5 100644
--- a/internal/release/tag_test.go
+++ b/internal/release/tag_test.go
@@ -2,6 +2,7 @@ package release
import (
"context"
+ "encoding/json"
"errors"
"os"
"strings"
@@ -81,3 +82,73 @@ func TestTagListErrors(t *testing.T) {
t.Fatalf("error does not name the key: %v", err)
}
}
+
+func shipIteration(t *testing.T, s Store, ver, label, notes string) {
+ t.Helper()
+ m := Manifest{Schema: 1, App: "km", Version: ver, Kind: "binary",
+ Commit: "c-" + ver, Label: label, Notes: notes,
+ Artifacts: map[string]Artifact{"linux-arm64": {File: "linux-arm64", SHA256: "0000000000000000000000000000000000000000000000000000000000000000", Size: 1}}}
+ raw, err := json.Marshal(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := putImmutable(context.Background(), s, ManifestKey("acct1", "km", ver), raw); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestEnsureTagCompilesChangelog(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ now := time.Unix(2000, 0).UTC()
+ shipIteration(t, s, "0.5.0-1", "fixing modals", "- focus trap fixed")
+ shipIteration(t, s, "0.5.0-2", "", "")
+ shipIteration(t, s, "0.5.0-3", "polish", "- rounded corners")
+ tag, created, err := EnsureTag(ctx, s, "acct1", "km", "0.5.0-3", "paul@example.com", now)
+ if err != nil || !created || tag == nil {
+ t.Fatalf("EnsureTag: %+v, %v, %v", tag, created, err)
+ }
+ if tag.Version != "0.5.0" || tag.Release != "0.5.0-3" || tag.Commit != "c-0.5.0-3" || tag.Actor != "paul@example.com" {
+ t.Fatalf("tag fields: %+v", tag)
+ }
+ want := "## 0.5.0-1 — fixing modals\n\n- focus trap fixed\n\n## 0.5.0-2\n\n## 0.5.0-3 — polish\n\n- rounded corners\n"
+ if tag.Changelog != want {
+ t.Fatalf("changelog:\n%q\nwant:\n%q", tag.Changelog, want)
+ }
+ // Auto-advance: the target moved to the next patch.
+ vt, _, err := GetTarget(ctx, s, "acct1", "km")
+ if err != nil || vt == nil || vt.Target != "0.5.1" {
+ t.Fatalf("target after cut: %+v, %v; want 0.5.1", vt, err)
+ }
+ // Idempotent: a second call finds the tag, creates nothing.
+ tag2, created2, err := EnsureTag(ctx, s, "acct1", "km", "0.5.0-3", "someone@else.com", now.Add(time.Hour))
+ if err != nil || created2 || tag2.Actor != "paul@example.com" {
+ t.Fatalf("repeat: %+v, %v, %v", tag2, created2, err)
+ }
+}
+
+func TestEnsureTagSkipsNonIterations(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ tag, created, err := EnsureTag(context.Background(), s, "acct1", "km", "de2766f8", "x", time.Unix(0, 0).UTC())
+ if tag != nil || created || err != nil {
+ t.Fatalf("sha version: %+v, %v, %v; want nil, false, nil", tag, created, err)
+ }
+}
+
+func TestEnsureTagDoesNotLowerRaisedTarget(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ now := time.Unix(2000, 0).UTC()
+ shipIteration(t, s, "0.5.0-1", "", "")
+ // Operator already aimed at 0.6.0; cutting 0.5.0 must not drag it back.
+ if err := SetTarget(ctx, s, "acct1", "km", "0.6.0", "paul@example.com", now); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := EnsureTag(ctx, s, "acct1", "km", "0.5.0-1", "x", now); err != nil {
+ t.Fatal(err)
+ }
+ vt, _, _ := GetTarget(ctx, s, "acct1", "km")
+ if vt == nil || vt.Target != "0.6.0" {
+ t.Fatalf("target was moved: %+v; want 0.6.0 kept", vt)
+ }
+}