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
39 changes: 26 additions & 13 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@ name: Release
# builds anything, and nothing is installed in it beyond what that action
# brings.
#
# Every action `publish` uses is pinned to a full commit SHA, with its
# version in a trailing comment, because every step in that job can reach
# the OIDC request credentials and a moved tag must not be able to mint a
# PyPI token. `verify` and ci.yml keep major-version tags: neither holds any
# permission beyond contents: read, so a moved tag there cannot reach one.
# Every action this workflow uses, in both jobs, is pinned to a full commit
# SHA, with its version in a trailing comment. In `publish`, every step can
# reach the OIDC request credentials, and a moved tag must not be able to
# mint a PyPI token. In `verify`, the actions check out the source the build
# reads, install the toolchain that builds it and store the files `publish`
# uploads, so a moved tag there could change what is published without ever
# reaching a token. ci.yml keeps major-version tags: it holds no permission
# beyond contents: read, and nothing it builds is published.
#
# A failure in `verify` publishes nothing: `publish` does not start and no
# token is asked for. A failure in `publish` before the upload publishes
Expand All @@ -45,6 +48,14 @@ name: Release
# that job is re-run alone) and reports it, and the run stays red although
# the version was published. No failure moves or deletes the tag.
#
# A version left on PyPI with only some of its files is never completed:
# this workflow stops at the "already on PyPI" check on every re-run, and
# the missing files are never uploaded by hand (see the last paragraph of
# this header). The remedy is a new patch version, through a new release
# prep and the tag that names it. Whether to yank the incomplete version is
# the maintainer's choice, made in the PyPI console; this workflow never
# yanks or deletes anything on PyPI.
#
# The manual act this workflow needs is a re-run from the run's page in the
# Actions tab (every job, or the failed `publish` job alone, which reuses the
# artifact and the SHA-256 `verify` recorded while the artifact is kept),
Expand Down Expand Up @@ -113,7 +124,7 @@ jobs:
steps:
# The tagged commit, by SHA. Full history: the ancestry check below
# walks it.
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
ref: ${{ github.sha }}
fetch-depth: 0
Expand All @@ -130,7 +141,9 @@ jobs:

# The three checks are scripts/release_guard.py at the tagged commit,
# run with the runner's own python3 (the script uses the standard
# library only), so they run before any toolchain is installed.
# library only), so they run before any toolchain is installed. Its
# tests (tests/test_release_guard.py) run in the gate, and the gate's
# coverage measurement covers scripts/ as well as the package.
- name: Check the tagged commit is on the default branch
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
Expand All @@ -155,7 +168,7 @@ jobs:
# Python release and uv, at the versions pinned there. No tool cache is
# restored in either job of this workflow: a release installs its tools
# fresh rather than from a cache another run wrote.
- uses: jdx/mise-action@v4
- uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0
with:
version: 2026.8.2
cache: false
Expand Down Expand Up @@ -223,7 +236,7 @@ jobs:
# The two distributions and SHA256SUMS, under release/, as one
# artifact `publish` downloads.
- name: Upload the distributions
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: distributions
path: release/
Expand Down Expand Up @@ -305,8 +318,8 @@ jobs:
with:
packages-dir: release/dist/

# The project name and the version both come from pyproject.toml at
# the tagged commit, through the guard script, which also checks the
# tag names that version again.
- name: Print the published version's address
run: |
set -euo pipefail
version="$(python3 scripts/release_guard.py version --tag "$GITHUB_REF_NAME")"
echo "https://pypi.org/project/uxid/${version}/"
run: python3 scripts/release_guard.py address --tag "$GITHUB_REF_NAME"
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,8 @@ The full gate runs `ruff format --check`, `ruff check`, `mypy --strict` over
(`scripts/check_corpus.py`, which recomputes the corpus hash and each case
file's case count by the reference's rule and fails on a difference from
`manifest.json` or `conformance/SOURCE.json`), the tests under pytest with
coverage measured on lines and branches (failing under 90 percent), the
corpus runner among them, and `uv build`.
coverage of the package and of `scripts/` measured on lines and branches
(failing under 90 percent), the corpus runner among them, and `uv build`.

CI (`.github/workflows/ci.yml`) runs the full gate, read from
`.claude/wurk.json`, on the Python `mise.toml` pins, and the tests on each
Expand Down
4 changes: 2 additions & 2 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ description = "Check the vendored corpus's hash and case counts against its mani
run = "uv run --frozen python scripts/check_corpus.py"

[tasks."gate:test"]
description = "Test with line and branch coverage, failing under 90 percent"
run = "uv run --frozen pytest --cov=uxid --cov-branch --cov-report=term-missing --cov-fail-under=90"
description = "Test with line and branch coverage of the package and scripts/, failing under 90 percent"
run = "uv run --frozen pytest --cov=uxid --cov=scripts --cov-branch --cov-report=term-missing --cov-fail-under=90"

[tasks."gate:build"]
description = "Build the sdist and the wheel"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ testpaths = ["tests"]
addopts = "-ra --strict-markers"

[tool.coverage.run]
source = ["uxid"]
source = ["uxid", "scripts"]
branch = true

[tool.coverage.report]
Expand Down
24 changes: 24 additions & 0 deletions scripts/release_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,16 @@
version PyPI has seen can never be uploaded again; any other answer, or no
answer, stops the run too.

After the upload the workflow calls it once more:

- ``address``: the same check as ``version``; on success the address of that
version of the project ``pyproject.toml`` names on PyPI is printed, alone,
on standard output.

python3 scripts/release_guard.py branch --default-branch BRANCH
python3 scripts/release_guard.py version --tag TAG [--pyproject PATH]
python3 scripts/release_guard.py pypi --tag TAG [--pyproject PATH]
python3 scripts/release_guard.py address --tag TAG [--pyproject PATH]

Each subcommand exits 0 when its check holds and 1, with one ``::error::``
line (a GitHub Actions error annotation) on standard error, when it does not.
Expand All @@ -39,6 +46,9 @@
# PyPI's JSON API: one version of one project.
PYPI_VERSION_URL = "https://pypi.org/pypi/{name}/{version}/json"

# PyPI's page for one version of one project.
PYPI_PROJECT_URL = "https://pypi.org/project/{name}/{version}/"

USER_AGENT = "uxid-py release workflow (https://github.com/riddler/uxid-py)"


Expand Down Expand Up @@ -95,6 +105,17 @@ def check_version(tag: str, pyproject: Path) -> str:
return stated


def address(tag: str, pyproject: Path) -> str:
"""The PyPI address of the version ``tag`` names, when ``pyproject`` states it.

The project name is ``pyproject``'s. Raises ``GuardError`` when the tag
does not name exactly the version ``pyproject`` states.
"""
version = check_version(tag, pyproject)
name, _ = project(pyproject)
return PYPI_PROJECT_URL.format(name=name, version=version)


def http_status(url: str) -> int:
"""The HTTP status a GET of ``url`` answers with.

Expand Down Expand Up @@ -183,6 +204,7 @@ def parser() -> argparse.ArgumentParser:
for name, text in (
("version", "the tag names pyproject.toml's version"),
("pypi", "PyPI does not already show the version"),
("address", "print the version's address on PyPI"),
):
sub = checks.add_parser(name, help=text)
sub.add_argument("--tag", required=True)
Expand All @@ -202,6 +224,8 @@ def main(argv: list[str], fetch: Callable[[str], int] = http_status) -> int:
print(check_branch(args.default_branch), file=sys.stderr)
elif args.check == "version":
print(check_version(args.tag, args.pyproject))
elif args.check == "address":
print(address(args.tag, args.pyproject))
else:
print(check_pypi(args.tag, args.pyproject, fetch), file=sys.stderr)
except GuardError as error:
Expand Down
45 changes: 45 additions & 0 deletions tests/test_release_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,51 @@ def test_the_default_pyproject_is_the_repositorys(
assert capsys.readouterr().out == f"{version}\n"


# --- address


# sabotage: writing a fixed project name into the address fails it
def test_the_address_names_the_projects_name_and_the_stated_version(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
other = pyproject(tmp_path, '[project]\nname = "other-name"\nversion = "2.0.0"\n')
assert (
guard.address("v2.0.0", other) == "https://pypi.org/project/other-name/2.0.0/"
)
argv = ["release_guard.py", "address", "--tag", "v2.0.0", "--pyproject", str(other)]
assert main(argv) == 0
assert capsys.readouterr().out == "https://pypi.org/project/other-name/2.0.0/\n"


# sabotage: building the address from the tag without the version check fails it
def test_the_address_refuses_a_tag_naming_another_version(
stated: Path, capsys: pytest.CaptureFixture[str]
) -> None:
with pytest.raises(GuardError, match=re.escape("at the tagged commit says 1.2.3")):
guard.address("v1.2.4", stated)
argv = [
"release_guard.py",
"address",
"--tag",
"v1.2.4",
"--pyproject",
str(stated),
]
assert main(argv) == 1
captured = capsys.readouterr()
assert captured.out == ""
assert captured.err.startswith("::error::v1.2.4 names 1.2.4 but")


# sabotage: pointing the default pyproject.toml elsewhere fails it
def test_the_default_address_is_the_repositorys_project(
capsys: pytest.CaptureFixture[str],
) -> None:
name, version = guard.project(ROOT_PYPROJECT)
assert main(["release_guard.py", "address", "--tag", f"v{version}"]) == 0
assert capsys.readouterr().out == f"https://pypi.org/project/{name}/{version}/\n"


# --- pypi


Expand Down
Loading