Skip to content
177 changes: 120 additions & 57 deletions scripts/releasing/create-patch-pr.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,56 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';

function formatCommand(command, args) {
return [command, ...args].join(' ');
}

function runCommand(
command,
args,
{ dryRun = false, throwOnError = true, options = {} } = {},
) {
const renderedCommand = formatCommand(command, args);
console.log(`> ${renderedCommand}`);
if (dryRun) {
return '';
}

try {
const result = execFileSync(command, args, {
encoding: 'utf8',
...options,
});

if (typeof result === 'string') {
return result.trim();
}

return result;
} catch (err) {
console.error(`Command failed: ${renderedCommand}`);
if (throwOnError) {
throw err;
}
return null;
}
}

function runGit(args, options = {}) {
return runCommand('git', args, options);
}

function validateCommitSha(commit) {
if (!/^[a-f0-9]{7,40}$/i.test(commit)) {
throw new Error(
"Invalid commit SHA: " + commit + ". Expected 7-40 hexadecimal characters.");
}
}

async function main() {
const argv = await yargs(hideBin(process.argv))
.option('commit', {
Expand Down Expand Up @@ -45,14 +91,15 @@ async function main() {
.alias('help', 'h').argv;

const { commit, channel, dryRun, pullRequestNumber } = argv;
validateCommitSha(commit);

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);
runGit(['fetch', '--all', '--tags', '--prune'], { dryRun });

const releaseInfo = getLatestReleaseInfo({ argv, channel });
const latestTag = releaseInfo.currentTag;
Expand All @@ -67,10 +114,9 @@ async function main() {
`Release branch ${releaseBranch} does not exist. Creating it from tag ${latestTag}...`,
);
try {
run(`git checkout -b ${releaseBranch} ${latestTag}`, dryRun);
run(`git push origin ${releaseBranch}`, dryRun);
runGit(['checkout', '-b', releaseBranch, latestTag], { dryRun });
runGit(['push', 'origin', releaseBranch], { dryRun });
} catch (error) {
// Check if this is a GitHub App workflows permission error
if (
error.message.match(/refusing to allow a GitHub App/i) &&
error.message.match(/workflows?['`]? permission/i)
Expand All @@ -90,7 +136,6 @@ async function main() {
);
process.exit(1);
} else {
// Re-throw other errors
throw error;
}
}
Expand All @@ -102,13 +147,21 @@ async function main() {
if (branchExists(hotfixBranch)) {
console.log(`Hotfix branch ${hotfixBranch} already exists.`);

// Check if there's already a PR for this branch
try {
const prInfo = execSync(
`gh pr list --head ${hotfixBranch} --json number,url --jq '.[0] // empty'`,
)
.toString()
.trim();
const prInfo = runCommand(
'gh',
[
'pr',
'list',
'--head',
hotfixBranch,
'--json',
'number,url',
'--jq',
'.[0] // empty',
],
{ dryRun: false },
);
if (prInfo && prInfo !== 'null' && prInfo !== '') {
const pr = JSON.parse(prInfo);
console.log(`Found existing PR #${pr.number}: ${pr.url}`);
Expand All @@ -132,24 +185,21 @@ async function main() {
console.log(
`Creating hotfix branch ${hotfixBranch} from ${releaseBranch}...`,
);
run(`git checkout -b ${hotfixBranch} origin/${releaseBranch}`, dryRun);

// Ensure git user is configured properly for commits
runGit(['checkout', '-b', hotfixBranch, `origin/${releaseBranch}`], {
dryRun,
});
console.log('Configuring git user for cherry-pick commits...');
run('git config user.name "gemini-cli-robot"', dryRun);
run('git config user.email "gemini-cli-robot@google.com"', dryRun);

// Cherry-pick the commit.
runGit(['config', 'user.name', 'gemini-cli-robot'], { dryRun });
runGit(['config', 'user.email', 'gemini-cli-robot@google.com'], { dryRun });
console.log(`Cherry-picking commit ${commit} into ${hotfixBranch}...`);
let hasConflicts = false;
if (!dryRun) {
try {
execSync(`git cherry-pick ${commit}`, { stdio: 'pipe' });
runGit(['cherry-pick', commit], { options: { stdio: 'pipe' } });
console.log(`✅ Cherry-pick successful - no conflicts detected`);
} catch (error) {
// Check if this is a cherry-pick conflict
try {
const status = execSync('git status --porcelain', { encoding: 'utf8' });
const status = runGit(['status', '--porcelain']);
const conflictFiles = status
.split('\n')
.filter(
Expand All @@ -168,20 +218,16 @@ async function main() {
conflictFiles.forEach((file) =>
console.log(` - ${file.substring(3)}`),
);

// Add all files (including conflict markers) and commit
console.log(
`📝 Creating commit with conflict markers for manual resolution...`,
);
execSync('git add .');
execSync(`git commit --no-edit --no-verify`);
runGit(['add', '.']);
runGit(['commit', '--no-edit', '--no-verify']);
console.log(`✅ Committed cherry-pick with conflict markers`);
} else {
// Re-throw if it's not a conflict error
throw error;
}
} catch {
// Re-throw original error if we can't determine the status
throw error;
}
}
Expand All @@ -191,7 +237,7 @@ async function main() {

// Push the hotfix branch.
console.log(`Pushing hotfix branch ${hotfixBranch} to origin...`);
run(`git push --set-upstream origin ${hotfixBranch}`, dryRun);
runGit(['push', '--set-upstream', 'origin', hotfixBranch], { dryRun });

// Create the pull request.
console.log(
Expand Down Expand Up @@ -226,8 +272,23 @@ The commit has been created with conflict markers for easier manual resolution.
prBody += '\n\n**[DRY RUN]**';
}

const prCommand = `gh pr create --base ${releaseBranch} --head ${hotfixBranch} --title "${prTitle}" --body "${prBody}"`;
run(prCommand, dryRun);

runCommand(
'gh',
[
'pr',
'create',
'--base',
releaseBranch,
'--head',
hotfixBranch,
'--title',
prTitle,
'--body',
prBody,
],
{ dryRun },
);

if (hasConflicts) {
console.log(
Expand All @@ -241,44 +302,46 @@ The commit has been created with conflict markers for easier manual resolution.
console.log('\n--- Dry Run Summary ---');
console.log(`Release Branch: ${releaseBranch}`);
console.log(`Hotfix Branch: ${hotfixBranch}`);
console.log(`Pull Request Command: ${prCommand}`);
console.log(
`Pull Request Command: ${formatCommand('gh', [
'pr',
'create',
'--base',
releaseBranch,
'--head',
hotfixBranch,
'--title',
prTitle,
'--body',
prBody,
])}`,
);
console.log('---------------------');
}

return { newBranch: hotfixBranch, created: true, hasConflicts };
}

function run(command, dryRun = false, throwOnError = true) {
Comment thread
S3DFX-CYBER marked this conversation as resolved.
console.log(`> ${command}`);
if (dryRun) {
return;
}
try {
return execSync(command).toString().trim();
} catch (err) {
console.error(`Command failed: ${command}`);
if (throwOnError) {
throw err;
}
return null;
}
}

function branchExists(branchName) {
try {
execSync(`git ls-remote --exit-code --heads origin ${branchName}`);
return true;
} catch {
return false;
}
const result = runGit(
['ls-remote', '--exit-code', '--heads', 'origin', branchName],
{ throwOnError: false },
);
return result !== null;
}

function getLatestReleaseInfo({ argv, channel } = {}) {
console.log(`Fetching latest release info for channel: ${channel}...`);
const patchFrom = channel; // 'stable' or 'preview'
const command = `node scripts/get-release-version.js --cli-package-name="${argv['cli-package-name']}" --type=patch --patch-from=${patchFrom}`;
const patchFrom = channel;
try {
const result = JSON.parse(execSync(command).toString().trim());
const result = JSON.parse(
runCommand('node', [
'scripts/get-release-version.js',
`--cli-package-name=${argv['cli-package-name']}`,
'--type=patch',
`--patch-from=${patchFrom}`,
]),
);
console.log(`Current ${channel} tag: ${result.previousReleaseTag}`);
console.log(`Next ${channel} version would be: ${result.releaseVersion}`);
return {
Expand Down