feat(release): automate patch creation and release process (#8202)

This commit is contained in:
matt korwel
2025-09-11 09:19:07 -07:00
committed by GitHub
parent f9f4b2a260
commit 1819ffe5bd
5 changed files with 313 additions and 28 deletions

58
.github/workflows/create-patch-pr.yml vendored Normal file
View File

@@ -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 }}'

View File

@@ -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.

View File

@@ -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()
}
})

View File

@@ -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 <channel> [--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

133
scripts/create-patch-pr.js Normal file
View File

@@ -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);
});