Skip to content

Commit 53a0018

Browse files
committed
Add pixi version tasks
1 parent c1d931d commit 53a0018

File tree

9 files changed

+382
-15
lines changed

9 files changed

+382
-15
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- TBD
6+
7+
## 7.0.1 - 2026-03-17
8+
9+
- added a Pixi version helper task and script so package metadata and release
10+
notes can be bumped consistently before the next tag
11+
312
## 7.0.0 - 2026-03-13
413

514
- moved package versioning to a single source of truth in

README.md

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ metadata stay aligned.
157157
```sh
158158
pixi install
159159
pixi run help
160+
pixi run version-show
160161
pixi run check
161162
pixi run release-check
162163
pixi run qtools-install
@@ -224,6 +225,22 @@ python -m build
224225
python -m twine check dist/*
225226
```
226227

228+
### Version updates
229+
230+
Use the Pixi helper to update the repository version consistently before a
231+
release:
232+
233+
```sh
234+
pixi run version-show
235+
pixi run version-bump 7.0.1
236+
pixi install
237+
```
238+
239+
`version-bump` updates the package metadata files and turns the current
240+
`## Unreleased` changelog section into a dated release entry. Run `pixi install`
241+
immediately afterward so `pixi.lock` is regenerated from the updated metadata
242+
before you commit or tag the release.
243+
227244
## Performance characterization workflow
228245

229246
`bench_results.jsonl` stores the host-side benchmark results, while QSPY
@@ -374,12 +391,13 @@ Publishing.
374391

375392
Recommended release flow:
376393

377-
1. Update `CHANGELOG.md`.
378-
2. Run `pixi run release-check` or the equivalent pip commands above.
379-
3. Commit the release changes and create a release tag such as `7.0.0` or
380-
`v7.0.0`.
381-
4. Push the tag to GitHub.
382-
5. The `publish.yml` workflow builds the wheel and sdist, then publishes them
394+
1. Add release notes under `## Unreleased` in `CHANGELOG.md`.
395+
2. Run `pixi run version-bump <version>` and then `pixi install`.
396+
3. Run `pixi run release-check` or the equivalent pip commands above.
397+
4. Commit the release changes and create a release tag such as `<version>` or
398+
`v<version>`.
399+
5. Push the tag to GitHub.
400+
6. The `publish.yml` workflow builds the wheel and sdist, then publishes them
383401
to PyPI using Trusted Publishing.
384402

385403
For conda-forge guidance, see `RELEASING.md`.

RELEASING.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,18 @@ Using Pixi:
1515

1616
```sh
1717
pixi install
18+
pixi run version-show
1819
pixi run release-check
1920
```
2021

22+
If you are preparing a release, bump the version first and then refresh the
23+
lock file:
24+
25+
```sh
26+
pixi run version-bump 7.0.1
27+
pixi install
28+
```
29+
2130
If you use Pixi and `pyproject.toml` changed, regenerate `pixi.lock` with
2231
`pixi install` and commit the updated lock file.
2332

@@ -42,7 +51,7 @@ One-time setup on PyPI:
4251

4352
Release trigger:
4453

45-
1. Push a release tag such as `7.0.0` or `v7.0.0`.
54+
1. Push a release tag such as `<version>` or `v<version>`.
4655
2. GitHub Actions will build `dist/*` and publish to PyPI without storing a
4756
long-lived API token in GitHub secrets.
4857
3. `workflow_dispatch` is kept as a manual build/debug entry point; the actual
@@ -68,11 +77,11 @@ upstream project does not need to vendor the feedstock into this repository.
6877

6978
Recommended flow after a PyPI release:
7079

71-
1. Wait for the `7.0.0` sdist to be available on PyPI.
80+
1. Wait for the `<version>` sdist to be available on PyPI.
7281
2. Generate or update a conda-forge v1 recipe:
7382

7483
```sh
75-
grayskull pypi --use-v1-format --strict-conda-forge arena-interface==7.0.0
84+
grayskull pypi --use-v1-format --strict-conda-forge arena-interface==<version>
7685
```
7786

7887
3. Submit the generated `recipe.yaml` to `conda-forge/staged-recipes` for the

codemeta.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
"license": "https://spdx.org/licenses/BSD-3-Clause",
55
"codeRepository": "https://github.com/janelia-python/arena_interface_python",
66
"dateCreated": "2023-10-17",
7-
"dateModified": "2026-03-13",
7+
"dateModified": "2026-03-17",
88
"name": "arena-interface",
9-
"version": "7.0.0",
9+
"version": "7.0.1",
1010
"description": "Python interface and CLI for the Reiser Lab ArenaController.",
1111
"programmingLanguage": [
1212
"Python 3"

pixi.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ select = ["E", "F", "I", "B", "UP"]
7676

7777
[tool.pixi.workspace]
7878
name = "arena-interface-python"
79-
version = "7.0.0"
79+
version = "7.0.1"
8080
description = "Python interface and benchmark tooling for ArenaController."
8181
authors = ["Peter Polidoro <peter@polidoro.io>"]
8282
channels = ["conda-forge"]
@@ -94,6 +94,8 @@ default = { features = ["dev"], solve-group = "default" }
9494
[tool.pixi.tasks]
9595
python = "python"
9696
help = "arena-interface --help"
97+
version-show = "python scripts/bump_version.py --current"
98+
version-bump = "python scripts/bump_version.py"
9799
all-off = "arena-interface all-off"
98100
all-on = "arena-interface all-on"
99101
format = "ruff format ."

scripts/bump_version.py

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import json
5+
import re
6+
import sys
7+
from dataclasses import dataclass
8+
from datetime import date
9+
from pathlib import Path
10+
11+
12+
ABOUT_RE = re.compile(r'(?m)^__version__\s*=\s*"([^"]+)"\s*$')
13+
PIXI_WORKSPACE_VERSION_RE = re.compile(
14+
r'(?ms)(^\[tool\.pixi\.workspace\]\n.*?^version = ")([^"]+)(")'
15+
)
16+
CODEMETA_VERSION_RE = re.compile(r'(?m)^(\s*"version"\s*:\s*")([^"]+)(",?\s*)$')
17+
CODEMETA_DATE_MODIFIED_RE = re.compile(
18+
r'(?m)^(\s*"dateModified"\s*:\s*")([^"]+)(",?\s*)$'
19+
)
20+
UNRELEASED_RE = re.compile(r'(?ms)^## Unreleased\s*\n(.*?)(?=^## |\Z)')
21+
22+
23+
@dataclass(frozen=True)
24+
class RepoPaths:
25+
root: Path
26+
about: Path
27+
pyproject: Path
28+
codemeta: Path
29+
changelog: Path
30+
31+
@classmethod
32+
def from_root(cls, root: Path) -> "RepoPaths":
33+
return cls(
34+
root=root,
35+
about=root / "src" / "arena_interface" / "__about__.py",
36+
pyproject=root / "pyproject.toml",
37+
codemeta=root / "codemeta.json",
38+
changelog=root / "CHANGELOG.md",
39+
)
40+
41+
42+
@dataclass(frozen=True)
43+
class UpdatedFile:
44+
path: Path
45+
changed: bool
46+
47+
48+
def read_text(path: Path) -> str:
49+
return path.read_text(encoding="utf-8")
50+
51+
52+
def write_text(path: Path, text: str) -> None:
53+
path.write_text(text, encoding="utf-8")
54+
55+
56+
def normalize_version(value: str) -> str:
57+
normalized = value.strip()
58+
if normalized.startswith("v"):
59+
normalized = normalized[1:]
60+
if not normalized or any(char.isspace() for char in normalized):
61+
raise ValueError("version must be a non-empty string without whitespace")
62+
return normalized
63+
64+
65+
def require_substitution(path: Path, text: str, pattern: re.Pattern[str], replacement: str) -> str:
66+
updated, count = pattern.subn(replacement, text, count=1)
67+
if count != 1:
68+
raise RuntimeError(f"expected exactly one replacement in {path}")
69+
return updated
70+
71+
72+
def current_version(paths: RepoPaths) -> str:
73+
match = ABOUT_RE.search(read_text(paths.about))
74+
if match is None:
75+
raise RuntimeError(f"could not find __version__ assignment in {paths.about}")
76+
return match.group(1)
77+
78+
79+
def update_about(paths: RepoPaths, new_version: str) -> UpdatedFile:
80+
original = read_text(paths.about)
81+
updated = require_substitution(
82+
paths.about,
83+
original,
84+
ABOUT_RE,
85+
rf'__version__ = "{new_version}"',
86+
)
87+
if updated != original:
88+
write_text(paths.about, updated)
89+
return UpdatedFile(paths.about, True)
90+
return UpdatedFile(paths.about, False)
91+
92+
93+
def update_pyproject(paths: RepoPaths, new_version: str) -> UpdatedFile:
94+
original = read_text(paths.pyproject)
95+
updated = require_substitution(
96+
paths.pyproject,
97+
original,
98+
PIXI_WORKSPACE_VERSION_RE,
99+
rf'\g<1>{new_version}\g<3>',
100+
)
101+
if updated != original:
102+
write_text(paths.pyproject, updated)
103+
return UpdatedFile(paths.pyproject, True)
104+
return UpdatedFile(paths.pyproject, False)
105+
106+
107+
def update_codemeta(paths: RepoPaths, new_version: str, release_date: str) -> UpdatedFile:
108+
original = read_text(paths.codemeta)
109+
updated = require_substitution(
110+
paths.codemeta,
111+
original,
112+
CODEMETA_VERSION_RE,
113+
rf'\g<1>{new_version}\g<3>',
114+
)
115+
updated = require_substitution(
116+
paths.codemeta,
117+
updated,
118+
CODEMETA_DATE_MODIFIED_RE,
119+
rf'\g<1>{release_date}\g<3>',
120+
)
121+
if updated != original:
122+
parsed = json.loads(updated)
123+
if parsed["version"] != new_version:
124+
raise RuntimeError("codemeta version update verification failed")
125+
if parsed["dateModified"] != release_date:
126+
raise RuntimeError("codemeta dateModified update verification failed")
127+
write_text(paths.codemeta, updated)
128+
return UpdatedFile(paths.codemeta, True)
129+
return UpdatedFile(paths.codemeta, False)
130+
131+
132+
def ensure_unreleased(text: str) -> str:
133+
if re.search(r'(?m)^## Unreleased\s*$', text):
134+
return text
135+
136+
heading = "# Changelog\n\n"
137+
if heading not in text:
138+
raise RuntimeError("CHANGELOG.md must start with '# Changelog'")
139+
140+
return text.replace(heading, heading + "## Unreleased\n\n- TBD\n\n", 1)
141+
142+
143+
def update_changelog(paths: RepoPaths, new_version: str, release_date: str) -> UpdatedFile:
144+
original = read_text(paths.changelog)
145+
146+
if re.search(
147+
rf'(?m)^## {re.escape(new_version)}\s*-\s*\d{{4}}-\d{{2}}-\d{{2}}\s*$',
148+
original,
149+
):
150+
raise RuntimeError(f"CHANGELOG.md already contains a section for {new_version}")
151+
152+
text = ensure_unreleased(original)
153+
match = UNRELEASED_RE.search(text)
154+
if match is None:
155+
raise RuntimeError("could not find an '## Unreleased' section in CHANGELOG.md")
156+
157+
unreleased_body = match.group(1).strip() or "- TBD"
158+
replacement = (
159+
"## Unreleased\n\n"
160+
"- TBD\n\n"
161+
f"## {new_version} - {release_date}\n\n"
162+
f"{unreleased_body}\n\n"
163+
)
164+
updated = text[: match.start()] + replacement + text[match.end() :].lstrip("\n")
165+
if updated != original:
166+
write_text(paths.changelog, updated)
167+
return UpdatedFile(paths.changelog, True)
168+
return UpdatedFile(paths.changelog, False)
169+
170+
171+
def parse_args(argv: list[str]) -> argparse.Namespace:
172+
parser = argparse.ArgumentParser(
173+
description=(
174+
"Update the repository version in package metadata and roll "
175+
"CHANGELOG.md from 'Unreleased' into a dated release section."
176+
)
177+
)
178+
parser.add_argument(
179+
"new_version",
180+
nargs="?",
181+
help="new package version such as 7.0.1",
182+
)
183+
parser.add_argument(
184+
"--current",
185+
action="store_true",
186+
help="print the current package version and exit",
187+
)
188+
parser.add_argument(
189+
"--date",
190+
default=date.today().isoformat(),
191+
help="release date to record in CHANGELOG.md (default: today)",
192+
)
193+
parser.add_argument(
194+
"--root",
195+
type=Path,
196+
default=Path(__file__).resolve().parents[1],
197+
help=argparse.SUPPRESS,
198+
)
199+
return parser.parse_args(argv)
200+
201+
202+
def main(argv: list[str] | None = None) -> int:
203+
args = parse_args(argv or sys.argv[1:])
204+
paths = RepoPaths.from_root(args.root.resolve())
205+
206+
if args.current:
207+
print(current_version(paths))
208+
return 0
209+
210+
if not args.new_version:
211+
print("error: NEW_VERSION is required unless --current is used", file=sys.stderr)
212+
return 2
213+
214+
try:
215+
new_version = normalize_version(args.new_version)
216+
except ValueError as exc:
217+
print(f"error: {exc}", file=sys.stderr)
218+
return 2
219+
220+
old_version = current_version(paths)
221+
if new_version == old_version:
222+
print(f"version is already {new_version}")
223+
return 0
224+
225+
changed = [
226+
update_about(paths, new_version),
227+
update_pyproject(paths, new_version),
228+
update_codemeta(paths, new_version, args.date),
229+
update_changelog(paths, new_version, args.date),
230+
]
231+
232+
updated_paths = [item.path.relative_to(paths.root).as_posix() for item in changed if item.changed]
233+
print(f"updated version: {old_version} -> {new_version}")
234+
for relative_path in updated_paths:
235+
print(f" - {relative_path}")
236+
print("next: run 'pixi install' to refresh pixi.lock, then review and commit the changes")
237+
return 0
238+
239+
240+
if __name__ == "__main__": # pragma: no cover
241+
raise SystemExit(main())

src/arena_interface/__about__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Project metadata for arena_interface."""
22

3-
__version__ = "7.0.0"
3+
__version__ = "7.0.1"
44
__description__ = "Python interface and CLI for the Reiser Lab ArenaController."
55
__license__ = "BSD-3-Clause"
66
__url__ = "https://github.com/janelia-python/arena_interface_python"

0 commit comments

Comments
 (0)