Skip to content

Commit ae5d5af

Browse files
committed
chore(workflow): added release workflow
1 parent ca1c347 commit ae5d5af

6 files changed

Lines changed: 816 additions & 4 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Draft release notes for a Strands Agents package via Bedrock.
2+
3+
The maintainer types the explicit version at workflow_dispatch time. This
4+
script's `bump` output is purely advisory — surfaced in the run summary so
5+
reviewers can sanity-check the typed version against what the commits suggest.
6+
The version is NOT derived from this script.
7+
8+
Inputs (env):
9+
PACKAGE "python" or "typescript"
10+
PREV_TAG previous release tag (e.g. "python/v1.2.3")
11+
NEW_REF git ref or SHA being released (typically a pinned SHA)
12+
BEDROCK_MODEL inference profile / model id (defaults to a Claude Sonnet on Bedrock)
13+
AWS_REGION defaults to us-west-2 (matches strands-command.yml)
14+
15+
Outputs (files in $GITHUB_WORKSPACE):
16+
release-notes.md markdown release notes
17+
bump.txt advisory bump: one of "major", "minor", "patch"
18+
19+
Exit codes:
20+
0 notes drafted successfully
21+
1 no commits between PREV_TAG and NEW_REF
22+
2 Bedrock call failed or returned unparseable output
23+
(the caller workflow falls back to `git shortlog` on non-zero exit)
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import json
29+
import os
30+
import subprocess
31+
import sys
32+
from pathlib import Path
33+
34+
import boto3
35+
from botocore.exceptions import BotoCoreError, ClientError
36+
37+
38+
WORKSPACE = Path(os.environ.get("GITHUB_WORKSPACE", "."))
39+
NOTES_PATH = WORKSPACE / "release-notes.md"
40+
BUMP_PATH = WORKSPACE / "bump.txt"
41+
42+
43+
def run(cmd: list[str]) -> str:
44+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
45+
return result.stdout
46+
47+
48+
def collect_commits(prev_tag: str, new_ref: str) -> str:
49+
"""Return the git log between prev_tag and new_ref, one commit per block."""
50+
return run(
51+
[
52+
"git",
53+
"log",
54+
f"{prev_tag}..{new_ref}",
55+
"--pretty=format:--- COMMIT ---%n%H%n%an%n%s%n%b",
56+
"--no-merges",
57+
]
58+
)
59+
60+
61+
def collect_diff_stats(prev_tag: str, new_ref: str) -> str:
62+
"""Return per-file change stats so the model can reason about scope."""
63+
return run(["git", "diff", "--stat", f"{prev_tag}...{new_ref}"])
64+
65+
66+
def build_prompt(
67+
package: str,
68+
prev_tag: str,
69+
new_ref: str,
70+
commits: str,
71+
diff_stats: str,
72+
) -> str:
73+
return f"""You are drafting release notes for the {package} package of the Strands Agents SDK.
74+
75+
Previous tag: `{prev_tag}`
76+
New ref: `{new_ref}`
77+
78+
## Your task
79+
80+
1. Suggest a semver bump (major / minor / patch) from the commits using
81+
Conventional Commits as a guide: `feat:` -> minor, `fix:` / `refactor:` /
82+
`perf:` / `docs:` -> patch, anything with `BREAKING CHANGE:` in the body or
83+
`!:` in the subject -> major. If the commits show API changes that aren't
84+
flagged with `!`, still call it major and say why. This suggestion is
85+
ADVISORY — the maintainer types the actual version separately.
86+
87+
2. Draft user-facing release notes in markdown, grouped under these headings
88+
(omit headings with no entries):
89+
- **Breaking changes**
90+
- **Features**
91+
- **Bug fixes**
92+
- **Other changes** (docs, refactors, internal)
93+
94+
3. Each bullet should be one line, written for users, referencing the commit
95+
hash in parens.
96+
97+
## Output format
98+
99+
Return a single JSON object with exactly two keys:
100+
- `bump`: one of "major", "minor", "patch"
101+
- `notes`: the markdown release notes as a single string
102+
103+
Do not wrap the JSON in markdown fences or any prose. The first character of
104+
your response must be `{{` and the last must be `}}`.
105+
106+
## Commits since last release
107+
108+
```
109+
{commits}
110+
```
111+
112+
## Diff stats
113+
114+
```
115+
{diff_stats}
116+
```
117+
"""
118+
119+
120+
def call_bedrock(prompt: str, model_id: str, region: str) -> dict:
121+
client = boto3.client("bedrock-runtime", region_name=region)
122+
response = client.converse(
123+
modelId=model_id,
124+
messages=[{"role": "user", "content": [{"text": prompt}]}],
125+
inferenceConfig={"maxTokens": 4096, "temperature": 0.2},
126+
)
127+
text = response["output"]["message"]["content"][0]["text"].strip()
128+
return json.loads(text)
129+
130+
131+
def main() -> int:
132+
package = os.environ["PACKAGE"]
133+
prev_tag = os.environ["PREV_TAG"]
134+
new_ref = os.environ["NEW_REF"]
135+
model_id = os.environ.get(
136+
"BEDROCK_MODEL", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
137+
)
138+
region = os.environ.get("AWS_REGION", "us-west-2")
139+
140+
commits = collect_commits(prev_tag, new_ref)
141+
if not commits.strip():
142+
print(f"No commits between {prev_tag} and {new_ref} — nothing to release.")
143+
return 1
144+
145+
diff_stats = collect_diff_stats(prev_tag, new_ref)
146+
prompt = build_prompt(package, prev_tag, new_ref, commits, diff_stats)
147+
148+
try:
149+
result = call_bedrock(prompt, model_id, region)
150+
except (BotoCoreError, ClientError) as exc:
151+
# Network / IAM / throttling. Caller workflow handles fallback.
152+
print(f"Bedrock call failed: {exc}", file=sys.stderr)
153+
return 2
154+
except json.JSONDecodeError as exc:
155+
# Model returned non-JSON. Surface it for the reviewer.
156+
print(f"Could not parse Bedrock response as JSON: {exc}", file=sys.stderr)
157+
return 2
158+
159+
bump = result.get("bump", "unknown")
160+
notes = result.get("notes", "").strip()
161+
if not notes:
162+
print("Bedrock response had no `notes` content.", file=sys.stderr)
163+
return 2
164+
165+
NOTES_PATH.write_text(notes)
166+
BUMP_PATH.write_text(bump)
167+
print(f"Wrote {NOTES_PATH} ({len(notes)} chars)")
168+
print(f"Wrote {BUMP_PATH} -> {bump} (advisory)")
169+
return 0
170+
171+
172+
if __name__ == "__main__":
173+
sys.exit(main())

.github/workflows/python-integration-test.yml

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,74 @@ on:
88
- '.github/workflows/python-*'
99
merge_group:
1010
types: [checks_requested]
11+
# === workflow_call entry point — TRUST INVARIANT ===
12+
#
13+
# This trigger lets another workflow in THIS repo reuse the integ tests
14+
# against an arbitrary `ref` while keeping access to AWS_*. The
15+
# authorization-check below is skipped on this path because the only caller
16+
# today (release.yml) is `workflow_dispatch`-gated, which requires repo
17+
# write access to invoke.
18+
#
19+
# Before adding a new caller, verify ALL of the following:
20+
# 1. The caller cannot be triggered by an unauthenticated user or a fork
21+
# (so: NOT pull_request, NOT pull_request_target without an auth gate,
22+
# NOT push from any branch except main).
23+
# 2. The caller cannot be coerced into passing a fork-PR ref
24+
# (refs/pull/.../merge) — see `validate-call-ref` job below for the
25+
# runtime guard, but the caller should not need to be coerced if (1)
26+
# holds.
27+
# 3. AWS_ROLE / STRANDS_INTEG_TEST_ROLE secrets are scoped to least
28+
# privilege.
29+
#
30+
# Skipping these checks effectively gives the new caller's trigger surface
31+
# the same trust as a maintainer-typed `Run workflow` click.
32+
workflow_call:
33+
inputs:
34+
ref:
35+
required: true
36+
type: string
1137

1238
jobs:
39+
validate-call-ref:
40+
name: Validate workflow_call ref
41+
if: inputs.ref != ''
42+
runs-on: ubuntu-latest
43+
permissions:
44+
contents: read
45+
# Defense in depth for the workflow_call entry point. `inputs.ref` is
46+
# supplied by the caller; rejecting fork-PR-shaped refs here means a
47+
# future caller cannot accidentally check out untrusted code with secrets
48+
# loaded. Branches and SHAs pass; `refs/pull/*/merge` and cross-repo refs
49+
# (`owner:branch`) fail.
50+
steps:
51+
- name: Reject untrusted-shaped refs
52+
env:
53+
REF: ${{ inputs.ref }}
54+
IS_FORK: ${{ github.event.repository.fork }}
55+
run: |
56+
set -euo pipefail
57+
if [ "$IS_FORK" = "true" ]; then
58+
echo "::error::workflow_call invoked on a fork — refusing to run with secrets."
59+
exit 1
60+
fi
61+
case "$REF" in
62+
refs/pull/*|*:*)
63+
echo "::error::workflow_call ref '$REF' looks like a fork PR or cross-repo ref."
64+
exit 1
65+
;;
66+
esac
67+
echo "ref '$REF' passed shape check."
68+
1369
authorization-check:
1470
name: Check access
71+
needs: [validate-call-ref]
72+
# `needs` only runs after non-skipped predecessors. validate-call-ref is
73+
# skipped on pull_request_target / merge_group (inputs.ref is empty), so
74+
# we use `if: always()` to ensure auth-check still runs there. On
75+
# workflow_call, validate-call-ref must have succeeded.
76+
if: |
77+
always() &&
78+
(needs.validate-call-ref.result == 'success' || needs.validate-call-ref.result == 'skipped')
1579
permissions:
1680
contents: read
1781
runs-on: ubuntu-latest
@@ -22,7 +86,10 @@ jobs:
2286
id: auth
2387
uses: strands-agents/devtools/authorization-check@main
2488
with:
25-
skip-check: ${{ github.event_name == 'merge_group' }}
89+
# Skip on merge_group (gated by branch protection) and on
90+
# workflow_call (the caller — release.yml — is workflow_dispatch
91+
# gated, see the trigger comment above).
92+
skip-check: ${{ github.event_name == 'merge_group' || github.event_name == 'workflow_call' }}
2693
username: ${{ github.event.pull_request.user.login || 'invalid' }}
2794
allowed-roles: 'maintain,triage,write,admin'
2895

@@ -50,7 +117,7 @@ jobs:
50117
- name: Checkout head commit
51118
uses: actions/checkout@v7
52119
with:
53-
ref: ${{ github.event.pull_request.head.sha }}
120+
ref: ${{ inputs.ref || github.event.pull_request.head.sha }}
54121
persist-credentials: false
55122
allow-unsafe-pr-checkout: true # opt back into fork-PR checkout, blocked by default in checkout@v7
56123
- name: Set up Python
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: "Python: Security Audit"
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
ref:
7+
required: true
8+
type: string
9+
10+
jobs:
11+
security-audit:
12+
name: pip-audit
13+
permissions:
14+
contents: read
15+
runs-on: ubuntu-latest
16+
defaults:
17+
run:
18+
working-directory: strands-py
19+
20+
steps:
21+
- name: Checkout code
22+
uses: actions/checkout@v7
23+
with:
24+
ref: ${{ inputs.ref }}
25+
persist-credentials: false
26+
27+
- name: Set up Python
28+
uses: actions/setup-python@v6
29+
with:
30+
python-version: '3.10'
31+
cache: 'pip'
32+
33+
- name: Install pip-audit
34+
run: pip install --no-cache-dir pip-audit
35+
36+
# Mirror of typescript-security-audit.yml: informational, does not block
37+
# the release. PR-introduced vulns are gated by the repo-level Dependency
38+
# Review job in ci.yml; pre-existing advisories are driven down via
39+
# Dependabot. The release reviewer can read the report below before
40+
# approving.
41+
- name: Audit installed dependency closure (informational)
42+
continue-on-error: true
43+
run: |
44+
set -euo pipefail
45+
# Resolve the package's full dependency closure into a temporary
46+
# venv, then audit it. Auditing the project metadata directly skips
47+
# transitive deps; this mirrors what an end user actually installs.
48+
python -m venv /tmp/audit-env
49+
/tmp/audit-env/bin/pip install --upgrade pip
50+
/tmp/audit-env/bin/pip install .
51+
/tmp/audit-env/bin/pip-audit
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# End-to-end wheel install smoke test.
2+
#
3+
# Mirror of typescript-test-package-pack.yml. The Python-side equivalent of
4+
# the TS hoisting bug would be: a wheel that imports a module from src/ but
5+
# misconfigures `[tool.hatch.build.targets.wheel].packages`, so `hatch test`
6+
# (which runs against editable source) passes while `pip install dist/*.whl`
7+
# from a fresh venv crashes at import time.
8+
#
9+
# This workflow reproduces a real user's install: build the wheel with
10+
# `python -m build`, install it in a venv OUTSIDE the monorepo tree (no
11+
# editable install, no fallback to source), then import the public surface.
12+
name: "Python: Test Package Build"
13+
14+
on:
15+
workflow_call:
16+
inputs:
17+
ref:
18+
required: true
19+
type: string
20+
21+
jobs:
22+
test-package-build:
23+
name: Wheel install
24+
permissions:
25+
contents: read
26+
runs-on: ubuntu-latest
27+
28+
steps:
29+
- name: Checkout code
30+
uses: actions/checkout@v7
31+
with:
32+
ref: ${{ inputs.ref }}
33+
persist-credentials: false
34+
35+
- name: Set up Python
36+
uses: actions/setup-python@v6
37+
with:
38+
python-version: '3.10'
39+
cache: 'pip'
40+
41+
- name: Install build tooling
42+
run: pip install --no-cache-dir build
43+
44+
- name: Build wheel + sdist
45+
working-directory: strands-py
46+
run: python -m build
47+
48+
- name: Install wheel in a clean venv outside the repo and import
49+
# The venv MUST live outside the monorepo — otherwise pip can fall
50+
# back to the in-tree source if the wheel is incomplete, masking the
51+
# bug we are trying to catch.
52+
run: |
53+
set -euo pipefail
54+
WORK=$(mktemp -d)
55+
trap 'rm -rf "$WORK"' EXIT
56+
echo "Workspace: $WORK"
57+
58+
WHEEL=$(find "$GITHUB_WORKSPACE/strands-py/dist" -maxdepth 1 -name '*.whl' -print -quit)
59+
if [ -z "$WHEEL" ]; then
60+
echo "::error::no wheel produced under strands-py/dist"
61+
exit 1
62+
fi
63+
echo "Wheel: $WHEEL"
64+
65+
python -m venv "$WORK/venv"
66+
"$WORK/venv/bin/pip" install --upgrade pip
67+
"$WORK/venv/bin/pip" install "$WHEEL"
68+
69+
# Import from a directory that does NOT contain the source tree, so
70+
# the only resolvable `strands` package is the one from the wheel.
71+
cd "$WORK"
72+
"$WORK/venv/bin/python" -c "import strands; print('strands version:', getattr(strands, '__version__', 'unknown'))"

0 commit comments

Comments
 (0)