Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ on:
branches: [main, release/*]

concurrency:
group: release-${{ github.ref }}
# Deliberately ref-independent: `main` and a `release/*` branch can carry the
# same version, and keying on `github.ref` would put them in separate groups and
# let both publish runs proceed at once. One group repo-wide serializes them, so
# the second run checks out *after* the first pushed the tag and its `tag_exists`
# probe short-circuits cleanly. Never cancel-in-progress — a cancelled publish
# leaves the tag unmade.
group: release-publish
cancel-in-progress: false

permissions:
Expand Down
38 changes: 36 additions & 2 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,30 @@ def current_branch() -> str:


def tag_exists(tag: str) -> bool:
"""Whether ``tag`` is in *this checkout's* refs.

Deliberately local: it is the cheap pre-check, not a distributed lock. Nothing
re-fetches between it and the ``gh release create`` it guards, so it cannot see
a tag another runner pushed after this job checked out. ``_already_exists``
covers that window on the failure side.
"""
return (
_run(["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"], check=False).returncode
== 0
)


def _already_exists(stderr: str) -> bool:
"""Whether a failed ``gh release create`` lost a race rather than genuinely erroring.

GitHub answers a duplicate tag with an HTTP 422 whose body names the offending
field, e.g. ``Release.tag_name already exists``. Matching the phrase rather than
the status keeps a 422 raised for some *other* validation failure (malformed
target, bad notes) on the loud path where it belongs.
"""
return "already exists" in (stderr or "").lower()


def last_release_tag() -> str | None:
"""Highest ``vX.Y.Z`` tag by version order, or ``None`` for a first release."""
out = _git_out("tag", "--list", "v*")
Expand Down Expand Up @@ -409,13 +427,29 @@ def cmd_publish(args: argparse.Namespace) -> int:
if not shutil.which("gh"):
_die("`gh` CLI not found — required to create the GitHub release")
print(f"creating release {tag} at {sha[:12]} ...")
subprocess.run(
proc = subprocess.run(
["gh", "release", "create", tag, "--target", sha, "--title", tag, "--notes-file", "-"],
cwd=REPO,
check=True,
check=False,
text=True,
input=notes,
capture_output=True,
)
if proc.returncode != 0:
# The `tag_exists` probe above reads the *checkout's* refs, and nothing
# fetches between it and this call — so it answers for tag state at
# checkout time, not for the remote now. Since release.yml fires on
# `main` and on `release/*`, and its concurrency group keys on
# `github.ref` (two branches ⇒ two groups), two runs carrying the same
# version can both pass the probe and both land here. Losing that race
# is not a failure: the winner created this exact tag from this exact
# CHANGELOG section, so the desired end state already holds. Anything
# else is a real error and still dies loudly.
if _already_exists(proc.stderr):
print(f"{tag} was created concurrently — nothing to publish")
return 0
sys.stderr.write(proc.stderr)
_die(f"`gh release create {tag}` failed with rc {proc.returncode}")
print(f"published {tag}")
return 0

Expand Down
58 changes: 58 additions & 0 deletions tests/test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,61 @@ def test_publish_dry_run_prints_notes(monkeypatch, capsys, tmp_path):
assert rc == 0
assert "would create release v0.5.0" in out
assert "**A thing.**" in out


# --- publish under a concurrent publisher ---------------------------------- #
# `tag_exists` reads the checkout's refs, so a run whose checkout predates another
# runner's tag push reaches `gh release create` and loses. That is the only failure
# the command may swallow; every other one still has to be loud.
def _publish_with_gh(monkeypatch, tmp_path, *, returncode, stderr):
cl = tmp_path / "CHANGELOG.md"
cl.write_text(SAMPLE)
monkeypatch.setattr(release, "CHANGELOG", cl)
monkeypatch.setattr(release.sync_version, "read_canonical", lambda: "0.5.0")
monkeypatch.setattr(release, "tag_exists", lambda tag: False)
monkeypatch.setattr(release, "_git_out", lambda *a: "deadbeef" * 5)
monkeypatch.setattr(release.shutil, "which", lambda name: f"/usr/bin/{name}")
monkeypatch.setattr(
release.subprocess,
"run",
lambda *a, **kw: SimpleNamespace(returncode=returncode, stdout="", stderr=stderr),
)
return release.cmd_publish(SimpleNamespace(dry_run=False))


def test_publish_treats_a_lost_race_as_success(monkeypatch, capsys, tmp_path):
rc = _publish_with_gh(
monkeypatch,
tmp_path,
returncode=1,
stderr="HTTP 422: Validation Failed\nRelease.tag_name already exists",
)
assert rc == 0
assert "created concurrently" in capsys.readouterr().out


def test_publish_still_dies_on_a_genuine_gh_failure(monkeypatch, capsys, tmp_path):
with pytest.raises(SystemExit) as exc:
_publish_with_gh(
monkeypatch,
tmp_path,
returncode=1,
stderr="HTTP 401: Bad credentials",
)
assert "gh release create v0.5.0" in str(exc.value)
assert "Bad credentials" in capsys.readouterr().err


@pytest.mark.parametrize(
"stderr,lost",
[
("Release.tag_name already exists", True),
("release already exists", True),
("ALREADY EXISTS", True),
("HTTP 401: Bad credentials", False),
("HTTP 422: Validation Failed\ntarget_commitish is invalid", False),
("", False),
],
)
def test_already_exists_matches_only_the_duplicate_tag_error(stderr, lost):
assert release._already_exists(stderr) is lost