Skip to content

release.py publish: tag_exists is a TOCTOU guard, so two branches publishing the same version race #431

Description

@pbean

Found by CodeRabbit on #430; verified against the code before filing.

What

scripts/release.py's cmd_publish guards on tag_exists(tag) and then calls
gh release create under check=True:

version = sync_version.read_canonical()
tag = f"v{version}"
if tag_exists(tag):
    print(f"{tag} already exists — nothing to publish")
    return 0
...
subprocess.run(["gh", "release", "create", tag, ...], check=True)

tag_exists is git rev-parse -q --verify refs/tags/<tag> against the local clone.
There is no fetch between checkout and the check, so the guard reflects the tag state at
checkout time, not the live remote. Two runs that both check out before either creates the
tag will both pass the guard, both call gh release create, and the loser exits non-zero →
CalledProcessError → red job.

The concurrency group does not serialize them:

concurrency:
  group: release-${{ github.ref }}

Two branches are two refs, so they are two groups.

Why it is newly reachable

Before #429/#430, release.yml fired only on push: branches: [main] — one ref, one
group, cancel-in-progress: false, so publish runs serialized. Adding release/* makes a
second ref able to publish, which is what opens the cross-branch window. The trigger change
is still correct; this is the sharp edge it exposes.

Severity

Low. The release that wins is correct — the failure mode is a spuriously red workflow run
on the losing branch, not a bad or duplicate release. It also needs the same canonical
version to land on two branches close together, which the 0.9.x flow avoids by design (the
forward-port to main does not bump versions, so main's publish no-ops on the existing
tag).

Fix shapes

Either would do; not ranking them without looking at how the 0.10 line will branch.

  1. Make the concurrency group version-scoped or ref-independent (e.g.
    group: release-${{ github.workflow }}) so all publish runs serialize repo-wide. The
    second run then re-checks out after the first has pushed the tag — but note that only
    helps if the checkout genuinely post-dates the tag push, so pair it with (2).
  2. Make cmd_publish tolerate the conflict: drop check=True and treat an
    "already exists" failure from gh release create as success, or re-verify against the
    remote (git ls-remote --tags) rather than the local clone. This is the one that
    actually closes the window, since it does not depend on scheduling.

Deliberately not fixed in #429/#430 — those were scoped to the trigger change alone, and
#429 is already merged and published.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions