release: EnsureTag compiles the changelog and advances the target
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+145
−0
internal/release/tag.go+74 −0internal/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) |
| + } |
| +} |