carlos / platform Public

release: per-app version target and iteration allocation

Adds the version-target object (get/set with tag-ordering validation)
and NextIteration, the counter allocator that reads shipped release
dirs as the ledger. SetTarget refuses a target at or below any
existing tag; setTargetUnchecked skips that check for the
auto-advance-after-tag path.

Deviates from the task brief's setTargetUnchecked: the brief's
s.Put(ctx, key, raw, "*") assumed "*" meant unconditional overwrite,
but store.go's Put has no such sentinel — "" is create-only
(If-None-Match: *) and non-empty is replace-only against that exact
etag. Matches this package's existing update convention instead
(SetProduction in production.go, Promote's pointer swap in
release.go): read the current etag first, treat os.ErrNotExist as ""
(first write), then Put once with that etag and let a lost race
surface as ErrCASConflict to the caller.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a2d058a1cebafe28654ccfe73348c53928fddf45 parent 17bbf92
2 files changed, +196 −0
  • internal/release/target.go +124 −0
  • internal/release/target_test.go +72 −0
diff --git a/internal/release/target.go b/internal/release/target.go
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
+}
diff --git a/internal/release/target_test.go b/internal/release/target_test.go
new file mode 100644
index 000000000..3a54adce3
--- /dev/null
+++ b/internal/release/target_test.go
@@ -0,0 +1,72 @@
+package release
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestTargetSetGet(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if vt, _, err := GetTarget(ctx, s, "acct1", "km"); err != nil || vt != nil {
+ t.Fatalf("unset target: got %v, %v; want nil, nil", vt, err)
+ }
+ now := time.Unix(1000, 0).UTC()
+ if err := SetTarget(ctx, s, "acct1", "km", "0.5.0", "paul@example.com", now); err != nil {
+ t.Fatal(err)
+ }
+ vt, _, err := GetTarget(ctx, s, "acct1", "km")
+ if err != nil || vt == nil || vt.Target != "0.5.0" || vt.SetBy != "paul@example.com" || !vt.SetAt.Equal(now) {
+ t.Fatalf("round trip: %+v, %v", vt, err)
+ }
+ // Raising is fine; garbage and iterations are not.
+ if err := SetTarget(ctx, s, "acct1", "km", "0.6.0", "paul@example.com", now); err != nil {
+ t.Fatal(err)
+ }
+ for _, bad := range []string{"", "0.5", "v1.0.0", "0.5.0-3", "de2766f8"} {
+ if err := SetTarget(ctx, s, "acct1", "km", bad, "x", now); err == nil {
+ t.Errorf("SetTarget(%q) accepted", bad)
+ }
+ }
+}
+
+func TestTargetMustExceedTags(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ now := time.Unix(1000, 0).UTC()
+ tag := Tag{Schema: 1, Version: "0.5.0", Release: "0.5.0-3", TaggedAt: now}
+ if err := writeTag(ctx, s, "acct1", "km", &tag); err != nil {
+ t.Fatal(err)
+ }
+ if err := SetTarget(ctx, s, "acct1", "km", "0.5.0", "x", now); err == nil {
+ t.Fatal("target equal to an existing tag accepted")
+ }
+ if err := SetTarget(ctx, s, "acct1", "km", "0.4.9", "x", now); err == nil {
+ t.Fatal("target below an existing tag accepted")
+ }
+ if err := SetTarget(ctx, s, "acct1", "km", "0.5.1", "x", now); err != nil {
+ t.Fatalf("next patch refused: %v", err)
+ }
+}
+
+func TestNextIteration(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ tgt := SemVer{0, 5, 0}
+ v, err := NextIteration(ctx, s, "acct1", "km", tgt)
+ if err != nil || v != "0.5.0-1" {
+ t.Fatalf("first iteration: %q, %v", v, err)
+ }
+ // Existing releases under this target and noise from other schemes both
+ // sit in the same prefix; only this target's iterations count.
+ for _, ver := range []string{"0.5.0-1", "0.5.0-3", "0.4.0-9", "de2766f8"} {
+ if _, err := s.Put(ctx, ReleasesPrefix("acct1", "km")+ver+"/manifest.json", []byte("{}"), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ v, err = NextIteration(ctx, s, "acct1", "km", tgt)
+ if err != nil || v != "0.5.0-4" {
+ t.Fatalf("after -1 and -3 exist: %q, %v; want 0.5.0-4", v, err)
+ }
+}