Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 78 additions & 56 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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<version>` 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<version>` 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<version>`, 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<version>` 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<version> 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:
Expand All @@ -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
Expand All @@ -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 != ''
Expand All @@ -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 `:<version>` 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
Expand Down Expand Up @@ -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<version>` 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<version> tag
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Push v<version> 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}"
Loading