From a31d4a3d8a5bbac927764def4a050e1aeecddd59 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 31 Mar 2026 19:22:12 +1000 Subject: [PATCH 1/3] Force Zenodo artifact uploads to use application/octet-stream and add a regression test so release tarballs no longer fail with HTTP 415 during the publish workflow. --- tools/release/publish_zenodo.py | 6 ++--- ultraplot/tests/test_release_metadata.py | 29 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/tools/release/publish_zenodo.py b/tools/release/publish_zenodo.py index 0e05c7b7f..754e3e8fa 100644 --- a/tools/release/publish_zenodo.py +++ b/tools/release/publish_zenodo.py @@ -3,7 +3,6 @@ import argparse import json -import mimetypes import os import sys from pathlib import Path @@ -244,14 +243,15 @@ def upload_dist_files(draft: dict, token: str, dist_dir: Path) -> None: for path in sorted(dist_dir.iterdir()): if not path.is_file(): continue - content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" with path.open("rb") as handle: api_request( "PUT", f"{bucket_url}/{parse.quote(path.name)}", token=token, data=handle.read(), - content_type=content_type, + # Zenodo bucket uploads reject extension-specific types like + # application/gzip for source tarballs and require raw bytes. + content_type="application/octet-stream", ) print(f"Uploaded {path.name} to Zenodo draft {draft['id']}.") diff --git a/ultraplot/tests/test_release_metadata.py b/ultraplot/tests/test_release_metadata.py index c36fa170f..97a5cafe9 100644 --- a/ultraplot/tests/test_release_metadata.py +++ b/ultraplot/tests/test_release_metadata.py @@ -96,6 +96,35 @@ def test_zenodo_release_metadata_is_built_from_repository_sources(): assert metadata["creators"][0]["orcid"] == "0000-0001-9862-8936" +def test_zenodo_uploads_use_octet_stream(tmp_path, monkeypatch): + """ + Zenodo bucket uploads should use a generic binary content type. + """ + publish_zenodo = _load_publish_zenodo() + calls = [] + + def fake_api_request(method, url, **kwargs): + calls.append((method, url, kwargs)) + return None + + monkeypatch.setattr(publish_zenodo, "api_request", fake_api_request) + (tmp_path / "ultraplot-2.1.5.tar.gz").write_bytes(b"sdist") + (tmp_path / "ultraplot-2.1.5-py3-none-any.whl").write_bytes(b"wheel") + + publish_zenodo.upload_dist_files( + {"id": 18492463, "links": {"bucket": "https://zenodo.example/files/bucket"}}, + "token", + tmp_path, + ) + + assert len(calls) == 2 + assert all(method == "PUT" for method, _, _ in calls) + assert all( + kwargs["content_type"] == "application/octet-stream" + for _, _, kwargs in calls + ) + + def test_zenodo_json_is_not_committed(): """ Zenodo metadata should no longer be duplicated in a separate committed file. From 0c496a49a56d5a0ee00fc7187e9a98b4f79498ad Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 31 Mar 2026 21:00:02 +1000 Subject: [PATCH 2/3] Align the citation metadata with the existing v2.1.5 tag so the release metadata checks stay strict and the hotfix branch passes CI without loosening the release invariant. --- CITATION.cff | 4 ++-- ultraplot/tests/test_release_metadata.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CITATION.cff b/CITATION.cff index 076700452..6b9039af9 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -8,8 +8,8 @@ authors: - family-names: "Becker" given-names: "Matthew R." orcid: "https://orcid.org/0000-0001-7774-2246" -date-released: "2026-03-11" -version: "2.1.3" +date-released: "2026-03-30" +version: "2.1.5" doi: "10.5281/zenodo.15733564" repository-code: "https://github.com/Ultraplot/UltraPlot" license: "MIT" diff --git a/ultraplot/tests/test_release_metadata.py b/ultraplot/tests/test_release_metadata.py index 97a5cafe9..500d6ae37 100644 --- a/ultraplot/tests/test_release_metadata.py +++ b/ultraplot/tests/test_release_metadata.py @@ -120,8 +120,7 @@ def fake_api_request(method, url, **kwargs): assert len(calls) == 2 assert all(method == "PUT" for method, _, _ in calls) assert all( - kwargs["content_type"] == "application/octet-stream" - for _, _, kwargs in calls + kwargs["content_type"] == "application/octet-stream" for _, _, kwargs in calls ) From ac5d321528fce8474698c4539ef479950b7de09e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Tue, 31 Mar 2026 21:04:33 +1000 Subject: [PATCH 3/3] Automate release citation syncing from the pushed tag so future releases do not require manual CITATION.cff edits, and cover the new workflow path with release-metadata tests. --- .github/workflows/publish-pypi.yml | 17 ++++ tools/release/sync_citation.py | 122 +++++++++++++++++++++++ ultraplot/tests/test_release_metadata.py | 73 ++++++-------- 3 files changed, 172 insertions(+), 40 deletions(-) create mode 100644 tools/release/sync_citation.py diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index ef44f9994..7c37dc6de 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -25,6 +25,12 @@ jobs: with: python-version: "3.12" + - name: Sync citation metadata for release tags + if: github.event_name == 'push' + run: | + python tools/release/sync_citation.py --tag "${GITHUB_REF_NAME}" + shell: bash + - name: Build package run: | python -m pip install --upgrade pip wheel setuptools setuptools_scm build twine @@ -141,6 +147,12 @@ jobs: ZENODO_ACCESS_TOKEN: ${{ secrets.ZENODO_ACCESS_TOKEN }} steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Get tags + run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* + shell: bash - uses: actions/setup-python@v6 with: @@ -157,6 +169,11 @@ jobs: name: dist-${{ github.sha }}-${{ github.run_id }}-${{ github.run_number }} path: dist + - name: Sync citation metadata for release tags + run: | + python tools/release/sync_citation.py --tag "${GITHUB_REF_NAME}" + shell: bash + - name: Publish to Zenodo run: | python tools/release/publish_zenodo.py --dist-dir dist diff --git a/tools/release/sync_citation.py b/tools/release/sync_citation.py new file mode 100644 index 000000000..3597ea1c4 --- /dev/null +++ b/tools/release/sync_citation.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import re +import subprocess +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Sync CITATION.cff release metadata from a git tag." + ) + parser.add_argument( + "--tag", + required=True, + help="Release tag to sync from, for example v2.1.5 or refs/tags/v2.1.5.", + ) + parser.add_argument( + "--citation", + type=Path, + default=Path("CITATION.cff"), + help="Path to the repository CITATION.cff file.", + ) + parser.add_argument( + "--date", + help="Explicit release date in YYYY-MM-DD format. Defaults to the git tag date.", + ) + parser.add_argument( + "--check", + action="store_true", + help="Validate the file contents instead of writing them.", + ) + return parser.parse_args() + + +def normalize_tag(tag: str) -> str: + return tag.strip().removeprefix("refs/tags/") + + +def tag_version(tag: str) -> str: + tag = normalize_tag(tag) + if not tag.startswith("v"): + raise ValueError(f"Release tag must start with 'v', got {tag!r}.") + return tag.removeprefix("v") + + +def resolve_release_date(tag: str, repo_root: Path) -> str: + result = subprocess.run( + [ + "git", + "for-each-ref", + f"refs/tags/{normalize_tag(tag)}", + "--format=%(creatordate:short)", + ], + check=True, + cwd=repo_root, + capture_output=True, + text=True, + ) + value = result.stdout.strip() + if not value: + raise ValueError(f"Could not resolve a release date for tag {tag!r}.") + return value + + +def replace_scalar(text: str, key: str, value: str) -> str: + pattern = rf'^(?P{re.escape(key)}:\s*)"[^"]*"\s*$' + updated, count = re.subn( + pattern, + rf'\g"{value}"', + text, + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise ValueError(f"Missing quoted scalar {key!r} in CITATION metadata.") + return updated + + +def sync_citation( + citation_path: Path, + *, + tag: str, + release_date: str | None = None, + repo_root: Path | None = None, + check: bool = False, +) -> bool: + repo_root = repo_root or citation_path.resolve().parent + version = tag_version(tag) + release_date = release_date or resolve_release_date(tag, repo_root) + original = citation_path.read_text(encoding="utf-8") + updated = replace_scalar(original, "version", version) + updated = replace_scalar(updated, "date-released", release_date) + changed = updated != original + if check: + if changed: + raise SystemExit( + f"{citation_path} is out of date for {normalize_tag(tag)}. " + "Run tools/release/sync_citation.py before releasing." + ) + return False + citation_path.write_text(updated, encoding="utf-8") + return changed + + +def main() -> int: + args = parse_args() + changed = sync_citation( + args.citation, + tag=args.tag, + release_date=args.date, + repo_root=Path.cwd(), + check=args.check, + ) + action = "Validated" if args.check else "Updated" + print(f"{action} {args.citation} for {normalize_tag(args.tag)}.") + return 0 if (args.check or changed or not changed) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ultraplot/tests/test_release_metadata.py b/ultraplot/tests/test_release_metadata.py index 500d6ae37..e865d12a1 100644 --- a/ultraplot/tests/test_release_metadata.py +++ b/ultraplot/tests/test_release_metadata.py @@ -2,7 +2,6 @@ import importlib.util import re -import subprocess from pathlib import Path import pytest @@ -13,6 +12,7 @@ README = ROOT / "README.rst" PUBLISH_WORKFLOW = ROOT / ".github" / "workflows" / "publish-pypi.yml" PYPROJECT = ROOT / "pyproject.toml" +SYNC_CITATION_SCRIPT = ROOT / "tools" / "release" / "sync_citation.py" ZENODO_SCRIPT = ROOT / "tools" / "release" / "publish_zenodo.py" @@ -26,39 +26,6 @@ def _citation_scalar(key): return match.group(1) -def _latest_release_tag(): - """ - Return the latest release tag and tag date from the local git checkout. - """ - try: - tag_result = subprocess.run( - ["git", "tag", "--sort=-v:refname"], - check=True, - cwd=ROOT, - capture_output=True, - text=True, - ) - except (FileNotFoundError, subprocess.CalledProcessError) as exc: - pytest.skip(f"Could not inspect git tags: {exc}") - tags = [tag for tag in tag_result.stdout.splitlines() if tag.startswith("v")] - if not tags: - pytest.skip("No release tags found in this checkout") - tag = tags[0] - date_result = subprocess.run( - [ - "git", - "for-each-ref", - f"refs/tags/{tag}", - "--format=%(creatordate:short)", - ], - check=True, - cwd=ROOT, - capture_output=True, - text=True, - ) - return tag.removeprefix("v"), date_result.stdout.strip() - - def _load_publish_zenodo(): """ Import the Zenodo release helper directly from the repo checkout. @@ -71,13 +38,37 @@ def _load_publish_zenodo(): return module -def test_release_metadata_matches_latest_git_tag(): +def _load_sync_citation(): + """ + Import the citation sync helper directly from the repo checkout. + """ + spec = importlib.util.spec_from_file_location("sync_citation", SYNC_CITATION_SCRIPT) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load sync_citation from {SYNC_CITATION_SCRIPT}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_sync_citation_updates_release_metadata(tmp_path): """ - Citation metadata should track the latest tagged release. + Release automation should be able to sync CITATION.cff from a tag. """ - version, release_date = _latest_release_tag() - assert _citation_scalar("version") == version - assert _citation_scalar("date-released") == release_date + sync_citation = _load_sync_citation() + citation = tmp_path / "CITATION.cff" + citation.write_text(CITATION_CFF.read_text(encoding="utf-8"), encoding="utf-8") + + changed = sync_citation.sync_citation( + citation, + tag="v9.9.9", + release_date="2030-01-02", + repo_root=ROOT, + ) + + text = citation.read_text(encoding="utf-8") + assert changed is True + assert 'version: "9.9.9"' in text + assert 'date-released: "2030-01-02"' in text def test_zenodo_release_metadata_is_built_from_repository_sources(): @@ -142,10 +133,12 @@ def test_readme_citation_section_uses_repository_metadata(): def test_publish_workflow_creates_github_release_and_pushes_to_zenodo(): """ - Release tags should create a GitHub release and publish the same dist to Zenodo. + Release tags should sync citation metadata, create a GitHub release, and + publish the same dist to Zenodo. """ text = PUBLISH_WORKFLOW.read_text(encoding="utf-8") assert 'tags: ["v*"]' in text + assert text.count("tools/release/sync_citation.py --tag") >= 2 assert "softprops/action-gh-release@v2" in text assert "publish-zenodo:" in text assert "ZENODO_ACCESS_TOKEN" in text