From d94dc19c15cb2fb232930ee820e734346050e6a3 Mon Sep 17 00:00:00 2001 From: Bonajo Date: Mon, 21 Sep 2026 11:10:56 +0200 Subject: [PATCH] feat: automate releases with semantic-release and publish to PyPI --- .github/workflows/pr-title.yml | 39 ++++++++++++++++++ .github/workflows/pypi-publish.yml | 45 ++++++++++++++++++++ .github/workflows/release.yml | 47 +++++++++++---------- .github/workflows/semantic-release.yml | 57 ++++++++++++++++++++++++++ .releaserc.json | 9 ++++ README.md | 14 ++++++- 6 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/pr-title.yml create mode 100644 .github/workflows/pypi-publish.yml create mode 100644 .github/workflows/semantic-release.yml create mode 100644 .releaserc.json diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..f833e6f --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,39 @@ +name: PR title + +# With squash merging the PR title becomes the commit subject on main, which is +# what semantic-release parses to pick the next version. An unparseable title +# is therefore a release that silently does not happen. +on: + pull_request: + branches: [main] + types: [opened, edited, synchronize, reopened] + +concurrency: + group: pr-title-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + pull-requests: read + +jobs: + lint: + name: conventional title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # The types already in use on main, plus revert. + types: | + feat + fix + docs + refactor + test + ci + style + chore + perf + build + revert diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml new file mode 100644 index 0000000..a335da3 --- /dev/null +++ b/.github/workflows/pypi-publish.yml @@ -0,0 +1,45 @@ +name: PyPI publish + +# Reusable build and publish. Two callers pass a version in: +# - release.yml on a human-pushed v* tag +# - semantic-release.yml after semantic-release cuts a new version +# +# It lives here rather than in either caller because a tag pushed with +# GITHUB_TOKEN does not trigger workflows, so semantic-release cannot rely on +# release.yml's tag trigger to publish the version it just cut. +on: + workflow_call: + inputs: + version: + description: Version to build and publish, without the leading v (e.g. 1.2.3). + required: true + type: string + +permissions: + contents: read + +jobs: + publish: + name: build & publish + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v10.1.0 + with: + python-version: "3.12" + + # The version is not committed back to main, so the version in + # pyproject.toml is stale. Set the released version just for this build. + - name: Set version + run: uv version "${{ inputs.version }}" --frozen + + - name: Build + run: uv build + + - name: Publish + run: uv publish + env: + UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a685e7..abe9e5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,28 +1,33 @@ -name: Release CodeStripper to PyPi +name: Release +# Fires only on tags pushed by a human: a manual tag cut as an escape hatch. +# Routine releases come from semantic-release.yml, whose GITHUB_TOKEN-pushed +# tags deliberately do not trigger this workflow — that is what keeps a +# release from being published twice. on: push: - branches: - - 'releases/**' - tags: - - 'v*' + tags: ["v*"] + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read jobs: - release: - strategy: - matrix: - python-version: ["3.12"] + version: + name: version from tag runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@v4 - - name: Install uv - uses: astral-sh/setup-uv@v10.1.0 - with: - python-version: ${{ matrix.python-version }} - enable-cache: true - - name: Build - run: uv build - - name: Publish - run: uv publish - env: - UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} + - id: version + run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + publish: + needs: version + uses: ./.github/workflows/pypi-publish.yml + with: + version: ${{ needs.version.outputs.version }} + secrets: inherit diff --git a/.github/workflows/semantic-release.yml b/.github/workflows/semantic-release.yml new file mode 100644 index 0000000..d15daf0 --- /dev/null +++ b/.github/workflows/semantic-release.yml @@ -0,0 +1,57 @@ +name: Semantic release + +on: + push: + branches: [main] + +# Never cancel a release mid-flight: a half-run that has tagged but not yet +# published would need cleaning up by hand. +concurrency: + group: semantic-release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + release: + name: version + runs-on: ubuntu-latest + permissions: + contents: write # create the tag and the GitHub release + issues: write # comment "released in vX.Y.Z" on issues + pull-requests: write # and on the PRs that fed the release + outputs: + published: ${{ steps.semantic.outputs.new_release_published }} + version: ${{ steps.semantic.outputs.new_release_version }} + + steps: + - uses: actions/checkout@v4 + with: + # semantic-release walks back to the previous tag to decide the bump, + # so a shallow clone makes it compute the wrong version. + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + # Pinned to an exact version: this action publishes no floating major + # tags, so @v6 would not resolve. All three plugins in .releaserc.json + # ship with the semantic-release core it bundles. + - name: Semantic release + id: semantic + uses: cycjimmy/semantic-release-action@v6.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # The tag above was pushed with GITHUB_TOKEN, so it will not trigger + # release.yml. Publish to PyPI here instead. + publish: + needs: release + if: needs.release.outputs.published == 'true' + uses: ./.github/workflows/pypi-publish.yml + with: + version: ${{ needs.release.outputs.version }} + secrets: inherit diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..fea7fae --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,9 @@ +{ + "branches": ["main"], + "tagFormat": "v${version}", + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/github" + ] +} diff --git a/README.md b/README.md index 0093316..3fa706f 100644 --- a/README.md +++ b/README.md @@ -282,4 +282,16 @@ default_tags: Set[Type[SingleTag]] = { TestCloseTag } ``` -> :warning: **Only the `SingleTag`(s) (including `RangeOpenTag` and `RangeCloseTag`) need to be added, not the `RangeTag`** \ No newline at end of file +> :warning: **Only the `SingleTag`(s) (including `RangeOpenTag` and `RangeCloseTag`) need to be added, not the `RangeTag`** +## Releases + +Releases are automated with [semantic-release](https://semantic-release.gitbook.io/). Pull requests are squash merged, so the PR title becomes the commit on `main` and must follow [Conventional Commits](https://www.conventionalcommits.org/) (checked on every PR): + +| PR title | Release | +|----------|---------| +| `fix: ...` | patch (1.2.3 → 1.2.4) | +| `feat: ...` | minor (1.2.3 → 1.3.0) | +| `feat!: ...` or a `BREAKING CHANGE:` footer | major (1.2.3 → 2.0.0) | +| `docs:`, `chore:`, `ci:`, `build:`, `refactor:`, `test:`, `style:`, `perf:` | no release | + +On every merge to `main` the next version is determined, tagged (`vX.Y.Z`), a GitHub release is created and the package is published to PyPI. The version is set during the build and is not committed, so the version in `pyproject.toml` is not the released version.