diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4bf9dbc5..a10ee917 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -47,6 +47,20 @@ jobs: > .tmp/release-evidence/checks.json - name: Verify lockfile run: uv lock --check + - name: Verify immutable package-page banner + run: | + python - <<'PYBANNER' + import pathlib, re, tomllib, urllib.request + root = pathlib.Path('.') + project = tomllib.loads((root / 'pyproject.toml').read_text())['project'] + url = re.findall(r'!\[[^\]]*\]\(([^)]+)\)', (root / 'README.md').read_text())[0] + expected = f"https://raw.githubusercontent.com/e-south/reader/v{project['version']}/assets/reader-banner.png" + assert url == expected, 'Banner must be bound to the released version' + with urllib.request.urlopen(url, timeout=30) as response: + published = response.read(10_000_001) + assert published == (root / 'assets/reader-banner.png').read_bytes(), 'Published banner differs from source' + assert len(published) < 10_000_000 and published.startswith(b'\x89PNG\r\n\x1a\n') + PYBANNER - name: Build distributions run: | export SOURCE_DATE_EPOCH="$(git show -s --format=%ct HEAD)" @@ -83,6 +97,7 @@ jobs: 'name': project['name'], 'version': project['version'], 'revision': os.environ['GITHUB_SHA'], 'tag': os.environ['GITHUB_REF_NAME'], 'python': platform.python_version(), 'uv_lock_sha256': sha(root / 'uv.lock'), + 'readme_sha256': sha(root / 'README.md'), 'banner_sha256': sha(root / 'assets/reader-banner.png'), 'uv': subprocess.check_output(['uv', '--version'], text=True).strip(), 'build_lock_sha256': sha(evidence / 'build-requirements.lock'), 'checks': json.loads((evidence / 'checks.json').read_text()), diff --git a/MANIFEST.in b/MANIFEST.in index ebf72c31..02cf9bad 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ include CITATION.cff +include assets/reader-banner.png diff --git a/README.md b/README.md index 9f7e71ed..d276a12e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ![Reader data workbench](https://raw.githubusercontent.com/e-south/reader/main/assets/reader-banner.svg) +# ![Reader data workbench](https://raw.githubusercontent.com/e-south/reader/v1.0.0/assets/reader-banner.png) [![Checks](https://github.com/e-south/reader/actions/workflows/checks.yaml/badge.svg?branch=main)](https://github.com/e-south/reader/actions/workflows/checks.yaml) [![Python 3.12](https://img.shields.io/badge/python-3.12-3776AB.svg)](https://www.python.org/downloads/release/python-3120/) @@ -14,11 +14,10 @@ The distribution and import package are named `reader-workbench` and ## Install -Reader has not yet been published to PyPI. Install the current source release as -a command-line tool: +Install Reader as a command-line tool: ```bash -uv tool install git+https://github.com/e-south/reader.git +uv tool install reader-workbench ``` For development, install from a checkout: diff --git a/assets/reader-banner.png b/assets/reader-banner.png new file mode 100644 index 00000000..8714a8eb Binary files /dev/null and b/assets/reader-banner.png differ diff --git a/docs/guides/releases.md b/docs/guides/releases.md index 2691b444..d91d5047 100644 --- a/docs/guides/releases.md +++ b/docs/guides/releases.md @@ -72,3 +72,16 @@ an additional archival identifier, not a substitute for the executable version. See [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/) and the [namespace migration](./package_namespace_migration.md). + +### Package-page images + +The README is also the PyPI description. Keep its links absolute and use a +PNG banner at an immutable source commit or the matching `v` tag. +Never use a relative asset path or a mutable branch for a release image. Keep +published tags and their assets; changing a new banner must not change older +release pages. When incrementing the version, update a version-bound README +image URL in the same change. The package tests enforce this relationship. + +Before publishing, fetch the banner URL after the tag exists and compare its +bytes with the source PNG. Check the rendered PyPI page after upload. The PNG is +a package-page export; its editable SVG remains the artwork source. diff --git a/src/reader_workbench/tests/repo/test_docs_routes.py b/src/reader_workbench/tests/repo/test_docs_routes.py index bb31e671..07c2db56 100644 --- a/src/reader_workbench/tests/repo/test_docs_routes.py +++ b/src/reader_workbench/tests/repo/test_docs_routes.py @@ -14,11 +14,11 @@ def test_root_readme_is_a_human_first_landing_page() -> None: text = (REPO_ROOT / "README.md").read_text(encoding="utf-8") assert text.startswith("# ![Reader") assert not text.startswith("# reader\n") - assert "uv tool install git+https://github.com/e-south/reader.git" in text + assert "uv tool install reader-workbench" in text assert "python -m pip install reader-workbench" not in text assert "uv tool install ." not in text assert "python -m pip install ." not in text - assert "not yet been published to PyPI" in text + assert "not yet been published to PyPI" not in text assert "\nreader demo\n" in text assert "\nreader protocols\n" in text assert "uv run reader demo" in text diff --git a/src/reader_workbench/tests/repo/test_package_metadata.py b/src/reader_workbench/tests/repo/test_package_metadata.py index e5fb5dd9..a14298d1 100644 --- a/src/reader_workbench/tests/repo/test_package_metadata.py +++ b/src/reader_workbench/tests/repo/test_package_metadata.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import tomllib from pathlib import Path @@ -56,3 +57,21 @@ def test_compile_checks_target_the_existing_import_package() -> None: assert "compileall -q src/reader_workbench" in text, path assert "compileall src/reader`" not in text, path assert "compileall -q src/reader\n" not in text, path + + +def test_package_readme_uses_durable_images_and_absolute_links() -> None: + root = REPO_ROOT + project = tomllib.loads((root / "pyproject.toml").read_text())["project"] + assert project["readme"] == "README.md" + text = (root / "README.md").read_text() + targets = re.findall(r"\]\(([^)]+)\)", text) + assert all(target.startswith(("https://", "#")) for target in targets) + images = re.findall(r"!\[[^\]]*\]\(([^)]+)\)", text) + banner = images[0] + prefix = "https://raw.githubusercontent.com/e-south/reader/" + assert banner.startswith(prefix) + revision, relative = banner.removeprefix(prefix).split("/", 1) + assert revision == "v" + project["version"] or re.fullmatch(r"[0-9a-f]{40}", revision) + assert relative.endswith(".png") + data = (root / relative).read_bytes() + assert data.startswith(b"\x89PNG\r\n\x1a\n") and len(data) < 10_000_000