diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0c10011 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + # Keep GitHub Actions pinned to commit SHAs (supply-chain hardening) while + # still receiving updates: Dependabot opens a PR whenever a pinned action has + # a newer release, bumping the SHA and the trailing "# vX.Y.Z" comment. + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + groups: + # One consolidated PR per week instead of one per action. + github-actions: + patterns: + - "*" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ad6170d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,131 @@ +name: Release + +# Two-stage trusted-publishing pipeline, driven by git tags: +# +# git tag v0.2.1.dev1 && git push origin v0.2.1.dev1 -> build + TestPyPI only +# git tag v0.2.1 && git push origin v0.2.1 -> build + TestPyPI + PyPI +# +# The SAME artifacts built once in `build` are promoted through both indexes, so +# what lands on PyPI is byte-identical to what you smoke-tested on TestPyPI. +# +# Auth is OIDC trusted publishing (no API tokens stored). The manual approval +# gates are GitHub Environment "required reviewers", configured in +# Settings -> Environments (NOT in this file). The trusted-publisher registration +# on each index must match, exactly: +# owner=thad0ctor repo=Gefen-X workflow=release.yml environment=testpypi / pypi +# Renaming this file breaks the OIDC handshake -- keep it release.yml. + +on: + push: + tags: + - "v*" + +# Only the publish jobs need privilege (id-token), and they widen it locally. +permissions: + contents: read + +# Never cancel a release that is mid-flight (e.g. waiting on an approval gate). +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build & verify artifacts + runs-on: ubuntu-latest + outputs: + version: ${{ steps.ver.outputs.version }} + prerelease: ${{ steps.ver.outputs.prerelease }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + # No pip cache in this privileged publish workflow: GitHub caches are + # branch-scoped and writable by less-trusted runs, so a poisoned cache + # could taint the wheel uploaded to PyPI. Build tooling installs fast. + + - name: Install build tooling + run: python -m pip install --upgrade pip build twine + + - name: Build sdist + wheel + # Pure-Python build (kernels JIT at runtime), so no CUDA toolchain needed. + run: python -m build + + - name: twine check + run: python -m twine check dist/* + + - name: Verify tag matches package version + id: ver + # Guards against tagging vX.Y.Z while pyproject.toml still says the old + # version (the version is hard-coded there, so this is easy to forget). + run: | + TAG="${GITHUB_REF_NAME#v}" + WHEEL=$(ls dist/*.whl) + PKG_VER=$(basename "$WHEEL" | sed -E 's/^gefen_x-([^-]+)-py3.*/\1/') + echo "tag=$TAG package=$PKG_VER" + if [ "$TAG" != "$PKG_VER" ]; then + echo "::error::Tag v$TAG does not match built package version $PKG_VER -- bump version in pyproject.toml" + exit 1 + fi + echo "version=$PKG_VER" >> "$GITHUB_OUTPUT" + # PEP 440 prerelease markers (.devN / aN / bN / rcN) => TestPyPI only. + if echo "$PKG_VER" | grep -Eq '(\.dev|a|b|rc)[0-9]+$'; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + + - name: Upload artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: dist + path: dist/ + + testpypi: + name: Publish to TestPyPI (gate 1) + needs: build + runs-on: ubuntu-latest + # Approval gate #1: the `testpypi` environment's required reviewers. + environment: + name: testpypi + url: https://test.pypi.org/project/gefen-x/${{ needs.build.outputs.version }}/ + permissions: + id-token: write # mint the short-lived OIDC token; no stored secret + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: dist + path: dist/ + - name: Publish to TestPyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + with: + repository-url: https://test.pypi.org/legacy/ + # A real release also passes through here; if that version was already + # tested on TestPyPI, don't hard-fail on the duplicate. + skip-existing: true + + pypi: + name: Publish to PyPI (gate 2) + needs: [build, testpypi] + # Prerelease tags stop at TestPyPI; only clean vX.Y.Z tags reach PyPI. + if: needs.build.outputs.prerelease == 'false' + runs-on: ubuntu-latest + # Approval gate #2: the `pypi` environment's required reviewers. + environment: + name: pypi + url: https://pypi.org/project/gefen-x/${{ needs.build.outputs.version }}/ + permissions: + id-token: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: dist + path: dist/ + - name: Publish to PyPI + # Default index is PyPI. No skip-existing: re-releasing an existing + # version should hard-fail, not silently no-op. + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0