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
42 changes: 42 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Bug report
description: Report a reproducible PACE Controller problem
title: "[Bug]: "
labels: [bug]
body:
- type: textarea
attributes:
label: What happened?
description: Describe the exact actions, connection type, and observed result.
validations:
required: true
- type: textarea
attributes:
label: Exact error message and log
description: Paste the error and the relevant diagnostic-log lines. Remove sensitive network information if necessary.
validations:
required: true
- type: textarea
attributes:
label: Expected behaviour
validations:
required: true
- type: input
attributes:
label: PACE Controller version and operating system
placeholder: "For example: 1.0.1, Windows 11"
validations:
required: true
- type: input
attributes:
label: Instrument and connection
placeholder: "For example: PACE 5000, Ethernet 192.168.10.2:5025"
validations:
required: true
- type: checkboxes
attributes:
label: Safety and privacy
options:
- label: I reproduced the issue on a safe, unloaded, low-pressure setup.
required: true
- label: I reviewed every attachment for confidential laboratory or network data.
required: true
50 changes: 50 additions & 0 deletions .github/scripts/prepare_cross_platform_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@
LEGACY = ROOT / "PACE_Controller.ps1"
LEGACY_HASH = "aa6ffe5431dfab7d2ea998f9b59e8ac5163b0e3478e84a3c15e2e826fb356b8e"
SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$")
VERSION_BADGE_RE = re.compile(
r"^\[!\[(?:Latest release|Version)\]\([^)]+\)\]\([^)]+\)[ \t]*$", re.M
)
DOI_BADGE_RE = re.compile(r"^\[!\[DOI\]\([^)]+\)\]\([^)]+\)[ \t]*$", re.M)
VERSION_BADGE = (
"[![Version](https://img.shields.io/github/v/release/SebRoLENS/pace-controller)]"
"(https://github.com/SebRoLENS/pace-controller/releases/latest)"
)
DOI_PENDING_BADGE = (
"[![DOI](https://img.shields.io/badge/DOI-pending-lightgrey)]"
"(https://github.com/SebRoLENS/pace-controller/releases/latest)"
)


def version_tuple(version: str) -> tuple[int, int, int]:
Expand Down Expand Up @@ -76,6 +88,41 @@ def assert_legacy_unchanged() -> None:
)


def replace_section(text: str, heading: str, next_heading: str, body: str) -> str:
pattern = re.compile(
rf"(?ms)^{re.escape(heading)}\n.*?(?=^{re.escape(next_heading)}\n)"
)
if not pattern.search(text):
raise SystemExit(f"Could not find README section {heading!r}")
return pattern.sub(body.rstrip() + "\n\n", text, count=1)


def update_pending_doi_metadata(version: str) -> None:
text = ROOT_README.read_text(encoding="utf-8")
if not VERSION_BADGE_RE.search(text):
raise SystemExit("Could not find README version badge")
text = VERSION_BADGE_RE.sub(VERSION_BADGE, text, count=1)
if DOI_BADGE_RE.search(text):
text = DOI_BADGE_RE.sub(DOI_PENDING_BADGE, text, count=1)
else:
text = text.replace(VERSION_BADGE, VERSION_BADGE + "\n" + DOI_PENDING_BADGE, 1)
citation = f"""## Citation

If PACE Controller contributes to published work, cite the exact version used.
GitHub also provides a **Cite this repository** entry from [`CITATION.cff`](CITATION.cff).

Version **{version}** will be archived by the Zenodo GitHub integration. The
version DOI will then be inserted here automatically.

> Romi, S. (2026). *PACE Controller* (Version {version}) [Computer software].
> GitHub. https://github.com/SebRoLENS/pace-controller/releases/tag/v{version}
"""
ROOT_README.write_text(
replace_section(text, "## Citation", "## License and independence", citation),
encoding="utf-8",
)


def update_version(new: str) -> None:
replace_one(INIT, r'^__version__\s*=\s*"[^"]+"$', f'__version__ = "{new}"', "package version")
replace_one(PYPROJECT, r'^version\s*=\s*"[^"]+"$', f'version = "{new}"', "project version")
Expand All @@ -86,6 +133,9 @@ def update_version(new: str) -> None:
replace_one(CITATION, r'^version: ".*"$', f'version: "{new}"', "citation version")
replace_one(CITATION, r'^url: ".*"$', f'url: "https://github.com/SebRoLENS/pace-controller/releases/tag/v{new}"', "citation URL")
replace_one(CITATION, r'^date-released: .*$', f'date-released: {dt.date.today().isoformat()}', "citation date")
cff = re.sub(r"^doi:\s*.*\n", "", CITATION.read_text(encoding="utf-8"), flags=re.M)
CITATION.write_text(cff, encoding="utf-8")
update_pending_doi_metadata(new)


def update_changelog(new: str) -> None:
Expand Down
181 changes: 181 additions & 0 deletions .github/scripts/sync_zenodo_doi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Find a PACE Controller release DOI and synchronise repository metadata."""

from __future__ import annotations

import argparse
import json
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path


ROOT = Path(__file__).resolve().parents[2]
VERSION_FILE = ROOT / "cross_platform" / "src" / "pace_controller" / "__init__.py"
README = ROOT / "README.md"
CITATION = ROOT / "CITATION.cff"

VERSION_RE = re.compile(r'^__version__\s*=\s*"(\d+\.\d+\.\d+)"', re.M)
VERSION_BADGE_RE = re.compile(
r"^\[!\[(?:Latest release|Version)\]\([^)]+\)\]\([^)]+\)[ \t]*$", re.M
)
DOI_BADGE_RE = re.compile(r"^\[!\[DOI\]\([^)]+\)\]\([^)]+\)[ \t]*$", re.M)
VERSION_BADGE = (
"[![Version](https://img.shields.io/github/v/release/SebRoLENS/pace-controller)]"
"(https://github.com/SebRoLENS/pace-controller/releases/latest)"
)
VALID_TITLES = {
"pace controller",
"pace-controller",
"pace controller: cross-platform pressure controller for druck pace 5000/6000",
}


def current_version() -> str:
match = VERSION_RE.search(VERSION_FILE.read_text(encoding="utf-8"))
if not match:
raise SystemExit("Could not find __version__")
return match.group(1)


def version_matches(value: object, wanted: str) -> bool:
text = str(value or "").strip()
return text in {wanted, f"v{wanted}"}


def extract_doi(record: dict) -> str | None:
pids = record.get("pids") or {}
doi = pids.get("doi") if isinstance(pids, dict) else None
if isinstance(doi, dict) and doi.get("identifier"):
return str(doi["identifier"])
if isinstance(doi, str):
return doi
if record.get("doi"):
return str(record["doi"])
metadata = record.get("metadata") or {}
return str(metadata["doi"]) if metadata.get("doi") else None


def zenodo_records(query: str) -> list[dict]:
params = urllib.parse.urlencode({"q": query, "size": 25})
request = urllib.request.Request(
f"https://zenodo.org/api/records?{params}",
headers={"Accept": "application/json", "User-Agent": "pace-controller-release-bot/0.1"},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
payload = json.load(response)
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace").strip()
raise RuntimeError(f"Zenodo API returned HTTP {exc.code}: {detail}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"Zenodo API request failed: {exc}") from exc
return ((payload.get("hits") or {}).get("hits") or [])


def find_doi(version: str) -> str | None:
candidates: list[dict] = []
seen: set[str] = set()
last_error: RuntimeError | None = None
for query in ('"PACE Controller"', f'"PACE Controller" AND "{version}"', version):
try:
records = zenodo_records(query)
except RuntimeError as exc:
last_error = exc
continue
for record in records:
identifier = str(record.get("id") or "")
if identifier and identifier in seen:
continue
seen.add(identifier)
metadata = record.get("metadata") or {}
if str(metadata.get("title", "")).strip().lower() not in VALID_TITLES:
continue
if version_matches(metadata.get("version"), version) and extract_doi(record):
candidates.append(record)
if not candidates:
if last_error is not None:
raise last_error
return None
candidates.sort(
key=lambda record: str(record.get("updated") or record.get("created") or ""),
reverse=True,
)
return extract_doi(candidates[0])


def replace_section(text: str, heading: str, next_heading: str, body: str) -> str:
pattern = re.compile(
rf"(?ms)^{re.escape(heading)}\n.*?(?=^{re.escape(next_heading)}\n)"
)
if not pattern.search(text):
raise SystemExit(f"Could not find README section {heading!r}")
return pattern.sub(body.rstrip() + "\n\n", text, count=1)


def apply_metadata(version: str, doi: str) -> None:
doi_url = f"https://doi.org/{doi}"
doi_badge = f"[![DOI](https://zenodo.org/badge/DOI/{doi}.svg)]({doi_url})"

readme = README.read_text(encoding="utf-8")
if not VERSION_BADGE_RE.search(readme):
raise SystemExit("Could not find README version badge")
readme = VERSION_BADGE_RE.sub(VERSION_BADGE, readme, count=1)
if DOI_BADGE_RE.search(readme):
readme = DOI_BADGE_RE.sub(doi_badge, readme, count=1)
else:
readme = readme.replace(VERSION_BADGE, VERSION_BADGE + "\n" + doi_badge, 1)
citation = f"""## Citation

If PACE Controller contributes to published work, cite the exact version used.
GitHub also provides a **Cite this repository** entry from [`CITATION.cff`](CITATION.cff).

> Romi, S. (2026). *PACE Controller* (Version {version})
> [Computer software]. Zenodo. {doi_url}

DOI: [**{doi}**]({doi_url})
"""
README.write_text(
replace_section(readme, "## Citation", "## License and independence", citation),
encoding="utf-8",
)

cff = CITATION.read_text(encoding="utf-8")
cff = re.sub(r"^doi:\s*.*\n", "", cff, flags=re.M)
cff = re.sub(r'^version:\s*.*$', f'version: "{version}"', cff, flags=re.M)
cff = re.sub(r'^url:\s*.*$', f'url: "{doi_url}"', cff, flags=re.M)
lines = cff.splitlines()
repository_index = next(
(index + 1 for index, line in enumerate(lines) if line.startswith("repository-code:")),
None,
)
if repository_index is None:
raise SystemExit("Could not find repository-code in CITATION.cff")
lines.insert(repository_index, f'doi: "{doi}"')
CITATION.write_text("\n".join(lines) + "\n", encoding="utf-8")


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--version")
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
version = args.version or current_version()
try:
doi = find_doi(version)
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
raise SystemExit(3) from exc
if not doi:
print(f"Zenodo DOI for v{version} not found yet.", file=sys.stderr)
raise SystemExit(2)
if args.apply:
apply_metadata(version, doi)
print(doi)


if __name__ == "__main__":
main()
13 changes: 13 additions & 0 deletions .github/workflows/cross-platform-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ on:

permissions:
contents: write
actions: write

concurrency:
group: pace-controller-cross-platform-release
Expand Down Expand Up @@ -294,10 +295,22 @@ jobs:
- frozen legacy PowerShell/WinForms v0.3.1 retained unchanged.

**Safety:** validate the software at low pressure on an unloaded setup. Software interlocks do not replace hardware pressure protections or operator supervision.

**Zenodo DOI:** pending archival or repository integration.
EOF
gh release create "v${VERSION}" release-assets/* \
--repo "$GITHUB_REPOSITORY" \
--target "${{ needs.prepare.outputs.commit_sha }}" \
--title "PACE Controller v${VERSION}" \
--notes-file /tmp/release-notes.md \
--verify-tag

- name: Dispatch Zenodo DOI sync
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
gh workflow run sync-zenodo-doi.yml \
--repo "$GITHUB_REPOSITORY" \
--ref main \
-f "version=${VERSION}"
Loading