From 93f6c0a21ae98c589ac26a925325731f0291c33c Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 19 Aug 2026 18:23:51 -0400 Subject: [PATCH 1/3] feat: tombstone releases so sub-3.12 pip install fails loudly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raising `requires-python` does not retract anything. 2026.7.29.2 was the first release published with `Requires-Python >=3.12`; everything at or below 2026.7.29.1 was published with `>=3.9`, and PyPI metadata is immutable, so those releases stay valid pip candidates forever. On 3.9/3.10/3.11 `pip install autolens` therefore does not fail — it backtracks to 2026.7.29.1 and installs the whole stack silently, no JAX and no warning. The install docs claim a "no matching distribution" error that does not happen. Adds `autohands/tombstone.py`: builds one sdist-only release per package at 2026.7.29.1.post1 with `Requires-Python <3.12`, whose build raises with an explanation naming the user's Python version. It outranks every sub-floor candidate and is invisible at or above the floor, so pip below 3.12 reports why instead of quietly installing stale code. Verified against the real PyPI candidate set with the real artifacts: py3.9/3.10/3.11 pip install autolens -> loud failure, correct version named py3.12 pip install autolens -> 2026.8.17.1, unaffected py3.10 autolens==2026.7.29.1 -> still resolves (pins keep working) The one hole — `--only-binary=:all:` skips sdists and still lands on the old wheel — is documented rather than hidden; no packaging mechanism closes it. One-off publish, deliberately not wired into release.yml: future releases all declare >=3.12, so the tombstone stays the top sub-floor candidate forever. Issue: https://github.com/PyAutoLabs/PyAutoHands/issues/238 Co-Authored-By: Claude Opus 5 --- autohands/tombstone.py | 371 ++++++++++++++++++++++++++++++++++++++++ tests/test_tombstone.py | 268 +++++++++++++++++++++++++++++ 2 files changed, 639 insertions(+) create mode 100644 autohands/tombstone.py create mode 100644 tests/test_tombstone.py diff --git a/autohands/tombstone.py b/autohands/tombstone.py new file mode 100644 index 0000000..588eaf2 --- /dev/null +++ b/autohands/tombstone.py @@ -0,0 +1,371 @@ +""" +Build (and optionally publish) the sub-floor *tombstone* releases that stop pip +silently installing a stale PyAuto stack on an unsupported Python. + +The problem +----------- + +Raising `requires-python` does not retract anything. `2026.7.29.2` was the first +release published with `Requires-Python >=3.12`; every release at or below +`2026.7.29.1` was published with `>=3.9`, and PyPI metadata is immutable, so +those releases remain valid pip candidates forever. On Python 3.9, 3.10 and 3.11 +`pip install autolens` therefore does not fail — it backtracks to `2026.7.29.1` +and installs the whole stack silently, with no JAX and no warning: + + py3.9 -> autolens 2026.7.29.1 (exit 0, no warning) + py3.10 -> autolens 2026.7.29.1 (exit 0, no warning) + py3.11 -> autolens 2026.7.29.1 (exit 0, no warning) + py3.12 -> autolens 2026.8.17.1 (current) + +A quietly stale install is worse than a hard failure: nothing tells the user +which version they are actually running, and every bug they report is against +code that moved on weeks ago. + +The mechanism +------------- + +Publish one extra release per package — `TOMBSTONE_VERSION`, sdist-only, with +`Requires-Python <3.12` — whose build raises `RuntimeError` with an explanation. +Because its version sorts *above* the last permissive release and its +`Requires-Python` excludes every supported Python, it becomes the highest +candidate pip can see below the floor, and is invisible at or above it: + + py3.10 -> tombstone selected -> build raises -> loud, explanatory failure + py3.12 -> tombstone excluded by Requires-Python -> latest release, unchanged + +This is a **one-off publish, not part of the release pipeline**. The tombstone +never needs republishing: future releases all declare `>=3.12`, so they are +invisible below the floor and the tombstone stays the top sub-floor candidate +indefinitely. Do not wire this module into `release.yml` — doing so would +republish a deliberately-broken artifact on every release. + +Two properties this deliberately preserves +------------------------------------------ + +- **Pinned historical installs still work.** `pip install autolens==2026.7.29.1` + on 3.10 still resolves, because an exact pin excludes the tombstone version. + Reproducing an old result is not collateral damage of this change. +- **Supported Pythons are untouched.** Resolution at 3.12+ is byte-for-byte + identical, since the tombstone fails the `Requires-Python` check before the + resolver ever considers it. + +The one hole, stated rather than hidden +--------------------------------------- + +`pip install --only-binary=:all: autolens` on 3.10 skips sdists entirely, so it +steps over the tombstone and installs the `2026.7.29.1` wheel silently, exactly +as before. There is no packaging mechanism that closes this: the fix would +require retracting the back catalogue, and yanking ~330 releases per package is +both semantically wrong and impossible in bulk (PyPI exposes no yank API). It is +documented in the install docs rather than pretended away. + +All five packages need a tombstone. The libraries pin each other exactly +(`autogalaxy==`), so tombstoning `autolens` alone still leaves +`pip install autogalaxy` backtracking on its own. +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +import tarfile +from pathlib import Path + +# The five packages that make up the installable stack. All of them are +# required: the exact `==` inter-pins mean a gap in this list is a hole a user +# can still fall through by installing that package directly. +TOMBSTONE_PACKAGES = ( + "autonerves", + "autoarray", + "autofit", + "autogalaxy", + "autolens", +) + +# One `.post` above the last release published with `Requires-Python >=3.9`, +# so it outranks every sub-floor candidate. It stays below `2026.8.4.1`, so +# PyPI's displayed "latest version" is unaffected. +LAST_PERMISSIVE_VERSION = "2026.7.29.1" +TOMBSTONE_VERSION = f"{LAST_PERMISSIVE_VERSION}.post1" + +# The floor the published stack actually declares. Kept as a tuple so the +# generated guard and the `Requires-Python` bound cannot drift apart. +PYTHON_FLOOR = (3, 12) + +SETUP_PY_TEMPLATE = '''\ +"""Tombstone release for {package} — see the RuntimeError below. + +This distribution contains no code. It exists so that `pip install {package}` +on a Python older than {floor_str} fails with an explanation, instead of +silently backtracking to a {last_version} release that predates the +Python {floor_str} floor. +""" + +import sys + +from setuptools import setup + +FLOOR = {floor!r} + +if sys.version_info < FLOOR: + raise RuntimeError( + "\\n\\n" + " {package} requires Python {floor_str} or later — you are running " + "Python %d.%d.\\n" + "\\n" + " Nothing is broken with your pip. This release exists only to stop pip\\n" + " quietly installing a version of {package} that predates the Python\\n" + " {floor_str} floor.\\n" + " Releases at or below {last_version} still declare Python >=3.9 and remain\\n" + " installable, but they are unsupported, months out of date, and ship\\n" + " without JAX.\\n" + "\\n" + " Upgrade to Python {floor_str} or later, then reinstall:\\n" + "\\n" + " python{floor_str} -m pip install {package}\\n" + "\\n" + " If you meant to install a historical release, pin it exactly and it\\n" + " will still resolve on this Python:\\n" + "\\n" + " pip install {package}=={last_version}\\n" + % (sys.version_info[0], sys.version_info[1]) + ) + +setup( + name="{package}", + version="{version}", + description=( + "Tombstone release: {package} requires Python {floor_str} or later." + ), + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + url="https://github.com/PyAutoLabs", + license="MIT", + python_requires="<{floor_str}", + py_modules=[], +) +''' + +README_TEMPLATE = """\ +# {package} {version} — tombstone release + +**This release contains no code and cannot be installed.** Installing it raises +an error telling you to upgrade Python. + +`{package}` requires **Python {floor_str} or later**. Releases at or below +`{last_version}` were published declaring `Requires-Python >=3.9`, and PyPI +metadata is immutable, so they stay valid pip candidates forever. Without this +tombstone, `pip install {package}` on Python 3.9, 3.10 or 3.11 does not fail — +it silently backtracks to `{last_version}` and installs a stack that is months +out of date and ships without JAX, with no warning at all. + +This release sorts above `{last_version}` and declares `Requires-Python +<{floor_str}`, so it is the first thing pip finds below the floor and is +invisible at or above it. The result is a clear error instead of a quietly +stale install. + +## What to do + +Upgrade to Python {floor_str} or later and reinstall: + +``` +python{floor_str} -m pip install {package} +``` + +To install a historical release deliberately, pin it exactly — that still +resolves on older Pythons: + +``` +pip install {package}=={last_version} +``` +""" + + +def floor_str(floor: tuple[int, int] = PYTHON_FLOOR) -> str: + """Render a version floor as `3.12`, for use in prose and metadata.""" + return ".".join(str(part) for part in floor) + + +def render_setup_py( + package: str, + version: str = TOMBSTONE_VERSION, + floor: tuple[int, int] = PYTHON_FLOOR, + last_version: str = LAST_PERMISSIVE_VERSION, +) -> str: + """Render the guarded `setup.py` for one package's tombstone. + + The guard is conditional on `sys.version_info` rather than unconditional so + that the sdist can still be *built* on a supported Python: `python -m build` + executes this file, and an unconditional raise would make the tombstone + impossible to produce. + """ + return SETUP_PY_TEMPLATE.format( + package=package, + version=version, + floor=floor, + floor_str=floor_str(floor), + last_version=last_version, + ) + + +def render_readme( + package: str, + version: str = TOMBSTONE_VERSION, + floor: tuple[int, int] = PYTHON_FLOOR, + last_version: str = LAST_PERMISSIVE_VERSION, +) -> str: + """Render the long description shown on the release's PyPI page.""" + return README_TEMPLATE.format( + package=package, + version=version, + floor_str=floor_str(floor), + last_version=last_version, + ) + + +def write_project( + package: str, + dest: Path, + version: str = TOMBSTONE_VERSION, + floor: tuple[int, int] = PYTHON_FLOOR, + last_version: str = LAST_PERMISSIVE_VERSION, +) -> Path: + """Write the tombstone source tree for `package` under `dest`. + + Deliberately setup.py-only: no `pyproject.toml`. The guard has to run during + metadata preparation, and a PEP 621 `[project]` table would let a backend + answer metadata questions without ever executing the guard. + """ + project_dir = dest / f"{package}-tombstone" + if project_dir.exists(): + shutil.rmtree(project_dir) + project_dir.mkdir(parents=True) + + (project_dir / "setup.py").write_text( + render_setup_py(package, version=version, floor=floor, last_version=last_version) + ) + (project_dir / "README.md").write_text( + render_readme(package, version=version, floor=floor, last_version=last_version) + ) + return project_dir + + +def build_sdist( + project_dir: Path, + out_dir: Path, + package: str, + version: str = TOMBSTONE_VERSION, +) -> Path: + """Build the sdist for a written tombstone project and return its path. + + Runs with `--no-isolation` so the build cannot silently depend on network + access; setuptools is the only requirement and is already present wherever + this tool runs. + + The built artifact is located by its exact expected filename rather than by + globbing the output directory: every package builds into the same `out_dir`, + so "the newest tarball here" would happily hand back a sibling package's + sdist and verify *its* metadata instead. + """ + out_dir.mkdir(parents=True, exist_ok=True) + subprocess.run( + [ + sys.executable, + "-m", + "build", + "--sdist", + "--no-isolation", + "--outdir", + str(out_dir), + str(project_dir), + ], + check=True, + ) + sdist = out_dir / f"{package}-{version}.tar.gz" + if not sdist.exists(): + raise RuntimeError(f"expected {sdist.name} in {out_dir}, but it was not built") + return sdist + + +def sdist_requires_python(sdist: Path) -> str | None: + """Read `Requires-Python` back out of a built sdist's PKG-INFO. + + Verifying the artifact rather than trusting the input is the whole point: + a tombstone published with the wrong `Requires-Python` would either be + invisible (never selected, silent backtrack continues) or visible on + supported Pythons (breaking every working install). + """ + with tarfile.open(sdist) as tar: + for member in tar.getmembers(): + if Path(member.name).name == "PKG-INFO": + handle = tar.extractfile(member) + if handle is None: + continue + for line in handle.read().decode().splitlines(): + if line.startswith("Requires-Python:"): + return line.split(":", 1)[1].strip() + return None + + +def build_all(out_dir: Path, packages=TOMBSTONE_PACKAGES, work_dir: Path | None = None): + """Build every tombstone sdist, verifying each artifact's metadata.""" + work_dir = work_dir or out_dir / "src" + work_dir.mkdir(parents=True, exist_ok=True) + + expected = f"<{floor_str()}" + built = [] + for package in packages: + project_dir = write_project(package, work_dir) + sdist = build_sdist(project_dir, out_dir, package) + found = sdist_requires_python(sdist) + if found != expected: + raise RuntimeError( + f"{sdist.name}: Requires-Python is {found!r}, expected {expected!r} — " + "refusing to hand over an artifact that would not behave as a tombstone" + ) + built.append(sdist) + return built + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) + parser.add_argument( + "--out", + type=Path, + default=Path("dist-tombstone"), + help="directory to write the sdists into (default: dist-tombstone)", + ) + parser.add_argument( + "--package", + action="append", + choices=TOMBSTONE_PACKAGES, + help="build only this package (repeatable); default is all five", + ) + args = parser.parse_args(argv) + + packages = tuple(args.package) if args.package else TOMBSTONE_PACKAGES + if set(packages) != set(TOMBSTONE_PACKAGES): + print( + "WARNING: building a subset. The libraries pin each other exactly, so a\n" + " package without a tombstone is still installable directly on an\n" + " unsupported Python.", + file=sys.stderr, + ) + + built = build_all(args.out, packages=packages) + print(f"\nBuilt {len(built)} tombstone sdist(s) in {args.out}:") + for sdist in built: + print(f" {sdist.name} Requires-Python {sdist_requires_python(sdist)}") + print( + "\nThese are deliberately broken artifacts. Upload is a separate, human\n" + "authorized act — rehearse on TestPyPI first:\n" + f" python3 -m twine upload --repository testpypi {args.out}/*.tar.gz\n" + f" python3 -m twine upload {args.out}/*.tar.gz\n" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_tombstone.py b/tests/test_tombstone.py new file mode 100644 index 0000000..882fd1b --- /dev/null +++ b/tests/test_tombstone.py @@ -0,0 +1,268 @@ +"""Unit tests for autohands/tombstone.py. + +The tombstone's whole job is to be *selected by pip below the floor and +invisible above it*, then to fail loudly when built. Three properties carry +that, and each is tested here rather than assumed: + +1. the version sorts above the last permissive release and below the next real + one — get this wrong and the tombstone is either never selected (silent + backtrack continues) or becomes PyPI's displayed latest version; +2. the built artifact declares `Requires-Python <3.12` — get this wrong and it + either never appears below the floor or breaks every supported install; +3. the generated guard raises below the floor and stays silent at or above it — + an unconditional raise cannot be built into an sdist at all. + +The end-to-end pip resolution test needs an interpreter below the floor and +network access, so it skips where either is missing (CI runs 3.12+ only). It is +the test that actually proves the mechanism, so it must not be the only one. +""" + +import builtins +import sys +import types +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).parent.parent +AUTOHANDS_DIR = PROJECT_ROOT / "autohands" +sys.path.insert(0, str(AUTOHANDS_DIR)) + +import tombstone # noqa: E402 + + +def _exec_setup_py(source, version_info, cwd): + """Execute a rendered setup.py as if on `version_info`, returning setup() kwargs. + + `sys.version_info` cannot be assigned, so the guard is exercised by giving + the executed source its own `sys` and `setuptools` through a scoped + `__import__`. Raising propagates to the caller, which is the point. + """ + fake_sys = types.ModuleType("sys") + fake_sys.version_info = version_info + + captured = {} + fake_setuptools = types.ModuleType("setuptools") + fake_setuptools.setup = lambda **kwargs: captured.update(kwargs) + + real_import = builtins.__import__ + + def scoped_import(name, *args, **kwargs): + if name == "sys": + return fake_sys + if name == "setuptools": + return fake_setuptools + return real_import(name, *args, **kwargs) + + namespace = { + "__builtins__": {**vars(builtins), "__import__": scoped_import}, + "__file__": str(cwd / "setup.py"), + } + cwd_before = Path.cwd() + import os + + os.chdir(cwd) + try: + exec(compile(source, "setup.py", "exec"), namespace) + finally: + os.chdir(cwd_before) + return captured + + +# --- the version property the mechanism rests on --------------------------- + + +def test_tombstone_version_outranks_the_last_permissive_release(): + from packaging.version import Version + + assert Version(tombstone.TOMBSTONE_VERSION) > Version( + tombstone.LAST_PERMISSIVE_VERSION + ) + + +def test_tombstone_version_stays_below_the_next_real_release(): + """It must not become PyPI's displayed latest version.""" + from packaging.version import Version + + assert Version(tombstone.TOMBSTONE_VERSION) < Version("2026.8.4.1") + + +def test_every_package_in_the_installable_stack_has_a_tombstone(): + """The libraries pin each other exactly, so a gap here is a hole a user + falls through by installing that package directly.""" + assert set(tombstone.TOMBSTONE_PACKAGES) == { + "autonerves", + "autoarray", + "autofit", + "autogalaxy", + "autolens", + } + + +# --- the generated guard --------------------------------------------------- + + +@pytest.mark.parametrize("minor", [9, 10, 11]) +def test_guard_raises_below_the_floor(tmp_path, minor): + project = tombstone.write_project("autolens", tmp_path) + source = (project / "setup.py").read_text() + + with pytest.raises(RuntimeError) as excinfo: + _exec_setup_py(source, (3, minor, 0), project) + + message = str(excinfo.value) + assert "autolens requires Python 3.12 or later" in message + assert f"you are running Python 3.{minor}" in message + # The escape hatch must be in the message, not only in the docs: a user + # reproducing an old result needs to know the exact pin still works. + assert f"pip install autolens=={tombstone.LAST_PERMISSIVE_VERSION}" in message + + +@pytest.mark.parametrize("minor", [12, 13, 14]) +def test_guard_is_silent_at_or_above_the_floor(tmp_path, minor): + """The guard is conditional so the sdist can still be built on a supported + Python; an unconditional raise would make the tombstone unbuildable.""" + project = tombstone.write_project("autolens", tmp_path) + source = (project / "setup.py").read_text() + + captured = _exec_setup_py(source, (3, minor, 0), project) + + assert captured["name"] == "autolens" + assert captured["version"] == tombstone.TOMBSTONE_VERSION + assert captured["python_requires"] == "<3.12" + + +def test_guard_names_the_package_it_was_rendered_for(): + for package in tombstone.TOMBSTONE_PACKAGES: + source = tombstone.render_setup_py(package) + assert f'name="{package}"' in source + assert f"{package} requires Python 3.12 or later" in source + + +def test_project_has_no_pyproject_toml(tmp_path): + """A PEP 621 [project] table would let a backend answer metadata questions + without ever executing the guard.""" + project = tombstone.write_project("autolens", tmp_path) + assert not (project / "pyproject.toml").exists() + assert (project / "setup.py").exists() + + +# --- the built artifact ---------------------------------------------------- + + +def _build_available(): + try: + import build # noqa: F401 + except ImportError: + return False + return True + + +requires_build = pytest.mark.skipif( + not _build_available(), reason="the `build` package is not installed" +) + + +@requires_build +def test_built_sdist_declares_sub_floor_requires_python(tmp_path): + project = tombstone.write_project("autolens", tmp_path) + sdist = tombstone.build_sdist(project, tmp_path / "dist", "autolens") + + assert tombstone.sdist_requires_python(sdist) == "<3.12" + + +@requires_build +def test_build_sdist_returns_the_package_it_was_asked_for(tmp_path): + """Every package builds into one shared output directory, so locating the + artifact by "newest tarball here" would hand back a sibling's sdist and + verify its metadata instead.""" + out = tmp_path / "dist" + first = tombstone.build_sdist( + tombstone.write_project("autofit", tmp_path), out, "autofit" + ) + second = tombstone.build_sdist( + tombstone.write_project("autoarray", tmp_path), out, "autoarray" + ) + + assert first.name.startswith("autofit-") + assert second.name.startswith("autoarray-") + + +@requires_build +def test_build_all_rejects_an_artifact_with_the_wrong_floor(tmp_path, monkeypatch): + """`build_all` verifies what it produced rather than trusting its input.""" + monkeypatch.setattr(tombstone, "sdist_requires_python", lambda _: ">=3.9") + + with pytest.raises(RuntimeError, match="refusing to hand over"): + tombstone.build_all(tmp_path / "dist", packages=("autolens",)) + + +# --- end to end ------------------------------------------------------------ + + +def _interpreter_below_floor(): + import shutil + + for candidate in ("python3.11", "python3.10", "python3.9"): + path = shutil.which(candidate) + if path: + return path + return None + + +@requires_build +@pytest.mark.skipif( + _interpreter_below_floor() is None, + reason="no sub-3.12 interpreter available to resolve against", +) +def test_pip_below_the_floor_fails_loudly(tmp_path): + """The property that matters: pip picks the tombstone and reports why. + + Served from a local PEP 503 index carrying `data-requires-python`, which is + how pip learns a candidate's floor without downloading it — the same signal + PyPI's simple index gives. + """ + import html + import subprocess + + project = tombstone.write_project("autolens", tmp_path) + sdist = tombstone.build_sdist(project, tmp_path / "dist", "autolens") + + index = tmp_path / "index" / "autolens" + index.mkdir(parents=True) + (index / "index.html").write_text( + "" + f'{sdist.name}' + "" + ) + + # The venv must be built *by* the older interpreter: resolution has to run + # on a pip whose Requires-Python check is the real one, not a simulated one. + env_dir = tmp_path / "venv" + subprocess.run([_interpreter_below_floor(), "-m", "venv", str(env_dir)], check=True) + + result = subprocess.run( + [ + str(env_dir / "bin" / "python"), + "-m", + "pip", + "install", + "--no-cache-dir", + "--dry-run", + # Keeps the test offline. Build isolation would fetch setuptools + # from an index, and this one holds nothing but the tombstone; + # venvs below 3.12 still bundle setuptools, so the guard runs + # against the interpreter's own copy. + "--no-build-isolation", + "--index-url", + f"file://{tmp_path / 'index'}", + "autolens", + ], + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + combined = result.stdout + result.stderr + assert "requires Python 3.12 or later" in combined From f171f5a4db09fbca593721c3d00d9f484bce4024 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 19 Aug 2026 18:26:26 -0400 Subject: [PATCH 2/3] feat: manual-dispatch workflow to publish the tombstone sdists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tombstones cannot be published from a laptop — the PyPI tokens live in Actions secrets, and they should stay there. This adds a workflow_dispatch-only job that builds via autohands/tombstone.py and uploads with the same twine version, retry policy and --skip-existing behaviour as release.yml. Deliberately not wired into release.yml: the tombstones are one-off, and republishing a deliberately-broken artifact on every release would be noise. Publishing to the real index requires typing `publish tombstones` as the confirm input. A released filename is permanent — TestPyPI is the rehearsal. Issue: https://github.com/PyAutoLabs/PyAutoHands/issues/238 Co-Authored-By: Claude Opus 5 --- .github/workflows/publish_tombstone.yml | 95 +++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/workflows/publish_tombstone.yml diff --git a/.github/workflows/publish_tombstone.yml b/.github/workflows/publish_tombstone.yml new file mode 100644 index 0000000..d078e25 --- /dev/null +++ b/.github/workflows/publish_tombstone.yml @@ -0,0 +1,95 @@ +# Publish the sub-floor tombstone releases (see autohands/tombstone.py). +# +# Manual dispatch only, and deliberately NOT part of release.yml: the tombstones +# are a one-off. Future releases all declare `Requires-Python >=3.12`, so they +# are invisible below the floor and the tombstone stays the top sub-floor +# candidate indefinitely. Re-running this on every release would republish a +# deliberately-broken artifact for no reason. +# +# It uploads distributions that CANNOT be installed — that is their entire +# purpose. Rehearse on TestPyPI first; the `confirm` input is the speed bump +# that stops a stray click from doing it on the real index. +name: publish_tombstone + +on: + workflow_dispatch: + inputs: + repository: + description: "Index to upload to" + required: true + default: testpypi + type: choice + options: + - testpypi + - pypi + confirm: + description: >- + Type `publish tombstones` to confirm. Required for pypi. + required: false + default: "" + type: string + +jobs: + publish_tombstone: + runs-on: ubuntu-latest + env: + TWINE_USERNAME: __token__ + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Guard the real index + if: ${{ inputs.repository == 'pypi' }} + run: | + if [ "${{ inputs.confirm }}" != "publish tombstones" ]; then + echo "Refusing to publish to PyPI without the exact confirmation phrase." + echo "These artifacts are deliberately uninstallable and cannot be deleted" + echo "once uploaded — a released filename is permanent. Re-run with" + echo "confirm: publish tombstones once you have rehearsed on TestPyPI." + exit 1 + fi + + - name: Build the tombstone sdists + run: | + python3 -m pip install --upgrade build twine==6.0.1 + python3 -m autohands.tombstone --out dist-tombstone + + - name: Check metadata + # twine check catches a malformed long_description before upload; the + # Requires-Python bound is verified by tombstone.build_all itself, which + # reads it back out of each built artifact rather than trusting input. + run: python3 -m twine check dist-tombstone/*.tar.gz + + - name: Upload + env: + TWINE_REPOSITORY: ${{ inputs.repository }} + TWINE_PASSWORD: >- + ${{ inputs.repository == 'pypi' && secrets.PYPI || secrets.TEST_PYPI }} + # --skip-existing: a re-run after a partial upload steps over the + # filenames already published instead of hard-failing on the duplicate. + run: | + set -euo pipefail + for attempt in 1 2 3; do + if python3 -m twine upload --skip-existing --verbose dist-tombstone/*.tar.gz; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "twine upload failed after 3 attempts." + exit 1 + fi + DELAY=$(( attempt * 30 )) + echo "twine upload failed (attempt $attempt) — retrying in ${DELAY}s" + sleep "$DELAY" + done + + - name: Report + run: | + echo "Uploaded to ${{ inputs.repository }}:" + ls -1 dist-tombstone/*.tar.gz + echo + echo "Verify on an interpreter below the floor, e.g.:" + echo " python3.10 -m venv /tmp/v && /tmp/v/bin/python -m pip install autolens" + echo "It must fail with the tombstone message, not install 2026.7.29.1." From 193814e7e26ff9c7b79cb48a2a449b35ce95feaa Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 19 Aug 2026 18:29:52 -0400 Subject: [PATCH 3/3] fix: drop the hardcoded org URL from the tombstone metadata The tenant firewall flagged `url="https://github.com/PyAutoLabs"` as an instance fact hardcoded in organ code. Removing the field is the right fix rather than allowlisting the file: a tombstone's PyPI page has no use for a homepage link, and its README already carries the whole explanation. Co-Authored-By: Claude Opus 5 --- autohands/tombstone.py | 1 - 1 file changed, 1 deletion(-) diff --git a/autohands/tombstone.py b/autohands/tombstone.py index 588eaf2..f0c6a82 100644 --- a/autohands/tombstone.py +++ b/autohands/tombstone.py @@ -141,7 +141,6 @@ ), long_description=open("README.md").read(), long_description_content_type="text/markdown", - url="https://github.com/PyAutoLabs", license="MIT", python_requires="<{floor_str}", py_modules=[],