carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
9package release
10
11import (
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.
24type 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
34func TagsPrefix(acct, app string) string { return appPrefix(acct, app) + "tags/" }
35
36func 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.
42func 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.
61func 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.
92func 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.
113func 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}
188func 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