Skip to content

release / create-pr #55

release / create-pr

release / create-pr #55

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"