Skip to content

agents: add manifest-driven subagent packages and lifecycle tooling (… #42

agents: add manifest-driven subagent packages and lifecycle tooling (…

agents: add manifest-driven subagent packages and lifecycle tooling (… #42

name: Release on main
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
pull-requests: read
concurrency:
group: release-main
cancel-in-progress: true
jobs:
release:
runs-on: ubuntu-latest
if: ${{ !contains(github.event.head_commit.message, '[skip release]') }}
env:
RELEASE_DRY_RUN: ${{ vars.RELEASE_DRY_RUN || 'true' }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Resolve dry-run mode
id: dryrun
run: |
case "${RELEASE_DRY_RUN,,}" in
1|true|yes|on) echo "enabled=true" >> "$GITHUB_OUTPUT" ;;
*) echo "enabled=false" >> "$GITHUB_OUTPUT" ;;
esac
- name: Determine last release tag
id: last_tag
run: |
git fetch --tags --force
LAST_TAG=$(git tag --list 'v*' --sort=-v:refname | head -n1)
if [ -z "$LAST_TAG" ]; then
LAST_TAG="v0.0.0"
fi
echo "last_tag=$LAST_TAG" >> "$GITHUB_OUTPUT"
- name: Gather merged PRs since last tag
id: prs
env:
GH_TOKEN: ${{ github.token }}
LAST_TAG: ${{ steps.last_tag.outputs.last_tag }}
run: |
set -euo pipefail
OWNER_REPO="${GITHUB_REPOSITORY}"
OWNER="${OWNER_REPO%/*}"
REPO="${OWNER_REPO#*/}"
if [ "$LAST_TAG" = "v0.0.0" ]; then
# No prior release — look back 30 days instead of all time
START_DATE=$(date -u -d '30 days ago' '+%Y-%m-%dT%H:%M:%SZ')
else
START_DATE=$(git log -1 --format=%cI "$LAST_TAG")
fi
gh api \
--paginate \
--slurp \
"/repos/$OWNER/$REPO/pulls?state=closed&base=main&sort=updated&direction=desc&per_page=100" \
> /tmp/all_prs.json
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
COUNT=$(jq 'length' /tmp/merged_prs.json)
echo "count=$COUNT" >> "$GITHUB_OUTPUT"
if [ "$COUNT" -eq 0 ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo '[]' > /tmp/pr_context.json
exit 0
fi
jq '[.[] | {number, title, body: (.body // "" | .[:200]), labels: [.labels[].name], user: .user.login, merged_at, html_url}]' /tmp/merged_prs.json > /tmp/pr_context.json
# Cap at 50 most recent PRs for the Claude prompt to keep payload reasonable
jq '[ sort_by(.merged_at) | reverse | .[:50] | reverse | .[] ]' /tmp/pr_context.json > /tmp/pr_context_capped.json
echo "has_changes=true" >> "$GITHUB_OUTPUT"
- name: Decide release bump with Claude Haiku 4.5
id: decide
if: steps.prs.outputs.has_changes == 'true'
env:
ANTHROPIC_API_KEY: ${{ secrets.CI_ANTHROPIC_KEY }}
LAST_TAG: ${{ steps.last_tag.outputs.last_tag }}
run: |
set -euo pipefail
if [ -z "${ANTHROPIC_API_KEY:-}" ]; then
echo "Missing CI_ANTHROPIC_KEY secret" >&2
exit 1
fi
read -r -d '' PROMPT <<'PROMPT_EOF' || true
You are a release manager. Analyze merged pull requests since the last release and decide semver bump.
Allowed outputs:
- none
- patch
- minor
Rules:
- Never return major. Major releases are manual-only.
- Use a judicious standard: user-facing features, capability expansion, or notable additive behavior => minor.
- Bug fixes, refactors, infra/internal changes, docs/tests only => patch or none.
- If no meaningful published change, choose none.
Return ONLY strict JSON:
{"decision":"none|patch|minor","reason":"short reason","highlights":["...","..."]}
PROMPT_EOF
PROMPT=$(echo "$PROMPT" | sed 's/^ //')
jq -n \
--arg model "claude-haiku-4-5" \
--arg system "You are precise and must output strict JSON only." \
--arg prompt "$PROMPT" \
--arg last_tag "$LAST_TAG" \
--slurpfile prs /tmp/pr_context_capped.json \
'{
model: $model,
max_tokens: 700,
temperature: 0,
system: $system,
messages: [
{role: "user", content: ($prompt + "\n\nLast release tag: " + $last_tag + "\n\nPRs:\n" + ($prs[0]|tojson))}
]
}' > /tmp/anthropic-payload.json
curl -sS https://api.anthropic.com/v1/messages \
-H "x-api-key: ${ANTHROPIC_API_KEY}" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
--data @/tmp/anthropic-payload.json > /tmp/anthropic-response.json
TEXT=$(jq -r '.content[0].text // empty' /tmp/anthropic-response.json)
if [ -z "$TEXT" ]; then
echo "Invalid Anthropic response" >&2
cat /tmp/anthropic-response.json >&2
exit 1
fi
echo "$TEXT" > /tmp/decision-raw.txt
python3 - <<'PY'
import json
import re
import sys
text = open('/tmp/decision-raw.txt', encoding='utf-8').read().strip()
def parse_json(candidate: str):
try:
return json.loads(candidate)
except Exception:
return None
decision = parse_json(text)
if decision is None:
fenced = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text, re.IGNORECASE)
if fenced:
decision = parse_json(fenced.group(1).strip())
if decision is None:
decoder = json.JSONDecoder()
for index, ch in enumerate(text):
if ch != '{':
continue
try:
decision, _ = decoder.raw_decode(text[index:])
break
except Exception:
continue
if decision is None:
print("Failed to parse release decision JSON from model response", file=sys.stderr)
print(text, file=sys.stderr)
sys.exit(1)
with open('/tmp/decision.json', 'w', encoding='utf-8') as fh:
json.dump(decision, fh)
fh.write('\n')
PY
DECISION=$(jq -r '.decision' /tmp/decision.json)
REASON=$(jq -r '.reason' /tmp/decision.json)
if [ "$DECISION" = "major" ]; then
echo "Major bump proposed but blocked by policy" >&2
exit 1
fi
case "$DECISION" in
none|patch|minor) ;;
*)
echo "Unexpected decision: $DECISION" >&2
exit 1
;;
esac
echo "decision=$DECISION" >> "$GITHUB_OUTPUT"
echo "reason=$REASON" >> "$GITHUB_OUTPUT"
- name: No-op summary
if: steps.prs.outputs.has_changes != 'true' || steps.decide.outputs.decision == 'none'
run: |
echo "## Release decision" >> "$GITHUB_STEP_SUMMARY"
if [ "${{ steps.prs.outputs.has_changes }}" != "true" ]; then
echo "No merged PRs since last tag; skipping release." >> "$GITHUB_STEP_SUMMARY"
else
echo "Decision: none" >> "$GITHUB_STEP_SUMMARY"
echo "Reason: ${{ steps.decide.outputs.reason }}" >> "$GITHUB_STEP_SUMMARY"
fi
- name: Bump version
id: bump
if: steps.decide.outputs.decision == 'patch' || steps.decide.outputs.decision == 'minor'
env:
BUMP: ${{ steps.decide.outputs.decision }}
run: |
set -euo pipefail
CURRENT=$(node -p "require('./package.json').version")
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}")
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}"
if [ -f package-lock.json ]; then
npm install --package-lock-only --ignore-scripts
fi
echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
echo "next=$NEXT" >> "$GITHUB_OUTPUT"
echo "tag=v${NEXT}" >> "$GITHUB_OUTPUT"
- name: Check tag does not already exist
id: tag_check
if: steps.bump.outputs.tag != '' && steps.dryrun.outputs.enabled != 'true'
env:
TAG: ${{ steps.bump.outputs.tag }}
run: |
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag already exists: $TAG. Skipping to keep idempotent."
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Commit and tag
if: steps.bump.outputs.tag != '' && steps.dryrun.outputs.enabled != 'true' && steps.tag_check.outputs.exists != 'true'
env:
TAG: ${{ steps.bump.outputs.tag }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json || true
git commit -m "release: ${TAG} [skip release]"
git tag "$TAG"
git push --atomic origin main "$TAG"
- name: Build changelog markdown
id: changelog
if: steps.bump.outputs.tag != ''
env:
TAG: ${{ steps.bump.outputs.tag }}
DECISION: ${{ steps.decide.outputs.decision }}
REASON: ${{ steps.decide.outputs.reason }}
run: |
set -euo pipefail
{
echo "## ${TAG}"
echo
echo "Release type: ${DECISION}"
echo
echo "Reason: ${REASON}"
echo
echo "### Merged PRs"
jq -r '.[] | "- #\(.number) \(.title) (@\(.user)) — \(.html_url)"' /tmp/pr_context.json
} > /tmp/release-notes.md
echo "notes_path=/tmp/release-notes.md" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
if: steps.bump.outputs.tag != '' && steps.dryrun.outputs.enabled != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.bump.outputs.tag }}
NOTES: ${{ steps.changelog.outputs.notes_path }}
run: |
set -euo pipefail
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release already exists for $TAG; skipping."
exit 0
fi
gh release create "$TAG" --title "$TAG" --notes-file "$NOTES"
- name: Release summary
if: steps.bump.outputs.tag != ''
run: |
echo "## Release created" >> "$GITHUB_STEP_SUMMARY"
echo "Tag: ${{ steps.bump.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"
echo "Decision: ${{ steps.decide.outputs.decision }}" >> "$GITHUB_STEP_SUMMARY"
echo "Reason: ${{ steps.decide.outputs.reason }}" >> "$GITHUB_STEP_SUMMARY"
- name: Dry-run release summary
if: steps.bump.outputs.tag != '' && steps.dryrun.outputs.enabled == 'true'
run: |
echo "## Dry run: no release published" >> "$GITHUB_STEP_SUMMARY"
echo "Decision: ${{ steps.decide.outputs.decision }}" >> "$GITHUB_STEP_SUMMARY"
echo "Reason: ${{ steps.decide.outputs.reason }}" >> "$GITHUB_STEP_SUMMARY"
echo "Current version: ${{ steps.bump.outputs.current }}" >> "$GITHUB_STEP_SUMMARY"
echo "Would publish tag: ${{ steps.bump.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"