diff --git a/.github/workflows/cherry-pick-prompt.yml b/.github/workflows/cherry-pick-prompt.yml new file mode 100644 index 0000000..a719e01 --- /dev/null +++ b/.github/workflows/cherry-pick-prompt.yml @@ -0,0 +1,288 @@ +name: Cherry-pick to main + +on: + pull_request: + types: [closed] + +permissions: + contents: write + pull-requests: write + issues: write + +concurrency: + group: cherry-pick-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + cherry-pick: + # Only run when: + # - PR was merged (not just closed) + # - PR is not from a fork (forked PRs have read-only GITHUB_TOKEN) + # - Target branch is a release branch (not main) + if: > + github.event.pull_request.merged == true && + github.event.pull_request.head.repo.fork == false && + github.event.pull_request.base.ref != github.event.repository.default_branch && + (startsWith(github.event.pull_request.base.ref, 'release/') || + startsWith(github.event.pull_request.base.ref, 'next/')) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git user + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Prepare cherry-pick branch + id: prepare + shell: bash + env: + ORIGINAL_BRANCH: ${{ github.event.pull_request.head.ref }} + RELEASE_BRANCH: ${{ github.event.pull_request.base.ref }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + set -euo pipefail + + # Create cherry-pick branch name + # Sanitize branch name (remove invalid characters, truncate if needed) + CHERRY_BRANCH="${ORIGINAL_BRANCH}-cherry-pick-main" + CHERRY_BRANCH=$(echo "$CHERRY_BRANCH" | sed 's/[^a-zA-Z0-9._/-]/-/g' | head -c 100) + + # Validate branch name is valid for git + if ! git check-ref-format --branch "$CHERRY_BRANCH" >/dev/null 2>&1; then + echo "::error::Invalid branch name after sanitization: $CHERRY_BRANCH" + exit 1 + fi + + echo "cherry_branch=$CHERRY_BRANCH" >> "$GITHUB_OUTPUT" + echo "Cherry-pick branch: $CHERRY_BRANCH" + + # Get default branch + DEFAULT_BRANCH="${{ github.event.repository.default_branch }}" + + # Checkout default branch (main) + git fetch origin "$DEFAULT_BRANCH" + git checkout "$DEFAULT_BRANCH" + git pull origin "$DEFAULT_BRANCH" + + # Create the cherry-pick branch (delete if already exists) + if git show-ref --verify --quiet "refs/heads/$CHERRY_BRANCH"; then + echo "Branch $CHERRY_BRANCH already exists locally, deleting..." + git branch -D "$CHERRY_BRANCH" + fi + if git ls-remote --heads origin "$CHERRY_BRANCH" | grep -q .; then + echo "Branch $CHERRY_BRANCH already exists on remote, deleting..." + git push origin --delete "$CHERRY_BRANCH" || true + fi + git checkout -b "$CHERRY_BRANCH" + + # Attempt cherry-pick + echo "Attempting cherry-pick of $MERGE_SHA..." + + # Detect if merge commit (has multiple parents) + PARENT_COUNT=$(git rev-list --parents -n1 "$MERGE_SHA" | wc -w) + PARENT_COUNT=$((PARENT_COUNT - 1)) # Subtract the commit itself + + CHERRY_PICK_ARGS="--no-commit" + if [ "$PARENT_COUNT" -gt 1 ]; then + echo "Detected merge commit with $PARENT_COUNT parents, using -m 1" + CHERRY_PICK_ARGS="-m 1 --no-commit" + fi + + if git cherry-pick $CHERRY_PICK_ARGS "$MERGE_SHA"; then + echo "conflict=false" >> "$GITHUB_OUTPUT" + + # Create commit with proper attribution + git commit -m "$(cat < + EOF + )" + echo "Cherry-pick successful" + else + echo "conflict=true" >> "$GITHUB_OUTPUT" + git cherry-pick --abort || true + echo "Cherry-pick failed due to conflicts" + fi + + - name: Ensure labels exist + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh label create "cherry-pick" --color "0E8A16" --description "Cherry-picked changes" 2>/dev/null || true + gh label create "auto-cherry-pick" --color "1D76DB" --description "Auto cherry-pick" 2>/dev/null || true + gh label create "conflict" --color "D93F0B" --description "Has conflicts" 2>/dev/null || true + gh label create "needs-attention" --color "FBCA04" --description "Needs attention" 2>/dev/null || true + + - name: Push branch and create PR + if: steps.prepare.outputs.conflict == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CHERRY_BRANCH: ${{ steps.prepare.outputs.cherry_branch }} + RELEASE_BRANCH: ${{ github.event.pull_request.base.ref }} + ORIGINAL_PR: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + + # Push the cherry-pick branch + git push origin "$CHERRY_BRANCH" + + # Create the PR + PR_URL=$(gh pr create \ + --base "$DEFAULT_BRANCH" \ + --head "$CHERRY_BRANCH" \ + --title "Cherry-pick: $PR_TITLE" \ + --label "cherry-pick" \ + --label "auto-cherry-pick" \ + --body "$(cat <> "$GITHUB_OUTPUT" + + - name: Comment on original PR (success) + if: steps.prepare.outputs.conflict == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ORIGINAL_PR: ${{ github.event.pull_request.number }} + CHERRY_BRANCH: ${{ steps.prepare.outputs.cherry_branch }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + gh pr comment "$ORIGINAL_PR" --body "$(cat <> "$GITHUB_OUTPUT" + + - name: Attempt to assign issue + if: steps.prepare.outputs.conflict == 'true' && steps.create_issue.outputs.issue_url != '' + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + ISSUE_URL: ${{ steps.create_issue.outputs.issue_url }} + run: | + # Extract issue number from URL + ISSUE_NUMBER=$(echo "$ISSUE_URL" | grep -oE '[0-9]+$') + gh issue edit "$ISSUE_NUMBER" --add-assignee "$PR_AUTHOR" || echo "Could not assign to $PR_AUTHOR, skipping assignment" + + - name: Comment on original PR (conflict) + if: steps.prepare.outputs.conflict == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ORIGINAL_PR: ${{ github.event.pull_request.number }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + gh pr comment "$ORIGINAL_PR" --body "$(cat <> "$GITHUB_OUTPUT" + echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" - - name: Get last release tag - id: last_tag + - name: Calculate initial version + id: version shell: bash run: | - LAST_TAG=$(git tag --list "v*" --sort=-version:refname | head -n1 || echo "") - if [ -z "$LAST_TAG" ]; then - LAST_TAG=$(git rev-list --max-parents=0 HEAD) - fi - echo "tag=$LAST_TAG" >> "$GITHUB_OUTPUT" - echo "Last tag: $LAST_TAG" + set -euo pipefail + + MAJOR="${{ inputs.major_version }}" + MINOR="${{ inputs.minor_version }}" + INITIAL_VERSION="${MAJOR}.${MINOR}.0" - - name: Create branch next/${{ steps.next.outputs.value }} + echo "initial_version=$INITIAL_VERSION" >> "$GITHUB_OUTPUT" + echo "Initial version: $INITIAL_VERSION" + + - name: Create release branch + id: branch shell: bash run: | - NEXT="${{ steps.next.outputs.value }}" - git switch -c "next/$NEXT" + set -euo pipefail + + BRANCH_NAME="${{ steps.check_branch.outputs.branch_name }}" + BASE_BRANCH="${{ inputs.base_branch }}" + + git fetch origin "$BASE_BRANCH" --tags + git switch -c "$BRANCH_NAME" "origin/$BASE_BRANCH" + + echo "Created branch: $BRANCH_NAME" - - name: Bump version in package.json + - name: Set package version shell: bash run: | - NEXT="${{ steps.next.outputs.value }}" + set -euo pipefail + + INITIAL_VERSION="${{ steps.version.outputs.initial_version }}" + echo "Setting version to $INITIAL_VERSION" + node -e " const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); - pkg.version = '$NEXT'; + pkg.version = '$INITIAL_VERSION'; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); " - - name: Generate changelog entry + - name: Update CHANGELOG.md shell: bash run: | set -euo pipefail - NEXT="${{ steps.next.outputs.value }}" - LAST_TAG="${{ steps.last_tag.outputs.tag }}" - TODAY=$(date -u +"%Y-%m-%d") - - # Get commits since last tag - COMMITS=$(git log "$LAST_TAG"...HEAD --oneline --no-merges 2>/dev/null || echo "Initial release") - - # Generate changelog entry - ENTRY="## [$NEXT] - $TODAY - -### Changes -" - # Parse commits into categories - FEATURES="" - FIXES="" - OTHER="" - - while IFS= read -r line; do - if [ -z "$line" ]; then continue; fi - commit_msg="${line#* }" - if [[ "$commit_msg" =~ ^feat ]]; then - FEATURES+="- ${commit_msg#feat: } -" - elif [[ "$commit_msg" =~ ^fix ]]; then - FIXES+="- ${commit_msg#fix: } -" - else - OTHER+="- $commit_msg -" - fi - done <<< "$COMMITS" - - if [ -n "$FEATURES" ]; then - ENTRY+=" -#### Added -$FEATURES" - fi - if [ -n "$FIXES" ]; then - ENTRY+=" -#### Fixed -$FIXES" - fi - - if [ -n "$OTHER" ]; then - ENTRY+=" -#### Other -$OTHER" - fi + INITIAL_VERSION="${{ steps.version.outputs.initial_version }}" + TODAY=$(date -u +"%Y-%m-%d") - # Prepend to CHANGELOG.md if [ -f "CHANGELOG.md" ]; then - echo "$ENTRY" > CHANGELOG.new.md - echo "" >> CHANGELOG.new.md - cat CHANGELOG.md >> CHANGELOG.new.md - mv CHANGELOG.new.md CHANGELOG.md + { echo "## [$INITIAL_VERSION] - $TODAY"; echo ""; cat CHANGELOG.md; } > CHANGELOG.tmp + mv CHANGELOG.tmp CHANGELOG.md + echo "Updated CHANGELOG.md" else - echo "# Changelog - -$ENTRY" > CHANGELOG.md + echo "## [$INITIAL_VERSION] - $TODAY" > CHANGELOG.md + echo "Created CHANGELOG.md" fi - - name: Commit release preparation + - name: Commit changes shell: bash run: | - NEXT="${{ steps.next.outputs.value }}" - git add -A - git commit -m "chore(release): prepare v$NEXT" + set -euo pipefail - - name: Push branch - shell: bash - run: | - NEXT="${{ steps.next.outputs.value }}" - git push --set-upstream origin "next/$NEXT" + INITIAL_VERSION="${{ steps.version.outputs.initial_version }}" + BRANCH_NAME="${{ steps.check_branch.outputs.branch_name }}" + + if [ -n "$(git status --porcelain)" ]; then + git add -A + git commit -m "chore(release): initialize $BRANCH_NAME at v$INITIAL_VERSION" \ + -m "- Set package version to $INITIAL_VERSION" + echo "Created initialization commit" + else + echo "No changes to commit" + fi - - name: Open PR - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Push branch shell: bash run: | - NEXT="${{ steps.next.outputs.value }}" - - gh pr create \ - --base "main" \ - --head "next/$NEXT" \ - --title "v$NEXT" \ - --body "Release v$NEXT + set -euo pipefail -## Checklist -- [ ] Review changelog -- [ ] Verify version bump is correct -- [ ] Tests pass" + BRANCH_NAME="${{ steps.check_branch.outputs.branch_name }}" + git push --set-upstream origin "$BRANCH_NAME" + echo "Pushed $BRANCH_NAME to origin" - name: Summary run: | - echo "✅ Created branch: next/${{ steps.next.outputs.value }}" - echo "➡️ Bump: ${{ inputs.bump }}" - echo "📦 PR title: v${{ steps.next.outputs.value }}" + echo "## Release Branch Created" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| **Branch** | \`${{ steps.check_branch.outputs.branch_name }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Initial Version** | \`${{ steps.version.outputs.initial_version }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| **Base Branch** | \`${{ inputs.base_branch }}\` |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Next Steps" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "1. Create PRs targeting \`${{ steps.check_branch.outputs.branch_name }}\`" >> $GITHUB_STEP_SUMMARY + echo "2. When ready to publish, run the **Publish Release** workflow from this branch" >> $GITHUB_STEP_SUMMARY + echo "3. Subsequent releases from this branch will increment: ${{ steps.version.outputs.initial_version }} -> x.y.1 -> x.y.2 ..." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/publish-on-next-close.yml b/.github/workflows/publish-on-next-close.yml deleted file mode 100644 index 5490a2b..0000000 --- a/.github/workflows/publish-on-next-close.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Publish on next/* merge - -on: - pull_request: - types: [closed] - -permissions: - contents: write - id-token: write - -concurrency: - group: publish-${{ github.event.pull_request.number || github.run_id }} - cancel-in-progress: false - -jobs: - publish: - if: > - github.event.pull_request.merged == true && - startsWith(github.event.pull_request.head.ref, 'next/') - runs-on: ubuntu-latest - environment: npm - - steps: - - name: Determine release ref - id: release_ref - shell: bash - env: - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} - run: | - if [ -n "$MERGE_SHA" ] && [ "$MERGE_SHA" != "null" ]; then - echo "ref=$MERGE_SHA" >> "$GITHUB_OUTPUT" - else - echo "ref=main" >> "$GITHUB_OUTPUT" - fi - - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ steps.release_ref.outputs.ref }} - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version-file: ".nvmrc" - cache: "yarn" - registry-url: "https://registry.npmjs.org/" - - - name: Update npm for trusted publishing - run: npm install -g npm@latest - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - name: Configure git - run: | - git config user.name "agentfront[bot]" - git config user.email "agentfront[bot]@users.noreply.github.com" - - - name: Determine version from branch - id: version - shell: bash - env: - HEAD_REF: ${{ github.event.pull_request.head.ref }} - run: | - if [[ "$HEAD_REF" =~ ^next/([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then - VERSION="${BASH_REMATCH[1]}" - else - VERSION=$(node -e "console.log(require('./package.json').version)") - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Release version: $VERSION" - - - name: Create and push git tag - shell: bash - run: | - VERSION="${{ steps.version.outputs.version }}" - TAG="v$VERSION" - - git fetch --tags - if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "Tag $TAG already exists" - else - git tag -a "$TAG" -m "Release $TAG" - git push origin "$TAG" - fi - - - name: Build - run: yarn build - - - name: Test - run: yarn test - - - name: Publish to npm - run: npm publish --provenance --access public - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: v${{ steps.version.outputs.version }} - name: v${{ steps.version.outputs.version }} - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Summary - run: | - echo "✅ Published: v${{ steps.version.outputs.version }}" - echo "🏷️ Tag: v${{ steps.version.outputs.version }}" diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..45fe497 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,369 @@ +name: Publish Release + +on: + workflow_dispatch: + inputs: + release_type: + description: "Release type" + required: true + type: choice + options: + - stable + - rc + - beta + default: stable + pre_release_number: + description: "Pre-release number (for rc/beta, leave empty for auto-increment)" + required: false + type: string + dry_run: + description: "Dry run (skip actual publish)" + required: false + type: boolean + default: false + +permissions: + contents: write + packages: write + id-token: write + +concurrency: + group: publish-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + environment: release + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate branch + id: context + shell: bash + run: | + set -euo pipefail + + # Get current branch + BRANCH="${GITHUB_REF#refs/heads/}" + echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" + + # Validate branch is a release branch + if [[ ! "$BRANCH" =~ ^release/[0-9]+\.[0-9]+\.x$ ]]; then + echo "::error::This workflow must be run from a release/X.Y.x branch. Current branch: $BRANCH" + exit 1 + fi + + # Extract release line (X.Y) from release/X.Y.x + RELEASE_LINE=$(echo "$BRANCH" | sed 's/release\/\([0-9]*\.[0-9]*\).*/\1/') + echo "release_line=$RELEASE_LINE" >> "$GITHUB_OUTPUT" + + echo "Branch: $BRANCH" + echo "Release line: $RELEASE_LINE" + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + cache: "yarn" + registry-url: "https://registry.npmjs.org/" + + - name: Update npm CLI for trusted publishing + run: npm install -g npm@latest + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Compute version + id: version + shell: bash + run: | + set -euo pipefail + + RELEASE_LINE="${{ steps.context.outputs.release_line }}" + RELEASE_TYPE="${{ inputs.release_type }}" + PRE_RELEASE_NUM="${{ inputs.pre_release_number }}" + + # Fetch all tags + git fetch --tags + + # Use compute-next-patch script + if [ -n "$PRE_RELEASE_NUM" ]; then + VERSION=$(node scripts/compute-next-patch.mjs "$RELEASE_LINE" "$RELEASE_TYPE" "$PRE_RELEASE_NUM") + else + VERSION=$(node scripts/compute-next-patch.mjs "$RELEASE_LINE" "$RELEASE_TYPE") + fi + + # Determine if this is a pre-release + IS_PRERELEASE="false" + NPM_TAG="latest" + if [[ "$VERSION" == *"-rc."* ]]; then + IS_PRERELEASE="true" + NPM_TAG="rc" + elif [[ "$VERSION" == *"-beta."* ]]; then + IS_PRERELEASE="true" + NPM_TAG="beta" + fi + + # Check if tag already exists + if git rev-parse "v$VERSION" >/dev/null 2>&1; then + echo "::error::Tag v$VERSION already exists!" + exit 1 + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "release_type=$RELEASE_TYPE" >> "$GITHUB_OUTPUT" + echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" + echo "npm_tag=$NPM_TAG" >> "$GITHUB_OUTPUT" + + echo "Version: $VERSION" + echo "Release type: $RELEASE_TYPE" + echo "Is prerelease: $IS_PRERELEASE" + echo "NPM tag: $NPM_TAG" + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Get previous version + id: prev_version + run: | + RELEASE_LINE="${{ steps.context.outputs.release_line }}" + # Get the latest stable tag for this release line + PREV_TAG=$(git tag --list "v${RELEASE_LINE}.*" --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + if [ -z "$PREV_TAG" ]; then + # No previous tag in this line, try previous minor + PREV_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + fi + echo "prev_tag=$PREV_TAG" >> "$GITHUB_OUTPUT" + echo "Previous tag: $PREV_TAG" + + - name: Generate diff + id: diff + run: | + PREV_TAG="${{ steps.prev_version.outputs.prev_tag }}" + if [ -n "$PREV_TAG" ]; then + DIFF=$(git diff "$PREV_TAG"..HEAD \ + --stat --patch \ + -- '*.ts' '*.js' '*.json' ':!package-lock.json' ':!*.test.ts' ':!*.spec.ts' \ + | head -c 50000) + else + DIFF="Initial release - no previous version to compare" + fi + # Use file to avoid shell escaping issues + echo "$DIFF" > /tmp/diff.txt + + - name: Generate AI changelog + id: ai_changelog + if: ${{ inputs.dry_run != true && inputs.release_type == 'stable' }} + uses: actions/github-script@v7 + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + VERSION: v${{ steps.version.outputs.version }} + VERSION_MINOR: ${{ steps.context.outputs.release_line }} + with: + script: | + const fs = require('fs'); + const diff = fs.readFileSync('/tmp/diff.txt', 'utf8'); + const releaseDate = new Date().toISOString().split('T')[0]; + const version = process.env.VERSION; + const versionNum = version.replace('v', ''); + + const prompt = `You are a technical writer for mcp-from-openapi, a library for converting OpenAPI specifications into MCP tool definitions. + + Version: ${version} + Release Date: ${releaseDate} + + Git diff: + \`\`\` + ${diff.substring(0, 40000)} + \`\`\` + + Generate two outputs: + + 1. CHANGELOG entry (Keep a Changelog format): + ## [${versionNum}] - ${releaseDate} + ### Added/Changed/Fixed/Security (only include relevant sections) + - Concise description of changes + + 2. A SINGLE Mintlify component (NOT the full file, just the Card): + + **Feature** – Description. + - Details if needed + + + IMPORTANT: For cardMdx, output ONLY the ... component, nothing else. + + Output ONLY valid JSON: {"changelog": "...", "cardMdx": "..."}`; + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model: 'gpt-4o', + messages: [{ role: 'user', content: prompt }], + response_format: { type: 'json_object' } + }) + }); + + if (!response.ok) throw new Error(`OpenAI API error: ${response.status}`); + const data = await response.json(); + const result = JSON.parse(data.choices[0].message.content); + + // Write card MDX to file to avoid shell escaping issues + fs.writeFileSync('/tmp/card-mdx.txt', result.cardMdx); + + core.setOutput('changelog', result.changelog); + core.setOutput('has_card_mdx', result.cardMdx ? 'true' : 'false'); + + - name: Update package version + if: ${{ inputs.dry_run != true }} + run: | + VERSION="${{ steps.version.outputs.version }}" + node -e " + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + pkg.version = '$VERSION'; + fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + + - name: Commit version bump + if: ${{ inputs.dry_run != true }} + run: | + if [ -n "$(git status --porcelain)" ]; then + git add -A + git commit -m "chore(release): v${{ steps.version.outputs.version }}" + git push origin HEAD + fi + + - name: Build package + run: yarn build + + - name: Publish to npm + if: ${{ inputs.dry_run != true }} + shell: bash + run: | + set -euo pipefail + + NPM_TAG="${{ steps.version.outputs.npm_tag }}" + + echo "Publishing mcp-from-openapi from dist/ with tag $NPM_TAG..." + npm publish dist/ --access public --tag "$NPM_TAG" --provenance + + echo "Successfully published mcp-from-openapi@${{ steps.version.outputs.version }}" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create and push git tag + if: ${{ inputs.dry_run != true }} + run: | + VERSION="${{ steps.version.outputs.version }}" + TAG="v$VERSION" + BRANCH="${{ steps.context.outputs.branch }}" + + # Fetch latest to ensure we tag the committed version + git fetch origin "$BRANCH" + git checkout "$BRANCH" + git pull origin "$BRANCH" + + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + + echo "Created and pushed tag: $TAG" + + - name: Prepare release body + id: release_body + env: + CHANGELOG: ${{ steps.ai_changelog.outputs.changelog }} + run: | + VERSION="${{ steps.version.outputs.version }}" + RELEASE_TYPE="${{ steps.version.outputs.release_type }}" + RELEASE_LINE="${{ steps.context.outputs.release_line }}" + BRANCH="${{ steps.context.outputs.branch }}" + IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}" + + # Start building the release body + { + echo "## Release v${VERSION}" + echo "" + echo "**Release type:** ${RELEASE_TYPE}" + echo "**Release line:** ${RELEASE_LINE}.x" + echo "**Branch:** ${BRANCH}" + } > /tmp/release-body.md + + # Add AI-generated changelog if available + if [ -f /tmp/card-mdx.txt ] && [ -s /tmp/card-mdx.txt ] && [ -n "$CHANGELOG" ]; then + echo "" >> /tmp/release-body.md + echo "$CHANGELOG" >> /tmp/release-body.md + fi + + # Add installation section + { + echo "" + echo "### Installation" + echo "" + echo '```bash' + echo "npm install mcp-from-openapi@${VERSION}" + echo '```' + } >> /tmp/release-body.md + + # Add pre-release note if applicable + if [ "$IS_PRERELEASE" = "true" ]; then + echo "" >> /tmp/release-body.md + echo "> **Note:** This is a pre-release version." >> /tmp/release-body.md + fi + + # Add Card MDX as hidden comment for docs sync (only for stable releases) + # NOTE: Content is sanitized to prevent --> from breaking the HTML comment. + # Consumer must reverse: replace "-->" with "-->" after extraction. + if [ -f /tmp/card-mdx.txt ] && [ -s /tmp/card-mdx.txt ]; then + echo "" >> /tmp/release-body.md + echo "/--\>/g' /tmp/card-mdx.txt >> /tmp/release-body.md + echo "CARD_MDX_END" >> /tmp/release-body.md + echo "-->" >> /tmp/release-body.md + fi + + - name: Create GitHub Release + if: ${{ inputs.dry_run != true }} + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.version }} + name: v${{ steps.version.outputs.version }} + prerelease: ${{ steps.version.outputs.is_prerelease }} + generate_release_notes: false + body_path: /tmp/release-body.md + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + run: | + if [ "${{ inputs.dry_run }}" = "true" ]; then + echo "## Dry Run Summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "> **This was a dry run. No packages were published.**" >> "$GITHUB_STEP_SUMMARY" + else + echo "## Release Complete" >> "$GITHUB_STEP_SUMMARY" + fi + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Property | Value |" >> "$GITHUB_STEP_SUMMARY" + echo "|----------|-------|" >> "$GITHUB_STEP_SUMMARY" + echo "| Version | \`${{ steps.version.outputs.version }}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Tag | \`v${{ steps.version.outputs.version }}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Release type | ${{ steps.version.outputs.release_type }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| NPM tag | \`${{ steps.version.outputs.npm_tag }}\` |" >> "$GITHUB_STEP_SUMMARY" + echo "| Pre-release | ${{ steps.version.outputs.is_prerelease }} |" >> "$GITHUB_STEP_SUMMARY" + echo "| Branch | \`${{ steps.context.outputs.branch }}\` |" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 31b6451..fee6a37 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -2,9 +2,19 @@ name: CI on: push: - branches: [main] + branches: + - main + - "next/**" + - "release/**" pull_request: - branches: [main] + branches: + - main + - "next/**" + - "release/**" + +permissions: + actions: read + contents: read concurrency: group: ci-${{ github.ref }} diff --git a/scripts/compute-next-patch.mjs b/scripts/compute-next-patch.mjs new file mode 100644 index 0000000..60737b3 --- /dev/null +++ b/scripts/compute-next-patch.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +/** + * Computes the next patch version for a release line based on git tags. + * + * Usage: + * node scripts/compute-next-patch.mjs [release-type] [pre-release-number] + * + * Examples: + * node scripts/compute-next-patch.mjs 1.4 # Returns 1.4.3 (if 1.4.2 exists) + * node scripts/compute-next-patch.mjs 1.4 rc 1 # Returns 1.4.3-rc.1 + * node scripts/compute-next-patch.mjs 1.4 beta # Returns 1.4.3-beta.1 (auto-increment) + * node scripts/compute-next-patch.mjs 1.4 rc # Returns 1.4.3-rc.1 (or rc.2 if rc.1 exists) + */ + +import { execSync } from 'node:child_process'; + +/** + * Escape all regex special characters in a string. + * Prevents regex injection when building patterns from user input. + */ +function escapeRegExp(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const [, , releaseLine, releaseType = 'stable', preReleaseNumArg] = process.argv; + +if (!releaseLine) { + console.error('Usage: compute-next-patch.mjs [release-type] [pre-release-number]'); + console.error(' release-line: X.Y (e.g., 1.4)'); + console.error(' release-type: stable, rc, or beta (default: stable)'); + console.error(' pre-release-number: N for -rc.N or -beta.N (optional, auto-increments if not provided)'); + process.exit(1); +} + +const allowedReleaseTypes = new Set(['stable', 'rc', 'beta']); +if (!allowedReleaseTypes.has(releaseType)) { + console.error(`Invalid release type: ${releaseType}. Expected: stable, rc, or beta.`); + process.exit(1); +} + +// Validate release line format +if (!/^\d+\.\d+$/.test(releaseLine)) { + console.error(`Invalid release line format: ${releaseLine}. Expected format: X.Y (e.g., 1.4)`); + process.exit(1); +} + +if (releaseType === 'stable' && typeof preReleaseNumArg !== 'undefined' && preReleaseNumArg !== '') { + console.error('pre-release-number is only valid for rc/beta releases.'); + process.exit(1); +} + +// Get all tags for this release line +let tags; +try { + const output = execSync(`git tag --list "v${releaseLine}.*" --sort=-version:refname`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + tags = output.trim().split('\n').filter(Boolean); +} catch { + // No tags found, that's OK + tags = []; +} + +// Find highest stable version to determine next patch number +const stableTags = tags.filter((t) => !t.includes('-')); +let nextPatch = 0; + +if (stableTags.length > 0) { + const escapedReleaseLine = escapeRegExp(releaseLine); + const stableTagPattern = new RegExp(`^v${escapedReleaseLine}\\.(\\d+)$`); + for (const tag of stableTags) { + const match = tag.match(stableTagPattern); + if (match) { + nextPatch = parseInt(match[1], 10) + 1; + break; + } + } +} + +let version = `${releaseLine}.${nextPatch}`; + +// Handle pre-release suffixes +if (releaseType === 'rc' || releaseType === 'beta') { + let preReleaseNum = preReleaseNumArg ? parseInt(preReleaseNumArg, 10) : null; + + if (preReleaseNumArg) { + if (!/^\d+$/.test(preReleaseNumArg) || !Number.isFinite(preReleaseNum) || preReleaseNum < 1) { + console.error(`Invalid pre-release number: ${preReleaseNumArg}. Expected a positive integer (e.g., 1).`); + process.exit(1); + } + } + + if (preReleaseNum === null) { + // Auto-increment pre-release number + const preReleaseTags = tags.filter((t) => t.includes(`-${releaseType}.`)); + const targetVersionTags = preReleaseTags.filter((t) => t.startsWith(`v${version}-${releaseType}.`)); + + if (targetVersionTags.length === 0) { + preReleaseNum = 1; + } else { + // Find highest pre-release number for this version + let maxPreRelease = 0; + for (const tag of targetVersionTags) { + const preMatch = tag.match(new RegExp(`-${releaseType}\\.(\\d+)`)); + if (preMatch) { + const num = parseInt(preMatch[1], 10); + if (num > maxPreRelease) { + maxPreRelease = num; + } + } + } + preReleaseNum = maxPreRelease + 1; + } + } + + version = `${version}-${releaseType}.${preReleaseNum}`; +} + +// Output just the version (no newline for use in shell scripts) +process.stdout.write(version);