Skip to content

Commit 8f91a7c

Browse files
Add PyPI publish workflow; display title pAMICA (#178)
* Add PyPI publish workflow and version-sync tool publish.yml uploads the pamica sdist/wheel to PyPI via Trusted Publishing (OIDC, environment `pypi`) on a published release. scripts/sync_version.py keeps the release version in lockstep across pyproject.toml, CITATION.cff and .zenodo.json; the publish job gates a release on tag==metadata via its check mode. * Set display title to pAMICA Human-readable title is now pAMICA (README H1, docs landing H1, MkDocs site_name, PyPI summary, CITATION/Zenodo title). The package, import path and pip name stay lowercase pamica. Also fixes two rename artifacts: the README docs badge label and an mkdocs comment that read /pamica but link to /pyAMICA. Adds version/publication_date to .zenodo.json for the sync tool. * Guard sync() no-op and empty dist upload Addresses both PR #178 reviewers: sync() now raises (via re.subn count check + a final read-back) instead of silently writing a file unchanged and reporting success if a metadata file's formatting drifts; upload-artifact uses if-no-files-found: error. Documents the uv lock step after sync so uv.lock tracks the bump.
1 parent 30b32be commit 8f91a7c

9 files changed

Lines changed: 269 additions & 9 deletions

File tree

.github/workflows/publish.yml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
name: Publish to PyPI
2+
3+
# Builds the pamica sdist + wheel and uploads them to PyPI when a GitHub release
4+
# is published. Uploading uses PyPI Trusted Publishing (OIDC): no API token is
5+
# stored. PyPI is configured to trust this repository, this workflow file
6+
# (publish.yml), and the `pypi` deployment environment.
7+
#
8+
# Release procedure (metadata must be correct in the *tag's* tree, because
9+
# PyPI publishes the built dist and Zenodo archives the tag on release):
10+
# 1. python scripts/sync_version.py sync X.Y.Z # pyproject + CITATION.cff + .zenodo.json
11+
# uv lock # refresh uv.lock to the new version
12+
# 2. commit "Bump version to X.Y.Z", open/merge the bump PR
13+
# 3. create a GitHub release with tag vX.Y.Z (or X.Y.Z)
14+
# The build job's `check` step then fails the release if the tag and the three
15+
# metadata files disagree, before anything is uploaded.
16+
#
17+
# workflow_dispatch runs a build-only dry run (the publish job is gated on the
18+
# release event), so the package can be built and metadata-checked without
19+
# publishing.
20+
21+
on:
22+
release:
23+
types: [published]
24+
workflow_dispatch:
25+
26+
permissions:
27+
contents: read
28+
29+
jobs:
30+
build:
31+
name: Build sdist + wheel
32+
runs-on: ubuntu-latest
33+
steps:
34+
- uses: actions/checkout@v4
35+
- uses: astral-sh/setup-uv@v5
36+
with:
37+
python-version: "3.12"
38+
enable-cache: true
39+
40+
# On a real release, the tag must match the version declared in
41+
# pyproject.toml, CITATION.cff, and .zenodo.json (all kept in lockstep by
42+
# scripts/sync_version.py). This gates PyPI + the Zenodo archive + the
43+
# citation record on one agreed version.
44+
- name: Verify tag matches metadata version
45+
if: github.event_name == 'release'
46+
run: uv run python scripts/sync_version.py check "${GITHUB_REF_NAME#v}"
47+
48+
- name: Build distributions
49+
run: uv build
50+
51+
# Fail early on a broken long-description / invalid metadata rather than at
52+
# the upload step.
53+
- name: Check distribution metadata
54+
run: uvx twine check dist/*
55+
56+
- uses: actions/upload-artifact@v4
57+
with:
58+
name: dist
59+
path: dist/*
60+
if-no-files-found: error # an empty dist/ means the build silently produced nothing
61+
62+
publish:
63+
name: Publish to PyPI
64+
needs: build
65+
if: github.event_name == 'release'
66+
runs-on: ubuntu-latest
67+
environment:
68+
name: pypi
69+
url: https://pypi.org/p/pamica
70+
permissions:
71+
id-token: write # OIDC token for Trusted Publishing
72+
steps:
73+
- uses: actions/download-artifact@v4
74+
with:
75+
name: dist
76+
path: dist
77+
- uses: pypa/gh-action-pypi-publish@release/v1

.zenodo.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
{
2-
"title": "pamica: a Python implementation of Adaptive Mixture ICA",
2+
"title": "pAMICA: a Python implementation of Adaptive Mixture ICA",
33
"description": "pamica is a Python (PyTorch) implementation of Adaptive Mixture Independent Component Analysis (AMICA) that reproduces the reference Fortran implementation within numerical tolerance, with CPU, NVIDIA GPU (CUDA), and Apple GPU (MLX) support. It targets EEG/EMG blind source separation and provides a scikit-learn-style interface and byte-identical EEGLAB (loadmodout15) output.",
4+
"version": "0.1.2",
5+
"publication_date": "2026-07-14",
46
"upload_type": "software",
57
"access_right": "open",
68
"license": "bsd-3-clause",

CITATION.cff

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
cff-version: 1.3.0
22
message: "If you use this software, please cite it using the metadata below."
3-
title: "pamica: a Python implementation of Adaptive Mixture ICA"
3+
title: "pAMICA: a Python implementation of Adaptive Mixture ICA"
44
abstract: >-
55
pamica is a Python (PyTorch) implementation of Adaptive Mixture Independent
66
Component Analysis (AMICA) that reproduces the reference Fortran

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
# pamica: Adaptive Mixture ICA
1+
# pAMICA: Adaptive Mixture ICA
22

33
[![CI](https://github.com/sccn/pyAMICA/actions/workflows/ci.yml/badge.svg)](https://github.com/sccn/pyAMICA/actions/workflows/ci.yml)
44
[![codecov](https://codecov.io/gh/sccn/pyAMICA/branch/main/graph/badge.svg)](https://codecov.io/gh/sccn/pyAMICA)
55
[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.21312148.svg)](https://doi.org/10.5281/zenodo.21312148)
6-
[![Docs](https://img.shields.io/badge/docs-eeglab.org%2Fpamica-blue)](https://eeglab.org/pyAMICA/)
6+
[![Docs](https://img.shields.io/badge/docs-eeglab.org%2FpyAMICA-blue)](https://eeglab.org/pyAMICA/)
77

88
Python (PyTorch) implementation of Adaptive Mixture Independent Component Analysis
99
(AMICA) that reproduces the reference Fortran implementation within numerical

docs/changelog.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ Release notes are also published on the
55

66
## Unreleased
77

8+
- Packaging and release: a PyPI publish workflow (`publish.yml`) uploads the
9+
`pamica` sdist and wheel via Trusted Publishing (OIDC) when a GitHub release
10+
is published, and `scripts/sync_version.py` keeps the release version in step
11+
across `pyproject.toml`, `CITATION.cff` and `.zenodo.json` (the publish job
12+
fails a release whose tag disagrees with them). The display title is now
13+
**pAMICA**; the package, import and `pip install pamica` stay lowercase
14+
`pamica` (pip name matching is case-insensitive, so `pip install pAmica`
15+
resolves to the same project) (#177).
816
- Native Fortran run engine (`AMICANative`), the fourth backend alongside NumPy,
917
PyTorch and MLX. It runs the AMICA Fortran reference itself and returns an
1018
`AmicaOutput` with the usual accessors, so it is the parity oracle the Python

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# pamica
1+
# pAMICA
22

33
Python (PyTorch) implementation of **Adaptive Mixture Independent Component
44
Analysis (AMICA)** that reproduces the results of the reference Fortran binary,

mkdocs.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
# pamica documentation (MkDocs Material).
1+
# pAMICA documentation (MkDocs Material).
22
# Deployed to GitHub Pages by .github/workflows/docs.yml. The repo lives at
33
# github.com/sccn/pyAMICA, whose project Pages inherit the sccn org Pages custom
4-
# domain (eeglab.org) at the /pamica subpath, so the site is served at
4+
# domain (eeglab.org) at the /pyAMICA subpath, so the site is served at
55
# https://eeglab.org/pyAMICA/. Links are relative so the build is location-independent.
66

7-
site_name: pamica Documentation
7+
site_name: pAMICA Documentation
88
site_url: https://eeglab.org/pyAMICA/
99
site_description: Python (PyTorch) implementation of Adaptive Mixture ICA (AMICA) with Fortran parity and GPU/MPS/CPU support.
1010
site_author: Seyed Yahya Shirazi

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
55
[project]
66
name = "pamica"
77
version = "0.1.2"
8-
description = "pamica: Python implementation of the Adaptive Mixture ICA algorithm"
8+
description = "pAMICA: Python implementation of the Adaptive Mixture ICA algorithm"
99
readme = "README.md"
1010
authors = [
1111
{name = "Seyed Yahya Shirazi", email = "shirazi@ieee.org"},

scripts/sync_version.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Keep the release version consistent across the three metadata files.
2+
3+
A pamica release version lives in three places that must agree, because each is
4+
consumed by a different tool at release time:
5+
6+
- ``pyproject.toml`` -> the wheel/sdist that ``publish.yml`` uploads to PyPI.
7+
- ``CITATION.cff`` -> the citation record (``version`` + ``date-released``).
8+
- ``.zenodo.json`` -> the Zenodo archive metadata (``version`` +
9+
``publication_date``); Zenodo reads it from the *tag's* tree when the GitHub
10+
release is published, so it must be correct **before** tagging, not committed
11+
afterward.
12+
13+
Two modes:
14+
15+
python scripts/sync_version.py sync 0.1.3 # writes all three
16+
python scripts/sync_version.py sync 0.1.3 --date 2026-08-01
17+
python scripts/sync_version.py check 0.1.3 # verifies, exit 1 on drift
18+
19+
``sync`` is the release-prep step: run it, then ``uv lock`` (so ``uv.lock``
20+
tracks the new version), commit "Bump version to X.Y.Z", and tag.
21+
``check`` is the release gate that ``publish.yml`` runs against the release tag,
22+
so a mistagged or half-bumped release fails before anything reaches PyPI.
23+
24+
Only string-level substitutions are used so the files' formatting, key order,
25+
and comments are preserved untouched.
26+
"""
27+
28+
from __future__ import annotations
29+
30+
import argparse
31+
import json
32+
import re
33+
import sys
34+
import tomllib
35+
from datetime import date
36+
from pathlib import Path
37+
38+
ROOT = Path(__file__).resolve().parent.parent
39+
PYPROJECT = ROOT / "pyproject.toml"
40+
CITATION = ROOT / "CITATION.cff"
41+
ZENODO = ROOT / ".zenodo.json"
42+
43+
_SEMVER = re.compile(r"^\d+\.\d+\.\d+([.\-+][0-9A-Za-z.\-+]+)?$")
44+
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
45+
46+
47+
def read_versions() -> dict[str, str]:
48+
"""Return the version string currently declared in each metadata file."""
49+
pyproject = tomllib.loads(PYPROJECT.read_text())["project"]["version"]
50+
51+
m = re.search(r"(?m)^version:\s*(.+?)\s*$", CITATION.read_text())
52+
if m is None:
53+
raise ValueError("CITATION.cff has no top-level 'version:' key")
54+
citation = m.group(1).strip().strip('"')
55+
56+
zenodo = json.loads(ZENODO.read_text()).get("version")
57+
if zenodo is None:
58+
raise ValueError(".zenodo.json has no 'version' key")
59+
60+
return {
61+
"pyproject.toml": pyproject,
62+
"CITATION.cff": citation,
63+
".zenodo.json": zenodo,
64+
}
65+
66+
67+
def _sub_once(text: str, pattern: str, repl: str, *, where: str) -> str:
68+
"""Substitute exactly one match, raising if the pattern is absent.
69+
70+
``re.sub`` returns the input unchanged (no error) when nothing matches, which
71+
would let ``sync`` write a file back untouched while reporting success -- a
72+
silent no-op if a file's formatting ever drifts from what the pattern expects.
73+
Raising instead turns that drift into a loud, actionable failure.
74+
"""
75+
new, n = re.subn(pattern, repl, text, count=1)
76+
if n == 0:
77+
raise ValueError(f"{where}: no line matched {pattern!r}; nothing written")
78+
return new
79+
80+
81+
def sync(version: str, released: str) -> None:
82+
PYPROJECT.write_text(
83+
_sub_once(
84+
PYPROJECT.read_text(),
85+
r'(?m)^version = "[^"]*"',
86+
f'version = "{version}"',
87+
where="pyproject.toml version",
88+
)
89+
)
90+
91+
citation = CITATION.read_text()
92+
citation = _sub_once(
93+
citation,
94+
r"(?m)^version:.*$",
95+
f"version: {version}",
96+
where="CITATION.cff version",
97+
)
98+
citation = _sub_once(
99+
citation,
100+
r"(?m)^date-released:.*$",
101+
f'date-released: "{released}"',
102+
where="CITATION.cff date-released",
103+
)
104+
CITATION.write_text(citation)
105+
106+
zenodo = ZENODO.read_text()
107+
zenodo = _sub_once(
108+
zenodo,
109+
r'"version": "[^"]*"',
110+
f'"version": "{version}"',
111+
where=".zenodo.json version",
112+
)
113+
zenodo = _sub_once(
114+
zenodo,
115+
r'"publication_date": "[^"]*"',
116+
f'"publication_date": "{released}"',
117+
where=".zenodo.json publication_date",
118+
)
119+
ZENODO.write_text(zenodo)
120+
121+
# Belt-and-suspenders: never report success while a file is still out of sync
122+
# (e.g. a substitution that matched an unexpected line and wrote a wrong value).
123+
drift = {name: got for name, got in read_versions().items() if got != version}
124+
if drift:
125+
raise RuntimeError(f"sync wrote the files but they still disagree: {drift}")
126+
127+
print(f"Set version {version} (released {released}) in all three metadata files.")
128+
129+
130+
def check(version: str) -> int:
131+
versions = read_versions()
132+
drift = {name: got for name, got in versions.items() if got != version}
133+
if drift:
134+
print(f"Version mismatch (expected {version}):", file=sys.stderr)
135+
for name, got in versions.items():
136+
mark = "!=" if name in drift else "=="
137+
print(f" {name}: {got} {mark} {version}", file=sys.stderr)
138+
return 1
139+
print(f"All metadata files agree on version {version}.")
140+
return 0
141+
142+
143+
def main() -> int:
144+
parser = argparse.ArgumentParser(description=__doc__)
145+
sub = parser.add_subparsers(dest="mode", required=True)
146+
147+
p_sync = sub.add_parser("sync", help="write the version into all three files")
148+
p_sync.add_argument("version", help="release version, e.g. 0.1.3")
149+
p_sync.add_argument(
150+
"--date",
151+
default=date.today().isoformat(),
152+
help="release date (ISO YYYY-MM-DD); defaults to today",
153+
)
154+
155+
p_check = sub.add_parser("check", help="verify all three files match the version")
156+
p_check.add_argument("version", help="expected version, e.g. 0.1.3")
157+
158+
args = parser.parse_args()
159+
version = args.version.removeprefix("v")
160+
161+
if not _SEMVER.match(version):
162+
parser.error(f"'{version}' is not a MAJOR.MINOR.PATCH version")
163+
164+
if args.mode == "sync":
165+
if not _ISO_DATE.match(args.date):
166+
parser.error(f"--date '{args.date}' is not ISO YYYY-MM-DD")
167+
sync(version, args.date)
168+
return 0
169+
return check(version)
170+
171+
172+
if __name__ == "__main__":
173+
raise SystemExit(main())

0 commit comments

Comments
 (0)