From 8411a86f8b0358ae000d73e4c37f13c9845f0552 Mon Sep 17 00:00:00 2001 From: Akshaya Shanbhogue Date: Thu, 9 Jul 2026 15:49:27 -0700 Subject: [PATCH] ci: restore auto-bump release (semantic-release + app token) Effectively reverts #8 (tag-only, manual-bump release). The release app has been re-added with a ruleset bypass, so the workflow can once again push the version-bump commit + tag directly to protected main. - Restore the `bump` dispatch input (patch/minor/major, default patch). - Mint a GitHub App installation token and checkout/push as the app (bypasses the "Protect main" ruleset that GITHUB_TOKEN cannot). - semantic-release writes the new version to pyproject.toml + __init__.py, tags v, regenerates the changelog, then we regenerate uv.lock, amend, and push commit + tag to main. - Drop the manual-bump re-release guard and the separate tag-release job (the tag now travels with the bump commit). Public PyPI publishing (OIDC Trusted Publishing) and the best-effort GHCR image are kept. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 134 ++++++++++++++++++++-------------- 1 file changed, 78 insertions(+), 56 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7dd409f..37220ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,39 +1,49 @@ name: Release -# Manually-triggered release: build the CURRENT version (from pyproject.toml) -# and publish it to public PyPI (wheel) and GHCR (agent image), then push the -# `v` git tag. +# Manually-triggered release: bump the version, tag, build, and publish to +# public PyPI (wheel) and GHCR (agent image). Merges to main do NOT +# auto-release -- run this workflow from the Actions tab when you want to cut a +# release. # -# Version management: `main` is protected by a ruleset (changes via PR only), -# so this workflow does NOT commit a version bump to main. Bump `version` in -# pyproject.toml (+ src/coder_eval/__init__.py) through a normal PR; once that -# merges, run this workflow to release whatever version main currently -# declares. It refuses to run if the `v` tag already exists (i.e. you -# forgot to bump). Only the git TAG is pushed -- tags are outside the branch -# ruleset -- never a commit to main. +# The bump level is CHOSEN at dispatch, not derived from commit messages: +# `patch` (default), `minor`, or `major`. A dispatch always cuts a release. +# semantic-release only does the mechanics -- write the new version to +# pyproject.toml + __init__.py, tag `v`, regenerate the changelog from +# the commits since the last tag (notes only -- they don't affect the version), +# and push. # (Continuous :latest / :sha- agent images still publish on every main push via # docker-publish.yml -- only the versioned release artifacts gate on this run.) on: workflow_dispatch: + inputs: + bump: + description: 'Version bump to release' + type: choice + default: patch + options: + - patch + - minor + - major concurrency: group: release-${{ github.ref }} cancel-in-progress: false -# The release pushes only the `v` git TAG (tags are not covered by -# the branch ruleset protecting main), so the built-in GITHUB_TOKEN with -# contents: write suffices -- no GitHub App / ruleset bypass required. +# The version-bump commit + tag are pushed with a GitHub App installation token +# (not GITHUB_TOKEN): main is protected by a ruleset (changes via PR only), and +# only the release app has a ruleset bypass. GITHUB_TOKEN is only used for the +# checkout's read access and the GHCR login. permissions: - contents: write # push the v tag + contents: read packages: write # push the versioned agent image to ghcr.io on release jobs: release: name: Bump version and publish # GitHub-hosted so cutting a release does not depend on the self-hosted - # `uipath-ubuntu-latest` pool. uv, twine, and the docker buildx -> GHCR - # push all run fine here. + # `uipath-ubuntu-latest` pool. semantic-release, uv, twine, and the docker + # buildx -> GHCR push all run fine here. runs-on: ubuntu-latest timeout-minutes: 15 outputs: @@ -48,8 +58,20 @@ jobs: SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS: "openai-codex-cli-bin,openai-codex" steps: + - name: Mint release app token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 # semantic-release needs full history for tags + changelog + # Persisted in .git config so semantic-release's push to main is + # authenticated as the app (which bypasses the branch ruleset). + token: ${{ steps.app-token.outputs.token }} - name: Set up Python 3.13 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -61,21 +83,44 @@ jobs: with: enable-cache: true - - name: Resolve version + guard against re-release + - name: Install build + release tools + run: uv tool install python-semantic-release && uv tool install twine + + - name: Run semantic-release (bump + tag, no push yet) id: release + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + # Passed via env (not interpolated into the script) per GitHub's + # injection guidance; it's a constrained choice input regardless. + BUMP: ${{ inputs.bump }} run: | set -euo pipefail - # Version is whatever main currently declares. Bumps land via a PR - # (main is ruleset-protected); this workflow never rewrites it. - VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") - echo "Releasing coder-eval ${VERSION}" - # Refuse to re-release an existing version: a PyPI version is - # immutable, so this is almost always a forgotten version bump. - if git ls-remote --exit-code --tags origin "refs/tags/v${VERSION}" >/dev/null 2>&1; then - echo "::error::Tag v${VERSION} already exists. Bump 'version' in pyproject.toml (+ src/coder_eval/__init__.py) via a PR before releasing." - exit 1 + # The bump level comes from the dispatch input, not commit messages: + # force a patch/minor/major bump outright. `version` writes the new + # version to pyproject.toml + __init__.py, tags it, and regenerates the + # changelog, but does not push yet (--no-push) so we can regenerate + # uv.lock and amend before sending. + PSR="uv tool run --from python-semantic-release semantic-release" + $PSR version "--$BUMP" --no-vcs-release --no-push --changelog + # A dispatch always cuts a release; report the just-published version. + echo "version=$($PSR version --print)" >> "$GITHUB_OUTPUT" + + - name: Regenerate uv.lock and amend release commit + if: steps.release.outputs.version != '' + run: | + uv lock + if ! git diff --quiet uv.lock; then + git config user.email "github-actions[bot]@users.noreply.github.com" + git config user.name "github-actions[bot]" + git add uv.lock + git commit --amend --no-edit + # Amend replaced the commit the tag points at; re-point it before pushing. + git tag -f "v${{ steps.release.outputs.version }}" fi - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Push release commit and tags + if: steps.release.outputs.version != '' + run: git push origin main "v${{ steps.release.outputs.version }}" - name: Build wheel + sdist if: steps.release.outputs.version != '' @@ -92,10 +137,12 @@ jobs: path: dist/ if-no-files-found: error - # Build + push the agent image HERE, in the release job, tagged with the - # version main declares. docker-publish.yml only publishes :latest / :sha- - # on every main push, so this is the authoritative versioned image; it - # also repoints `:latest` to the exact release artifact. + # Build + push the agent image HERE, in the same job that cut the release, + # so the `:` tag is built from the BUMPED pyproject (the version + # only exists after the semantic-release step above). docker-publish.yml + # runs on the triggering commit, BEFORE the bump, so it can never tag the + # release version -- this is the authoritative versioned image. It also + # repoints `:latest` to the exact release artifact. - name: Lowercase owner for GHCR if: steps.release.outputs.version != '' id: img @@ -164,28 +211,3 @@ jobs: # Trusted Publisher is configured on pypi.org for this repo + # workflow (release.yml) + environment (pypi); no password needed. packages-dir: dist/ - - # Tag the release ONLY after PyPI has published, so a `v` tag marks a - # completed public release and the re-release guard never blocks retrying a - # failed publish. Tags are outside the branch ruleset, so GITHUB_TOKEN suffices. - tag-release: - name: Push release tag - needs: [release, publish-pypi] - if: needs.release.outputs.version != '' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write # push the v tag - steps: - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Push v tag - env: - VERSION: ${{ needs.release.outputs.version }} - run: | - set -euo pipefail - git config user.email "github-actions[bot]@users.noreply.github.com" - git config user.name "github-actions[bot]" - git tag -a "v${VERSION}" -m "Release v${VERSION}" - git push origin "v${VERSION}"