From 469fc003fdf22423a30be698c5d6061a4180366f Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Fri, 1 May 2026 13:24:32 -0700 Subject: [PATCH 1/3] Make release.yml idempotent and tighten permissions The github-release step previously called `gh release create` unconditionally, which fails with HTTP 422 when a release already exists for the tag - e.g. when a maintainer publishes the release through the GitHub UI (which also creates the tag), as happened for v0.1.1. - Guard with `gh release view ... || gh release create ...` and upload `dist/*` to the existing release with `--clobber` when present. - `permissions: {}` at workflow level, escalated only where needed. - Drop unused `attestations: write`; PEP 740 attestations are produced by pypa/gh-action-pypi-publish v1.11+ from `id-token: write` alone. - Add `concurrency: cancel-in-progress: false` on the tag ref. - Verify the tagged commit is an ancestor of origin/main. - Attach built sdist + wheel to the GitHub Release. - Fold verify-version into build (no need for a separate runner). --- .github/workflows/release.yml | 56 ++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68a6a60..55eadaf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,21 +4,25 @@ on: push: tags: ["v*"] -permissions: - contents: read +permissions: {} + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false env: UV_FROZEN: true jobs: - verify-version: + build: runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - - name: Verify tag matches pyproject version + fetch-depth: 0 + - name: Verify tag matches pyproject version and is unpublished run: | set -euo pipefail TAG_VERSION="${GITHUB_REF_NAME#v}" @@ -32,15 +36,14 @@ jobs: exit 1 fi echo "Releasing ionq-core ${PYPROJECT_VERSION}" - - build: - needs: verify-version - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false + - name: Verify tag commit is on main + run: | + set -euo pipefail + git fetch origin main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::Tag ${GITHUB_REF_NAME} (${GITHUB_SHA}) is not an ancestor of origin/main" + exit 1 + fi - uses: ./.github/actions/setup-uv - run: uv build - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -49,7 +52,7 @@ jobs: path: dist/ if-no-files-found: error - publish: + publish-pypi: needs: build runs-on: ubuntu-latest timeout-minutes: 5 @@ -58,7 +61,6 @@ jobs: url: https://pypi.org/p/ionq-core permissions: id-token: write - attestations: write steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: @@ -67,16 +69,28 @@ jobs: - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 github-release: - needs: publish + needs: publish-pypi runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: - persist-credentials: false - - name: Create GitHub Release + name: dist + path: dist/ + - name: Create or update GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release create "$GITHUB_REF_NAME" --generate-notes + run: | + set -euo pipefail + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $GITHUB_REF_NAME already exists; attaching distribution artifacts." + gh release upload "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --clobber dist/* + else + gh release create "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --generate-notes \ + --verify-tag \ + dist/* + fi From 178c3691829b6c90acf89e99dad2922416e6c825 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Fri, 1 May 2026 13:29:43 -0700 Subject: [PATCH 2/3] Simplify release.yml Drop machinery that wasn't earning its lines: - The concurrency block - tag-push races are vanishingly rare for this repo. - UV_FROZEN env - irrelevant; uv build doesn't sync from the lockfile. - The tag-on-main ancestry check (and its fetch-depth: 0) - the tag-vs-pyproject match already catches the common error modes. - The if/else in github-release. Replace with the cleaner pattern of "create the release if missing, then always upload dist/* with --clobber." Idempotent on retry without branching. - --verify-tag and the post-publish info echo - noise. Use job-level env (GH_REPO, TAG) to drop --repo flags from each gh call. --- .github/workflows/release.yml | 54 +++++++++-------------------------- 1 file changed, 13 insertions(+), 41 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 55eadaf..46ea5dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,13 +6,6 @@ on: permissions: {} -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false - -env: - UV_FROZEN: true - jobs: build: runs-on: ubuntu-latest @@ -21,27 +14,14 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - fetch-depth: 0 - - name: Verify tag matches pyproject version and is unpublished + - name: Verify tag matches pyproject version run: | set -euo pipefail - TAG_VERSION="${GITHUB_REF_NAME#v}" - PYPROJECT_VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") - if [[ "$TAG_VERSION" != "$PYPROJECT_VERSION" ]]; then - echo "::error::Tag ${GITHUB_REF_NAME} does not match pyproject.toml version ${PYPROJECT_VERSION}" - exit 1 - fi - if curl -sf "https://pypi.org/pypi/ionq-core/${PYPROJECT_VERSION}/json" > /dev/null 2>&1; then - echo "::error::Version ${PYPROJECT_VERSION} already exists on PyPI" - exit 1 - fi - echo "Releasing ionq-core ${PYPROJECT_VERSION}" - - name: Verify tag commit is on main - run: | - set -euo pipefail - git fetch origin main - if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then - echo "::error::Tag ${GITHUB_REF_NAME} (${GITHUB_SHA}) is not an ancestor of origin/main" + TAG="${GITHUB_REF_NAME#v}" + VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + [[ "$TAG" == "$VERSION" ]] || { echo "::error::Tag $GITHUB_REF_NAME does not match pyproject version $VERSION"; exit 1; } + if curl -sf "https://pypi.org/pypi/ionq-core/$VERSION/json" >/dev/null 2>&1; then + echo "::error::Version $VERSION already exists on PyPI" exit 1 fi - uses: ./.github/actions/setup-uv @@ -74,23 +54,15 @@ jobs: timeout-minutes: 5 permissions: contents: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} steps: - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: dist path: dist/ - - name: Create or update GitHub Release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "Release $GITHUB_REF_NAME already exists; attaching distribution artifacts." - gh release upload "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --clobber dist/* - else - gh release create "$GITHUB_REF_NAME" \ - --repo "$GITHUB_REPOSITORY" \ - --generate-notes \ - --verify-tag \ - dist/* - fi + - run: | + gh release view "$TAG" >/dev/null 2>&1 || gh release create "$TAG" --generate-notes + gh release upload "$TAG" --clobber dist/* From af006da30a1b09421eca54855a5c9ef5b6628b21 Mon Sep 17 00:00:00 2001 From: Spencer Churchill <25377399+splch@users.noreply.github.com> Date: Fri, 1 May 2026 13:47:39 -0700 Subject: [PATCH 3/3] Align release.yml with repo conventions - Add concurrency block matching ci.yml's group key. Use cancel-in-progress: false (unlike ci.yml which uses true) so an in-flight pypa/gh-action-pypi-publish never gets killed mid-upload. - Add env: UV_FROZEN: true to match the other uv-using workflows. - Name the final github-release step, matching the repo pattern of naming any step with a multi-line script. --- .github/workflows/release.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46ea5dc..268996f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,8 +4,15 @@ on: push: tags: ["v*"] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + permissions: {} +env: + UV_FROZEN: true + jobs: build: runs-on: ubuntu-latest @@ -63,6 +70,7 @@ jobs: with: name: dist path: dist/ - - run: | + - name: Create or update GitHub Release + run: | gh release view "$TAG" >/dev/null 2>&1 || gh release create "$TAG" --generate-notes gh release upload "$TAG" --clobber dist/*