Skip to content
Open
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
87 changes: 81 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@ on:
push:
tags:
- "v*.*.*"
# -rc.N and friends. .github/STANDARDS.md names -rc.N as the org's only
# recognised pre-release suffix, and the release job below handles them
# explicitly (--prerelease). Without this pattern no pre-release tag
# starts the workflow at all.
- "v*.*.*-*"
# This workflow's only trigger was a version tag, so the first execution of
# any change to it was a real release -- an uncomfortable place to be for a
# path whose last 15 runs all failed. workflow_dispatch lets a maintainer
# run validate + cibuildwheel + cross-compile on demand and watch the wheels
# land as artifacts, without spending a tag. The publishing jobs stay gated
# on a tag push (see their `if:`), so a manual run can never publish.
workflow_dispatch:

permissions:
contents: write
Expand Down Expand Up @@ -35,23 +46,70 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-22.04-arm, windows-latest, macos-13, macos-14]
# macos-13 is retired: jobs requesting it are never assigned a runner
# and sit queued until GitHub kills them at 24h. Every release run this
# repository has ever had ended that way -- six tags, six 1d failures,
# with Publish to PyPI skipped each time because it needs this job.
# ci.yml moved off it already; this workflow did not.
#
# Dropped rather than swapped: macos-14 builds both architectures via
# macos-14. No per-architecture wheels are lost by that: ebuild is
# pure Python -- pyproject.toml declares setuptools.build_meta with no
# ext-modules, no cmdclass and no setup.py -- so the Build wheels step
# below selects `python -m build` on every leg, and all four produce
# the same ebuild-X.Y.Z-py3-none-any.whl that download-artifact then
# merges back into one. macos-13's wheels were that same universal
# file.
os: [ubuntu-latest, ubuntu-22.04-arm, windows-latest, macos-14]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install cibuildwheel
run: pip install cibuildwheel==2.21.3
# shell: bash -- the matrix includes windows-latest, where the default
# shell is PowerShell and the trailing-backslash continuations below are
# a syntax error, so the step failed outright there. That is the other
# half of why no release has ever published.
#
# The builder is chosen, not attempted-and-caught. This step used to be
# python -m cibuildwheel ... || (warn && python -m build --wheel)
# which is a fail-open build: it does not merely swallow the failure, it
# substitutes a *different artefact* and reports success. Today that
# fires on every leg by design, because ebuild is pure Python -- so the
# step's name has never meant what it says. The day a C extension lands,
# a genuine platform-specific compile failure on, say, Windows would
# have quietly published a pure-Python wheel in its place, and
# `skip-existing: true` on the publish step means it would not even
# collide with anything.
- name: Build wheels
shell: bash
env:
CIBW_BUILD: "cp310-* cp311-* cp312-*"
CIBW_SKIP: "*-musllinux_*"
run: |
python -m cibuildwheel --output-dir wheelhouse || \
(echo "::warning::cibuildwheel failed (likely no C-extension); building pure-Python wheel" && \
pip install build && \
python -m build --wheel --outdir wheelhouse)
set -euo pipefail
# Does this project actually build a C extension? cibuildwheel is
# the right tool only if it does.
if python - <<'EOF'
import sys, pathlib, tomllib
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text())
has_ext = bool(
data.get("tool", {}).get("setuptools", {}).get("ext-modules")
or data.get("tool", {}).get("setuptools", {}).get("cmdclass")
or pathlib.Path("setup.py").exists()
)
sys.exit(0 if has_ext else 1)
EOF
then
echo "C extension detected: building platform wheels with cibuildwheel"
python -m cibuildwheel --output-dir wheelhouse
else
echo "Pure Python: building one universal wheel with python -m build"
pip install build
python -m build --wheel --outdir wheelhouse
fi
- uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.os }}
Expand Down Expand Up @@ -116,6 +174,10 @@ jobs:
name: Publish to PyPI (OIDC)
runs-on: ubuntu-latest
needs: [validate, cibuildwheel]
# Publishing happens only for a real tag push. A workflow_dispatch run
# builds and uploads artifacts so the path can be exercised, and stops
# short of releasing anything.
if: github.event_name == 'push'
permissions:
id-token: write
contents: read
Expand All @@ -136,9 +198,21 @@ jobs:
merge-multiple: true
- name: Stage dist
run: |
set -euo pipefail
mkdir -p dist
find dist -mindepth 1 -delete
mv wheelhouse/*.whl dist/ 2>/dev/null || true
# `2>/dev/null || true` here meant that if no wheel was downloaded --
# every cibuildwheel leg red, the artifact upload skipped, the glob
# matching nothing -- dist/ ended up holding the sdist alone and
# PyPI was published with no wheels at all, silently. A release that
# ships less than it was asked to must fail, not publish.
shopt -s nullglob
wheels=(wheelhouse/*.whl)
if [ ${#wheels[@]} -eq 0 ]; then
echo "::error::no wheels were staged; refusing to publish a partial release"
exit 1
fi
mv "${wheels[@]}" dist/
ls -lh dist/
- name: Publish to PyPI (trusted publishing)
uses: pypa/gh-action-pypi-publish@release/v1
Expand All @@ -149,6 +223,7 @@ jobs:
name: Create Release
runs-on: ubuntu-latest
needs: [validate, cibuildwheel, pypi]
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
with:
Expand Down
149 changes: 149 additions & 0 deletions tests/unit/test_release_workflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 EoS Project
"""The release workflow's triggers and staging, asserted structurally.

This file exists because `yaml.safe_load(...)` succeeding was mistaken for
verification. A `workflow_dispatch:` key inserted between two tag patterns
parses perfectly well -- and turns the second pattern into its value:

on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
- "v*.*.*-*" # now workflow_dispatch's value, not a tag

{"push": {"tags": ["v*.*.*"]}, "workflow_dispatch": ["v*.*.*-*"]}

Pre-release tags stop triggering the workflow, and `workflow_dispatch` as a
sequence is not valid in GitHub's schema at all. Neither shows up as a parse
error. The lesson generalises: assert the shape you meant, not that the file
is loadable.
"""

import re
import shlex
from pathlib import Path

import pytest
import yaml

WORKFLOW = Path(__file__).resolve().parents[2] / ".github" / "workflows" / "release.yml"

#: Tag patterns release.yml must fire on. -rc.N is the org's recognised
#: pre-release suffix (.github/STANDARDS.md) and the release job handles it
#: explicitly with --prerelease, so a tag pattern that does not match it makes
#: that code unreachable.
EXPECTED_TAGS = ["v*.*.*", "v*.*.*-*"]

#: Jobs that publish. A workflow_dispatch run must never reach these.
PUBLISHING_JOBS = ("pypi", "release")


def _executable_lines(script):
"""Shell lines with comments stripped.

A comment explaining why a `|| true` was removed must not read as a
`|| true`. Splitting on an unquoted `#` is enough here and does not
mistake a `#` inside a string for the start of a comment.
"""
out = []
for raw in str(script).splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
try:
lexer = shlex.shlex(raw, posix=True, punctuation_chars=True)
lexer.whitespace_split = True
list(lexer) # raises on an unbalanced quote
except ValueError:
pass
code = re.split(r'(?<![\\"\'])#', raw, maxsplit=1)[0]
if code.strip():
out.append(code)
return out


@pytest.fixture(scope="module")
def workflow():
assert WORKFLOW.is_file(), f"{WORKFLOW} does not exist"
return yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))


@pytest.fixture(scope="module")
def triggers(workflow):
# PyYAML parses a bare `on:` key as the boolean True.
return workflow.get("on", workflow.get(True))


def test_push_tags_are_exactly_the_patterns_we_mean(triggers):
assert isinstance(triggers, dict), "`on:` must be a mapping"
assert "push" in triggers, "release.yml must trigger on a tag push"
tags = triggers["push"]["tags"]
assert tags == EXPECTED_TAGS, (
f"push.tags is {tags!r}, expected {EXPECTED_TAGS!r}. A pattern that "
f"goes missing here does not fail anything -- it just stops releasing."
)


def test_workflow_dispatch_is_a_null_key_not_a_value(triggers):
"""`workflow_dispatch:` takes null or a mapping, never a sequence."""
assert "workflow_dispatch" in triggers, (
"without workflow_dispatch the first execution of any change to this "
"workflow is a real release"
)
value = triggers["workflow_dispatch"]
assert value is None or isinstance(value, dict), (
f"workflow_dispatch is {value!r}. A sequence here is invalid in "
f"GitHub's schema, and it means the line above it was absorbed as its "
f"value rather than being a trigger of its own."
)


def test_a_manual_run_cannot_publish(workflow):
"""workflow_dispatch is for exercising the build, not for releasing."""
for job in PUBLISHING_JOBS:
condition = str(workflow["jobs"][job].get("if", ""))
assert "github.event_name == 'push'" in condition, (
f"job {job!r} publishes but is not gated on a tag push, so a "
f"workflow_dispatch run would release"
)


def test_artifact_staging_cannot_publish_an_empty_dist(workflow):
"""A release that ships less than it was asked to must fail, not publish.

`mv wheelhouse/*.whl dist/ 2>/dev/null || true` swallowed the case where no
wheel had been produced at all, and the publish step ran anyway.
"""
steps = workflow["jobs"]["pypi"]["steps"]
stage = [s for s in steps if s.get("name") == "Stage dist"]
assert stage, "the pypi job has no 'Stage dist' step"
code = "\n".join(_executable_lines(stage[0]["run"]))

assert "|| true" not in code, (
"staging must not swallow its own failure: publishing a release with "
"no wheels is worse than failing the job"
)
assert "exit 1" in code, (
"staging must fail explicitly when no wheel was produced"
)


def test_no_build_or_publish_step_swallows_its_exit_status(workflow):
"""`|| true` on a step that produces a release artefact.

.ai/reviewer.md names this shape directly. Reporting steps may still use
it -- they claim no verdict -- so this checks the jobs that build or ship.
"""
offenders = []
for job_id in ("validate", "cibuildwheel", "cross-compile", "pypi"):
for step in workflow["jobs"][job_id].get("steps", []):
for line in _executable_lines(step.get("run", "")):
if re.search(r"\|\|\s*true\s*$", line):
offenders.append(
f"{job_id}/{step.get('name', '?')}: {line.strip()}"
)
assert not offenders, (
"these steps discard their exit status:\n " + "\n ".join(offenders)
)
Loading