| 1 | // Tags — the human names releases earn when they reach stable (release |
| 2 | // versioning spec, 2026-08-08). A tag is written once, points at exactly |
| 3 | // one shipped release, and carries the compiled changelog of every |
| 4 | // iteration that led there. Tags are read-side sugar plus an audit record: |
| 5 | // no control-plane flow keys off one, adopt never reads them, and they are |
| 6 | // unsigned for the same reason manifests are — every path that writes one |
| 7 | // sits behind an authenticated door, and nothing that runs code trusts |
| 8 | // them. |
| 9 | package release |
| 10 | |
| 11 | import ( |
| 12 | "context" |
| 13 | "encoding/json" |
| 14 | "errors" |
| 15 | "fmt" |
| 16 | "os" |
| 17 | "sort" |
| 18 | "strings" |
| 19 | "time" |
| 20 | ) |
| 21 | |
| 22 | // Tag records one cut version: which build it points at, who cut it, and |
| 23 | // the story of how it got there. |
| 24 | type Tag struct { |
| 25 | Schema int `json:"schema"` // 1 |
| 26 | Version string `json:"version"` |
| 27 | Release string `json:"release"` |
| 28 | Commit string `json:"commit,omitempty"` |
| 29 | TaggedAt time.Time `json:"tagged_at"` |
| 30 | Actor string `json:"actor,omitempty"` |
| 31 | Changelog string `json:"changelog,omitempty"` |
| 32 | } |
| 33 | |
| 34 | func TagsPrefix(acct, app string) string { return appPrefix(acct, app) + "tags/" } |
| 35 | |
| 36 | func TagKey(acct, app, version string) string { |
| 37 | return TagsPrefix(acct, app) + version + ".json" |
| 38 | } |
| 39 | |
| 40 | // GetTag reads one tag; a miss is os.ErrNotExist-wrapped, matching |
| 41 | // GetManifest's contract. |
| 42 | func GetTag(ctx context.Context, s Store, acct, app, version string) (*Tag, error) { |
| 43 | raw, _, err := s.Get(ctx, TagKey(acct, app, version)) |
| 44 | if err != nil { |
| 45 | return nil, err |
| 46 | } |
| 47 | var t Tag |
| 48 | if err := json.Unmarshal(raw, &t); err != nil { |
| 49 | return nil, fmt.Errorf("tag at %s: %w", TagKey(acct, app, version), err) |
| 50 | } |
| 51 | if t.Version != version { |
| 52 | return nil, fmt.Errorf("tag at %s names %s", TagKey(acct, app, version), t.Version) |
| 53 | } |
| 54 | return &t, nil |
| 55 | } |
| 56 | |
| 57 | // ListTags lists an app's tags, semver-descending — newest line first, |
| 58 | // which string order gets wrong the day 0.10.0 exists. Skips key names |
| 59 | // that don't parse as semver and os.ErrNotExist misses (list/delete race). |
| 60 | // Returns other GetTag errors wrapped with the key. |
| 61 | func ListTags(ctx context.Context, s Store, acct, app string) ([]Tag, error) { |
| 62 | prefix := TagsPrefix(acct, app) |
| 63 | keys, err := s.List(ctx, prefix) |
| 64 | if err != nil { |
| 65 | return nil, err |
| 66 | } |
| 67 | var out []Tag |
| 68 | for _, k := range keys { |
| 69 | name := strings.TrimSuffix(strings.TrimPrefix(k, prefix), ".json") |
| 70 | if _, ok := ParseSemVer(name); !ok { |
| 71 | continue |
| 72 | } |
| 73 | t, err := GetTag(ctx, s, acct, app, name) |
| 74 | if err != nil { |
| 75 | if errors.Is(err, os.ErrNotExist) { |
| 76 | continue |
| 77 | } |
| 78 | return nil, fmt.Errorf("tag at %s: %w", k, err) |
| 79 | } |
| 80 | out = append(out, *t) |
| 81 | } |
| 82 | sort.SliceStable(out, func(i, j int) bool { |
| 83 | a, _ := ParseSemVer(out[i].Version) |
| 84 | b, _ := ParseSemVer(out[j].Version) |
| 85 | return a.Compare(b) > 0 |
| 86 | }) |
| 87 | return out, nil |
| 88 | } |
| 89 | |
| 90 | // writeTag stores a tag under the same immutability rule as every release |
| 91 | // object: create, or confirm byte-identical content already there. |
| 92 | func writeTag(ctx context.Context, s Store, acct, app string, t *Tag) error { |
| 93 | raw, err := json.Marshal(t) |
| 94 | if err != nil { |
| 95 | return err |
| 96 | } |
| 97 | return putImmutable(ctx, s, TagKey(acct, app, t.Version), raw) |
| 98 | } |
| 99 | |
| 100 | // EnsureTag is the promote-to-stable hook: given the release version that |
| 101 | // just landed on stable, make sure its tag exists. Three outcomes, none of |
| 102 | // which may fail a promotion that already happened: |
| 103 | // |
| 104 | // not an iteration -> (nil, false, nil): sha builds and explicit |
| 105 | // -version ships never grow invented history. |
| 106 | // tag exists -> (tag, false, nil): promote is re-runnable, so |
| 107 | // this must be too. |
| 108 | // tag cut -> (tag, true, nil), changelog compiled from every |
| 109 | // iteration of the target, oldest first; then the |
| 110 | // target auto-advances to the next patch — |
| 111 | // best-effort, and never past a target the |
| 112 | // operator already raised. |
| 113 | func claimRawEnsureTag(ctx context.Context, s Store, acct, app, releaseVersion, actor string, now time.Time) (*Tag, bool, error) { |
| 114 | target, n, ok := SplitIteration(releaseVersion) |
| 115 | if !ok { |
| 116 | return nil, false, nil |
| 117 | } |
| 118 | if existing, err := GetTag(ctx, s, acct, app, target.String()); err == nil { |
| 119 | return existing, false, nil |
| 120 | } else if !errors.Is(err, os.ErrNotExist) { |
| 121 | return nil, false, err |
| 122 | } |
| 123 | |
| 124 | tag := &Tag{Schema: 1, Version: target.String(), Release: releaseVersion, TaggedAt: now, Actor: actor} |
| 125 | var b strings.Builder |
| 126 | for i := 1; i <= n; i++ { |
| 127 | ver := IterationVersion(target, i) |
| 128 | m, err := GetManifest(ctx, s, acct, app, ver) |
| 129 | if err != nil { |
| 130 | // Only skip genuine gaps (not-found); propagate other errors. |
| 131 | if errors.Is(err, os.ErrNotExist) { |
| 132 | continue |
| 133 | } |
| 134 | // For the promoted release itself, any error prevents tag creation. |
| 135 | if ver == releaseVersion { |
| 136 | return nil, false, err |
| 137 | } |
| 138 | // For other iterations, a transient error also aborts. |
| 139 | return nil, false, err |
| 140 | } |
| 141 | if ver == releaseVersion { |
| 142 | tag.Commit = m.Commit |
| 143 | } |
| 144 | if b.Len() > 0 { |
| 145 | b.WriteString("\n") |
| 146 | } |
| 147 | b.WriteString("## " + ver) |
| 148 | if m.Label != "" { |
| 149 | b.WriteString(" — " + m.Label) |
| 150 | } |
| 151 | b.WriteString("\n") |
| 152 | if m.Notes != "" { |
| 153 | b.WriteString("\n" + strings.TrimRight(m.Notes, "\n") + "\n") |
| 154 | } |
| 155 | } |
| 156 | tag.Changelog = b.String() |
| 157 | if err := writeTag(ctx, s, acct, app, tag); err != nil { |
| 158 | return nil, false, err |
| 159 | } |
| 160 | |
| 161 | // Advance the target — best-effort, and only forward: an unset target |
| 162 | // stays unset only if the next patch would collide with a HIGHER |
| 163 | // existing tag (a hotfix line under a fleet already past it). |
| 164 | next := target.NextPatch() |
| 165 | cur, etag, err := GetTarget(ctx, s, acct, app) |
| 166 | if err == nil && (cur == nil || func() bool { c, ok := ParseSemVer(cur.Target); return ok && c.Compare(target) <= 0 }()) { |
| 167 | if higher, lerr := ListTags(ctx, s, acct, app); lerr == nil { |
| 168 | blocked := false |
| 169 | for _, ht := range higher { |
| 170 | if hv, ok := ParseSemVer(ht.Version); ok && next.Compare(hv) <= 0 { |
| 171 | blocked = true |
| 172 | break |
| 173 | } |
| 174 | } |
| 175 | if !blocked { |
| 176 | // Advance with the etag from our check. The CAS window now spans |
| 177 | // the full check-then-write, so a lost race is truly advisory |
| 178 | // (operator's raise wins, concurrent advance is idempotent). |
| 179 | if cur == nil { |
| 180 | etag = "" // first write: create-only |
| 181 | } |
| 182 | _ = setTargetUnchecked(ctx, s, acct, app, next, actor, now, etag) |
| 183 | } |
| 184 | } |
| 185 | } |
| 186 | return tag, true, nil |
| 187 | } |
| 188 | func EnsureTag(ctx context.Context, s Store, acct, app, releaseVersion, actor string, now time.Time) (claimResult0 *Tag, claimResult1 bool, claimResult2 error) { |
| 189 | claimResult2 = WithAppMutation(ctx, s, acct, app, "release.EnsureTag", func(claimContext context.Context) error { |
| 190 | claimResult0, claimResult1, claimResult2 = claimRawEnsureTag(claimContext, s, acct, app, releaseVersion, actor, now) |
| 191 | return claimResult2 |
| 192 | }) |
| 193 | return |
| 194 | } |
| 195 | |