Skip to content

release: the publish lost-race swallow does not verify the remote tag's target #704

Description

@pbean

cmd_publish in scripts/release.py returns success on two paths without ever re-reading the
remote, so neither confirms that the tag it is standing down for is our release.

The two paths

  1. The local pre-check (scripts/release.py:518-520, pre-existing):

    if tag_exists(tag):
        print(f"{tag} already exists — nothing to publish")
        return 0

    tag_exists is documented as deliberately local — it reads this checkout's refs.

  2. The lost-race swallow (scripts/release.py:554-556, landed in 9a1dfb8 / release.py publish: tag_exists is a TOCTOU guard, so two branches publishing the same version race #431):

    if _already_exists(proc.stderr):
        print(f"{tag} was created concurrently — nothing to publish")
        return 0

    _already_exists is "already exists" in stderr.lower() — it matches the phrase GitHub
    returns in its 422 body and nothing else.

Neither path checks that the remote tag v<version> points at sha (the commit this run
intended to release, resolved at :526), nor that the published notes came from this
checkout's ## [<version>] CHANGELOG section.

Consequence

A release hand-cut under the same tag from a different commit — or a release.py publish
run from a stale checkout — is indistinguishable from the benign same-flow race, and
cmd_publish exits 0. The version is then tagged, the workflow reports success, and nothing
records that the tag does not name the commit we released.

Why it is bounded (and why it was not fixed in #703)

For the flow this script drives, the risk is low: every publisher running release.py publish
derives both the tag name and the notes from the checkout's canonical version
(sync_version.read_canonical()extract_section(CHANGELOG, version)), so a competing
same-flow publisher produces the release we would have produced. The gap is publishers
outside that flow.

Surfaced by CodeRabbit on PR #703 (Major, scripts/release.py:550-552). Triaged as out of
scope there: #703 is #468's three residuals and rewrites that comment only. Adding remote
verification is a behavior change to the release path with a new failure mode and new tests,
and it would have to cover path 1 as well as path 2 to be coherent — path 1 is untouched
pre-existing code. #703 instead narrowed the comment so it no longer claims the desired end
state is proven, and names this gap.

Fix sketch

After a swallowed race (and, for coherence, in place of the bare local pre-check), query the
remote and compare before returning 0 — e.g. gh release view <tag> --json tagName,targetCommitish
or git ls-remote --tags origin <tag> — and _die loudly on a target mismatch. Decide
deliberately whether a notes mismatch is also fatal or merely warned.

Testing hook already exists: tests/test_release.py::_publish_with_gh drives cmd_publish with
a subprocess.run spy (it records kwargs via the seen dict added in #703), so a new remote
probe can be stubbed the same way.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Robustness, enhancement, tests, or docs worth schedulingbugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions