2025-09-18 16:31:39 -07:00
#!/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' ;
2025-10-02 16:21:37 -04:00
/**
2025-10-03 14:37:48 -04:00
* Extract base version, original pr, and originalPr info from hotfix branch name.
* Formats:
* - New NEW: hotfix/v0.5.3/v0.5.4/preview/cherry-pick-abc/pr-1234 -> v0.5.4, preview, 1234
2025-10-02 16:21:37 -04:00
* - 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)
* We check the formats from newest to oldest. If the channel found is invalid,
* an error is thrown.
*/
function getBranchInfo ( { branchName , context } ) {
const parts = branchName . split ( '/' ) ;
const version = parts [ 1 ] ;
2025-10-03 14:37:48 -04:00
let prNum ;
2025-10-02 16:21:37 -04:00
let channel = 'stable' ; // default for old format
2025-10-03 14:37:48 -04:00
if ( parts . length >= 6 && ( parts [ 3 ] === 'stable' || parts [ 3 ] === 'preview' ) ) {
2025-10-02 16:21:37 -04:00
channel = parts [ 3 ] ;
2025-10-03 14:37:48 -04:00
const prMatch = parts [ 5 ] . match ( /pr-(\d+)/ ) ;
prNum = prMatch [ 1 ] ;
2025-10-02 16:21:37 -04:00
} else 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'. ` ,
) ;
}
2025-10-03 14:37:48 -04:00
return { channel , prNum , version } ;
2025-10-02 16:21:37 -04:00
}
2025-09-18 16:31:39 -07:00
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 ,
} )
2025-09-19 05:39:38 -07:00
. option ( 'force-skip-tests' , {
description : 'Skip the "Run Tests" step in testing' ,
type : 'boolean' ,
default : false ,
} )
2025-10-20 23:09:27 -04:00
. option ( 'environment' , {
choices : [ 'prod' , 'dev' ] ,
type : 'string' ,
default : process . env . ENVIRONMENT || 'prod' ,
} )
2025-09-18 16:31:39 -07:00
. 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' ;
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 ;
2025-10-20 23:09:27 -04:00
const environment = argv . environment ;
2025-09-18 16:31:39 -07:00
const body = argv . prBody || process . env . PR _BODY || '' ;
const isDryRun = argv . dryRun || body . includes ( '[DRY RUN]' ) ;
2025-09-19 05:39:38 -07:00
const forceSkipTests =
argv . forceSkipTests || process . env . FORCE _SKIP _TESTS === 'true' ;
2026-03-10 13:15:04 -04:00
const runId = process . env . GITHUB _RUN _ID || '0' ;
2025-09-18 16:31:39 -07:00
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 } ` ) ;
2025-10-03 14:37:48 -04:00
const { prNum , version , channel } = getBranchInfo ( {
branchName : headRef ,
context ,
} ) ;
2025-09-18 16:31:39 -07:00
2025-10-03 14:37:48 -04:00
let originalPr = prNum ;
console . log ( ` Found originalPr: ${ prNum } from hotfix branch ` ) ;
// Fallback to using PR search (inconsistent) if no pr found in branch name.
if ( ! testMode && ! originalPr ) {
2025-09-18 16:31:39 -07:00
try {
console . log ( 'Looking for original PR using search...' ) ;
2025-09-19 05:39:38 -07:00
const { execFileSync } = await import ( 'node:child_process' ) ;
2025-10-02 14:30:25 -04:00
// Split search string into searchArgs to prevent triple escaping on the quoted filters
const searchArgs =
` repo: ${ context . repo . owner } / ${ context . repo . repo } is:pr in:comments " ${ headRef } " ` . split (
' ' ,
) ;
console . log ( 'Search args:' , searchArgs ) ;
2025-09-19 05:39:38 -07:00
// Use gh CLI to search for PRs with comments referencing the hotfix branch
const result = execFileSync (
'gh' ,
2025-10-02 14:30:25 -04:00
[
'search' ,
'prs' ,
'--json' ,
'number,title' ,
'--limit' ,
'1' ,
... searchArgs ,
'Patch PR Created' ,
] ,
2025-09-19 05:39:38 -07:00
{
encoding : 'utf8' ,
env : { ... process . env , GH _TOKEN : process . env . GITHUB _TOKEN } ,
} ,
) ;
const searchResults = JSON . parse ( result ) ;
if ( searchResults && searchResults . length > 0 ) {
originalPr = searchResults [ 0 ] . number ;
2025-09-18 16:31:39 -07:00
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);
}
2025-10-03 14:37:48 -04:00
}
if (!originalPr && testMode) {
2025-09-18 16:31:39 -07:00
console.log('Skipping original PR lookup (test mode)');
originalPr = 8655; // Mock for testing
}
2025-10-02 14:30:25 -04:00
if (!originalPr) {
throw new Error(
'Could not find the original PR for this patch. Cannot proceed with release.',
);
}
const releaseRef = ` release / $ { version } - pr - $ { originalPr } ` ;
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;
}
2025-09-18 16:31:39 -07:00
// Trigger the release workflow
console.log( ` Triggering release workflow : $ { workflowId } ` );
2025-09-19 05:39:38 -07:00
if (!testMode) {
try {
const { execFileSync } = await import('node:child_process');
const args = [
'workflow',
'run',
workflowId,
'--ref',
'main',
'--field',
` type = $ { channel } ` ,
'--field',
` dry _run = $ { isDryRun . toString ( ) } ` ,
'--field',
` force _skip _tests = $ { forceSkipTests . toString ( ) } ` ,
'--field',
` release _ref = $ { releaseRef } ` ,
'--field',
2025-10-20 23:09:27 -04:00
` environment = $ { environment } ` ,
'--field',
2025-09-19 05:39:38 -07:00
originalPr ? ` original _pr = $ { originalPr . toString ( ) } ` : 'original_pr=',
];
console.log( ` Running command : gh $ { args . join ( ' ' ) } ` );
execFileSync('gh', args, {
stdio: 'inherit',
env: { ...process.env, GH_TOKEN: process.env.GITHUB_TOKEN },
});
console.log('✅ Workflow dispatch completed successfully');
} catch (e) {
console.error('❌ Failed to dispatch workflow:', e.message);
throw e;
}
2025-09-18 16:31:39 -07:00
} else {
console.log('✅ Would trigger workflow with inputs:', {
type: channel,
dry_run: isDryRun.toString(),
2025-09-19 05:39:38 -07:00
force_skip_tests: forceSkipTests.toString(),
2025-09-18 16:31:39 -07:00
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';
2026-03-10 13:15:04 -04:00
const commentBody = ` 🚀 * * [ Step 3 / 4 ] Patch Release $ { environment === 'prod' ? 'Waiting for Approval' : 'Triggered' } ! * *
2025-09-18 16:31:39 -07:00
* * 📋 Release Details : * *
2025-10-20 23:09:27 -04:00
- * * Environment * * : \ ` ${ environment } \`
2025-09-18 16:31:39 -07:00
- **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 } )
2026-03-10 13:15:04 -04:00
**⏳ Status:** The patch release has been triggered ${ environment === 'prod' ? ' and is waiting for deployment approval. Please visit the specific workflow run link below and approve the deployment' : '' } . You'll receive another update when it completes.
2025-09-18 16:31:39 -07:00
**🔗 Track Progress:**
2026-03-10 13:15:04 -04:00
- [View release workflow history](https://github.com/ ${ context . repo . owner } / ${ context . repo . repo } /actions/workflows/ ${ workflowId } )
- [This trigger workflow run](https://github.com/ ${ context . repo . owner } / ${ context . repo . repo } /actions/runs/ ${ runId } ) ` ;
2025-09-18 16:31:39 -07:00
2025-09-19 05:39:38 -07:00
if ( ! testMode ) {
let tempDir ;
try {
const { execFileSync } = await import ( 'node:child_process' ) ;
const { writeFileSync , mkdtempSync } = await import ( 'node:fs' ) ;
const { join } = await import ( 'node:path' ) ;
const { tmpdir } = await import ( 'node:os' ) ;
// Create secure temporary directory and file
tempDir = mkdtempSync ( join ( tmpdir ( ) , 'patch-trigger-' ) ) ;
const tempFile = join ( tempDir , 'comment.md' ) ;
writeFileSync ( tempFile , commentBody ) ;
execFileSync (
'gh' ,
[ 'pr' , 'comment' , originalPr . toString ( ) , '--body-file' , tempFile ] ,
{
stdio : 'inherit' ,
env : { ... process . env , GH _TOKEN : process . env . GITHUB _TOKEN } ,
} ,
) ;
console . log ( '✅ Comment posted successfully' ) ;
} catch ( e ) {
console . error ( '❌ Failed to post comment:' , e . message ) ;
// Don't throw here since the main workflow dispatch succeeded
} finally {
// Clean up temp directory and all its contents
if ( tempDir ) {
try {
const { rmSync } = await import ( 'node:fs' ) ;
rmSync ( tempDir , { recursive : true , force : true } ) ;
} catch ( cleanupError ) {
console . warn (
'⚠️ Failed to clean up temp directory:' ,
cleanupError . message ,
) ;
}
}
}
2025-09-18 16:31:39 -07:00
} 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 ) ;
} ) ;