From daf86039b9934d7f4987a6d8790500efb914f7ac Mon Sep 17 00:00:00 2001 From: josh Date: Mon, 29 Jun 2026 11:42:26 +0800 Subject: [PATCH] ci: add tag-driven PyPI release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .github/workflows/release.yml fires on `v*` tags and runs a 4-job pipeline: offline test gate → build sdist+wheel → publish to PyPI → attach artefacts to a GitHub Release. - PyPI upload uses OIDC trusted publishing (pypa/gh-action-pypi-publish) bound to a `pypi` GitHub Environment — no long-lived token in repo secrets. The Environment can carry Required reviewers, so each release gates on a human approval click before the upload step runs. - Build job rejects any tag whose name doesn't match the version in pyproject.toml, so accidental "tagged v3.4.1 but forgot to bump" is caught before anything ships. - README documents the release flow and the one-time PyPI Trusted Publisher + GitHub Environment setup the workflow expects. --- .github/workflows/release.yml | 95 +++++++++++++++++++++++++++++++++++ README.md | 36 +++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6bd266b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,95 @@ +name: Release + +# Triggered by pushing a tag like `v3.4.1`. See "Releasing" in README for the +# end-to-end flow and the one-time PyPI / GitHub setup this workflow expects. +on: + push: + tags: ["v*"] + +# Don't cancel an in-flight publish — that could leave PyPI in a half-uploaded +# state. We only need to prevent two concurrent attempts for the same tag. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + test: + name: Offline tests (gate) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + - run: make install + - run: make ci + + build: + name: Build sdist + wheel + needs: test + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Verify tag matches pyproject.toml version + run: | + tag="${GITHUB_REF#refs/tags/v}" + pkg=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + if [ "$tag" != "$pkg" ]; then + echo "::error::Tag v$tag does not match pyproject.toml version $pkg" + exit 1 + fi + echo "Tag and pyproject version match: $tag" + + - run: python -m pip install --upgrade build + - run: python -m build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + if-no-files-found: error + + publish-pypi: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + # The `pypi` environment is what PyPI's Trusted Publisher config is bound + # to. Add Required reviewers to this environment in repo Settings to gate + # each release on a human approval. + environment: + name: pypi + url: https://pypi.org/project/apexomni/ + permissions: + id-token: write # mandatory for OIDC trusted publishing — no token needed + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: Create GitHub Release + needs: publish-pypi + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write # to create the GitHub Release + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: softprops/action-gh-release@v2 + with: + files: dist/* + generate_release_notes: true + fail_on_unmatched_files: true diff --git a/README.md b/README.md index 57a5781..e33ebe5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,42 @@ machine will automatically run `make test` first and refuse to push if anything fails. CI runs the exact same `make test` target on every PR — keeping the local and remote gates identical. +### Releasing + +Releases publish to PyPI automatically when a `v*` tag is pushed. The +release workflow (`.github/workflows/release.yml`) reruns the test gate, +builds the wheel + sdist, publishes to PyPI via OIDC trusted publishing +(no long-lived token), and creates a GitHub Release with the artifacts. + +To cut a release from `main`: + +```bash +# 1. Bump version in pyproject.toml, open + merge a PR. +# 2. From the merged main commit: +git checkout main && git pull +git tag v3.4.1 +git push origin v3.4.1 +``` + +The workflow refuses to publish if the tag does not match +`pyproject.toml`'s `version`, and (if configured) waits on a Required +reviewer for the `pypi` GitHub Environment before uploading. + +#### One-time setup (per project, per release target) + +1. **PyPI** — go to https://pypi.org/manage/project/apexomni/settings/publishing/ + and add a Trusted Publisher with: + - Owner: `ApeX-Protocol` + - Repository: `apexpro-openapi` + - Workflow filename: `release.yml` + - Environment name: `pypi` + +2. **GitHub** — Settings → Environments → New environment named `pypi`: + - (Recommended) Add Required reviewers — every release waits for an + approval click before the PyPI upload step runs. + - (Recommended) Restrict deployment branches to `main` so a tag from + elsewhere can't trigger a release. + ## Installation `apex omni` supports Python 3.9 through 3.12. The module can be installed manually or via [apexomni](https://pypi.org/project/apexomni/) with `pip`: ```