release / create-pr #55
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 / create-pr | |
| # Mirrors CopilotKit's `release / create-pr` DX (pick a scope + bump, click | |
| # run, get a release PR). AG-UI has ~20 scopes vs CopilotKit's 3, so we | |
| # additionally allow STACKING: running this workflow multiple times | |
| # accumulates version bumps onto a single `release/next` PR. Merge the PR | |
| # once when you're ready to ship everything together. | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| scope: | |
| description: "What to release" | |
| required: true | |
| type: choice | |
| options: | |
| - integration-a2a | |
| - integration-adk-py | |
| - integration-adk-ts | |
| - integration-ag2 | |
| - integration-agent-spec | |
| - integration-agno | |
| - integration-aws-strands-py | |
| - integration-aws-strands-ts | |
| - integration-claude-agent-sdk-py | |
| - integration-claude-agent-sdk-ts | |
| - integration-cloudflare-agents | |
| - integration-crewai-py | |
| - integration-crewai-ts | |
| - integration-langchain | |
| - integration-langgraph-py | |
| - integration-langgraph-ts | |
| - integration-langroid | |
| - integration-llama-index | |
| - integration-mastra | |
| - integration-pydantic-ai | |
| - integration-spring-ai | |
| - integration-watsonx-py | |
| - integration-watsonx-ts | |
| - middleware-a2a | |
| - middleware-a2ui | |
| - middleware-mcp | |
| - middleware-mcp-apps | |
| - sdk-py | |
| - sdk-py-a2ui-toolkit | |
| - sdk-dotnet | |
| - sdk-ts | |
| - sdk-ts-a2ui-toolkit | |
| - create-ag-ui-app | |
| bump: | |
| description: "Version bump level" | |
| required: true | |
| type: choice | |
| options: | |
| - patch | |
| - minor | |
| - major | |
| dry_run: | |
| description: "Dry run (preview without creating PR)" | |
| required: false | |
| default: false | |
| type: boolean | |
| concurrency: | |
| group: release-create-pr | |
| cancel-in-progress: false | |
| permissions: | |
| contents: read | |
| env: | |
| NX_VERBOSE_LOGGING: true | |
| jobs: | |
| create-release-pr: | |
| if: github.ref == 'refs/heads/main' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| environment: npm | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| steps: | |
| - name: Find existing release/next PR (stacking target) | |
| id: existing | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const { owner, repo } = context.repo; | |
| const { data: prs } = await github.rest.pulls.list({ | |
| owner, | |
| repo, | |
| state: "open", | |
| base: "main", | |
| head: `${owner}:release/next`, | |
| }); | |
| if (prs.length > 0) { | |
| const pr = prs[0]; | |
| core.info(`Found existing release PR #${pr.number}: ${pr.html_url}`); | |
| core.setOutput("has_existing", "true"); | |
| core.setOutput("pr_number", String(pr.number)); | |
| core.setOutput("pr_url", pr.html_url); | |
| } else { | |
| core.info("No existing release/next PR — will create a new one"); | |
| core.setOutput("has_existing", "false"); | |
| } | |
| - name: Checkout repo | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| ref: main | |
| persist-credentials: false | |
| - name: Setup pnpm | |
| uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 | |
| with: | |
| version: "10.33.4" | |
| - name: Setup Node | |
| uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: "22" | |
| - name: Setup Python | |
| uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 | |
| with: | |
| python-version: "3.12" | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Configure git | |
| run: | | |
| git config --local user.email "github-actions[bot]@users.noreply.github.com" | |
| git config --local user.name "github-actions[bot]" | |
| - name: Check out or create release/next | |
| if: inputs.dry_run != true | |
| env: | |
| HAS_EXISTING: ${{ steps.existing.outputs.has_existing }} | |
| run: | | |
| if [ "$HAS_EXISTING" = "true" ]; then | |
| echo "Stacking onto existing release/next" | |
| git fetch origin release/next:release/next | |
| git checkout release/next | |
| else | |
| echo "Starting fresh release/next branch" | |
| git checkout -b release/next | |
| fi | |
| - name: Bump versions for scope | |
| id: bump | |
| env: | |
| INPUT_SCOPE: ${{ inputs.scope }} | |
| INPUT_BUMP: ${{ inputs.bump }} | |
| INPUT_DRY_RUN: ${{ inputs.dry_run }} | |
| run: | | |
| EXTRA_ARGS=() | |
| if [ "$INPUT_DRY_RUN" = "true" ]; then | |
| EXTRA_ARGS+=(--dry-run) | |
| fi | |
| RESULT=$(pnpm tsx scripts/release/prepare-release.ts \ | |
| --scope "$INPUT_SCOPE" \ | |
| --bump "$INPUT_BUMP" \ | |
| "${EXTRA_ARGS[@]}") | |
| echo "$RESULT" > /tmp/bump-result.json | |
| CHANGED=$(jq '[.packages[] | select(.oldVersion != .newVersion)] | length' /tmp/bump-result.json) | |
| echo "changed_count=$CHANGED" >> "$GITHUB_OUTPUT" | |
| SUMMARY=$(jq -r '.packages | map("\(.name)@\(.newVersion)") | join(", ")' /tmp/bump-result.json) | |
| echo "summary=$SUMMARY" >> "$GITHUB_OUTPUT" | |
| FILES=$(jq -r '.packages[].file' /tmp/bump-result.json | tr '\n' ' ') | |
| echo "files=$FILES" >> "$GITHUB_OUTPUT" | |
| - name: Dry-run summary | |
| if: inputs.dry_run == true | |
| env: | |
| INPUT_SCOPE: ${{ inputs.scope }} | |
| INPUT_BUMP: ${{ inputs.bump }} | |
| run: | | |
| { | |
| echo "## Dry Run — release / create-pr" | |
| echo "" | |
| echo "**Scope:** \`${INPUT_SCOPE}\` | **Bump:** \`${INPUT_BUMP}\`" | |
| echo "" | |
| echo "### Would bump" | |
| jq -r '.packages[] | "- **\(.name)**: \(.oldVersion) → \(.newVersion)"' /tmp/bump-result.json | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Mint devops-bot token | |
| id: app-token | |
| if: inputs.dry_run != true && steps.bump.outputs.changed_count != '0' | |
| uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 | |
| with: | |
| app-id: "3877599" | |
| private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }} | |
| - name: Configure git credentials for push | |
| if: inputs.dry_run != true && steps.bump.outputs.changed_count != '0' | |
| run: | | |
| git config user.name "ag-ui-devops-bot[bot]" | |
| git config user.email "3877599+ag-ui-devops-bot[bot]@users.noreply.github.com" | |
| git config --local url."https://x-access-token:${GH_APP_TOKEN}@github.com/".insteadOf "https://github.com/" | |
| env: | |
| GH_APP_TOKEN: ${{ steps.app-token.outputs.token }} | |
| - name: Commit and push version bumps | |
| if: inputs.dry_run != true && steps.bump.outputs.changed_count != '0' | |
| env: | |
| SCOPE: ${{ inputs.scope }} | |
| SUMMARY: ${{ steps.bump.outputs.summary }} | |
| FILES: ${{ steps.bump.outputs.files }} | |
| run: | | |
| for f in $FILES; do | |
| git add "$f" | |
| done | |
| git commit -m "chore(release): bump ${SCOPE} (${SUMMARY})" | |
| git push -u origin release/next | |
| - name: Collect accumulated bumps | |
| id: notes | |
| if: inputs.dry_run != true && steps.bump.outputs.changed_count != '0' | |
| env: | |
| INPUT_SCOPE: ${{ inputs.scope }} | |
| run: | | |
| git fetch origin main | |
| python3 scripts/release/collect-accumulated-bumps.py origin/main HEAD > /tmp/accumulated.json | |
| SCOPES=$(jq -r '[.[].scope] | unique | join(" + ")' /tmp/accumulated.json) | |
| [ -z "$SCOPES" ] || [ "$SCOPES" = "null" ] && SCOPES="$INPUT_SCOPE" | |
| echo "title=release: ${SCOPES}" >> "$GITHUB_OUTPUT" | |
| - name: Generate AI release notes | |
| id: ai_notes | |
| if: inputs.dry_run != true && steps.bump.outputs.changed_count != '0' | |
| env: | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| run: | | |
| pnpm tsx scripts/release/generate-ai-release-notes.ts \ | |
| --accumulated /tmp/accumulated.json \ | |
| --output /tmp/ai-notes.md \ | |
| || echo "AI notes generation failed; falling back to table-only body" | |
| - name: Build PR body | |
| id: prbody | |
| if: inputs.dry_run != true && steps.bump.outputs.changed_count != '0' | |
| run: | | |
| { | |
| echo "## Accumulated Release" | |
| echo "" | |
| echo "Merging this PR publishes the following to npm, PyPI, and NuGet:" | |
| echo "" | |
| echo "| Package | Old | New | Registry |" | |
| echo "|---------|-----|-----|----------|" | |
| jq -r '.[] | "| \(.name) | \(.oldVersion) | \(.newVersion) | \(if .ecosystem == "typescript" then "npm" elif .ecosystem == "dotnet" then "NuGet" else "PyPI" end) |"' /tmp/accumulated.json | |
| echo "" | |
| echo "---" | |
| echo "" | |
| if [ -s /tmp/ai-notes.md ]; then | |
| { | |
| echo "" | |
| echo "## What's in this release" | |
| echo "" | |
| cat /tmp/ai-notes.md | |
| echo "" | |
| } | |
| fi | |
| echo "### How this release process works" | |
| echo "" | |
| echo "1. **This PR was created automatically** by the \`release / create-pr\` workflow." | |
| echo " Each run appends a scope; re-run the workflow to add more to this PR before merging." | |
| echo "2. **CI runs on this PR** — tests, lint, type checks, build must pass before merge." | |
| echo "3. **When merged**, the \`release / publish\` workflow detects the version bumps and:" | |
| echo " - Builds and tests all packages" | |
| echo " - Publishes to npm, PyPI, and NuGet" | |
| echo " - Creates per-package git tags" | |
| echo " - Creates a GitHub Release" | |
| echo "" | |
| echo "### Before merging" | |
| echo "" | |
| echo "- [ ] CI is green" | |
| echo "- [ ] Version bumps look correct" | |
| echo "" | |
| echo "> **Do not merge until CI is fully green.**" | |
| echo "> To add another scope to this release, re-run \`release / create-pr\` with a different scope." | |
| echo "> To cancel this release, close this PR and delete the \`release/next\` branch." | |
| } > /tmp/pr-body.md | |
| echo "body_path=/tmp/pr-body.md" >> "$GITHUB_OUTPUT" | |
| - name: Create or update release PR | |
| if: inputs.dry_run != true && steps.bump.outputs.changed_count != '0' | |
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 | |
| env: | |
| HAS_EXISTING: ${{ steps.existing.outputs.has_existing }} | |
| EXISTING_PR: ${{ steps.existing.outputs.pr_number }} | |
| PR_TITLE: ${{ steps.notes.outputs.title }} | |
| PR_BODY_PATH: ${{ steps.prbody.outputs.body_path }} | |
| INPUT_SCOPE: ${{ inputs.scope }} | |
| INPUT_BUMP: ${{ inputs.bump }} | |
| with: | |
| github-token: ${{ steps.app-token.outputs.token }} | |
| script: | | |
| const fs = require("fs"); | |
| const { owner, repo } = context.repo; | |
| const title = process.env.PR_TITLE; | |
| const body = fs.readFileSync(process.env.PR_BODY_PATH, "utf8"); | |
| const hasExisting = process.env.HAS_EXISTING === "true"; | |
| const scope = process.env.INPUT_SCOPE; | |
| const bump = process.env.INPUT_BUMP; | |
| let pr; | |
| if (hasExisting) { | |
| const number = parseInt(process.env.EXISTING_PR, 10); | |
| ({ data: pr } = await github.rest.pulls.update({ | |
| owner, repo, pull_number: number, title, body, | |
| })); | |
| core.info(`Updated PR #${pr.number}: ${pr.html_url}`); | |
| } else { | |
| ({ data: pr } = await github.rest.pulls.create({ | |
| owner, repo, base: "main", head: "release/next", title, body, | |
| })); | |
| core.info(`Created PR #${pr.number}: ${pr.html_url}`); | |
| // Apply release label | |
| await github.rest.issues.addLabels({ | |
| owner, repo, issue_number: pr.number, labels: ["release"], | |
| }).catch((e) => core.warning(`Could not apply label: ${e.message}`)); | |
| } | |
| await core.summary | |
| .addHeading("release / create-pr", 2) | |
| .addRaw(`**Added scope:** \`${scope}\` (\`${bump}\`)\n\n`) | |
| .addRaw(`**PR:** ${pr.html_url}\n`) | |
| .write(); | |
| - name: No-op summary | |
| if: inputs.dry_run != true && steps.bump.outputs.changed_count == '0' | |
| env: | |
| INPUT_SCOPE: ${{ inputs.scope }} | |
| run: | | |
| { | |
| echo "## release / create-pr" | |
| echo "" | |
| echo "Scope \`${INPUT_SCOPE}\` is already bumped on \`release/next\` — nothing to add." | |
| } >> "$GITHUB_STEP_SUMMARY" |