-
Notifications
You must be signed in to change notification settings - Fork 3
691 lines (650 loc) · 37.7 KB
/
Copy pathrelease.yml
File metadata and controls
691 lines (650 loc) · 37.7 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
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
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[@]}"