carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The version target — the per-app "what we're building toward" (release
2// versioning spec, 2026-08-08). With a target set, ship mints iteration
3// versions under it ("0.5.0-3"); with none set, ship behaves exactly as it
4// always has. The whole feature hangs off this one optional object, which
5// is what keeps it opt-in per app.
6package release
7
8import (
9 "context"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "os"
14 "strings"
15 "time"
16)
17
18// ErrTargetNotAboveTags is SetTarget's ordering refusal, wrapped (not
19// returned bare) so a caller can tell it apart from every other way
20// SetTarget can fail — a store outage, say — with errors.Is, while the
21// human-facing message (which names the colliding tag) still comes through
22// on Error(). The console API is exactly that caller: SetTarget's shape
23// refusal (400, the caller mistyped) and this ordering refusal (409, a fact
24// about the app's own history) are a caller's fault either way, but
25// anything else SetTarget can return is the STORE's fault and must not
26// share a status code with either.
27var ErrTargetNotAboveTags = errors.New("target must be greater than every existing tag")
28
29// VersionTarget is the bucket object at TargetKey.
30type VersionTarget struct {
31 Schema int `json:"schema"` // 1
32 Target string `json:"target"`
33 SetBy string `json:"set_by,omitempty"`
34 SetAt time.Time `json:"set_at"`
35}
36
37func TargetKey(acct, app string) string { return appPrefix(acct, app) + "version-target.json" }
38
39// GetTarget reads the target; (nil, "", nil) when none is set — unset is a
40// state, not an error. The etag is returned for CAS on update. Like
41// validateProductionFlag and GetTag, a hit is validated before it is
42// trusted: an unsupported schema or a target that no longer parses as a
43// bare semver is refused rather than handed to a caller that assumes both.
44func GetTarget(ctx context.Context, s Store, acct, app string) (*VersionTarget, string, error) {
45 key := TargetKey(acct, app)
46 raw, etag, err := s.Get(ctx, key)
47 if errors.Is(err, os.ErrNotExist) {
48 return nil, "", nil
49 }
50 if err != nil {
51 return nil, "", err
52 }
53 var vt VersionTarget
54 if err := json.Unmarshal(raw, &vt); err != nil {
55 return nil, "", fmt.Errorf("version target at %s: %w", key, err)
56 }
57 if vt.Schema != 1 {
58 return nil, "", fmt.Errorf("version target at %s: unsupported schema %d", key, vt.Schema)
59 }
60 if _, ok := ParseSemVer(vt.Target); !ok {
61 return nil, "", fmt.Errorf("version target at %s: target %q is not a bare semver", key, vt.Target)
62 }
63 return &vt, etag, nil
64}
65
66// SetTarget validates and writes the target. A target must be a bare
67// semver strictly greater than every existing tag — a released version is
68// history, and history is not reopened.
69func claimRawSetTarget(ctx context.Context, s Store, acct, app, target, actor string, now time.Time) error {
70 t, ok := ParseSemVer(target)
71 if !ok {
72 return fmt.Errorf("version target %q: want MAJOR.MINOR.PATCH, like 0.5.0", target)
73 }
74 tags, err := ListTags(ctx, s, acct, app)
75 if err != nil {
76 return fmt.Errorf("version target: list tags: %w", err)
77 }
78 for _, tag := range tags {
79 if tv, ok := ParseSemVer(tag.Version); ok && t.Compare(tv) <= 0 {
80 return fmt.Errorf("version target %s: already released as tag %s — %w", target, tag.Version, ErrTargetNotAboveTags)
81 }
82 }
83 cur, etag, err := GetTarget(ctx, s, acct, app)
84 if err != nil {
85 return err
86 }
87 if cur == nil {
88 etag = "" // first write: create-only
89 }
90 return setTargetUnchecked(ctx, s, acct, app, t, actor, now, etag)
91}
92
93// setTargetUnchecked writes the target with no tag comparison — the
94// auto-advance after a tag cut has already proved its ordering, and
95// re-checking would race the tag it just wrote. The caller provides the
96// ifMatch etag from their own GetTarget read, so the CAS window spans
97// the entire check-then-write: a lost race returns ErrCASConflict and the
98// auto-advance caller treats that conflict as ADVISORY and does not retry.
99// Both outcomes are correct: an operator's raise between the caller's check
100// and this write wins (operator's ordering was higher), and a concurrent
101// advance landing the same value is idempotent. Neither should be
102// retried by a stale check.
103//
104// Put mechanism: this codebase's Store.Put treats ifMatch "" as create-only
105// (If-None-Match: *) and non-empty ifMatch as replace-only against that
106// exact etag (store.go Put on both DirStore and S3Store) — there is no "*"
107// sentinel meaning "overwrite unconditionally". Every existing mutable
108// object in this package (SetProduction in production.go, Promote's
109// pointer swap in release.go) follows the same shape to update-or-create:
110// Get the key first, treat os.ErrNotExist as "" (first write, create-only),
111// otherwise use the etag just read, then Put once with that ifMatch and let
112// a lost race surface as ErrCASConflict to the caller — re-read and retry,
113// never overwrite blind. The target follows that same convention.
114func setTargetUnchecked(ctx context.Context, s Store, acct, app string, t SemVer, actor string, now time.Time, ifMatch string) error {
115 raw, err := json.Marshal(VersionTarget{Schema: 1, Target: t.String(), SetBy: actor, SetAt: now})
116 if err != nil {
117 return err
118 }
119 key := TargetKey(acct, app)
120 if _, err := s.Put(ctx, key, raw, ifMatch); err != nil {
121 return fmt.Errorf("version target: write: %w", err)
122 }
123 return nil
124}
125
126// NextIteration allocates the next counter under a target by reading what
127// has actually been shipped — the release dirs are the ledger, so there is
128// no side counter to drift from it. Racing allocators can collide, and the
129// immutable-release refusal is the backstop: the loser's ship fails
130// cleanly and is re-run.
131func NextIteration(ctx context.Context, s Store, acct, app string, t SemVer) (string, error) {
132 prefix := ReleasesPrefix(acct, app)
133 keys, err := s.List(ctx, prefix)
134 if err != nil {
135 return "", fmt.Errorf("next iteration: list %s: %w", prefix, err)
136 }
137 max := 0
138 seen := map[string]bool{}
139 for _, k := range keys {
140 v, _, ok := strings.Cut(strings.TrimPrefix(k, prefix), "/")
141 if !ok || seen[v] {
142 continue
143 }
144 seen[v] = true
145 vt, n, ok := SplitIteration(v)
146 if ok && vt == t && n > max {
147 max = n
148 }
149 }
150 return IterationVersion(t, max+1), nil
151}
152func SetTarget(ctx context.Context, s Store, acct, app, target, actor string, now time.Time) (claimResult0 error) {
153 claimResult0 = WithAppMutation(ctx, s, acct, app, "release.SetTarget", func(claimContext context.Context) error {
154 claimResult0 = claimRawSetTarget(claimContext, s, acct, app, target, actor, now)
155 return claimResult0
156 })
157 return
158}
159