Release #22
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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/ |