| 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. |
| 6 | package release |
| 7 | |
| 8 | import ( |
| 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. |
| 27 | var ErrTargetNotAboveTags = errors.New("target must be greater than every existing tag") |
| 28 | |
| 29 | // VersionTarget is the bucket object at TargetKey. |
| 30 | type 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 | |
| 37 | func 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. |
| 44 | func 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. |
| 69 | func 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. |
| 114 | func 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. |
| 131 | func 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 | } |
| 152 | func 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 | |