Skip to content

Commit 64ac47b

Browse files
committed
Set up CI/CD with auto semantic versioning and PyPI publish
1 parent 72c8a85 commit 64ac47b

File tree

15 files changed

+423
-148
lines changed

15 files changed

+423
-148
lines changed

.github/scripts/bump_version.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import os
2+
import re
3+
import subprocess
4+
from pathlib import Path
5+
6+
7+
def run(*args: str) -> str:
8+
return subprocess.check_output(args, text=True).strip()
9+
10+
11+
def write_output(key: str, value: str) -> None:
12+
output_path = os.getenv("GITHUB_OUTPUT")
13+
if not output_path:
14+
return
15+
with open(output_path, "a", encoding="utf-8") as fh:
16+
fh.write(f"{key}={value}\n")
17+
18+
19+
def latest_version_tag() -> str | None:
20+
tags = run("git", "tag", "--list", "v*", "--sort=-v:refname").splitlines()
21+
for tag in tags:
22+
if re.fullmatch(r"v\d+\.\d+\.\d+", tag):
23+
return tag
24+
return None
25+
26+
27+
def read_setup_version() -> str:
28+
setup_text = Path("setup.py").read_text(encoding="utf-8")
29+
match = re.search(r"version\s*=\s*['\"](\d+\.\d+\.\d+)['\"]", setup_text)
30+
if not match:
31+
raise RuntimeError("Could not parse current version from setup.py")
32+
return match.group(1)
33+
34+
35+
def commit_log_since(tag: str | None) -> str:
36+
if tag:
37+
return run("git", "log", "--format=%s%n%b", f"{tag}..HEAD")
38+
return run("git", "log", "--format=%s%n%b")
39+
40+
41+
def commits_since(tag: str | None) -> int:
42+
if tag:
43+
return int(run("git", "rev-list", "--count", f"{tag}..HEAD"))
44+
return int(run("git", "rev-list", "--count", "HEAD"))
45+
46+
47+
def determine_bump(log_text: str) -> str:
48+
if re.search(r"BREAKING CHANGE|!:", log_text, re.IGNORECASE):
49+
return "major"
50+
if re.search(r"(?mi)^feat(\(.+\))?:", log_text):
51+
return "minor"
52+
return "patch"
53+
54+
55+
def bump_version(current: str, bump: str) -> str:
56+
major, minor, patch = [int(part) for part in current.split(".")]
57+
if bump == "major":
58+
return f"{major + 1}.0.0"
59+
if bump == "minor":
60+
return f"{major}.{minor + 1}.0"
61+
return f"{major}.{minor}.{patch + 1}"
62+
63+
64+
def update_setup_py(new_version: str) -> bool:
65+
setup_path = Path("setup.py")
66+
setup_text = setup_path.read_text(encoding="utf-8")
67+
new_text, replacements = re.subn(
68+
r"version\s*=\s*['\"]\d+\.\d+\.\d+['\"]",
69+
f"version='{new_version}'",
70+
setup_text,
71+
count=1,
72+
)
73+
if replacements == 0:
74+
raise RuntimeError("Could not find version assignment in setup.py")
75+
if new_text == setup_text:
76+
return False
77+
setup_path.write_text(new_text, encoding="utf-8")
78+
return True
79+
80+
81+
def main() -> None:
82+
tag = latest_version_tag()
83+
current_version = tag[1:] if tag else read_setup_version()
84+
85+
if commits_since(tag) == 0:
86+
write_output("released", "false")
87+
return
88+
89+
bump_kind = determine_bump(commit_log_since(tag)) if tag else "patch"
90+
next_version = bump_version(current_version, bump_kind)
91+
changed = update_setup_py(next_version)
92+
93+
write_output("version", next_version)
94+
write_output("released", "true" if changed else "false")
95+
print(f"Bumped {current_version} -> {next_version} ({bump_kind})")
96+
97+
98+
if __name__ == "__main__":
99+
main()

.github/workflows/ci.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: CI
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- main
7+
push:
8+
branches:
9+
- main
10+
11+
jobs:
12+
test:
13+
runs-on: ubuntu-latest
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
python-version: ["3.10", "3.11", "3.12"]
18+
19+
steps:
20+
- name: Checkout code
21+
uses: actions/checkout@v4
22+
23+
- name: Set up Python
24+
uses: actions/setup-python@v5
25+
with:
26+
python-version: ${{ matrix.python-version }}
27+
28+
- name: Install dependencies
29+
run: |
30+
python -m pip install --upgrade pip
31+
python -m pip install -e .
32+
python -m pip install pytest
33+
34+
- name: Run tests
35+
run: pytest -q tests

.github/workflows/publish.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*.*.*"
7+
workflow_dispatch:
8+
9+
jobs:
10+
publish:
11+
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
12+
runs-on: ubuntu-latest
13+
permissions:
14+
contents: read
15+
16+
steps:
17+
- name: Checkout code
18+
uses: actions/checkout@v4
19+
20+
- name: Set up Python
21+
uses: actions/setup-python@v5
22+
with:
23+
python-version: "3.12"
24+
25+
- name: Install build tooling
26+
run: |
27+
python -m pip install --upgrade pip
28+
python -m pip install -e .
29+
python -m pip install build twine pytest
30+
31+
- name: Run tests
32+
run: pytest -q tests
33+
34+
- name: Build distribution
35+
run: python -m build
36+
37+
- name: Check PyPI token
38+
if: ${{ secrets.PYPI_API_TOKEN == '' }}
39+
run: |
40+
echo "Missing required secret: PYPI_API_TOKEN"
41+
exit 1
42+
43+
- name: Publish to PyPI
44+
env:
45+
TWINE_USERNAME: __token__
46+
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
47+
run: python -m twine upload --skip-existing dist/*

.github/workflows/release.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Auto Version Release
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
8+
permissions:
9+
contents: write
10+
11+
concurrency:
12+
group: release-${{ github.ref }}
13+
cancel-in-progress: false
14+
15+
jobs:
16+
release:
17+
if: github.actor != 'github-actions[bot]' && !contains(github.event.head_commit.message, 'chore(release):')
18+
runs-on: ubuntu-latest
19+
20+
steps:
21+
- name: Checkout code
22+
uses: actions/checkout@v4
23+
with:
24+
fetch-depth: 0
25+
26+
- name: Set up Python
27+
uses: actions/setup-python@v5
28+
with:
29+
python-version: "3.12"
30+
31+
- name: Install dependencies
32+
run: |
33+
python -m pip install --upgrade pip
34+
python -m pip install -e .
35+
python -m pip install pytest
36+
37+
- name: Run tests
38+
run: pytest -q tests
39+
40+
- name: Bump version
41+
id: bump
42+
run: python .github/scripts/bump_version.py
43+
44+
- name: Commit version and create tag
45+
if: steps.bump.outputs.released == 'true'
46+
env:
47+
NEW_VERSION: ${{ steps.bump.outputs.version }}
48+
run: |
49+
git config user.name "github-actions[bot]"
50+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
51+
git add setup.py
52+
git commit -m "chore(release): v${NEW_VERSION} [skip ci]"
53+
git tag "v${NEW_VERSION}"
54+
git push origin HEAD:main --follow-tags

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
.venv
22
venv/
33
__pycache__
4+
.pytest_cache/
5+
build/
6+
dist/
7+
*.egg-info/
8+
pytest-cache-files-*/

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,23 @@ ladder.run(visualize=True)
102102

103103
We welcome contributions to add new features, fix bugs, and improve documentation. To contribute, fork the repository, make your changes, and open a pull request.
104104

105+
## CI/CD (Auto Publish to PyPI)
106+
107+
This repository is configured with GitHub Actions:
108+
- `CI`: runs tests on pull requests and pushes to `main`.
109+
- `Auto Version Release`: on each merge/push to `main`, it auto-bumps version in `setup.py`, creates a git tag (for example `v0.1.1`), and pushes it.
110+
- `Publish to PyPI`: builds and uploads the package when a release tag is pushed.
111+
112+
One-time setup required:
113+
1. Create a PyPI API token (`__token__`) for your PyPI project.
114+
2. In GitHub: `Settings -> Secrets and variables -> Actions`, add a repository secret named `PYPI_API_TOKEN`.
115+
3. Use conventional commit messages in merged commits/PR titles for semantic bumps:
116+
- `feat:` -> minor bump
117+
- `fix:` (or anything else) -> patch bump
118+
- `BREAKING CHANGE` or `feat!:` / `fix!:` -> major bump
119+
120+
If the version already exists on PyPI, publish is skipped safely.
121+
105122
## License
106123

107124
This project is licensed under the MIT License.

pyladdersim/components.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,20 @@ def __init__(self, name, delay):
6262
self.ET = 0 # Elapsed Time in seconds
6363
self.Q = False # Q (done output)
6464

65+
@property
66+
def state(self):
67+
"""Compatibility alias used by existing examples/tests."""
68+
return self.Q
69+
6570
def reset(self):
6671
"""Resets the timer's internal state."""
6772
self.ET = 0
6873
self.Q = False
6974

75+
def update(self, IN):
76+
"""Compatibility alias that mirrors evaluate(IN)."""
77+
return self.evaluate(IN)
78+
7079
def evaluate(self, IN):
7180
"""This method should be overridden in subclasses."""
7281
pass

0 commit comments

Comments
 (0)