carlos / platform Public

release: fix EnsureTag race condition and error handling

Three critical fixes:

1. Thread etag through setTargetUnchecked to close check-then-write race:
   - setTargetUnchecked now takes ifMatch parameter instead of doing its own Get
   - SetTarget and EnsureTag pass the etag from their own GetTarget reads
   - CAS window now spans full check-then-write, making advisory loss truly safe
   - Operator raises between check and write now properly protected

2. Propagate non-ENOENT errors in EnsureTag changelog loop:
   - Only skip os.ErrNotExist (genuine gaps in iteration history)
   - Any other error on releaseVersion aborts before writing (prevents Commit="" tags)
   - Any other error on other iterations also aborts (prevents partial changelogs)
   - Ensures immutable tags are always complete and correct

3. Add test coverage for blocked-by-higher-tag and mid-range-gap scenarios:
   - TestEnsureTagBlockedByHigherTag: verify tag cut succeeds but advance blocked
   - TestEnsureTagMidRangeGap: verify changelog skips missing iteration (ENOENT)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5896ef23e89d369a9bf1f9e159983d5acbebbdb3 parent 1ae7f3f
3 files changed, +85 −21
  • internal/release/tag.go +18 −3
  • internal/release/tag_test.go +48 −0
  • internal/release/target.go +19 −18
diff --git a/internal/release/tag.go b/internal/release/tag.go
index a647fb73c..1616e99f5 100644
--- a/internal/release/tag.go
+++ b/internal/release/tag.go
@@ -127,7 +127,16 @@ func EnsureTag(ctx context.Context, s Store, acct, app, releaseVersion, actor st
ver := IterationVersion(target, i)
m, err := GetManifest(ctx, s, acct, app, ver)
if err != nil {
- continue // an unshipped or unreadable iteration is a gap, not a failure
+ // Only skip genuine gaps (not-found); propagate other errors.
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ // For the promoted release itself, any error prevents tag creation.
+ if ver == releaseVersion {
+ return nil, false, err
+ }
+ // For other iterations, a transient error also aborts.
+ return nil, false, err
}
if ver == releaseVersion {
tag.Commit = m.Commit
@@ -153,7 +162,7 @@ func EnsureTag(ctx context.Context, s Store, acct, app, releaseVersion, actor st
// stays unset only if the next patch would collide with a HIGHER
// existing tag (a hotfix line under a fleet already past it).
next := target.NextPatch()
- cur, _, err := GetTarget(ctx, s, acct, app)
+ cur, etag, err := GetTarget(ctx, s, acct, app)
if err == nil && (cur == nil || func() bool { c, ok := ParseSemVer(cur.Target); return ok && c.Compare(target) <= 0 }()) {
if higher, lerr := ListTags(ctx, s, acct, app); lerr == nil {
blocked := false
@@ -164,7 +173,13 @@ func EnsureTag(ctx context.Context, s Store, acct, app, releaseVersion, actor st
}
}
if !blocked {
- _ = setTargetUnchecked(ctx, s, acct, app, next, actor, now) // advisory; the tag is already cut
+ // Advance with the etag from our check. The CAS window now spans
+ // the full check-then-write, so a lost race is truly advisory
+ // (operator's raise wins, concurrent advance is idempotent).
+ if cur == nil {
+ etag = "" // first write: create-only
+ }
+ _ = setTargetUnchecked(ctx, s, acct, app, next, actor, now, etag)
}
}
}
diff --git a/internal/release/tag_test.go b/internal/release/tag_test.go
index a20df48b5..af9aa02ef 100644
--- a/internal/release/tag_test.go
+++ b/internal/release/tag_test.go
@@ -152,3 +152,51 @@ func TestEnsureTagDoesNotLowerRaisedTarget(t *testing.T) {
t.Fatalf("target was moved: %+v; want 0.6.0 kept", vt)
}
}
+
+func TestEnsureTagBlockedByHigherTag(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ now := time.Unix(2000, 0).UTC()
+ shipIteration(t, s, "0.5.0-1", "first", "- first iteration")
+ // Operator or a fleet already past has a 0.6.0 tag; next patch would collide.
+ higherTag := &Tag{Schema: 1, Version: "0.6.0", Release: "0.6.0-1", TaggedAt: now, Actor: "admin"}
+ if err := writeTag(ctx, s, "acct1", "km", higherTag); err != nil {
+ t.Fatal(err)
+ }
+ // Cutting 0.5.0 from -1 must succeed, but advance is blocked by the collision.
+ tag, created, err := EnsureTag(ctx, s, "acct1", "km", "0.5.0-1", "paul@example.com", now)
+ if err != nil || !created || tag == nil {
+ t.Fatalf("EnsureTag should succeed: %+v, %v, %v", tag, created, err)
+ }
+ if tag.Version != "0.5.0" {
+ t.Fatalf("tag version: %s; want 0.5.0", tag.Version)
+ }
+ // Target must not have been set (advance blocked by 0.6.0).
+ vt, _, _ := GetTarget(ctx, s, "acct1", "km")
+ if vt != nil {
+ t.Fatalf("target was set: %+v; want nil (blocked by higher tag 0.6.0)", vt)
+ }
+}
+
+func TestEnsureTagMidRangeGap(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ now := time.Unix(2000, 0).UTC()
+ shipIteration(t, s, "0.5.0-1", "first", "- first")
+ // Skip 0.5.0-2 intentionally.
+ shipIteration(t, s, "0.5.0-3", "third", "- third")
+ tag, created, err := EnsureTag(ctx, s, "acct1", "km", "0.5.0-3", "paul@example.com", now)
+ if err != nil || !created || tag == nil {
+ t.Fatalf("EnsureTag: %+v, %v, %v", tag, created, err)
+ }
+ // Changelog should have sections for -1 and -3 only (gap at -2 skipped).
+ want := "## 0.5.0-1 — first\n\n- first\n\n## 0.5.0-3 — third\n\n- third\n"
+ if tag.Changelog != want {
+ t.Fatalf("changelog with gap:\n%q\nwant:\n%q", tag.Changelog, want)
+ }
+ // Target should auto-advance to 0.5.1 (no collision).
+ vt, _, _ := GetTarget(ctx, s, "acct1", "km")
+ if vt == nil || vt.Target != "0.5.1" {
+ t.Fatalf("target: %+v; want 0.5.1", vt)
+ }
+}
diff --git a/internal/release/target.go b/internal/release/target.go
index e474dde33..0140c10d4 100644
--- a/internal/release/target.go
+++ b/internal/release/target.go
@@ -69,18 +69,26 @@ func SetTarget(ctx context.Context, s Store, acct, app, target, actor string, no
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)
+ 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 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.
+// 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
@@ -91,21 +99,14 @@ func SetTarget(ctx context.Context, s Store, acct, app, target, actor string, no
// 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 {
+// 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)
- _, 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 {
+ if _, err := s.Put(ctx, key, raw, ifMatch); err != nil {
return fmt.Errorf("version target: write: %w", err)
}
return nil