diff --git a/.github/workflows/release-patch-1-create-pr.yml b/.github/workflows/release-patch-1-create-pr.yml index 71c324cbbc..b5b4afbd38 100644 --- a/.github/workflows/release-patch-1-create-pr.yml +++ b/.github/workflows/release-patch-1-create-pr.yml @@ -65,25 +65,13 @@ jobs: permission-pull-requests: 'write' permission-contents: 'write' - - name: 'Create Patch for Stable' - id: 'create_patch_stable' - if: "github.event.inputs.channel == 'stable'" + - name: 'Create Patch' + id: 'create_patch' env: GH_TOKEN: '${{ steps.generate_token.outputs.token }}' continue-on-error: true run: | - node scripts/create-patch-pr.js --commit=${{ github.event.inputs.commit }} --channel=stable --dry-run=${{ github.event.inputs.dry_run }} > patch_output.log 2>&1 - echo "EXIT_CODE=$?" >> "$GITHUB_OUTPUT" - cat patch_output.log - - - name: 'Create Patch for Preview' - id: 'create_patch_preview' - if: "github.event.inputs.channel != 'stable'" - env: - GH_TOKEN: '${{ steps.generate_token.outputs.token }}' - continue-on-error: true - run: | - node scripts/create-patch-pr.js --commit=${{ github.event.inputs.commit }} --channel=${{ github.event.inputs.channel }} --dry-run=${{ github.event.inputs.dry_run }} > patch_output.log 2>&1 + node scripts/releasing/create-patch-pr.js --commit=${{ github.event.inputs.commit }} --channel=${{ github.event.inputs.channel }} --dry-run=${{ github.event.inputs.dry_run }} > patch_output.log 2>&1 echo "EXIT_CODE=$?" >> "$GITHUB_OUTPUT" cat patch_output.log @@ -91,46 +79,12 @@ jobs: if: '!inputs.dry_run && inputs.original_pr' env: GH_TOKEN: '${{ steps.generate_token.outputs.token }}' + ORIGINAL_PR: '${{ github.event.inputs.original_pr }}' + EXIT_CODE: '${{ steps.create_patch.outputs.EXIT_CODE }}' + OUTPUT_LOG: 'patch_output.log' + COMMIT: '${{ github.event.inputs.commit }}' + CHANNEL: '${{ github.event.inputs.channel }}' + REPOSITORY: '${{ github.repository }}' + GITHUB_RUN_ID: '${{ github.run_id }}' run: | - # Determine which step ran based on channel - if [ "${{ github.event.inputs.channel }}" = "stable" ]; then - EXIT_CODE="${{ steps.create_patch_stable.outputs.EXIT_CODE }}" - else - EXIT_CODE="${{ steps.create_patch_preview.outputs.EXIT_CODE }}" - fi - - # Check if patch output exists and contains branch info - if [ -f patch_output.log ]; then - if grep -q "already has an open PR" patch_output.log; then - # Branch exists with existing PR - PR_NUMBER=$(grep "Found existing PR" patch_output.log | sed 's/.*Found existing PR #\([0-9]*\).*/\1/') - PR_URL=$(grep "Found existing PR" patch_output.log | sed 's/.*Found existing PR #[0-9]*: \(.*\)/\1/') - gh pr comment ${{ github.event.inputs.original_pr }} --body "โ„น๏ธ Patch PR already exists! A patch PR for this change already exists: [#$PR_NUMBER]($PR_URL). Please review and approve this existing patch PR. If it's incorrect, close it and run the patch command again." - - elif grep -q "exists but has no open PR" patch_output.log; then - # Branch exists but no PR - BRANCH=$(grep "Hotfix branch" patch_output.log | grep "already exists" | sed 's/.*Hotfix branch \(.*\) already exists.*/\1/') - gh pr comment ${{ github.event.inputs.original_pr }} --body "โ„น๏ธ Patch branch exists but no PR found! A patch branch [\`$BRANCH\`](https://github.com/${{ github.repository }}/tree/$BRANCH) exists but has no open PR. This might indicate an incomplete patch process. Please delete the branch and run the patch command again." - - elif [ "$EXIT_CODE" = "0" ]; then - # Success - find the newly created PR - BRANCH=$(grep "Creating hotfix branch" patch_output.log | sed 's/.*Creating hotfix branch \(.*\) from.*/\1/') - - # Find the PR for the new branch - PR_INFO=$(gh pr list --head "$BRANCH" --json number,url --jq '.[0] // empty') - - if [ -n "$PR_INFO" ]; then - PR_NUMBER=$(echo "$PR_INFO" | jq -r '.number') - PR_URL=$(echo "$PR_INFO" | jq -r '.url') - gh pr comment ${{ github.event.inputs.original_pr }} --body "๐Ÿš€ Patch PR created! The patch release PR has been created: [#$PR_NUMBER]($PR_URL). Please review and approve this PR to complete the patch release." - else - # Fallback if we can't find the specific PR - gh pr comment ${{ github.event.inputs.original_pr }} --body "๐Ÿš€ Patch PR created! The patch release PR for this change has been created. Please review and approve it: [View all patch PRs](https://github.com/${{ github.repository }}/pulls?q=is%3Apr+is%3Aopen+label%3Apatch)" - fi - else - # Other error - gh pr comment ${{ github.event.inputs.original_pr }} --body "โŒ Patch creation failed! There was an error creating the patch. Please check the workflow logs for details: [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" - fi - else - gh pr comment ${{ github.event.inputs.original_pr }} --body "โŒ Patch creation failed! No output was generated. Please check the workflow logs: [View workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" - fi + node scripts/releasing/patch-create-comment.js diff --git a/.github/workflows/release-patch-2-trigger.yml b/.github/workflows/release-patch-2-trigger.yml index 170c6524ca..3e3a9225d2 100644 --- a/.github/workflows/release-patch-2-trigger.yml +++ b/.github/workflows/release-patch-2-trigger.yml @@ -10,6 +10,11 @@ on: description: 'The head ref of the merged hotfix PR to trigger the release for (e.g. hotfix/v1.2.3/cherry-pick-abc).' required: true type: 'string' + workflow_ref: + description: 'The ref to checkout the workflow code from.' + required: false + type: 'string' + default: 'main' workflow_id: description: 'The workflow to trigger. Defaults to patch-release.yml' required: false @@ -28,41 +33,30 @@ jobs: permissions: actions: 'write' steps: - - name: 'Trigger Patch Release' - uses: 'actions/github-script@00f12e3e20659f42342b1c0226afda7f7c042325' + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' with: - script: | - let body = ''; - let headRef = ''; + ref: "${{ github.event.inputs.workflow_ref || 'main' }}" + fetch-depth: 1 - if (context.eventName === 'pull_request') { - body = context.payload.pull_request.body; - headRef = context.payload.pull_request.head.ref; - } else { // workflow_dispatch - body = ${{ github.event.inputs.dry_run }} ? '[DRY RUN]' : ''; - headRef = '${{ github.event.inputs.ref }}'; - } + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' + with: + node-version-file: '.nvmrc' + cache: 'npm' - const isDryRun = body.includes('[DRY RUN]'); + - name: 'Install Dependencies' + run: 'npm ci' - // Extract base version and channel from hotfix branch name - // e.g., hotfix/v0.5.3/cherry-pick-abc -> v0.5.3 - const version = headRef.split('/')[1]; - const channel = version.includes('preview') ? 'preview' : 'stable'; - const releaseRef = `release/${version}`; - - const workflow_id = context.eventName === 'pull_request' - ? 'release-patch-3-release.yml' - : '${{ github.event.inputs.workflow_id }}'; - - github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: workflow_id, - ref: 'main', - inputs: { - type: channel, - dry_run: isDryRun.toString(), - release_ref: releaseRef - } - }) + - name: 'Trigger Patch Release' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + HEAD_REF: "${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.event.inputs.ref }}" + PR_BODY: "${{ github.event_name == 'pull_request' && github.event.pull_request.body || '' }}" + WORKFLOW_ID: '${{ github.event.inputs.workflow_id }}' + GITHUB_REPOSITORY_OWNER: '${{ github.repository_owner }}' + GITHUB_REPOSITORY_NAME: '${{ github.event.repository.name }}' + GITHUB_EVENT_NAME: '${{ github.event_name }}' + GITHUB_EVENT_PAYLOAD: '${{ toJSON(github.event) }}' + run: | + node scripts/releasing/patch-trigger.js diff --git a/.github/workflows/release-patch-3-release.yml b/.github/workflows/release-patch-3-release.yml index 0e9bf6e56f..45627f34c9 100644 --- a/.github/workflows/release-patch-3-release.yml +++ b/.github/workflows/release-patch-3-release.yml @@ -24,6 +24,10 @@ on: description: 'The branch, tag, or SHA to release from.' required: true type: 'string' + original_pr: + description: 'The original PR number to comment back on.' + required: false + type: 'string' jobs: release: @@ -82,6 +86,55 @@ jobs: echo "NPM_TAG=${NPM_TAG}" >> "${GITHUB_OUTPUT}" echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> "${GITHUB_OUTPUT}" + - name: 'Verify Version Consistency' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + CHANNEL: '${{ github.event.inputs.type }}' + run: | + echo "๐Ÿ” Verifying no concurrent patch releases have occurred..." + + # Store original calculation for comparison + ORIGINAL_RELEASE_VERSION="${{ steps.patch_version.outputs.RELEASE_VERSION }}" + ORIGINAL_RELEASE_TAG="${{ steps.patch_version.outputs.RELEASE_TAG }}" + ORIGINAL_PREVIOUS_TAG="${{ steps.patch_version.outputs.PREVIOUS_TAG }}" + + echo "Original calculation:" + echo " Release version: ${ORIGINAL_RELEASE_VERSION}" + echo " Release tag: ${ORIGINAL_RELEASE_TAG}" + echo " Previous tag: ${ORIGINAL_PREVIOUS_TAG}" + + # Re-run the same version calculation script + echo "Re-calculating version to check for changes..." + CURRENT_PATCH_JSON=$(node scripts/get-release-version.js --type=patch --patch-from="${CHANNEL}") + CURRENT_RELEASE_VERSION=$(echo "${CURRENT_PATCH_JSON}" | jq -r .releaseVersion) + CURRENT_RELEASE_TAG=$(echo "${CURRENT_PATCH_JSON}" | jq -r .releaseTag) + CURRENT_PREVIOUS_TAG=$(echo "${CURRENT_PATCH_JSON}" | jq -r .previousReleaseTag) + + echo "Current calculation:" + echo " Release version: ${CURRENT_RELEASE_VERSION}" + echo " Release tag: ${CURRENT_RELEASE_TAG}" + echo " Previous tag: ${CURRENT_PREVIOUS_TAG}" + + # Compare calculations + if [[ "${ORIGINAL_RELEASE_VERSION}" != "${CURRENT_RELEASE_VERSION}" ]] || \ + [[ "${ORIGINAL_RELEASE_TAG}" != "${CURRENT_RELEASE_TAG}" ]] || \ + [[ "${ORIGINAL_PREVIOUS_TAG}" != "${CURRENT_PREVIOUS_TAG}" ]]; then + echo "โŒ RACE CONDITION DETECTED: Version calculations have changed!" + echo "This indicates another patch release completed while this one was in progress." + echo "" + echo "Originally planned: ${ORIGINAL_RELEASE_VERSION} (from ${ORIGINAL_PREVIOUS_TAG})" + echo "Should now build: ${CURRENT_RELEASE_VERSION} (from ${CURRENT_PREVIOUS_TAG})" + echo "" + echo "# Setting outputs for failure comment" + echo "CURRENT_RELEASE_VERSION=${CURRENT_RELEASE_VERSION}" >> "${GITHUB_ENV}" + echo "CURRENT_RELEASE_TAG=${CURRENT_RELEASE_TAG}" >> "${GITHUB_ENV}" + echo "CURRENT_PREVIOUS_TAG=${CURRENT_PREVIOUS_TAG}" >> "${GITHUB_ENV}" + echo "The patch release must be restarted to use the correct version numbers." + exit 1 + fi + + echo "โœ… Version calculations unchanged - proceeding with release" + - name: 'Print Calculated Version' run: |- echo "Patch Release Summary:" @@ -121,3 +174,46 @@ jobs: --title 'Patch Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')' \ --body 'The patch-release workflow failed. See the full run for details: ${DETAILS_URL}' \ --label 'kind/bug,release-failure,priority/p0' + + - name: 'Comment Success on Original PR' + if: '${{ success() && github.event.inputs.original_pr }}' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + ORIGINAL_PR: '${{ github.event.inputs.original_pr }}' + SUCCESS: 'true' + RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' + RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' + NPM_TAG: '${{ steps.patch_version.outputs.NPM_TAG }}' + CHANNEL: '${{ github.event.inputs.type }}' + DRY_RUN: '${{ github.event.inputs.dry_run }}' + GITHUB_RUN_ID: '${{ github.run_id }}' + GITHUB_REPOSITORY_OWNER: '${{ github.repository_owner }}' + GITHUB_REPOSITORY_NAME: '${{ github.event.repository.name }}' + run: | + node scripts/releasing/patch-comment.js + + - name: 'Comment Failure on Original PR' + if: '${{ failure() && github.event.inputs.original_pr }}' + env: + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + ORIGINAL_PR: '${{ github.event.inputs.original_pr }}' + SUCCESS: 'false' + RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' + RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' + NPM_TAG: '${{ steps.patch_version.outputs.NPM_TAG }}' + CHANNEL: '${{ github.event.inputs.type }}' + DRY_RUN: '${{ github.event.inputs.dry_run }}' + GITHUB_RUN_ID: '${{ github.run_id }}' + GITHUB_REPOSITORY_OWNER: '${{ github.repository_owner }}' + GITHUB_REPOSITORY_NAME: '${{ github.event.repository.name }}' + # Pass current version info for race condition failures + CURRENT_RELEASE_VERSION: '${{ env.CURRENT_RELEASE_VERSION }}' + CURRENT_RELEASE_TAG: '${{ env.CURRENT_RELEASE_TAG }}' + CURRENT_PREVIOUS_TAG: '${{ env.CURRENT_PREVIOUS_TAG }}' + run: | + # Check if this was a version consistency failure + if [[ -n "${CURRENT_RELEASE_VERSION}" ]]; then + echo "Detected version race condition failure - posting specific comment with current version info" + export RACE_CONDITION_FAILURE=true + fi + node scripts/releasing/patch-comment.js diff --git a/.github/workflows/release-patch-from-comment.yml b/.github/workflows/release-patch-from-comment.yml index 6ac3276deb..2ba2eea5e1 100644 --- a/.github/workflows/release-patch-from-comment.yml +++ b/.github/workflows/release-patch-from-comment.yml @@ -53,9 +53,35 @@ jobs: - name: 'Dispatch if Merged' if: "steps.pr_status.outputs.STATE == 'MERGED'" uses: 'actions/github-script@00f12e3e20659f42342b1c0226afda7f7c042325' + env: + COMMENT_BODY: '${{ github.event.comment.body }}' with: script: | - const args = ${{ fromJSON(steps.slash_command.outputs.command-arguments || '{}') }}; + // Parse the comment body directly to extract channel + const commentBody = process.env.COMMENT_BODY; + console.log('Comment body:', commentBody); + + let channel = 'stable'; // default + + // Parse different formats: + // /patch channel=preview + // /patch --channel preview + // /patch preview + if (commentBody.includes('channel=preview')) { + channel = 'preview'; + } else if (commentBody.includes('--channel preview')) { + channel = 'preview'; + } else if (commentBody.trim() === '/patch preview') { + channel = 'preview'; + } + + // Validate channel + if (channel !== 'stable' && channel !== 'preview') { + throw new Error(`Invalid channel: ${channel}. Must be 'stable' or 'preview'.`); + } + + console.log('Detected channel:', channel); + github.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, @@ -63,8 +89,8 @@ jobs: ref: 'main', inputs: { commit: '${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}', - channel: args.channel || 'stable', - dry_run: args.dry_run || 'false', + channel: channel, + dry_run: 'false', original_pr: '${{ github.event.issue.number }}' } }) diff --git a/scripts/create-patch-pr.js b/scripts/releasing/create-patch-pr.js similarity index 98% rename from scripts/create-patch-pr.js rename to scripts/releasing/create-patch-pr.js index 7e6ab84408..1874f3f1ca 100644 --- a/scripts/create-patch-pr.js +++ b/scripts/releasing/create-patch-pr.js @@ -46,7 +46,7 @@ async function main() { console.log(`Found latest tag for ${channel}: ${latestTag}`); const releaseBranch = `release/${latestTag}`; - const hotfixBranch = `hotfix/${latestTag}/cherry-pick-${commit.substring(0, 7)}`; + const hotfixBranch = `hotfix/${latestTag}/${channel}/cherry-pick-${commit.substring(0, 7)}`; // Create the release branch from the tag if it doesn't exist. if (!branchExists(releaseBranch)) { diff --git a/scripts/releasing/patch-comment.js b/scripts/releasing/patch-comment.js new file mode 100644 index 0000000000..7c7fe7d5ed --- /dev/null +++ b/scripts/releasing/patch-comment.js @@ -0,0 +1,220 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Script for commenting back to original PR with patch release results. + * Used by the patch release workflow (step 3). + */ + +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +async function main() { + const argv = await yargs(hideBin(process.argv)) + .option('original-pr', { + description: 'The original PR number to comment on', + type: 'number', + demandOption: !process.env.GITHUB_ACTIONS, + }) + .option('success', { + description: 'Whether the release succeeded', + type: 'boolean', + }) + .option('release-version', { + description: 'The release version (e.g., 0.5.4)', + type: 'string', + demandOption: !process.env.GITHUB_ACTIONS, + }) + .option('release-tag', { + description: 'The release tag (e.g., v0.5.4)', + type: 'string', + }) + .option('npm-tag', { + description: 'The npm tag (latest or preview)', + type: 'string', + }) + .option('channel', { + description: 'The channel (stable or preview)', + type: 'string', + choices: ['stable', 'preview'], + }) + .option('dry-run', { + description: 'Whether this was a dry run', + type: 'boolean', + default: false, + }) + .option('test', { + description: 'Test mode - validate logic without GitHub API calls', + type: 'boolean', + default: false, + }) + .example( + '$0 --original-pr 8655 --success --release-version "0.5.4" --channel stable --test', + 'Test success comment', + ) + .example( + '$0 --original-pr 8655 --no-success --channel preview --test', + 'Test failure comment', + ) + .help() + .alias('help', 'h').argv; + + const testMode = argv.test || process.env.TEST_MODE === 'true'; + + // Initialize GitHub API client only if not in test mode + let github; + if (!testMode) { + const { Octokit } = await import('@octokit/rest'); + github = new Octokit({ + auth: process.env.GITHUB_TOKEN, + }); + } + + const repo = { + owner: process.env.GITHUB_REPOSITORY_OWNER || 'google-gemini', + repo: process.env.GITHUB_REPOSITORY_NAME || 'gemini-cli', + }; + + // Get inputs from CLI args or environment + const originalPr = argv.originalPr || process.env.ORIGINAL_PR; + const success = + argv.success !== undefined ? argv.success : process.env.SUCCESS === 'true'; + const releaseVersion = argv.releaseVersion || process.env.RELEASE_VERSION; + const releaseTag = + argv.releaseTag || + process.env.RELEASE_TAG || + (releaseVersion ? `v${releaseVersion}` : null); + const npmTag = + argv.npmTag || + process.env.NPM_TAG || + (argv.channel === 'stable' ? 'latest' : 'preview'); + const channel = argv.channel || process.env.CHANNEL || 'stable'; + const dryRun = argv.dryRun || process.env.DRY_RUN === 'true'; + const runId = process.env.GITHUB_RUN_ID || '12345678'; + const raceConditionFailure = process.env.RACE_CONDITION_FAILURE === 'true'; + + // Current version info for race condition failures + const currentReleaseVersion = process.env.CURRENT_RELEASE_VERSION; + const currentReleaseTag = process.env.CURRENT_RELEASE_TAG; + const currentPreviousTag = process.env.CURRENT_PREVIOUS_TAG; + + if (!originalPr) { + console.log('No original PR specified, skipping comment'); + return; + } + + console.log( + `Commenting on original PR ${originalPr} with ${success ? 'success' : 'failure'} status`, + ); + + if (testMode) { + console.log('\n๐Ÿงช TEST MODE - No API calls will be made'); + console.log('\n๐Ÿ“‹ Inputs:'); + console.log(` - Original PR: ${originalPr}`); + console.log(` - Success: ${success}`); + console.log(` - Release Version: ${releaseVersion}`); + console.log(` - Release Tag: ${releaseTag}`); + console.log(` - NPM Tag: ${npmTag}`); + console.log(` - Channel: ${channel}`); + console.log(` - Dry Run: ${dryRun}`); + console.log(` - Run ID: ${runId}`); + } + + let commentBody; + + if (success) { + commentBody = `โœ… **Patch Release Complete!** + +**๐Ÿ“ฆ Release Details:** +- **Version**: [\`${releaseVersion}\`](https://github.com/${repo.owner}/${repo.repo}/releases/tag/${releaseTag}) +- **NPM Tag**: \`${npmTag}\` +- **Channel**: \`${channel}\` +- **Dry Run**: ${dryRun} + +**๐ŸŽ‰ Status:** Your patch has been successfully released and published to npm! + +**๐Ÿ“ What's Available:** +- **GitHub Release**: [View release ${releaseTag}](https://github.com/${repo.owner}/${repo.repo}/releases/tag/${releaseTag}) +- **NPM Package**: \`npm install @google/gemini-cli@${npmTag}\` + +**๐Ÿ”— Links:** +- [GitHub Release](https://github.com/${repo.owner}/${repo.repo}/releases/tag/${releaseTag}) +- [Workflow Run](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId})`; + } else if (raceConditionFailure) { + commentBody = `โš ๏ธ **Patch Release Cancelled - Concurrent Release Detected** + +**๐Ÿšฆ What Happened:** +Another patch release completed while this one was in progress, causing a version conflict. + +**๐Ÿ“‹ Details:** +- **Originally planned**: \`${releaseVersion || 'Unknown'}\` +- **Channel**: \`${channel}\` +- **Issue**: Version numbers are no longer sequential due to concurrent releases + +**๐Ÿ“Š Current State:**${ + currentReleaseVersion + ? ` +- **Latest ${channel} version**: \`${currentPreviousTag?.replace(/^v/, '') || 'unknown'}\` +- **Next patch should be**: \`${currentReleaseVersion}\` +- **New release tag**: \`${currentReleaseTag || 'unknown'}\`` + : ` +- **Status**: Version information updated since this release started` + } + +**๐Ÿ”„ Next Steps:** +1. **Request a new patch** - The version calculation will now be correct +2. No action needed on your part - simply request the patch again +3. The system detected this automatically to prevent invalid releases + +**๐Ÿ’ก Why This Happens:** +Multiple patch releases can't run simultaneously. When they do, the second one is automatically cancelled to maintain version consistency. + +**๐Ÿ”— Details:** +- [View cancelled workflow run](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId})`; + } else { + commentBody = `โŒ **Patch Release Failed!** + +**๐Ÿ“‹ Details:** +- **Version**: \`${releaseVersion || 'Unknown'}\` +- **Channel**: \`${channel}\` +- **Error**: The patch release workflow encountered an error + +**๐Ÿ” Next Steps:** +1. Check the workflow logs for detailed error information +2. The maintainers have been notified via automatic issue creation +3. You may need to retry the patch once the issue is resolved + +**๐Ÿ”— Troubleshooting:** +- [View workflow run](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId}) +- [View workflow logs](https://github.com/${repo.owner}/${repo.repo}/actions/runs/${runId})`; + } + + if (testMode) { + console.log('\n๐Ÿ’ฌ Would post comment:'); + console.log('----------------------------------------'); + console.log(commentBody); + console.log('----------------------------------------'); + console.log('\nโœ… Comment generation working correctly!'); + } else if (github) { + await github.rest.issues.createComment({ + owner: repo.owner, + repo: repo.repo, + issue_number: parseInt(originalPr), + body: commentBody, + }); + + console.log(`Successfully commented on PR ${originalPr}`); + } else { + console.log('No GitHub client available'); + } +} + +main().catch((error) => { + console.error('Error commenting on PR:', error); + process.exit(1); +}); diff --git a/scripts/releasing/patch-create-comment.js b/scripts/releasing/patch-create-comment.js new file mode 100644 index 0000000000..71cd248e2a --- /dev/null +++ b/scripts/releasing/patch-create-comment.js @@ -0,0 +1,299 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Script for commenting on the original PR after patch creation (step 1). + * Handles parsing create-patch-pr.js output and creating appropriate feedback. + */ + +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import { readFileSync } from 'node:fs'; + +async function main() { + const argv = await yargs(hideBin(process.argv)) + .option('original-pr', { + description: 'The original PR number to comment on', + type: 'number', + demandOption: !process.env.GITHUB_ACTIONS, + }) + .option('exit-code', { + description: 'Exit code from patch creation step', + type: 'number', + demandOption: !process.env.GITHUB_ACTIONS, + }) + .option('output-log', { + description: 'Path to the patch output log file', + type: 'string', + default: 'patch_output.log', + }) + .option('commit', { + description: 'The commit SHA being patched', + type: 'string', + demandOption: !process.env.GITHUB_ACTIONS, + }) + .option('channel', { + description: 'The channel (stable or preview)', + type: 'string', + choices: ['stable', 'preview'], + demandOption: !process.env.GITHUB_ACTIONS, + }) + .option('repository', { + description: 'The GitHub repository (owner/repo format)', + type: 'string', + demandOption: !process.env.GITHUB_ACTIONS, + }) + .option('run-id', { + description: 'The GitHub workflow run ID', + type: 'string', + default: '0', + }) + .option('test', { + description: 'Test mode - validate logic without GitHub API calls', + type: 'boolean', + default: false, + }) + .example( + '$0 --original-pr 8655 --exit-code 0 --commit abc1234 --channel preview --repository google-gemini/gemini-cli --test', + 'Test success comment', + ) + .example( + '$0 --original-pr 8655 --exit-code 1 --commit abc1234 --channel stable --repository google-gemini/gemini-cli --test', + 'Test failure comment', + ) + .help() + .alias('help', 'h').argv; + + const testMode = argv.test || process.env.TEST_MODE === 'true'; + + // Initialize GitHub API client only if not in test mode + let github; + if (!testMode) { + const { Octokit } = await import('@octokit/rest'); + github = new Octokit({ + auth: process.env.GH_TOKEN || process.env.GITHUB_TOKEN, + }); + } + + // Get inputs from CLI args or environment + const originalPr = argv.originalPr || process.env.ORIGINAL_PR; + const exitCode = + argv.exitCode !== undefined + ? argv.exitCode + : parseInt(process.env.EXIT_CODE || '1'); + const outputLog = + argv.outputLog || process.env.OUTPUT_LOG || 'patch_output.log'; + const commit = argv.commit || process.env.COMMIT; + const channel = argv.channel || process.env.CHANNEL; + const repository = + argv.repository || process.env.REPOSITORY || 'google-gemini/gemini-cli'; + const runId = argv.runId || process.env.GITHUB_RUN_ID || '0'; + + if (!originalPr) { + console.log('No original PR specified, skipping comment'); + return; + } + + console.log( + `Analyzing patch creation result for PR ${originalPr} (exit code: ${exitCode})`, + ); + + const [owner, repo] = repository.split('/'); + const npmTag = channel === 'stable' ? 'latest' : 'preview'; + + if (testMode) { + console.log('\n๐Ÿงช TEST MODE - No API calls will be made'); + console.log('\n๐Ÿ“‹ Inputs:'); + console.log(` - Original PR: ${originalPr}`); + console.log(` - Exit Code: ${exitCode}`); + console.log(` - Output Log: ${outputLog}`); + console.log(` - Commit: ${commit}`); + console.log(` - Channel: ${channel} โ†’ npm tag: ${npmTag}`); + console.log(` - Repository: ${repository}`); + console.log(` - Run ID: ${runId}`); + } + + let commentBody; + let logContent = ''; + + // Try to read the output log + try { + if (testMode) { + // Create mock log content for testing + if (exitCode === 0) { + logContent = `Creating hotfix branch hotfix/v0.5.3/${channel}/cherry-pick-${commit.substring(0, 7)} from release/v0.5.3`; + } else { + logContent = 'Error: Failed to create patch'; + } + } else { + logContent = readFileSync(outputLog, 'utf8'); + } + } catch (error) { + console.log(`Could not read output log ${outputLog}:`, error.message); + } + + if (logContent.includes('already has an open PR')) { + // Branch exists with existing PR + const prMatch = logContent.match(/Found existing PR #(\d+): (.*)/); + if (prMatch) { + const [, prNumber, prUrl] = prMatch; + commentBody = `โ„น๏ธ **Patch PR already exists!** + +A patch PR for this change already exists: [#${prNumber}](${prUrl}). + +**๐Ÿ“ Next Steps:** +1. Review and approve the existing patch PR +2. If it's incorrect, close it and run the patch command again + +**๐Ÿ”— Links:** +- [View existing patch PR #${prNumber}](${prUrl})`; + } + } else if (logContent.includes('exists but has no open PR')) { + // Branch exists but no PR + const branchMatch = logContent.match(/Hotfix branch (.*) already exists/); + if (branchMatch) { + const [, branch] = branchMatch; + commentBody = `โ„น๏ธ **Patch branch exists but no PR found!** + +A patch branch [\`${branch}\`](https://github.com/${repository}/tree/${branch}) exists but has no open PR. + +**๐Ÿ” Issue:** This might indicate an incomplete patch process. + +**๐Ÿ“ Next Steps:** +1. Delete the branch: \`git branch -D ${branch}\` +2. Run the patch command again + +**๐Ÿ”— Links:** +- [View branch on GitHub](https://github.com/${repository}/tree/${branch})`; + } + } else if (exitCode === 0) { + // Success - extract branch info + const branchMatch = logContent.match(/Creating hotfix branch (.*) from/); + if (branchMatch) { + const [, branch] = branchMatch; + + if (testMode) { + // Mock PR info for testing + const mockPrNumber = Math.floor(Math.random() * 1000) + 8000; + const mockPrUrl = `https://github.com/${repository}/pull/${mockPrNumber}`; + + commentBody = `๐Ÿš€ **Patch PR Created!** + +**๐Ÿ“‹ Patch Details:** +- **Channel**: \`${channel}\` โ†’ will publish to npm tag \`${npmTag}\` +- **Commit**: \`${commit}\` +- **Hotfix Branch**: [\`${branch}\`](https://github.com/${repository}/tree/${branch}) +- **Hotfix PR**: [#${mockPrNumber}](${mockPrUrl}) + +**๐Ÿ“ Next Steps:** +1. Review and approve the hotfix PR: [#${mockPrNumber}](${mockPrUrl}) +2. Once merged, the patch release will automatically trigger +3. You'll receive updates here when the release completes + +**๐Ÿ”— Track Progress:** +- [View hotfix PR #${mockPrNumber}](${mockPrUrl})`; + } else if (github) { + // Find the actual PR for the new branch + try { + const prList = await github.rest.pulls.list({ + owner, + repo, + head: `${owner}:${branch}`, + state: 'open', + }); + + if (prList.data.length > 0) { + const pr = prList.data[0]; + commentBody = `๐Ÿš€ **Patch PR Created!** + +**๐Ÿ“‹ Patch Details:** +- **Channel**: \`${channel}\` โ†’ will publish to npm tag \`${npmTag}\` +- **Commit**: \`${commit}\` +- **Hotfix Branch**: [\`${branch}\`](https://github.com/${repository}/tree/${branch}) +- **Hotfix PR**: [#${pr.number}](${pr.html_url}) + +**๐Ÿ“ Next Steps:** +1. Review and approve the hotfix PR: [#${pr.number}](${pr.html_url}) +2. Once merged, the patch release will automatically trigger +3. You'll receive updates here when the release completes + +**๐Ÿ”— Track Progress:** +- [View hotfix PR #${pr.number}](${pr.html_url})`; + } else { + // Fallback if PR not found yet + commentBody = `๐Ÿš€ **Patch PR Created!** + +The patch release PR for this change has been created on branch [\`${branch}\`](https://github.com/${repository}/tree/${branch}). + +**๐Ÿ“ Next Steps:** +1. Review and approve the patch PR +2. Once merged, the patch release will automatically trigger + +**๐Ÿ”— Links:** +- [View all patch PRs](https://github.com/${repository}/pulls?q=is%3Apr+is%3Aopen+label%3Apatch)`; + } + } catch (error) { + console.log('Error finding PR for branch:', error.message); + // Fallback + commentBody = `๐Ÿš€ **Patch PR Created!** + +The patch release PR for this change has been created. + +**๐Ÿ”— Links:** +- [View all patch PRs](https://github.com/${repository}/pulls?q=is%3Apr+is%3Aopen+label%3Apatch)`; + } + } + } + } else { + // Failure + commentBody = `โŒ **Patch creation failed!** + +There was an error creating the patch release. + +**๐Ÿ” Troubleshooting:** +- Check the workflow logs for detailed error information +- Verify the commit SHA is valid and accessible +- Ensure you have permissions to create branches and PRs + +**๐Ÿ”— Links:** +- [View workflow run](https://github.com/${repository}/actions/runs/${runId})`; + } + + if (!commentBody) { + commentBody = `โŒ **Patch creation failed!** + +No output was generated during patch creation. + +**๐Ÿ”— Links:** +- [View workflow run](https://github.com/${repository}/actions/runs/${runId})`; + } + + if (testMode) { + console.log('\n๐Ÿ’ฌ Would post comment:'); + console.log('----------------------------------------'); + console.log(commentBody); + console.log('----------------------------------------'); + console.log('\nโœ… Comment generation working correctly!'); + } else if (github) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number: parseInt(originalPr), + body: commentBody, + }); + + console.log(`Successfully commented on PR ${originalPr}`); + } else { + console.log('No GitHub client available'); + } +} + +main().catch((error) => { + console.error('Error commenting on PR:', error); + process.exit(1); +}); diff --git a/scripts/releasing/patch-trigger.js b/scripts/releasing/patch-trigger.js new file mode 100644 index 0000000000..7402cdebda --- /dev/null +++ b/scripts/releasing/patch-trigger.js @@ -0,0 +1,221 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Script for patch release trigger workflow (step 2). + * Handles channel detection, workflow dispatch, and user feedback. + */ + +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +async function main() { + const argv = await yargs(hideBin(process.argv)) + .option('head-ref', { + description: + 'The hotfix branch name (e.g., hotfix/v0.5.3/preview/cherry-pick-abc1234)', + type: 'string', + demandOption: !process.env.GITHUB_ACTIONS, + }) + .option('pr-body', { + description: 'The PR body content', + type: 'string', + default: '', + }) + .option('dry-run', { + description: 'Run in test mode without actually triggering workflows', + type: 'boolean', + default: false, + }) + .option('test', { + description: 'Test mode - validate logic without GitHub API calls', + type: 'boolean', + default: false, + }) + .example( + '$0 --head-ref "hotfix/v0.5.3/preview/cherry-pick-abc1234" --test', + 'Test channel detection logic', + ) + .example( + '$0 --head-ref "hotfix/v0.5.3/stable/cherry-pick-abc1234" --dry-run', + 'Test with GitHub API in dry-run mode', + ) + .help() + .alias('help', 'h').argv; + + const testMode = argv.test || process.env.TEST_MODE === 'true'; + + // Initialize GitHub API client only if not in test mode + let github; + if (!testMode) { + const { Octokit } = await import('@octokit/rest'); + github = new Octokit({ + auth: process.env.GITHUB_TOKEN, + }); + } + + const context = { + eventName: process.env.GITHUB_EVENT_NAME || 'pull_request', + repo: { + owner: process.env.GITHUB_REPOSITORY_OWNER || 'google-gemini', + repo: process.env.GITHUB_REPOSITORY_NAME || 'gemini-cli', + }, + payload: JSON.parse(process.env.GITHUB_EVENT_PAYLOAD || '{}'), + }; + + // Get inputs from CLI args or environment + const headRef = argv.headRef || process.env.HEAD_REF; + const body = argv.prBody || process.env.PR_BODY || ''; + const isDryRun = argv.dryRun || body.includes('[DRY RUN]'); + + if (!headRef) { + throw new Error( + 'head-ref is required. Use --head-ref or set HEAD_REF environment variable.', + ); + } + + console.log(`Processing patch trigger for branch: ${headRef}`); + + // Extract base version and channel from hotfix branch name + // New format: hotfix/v0.5.3/preview/cherry-pick-abc -> v0.5.3 and preview + // Old format: hotfix/v0.5.3/cherry-pick-abc -> v0.5.3 and stable (default) + const parts = headRef.split('/'); + const version = parts[1]; + let channel = 'stable'; // default for old format + + if (parts.length >= 4 && (parts[2] === 'stable' || parts[2] === 'preview')) { + // New format with explicit channel + channel = parts[2]; + } else if (context.eventName === 'workflow_dispatch') { + // Manual dispatch, infer from version name + channel = version.includes('preview') ? 'preview' : 'stable'; + } + + // Validate channel + if (channel !== 'stable' && channel !== 'preview') { + throw new Error( + `Invalid channel: ${channel}. Must be 'stable' or 'preview'.`, + ); + } + + const releaseRef = `release/${version}`; + const workflowId = + context.eventName === 'pull_request' + ? 'release-patch-3-release.yml' + : process.env.WORKFLOW_ID || 'release-patch-3-release.yml'; + + console.log(`Detected channel: ${channel}, version: ${version}`); + console.log(`Release ref: ${releaseRef}`); + console.log(`Workflow ID: ${workflowId}`); + console.log(`Dry run: ${isDryRun}`); + + if (testMode) { + console.log('\n๐Ÿงช TEST MODE - No API calls will be made'); + console.log('\n๐Ÿ“‹ Parsed Results:'); + console.log(` - Branch: ${headRef}`); + console.log( + ` - Channel: ${channel} โ†’ npm tag: ${channel === 'stable' ? 'latest' : 'preview'}`, + ); + console.log(` - Version: ${version}`); + console.log(` - Release ref: ${releaseRef}`); + console.log(` - Workflow: ${workflowId}`); + console.log(` - Dry run: ${isDryRun}`); + console.log('\nโœ… Channel detection logic working correctly!'); + return; + } + + // Try to find the original PR that requested this patch + let originalPr = null; + if (!testMode && github) { + try { + console.log('Looking for original PR using search...'); + // Use GitHub search to find the PR with a comment referencing the hotfix branch. + // This is much more efficient than listing PRs and their comments. + const query = `repo:${context.repo.owner}/${context.repo.repo} is:pr is:all in:comments "Patch PR Created" "${headRef}"`; + const searchResults = await github.rest.search.issuesAndPullRequests({ + q: query, + sort: 'updated', + order: 'desc', + per_page: 1, + }); + + if (searchResults.data.items.length > 0) { + originalPr = searchResults.data.items[0].number; + console.log(`Found original PR: #${originalPr}`); + } else { + console.log('Could not find a matching original PR via search.'); + } + } catch (e) { + console.log('Could not determine original PR:', e.message); + } + } else { + console.log('Skipping original PR lookup (test mode)'); + originalPr = 8655; // Mock for testing + } + + // Trigger the release workflow + console.log(`Triggering release workflow: ${workflowId}`); + if (!testMode && github) { + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: workflowId, + ref: 'main', + inputs: { + type: channel, + dry_run: isDryRun.toString(), + release_ref: releaseRef, + original_pr: originalPr ? originalPr.toString() : '', + }, + }); + } else { + console.log('โœ… Would trigger workflow with inputs:', { + type: channel, + dry_run: isDryRun.toString(), + release_ref: releaseRef, + original_pr: originalPr ? originalPr.toString() : '', + }); + } + + // Comment back to original PR if we found it + if (originalPr) { + console.log(`Commenting on original PR ${originalPr}...`); + const npmTag = channel === 'stable' ? 'latest' : 'preview'; + + const commentBody = `๐Ÿš€ **Patch Release Started!** + +**๐Ÿ“‹ Release Details:** +- **Channel**: \`${channel}\` โ†’ publishing to npm tag \`${npmTag}\` +- **Version**: \`${version}\` +- **Hotfix PR**: Merged โœ… +- **Release Branch**: [\`${releaseRef}\`](https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${releaseRef}) + +**โณ Status:** The patch release is now running. You'll receive another update when it completes. + +**๐Ÿ”— Track Progress:** +- [View release workflow](https://github.com/${context.repo.owner}/${context.repo.repo}/actions)`; + + if (!testMode && github) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: originalPr, + body: commentBody, + }); + } else { + console.log('โœ… Would post comment:', commentBody); + } + } + + console.log('Patch trigger completed successfully!'); +} + +main().catch((error) => { + console.error('Error in patch trigger:', error); + process.exit(1); +});