| 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) |
| +} |