From 393763c3dbb94ae80f077aa217fa34e4741d90a0 Mon Sep 17 00:00:00 2001 From: Pierre Brunelle Date: Thu, 10 Sep 2026 14:32:45 -0700 Subject: [PATCH] ci: publish from a tag with Trusted Publishing, and verify the artifact first 0.2.0 is on main but PyPI still serves the broken 0.1.x, so the fix has not reached anyone. Releasing was a shell script, and the script no longer worked: it required a git remote named `home` that a normal clone does not have, read a long-lived PYPI_API_KEY out of ~/.pixeltable/config.toml, and called `poetry build` after the project had moved to uv. Releasing is now a tag push: git tag vX.Y.Z && git push origin vX.Y.Z No token exists. PyPI Trusted Publishing mints a short-lived credential from the workflow's OIDC identity, so there is nothing to leak, rotate, or paste into a shell. It needs a one-time pending publisher registered on PyPI; RELEASING.md has the four fields. The build job refuses to publish an artifact it has not exercised. It checks the tag against pyproject.toml, runs twine check, then installs the built wheel into a clean environment and constructs a real agent/memory with it. That last step is the one that matters: 0.1.x shipped a package that could not import against a current Pixeltable precisely because nothing ever installed what it built. Verified locally on both projects; the wheel pulls pixeltable 0.7.7 through the <0.8 bound and works. workflow_dispatch defaults to dry_run, so the whole path can be exercised without publishing. The publish job sits behind a `pypi` environment, which is where a required-reviewer gate goes if you want one. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 96 +++++++++++++++++++++++++++++++++++ RELEASING.md | 45 ++++++++++++++++ scripts/release.sh | 64 ----------------------- 3 files changed, 141 insertions(+), 64 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 RELEASING.md delete mode 100644 scripts/release.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..25f26c3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: Release + +# Publishing is a tag push: +# +# git tag v0.2.0 && git push origin v0.2.0 +# +# No token anywhere. PyPI Trusted Publishing mints a short-lived credential from +# this workflow's OIDC identity, so there is no long-lived PYPI_API_KEY to leak, +# rotate, or paste into a shell. The previous scripts/release.sh read one out of +# ~/.pixeltable/config.toml. +# +# One-time setup on PyPI (Manage project -> Publishing -> Add a pending publisher): +# owner: pixeltable repository: pixelmemory +# workflow: release.yml environment: pypi + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + dry_run: + description: "Build and check only; do not publish" + type: boolean + default: true + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Version in pyproject.toml must match the tag + if: startsWith(github.ref, 'refs/tags/v') + run: | + tag="${GITHUB_REF_NAME#v}" + pyproject=$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2) + echo "tag=$tag pyproject=$pyproject" + if [ "$tag" != "$pyproject" ]; then + echo "::error::Tag v$tag does not match pyproject.toml version $pyproject." + exit 1 + fi + + - run: uv build + + - name: Check the artifacts + run: | + uv tool run twine check dist/* + ls -la dist/ + + # A release that cannot be installed is worse than no release. This is the + # same check that caught the 0.1.x breakage: install the actual artifact + # against the newest pixeltable our bound allows, then build an agent. + - name: Install the built wheel and smoke-test it + env: + PIXELTABLE_HOME: ${{ runner.temp }}/pxt-release + run: | + uv venv /tmp/verify --python 3.12 + VIRTUAL_ENV=/tmp/verify uv pip install dist/*.whl + VIRTUAL_ENV=/tmp/verify uv pip list | grep -E "pixelmemory|pixeltable" + /tmp/verify/bin/python -c " + from pixelmemory import Memory + from pixelmemory.context import Text + m = Memory([Text(id='body', embed=False)], namespace='release_check') + assert m.table.columns() == ['body'], m.table.columns() + print('built wheel installs and builds a memory') + " + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + if: startsWith(github.ref, 'refs/tags/v') && !inputs.dry_run + runs-on: ubuntu-latest + # The environment gives you a required-reviewer gate if you want one: + # Settings -> Environments -> pypi -> Required reviewers. + environment: + name: pypi + url: https://pypi.org/p/pixelmemory + permissions: + id-token: write # mints the OIDC token Trusted Publishing exchanges + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..01a6828 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,45 @@ +# Releasing pixelmemory + +```bash +# 1. bump the version +# pyproject.toml -> version = "X.Y.Z" +uv lock # keep uv.lock in step; CI runs `uv sync --locked` and will fail otherwise + +# 2. land it on main through a PR, then +git tag vX.Y.Z && git push origin vX.Y.Z +``` + +The tag push runs `.github/workflows/release.yml`, which builds the sdist and +wheel, checks the tag matches `pyproject.toml`, installs the built wheel into a +clean environment and exercises it, then publishes to PyPI. + +## There is no PyPI token + +Publishing uses PyPI Trusted Publishing. PyPI mints a short-lived credential +from the workflow's OIDC identity, so there is no long-lived secret to leak, +rotate, or paste into a shell. + +One-time setup, on PyPI under **Manage project -> Publishing -> Add a pending publisher**: + +| field | value | +|---|---| +| owner | `pixeltable` | +| repository | `pixelmemory` | +| workflow | `release.yml` | +| environment | `pypi` | + +To require a human approval before each publish, add a required reviewer to the +`pypi` environment under **Settings -> Environments**. + +## Dry run + +Run the workflow manually from the Actions tab with `dry_run` left checked. It +builds and verifies the artifact without publishing anything. + +## Why the release script is gone + +`scripts/release.sh` read a long-lived `PYPI_API_KEY` out of +`~/.pixeltable/config.toml`, required a git remote named `home` that a normal +clone does not have, and called `poetry build` after the project had moved to +uv. It also published without ever installing what it built, which is how +0.1.x shipped a package that could not import against a current Pixeltable. diff --git a/scripts/release.sh b/scripts/release.sh deleted file mode 100644 index 0b6ac88..0000000 --- a/scripts/release.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/bash -e - -# pixelmemory release script - -SCRIPT_DIR="$(dirname "$0")" -PROJECT_ROOT="$(readlink -f "$SCRIPT_DIR/..")" - -echo -e "\n============= Running pixelmemory release script.\n" - -echo "Project root: $PROJECT_ROOT" -cd "$PROJECT_ROOT" - -if [[ ! $(gh --version) ]]; then - echo "You must have Github commandline utilities installed to run this script. See: https://cli.github.com/" - exit 1 -fi - -if [ "$(git remote get-url home)" != 'https://github.com/pixeltable/pixelmemory' ]; then - echo "Unexpected home repo: $(git remote get-url home)" - exit 1 -fi - -git diff # Needed to ensure correct behavior of subsequent `git diff-index` call - -if [[ -n "$(git diff-index HEAD)" || -n "$(git diff-files)" || -n "$(git ls-files --exclude-standard --others)" ]]; then - echo "The release script must be run from a clean git repo." - exit 1 -fi - -if [ -z "$PYPI_API_KEY" ]; then - echo "PYPI_API_KEY not defined in environment; searching in ~/.pixeltable/config.toml." - PYPI_API_KEY=$( - python -c "import toml,sys; y = toml.load(sys.stdin); print(y['pypi']['api_key'])" < ~/.pixeltable/config.toml - ) -fi - -git fetch home -git checkout home/main - -echo -n "Enter version number for release: " -read VERSION - -echo "" -echo "This will publish version v$VERSION to PyPI. Enter to confirm; Ctrl-C to abort." -read - -git tag "v$VERSION" -git push home "v$VERSION" - -echo "v$VERSION tag created and pushed to home repo." -echo "Enter to proceed; Ctrl-C to abort." -read - -git tag -d release -git push --delete home release -git tag release -git push home release - -poetry build -poetry publish --username __token__ --password "$PYPI_API_KEY" - -echo "Creating release on github ..." -gh release create "v$VERSION" --generate-notes --repo pixeltable/pixelmemory -open "https://github.com/pixeltable/pixelmemory/releases/tag/v$VERSION"