From 83df045d613adb2b088641b30dadc6b7aeeb8430 Mon Sep 17 00:00:00 2001 From: Latiff Danieyal Date: Fri, 24 Jul 2026 17:09:02 +0800 Subject: [PATCH 1/3] add publishing ci --- .github/workflows/release.yml | 95 +++++++++++++++++++++++++++++++++++ 1 file changed, 95 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..9010fe5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,95 @@ +name: Release + +# Publishes to PyPI via Trusted Publishing (OIDC) +# Two paths: +# +# * Push a tag `vX.Y.Z` -> build, verify, publish to PyPI. +# * Run manually (workflow_dispatch) -> build, verify, publish to TestPyPI, +# for a dry run of the whole pipeline against a throwaway index. + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build and verify distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + # Guard against the one mistake tags make: a vX.Y.Z tag whose number does + # not match pyproject. PyPI would publish whatever pyproject says, under a + # version the tag does not describe -- and it could never be corrected. + - name: Tag matches package version + if: startsWith(github.ref, 'refs/tags/v') + run: | + tag="${GITHUB_REF_NAME#v}" + pkg="$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")" + echo "tag=$tag pyproject=$pkg" + if [ "$tag" != "$pkg" ]; then + echo "::error::tag v$tag does not match pyproject version $pkg" + exit 1 + fi + + - name: Build sdist and wheel + run: uv build + + # Same guard CI runs on every PR: required data ships, no test fixtures + # or signing material leak. Doubly worth it here -- this build is the one + # that becomes an unremovable public artifact. + - name: Verify distribution contents + run: uv run --no-project python scripts/check_dist.py + + - uses: actions/upload-artifact@v4 + with: + name: distributions + path: dist/ + + publish-testpypi: + name: Publish to TestPyPI (dry run) + if: github.event_name == 'workflow_dispatch' + needs: build + runs-on: ubuntu-latest + environment: testpypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: distributions + path: dist/ + + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + publish-pypi: + name: Publish to PyPI + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: distributions + path: dist/ + + - name: Publish + uses: pypa/gh-action-pypi-publish@release/v1 From 56337d6d29bbe6fa93e58bb2fb51487d0a536e2e Mon Sep 17 00:00:00 2001 From: Latiff Danieyal Date: Fri, 24 Jul 2026 17:20:17 +0800 Subject: [PATCH 2/3] ci: add PyPI release workflow (Trusted Publishing), and address review Adds .github/workflows/release.yml. Publishes via Trusted Publishing (OIDC) -- no API token stored anywhere. A vX.Y.Z tag publishes to PyPI; a manual workflow_dispatch publishes to TestPyPI for a dry run. Both still require a pending publisher registered on the target index (a one-time web step). Guards, because a published version can never be reused or deleted: - Tag-vs-version: refuses to publish if the git tag does not match the pyproject version, so a mistyped tag cannot ship under the wrong number. - Distribution contents: reuses scripts/check_dist.py (required data ships, no test fixtures or signing material leak) on the exact artifact published. All three review findings were valid and are applied: 1. No build cache on the artifact-producing job. Removed enable-cache from its setup-uv step: the wheel that becomes an unremovable public artifact is built from a clean environment, not a restored cache. 2. Full validation before publish. A tag can point at any commit, including one that never passed CI, so publishing is now gated on the complete PR gate -- lint, type-check, the 3.11-3.13 test matrix, and the packaging check. Implemented by making ci.yml reusable (workflow_call) and calling it, rather than duplicating the jobs, so the release gate cannot drift from the everyday one and inherits UV_LOCKED=1 for free. workflow_call adds no automatic runs of its own. 3. Actions pinned to full commit SHAs, version in a comment. This graph carries id-token: write, so a moving tag on a compromised action could exfiltrate the OIDC token or tamper with the artifact. Limited to release.yml as requested; ci.yml action refs untouched. Validated: both workflows pass actionlint (exit 0); the reusable reference resolves; the release build uploads `release-dist`, distinct from ci's `distributions`, so there is no artifact-name collision in a release run. Nothing publishes until a pending publisher is configured AND a tag is pushed; merging this is safe. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 4 +++ .github/workflows/release.yml | 51 ++++++++++++++++++++++++----------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cec919..96a722c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [master] pull_request: workflow_dispatch: + # Lets the release workflow run this exact gate (lint + test matrix + + # package) before publishing, instead of duplicating it. workflow_call adds + # no automatic runs of its own. + workflow_call: concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9010fe5..205edf8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,9 +3,17 @@ name: Release # Publishes to PyPI via Trusted Publishing (OIDC) # Two paths: # -# * Push a tag `vX.Y.Z` -> build, verify, publish to PyPI. -# * Run manually (workflow_dispatch) -> build, verify, publish to TestPyPI, -# for a dry run of the whole pipeline against a throwaway index. +# * Push a tag `vX.Y.Z` -> validate, build, verify, publish to PyPI. +# * Run manually (workflow_dispatch) -> validate, build, verify, publish to +# TestPyPI, for a dry run of the whole pipeline against a throwaway index. +# +# A pending publisher must be registered on the target index (PyPI and/or +# TestPyPI) pointing at this repo + release.yml + the matching environment. +# That is a one-time web step and cannot be done here. +# +# Third-party actions are pinned to full commit SHAs (version in a comment). +# This job graph carries `id-token: write`, so a moving tag on a compromised +# action could exfiltrate the OIDC token or tamper with the artifact. on: push: @@ -17,18 +25,29 @@ permissions: contents: read jobs: + # Run the full PR gate -- lint, type-check, the 3-version test matrix, and + # the packaging check -- before anything is published. Publishing is + # irreversible per version, and a tag can point at any commit, including one + # that never went through CI. Reused from ci.yml rather than duplicated, so + # the release gate cannot drift from the everyday one. + checks: + uses: ./.github/workflows/ci.yml + permissions: + contents: read + build: name: Build and verify distributions + needs: checks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true + # No build cache: this build becomes an unremovable public artifact, so + # it is produced from a clean environment rather than a restored cache. + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 # Guard against the one mistake tags make: a vX.Y.Z tag whose number does # not match pyproject. PyPI would publish whatever pyproject says, under a @@ -53,9 +72,9 @@ jobs: - name: Verify distribution contents run: uv run --no-project python scripts/check_dist.py - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: distributions + name: release-dist path: dist/ publish-testpypi: @@ -65,15 +84,15 @@ jobs: runs-on: ubuntu-latest environment: testpypi permissions: - id-token: write + id-token: write # OIDC token for Trusted Publishing; no stored secret. steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: distributions + name: release-dist path: dist/ - name: Publish - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1 with: repository-url: https://test.pypi.org/legacy/ @@ -86,10 +105,10 @@ jobs: permissions: id-token: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: distributions + name: release-dist path: dist/ - name: Publish - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # release/v1 From e2b09e3e4854e2c8d6fa8a6c7316ed50e91f5022 Mon Sep 17 00:00:00 2001 From: Latiff Danieyal Date: Fri, 24 Jul 2026 17:22:51 +0800 Subject: [PATCH 3/3] ci: pin ci.yml action SHAs to match release.yml Extends the SHA pinning to ci.yml. The reviewer scoped it to release.yml because that is the graph with id-token: write, but ci.yml is now a reusable workflow the release path calls, so an unpinned action there is reachable from the same publish run. Pinning both keeps the whole tree consistent and the supply-chain surface uniform. Same three actions, same SHAs already used in release.yml (resolved from the current v4/v5 tags), version kept in an adjacent comment: - actions/checkout 11d5960 # v4 - astral-sh/setup-uv d4b2f3b # v5 - actions/upload-artifact ea165f8 # v4 actionlint clean on both workflows; no @vN refs remain outside comments. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96a722c..970bee0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,13 +31,13 @@ jobs: name: Lint and type-check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: # Don't leave GITHUB_TOKEN in .git/config for later steps to reach. persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: enable-cache: true @@ -73,13 +73,13 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: # Don't leave GITHUB_TOKEN in .git/config for later steps to reach. persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: enable-cache: true python-version: ${{ matrix.python-version }} @@ -97,13 +97,13 @@ jobs: name: Build and verify distributions runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: # Don't leave GITHUB_TOKEN in .git/config for later steps to reach. persist-credentials: false - name: Install uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: enable-cache: true @@ -120,7 +120,7 @@ jobs: - name: Verify distribution contents run: uv run --no-project python scripts/check_dist.py - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: distributions path: dist/