agents: add manifest-driven subagent packages and lifecycle tooling (… #42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | |