Skip to content

Commit 08bb666

Browse files
committed
ci: add Sonnet-driven semver release workflow on main
1 parent e004988 commit 08bb666

1 file changed

Lines changed: 255 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)