Skip to content

Commit b21902f

Browse files
committed
Force Zenodo artifact uploads to use application/octet-stream and add… (#670)
* 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. * 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. * 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.
1 parent e31a18a commit b21902f

File tree

5 files changed

+205
-45
lines changed

5 files changed

+205
-45
lines changed

.github/workflows/publish-pypi.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ jobs:
2525
with:
2626
python-version: "3.12"
2727

28+
- name: Sync citation metadata for release tags
29+
if: github.event_name == 'push'
30+
run: |
31+
python tools/release/sync_citation.py --tag "${GITHUB_REF_NAME}"
32+
shell: bash
33+
2834
- name: Build package
2935
run: |
3036
python -m pip install --upgrade pip wheel setuptools setuptools_scm build twine
@@ -141,6 +147,12 @@ jobs:
141147
ZENODO_ACCESS_TOKEN: ${{ secrets.ZENODO_ACCESS_TOKEN }}
142148
steps:
143149
- uses: actions/checkout@v6
150+
with:
151+
fetch-depth: 0
152+
153+
- name: Get tags
154+
run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
155+
shell: bash
144156

145157
- uses: actions/setup-python@v6
146158
with:
@@ -157,6 +169,11 @@ jobs:
157169
name: dist-${{ github.sha }}-${{ github.run_id }}-${{ github.run_number }}
158170
path: dist
159171

172+
- name: Sync citation metadata for release tags
173+
run: |
174+
python tools/release/sync_citation.py --tag "${GITHUB_REF_NAME}"
175+
shell: bash
176+
160177
- name: Publish to Zenodo
161178
run: |
162179
python tools/release/publish_zenodo.py --dist-dir dist

CITATION.cff

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ authors:
88
- family-names: "Becker"
99
given-names: "Matthew R."
1010
orcid: "https://orcid.org/0000-0001-7774-2246"
11-
date-released: "2026-03-11"
12-
version: "2.1.3"
11+
date-released: "2026-03-30"
12+
version: "2.1.5"
1313
doi: "10.5281/zenodo.15733564"
1414
repository-code: "https://github.com/Ultraplot/UltraPlot"
1515
license: "MIT"

tools/release/publish_zenodo.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
import argparse
55
import json
6-
import mimetypes
76
import os
87
import sys
98
from pathlib import Path
@@ -244,14 +243,15 @@ def upload_dist_files(draft: dict, token: str, dist_dir: Path) -> None:
244243
for path in sorted(dist_dir.iterdir()):
245244
if not path.is_file():
246245
continue
247-
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
248246
with path.open("rb") as handle:
249247
api_request(
250248
"PUT",
251249
f"{bucket_url}/{parse.quote(path.name)}",
252250
token=token,
253251
data=handle.read(),
254-
content_type=content_type,
252+
# Zenodo bucket uploads reject extension-specific types like
253+
# application/gzip for source tarballs and require raw bytes.
254+
content_type="application/octet-stream",
255255
)
256256
print(f"Uploaded {path.name} to Zenodo draft {draft['id']}.")
257257

tools/release/sync_citation.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import re
6+
import subprocess
7+
from pathlib import Path
8+
9+
10+
def parse_args() -> argparse.Namespace:
11+
parser = argparse.ArgumentParser(
12+
description="Sync CITATION.cff release metadata from a git tag."
13+
)
14+
parser.add_argument(
15+
"--tag",
16+
required=True,
17+
help="Release tag to sync from, for example v2.1.5 or refs/tags/v2.1.5.",
18+
)
19+
parser.add_argument(
20+
"--citation",
21+
type=Path,
22+
default=Path("CITATION.cff"),
23+
help="Path to the repository CITATION.cff file.",
24+
)
25+
parser.add_argument(
26+
"--date",
27+
help="Explicit release date in YYYY-MM-DD format. Defaults to the git tag date.",
28+
)
29+
parser.add_argument(
30+
"--check",
31+
action="store_true",
32+
help="Validate the file contents instead of writing them.",
33+
)
34+
return parser.parse_args()
35+
36+
37+
def normalize_tag(tag: str) -> str:
38+
return tag.strip().removeprefix("refs/tags/")
39+
40+
41+
def tag_version(tag: str) -> str:
42+
tag = normalize_tag(tag)
43+
if not tag.startswith("v"):
44+
raise ValueError(f"Release tag must start with 'v', got {tag!r}.")
45+
return tag.removeprefix("v")
46+
47+
48+
def resolve_release_date(tag: str, repo_root: Path) -> str:
49+
result = subprocess.run(
50+
[
51+
"git",
52+
"for-each-ref",
53+
f"refs/tags/{normalize_tag(tag)}",
54+
"--format=%(creatordate:short)",
55+
],
56+
check=True,
57+
cwd=repo_root,
58+
capture_output=True,
59+
text=True,
60+
)
61+
value = result.stdout.strip()
62+
if not value:
63+
raise ValueError(f"Could not resolve a release date for tag {tag!r}.")
64+
return value
65+
66+
67+
def replace_scalar(text: str, key: str, value: str) -> str:
68+
pattern = rf'^(?P<prefix>{re.escape(key)}:\s*)"[^"]*"\s*$'
69+
updated, count = re.subn(
70+
pattern,
71+
rf'\g<prefix>"{value}"',
72+
text,
73+
count=1,
74+
flags=re.MULTILINE,
75+
)
76+
if count != 1:
77+
raise ValueError(f"Missing quoted scalar {key!r} in CITATION metadata.")
78+
return updated
79+
80+
81+
def sync_citation(
82+
citation_path: Path,
83+
*,
84+
tag: str,
85+
release_date: str | None = None,
86+
repo_root: Path | None = None,
87+
check: bool = False,
88+
) -> bool:
89+
repo_root = repo_root or citation_path.resolve().parent
90+
version = tag_version(tag)
91+
release_date = release_date or resolve_release_date(tag, repo_root)
92+
original = citation_path.read_text(encoding="utf-8")
93+
updated = replace_scalar(original, "version", version)
94+
updated = replace_scalar(updated, "date-released", release_date)
95+
changed = updated != original
96+
if check:
97+
if changed:
98+
raise SystemExit(
99+
f"{citation_path} is out of date for {normalize_tag(tag)}. "
100+
"Run tools/release/sync_citation.py before releasing."
101+
)
102+
return False
103+
citation_path.write_text(updated, encoding="utf-8")
104+
return changed
105+
106+
107+
def main() -> int:
108+
args = parse_args()
109+
changed = sync_citation(
110+
args.citation,
111+
tag=args.tag,
112+
release_date=args.date,
113+
repo_root=Path.cwd(),
114+
check=args.check,
115+
)
116+
action = "Validated" if args.check else "Updated"
117+
print(f"{action} {args.citation} for {normalize_tag(args.tag)}.")
118+
return 0 if (args.check or changed or not changed) else 0
119+
120+
121+
if __name__ == "__main__":
122+
raise SystemExit(main())

ultraplot/tests/test_release_metadata.py

Lines changed: 61 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import importlib.util
44
import re
5-
import subprocess
65
from pathlib import Path
76

87
import pytest
@@ -13,6 +12,7 @@
1312
README = ROOT / "README.rst"
1413
PUBLISH_WORKFLOW = ROOT / ".github" / "workflows" / "publish-pypi.yml"
1514
PYPROJECT = ROOT / "pyproject.toml"
15+
SYNC_CITATION_SCRIPT = ROOT / "tools" / "release" / "sync_citation.py"
1616
ZENODO_SCRIPT = ROOT / "tools" / "release" / "publish_zenodo.py"
1717

1818

@@ -26,39 +26,6 @@ def _citation_scalar(key):
2626
return match.group(1)
2727

2828

29-
def _latest_release_tag():
30-
"""
31-
Return the latest release tag and tag date from the local git checkout.
32-
"""
33-
try:
34-
tag_result = subprocess.run(
35-
["git", "tag", "--sort=-v:refname"],
36-
check=True,
37-
cwd=ROOT,
38-
capture_output=True,
39-
text=True,
40-
)
41-
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
42-
pytest.skip(f"Could not inspect git tags: {exc}")
43-
tags = [tag for tag in tag_result.stdout.splitlines() if tag.startswith("v")]
44-
if not tags:
45-
pytest.skip("No release tags found in this checkout")
46-
tag = tags[0]
47-
date_result = subprocess.run(
48-
[
49-
"git",
50-
"for-each-ref",
51-
f"refs/tags/{tag}",
52-
"--format=%(creatordate:short)",
53-
],
54-
check=True,
55-
cwd=ROOT,
56-
capture_output=True,
57-
text=True,
58-
)
59-
return tag.removeprefix("v"), date_result.stdout.strip()
60-
61-
6229
def _load_publish_zenodo():
6330
"""
6431
Import the Zenodo release helper directly from the repo checkout.
@@ -71,13 +38,37 @@ def _load_publish_zenodo():
7138
return module
7239

7340

74-
def test_release_metadata_matches_latest_git_tag():
41+
def _load_sync_citation():
7542
"""
76-
Citation metadata should track the latest tagged release.
43+
Import the citation sync helper directly from the repo checkout.
7744
"""
78-
version, release_date = _latest_release_tag()
79-
assert _citation_scalar("version") == version
80-
assert _citation_scalar("date-released") == release_date
45+
spec = importlib.util.spec_from_file_location("sync_citation", SYNC_CITATION_SCRIPT)
46+
if spec is None or spec.loader is None:
47+
raise ImportError(f"Could not load sync_citation from {SYNC_CITATION_SCRIPT}")
48+
module = importlib.util.module_from_spec(spec)
49+
spec.loader.exec_module(module)
50+
return module
51+
52+
53+
def test_sync_citation_updates_release_metadata(tmp_path):
54+
"""
55+
Release automation should be able to sync CITATION.cff from a tag.
56+
"""
57+
sync_citation = _load_sync_citation()
58+
citation = tmp_path / "CITATION.cff"
59+
citation.write_text(CITATION_CFF.read_text(encoding="utf-8"), encoding="utf-8")
60+
61+
changed = sync_citation.sync_citation(
62+
citation,
63+
tag="v9.9.9",
64+
release_date="2030-01-02",
65+
repo_root=ROOT,
66+
)
67+
68+
text = citation.read_text(encoding="utf-8")
69+
assert changed is True
70+
assert 'version: "9.9.9"' in text
71+
assert 'date-released: "2030-01-02"' in text
8172

8273

8374
def test_zenodo_release_metadata_is_built_from_repository_sources():
@@ -96,6 +87,34 @@ def test_zenodo_release_metadata_is_built_from_repository_sources():
9687
assert metadata["creators"][0]["orcid"] == "0000-0001-9862-8936"
9788

9889

90+
def test_zenodo_uploads_use_octet_stream(tmp_path, monkeypatch):
91+
"""
92+
Zenodo bucket uploads should use a generic binary content type.
93+
"""
94+
publish_zenodo = _load_publish_zenodo()
95+
calls = []
96+
97+
def fake_api_request(method, url, **kwargs):
98+
calls.append((method, url, kwargs))
99+
return None
100+
101+
monkeypatch.setattr(publish_zenodo, "api_request", fake_api_request)
102+
(tmp_path / "ultraplot-2.1.5.tar.gz").write_bytes(b"sdist")
103+
(tmp_path / "ultraplot-2.1.5-py3-none-any.whl").write_bytes(b"wheel")
104+
105+
publish_zenodo.upload_dist_files(
106+
{"id": 18492463, "links": {"bucket": "https://zenodo.example/files/bucket"}},
107+
"token",
108+
tmp_path,
109+
)
110+
111+
assert len(calls) == 2
112+
assert all(method == "PUT" for method, _, _ in calls)
113+
assert all(
114+
kwargs["content_type"] == "application/octet-stream" for _, _, kwargs in calls
115+
)
116+
117+
99118
def test_zenodo_json_is_not_committed():
100119
"""
101120
Zenodo metadata should no longer be duplicated in a separate committed file.
@@ -114,10 +133,12 @@ def test_readme_citation_section_uses_repository_metadata():
114133

115134
def test_publish_workflow_creates_github_release_and_pushes_to_zenodo():
116135
"""
117-
Release tags should create a GitHub release and publish the same dist to Zenodo.
136+
Release tags should sync citation metadata, create a GitHub release, and
137+
publish the same dist to Zenodo.
118138
"""
119139
text = PUBLISH_WORKFLOW.read_text(encoding="utf-8")
120140
assert 'tags: ["v*"]' in text
141+
assert text.count("tools/release/sync_citation.py --tag") >= 2
121142
assert "softprops/action-gh-release@v2" in text
122143
assert "publish-zenodo:" in text
123144
assert "ZENODO_ACCESS_TOKEN" in text

0 commit comments

Comments
 (0)