| new file mode 100644 |
| index 000000000..45f2c01a6 |
| --- /dev/null |
| +++ b/internal/release/target.go |
| @@ -0,0 +1,124 @@ |
| +// 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" |
| +) |
| + |
| +// 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. |
| +func GetTarget(ctx context.Context, s Store, acct, app string) (*VersionTarget, string, error) { |
| + raw, etag, err := s.Get(ctx, TargetKey(acct, app)) |
| + 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", TargetKey(acct, app), err) |
| + } |
| + 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 SetTarget(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 — the target must be greater", target, tag.Version) |
| + } |
| + } |
| + return setTargetUnchecked(ctx, s, acct, app, t, actor, now) |
| +} |
| + |
| +// 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. Last write wins: the |
| +// target is an intention, not a ledger. |
| +// |
| +// 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 rather |
| +// than the brief's literal "*", which store.go does not special-case. |
| +func setTargetUnchecked(ctx context.Context, s Store, acct, app string, t SemVer, actor string, now time.Time) error { |
| + raw, err := json.Marshal(VersionTarget{Schema: 1, Target: t.String(), SetBy: actor, SetAt: now}) |
| + if err != nil { |
| + return err |
| + } |
| + key := TargetKey(acct, app) |
| + _, etag, err := s.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + etag = "" // first write: create-only |
| + } else if err != nil { |
| + return fmt.Errorf("version target: read %s: %w", key, err) |
| + } |
| + if _, err := s.Put(ctx, key, raw, etag); 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 |
| +} |