diff --git a/.github/workflows/ci-stable.yml b/.github/workflows/ci-stable.yml index 3213470..97f180a 100644 --- a/.github/workflows/ci-stable.yml +++ b/.github/workflows/ci-stable.yml @@ -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" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..fa59aa4 --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 1299634..8fd3887 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 --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 --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