Skip to content

Commit e50fcec

Browse files
authored
ci: automate semver releases on main using Sonnet (#162)
1 parent 29539b1 commit e50fcec

1 file changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
name: Release on main
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
pull-requests: read
11+
12+
concurrency:
13+
group: release-main
14+
cancel-in-progress: true
15+
16+
jobs:
17+
release:
18+
runs-on: ubuntu-latest
19+
if: ${{ !contains(github.event.head_commit.message, '[skip release]') }}
20+
env:
21+
RELEASE_DRY_RUN: ${{ vars.RELEASE_DRY_RUN || 'true' }}
22+
steps:
23+
- name: Checkout
24+
uses: actions/checkout@v4
25+
with:
26+
fetch-depth: 0
27+
28+
- name: Setup Node
29+
uses: actions/setup-node@v4
30+
with:
31+
node-version: "22"
32+
33+
- name: Resolve dry-run mode
34+
id: dryrun
35+
run: |
36+
case "${RELEASE_DRY_RUN,,}" in
37+
1|true|yes|on) echo "enabled=true" >> "$GITHUB_OUTPUT" ;;
38+
*) echo "enabled=false" >> "$GITHUB_OUTPUT" ;;
39+
esac
40+
41+
- name: Determine last release tag
42+
id: last_tag
43+
run: |
44+
git fetch --tags --force
45+
LAST_TAG=$(git tag --list 'v*' --sort=-v:refname | head -n1)
46+
if [ -z "$LAST_TAG" ]; then
47+
LAST_TAG="v0.0.0"
48+
fi
49+
echo "last_tag=$LAST_TAG" >> "$GITHUB_OUTPUT"
50+
51+
- name: Gather merged PRs since last tag
52+
id: prs
53+
env:
54+
GH_TOKEN: ${{ github.token }}
55+
LAST_TAG: ${{ steps.last_tag.outputs.last_tag }}
56+
run: |
57+
set -euo pipefail
58+
OWNER_REPO="${GITHUB_REPOSITORY}"
59+
OWNER="${OWNER_REPO%/*}"
60+
REPO="${OWNER_REPO#*/}"
61+
62+
if [ "$LAST_TAG" = "v0.0.0" ]; then
63+
START_DATE="1970-01-01T00:00:00Z"
64+
else
65+
START_DATE=$(git log -1 --format=%cI "$LAST_TAG")
66+
fi
67+
68+
gh api \
69+
--paginate \
70+
--slurp \
71+
"/repos/$OWNER/$REPO/pulls?state=closed&base=main&sort=updated&direction=desc&per_page=100" \
72+
> /tmp/all_prs.json
73+
74+
jq --arg start "$START_DATE" 'flatten | map(select(.merged_at != null and .merged_at > $start)) | sort_by(.merged_at)' /tmp/all_prs.json > /tmp/merged_prs.json
75+
76+
COUNT=$(jq 'length' /tmp/merged_prs.json)
77+
echo "count=$COUNT" >> "$GITHUB_OUTPUT"
78+
79+
if [ "$COUNT" -eq 0 ]; then
80+
echo "has_changes=false" >> "$GITHUB_OUTPUT"
81+
echo "pr_context=[]" >> "$GITHUB_OUTPUT"
82+
exit 0
83+
fi
84+
85+
jq '[.[] | {number, title, body, labels: [.labels[].name], user: .user.login, merged_at, html_url}]' /tmp/merged_prs.json > /tmp/pr_context.json
86+
echo "has_changes=true" >> "$GITHUB_OUTPUT"
87+
echo "pr_context=$(jq -c . /tmp/pr_context.json)" >> "$GITHUB_OUTPUT"
88+
89+
- name: Decide release bump with Claude Sonnet
90+
id: decide
91+
if: steps.prs.outputs.has_changes == 'true'
92+
env:
93+
ANTHROPIC_API_KEY: ${{ secrets.CI_ANTHROPIC_KEY }}
94+
PR_CONTEXT: ${{ steps.prs.outputs.pr_context }}
95+
LAST_TAG: ${{ steps.last_tag.outputs.last_tag }}
96+
run: |
97+
set -euo pipefail
98+
if [ -z "${ANTHROPIC_API_KEY:-}" ]; then
99+
echo "Missing CI_ANTHROPIC_KEY secret" >&2
100+
exit 1
101+
fi
102+
103+
PROMPT=$(cat <<'EOF'
104+
You are a release manager. Analyze merged pull requests since the last release and decide semver bump.
105+
Allowed outputs:
106+
- none
107+
- patch
108+
- minor
109+
110+
Rules:
111+
- Never return major. Major releases are manual-only.
112+
- Use a judicious standard: user-facing features, capability expansion, or notable additive behavior => minor.
113+
- Bug fixes, refactors, infra/internal changes, docs/tests only => patch or none.
114+
- If no meaningful published change, choose none.
115+
116+
Return ONLY strict JSON:
117+
{"decision":"none|patch|minor","reason":"short reason","highlights":["...","..."]}
118+
EOF
119+
)
120+
121+
jq -n \
122+
--arg model "claude-3-5-sonnet-latest" \
123+
--arg system "You are precise and must output strict JSON only." \
124+
--arg prompt "$PROMPT" \
125+
--arg last_tag "$LAST_TAG" \
126+
--argjson prs "$PR_CONTEXT" \
127+
'{
128+
model: $model,
129+
max_tokens: 700,
130+
temperature: 0,
131+
system: $system,
132+
messages: [
133+
{role: "user", content: ($prompt + "\n\nLast release tag: " + $last_tag + "\n\nPRs:\n" + ($prs|tojson))}
134+
]
135+
}' > /tmp/anthropic-payload.json
136+
137+
curl -sS https://api.anthropic.com/v1/messages \
138+
-H "x-api-key: ${ANTHROPIC_API_KEY}" \
139+
-H "anthropic-version: 2023-06-01" \
140+
-H "content-type: application/json" \
141+
--data @/tmp/anthropic-payload.json > /tmp/anthropic-response.json
142+
143+
TEXT=$(jq -r '.content[0].text // empty' /tmp/anthropic-response.json)
144+
if [ -z "$TEXT" ]; then
145+
echo "Invalid Anthropic response" >&2
146+
cat /tmp/anthropic-response.json >&2
147+
exit 1
148+
fi
149+
150+
echo "$TEXT" > /tmp/decision-raw.txt
151+
jq . /tmp/decision-raw.txt > /tmp/decision.json
152+
153+
DECISION=$(jq -r '.decision' /tmp/decision.json)
154+
REASON=$(jq -r '.reason' /tmp/decision.json)
155+
156+
if [ "$DECISION" = "major" ]; then
157+
echo "Major bump proposed but blocked by policy" >&2
158+
exit 1
159+
fi
160+
161+
case "$DECISION" in
162+
none|patch|minor) ;;
163+
*)
164+
echo "Unexpected decision: $DECISION" >&2
165+
exit 1
166+
;;
167+
esac
168+
169+
echo "decision=$DECISION" >> "$GITHUB_OUTPUT"
170+
echo "reason=$REASON" >> "$GITHUB_OUTPUT"
171+
172+
- name: No-op summary
173+
if: steps.prs.outputs.has_changes != 'true' || steps.decide.outputs.decision == 'none'
174+
run: |
175+
echo "## Release decision" >> "$GITHUB_STEP_SUMMARY"
176+
if [ "${{ steps.prs.outputs.has_changes }}" != "true" ]; then
177+
echo "No merged PRs since last tag; skipping release." >> "$GITHUB_STEP_SUMMARY"
178+
else
179+
echo "Decision: none" >> "$GITHUB_STEP_SUMMARY"
180+
echo "Reason: ${{ steps.decide.outputs.reason }}" >> "$GITHUB_STEP_SUMMARY"
181+
fi
182+
183+
- name: Bump version
184+
id: bump
185+
if: steps.decide.outputs.decision == 'patch' || steps.decide.outputs.decision == 'minor'
186+
env:
187+
BUMP: ${{ steps.decide.outputs.decision }}
188+
run: |
189+
set -euo pipefail
190+
CURRENT=$(node -p "require('./package.json').version")
191+
NEXT=$(node -e "const v=process.argv[1].split('.').map(Number); if(process.argv[2]==='minor'){v[1]++; v[2]=0;} else {v[2]++;} process.stdout.write(v.join('.'))" -- "${CURRENT}" "${BUMP}")
192+
node -e "const fs=require('fs'); const p='package.json'; const j=JSON.parse(fs.readFileSync(p,'utf8')); j.version=process.argv[1]; fs.writeFileSync(p, JSON.stringify(j,null,2)+'\\n');" -- "${NEXT}"
193+
if [ -f package-lock.json ]; then
194+
npm install --package-lock-only --ignore-scripts
195+
fi
196+
echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
197+
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
198+
echo "tag=v${NEXT}" >> "$GITHUB_OUTPUT"
199+
200+
- name: Check tag does not already exist
201+
id: tag_check
202+
if: steps.bump.outputs.tag != '' && steps.dryrun.outputs.enabled != 'true'
203+
env:
204+
TAG: ${{ steps.bump.outputs.tag }}
205+
run: |
206+
if git rev-parse "$TAG" >/dev/null 2>&1; then
207+
echo "Tag already exists: $TAG. Skipping to keep idempotent."
208+
echo "exists=true" >> "$GITHUB_OUTPUT"
209+
else
210+
echo "exists=false" >> "$GITHUB_OUTPUT"
211+
fi
212+
213+
- name: Commit and tag
214+
if: steps.bump.outputs.tag != '' && steps.dryrun.outputs.enabled != 'true' && steps.tag_check.outputs.exists != 'true'
215+
env:
216+
TAG: ${{ steps.bump.outputs.tag }}
217+
run: |
218+
set -euo pipefail
219+
git config user.name "github-actions[bot]"
220+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
221+
git add package.json package-lock.json || true
222+
git commit -m "release: ${TAG} [skip release]"
223+
git tag "$TAG"
224+
git push --atomic origin main "$TAG"
225+
226+
- name: Build changelog markdown
227+
id: changelog
228+
if: steps.bump.outputs.tag != ''
229+
env:
230+
PR_CONTEXT: ${{ steps.prs.outputs.pr_context }}
231+
TAG: ${{ steps.bump.outputs.tag }}
232+
DECISION: ${{ steps.decide.outputs.decision }}
233+
REASON: ${{ steps.decide.outputs.reason }}
234+
run: |
235+
set -euo pipefail
236+
{
237+
echo "## ${TAG}"
238+
echo
239+
echo "Release type: ${DECISION}"
240+
echo
241+
echo "Reason: ${REASON}"
242+
echo
243+
echo "### Merged PRs"
244+
jq -r '.[] | "- #\(.number) \(.title) (@\(.user)) — \(.html_url)"' <<< "$PR_CONTEXT"
245+
} > /tmp/release-notes.md
246+
echo "notes_path=/tmp/release-notes.md" >> "$GITHUB_OUTPUT"
247+
248+
- name: Create GitHub Release
249+
if: steps.bump.outputs.tag != '' && steps.dryrun.outputs.enabled != 'true'
250+
env:
251+
GH_TOKEN: ${{ github.token }}
252+
TAG: ${{ steps.bump.outputs.tag }}
253+
NOTES: ${{ steps.changelog.outputs.notes_path }}
254+
run: |
255+
set -euo pipefail
256+
if gh release view "$TAG" >/dev/null 2>&1; then
257+
echo "Release already exists for $TAG; skipping."
258+
exit 0
259+
fi
260+
gh release create "$TAG" --title "$TAG" --notes-file "$NOTES"
261+
262+
- name: Release summary
263+
if: steps.bump.outputs.tag != ''
264+
run: |
265+
echo "## Release created" >> "$GITHUB_STEP_SUMMARY"
266+
echo "Tag: ${{ steps.bump.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"
267+
echo "Decision: ${{ steps.decide.outputs.decision }}" >> "$GITHUB_STEP_SUMMARY"
268+
echo "Reason: ${{ steps.decide.outputs.reason }}" >> "$GITHUB_STEP_SUMMARY"
269+
270+
- name: Dry-run release summary
271+
if: steps.bump.outputs.tag != "" && steps.dryrun.outputs.enabled == "true"
272+
run: |
273+
echo "## Dry run: no release published" >> "$GITHUB_STEP_SUMMARY"
274+
echo "Decision: ${{ steps.decide.outputs.decision }}" >> "$GITHUB_STEP_SUMMARY"
275+
echo "Reason: ${{ steps.decide.outputs.reason }}" >> "$GITHUB_STEP_SUMMARY"
276+
echo "Current version: ${{ steps.bump.outputs.current }}" >> "$GITHUB_STEP_SUMMARY"
277+
echo "Would publish tag: ${{ steps.bump.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"
278+

0 commit comments

Comments
 (0)