Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .github/workflows/create-patch-pr.yml
Original file line number Diff line number Diff line change
@@ -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'
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super nit (for consistency): consider updating this from 'boolean' to 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 }}'
56 changes: 56 additions & 0 deletions .github/workflows/patch-from-comment.yml
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 additions & 0 deletions .github/workflows/trigger-patch-release.yml
Original file line number Diff line number Diff line change
@@ -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()
}
})
64 changes: 36 additions & 28 deletions docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
133 changes: 133 additions & 0 deletions scripts/create-patch-pr.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading