From 0f22bbfffe1fd258170ae78945531f8f70b4e548 Mon Sep 17 00:00:00 2001 From: Bernhard Trinnes <5207214+Boernsman@users.noreply.github.com> Date: Fri, 28 Aug 2026 22:47:31 +0200 Subject: [PATCH] Make the release pipeline rehearsable without publishing anything workflow_dispatch was listed as a trigger and could never work. GITHUB_REF_NAME is the branch on a manual run, so the tag-matches-version step compared a branch name to a version and failed on the first step every time. There was no way to exercise the release short of tagging, and a tag cannot be taken back. A manual run now takes a target. `rehearse`, the default, does everything a tag does except publish: builds the sdist and wheel, asserts the wheel still carries the firmware templates, runs twine check, installs the wheel into a clean environment and generates firmware from it. `testpypi` additionally uploads to test.pypi.org, for exercising the upload itself. `pypi` is there for a manual re-run of a release that half failed, and has to be asked for by name, so the default manual run cannot reach PyPI by accident. twine check is the useful addition even for tagged releases: it is what PyPI validates on upload, and nothing else in the pipeline looks at the metadata or at whether the README renders. Without it the first sign of a bad long_description is the upload rejecting it, at which point the tag exists. Verified locally: twine check --strict passes on both artifacts built from this commit, the install-and-run step runs end to end from the built wheel, and the workflow parses with the job conditions resolving as intended. --- .github/workflows/release.yml | 70 +++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8ac3ca..1937cf3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,22 @@ name: Release on: push: tags: ["v*"] + # A rehearsal. Everything a tag does except the publishing, so the pipeline can + # be exercised before the first real release and after any change to it. + # + # Manual runs used to be listed here and could never work: GITHUB_REF_NAME is + # the branch on a workflow_dispatch, so the tag-matches-version check compared + # a branch name to a version and failed on the first step, every time. workflow_dispatch: + inputs: + target: + description: "Where to publish. 'rehearse' builds and checks everything and publishes nothing." + type: choice + options: + - rehearse + - testpypi + - pypi + default: rehearse permissions: contents: read @@ -22,6 +37,7 @@ jobs: # Disagreement between the two is how a release ends up unreproducible, so # it fails here rather than shipping. - name: The tag matches the packaged version + if: github.event_name == 'push' run: | tagged="${GITHUB_REF_NAME#v}" packaged=$(python -c "import tomllib,pathlib; \ @@ -32,6 +48,15 @@ jobs: fi echo "releasing $packaged" + # Nothing to compare a version against on a rehearsal, so say what a tag + # would have to be named for this commit to release. + - name: What a tag would release + if: github.event_name != 'push' + run: | + packaged=$(python -c "import tomllib,pathlib; \ + print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") + echo "rehearsing ${{ inputs.target }}: this commit would release as v$packaged" + - name: Build run: | python -m pip install --upgrade build @@ -51,15 +76,38 @@ jobs: assert any(n.endswith("py.typed") for n in names), "py.typed missing" PY + # What PyPI itself validates on upload: the metadata, and whether the + # README renders. Nothing else in the pipeline looks at either, so without + # this the first time a bad long_description is noticed is the upload + # rejecting it, after the tag exists and cannot be moved. + - name: The metadata is one PyPI would accept + run: | + python -m pip install --upgrade twine + twine check --strict dist/* + - uses: actions/upload-artifact@v7 with: name: dist path: dist/ if-no-files-found: error + # A rehearsal that stopped here proved the build and said nothing about + # it. Install the wheel into a clean environment and run the thing. + - name: The wheel installs and runs + run: | + python -m venv /tmp/verify + /tmp/verify/bin/pip install --quiet dist/*.whl + /tmp/verify/bin/pinside --version + /tmp/verify/bin/pinside check examples/demo-board.kicad_pcb --strict + /tmp/verify/bin/pinside generate examples/demo-fixture.json --out /tmp/verify-fw + /tmp/verify-fw/test/run.sh + pypi: name: Publish to PyPI needs: build + # A tag publishes. A manual run publishes only when asked to in as many + # words, so the default manual run cannot reach PyPI by accident. + if: github.event_name == 'push' || inputs.target == 'pypi' runs-on: ubuntu-latest # A trusted publisher: PyPI verifies this workflow's OIDC identity, so there # is no long-lived token in the repository to leak or rotate. Configure it at @@ -78,9 +126,31 @@ jobs: path: dist/ - uses: pypa/gh-action-pypi-publish@release/v1 + testpypi: + name: Publish to TestPyPI + needs: build + # The upload path itself, against a throwaway index. Needs its own trusted + # publisher at test.pypi.org, configured the same way as the real one. + if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi' + runs-on: ubuntu-latest + environment: + name: testpypi + url: https://test.pypi.org/p/pinside + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v7 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + github: name: Publish the GitHub release needs: pypi + if: github.event_name == 'push' runs-on: ubuntu-latest permissions: contents: write