-
Notifications
You must be signed in to change notification settings - Fork 963
456 lines (421 loc) · 17.6 KB
/
Copy pathrelease-python.yml
File metadata and controls
456 lines (421 loc) · 17.6 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
name: "Python: Release"
# Manual release of `strands-agents` to PyPI.
# Setup, recovery, fork-testing: .github/workflows/RELEASE.md
#
# Shape: scan → lint/unit/integ/audit/build → inspect → notes → approve →
# tag + release → publish.
#
# Publish is the last step. Everything before it runs on a fork too — only
# the OIDC upload fails there, because the fork is not the registered
# trusted publisher. See RELEASE.md → "Fork testability".
on:
workflow_dispatch:
inputs:
version:
description: 'Explicit version, e.g. 1.4.0 (no leading v, no prefix).'
required: true
type: string
sha:
description: 'Optional commit SHA to release (must be an ancestor of origin/main). Defaults to current origin/main.'
required: false
default: ''
type: string
dry_run:
description: 'Skip approval + tag + publish. Build/inspect/notes still run so you can review the artifact on the run page.'
required: true
default: true
type: boolean
run_integ_tests:
description: 'Run integration tests. No effect on upstream (integ always runs there). On a fork: false skips integ; true runs integ but requires the fork to have its own AWS credentials configured.'
required: true
default: false
type: boolean
concurrency:
group: release-python
cancel-in-progress: false
jobs:
scan-commits:
name: Resolve SHA and validate version
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
release_sha: ${{ steps.scan.outputs.release_sha }}
prev_tag: ${{ steps.scan.outputs.prev_tag }}
new_tag: ${{ steps.scan.outputs.new_tag }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
persist-credentials: false
- name: Resolve SHA, find previous tag, validate version
id: scan
env:
NEW_VERSION: ${{ inputs.version }}
SHA_INPUT: ${{ inputs.sha }}
run: |
set -euo pipefail
# 1. Validate the typed version (bare semver, no prefix).
if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::version '$NEW_VERSION' is not bare semver (expected MAJOR.MINOR.PATCH, no 'v')."
exit 1
fi
# 2. Resolve the SHA. Default to origin/main; if a sha was typed,
# accept it only if it's an ancestor of origin/main.
if [ -n "$SHA_INPUT" ]; then
RELEASE_SHA=$(git rev-parse --verify "$SHA_INPUT^{commit}" 2>/dev/null) || {
echo "::error::sha '$SHA_INPUT' is not a valid commit."
exit 1
}
if ! git merge-base --is-ancestor "$RELEASE_SHA" origin/main; then
echo "::error::sha $RELEASE_SHA is not an ancestor of origin/main — release only from main history."
exit 1
fi
else
RELEASE_SHA=$(git rev-parse origin/main)
fi
# 3. Pick tag prefix and resolve previous tag.
TAG_PREFIX="python/v"
PREV_TAG=$(git tag --list "${TAG_PREFIX}*" --sort=-v:refname | head -n1)
if [ -z "$PREV_TAG" ]; then
echo "::error::No prior tag matching ${TAG_PREFIX}* — refusing to release without a baseline. (Fork-testing? Run \`git fetch upstream --tags && git push origin --tags\` first.)"
exit 1
fi
PREV_VERSION="${PREV_TAG#"${TAG_PREFIX}"}"
NEW_TAG="${TAG_PREFIX}${NEW_VERSION}"
# 4. Reject duplicate / non-monotonic / pre-existing tags.
if [ "$NEW_VERSION" = "$PREV_VERSION" ]; then
echo "::error::version $NEW_VERSION matches existing tag $PREV_TAG."
exit 1
fi
HIGHER=$(printf '%s\n%s\n' "$PREV_VERSION" "$NEW_VERSION" | sort -V | tail -n1)
if [ "$HIGHER" != "$NEW_VERSION" ]; then
echo "::error::version $NEW_VERSION is not greater than previous $PREV_VERSION."
exit 1
fi
if git rev-parse --verify "refs/tags/$NEW_TAG" >/dev/null 2>&1; then
echo "::error::tag $NEW_TAG already exists."
exit 1
fi
# 5. Sanity-check: at least one commit since prev tag.
COUNT=$(git rev-list --count "$PREV_TAG..$RELEASE_SHA")
if [ "$COUNT" = "0" ]; then
echo "::error::no commits between $PREV_TAG and $RELEASE_SHA — nothing to release."
exit 1
fi
{
echo "release_sha=$RELEASE_SHA"
echo "prev_tag=$PREV_TAG"
echo "new_tag=$NEW_TAG"
} >> "$GITHUB_OUTPUT"
{
echo "### Release scan"
echo ""
echo "| | |"
echo "|---|---|"
echo "| Previous tag | \`$PREV_TAG\` (v$PREV_VERSION) |"
echo "| Proposed tag | \`$NEW_TAG\` (v$NEW_VERSION) |"
echo "| Pinned SHA | \`$RELEASE_SHA\` |"
echo "| Commits since prev tag | **$COUNT** |"
} >> "$GITHUB_STEP_SUMMARY"
# ── Lint + unit-test gates ─────────────────────────────────────────────
test-lint:
name: Python lint + unit tests
needs: scan-commits
uses: ./.github/workflows/python-test-lint.yml
permissions:
contents: read
with:
ref: ${{ needs.scan-commits.outputs.release_sha }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# ── Integration tests ──────────────────────────────────────────────────
# Always runs on upstream. On a fork: opt-in via `run_integ_tests: true`
# (fork needs its own AWS credentials).
integ:
name: Python integration tests
needs: scan-commits
if: github.event.repository.fork != true || inputs.run_integ_tests == true
uses: ./.github/workflows/python-integration-test.yml
permissions:
id-token: write
contents: read
pull-requests: read
secrets: inherit
with:
ref: ${{ needs.scan-commits.outputs.release_sha }}
# ── Security audit (informational) ─────────────────────────────────────
security-audit:
name: Python security audit
needs: scan-commits
uses: ./.github/workflows/python-security-audit.yml
permissions:
contents: read
with:
ref: ${{ needs.scan-commits.outputs.release_sha }}
# ── Build + smoke test (uploads pypi-build-output artifact) ────────────
package-build:
name: Python wheel install smoke test
needs: scan-commits
uses: ./.github/workflows/python-test-package-build.yml
permissions:
contents: read
with:
ref: ${{ needs.scan-commits.outputs.release_sha }}
version: ${{ inputs.version }}
# ── Inspect the built artifact ─────────────────────────────────────────
# Downloads pypi-build-output, checks metadata and version, re-uploads
# as pypi-dist-bundle. publish-pypi pulls from the same bundle, so the
# bytes on the run page are the bytes uploaded to PyPI.
inspect:
name: Inspect Python dists
needs: package-build
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Download build output
uses: actions/download-artifact@v8
with:
name: pypi-build-output
path: dist
- uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: List wheel + sdist contents
run: |
set -euo pipefail
for f in dist/*.whl; do
echo "::group::$f"; python -m zipfile -l "$f"; echo "::endgroup::"
done
for f in dist/*.tar.gz; do
echo "::group::$f"; tar tzf "$f"; echo "::endgroup::"
done
- name: Assert artifacts are stamped with the typed version
# Wheel filenames encode the version (PEP 427) and publish cannot
# change them. The build job already checks `strands.__version__`
# at install time — this catches the filename side.
env:
EXPECTED: ${{ inputs.version }}
run: |
python - <<'PYEOF'
import os
import re
import sys
from pathlib import Path
expected = os.environ["EXPECTED"]
errors = []
checked = 0
for f in sorted(Path("dist").iterdir()):
name = f.name
if name.endswith(".whl"):
m = re.match(r"^[^-]+-([^-]+)-", name)
elif name.endswith(".tar.gz"):
m = re.match(r"^[^-]+-(.+)\.tar\.gz$", name)
else:
continue
checked += 1
version = m.group(1) if m else None
if version != expected:
errors.append(f"{name}: version is {version!r}, expected {expected!r}")
if not checked:
print("::error::No wheel or sdist found in dist/", file=sys.stderr)
sys.exit(1)
if errors:
for e in errors:
print(f"::error::{e}")
sys.exit(1)
print(f"All {checked} artifact(s) stamped with version {expected}")
PYEOF
- name: twine check (metadata + README rendering)
run: |
pip install --no-cache-dir twine
twine check dist/*
- name: Upload verified bundle for publish
uses: actions/upload-artifact@v7
with:
name: pypi-dist-bundle
path: dist/*
if-no-files-found: error
retention-days: 30
# ── Draft notes grouped by commit type ─────────────────────────────────
# Polished release notes live on the website. This step just gives
# reviewers a quick "what landed since prev tag" view.
draft-notes:
name: Draft release notes (grouped by commit type)
needs:
- scan-commits
- test-lint
- integ
- security-audit
- package-build
- inspect
if: |
always() &&
needs.scan-commits.result == 'success' &&
needs.test-lint.result == 'success' &&
(needs.integ.result == 'success' || needs.integ.result == 'skipped') &&
needs.security-audit.result == 'success' &&
needs.package-build.result == 'success' &&
needs.inspect.result == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.scan-commits.outputs.release_sha }}
fetch-depth: 0
fetch-tags: true
persist-credentials: false
- name: Render notes
env:
PREV_TAG: ${{ needs.scan-commits.outputs.prev_tag }}
NEW_REF: ${{ needs.scan-commits.outputs.release_sha }}
NEW_TAG: ${{ needs.scan-commits.outputs.new_tag }}
run: |
set -euo pipefail
# Feed commits to the grouper as `hash<TAB>subject` lines and let
# it bucket them by conventional-commit type (feat, fix, ...).
git log --no-merges --pretty=format:'%h%x09%s' "$PREV_TAG..$NEW_REF" \
| NEW_TAG="$NEW_TAG" PREV_TAG="$PREV_TAG" \
python .github/scripts/group-release-notes.py > release-notes.md
- name: Render release summary
env:
NEW_TAG: ${{ needs.scan-commits.outputs.new_tag }}
RELEASE_SHA: ${{ needs.scan-commits.outputs.release_sha }}
DRY_RUN: ${{ inputs.dry_run }}
IS_FORK: ${{ github.event.repository.fork }}
run: |
set -euo pipefail
{
echo "## Release proposal"
echo ""
echo "| | |"
echo "|---|---|"
echo "| Package | \`strands-agents\` (Python) |"
echo "| Proposed tag | \`$NEW_TAG\` |"
echo "| Pinned SHA | \`$RELEASE_SHA\` |"
echo "| Dry run | \`$DRY_RUN\` |"
echo "| Running on fork | \`$IS_FORK\` |"
echo ""
echo "> Reviewers: the verified artifact is uploaded as \`pypi-dist-bundle\` on this run's page. Download and install in a fresh venv to sanity-check before approving."
echo ""
echo "### Drafted notes"
echo ""
cat release-notes.md
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload release notes artifact
uses: actions/upload-artifact@v7
with:
name: release-notes
path: release-notes.md
retention-days: 30
# ── Reviewer approval (skipped on dry runs) ────────────────────────────
approve-release:
name: Wait for reviewer approvals
needs: [scan-commits, draft-notes]
if: inputs.dry_run != true
runs-on: ubuntu-latest
environment:
# Shared with release-typescript.yml. Configure reviewers + main-only
# deployment branches in repo settings.
#
# On forks the environment has no reviewer config, so this just
# passes — the publish step is the one that fails there.
name: release-gate
permissions: {}
steps:
- name: Acknowledge approval
env:
NEW_TAG: ${{ needs.scan-commits.outputs.new_tag }}
RELEASE_SHA: ${{ needs.scan-commits.outputs.release_sha }}
run: |
echo "Approved to release $NEW_TAG at $RELEASE_SHA."
# ── Tag + GitHub release ───────────────────────────────────────────────
# Runs before publish so the tag is the source of truth. If publish
# fails, the tag stays — just re-run publish. Runs on forks too; creates
# the release on the fork's repo so fork-tests go end-to-end.
create-gh-release:
name: Create GitHub release
needs: [scan-commits, draft-notes, approve-release]
if: inputs.dry_run != true
runs-on: ubuntu-latest
permissions:
contents: write
# actions: write lets this job dispatch the changelog-sync workflow below.
actions: write
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.scan-commits.outputs.release_sha }}
# This job creates the tag, so keep the GITHUB_TOKEN in git config
# for the push below. Other jobs use persist-credentials: false.
persist-credentials: true
- name: Download release notes artifact
uses: actions/download-artifact@v8
with:
name: release-notes
- name: Tag the pinned SHA and create the release
env:
GH_TOKEN: ${{ github.token }}
NEW_TAG: ${{ needs.scan-commits.outputs.new_tag }}
RELEASE_SHA: ${{ needs.scan-commits.outputs.release_sha }}
run: |
set -euo pipefail
# Push the tag over git rather than letting `gh release create
# --target <sha>` create it through the releases API. The API path
# returns "403 Resource not accessible by integration" for the
# GITHUB_TOKEN (see cli/cli#9514); a plain ref push with the same
# token succeeds.
#
# Both steps are guarded so re-running the job after a partial
# failure is safe: if a prior run pushed the tag but errored before
# the release was made, git push would otherwise refuse the existing
# tag. This keeps the "tag is source of truth, just re-run" contract.
if ! git ls-remote --exit-code --tags origin "refs/tags/$NEW_TAG" >/dev/null 2>&1; then
git tag "$NEW_TAG" "$RELEASE_SHA"
git push origin "refs/tags/$NEW_TAG"
fi
# Tag now exists on the remote, so this only attaches notes to it.
if ! gh release view "$NEW_TAG" >/dev/null 2>&1; then
gh release create "$NEW_TAG" \
--title "$NEW_TAG" \
--notes-file release-notes.md
fi
# Trigger the changelog sync directly. A release created with the built-in
# GITHUB_TOKEN (above) does NOT emit a `release` event, by design, so the
# Changelog: Sync workflow's `on: release` trigger never fires from here --
# dispatch it explicitly instead. Non-fatal by design (`|| echo`): a
# changelog hiccup must not fail a release that already published its tag,
# and the daily cron backstops -- but surface a warning annotation so the
# failure is visible now rather than only after the ~24h cron.
- name: Trigger changelog sync
env:
GH_TOKEN: ${{ github.token }}
NEW_TAG: ${{ needs.scan-commits.outputs.new_tag }}
run: |
gh workflow run changelog-sync.yml -f tag="$NEW_TAG" \
|| echo "::warning::Failed to dispatch changelog sync for $NEW_TAG; the daily cron will backstop."
# ── Publish to PyPI ────────────────────────────────────────────────────
# Last step. On a fork this fails at the OIDC step — by design.
publish-pypi:
name: Publish to PyPI
needs: [inspect, approve-release, create-gh-release]
if: inputs.dry_run != true
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/strands-agents
permissions:
id-token: write
contents: read
steps:
- name: Download verified dists
uses: actions/download-artifact@v8
with:
name: pypi-dist-bundle
path: dist
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1