Release #39
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 }} | |
| # Real-release-only version: empty on a prerelease dispatch (the `release` | |
| # step is skipped off main), where `version` above instead carries the stamped | |
| # rc -- which is why the two are NOT interchangeable. | |
| # | |
| # What keeps a prerelease from moving the major tag or cutting a Release is the | |
| # `promote` job's `if: github.ref == 'refs/heads/main'`, NOT an emptiness test | |
| # on this output; gating a job on a `needs` output is the skipped-green hazard | |
| # that job's header documents. `promote` consumes this value for the version it | |
| # promotes and enforces non-emptiness INSIDE the job ("Validate version shape"), | |
| # so a lost output is a red job rather than a silent no-op. | |
| released_version: ${{ steps.release.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 }} | |
| # Scoped explicitly: omitting `permission-*` mints a token carrying EVERY | |
| # permission of the installation, and this is the app with the main-branch | |
| # ruleset bypass. All it does here is push the bump commit + tag. | |
| permission-contents: write | |
| - 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 | |
| # Pinned: python-semantic-release declares gitpython~=3.0, so an | |
| # unpinned install can resolve GitPython 3.1.60+, which removed | |
| # git.Actor.name_email_regex and breaks semantic-release's `version` | |
| # command outright ("type object 'Actor' has no attribute | |
| # 'name_email_regex'"). Pin both explicitly until upstream adapts. | |
| run: | | |
| uv tool install python-semantic-release==10.6.1 --with gitpython==3.1.59 | |
| 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==10.6.1 --with gitpython==3.1.59 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 + plugin.json pins, 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 | |
| # Keep the Claude Code plugin manifest's version in lockstep. `claude | |
| # plugin validate --strict` (run in pr-checks) rejects a manifest with | |
| # no version, and a stale one strands users on a cached copy. | |
| sed -i -E 's/^([[:space:]]*"version": ")[0-9]+\.[0-9]+\.[0-9]+(",)/\1'"${VERSION}"'\2/' \ | |
| plugins/coder-eval/.claude-plugin/plugin.json | |
| grep -q "\"version\": \"${VERSION}\"" plugins/coder-eval/.claude-plugin/plugin.json \ | |
| || { echo "plugin.json version bump failed"; exit 1; } | |
| git add plugins/coder-eval/.claude-plugin/plugin.json | |
| # Regenerate the lock too; stage it (a no-op if unchanged). | |
| uv lock | |
| git add uv.lock | |
| # Amend only if action.yml/plugin.json/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 != '' | |
| env: | |
| # Passed via env (not interpolated into the script) per GitHub's | |
| # injection guidance — matching the step above. | |
| VERSION: ${{ steps.release.outputs.version }} | |
| run: git push origin main "v${VERSION}" | |
| # NOTE: the moving major tag (`v0`) is deliberately NOT moved here. It is the | |
| # ref every consumer pins, and moving it before the wheel is on PyPI strands | |
| # `@v0` on an action.yml pin whose version does not exist -- see the `promote` | |
| # job at the bottom of this file, which moves it only after publish-pypi | |
| # succeeds. | |
| - 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 | |
| # NOTE: the GitHub Release is deliberately NOT cut here either. Marketplace | |
| # listings are cut from a published Release, so creating one announces a | |
| # version to consumers -- which must not happen before the wheel is on PyPI. | |
| # It moved to the `promote` job at the bottom of this file, alongside the | |
| # major-tag move, for the same reason. | |
| # 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. | |
| # | |
| # NO `if:` ON THIS JOB, deliberately. It used to carry | |
| # `if: needs.release.outputs.version != ''`, which was both dead and dangerous. Dead: | |
| # "Resolve published version" already `exit 1`s on an empty version, so a successful | |
| # `release` job never produces one. Dangerous: it is the skipped-green shape the | |
| # `promote` header condemns -- if that output failed to carry over into a partial | |
| # "Re-run failed jobs" attempt, this job resolved to SKIPPED, which (since `promote` | |
| # now declares `needs: [release, publish-pypi]`) also skipped the promotion, for a | |
| # fully GREEN run that published no wheel and never moved the major tag. The implicit | |
| # `success()` on `needs: release` is the real gate; emptiness is asserted in-job below, | |
| # so a lost output is RED. | |
| publish-pypi: | |
| name: Publish to PyPI | |
| needs: release | |
| 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: | |
| # The enforcement point for a missing version, now that the job's `if:` no longer | |
| # gates on it (see the header). On a successful `release` job this is always set, | |
| # so an empty value means the output did not carry over into a partial re-run -- | |
| # which must be loud, because the alternative shape was a silent skip. | |
| - name: Validate version carried over | |
| env: | |
| VERSION: ${{ needs.release.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "$VERSION" ]; then | |
| echo "::error title=Release version unavailable::needs.release.outputs.version is empty. On a successful release job it is always set, so the output most likely did not carry over into a partial re-run -- re-run the whole Release workflow's remaining jobs." | |
| exit 1 | |
| fi | |
| echo "publishing coder-eval==$VERSION" | |
| - 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@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 | |
| with: | |
| # Trusted Publisher is configured on pypi.org for this repo + | |
| # workflow (release.yml) + environment (pypi); no password needed. | |
| packages-dir: dist/ | |
| # Required for the `promote` job's recovery story to actually work. Without | |
| # it, a step that fails AFTER a successful upload (lost response, job | |
| # timeout) can never be re-run: PyPI answers 400 "File already exists", this | |
| # job stays permanently red, so `promote` can never run and the major tag is | |
| # never moved for a version that IS published -- the stranded state from the | |
| # other direction. Trusted-Publishing scoping is unaffected. | |
| # | |
| # It does cost something, which the next step buys back: twine treats PyPI's | |
| # 400 "File already exists" as success WITHOUT comparing content, so on its own | |
| # a green publish stops proving that the wheel THIS run built is the one PyPI | |
| # serves. Before this flag, a duplicate upload failed loudly and incidentally | |
| # established that. Nothing else in release -> promote -> verify re-asserts it | |
| # (promote moves `v0` on job success alone; the nightly preflight checks | |
| # reachability, not identity), so the identity assertion is made explicit below. | |
| skip-existing: true | |
| # Re-establish what `skip-existing` gives up: the files PyPI serves for this version | |
| # must be byte-identical to the ones this run built. Without it, a wheel pre-uploaded | |
| # under the release's exact version (compromised maintainer account, leaked legacy | |
| # API token) is silently accepted, `promote` then points `v0` at an action.yml | |
| # pinning it, and every `uses: UiPath/coder_eval@v0` consumer installs it on a fully | |
| # green release. | |
| # | |
| # A mismatch is fatal -- it must stop `promote`. Being unable to READ the index is | |
| # not: the JSON API can lag seconds behind an upload, and a transient must not | |
| # redden a publish that actually succeeded (it would also block the re-run story | |
| # `skip-existing` exists for). So: mismatch => error, unreachable => warning. | |
| # | |
| # Scope, so the guarantee is not read as wider than it is: this is a POINT-IN-TIME | |
| # set-equality check -- the files PyPI serves for this version at the moment the | |
| # step runs are exactly the files this run built. It cannot see an upload that | |
| # lands after it passes, and it says nothing about any other version. | |
| - name: Assert PyPI serves this run's artifacts | |
| env: | |
| VERSION: ${{ needs.release.outputs.version }} | |
| run: | | |
| set -euo pipefail | |
| python3 <<'PY' | |
| import hashlib, json, os, pathlib, sys, time, urllib.error, urllib.request | |
| version = os.environ["VERSION"] | |
| url = f"https://pypi.org/pypi/coder-eval/{version}/json" | |
| payload = None | |
| for attempt in range(1, 7): | |
| try: | |
| with urllib.request.urlopen(url, timeout=30) as resp: # noqa: S310 - fixed https URL | |
| payload = json.load(resp) | |
| break | |
| except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: | |
| print(f"attempt {attempt}: could not read {url} ({exc}); waiting for propagation...") | |
| time.sleep(20) | |
| if payload is None: | |
| print(f"::warning title=Artifact identity unverified::could not read {url} after 6 attempts. " | |
| "The publish itself succeeded; this check is inconclusive, not a failure. The nightly " | |
| "Verify Published Action workflow re-checks the pin.") | |
| sys.exit(0) | |
| remote = {u["filename"]: (u.get("digests") or {}).get("sha256") for u in payload.get("urls") or []} | |
| # gh-action-pypi-publish v1.14+ writes `<dist>.publish.attestation` | |
| # sidecars into packages-dir before upload. PyPI serves attestations | |
| # via the integrity API, never as distribution files, so the sidecars | |
| # must not enter the set-equality comparison below -- with them, this | |
| # assert fails on every release even when the publish is complete and | |
| # attested (observed on the v0.10.1 run). | |
| local = sorted( | |
| p for p in pathlib.Path("dist").iterdir() | |
| if p.is_file() and not p.name.endswith(".publish.attestation") | |
| ) | |
| if not local: | |
| print("::error::no files in dist/ to compare -- the download-artifact step produced nothing") | |
| sys.exit(1) | |
| bad = [] | |
| for path in local: | |
| want = hashlib.sha256(path.read_bytes()).hexdigest() | |
| got = remote.get(path.name) | |
| if got is None: | |
| bad.append(f"{path.name}: not present on PyPI for {version}") | |
| elif got != want: | |
| bad.append(f"{path.name}: PyPI serves sha256 {got}, this run built {want}") | |
| else: | |
| print(f" {path.name}: sha256 matches ({want[:12]}...)") | |
| # Digest-matching every local file proves nothing about files we did NOT build. | |
| # An EXTRA distribution under our exact version is the more dangerous half of | |
| # the threat this step exists for: installers prefer a platform-specific wheel | |
| # over our `py3-none-any`, so a single planted `...-cp313-manylinux_*.whl` would | |
| # be what `uv tool install coder-eval==<version>` (action.yml) actually resolves, | |
| # while every file we built still matches byte-for-byte. Set equality, not | |
| # containment. | |
| extra = sorted(set(remote) - {p.name for p in local}) | |
| if extra: | |
| bad.append(f"PyPI serves {len(extra)} file(s) this run did not build: {', '.join(extra)}") | |
| if bad: | |
| print("::error title=Published artifact is not ours::PyPI does not serve the artifacts this run " | |
| f"built for {version}: " + "; ".join(bad) + ". Do NOT promote: investigate before moving " | |
| "the major tag, since `v0` would point every consumer at these files.") | |
| sys.exit(1) | |
| print(f"PyPI serves exactly the {len(local)} artifact(s) this run built for {version}.") | |
| PY | |
| # Everything CONSUMER-VISIBLE happens here, and only after the wheel is actually | |
| # on PyPI: the moving major tag (`v0`, what every consumer pins) and the GitHub | |
| # Release (what the Marketplace listing is cut from). | |
| # | |
| # WHY A SEPARATE JOB. The composite action installs `coder-eval==<action.yml's | |
| # version: default>`, and the release commit bumps that pin. So moving `v0` before | |
| # the wheel exists points every `uses: UiPath/coder_eval@v0` consumer at a pin | |
| # that cannot resolve -- `uv tool install` 404s and their pipeline breaks. That was | |
| # reachable two ways while both steps lived in the `release` job: publish-pypi is a | |
| # separate `needs: release` job that can fail or sit waiting on the `pypi` | |
| # environment gate, AND the tag move sat *before* "Build wheel + sdist", so a build | |
| # failure stranded the pin without PyPI being involved at all. Ordering the tag move | |
| # after the publish removes both, rather than detecting them after the fact. | |
| # | |
| # RE-RUNNABILITY IS THE POINT. The `release` job is NOT re-runnable -- re-running it | |
| # would bump and tag a second version. This job is: the tag move is force-push | |
| # idempotent and the Release create is existence-guarded. So a failure here (or in | |
| # publish-pypi) is recovered by re-running the failed jobs from the Actions tab, | |
| # with `v0` still pointing at the last fully-published release the whole time. That | |
| # is why these steps can now fail LOUDLY instead of being swallowed by | |
| # `continue-on-error` -- the previous best-effort + annotation dance existed only | |
| # because a failure would have skipped publish-pypi and stranded the tag. | |
| # | |
| # RESIDUAL, ACCEPTED: the exact-version tag `vX.Y.Z` and `main` are pushed by the | |
| # `release` job, so if publish-pypi fails they briefly reference an unpublished | |
| # version. Narrower than the `v0` window by design -- `@v0` is the documented pin | |
| # (see action.yml's header) and `@vX.Y.Z`/`@main` are opt-in -- and cleared by | |
| # re-running publish-pypi. Closing it entirely would mean publishing to PyPI before | |
| # pushing any git ref, which requires carrying the bumped commit + tag between jobs | |
| # as an artifact; not worth the new failure modes. | |
| # | |
| # NOT COVERED HERE, deliberately: the GHCR agent image. "Build and push versioned | |
| # agent image" stays in the `release` job, pushing `:<version>` and moving `:latest` | |
| # before publish-pypi runs, all under `continue-on-error: true`. So a release whose | |
| # PyPI publish fails still advertises `:latest` for a version absent from PyPI. That | |
| # is accepted rather than overlooked: the image is an INTERNAL convenience (the | |
| # nightly's sandbox base, docs/DOCKER_ISOLATION.md), not a ref a stranger's pipeline | |
| # resolves, and it must be built in the job that holds the bumped pyproject -- moving | |
| # it here would mean re-running buildx and the private-index secrets in a second job | |
| # to protect a best-effort artifact. `v0` is the consumer contract; the image is not. | |
| promote: | |
| name: Promote major tag and cut GitHub Release | |
| needs: [release, publish-pypi] | |
| # Real releases only, discriminated on the DISPATCHED REF rather than on a `needs` | |
| # output. Prerelease mode is defined by the ref (see "Determine release mode"), so | |
| # this is the same signal, and it cannot silently evaporate: were this gated on | |
| # `needs.release.outputs.released_version != ''` and that output failed to carry | |
| # over into a partial "Re-run failed jobs" attempt, the job would resolve to | |
| # SKIPPED-GREEN -- the operator sees a green re-run while the major tag never moves | |
| # and no Release is cut. Emptiness is enforced inside the job instead, by | |
| # "Validate version shape", so a lost output is a RED job, not a silent no-op. | |
| if: github.ref == 'refs/heads/main' | |
| runs-on: uipath-ubuntu-latest | |
| timeout-minutes: 10 | |
| # Every write in this job goes through the app token below, so GITHUB_TOKEN needs | |
| # nothing beyond read. Declared explicitly to drop the workflow-level | |
| # `packages: write`, which exists only for the GHCR steps in the `release` job. | |
| permissions: | |
| contents: read | |
| steps: | |
| # Pushing the major tag needs the release app's credentials, same as the | |
| # version-tag push in the `release` job: the workflow's GITHUB_TOKEN is | |
| # contents: read, and tag writes are the app's job. | |
| - 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 }} | |
| # Scoped explicitly (see the `release` job's mint): the only writes are the | |
| # major-tag re-point and `gh release create`, both contents. | |
| permission-contents: write | |
| # Two jobs in one: (1) the version is interpolated into `ref:` below, so pin its | |
| # shape first -- defence-in-depth against a malformed value producing a surprising | |
| # ref (the value is `semantic-release version --print` output, first-party, not | |
| # untrusted input); (2) this is the ENFORCEMENT POINT for a missing version, which | |
| # the job's `if:` deliberately no longer gates on. An empty string fails the regex, | |
| # so a `needs` output lost across a partial re-run surfaces as a red job with a | |
| # clear message instead of a silently skipped promotion. | |
| - name: Validate version shape | |
| env: | |
| VERSION: ${{ needs.release.outputs.released_version }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "$VERSION" ]; then | |
| echo "::error title=Release version unavailable::needs.release.outputs.released_version is empty. On a real release it is always set, so this most likely means the output did not carry over into a partial re-run -- re-run the whole Release workflow's remaining jobs, or promote by hand." | |
| exit 1 | |
| fi | |
| [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { | |
| echo "::error::refusing to promote a malformed version: '$VERSION'"; exit 1; } | |
| echo "promoting v$VERSION" | |
| # Check out the released TAG, not main: main may have advanced since the | |
| # release job ran, and the CHANGELOG slice below must be the one that shipped | |
| # with this version. | |
| - name: Checkout released tag | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| ref: refs/tags/v${{ needs.release.outputs.released_version }} | |
| fetch-depth: 0 # need tag objects to re-point the major tag | |
| token: ${{ steps.app-token.outputs.token }} | |
| # The `v0` promotion itself. Force-move makes re-running THIS run's job safe, but | |
| # force-push is only self-idempotent -- it says nothing about ORDERING. GitHub | |
| # keeps "Re-run failed jobs" available for 30 days, so replaying an OLD release's | |
| # promote (e.g. 0.9.5 failed at publish-pypi, the operator moved on and shipped | |
| # 0.9.6, then later cleaned up the red 0.9.5 run) would walk `v0` BACKWARDS and | |
| # silently downgrade every consumer. The monotonicity guard below is what makes | |
| # "re-running is safe" actually true. No `-a`/`-m`, so this is a lightweight tag: | |
| # a plain ref write needing no committer identity. | |
| - name: Move major action tag (vN -> this release) | |
| env: | |
| VERSION: ${{ needs.release.outputs.released_version }} | |
| run: | | |
| set -euo pipefail | |
| MAJOR="v${VERSION%%.*}" | |
| # Refuse to promote anything but the newest release tag. | |
| NEWEST=$(git tag -l 'v*' --sort=-v:refname \ | |
| | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 || true) | |
| if [ -z "$NEWEST" ]; then | |
| echo "::error::no vX.Y.Z release tag found; refusing to move $MAJOR"; exit 1 | |
| fi | |
| if [ "$NEWEST" != "v${VERSION}" ]; then | |
| echo "::error title=Refusing to move $MAJOR backwards::this run promotes v${VERSION}, but ${NEWEST} is the newest release tag. Moving $MAJOR would downgrade every 'uses: UiPath/coder_eval@$MAJOR' consumer. If you are recovering an old release, promote ${NEWEST} instead." | |
| exit 1 | |
| fi | |
| git tag -f "$MAJOR" "v${VERSION}" | |
| git push -f origin "$MAJOR" | |
| echo "Moved $MAJOR -> v${VERSION} (coder-eval==${VERSION} is on PyPI)" | |
| # semantic-release runs with --no-vcs-release (it also runs --no-push, and the | |
| # commit is amended + the tag re-pointed afterwards), so it cannot create the | |
| # Release itself -- it happens here, once the tag is on the remote AND the wheel | |
| # is published. 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.) | |
| # | |
| # Notes are the CHANGELOG section semantic-release 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 | |
| env: | |
| GH_TOKEN: ${{ steps.app-token.outputs.token }} | |
| # Passed via env (not interpolated into the script) per GitHub's | |
| # injection guidance. | |
| VERSION: ${{ needs.release.outputs.released_version }} | |
| run: | | |
| set -euo pipefail | |
| # Existence-guarded so re-running this job after a partial failure is a | |
| # no-op rather than a "release already exists" error. `gh release view` | |
| # also matches a DRAFT or prerelease, which would announce nothing to the | |
| # Marketplace -- so normalize rather than trusting mere existence, keeping | |
| # this job idempotent in fact and not just in the happy case. | |
| if gh release view "v${VERSION}" >/dev/null 2>&1; then | |
| gh release edit "v${VERSION}" --draft=false --prerelease=false --latest | |
| echo "GitHub Release v${VERSION} already existed — normalized to published/latest." | |
| exit 0 | |
| fi | |
| # 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). The sdist is built in the `release` job, not here, | |
| # but keeping the convention avoids re-learning it if that ever changes. | |
| 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[@]}" |