-
Notifications
You must be signed in to change notification settings - Fork 2
439 lines (411 loc) · 21.8 KB
/
Copy pathrelease.yml
File metadata and controls
439 lines (411 loc) · 21.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
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
# GitHub-hosted so cutting a release does not depend on the self-hosted
# `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:
# 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:
# The self-hosted `uipath-ubuntu-latest` runners enforce a minimum
# package-age safe-chain check on uv installs; on GitHub-hosted runners
# this is a no-op. Kept (matching pr-checks.yml) so parity is preserved
# if the job ever moves back to the self-hosted pool.
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 on
# GitHub-hosted runners -- 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: 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/