From 1819ffe5bd34493af195670d9e73ff5e257176fa Mon Sep 17 00:00:00 2001 From: matt korwel Date: Thu, 11 Sep 2025 09:19:07 -0700 Subject: [PATCH] feat(release): automate patch creation and release process (#8202) --- .github/workflows/create-patch-pr.yml | 58 +++++++++ .github/workflows/patch-from-comment.yml | 56 +++++++++ .github/workflows/trigger-patch-release.yml | 30 +++++ docs/releases.md | 64 +++++----- scripts/create-patch-pr.js | 133 ++++++++++++++++++++ 5 files changed, 313 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/create-patch-pr.yml create mode 100644 .github/workflows/patch-from-comment.yml create mode 100644 .github/workflows/trigger-patch-release.yml create mode 100644 scripts/create-patch-pr.js diff --git a/.github/workflows/create-patch-pr.yml b/.github/workflows/create-patch-pr.yml new file mode 100644 index 0000000000..2ec6aed3eb --- /dev/null +++ b/.github/workflows/create-patch-pr.yml @@ -0,0 +1,58 @@ +name: 'Create Patch PR' + +on: + workflow_dispatch: + inputs: + commit: + description: 'The commit SHA to cherry-pick for the patch.' + required: true + type: 'string' + channel: + description: 'The release channel to patch.' + required: true + type: 'choice' + options: + - 'stable' + - 'preview' + dry_run: + description: 'Whether to run in dry-run mode.' + required: false + type: 'boolean' + default: false + +jobs: + create-patch: + runs-on: 'ubuntu-latest' + permissions: + contents: 'write' + pull-requests: 'write' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5 + with: + fetch-depth: 0 + + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: 'Install Dependencies' + run: 'npm ci' + + - name: 'Configure Git User' + run: |- + git config user.name "gemini-cli-robot" + git config user.email "gemini-cli-robot@google.com" + + - name: 'Create Patch for Stable' + if: "github.event.inputs.channel == 'stable'" + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: 'node scripts/create-patch-pr.js --commit=${{ github.event.inputs.commit }} --channel=stable --dry-run=${{ github.event.inputs.dry_run }}' + + - name: 'Create Patch for Preview' + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: 'node scripts/create-patch-pr.js --commit=${{ github.event.inputs.commit }} --channel=${{ github.event.inputs.channel }} --dry-run=${{ github.event.inputs.dry_run }}' diff --git a/.github/workflows/patch-from-comment.yml b/.github/workflows/patch-from-comment.yml new file mode 100644 index 0000000000..55065b5b1c --- /dev/null +++ b/.github/workflows/patch-from-comment.yml @@ -0,0 +1,56 @@ +name: 'Patch from Comment' + +on: + issue_comment: + types: ['created'] + +jobs: + slash-command: + runs-on: 'ubuntu-latest' + steps: + - name: 'Slash Command Dispatch' + id: 'slash_command' + uses: 'peter-evans/slash-command-dispatch@40877f718dce0101edfc7aea2b3800cc192f9ed5' + with: + token: '${{ secrets.GITHUB_TOKEN }}' + commands: 'patch' + permission: 'write' + issue-type: 'pull-request' + static-args: | + dry_run=false + + - name: 'Get PR Status' + id: 'pr_status' + if: "steps.slash_command.outputs.dispatched == 'true'" + env: + GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + run: | + gh pr view "${{ github.event.issue.number }}" --json mergeCommit,state > pr_status.json + echo "MERGE_COMMIT_SHA=$(jq -r .mergeCommit.oid pr_status.json)" >> "$GITHUB_OUTPUT" + echo "STATE=$(jq -r .state pr_status.json)" >> "$GITHUB_OUTPUT" + + - name: 'Dispatch if Merged' + if: "steps.pr_status.outputs.STATE == 'MERGED'" + uses: 'actions/github-script@00f12e3e20659f42342b1c0226afda7f7c042325' + with: + script: | + const args = JSON.parse('${{ steps.slash_command.outputs.command-arguments }}'); + github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'create-patch-pr.yml', + ref: 'main', + inputs: { + commit: '${{ steps.pr_status.outputs.MERGE_COMMIT_SHA }}', + channel: args.channel, + dry_run: args.dry_run + } + }) + + - name: 'Comment on Failure' + if: "steps.pr_status.outputs.STATE != 'MERGED'" + uses: 'peter-evans/create-or-update-comment@67dcc547d311b736a8e6c5c236542148a47adc3d' + with: + issue-number: '${{ github.event.issue.number }}' + body: | + :x: The `/patch` command failed. This pull request must be merged before a patch can be created. diff --git a/.github/workflows/trigger-patch-release.yml b/.github/workflows/trigger-patch-release.yml new file mode 100644 index 0000000000..4270111bbd --- /dev/null +++ b/.github/workflows/trigger-patch-release.yml @@ -0,0 +1,30 @@ +name: 'Trigger Patch Release' + +on: + pull_request: + types: + - 'closed' + +jobs: + trigger-patch-release: + if: "github.event.pull_request.merged == true && startsWith(github.head_ref, 'hotfix/')" + runs-on: 'ubuntu-latest' + steps: + - name: 'Trigger Patch Release' + uses: 'actions/github-script@00f12e3e20659f42342b1c0226afda7f7c042325' + with: + script: | + const body = context.payload.pull_request.body; + const isDryRun = body.includes('[DRY RUN]'); + const ref = context.payload.pull_request.base.ref; + const channel = ref.includes('preview') ? 'preview' : 'stable'; + github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'patch-release.yml', + ref: ref, + inputs: { + type: channel, + dry_run: isDryRun.toString() + } + }) diff --git a/docs/releases.md b/docs/releases.md index 62c5d59d87..78c2b0845f 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -58,50 +58,58 @@ After one week (On the following Tuesday) with all signals a go, we will manuall ## Patching Releases -If a critical bug needs to be fixed before the next scheduled release, follow this process to create a patch. +If a critical bug that is already fixed on `main` needs to be patched on a `stable` or `preview` release, the process is now highly automated. -### 1. Create a Hotfix Branch +### 1. Create the Patch Pull Request -First, create a new branch for your fix. The source for this branch depends on whether you are patching a stable or a preview release. +There are two ways to create a patch pull request: -- **For a stable release patch:** - Create a branch from the Git tag of the version you need to patch. Tag names are formatted as `vx.y.z`. +**Option A: From a GitHub Comment (Recommended)** - ```bash - # Example: Create a hotfix branch for v0.2.0 - git checkout v0.2.0 -b hotfix/issue-123-fix-for-v0.2.0 - ``` +After a pull request has been merged, a maintainer can add a comment on that same PR with the following format: -- **For a preview release patch:** - Create a branch from the existing preview release branch, which is formatted as `release/vx.y.z-preview.n`. +`/patch [--dry-run]` - ```bash - # Example: Create a hotfix branch for a preview release - git checkout release/v0.2.0-preview.0 && git checkout -b hotfix/issue-456-fix-for-preview - ``` +- **channel**: `stable` or `preview` +- **--dry-run** (optional): If included, the workflow will run in dry-run mode. This will create the PR with "[DRY RUN]" in the title, and merging it will trigger a dry run of the final release, so nothing is actually published. -### 2. Implement the Fix +Example: `/patch stable --dry-run` -In your new hotfix branch, either create a new commit with the fix or cherry-pick an existing commit from the `main` branch. Merge your changes into the source of the hotfix branch (ex. https://github.com/google-gemini/gemini-cli/pull/6850). +The workflow will automatically find the merge commit SHA and begin the patch process. If the PR is not yet merged, it will post a comment indicating the failure. -### 3. Perform the Release +**Option B: Manually Triggering the Workflow** -Follow the manual release process using the "Release" GitHub Actions workflow. +Navigate to the **Actions** tab and run the **Create Patch PR** workflow. -- **Version**: For stable patches, increment the patch version (e.g., `v0.2.0` -> `v0.2.1`). For preview patches, increment the preview number (e.g., `v0.2.0-preview.0` -> `v0.2.0-preview.1`). -- **Ref**: Use your source branch as the reference (ex. `release/v0.2.0-preview.0`) +- **Commit**: The full SHA of the commit on `main` that you want to cherry-pick. +- **Channel**: The channel you want to patch (`stable` or `preview`). -![How to run a release](assets/release_patch.png) +This workflow will automatically: -### 4. Update Versions +1. Find the latest release tag for the channel. +2. Create a release branch from that tag if one doesn't exist (e.g., `release/v0.5.1`). +3. Create a new hotfix branch from the release branch. +4. Cherry-pick your specified commit into the hotfix branch. +5. Create a pull request from the hotfix branch back to the release branch. -After the hotfix is released, merge the changes back to the appropriate branch. +**Important:** If you select `stable`, the workflow will run twice, creating one PR for the `stable` channel and a second PR for the `preview` channel. -- **For a stable release hotfix:** - Open a pull request to merge the release branch (e.g., `release/0.2.1`) back into `main`. This keeps the version number in `main` up to date. +### 2. Review and Merge -- **For a preview release hotfix:** - Open a pull request to merge the new preview release branch (e.g., `release/v0.2.0-preview.1`) back into the existing preview release branch (`release/v0.2.0-preview.0`) (ex. https://github.com/google-gemini/gemini-cli/pull/6868) +Review the automatically created pull request(s) to ensure the cherry-pick was successful and the changes are correct. Once approved, merge the pull request. + +**Security Note:** The `release/*` branches are protected by branch protection rules. A pull request to one of these branches requires at least one review from a code owner before it can be merged. This ensures that no unauthorized code is released. + +### 3. Automatic Release + +Upon merging the pull request, a final workflow is automatically triggered. It will: + +1. Run the `patch-release` workflow. +2. Build and test the patched code. +3. Publish the new patch version to npm. +4. Create a new GitHub release with the patch notes. + +This fully automated process ensures that patches are created and released consistently and reliably. ## Release Schedule diff --git a/scripts/create-patch-pr.js b/scripts/create-patch-pr.js new file mode 100644 index 0000000000..7804c2a9f0 --- /dev/null +++ b/scripts/create-patch-pr.js @@ -0,0 +1,133 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +async function main() { + const argv = await yargs(hideBin(process.argv)) + .option('commit', { + alias: 'c', + description: 'The commit SHA to cherry-pick for the patch.', + type: 'string', + demandOption: true, + }) + .option('channel', { + alias: 'ch', + description: 'The release channel to patch.', + choices: ['stable', 'preview'], + demandOption: true, + }) + .option('dry-run', { + description: 'Whether to run in dry-run mode.', + type: 'boolean', + default: false, + }) + .help() + .alias('help', 'h').argv; + + const { commit, channel, dryRun } = argv; + + console.log(`Starting patch process for commit: ${commit}`); + console.log(`Targeting channel: ${channel}`); + if (dryRun) { + console.log('Running in dry-run mode.'); + } + + run('git fetch --all --tags --prune', dryRun); + + const latestTag = getLatestTag(channel); + console.log(`Found latest tag for ${channel}: ${latestTag}`); + + const releaseBranch = `release/${latestTag}`; + const hotfixBranch = `hotfix/${latestTag}/cherry-pick-${commit.substring(0, 7)}`; + + // Create the release branch from the tag if it doesn't exist. + if (!branchExists(releaseBranch)) { + console.log( + `Release branch ${releaseBranch} does not exist. Creating it from tag ${latestTag}...`, + ); + run(`git checkout -b ${releaseBranch} ${latestTag}`, dryRun); + run(`git push origin ${releaseBranch}`, dryRun); + } else { + console.log(`Release branch ${releaseBranch} already exists.`); + } + + // Create the hotfix branch from the release branch. + console.log( + `Creating hotfix branch ${hotfixBranch} from ${releaseBranch}...`, + ); + run(`git checkout -b ${hotfixBranch} origin/${releaseBranch}`, dryRun); + + // Cherry-pick the commit. + console.log(`Cherry-picking commit ${commit} into ${hotfixBranch}...`); + run(`git cherry-pick ${commit}`, dryRun); + + // Push the hotfix branch. + console.log(`Pushing hotfix branch ${hotfixBranch} to origin...`); + run(`git push --set-upstream origin ${hotfixBranch}`, dryRun); + + // Create the pull request. + console.log( + `Creating pull request from ${hotfixBranch} to ${releaseBranch}...`, + ); + const prTitle = `fix(patch): cherry-pick ${commit.substring(0, 7)} to ${releaseBranch}`; + let prBody = `This PR automatically cherry-picks commit ${commit} to patch the ${channel} release.`; + if (dryRun) { + prBody += '\n\n**[DRY RUN]**'; + } + run( + `gh pr create --base ${releaseBranch} --head ${hotfixBranch} --title "${prTitle}" --body "${prBody}"`, + dryRun, + ); + + console.log('Patch process completed successfully!'); +} + +function run(command, dryRun = false) { + console.log(`> ${command}`); + if (dryRun) { + return; + } + try { + return execSync(command).toString().trim(); + } catch (err) { + console.error(`Command failed: ${command}`); + throw err; + } +} + +function branchExists(branchName) { + try { + execSync(`git ls-remote --exit-code --heads origin ${branchName}`); + return true; + } catch (_e) { + return false; + } +} + +function getLatestTag(channel) { + console.log(`Fetching latest tag for channel: ${channel}...`); + const pattern = + channel === 'stable' + ? '\'(contains("nightly") or contains("preview")) | not\'' + : '\'(contains("preview"))\''; + const command = `gh release list --limit 30 --json tagName | jq -r '[.[] | select(.tagName | ${pattern})] | .[0].tagName'`; + try { + return execSync(command).toString().trim(); + } catch (err) { + console.error(`Failed to get latest tag for channel: ${channel}`); + throw err; + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});