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
17 changes: 17 additions & 0 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions tools/release/publish_zenodo.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

import argparse
import json
import mimetypes
import os
import sys
from pathlib import Path
Expand Down Expand Up @@ -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']}.")

Expand Down
122 changes: 122 additions & 0 deletions tools/release/sync_citation.py
Original file line number Diff line number Diff line change
@@ -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<prefix>{re.escape(key)}:\s*)"[^"]*"\s*$'
updated, count = re.subn(
pattern,
rf'\g<prefix>"{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())
101 changes: 61 additions & 40 deletions ultraplot/tests/test_release_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import importlib.util
import re
import subprocess
from pathlib import Path

import pytest
Expand All @@ -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"


Expand All @@ -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.
Expand All @@ -71,13 +38,37 @@ def _load_publish_zenodo():
return module


def test_release_metadata_matches_latest_git_tag():
def _load_sync_citation():
"""
Citation metadata should track the latest tagged release.
Import the citation sync helper directly from the repo checkout.
"""
version, release_date = _latest_release_tag()
assert _citation_scalar("version") == version
assert _citation_scalar("date-released") == release_date
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):
"""
Release automation should be able to sync CITATION.cff from a tag.
"""
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():
Expand All @@ -96,6 +87,34 @@ 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.
Expand All @@ -114,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
Expand Down
Loading