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
194 changes: 0 additions & 194 deletions .github/workflows/ci-stable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,197 +58,3 @@ jobs:
with:
name: coverage-xml
path: coverage.xml

publish:
needs: pytest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install build tools
run: |
python -m pip install --upgrade pip
pip install build twine
- name: Bump patch version in stable.toml and dev.toml
id: version
run: |
python - <<'PY'
import os
import pathlib
import re

def bump(path: pathlib.Path) -> str:
text = path.read_text(encoding="utf-8")
match = re.search(r'^version = "(\d+)\.(\d+)\.(\d+)"', text, re.MULTILINE)
if match is None:
raise SystemExit(f"no version line found in {path}")
major, minor, patch = (int(g) for g in match.groups())
new = f"{major}.{minor}.{patch + 1}"
path.write_text(text.replace(match.group(0), f'version = "{new}"', 1), encoding="utf-8")
return new

stable_version = bump(pathlib.Path("stable.toml"))
dev_version = bump(pathlib.Path("dev.toml"))
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fp:
fp.write(f"version={stable_version}\n")
fp.write(f"dev_version={dev_version}\n")
print(f"stable.toml -> {stable_version}")
print(f"dev.toml -> {dev_version}")
PY
- name: Push signed bump commit to a release branch
id: bump_commit
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
run: |
python - <<'PY'
import base64
import json
import os
import subprocess
import urllib.error
import urllib.request

head_oid = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
repo = os.environ["GITHUB_REPOSITORY"]
token = os.environ["GH_TOKEN"]
version = os.environ["VERSION"]
branch = f"release/bump-v{version}"

def api(path, method="GET", body=None):
req = urllib.request.Request(
f"https://api.github.com/{path}",
data=(json.dumps(body).encode("utf-8") if body is not None else None),
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"Content-Type": "application/json",
},
method=method,
)
with urllib.request.urlopen(req) as resp:
raw = resp.read()
return json.loads(raw) if raw else None

# Delete any pre-existing release branch (from a prior partial run)
# so the bump always starts from main's HEAD. Avoids STALE_DATA from
# createCommitOnBranch and prevents stacking multiple bump commits.
# Any open PR on the stale branch gets auto-closed when the branch
# is deleted, which is fine — we always open a fresh PR below.
try:
api(f"repos/{repo}/git/refs/heads/{branch}", method="DELETE")
print(f"deleted stale branch: {branch}")
except urllib.error.HTTPError as error:
if error.code not in (404, 422): # ref doesn't exist; nothing to clean
raise

api(
f"repos/{repo}/git/refs",
method="POST",
body={"ref": f"refs/heads/{branch}", "sha": head_oid},
)
expected_oid = head_oid

def b64(path: str) -> str:
with open(path, "rb") as fp:
return base64.b64encode(fp.read()).decode("ascii")

mutation = """
mutation($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) {
commit { oid url }
}
}
"""

payload = {
"query": mutation,
"variables": {
"input": {
"branch": {
"repositoryNameWithOwner": repo,
"branchName": branch,
},
"message": {
"headline": f"Bump version to v{version} [skip ci]",
},
"expectedHeadOid": expected_oid,
"fileChanges": {
"additions": [
{"path": "stable.toml", "contents": b64("stable.toml")},
{"path": "dev.toml", "contents": b64("dev.toml")},
]
},
}
},
}

request = urllib.request.Request(
"https://api.github.com/graphql",
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request) as resp:
body = json.loads(resp.read())
if body.get("errors"):
raise SystemExit(f"GraphQL error: {body['errors']}")
commit = body["data"]["createCommitOnBranch"]["commit"]
print(f"bump commit: {commit['oid']} -> {commit['url']}")

with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fp:
fp.write(f"branch={branch}\n")
fp.write(f"oid={commit['oid']}\n")
PY
- name: Open PR for the version bump
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
BRANCH: ${{ steps.bump_commit.outputs.branch }}
run: |
gh pr create \
--base main \
--head "$BRANCH" \
--title "Bump version to v${VERSION} [skip ci]" \
--body "Automated patch bump emitted by the stable publish workflow. [skip ci]"
- name: Try to auto-merge the bump PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH: ${{ steps.bump_commit.outputs.branch }}
run: |
gh pr merge "$BRANCH" --squash --auto --delete-branch \
|| echo "auto-merge unavailable; PR left open for manual merge"
- name: Use stable.toml as pyproject.toml
run: cp stable.toml pyproject.toml
- name: Build sdist and wheel
run: python -m build
- name: Twine check
run: twine check dist/*
- name: Twine upload to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: twine upload --non-interactive dist/*
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUMP_OID: ${{ steps.bump_commit.outputs.oid }}
run: |
gh release create "v${{ steps.version.outputs.version }}" dist/* \
--title "v${{ steps.version.outputs.version }}" \
--generate-notes \
--target "$BUMP_OID"
96 changes: 96 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: Publish to PyPI

on:
push:
branches: ["main"]

permissions:
contents: write

concurrency:
group: publish-pypi
cancel-in-progress: false

jobs:
publish:
runs-on: ubuntu-latest
if: "!contains(github.event.head_commit.message, 'chore: bump version')"
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install build tools
run: |
python -m pip install --upgrade pip
pip install build twine

- name: Bump patch version in stable.toml and dev.toml
id: bump
run: |
python <<'EOF'
import os
import pathlib
import re

def bump(path: pathlib.Path) -> str:
text = path.read_text(encoding="utf-8")
match = re.search(r'^version\s*=\s*"(\d+)\.(\d+)\.(\d+)"', text, re.MULTILINE)
if not match:
raise SystemExit(f"Could not find version in {path}")
major, minor, patch = (int(part) for part in match.groups())
new_version = f"{major}.{minor}.{patch + 1}"
new_text = re.sub(
r'^(version\s*=\s*)"\d+\.\d+\.\d+"',
rf'\1"{new_version}"',
text,
count=1,
flags=re.MULTILINE,
)
path.write_text(new_text, encoding="utf-8")
return new_version

stable_version = bump(pathlib.Path("stable.toml"))
dev_version = bump(pathlib.Path("dev.toml"))
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
output.write(f"new_version={stable_version}\n")
output.write(f"dev_version={dev_version}\n")
print(f"stable.toml -> {stable_version}")
print(f"dev.toml -> {dev_version}")
EOF

- name: Use stable.toml as pyproject.toml
run: cp stable.toml pyproject.toml

- name: Build distribution
run: python -m build

- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: twine upload --non-interactive dist/*

- name: Commit and tag version bump
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add stable.toml dev.toml
git commit -m "chore: bump version to ${{ steps.bump.outputs.new_version }}"
git tag "v${{ steps.bump.outputs.new_version }}"
git push origin main
git push origin "v${{ steps.bump.outputs.new_version }}"

- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "v${{ steps.bump.outputs.new_version }}" \
dist/* \
--title "v${{ steps.bump.outputs.new_version }}" \
--generate-notes
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,10 @@ automation_file/
- `main` branch: stable releases, publishes `automation_file` to PyPI (version in `stable.toml`).
- `dev` branch: development, publishes `automation_file_dev` to PyPI (version in `dev.toml`).
- Keep `dependencies` and `[project.optional-dependencies]` (`dev`) in sync across both TOMLs. Backends (`boto3`, `azure-storage-blob`, `dropbox`, `paramiko`) and `PySide6` are first-class runtime deps — do not move them back under extras.
- **Version bumping is automatic.** The stable publish job bumps the patch in both `stable.toml` and `dev.toml`, commits the bump back to `main` with `[skip ci]`, then builds and releases. Do not hand-bump before merging to `main`.
- **Version bumping is automatic.** A dedicated publish workflow bumps the patch in both `stable.toml` and `dev.toml`, builds, uploads to PyPI, then commits the bump back to `main` tagged as `vX.Y.Z`. Do not hand-bump before merging to `main`. The next publish run is skipped via a commit-message guard (`chore: bump version`), so the bump itself never re-triggers publishing.
- CI: GitHub Actions (Windows, Python 3.10 / 3.11 / 3.12) — one matrix workflow per branch: `.github/workflows/ci-dev.yml`, `.github/workflows/ci-stable.yml`.
- CI steps: `lint` (ruff check + ruff format --check + mypy) → `pytest` with coverage → uploads `coverage.xml` as an artifact.
- Stable branch additionally runs a `publish` job on push to `main`: auto-bumps the patch in both TOMLs and commits back, then builds the sdist + wheel, `twine check`, `twine upload` using `PYPI_API_TOKEN`, then `gh release create v<version> --generate-notes`.
- Publishing lives in a separate workflow (`.github/workflows/publish.yml`) that runs on push to `main`: bumps both TOMLs, copies `stable.toml` to `pyproject.toml`, builds the sdist + wheel, `twine upload` via `PYPI_API_TOKEN`, then commits + tags + pushes and creates `gh release create v<version> --generate-notes`.
- `pre-commit` is configured (`.pre-commit-config.yaml`): trailing-whitespace, eof-fixer, check-yaml, check-toml, check-added-large-files, ruff, ruff-format, mypy. Install with `pre-commit install` after cloning.

## Development
Expand Down
Loading