// The version target — the per-app "what we're building toward" (release // versioning spec, 2026-08-08). With a target set, ship mints iteration // versions under it ("0.5.0-3"); with none set, ship behaves exactly as it // always has. The whole feature hangs off this one optional object, which // is what keeps it opt-in per app. package release import ( "context" "encoding/json" "errors" "fmt" "os" "strings" "time" ) // ErrTargetNotAboveTags is SetTarget's ordering refusal, wrapped (not // returned bare) so a caller can tell it apart from every other way // SetTarget can fail — a store outage, say — with errors.Is, while the // human-facing message (which names the colliding tag) still comes through // on Error(). The console API is exactly that caller: SetTarget's shape // refusal (400, the caller mistyped) and this ordering refusal (409, a fact // about the app's own history) are a caller's fault either way, but // anything else SetTarget can return is the STORE's fault and must not // share a status code with either. var ErrTargetNotAboveTags = errors.New("target must be greater than every existing tag") // VersionTarget is the bucket object at TargetKey. type VersionTarget struct { Schema int `json:"schema"` // 1 Target string `json:"target"` SetBy string `json:"set_by,omitempty"` SetAt time.Time `json:"set_at"` } func TargetKey(acct, app string) string { return appPrefix(acct, app) + "version-target.json" } // GetTarget reads the target; (nil, "", nil) when none is set — unset is a // state, not an error. The etag is returned for CAS on update. Like // validateProductionFlag and GetTag, a hit is validated before it is // trusted: an unsupported schema or a target that no longer parses as a // bare semver is refused rather than handed to a caller that assumes both. func GetTarget(ctx context.Context, s Store, acct, app string) (*VersionTarget, string, error) { key := TargetKey(acct, app) raw, etag, err := s.Get(ctx, key) if errors.Is(err, os.ErrNotExist) { return nil, "", nil } if err != nil { return nil, "", err } var vt VersionTarget if err := json.Unmarshal(raw, &vt); err != nil { return nil, "", fmt.Errorf("version target at %s: %w", key, err) } if vt.Schema != 1 { return nil, "", fmt.Errorf("version target at %s: unsupported schema %d", key, vt.Schema) } if _, ok := ParseSemVer(vt.Target); !ok { return nil, "", fmt.Errorf("version target at %s: target %q is not a bare semver", key, vt.Target) } return &vt, etag, nil } // SetTarget validates and writes the target. A target must be a bare // semver strictly greater than every existing tag — a released version is // history, and history is not reopened. func claimRawSetTarget(ctx context.Context, s Store, acct, app, target, actor string, now time.Time) error { t, ok := ParseSemVer(target) if !ok { return fmt.Errorf("version target %q: want MAJOR.MINOR.PATCH, like 0.5.0", target) } tags, err := ListTags(ctx, s, acct, app) if err != nil { return fmt.Errorf("version target: list tags: %w", err) } for _, tag := range tags { if tv, ok := ParseSemVer(tag.Version); ok && t.Compare(tv) <= 0 { return fmt.Errorf("version target %s: already released as tag %s — %w", target, tag.Version, ErrTargetNotAboveTags) } } cur, etag, err := GetTarget(ctx, s, acct, app) if err != nil { return err } if cur == nil { etag = "" // first write: create-only } return setTargetUnchecked(ctx, s, acct, app, t, actor, now, etag) } // setTargetUnchecked writes the target with no tag comparison — the // auto-advance after a tag cut has already proved its ordering, and // re-checking would race the tag it just wrote. The caller provides the // ifMatch etag from their own GetTarget read, so the CAS window spans // the entire check-then-write: a lost race returns ErrCASConflict and the // auto-advance caller treats that conflict as ADVISORY and does not retry. // Both outcomes are correct: an operator's raise between the caller's check // and this write wins (operator's ordering was higher), and a concurrent // advance landing the same value is idempotent. Neither should be // retried by a stale check. // // Put mechanism: this codebase's Store.Put treats ifMatch "" as create-only // (If-None-Match: *) and non-empty ifMatch as replace-only against that // exact etag (store.go Put on both DirStore and S3Store) — there is no "*" // sentinel meaning "overwrite unconditionally". Every existing mutable // object in this package (SetProduction in production.go, Promote's // pointer swap in release.go) follows the same shape to update-or-create: // Get the key first, treat os.ErrNotExist as "" (first write, create-only), // otherwise use the etag just read, then Put once with that ifMatch and let // a lost race surface as ErrCASConflict to the caller — re-read and retry, // never overwrite blind. The target follows that same convention. func setTargetUnchecked(ctx context.Context, s Store, acct, app string, t SemVer, actor string, now time.Time, ifMatch string) error { raw, err := json.Marshal(VersionTarget{Schema: 1, Target: t.String(), SetBy: actor, SetAt: now}) if err != nil { return err } key := TargetKey(acct, app) if _, err := s.Put(ctx, key, raw, ifMatch); err != nil { return fmt.Errorf("version target: write: %w", err) } return nil } // NextIteration allocates the next counter under a target by reading what // has actually been shipped — the release dirs are the ledger, so there is // no side counter to drift from it. Racing allocators can collide, and the // immutable-release refusal is the backstop: the loser's ship fails // cleanly and is re-run. func NextIteration(ctx context.Context, s Store, acct, app string, t SemVer) (string, error) { prefix := ReleasesPrefix(acct, app) keys, err := s.List(ctx, prefix) if err != nil { return "", fmt.Errorf("next iteration: list %s: %w", prefix, err) } max := 0 seen := map[string]bool{} for _, k := range keys { v, _, ok := strings.Cut(strings.TrimPrefix(k, prefix), "/") if !ok || seen[v] { continue } seen[v] = true vt, n, ok := SplitIteration(v) if ok && vt == t && n > max { max = n } } return IterationVersion(t, max+1), nil } func SetTarget(ctx context.Context, s Store, acct, app, target, actor string, now time.Time) (claimResult0 error) { claimResult0 = WithAppMutation(ctx, s, acct, app, "release.SetTarget", func(claimContext context.Context) error { claimResult0 = claimRawSetTarget(claimContext, s, acct, app, target, actor, now) return claimResult0 }) return }