carlos / platform Public

release: tag objects — immutable cut records under tags/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d9c098d3f7f6fa56be598193e55815ab5d65034d parent 90b5127
2 files changed, +152 −0
  • internal/release/tag.go +92 −0
  • internal/release/tag_test.go +60 −0
diff --git a/internal/release/tag.go b/internal/release/tag.go
new file mode 100644
index 000000000..c897cb366
--- /dev/null
+++ b/internal/release/tag.go
@@ -0,0 +1,92 @@
+// Tags — the human names releases earn when they reach stable (release
+// versioning spec, 2026-08-08). A tag is written once, points at exactly
+// one shipped release, and carries the compiled changelog of every
+// iteration that led there. Tags are read-side sugar plus an audit record:
+// no control-plane flow keys off one, adopt never reads them, and they are
+// unsigned for the same reason manifests are — every path that writes one
+// sits behind an authenticated door, and nothing that runs code trusts
+// them.
+package release
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+)
+
+// Tag records one cut version: which build it points at, who cut it, and
+// the story of how it got there.
+type Tag struct {
+ Schema int `json:"schema"` // 1
+ Version string `json:"version"`
+ Release string `json:"release"`
+ Commit string `json:"commit,omitempty"`
+ TaggedAt time.Time `json:"tagged_at"`
+ Actor string `json:"actor,omitempty"`
+ Changelog string `json:"changelog,omitempty"`
+}
+
+func TagsPrefix(acct, app string) string { return appPrefix(acct, app) + "tags/" }
+
+func TagKey(acct, app, version string) string {
+ return TagsPrefix(acct, app) + version + ".json"
+}
+
+// GetTag reads one tag; a miss is os.ErrNotExist-wrapped, matching
+// GetManifest's contract.
+func GetTag(ctx context.Context, s Store, acct, app, version string) (*Tag, error) {
+ raw, _, err := s.Get(ctx, TagKey(acct, app, version))
+ if err != nil {
+ return nil, err
+ }
+ var t Tag
+ if err := json.Unmarshal(raw, &t); err != nil {
+ return nil, fmt.Errorf("tag at %s: %w", TagKey(acct, app, version), err)
+ }
+ if t.Version != version {
+ return nil, fmt.Errorf("tag at %s names %s", TagKey(acct, app, version), t.Version)
+ }
+ return &t, nil
+}
+
+// ListTags lists an app's tags, semver-descending — newest line first,
+// which string order gets wrong the day 0.10.0 exists. A key that does not
+// parse as a semver tag is skipped, not fatal.
+func ListTags(ctx context.Context, s Store, acct, app string) ([]Tag, error) {
+ prefix := TagsPrefix(acct, app)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return nil, err
+ }
+ var out []Tag
+ for _, k := range keys {
+ name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json")
+ if _, ok := ParseSemVer(name); !ok {
+ continue
+ }
+ t, err := GetTag(ctx, s, acct, app, name)
+ if err != nil {
+ continue
+ }
+ out = append(out, *t)
+ }
+ sort.SliceStable(out, func(i, j int) bool {
+ a, _ := ParseSemVer(out[i].Version)
+ b, _ := ParseSemVer(out[j].Version)
+ return a.Compare(b) > 0
+ })
+ return out, nil
+}
+
+// writeTag stores a tag under the same immutability rule as every release
+// object: create, or confirm byte-identical content already there.
+func writeTag(ctx context.Context, s Store, acct, app string, t *Tag) error {
+ raw, err := json.Marshal(t)
+ if err != nil {
+ return err
+ }
+ return putImmutable(ctx, s, TagKey(acct, app, t.Version), raw)
+}
diff --git a/internal/release/tag_test.go b/internal/release/tag_test.go
new file mode 100644
index 000000000..a207f3485
--- /dev/null
+++ b/internal/release/tag_test.go
@@ -0,0 +1,60 @@
+package release
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+ "time"
+)
+
+func TestTagWriteGetList(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if _, err := GetTag(ctx, s, "acct1", "km", "0.5.0"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("missing tag: got %v; want os.ErrNotExist", err)
+ }
+ tags := []Tag{
+ {Schema: 1, Version: "0.5.0", Release: "0.5.0-3", TaggedAt: time.Now().UTC(), Actor: "paul@example.com", Changelog: "## 0.5.0-1 — fixing modals\n"},
+ {Schema: 1, Version: "0.10.0", Release: "0.10.0-1", TaggedAt: time.Now().UTC()},
+ {Schema: 1, Version: "0.6.0", Release: "0.6.0-2", TaggedAt: time.Now().UTC()},
+ }
+ for i := range tags {
+ if err := writeTag(ctx, s, "acct1", "km", &tags[i]); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := GetTag(ctx, s, "acct1", "km", "0.5.0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Release != "0.5.0-3" || got.Changelog == "" {
+ t.Fatalf("round trip lost fields: %+v", got)
+ }
+ list, err := ListTags(ctx, s, "acct1", "km")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Semver-descending, which is NOT string order (0.10.0 > 0.6.0).
+ if len(list) != 3 || list[0].Version != "0.10.0" || list[1].Version != "0.6.0" || list[2].Version != "0.5.0" {
+ t.Fatalf("wrong order: %+v", list)
+ }
+}
+
+func TestTagImmutable(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ a := Tag{Schema: 1, Version: "0.5.0", Release: "0.5.0-3", TaggedAt: time.Unix(100, 0).UTC()}
+ if err := writeTag(ctx, s, "acct1", "km", &a); err != nil {
+ t.Fatal(err)
+ }
+ // Same bytes: idempotent. Different bytes: refused.
+ if err := writeTag(ctx, s, "acct1", "km", &a); err != nil {
+ t.Fatalf("identical rewrite refused: %v", err)
+ }
+ b := a
+ b.Release = "0.5.0-4"
+ if err := writeTag(ctx, s, "acct1", "km", &b); err == nil {
+ t.Fatal("conflicting rewrite accepted")
+ }
+}