Skip to content

Release

Release #22

Workflow file for this run

name: Release
# Manually-triggered release: bump the version, tag, build, publish to public
# PyPI (wheel) and GHCR (agent image), and cut a GitHub Release for the tag.
# Merges to main do NOT auto-release -- run this workflow from the Actions tab
# when you want to cut a release.
#
# 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.)
#
# PRERELEASE mode (dispatched from a NON-main branch): instead of bumping and
# pushing main, stamp a throwaway `<next-patch>rc<run#>` version, then build and
# publish the wheel to public PyPI + a `:<version>` GHCR image. It does NOT move
# `:latest`, tag, commit, push to main, or create a GitHub Release (there is no
# tag for a Release to point at). This lets a branch be dry-run on the
# ADO nightly infra before merge (pinned via the pipeline's `coderEvalVersion`).
# The `bump` input is ignored off main. On `main` the behavior is unchanged.
on:
workflow_dispatch:
inputs:
bump:
description: 'Version bump to release (ignored on non-main / prerelease dispatch, which always stamps a next-patch rc)'
type: choice
default: patch
options:
- patch
- minor
- major
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
# 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: read
packages: write # push the versioned agent image to ghcr.io on release
jobs:
release:
name: Bump version and publish
# KNOWINGLY reverses PR #6, which moved this job to a GitHub-hosted runner because
# "cutting a release was blocked whenever that pool was unavailable". The pool's
# availability is what changed; the accepted trade-off is that the release path has
# no GitHub-hosted fallback again. Both jobs here are `workflow_dispatch`-only, so
# no PR check exercises them -- dry-run publish-testpypi.yml after editing this file.
runs-on: uipath-ubuntu-latest
timeout-minutes: 15
outputs:
# Exposed so the downstream publish-pypi job gates on a version having been
# produced (real release on main, or a stamped prerelease on a branch).
version: ${{ steps.ver.outputs.version }}
env:
# Load-bearing on the release path: the pool enforces a package-age safe-chain
# check on uv installs. Same expression as pr-checks.yml (see the comment there),
# so a package can't pass PR CI and then fail the release install.
SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS: ${{ secrets.SAFE_CHAIN_MINIMUM_PACKAGE_AGE_EXCLUSIONS || 'openai-codex-cli-bin,openai-codex' }}
steps:
# Only a real release (main) needs the app token: semantic-release pushes the
# bump commit + tag to the ruleset-protected main branch, and only this app
# has the bypass. A prerelease from a branch never commits or pushes, so it
# skips the token and checks out with the default GITHUB_TOKEN.
- name: Mint release app token
id: app-token
if: github.ref == 'refs/heads/main'
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). Falls back
# to the default token for a prerelease (no push, read-only checkout).
token: ${{ steps.app-token.outputs.token || github.token }}
- name: Set up Python 3.13
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0
with:
enable-cache: true
# Release mode is chosen by the dispatched ref: main => real release (the
# semantic-release path below); any other branch => prerelease (stamp a
# throwaway rc version, publish, never touch main). Keying off the ref (not a
# new input) keeps this dispatchable from a branch — a new input would have to
# exist on the default branch first to be accepted.
- name: Determine release mode
id: mode
env:
REF: ${{ github.ref }}
RUN_NUMBER: ${{ github.run_number }}
run: |
set -euo pipefail
if [ "$REF" = "refs/heads/main" ]; then
echo "prerelease=false" >> "$GITHUB_OUTPUT"
echo "Mode: RELEASE (main)"
else
# Next patch of the current version, suffixed with the run number so
# repeated dispatches never collide on PyPI. An exact `==` pin installs
# it even though pip/uv skip prereleases by default.
NEXT=$(python3 -c 'import re,tomllib; v=tomllib.load(open("pyproject.toml","rb"))["project"]["version"]; m=re.match(r"(\d+)\.(\d+)\.(\d+)",v); print("{}.{}.{}".format(int(m[1]),int(m[2]),int(m[3])+1))')
PRE="${NEXT}rc${RUN_NUMBER}"
echo "prerelease=true" >> "$GITHUB_OUTPUT"
echo "version=${PRE}" >> "$GITHUB_OUTPUT"
echo "Mode: PRERELEASE ${PRE}"
fi
- 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
if: steps.mode.outputs.prerelease != 'true'
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
# 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"
# PRERELEASE: stamp the rc version into the two canonical spots (mirrors
# publish-testpypi.yml), then refresh uv.lock so the Dockerfile's
# `uv export --frozen` accepts the bumped project version. No commit is made —
# the working tree is what `uv build` and the image build below consume.
- name: Stamp prerelease version
if: steps.mode.outputs.prerelease == 'true'
env:
PRE_VERSION: ${{ steps.mode.outputs.version }}
run: |
set -euo pipefail
python3 - <<'PY'
import os, re, pathlib
version = os.environ["PRE_VERSION"]
for path, key in (("pyproject.toml", "version"), ("src/coder_eval/__init__.py", "__version__")):
p = pathlib.Path(path)
# encoding pinned, not left to the ambient locale: a non-UTF-8 default
# would raise UnicodeDecodeError mid-release on a non-ASCII source file.
new, n = re.subn(rf'(?m)^{key}\s*=\s*".+?"$', f'{key} = "{version}"', p.read_text(encoding="utf-8"), count=1)
if n != 1:
raise SystemExit(f"version pattern did not match {path} (matched {n})")
p.write_text(new, encoding="utf-8")
print(f"Stamped prerelease version: {version}")
PY
uv lock
# Single source of truth for the steps below: the real release version (main)
# or the stamped prerelease version (branch).
- name: Resolve published version
id: ver
env:
REL: ${{ steps.release.outputs.version }}
PRE: ${{ steps.mode.outputs.version }}
run: |
set -euo pipefail
V="${REL:-$PRE}"
if [ -z "$V" ]; then echo "no version resolved" >&2; exit 1; fi
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "Publishing version: $V"
- name: Regenerate uv.lock, bump action.yml pin, and amend release commit
if: steps.mode.outputs.prerelease != 'true' && steps.release.outputs.version != ''
env:
# Passed via env (not interpolated into the script) per GitHub's
# injection guidance.
VERSION: ${{ steps.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]"
# Bump the composite action's default `version:` pin to the just-released
# version so `UiPath/coder_eval@vX.Y.Z` installs `coder-eval==X.Y.Z`. The
# anchor is indentation-tolerant and keyed on the unique trailing
# "# <-- kept in sync" comment; the grep guard fails the release loudly
# if a reformat ever detaches it (rather than shipping a stale pin).
sed -i -E 's/^([[:space:]]*default: ")[0-9]+\.[0-9]+\.[0-9]+(" # <-- kept in sync)/\1'"${VERSION}"'\2/' action.yml
grep -q "default: \"${VERSION}\"" action.yml || { echo "action.yml version bump failed"; exit 1; }
git add action.yml
# Regenerate the lock too; stage it (a no-op if unchanged).
uv lock
git add uv.lock
# Amend only if action.yml/uv.lock actually changed the tree.
if ! git diff --cached --quiet; then
git commit --amend --no-edit
# Amend replaced the commit the tag points at; re-point it before pushing.
git tag -f "v${VERSION}"
fi
- name: Push release commit and tags
if: steps.mode.outputs.prerelease != 'true' && steps.release.outputs.version != ''
run: git push origin main "v${{ steps.release.outputs.version }}"
- name: Move major action tag (vN -> this release)
if: steps.release.outputs.version != ''
env:
VERSION: ${{ steps.release.outputs.version }}
run: |
set -euo pipefail
# Consumers pin `UiPath/coder_eval@v0` (becomes `@v1` at 1.0.0). Force-move
# the moving major tag to this release. Force on a missing tag creates it.
MAJOR="v${VERSION%%.*}"
git tag -f "$MAJOR" "v${VERSION}"
git push -f origin "$MAJOR"
- name: Build wheel + sdist
if: steps.ver.outputs.version != ''
run: uv build
# Hand the exact built artifacts to the publish-pypi job. Publishing to
# public PyPI runs in its own environment-gated job (OIDC), so it must
# consume these files rather than rebuild them.
- name: Upload dist for PyPI publish
if: steps.ver.outputs.version != ''
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: release-dist
path: dist/
if-no-files-found: error
# Publish the GitHub Release for the tag pushed above. semantic-release runs
# with --no-vcs-release because it also runs --no-push (the commit is amended
# and the tag re-pointed first), so it cannot create the release itself -- it
# happens here, once the tag is actually on the remote. A published Release is
# what GitHub Marketplace listings are cut from, so every release needs one.
# (`gh release create` cannot tick the "Publish this Action to the
# Marketplace" checkbox -- that stays a one-time manual step in the GitHub UI
# on the first Release; every subsequent release then lists automatically.)
#
# Deliberately placed AFTER "Build wheel + sdist" and "Upload dist for PyPI
# publish" rather than at the earliest legal point after the tag push: those
# two steps are the last ones that can still fail for an already-tagged
# version, and a Release announcing a version whose artifacts never built is
# worse than a missing Release. This narrows the window rather than closing
# it -- publish-pypi is a separate job, so the actual upload to PyPI still
# happens after this. Running here also keeps a slow/hung `gh` API call from
# eating the 15-minute job budget BEFORE the artifacts are safe, which would
# produce exactly the stranded-tag state the note below warns about.
#
# Notes are the CHANGELOG section semantic-release just generated for this
# version, sliced by .github/scripts/release_notes.py (a real module, so the
# regex is unit-tested -- see tests/test_release_notes.py); an empty result
# falls back to GitHub's generated notes.
#
# ACCEPTED RISK: those notes render commit subjects, i.e. squashed PR titles.
# The Release body is a first-party surface that GitHub also fans out in
# notification emails, so it carries text that was reviewed as *code*, not as
# markdown -- a PR title can land an arbitrary link in it. Bounded to
# content/link spoofing (GitHub strips raw HTML from release bodies) and
# gated by this repo's mandatory PR review. Revisit with `--draft` plus a
# human glance, or link-stripping in release_notes.py, if the repo ever takes
# drive-by contributions.
- name: Publish GitHub Release
id: gh_release
if: steps.mode.outputs.prerelease != 'true' && steps.release.outputs.version != ''
# Best-effort, mirroring the GHCR steps below. main, the version tag, the
# moving major tag, and the dist artifact are all in place by the time this
# runs, so a transient GitHub API failure here must not fail the job: the
# publish-pypi job is `needs: release`, so a failure would SKIP the PyPI
# publish of an already-tagged version and strand `@vN` on an action.yml pin
# whose version was never published. The next step turns the swallowed
# failure into a loud annotation instead of a collapsed step marker.
continue-on-error: true
env:
# The app token, not GITHUB_TOKEN: the workflow's `permissions:` are
# contents: read, and `gh release create` needs contents: write. Granting
# the job contents: write to use GITHUB_TOKEN here would ADD a second
# write credential rather than remove one -- `actions/checkout` above
# already persists this same app token in .git/config for every step in
# the job, so scoping it out of this one step's env buys no isolation.
GH_TOKEN: ${{ steps.app-token.outputs.token }}
# Passed via env (not interpolated into the script) per GitHub's
# injection guidance.
VERSION: ${{ steps.release.outputs.version }}
run: |
set -euo pipefail
# Written under RUNNER_TEMP, never the repo root: hatchling's default sdist
# file selection sweeps in untracked files at the root (verified -- it ships
# even git-ignored paths), so a notes file left in the tree would leak into
# the sdist that the "Build wheel + sdist" step above produced and the
# publish-pypi job uploads.
NOTES_FILE="${RUNNER_TEMP}/release-notes.md"
python3 .github/scripts/release_notes.py "$VERSION" "$NOTES_FILE"
# Empty notes file => no CHANGELOG section was found (the script already
# emitted the ::warning::); let GitHub generate the body instead.
if [ -s "$NOTES_FILE" ]; then
NOTES=(--notes-file "$NOTES_FILE")
else
NOTES=(--generate-notes)
fi
gh release create "v${VERSION}" \
--title "v${VERSION}" \
--verify-tag \
--latest \
"${NOTES[@]}"
# `continue-on-error` above hides a failure in a collapsed step marker that
# nobody expands on an otherwise-green release run -- the same silence that
# let "no GitHub Releases at all" go unnoticed until this PR. Re-raise it as
# an ::error annotation plus a run-summary block, WITHOUT failing the job
# (that would skip publish-pypi, see above).
- name: Flag missing GitHub Release
if: always() && steps.gh_release.outcome == 'failure'
env:
VERSION: ${{ steps.release.outputs.version }}
run: |
set -euo pipefail
MAJOR="v${VERSION%%.*}"
echo "::error title=GitHub Release not published::v${VERSION} was tagged and its artifacts built, but 'gh release create' failed. Create the Release by hand so ${MAJOR} and the Marketplace listing resolve."
{
echo "### :x: GitHub Release for \`v${VERSION}\` was NOT created"
echo
echo "The version tag, the moving \`${MAJOR}\` tag, and the PyPI artifacts are unaffected —"
echo "only \`gh release create\` failed. Create it by hand:"
echo
echo '```sh'
echo "gh release create v${VERSION} --title v${VERSION} --verify-tag --latest --generate-notes"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Build + push the agent image HERE, in the same job that produced the
# version, so the `:<version>` tag is built from the correct pyproject (bumped
# by semantic-release on main, or the stamped rc on a prerelease).
# docker-publish.yml runs on the triggering commit, BEFORE a main bump, so it
# can never tag the release version -- this is the authoritative versioned
# image. A real release also repoints `:latest`; a prerelease publishes only
# its `:<version>` tag (see Compute image tags).
- name: Lowercase owner for GHCR
if: steps.ver.outputs.version != ''
id: img
run: echo "owner_lc=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
# `:<version>` always; `:latest` only for a real release. A prerelease must
# not move `:latest`, which the nightly treats as tip-of-main.
- name: Compute image tags
if: steps.ver.outputs.version != ''
id: tags
env:
OWNER: ${{ steps.img.outputs.owner_lc }}
VERSION: ${{ steps.ver.outputs.version }}
IS_PRERELEASE: ${{ steps.mode.outputs.prerelease }}
run: |
set -euo pipefail
{
echo "tags<<EOF"
echo "ghcr.io/${OWNER}/coder-eval-agent:${VERSION}"
if [ "$IS_PRERELEASE" != "true" ]; then
echo "ghcr.io/${OWNER}/coder-eval-agent:latest"
fi
echo "EOF"
} >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
continue-on-error: true # GHCR image is internal/best-effort; don't block PyPI
if: steps.ver.outputs.version != ''
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GHCR
continue-on-error: true # GHCR image is internal/best-effort; don't block PyPI
if: steps.ver.outputs.version != ''
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push versioned agent image
continue-on-error: true # GHCR image is internal/best-effort; don't block PyPI
if: steps.ver.outputs.version != ''
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
file: docker/Dockerfile
push: true
tags: ${{ steps.tags.outputs.tags }}
build-args: |
CODER_EVAL_VERSION=${{ steps.ver.outputs.version }}
secrets: |
"uv_index_username=${{ secrets.UV_INDEX_UIPATH_USERNAME }}"
"uv_index_password=${{ secrets.UV_INDEX_UIPATH_PASSWORD }}"
# Read the shared buildcache docker-publish.yml writes; don't write it
# back from here, to avoid two concurrent writers racing the cache tag.
cache-from: type=registry,ref=ghcr.io/${{ steps.img.outputs.owner_lc }}/coder-eval-agent:buildcache
# Publish the wheel+sdist to public PyPI. This runs as its own job so OIDC
# Trusted Publishing is scoped to a dedicated, environment-gated context --
# no PyPI token/secret is stored. Gated on the release job having actually
# cut a version.
publish-pypi:
name: Publish to PyPI
needs: release
if: needs.release.outputs.version != ''
runs-on: uipath-ubuntu-latest
timeout-minutes: 10
environment:
name: pypi
url: https://pypi.org/project/coder-eval/${{ needs.release.outputs.version }}/
permissions:
# OIDC token minting for Trusted Publishing; no long-lived credentials.
id-token: write
steps:
- name: Download built dist
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: release-dist
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
# Trusted Publisher is configured on pypi.org for this repo +
# workflow (release.yml) + environment (pypi); no password needed.
packages-dir: dist/