release: fix stale CAS comment, validate GetTarget hits, add CAS/etag tests
Review found two issues in the version-target CAS resolution: - setTargetUnchecked's doc comment still said "last write wins," which contradicted the actual behavior (a lost race returns ErrCASConflict). Replaced with the real contract, including the auto-advance caller's decision: treat a lost race as advisory and don't retry, since the winner was either an operator raising the target or another advance landing the same value. - GetTarget validated nothing it read, unlike every sibling reader (validateProductionFlag gates schema, GetTag checks payload-vs-key). Now refuses schema != 1 and a target that fails ParseSemVer. Added TestGetTargetValidates (garbage JSON, wrong schema, bad target), TestSetTargetCASConflict (a racingStore double proves a competing write between SetTarget's read and its own Put surfaces ErrCASConflict), and etag assertions in TestTargetSetGet (empty when unset, non-empty on a hit).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+104
−8
internal/release/target.go+20 −5internal/release/target_test.go+84 −3
diff --git a/internal/release/target.go b/internal/release/target.go| index 45f2c01a6..e474dde33 100644 |
| --- a/internal/release/target.go |
| +++ b/internal/release/target.go |
| @@ -26,9 +26,13 @@ type VersionTarget struct { |
| 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. |
| +// 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) { |
| - raw, etag, err := s.Get(ctx, TargetKey(acct, app)) |
| + key := TargetKey(acct, app) |
| + raw, etag, err := s.Get(ctx, key) |
| if errors.Is(err, os.ErrNotExist) { |
| return nil, "", nil |
| } |
| @@ -37,7 +41,13 @@ func GetTarget(ctx context.Context, s Store, acct, app string) (*VersionTarget, |
| } |
| 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 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 |
| } |
| @@ -64,8 +74,13 @@ func SetTarget(ctx context.Context, s Store, acct, app, target, actor string, no |
| // 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. |
| +// re-checking would race the tag it just wrote. The write is still a real |
| +// CAS: a lost race returns ErrCASConflict like any other Put here, it does |
| +// not silently overwrite. The auto-advance caller treats that conflict as |
| +// ADVISORY and does not retry — whoever won the race was either an operator |
| +// raising the target (which must not be clobbered by a stale advance) or |
| +// another advance landing the same value, and both outcomes are fine to |
| +// leave as they are. |
| // |
| // Put mechanism: this codebase's Store.Put treats ifMatch "" as create-only |
| // (If-None-Match: *) and non-empty ifMatch as replace-only against that |
diff --git a/internal/release/target_test.go b/internal/release/target_test.go| index 3a54adce3..fbfea3546 100644 |
| --- a/internal/release/target_test.go |
| +++ b/internal/release/target_test.go |
| @@ -2,6 +2,8 @@ package release |
| import ( |
| "context" |
| + "encoding/json" |
| + "errors" |
| "testing" |
| "time" |
| ) |
| @@ -9,17 +11,20 @@ import ( |
| 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) |
| + if vt, etag, err := GetTarget(ctx, s, "acct1", "km"); err != nil || vt != nil || etag != "" { |
| + t.Fatalf("unset target: got %v, %q, %v; want nil, \"\", nil", vt, etag, 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") |
| + vt, etag, 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) |
| } |
| + if etag == "" { |
| + t.Fatal("etag on a hit must not be empty — CAS on update depends on it") |
| + } |
| // 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) |
| @@ -70,3 +75,79 @@ func TestNextIteration(t *testing.T) { |
| t.Fatalf("after -1 and -3 exist: %q, %v; want 0.5.0-4", v, err) |
| } |
| } |
| + |
| +// TestGetTargetValidates matches GetTag/validateProductionFlag's discipline: |
| +// a hit is checked before it is trusted, not handed back as-is. |
| +func TestGetTargetValidates(t *testing.T) { |
| + ctx := context.Background() |
| + cases := []struct { |
| + name string |
| + raw []byte |
| + }{ |
| + {"garbage json", []byte("not json")}, |
| + {"unsupported schema", mustMarshal(t, VersionTarget{Schema: 2, Target: "0.5.0"})}, |
| + {"target not a bare semver", mustMarshal(t, VersionTarget{Schema: 1, Target: "not-semver"})}, |
| + } |
| + for _, c := range cases { |
| + t.Run(c.name, func(t *testing.T) { |
| + s := NewDirStore(t.TempDir()) |
| + if _, err := s.Put(ctx, TargetKey("acct1", "km"), c.raw, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if vt, etag, err := GetTarget(ctx, s, "acct1", "km"); err == nil { |
| + t.Fatalf("want error, got vt=%+v etag=%q", vt, etag) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func mustMarshal(t *testing.T, v any) []byte { |
| + t.Helper() |
| + b, err := json.Marshal(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |
| + |
| +// racingStore wraps a Store and, the first time its key of interest is Get, |
| +// sneaks in a competing write directly against the underlying store before |
| +// returning — simulating a second operator's write landing between the |
| +// caller's Get and its own Put. Everything else passes through unchanged. |
| +type racingStore struct { |
| + Store |
| + key string |
| + raced bool |
| + race func() |
| +} |
| + |
| +func (r *racingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + raw, etag, err := r.Store.Get(ctx, key) |
| + if key == r.key && !r.raced { |
| + r.raced = true |
| + r.race() |
| + } |
| + return raw, etag, err |
| +} |
| + |
| +// TestSetTargetCASConflict is the insurance for setTargetUnchecked's CAS |
| +// rewrite: a competing write landing between SetTarget's read and its own |
| +// Put must surface as ErrCASConflict, not a silent overwrite. |
| +func TestSetTargetCASConflict(t *testing.T) { |
| + base := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + now := time.Unix(1000, 0).UTC() |
| + key := TargetKey("acct1", "km") |
| + rs := &racingStore{Store: base, key: key} |
| + rs.race = func() { |
| + // A second writer creates the target first, directly against the |
| + // underlying store — bypassing rs so this doesn't re-trigger itself. |
| + if err := SetTarget(ctx, base, "acct1", "km", "0.4.0", "other", now); err != nil { |
| + t.Fatalf("competing write: %v", err) |
| + } |
| + } |
| + err := SetTarget(ctx, rs, "acct1", "km", "0.5.0", "actor", now) |
| + if !errors.Is(err, ErrCASConflict) { |
| + t.Fatalf("want ErrCASConflict, got %v", err) |
| + } |
| +} |