@@ -4,16 +4,34 @@ import { discoverWorkspace } from '../core/workspace.ts';
44import { DependencyGraph } from '../core/dep-graph.ts' ;
55import { readChangesets } from '../core/changeset.ts' ;
66import { assembleReleasePlan } from '../core/release-plan.ts' ;
7- import { run , tryRun , runAsync } from '../utils/shell.ts' ;
7+ import { runArgs , runArgsAsync , tryRunArgs } from '../utils/shell.ts' ;
88import type { BumpyConfig , ReleasePlan , PlannedRelease } from '../types.ts' ;
99
10+ // ---- Validation helpers ----
11+
12+ /** Validate a git branch name to prevent injection */
13+ function validateBranchName ( name : string ) : string {
14+ if ( ! / ^ [ a - z A - Z 0 - 9 _ . / - ] + $ / . test ( name ) ) {
15+ throw new Error ( `Invalid branch name: ${ name } ` ) ;
16+ }
17+ return name ;
18+ }
19+
20+ /** Validate a PR number is numeric */
21+ function validatePrNumber ( pr : string ) : string {
22+ if ( ! / ^ \d + $ / . test ( pr ) ) {
23+ throw new Error ( `Invalid PR number: ${ pr } ` ) ;
24+ }
25+ return pr ;
26+ }
27+
1028/** Configure git identity for CI commits if not already set */
1129function ensureGitIdentity ( rootDir : string , config : BumpyConfig ) : void {
12- const name = tryRun ( 'git config user.name' , { cwd : rootDir } ) ;
30+ const name = tryRunArgs ( [ 'git' , ' config' , ' user.name'] , { cwd : rootDir } ) ;
1331 if ( ! name ) {
1432 const { name : gitName , email : gitEmail } = config . gitUser ;
15- run ( ` git config user.name " ${ gitName } "` , { cwd : rootDir } ) ;
16- run ( ` git config user.email " ${ gitEmail } "` , { cwd : rootDir } ) ;
33+ runArgs ( [ ' git' , ' config' , ' user.name' , gitName ] , { cwd : rootDir } ) ;
34+ runArgs ( [ ' git' , ' config' , ' user.email' , gitEmail ] , { cwd : rootDir } ) ;
1735 log . dim ( ` Using git identity: ${ gitName } <${ gitEmail } >` ) ;
1836 }
1937}
@@ -119,11 +137,11 @@ async function autoPublish(rootDir: string, config: BumpyConfig, tag?: string):
119137
120138 // Commit the version changes
121139 log . step ( 'Committing version changes...' ) ;
122- run ( 'git add -A' , { cwd : rootDir } ) ;
123- const status = tryRun ( 'git status --porcelain' , { cwd : rootDir } ) ;
140+ runArgs ( [ 'git' , ' add' , ' -A'] , { cwd : rootDir } ) ;
141+ const status = tryRunArgs ( [ 'git' , ' status' , ' --porcelain'] , { cwd : rootDir } ) ;
124142 if ( status ) {
125- run ( 'git commit -m " Version packages"' , { cwd : rootDir } ) ;
126- run ( 'git push' , { cwd : rootDir } ) ;
143+ runArgs ( [ 'git' , ' commit' , '-m' , ' Version packages' ] , { cwd : rootDir } ) ;
144+ runArgs ( [ 'git' , ' push'] , { cwd : rootDir } ) ;
127145 }
128146
129147 log . step ( 'Running bumpy publish...' ) ;
@@ -139,21 +157,25 @@ async function createVersionPr(
139157 config : BumpyConfig ,
140158 branchName ?: string ,
141159) : Promise < void > {
142- const branch = branchName || config . versionPr . branch ;
143- const baseBranch = tryRun ( 'git rev-parse --abbrev-ref HEAD' , { cwd : rootDir } ) || 'main' ;
160+ const branch = validateBranchName ( branchName || config . versionPr . branch ) ;
161+ const baseBranch = validateBranchName (
162+ tryRunArgs ( [ 'git' , 'rev-parse' , '--abbrev-ref' , 'HEAD' ] , { cwd : rootDir } ) || 'main' ,
163+ ) ;
144164
145165 // Check if a version PR already exists
146- const existingPr = tryRun ( `gh pr list --head "${ branch } " --json number --jq ".[0].number"` , { cwd : rootDir } ) ;
166+ const existingPr = tryRunArgs ( [ 'gh' , 'pr' , 'list' , '--head' , branch , '--json' , 'number' , '--jq' , '.[0].number' ] , {
167+ cwd : rootDir ,
168+ } ) ;
147169
148170 // Create or update the branch
149171 log . step ( `Creating branch ${ branch } ...` ) ;
150- const branchExists = tryRun ( ` git rev-parse --verify ${ branch } ` , { cwd : rootDir } ) !== null ;
172+ const branchExists = tryRunArgs ( [ ' git' , ' rev-parse' , ' --verify' , branch ] , { cwd : rootDir } ) !== null ;
151173
152174 if ( branchExists ) {
153- run ( ` git checkout ${ branch } ` , { cwd : rootDir } ) ;
154- run ( ` git reset --hard ${ baseBranch } ` , { cwd : rootDir } ) ;
175+ runArgs ( [ ' git' , ' checkout' , branch ] , { cwd : rootDir } ) ;
176+ runArgs ( [ ' git' , ' reset' , ' --hard' , baseBranch ] , { cwd : rootDir } ) ;
155177 } else {
156- run ( ` git checkout -b ${ branch } ` , { cwd : rootDir } ) ;
178+ runArgs ( [ ' git' , ' checkout' , '-b' , branch ] , { cwd : rootDir } ) ;
157179 }
158180
159181 // Run bumpy version
@@ -162,40 +184,41 @@ async function createVersionPr(
162184 await versionCommand ( rootDir ) ;
163185
164186 // Commit and push
165- run ( 'git add -A' , { cwd : rootDir } ) ;
166- const status = tryRun ( 'git status --porcelain' , { cwd : rootDir } ) ;
187+ runArgs ( [ 'git' , ' add' , ' -A'] , { cwd : rootDir } ) ;
188+ const status = tryRunArgs ( [ 'git' , ' status' , ' --porcelain'] , { cwd : rootDir } ) ;
167189 if ( ! status ) {
168190 log . info ( 'No version changes to commit.' ) ;
169- run ( ` git checkout ${ baseBranch } ` , { cwd : rootDir } ) ;
191+ runArgs ( [ ' git' , ' checkout' , baseBranch ] , { cwd : rootDir } ) ;
170192 return ;
171193 }
172194
173195 const commitMsg = [ 'Version packages' , '' , ...plan . releases . map ( ( r ) => `${ r . name } @${ r . newVersion } ` ) ] . join ( '\n' ) ;
174- run ( 'git commit -F -' , { cwd : rootDir , input : commitMsg } ) ;
175- run ( ` git push -u origin ${ branch } --force` , { cwd : rootDir } ) ;
196+ runArgs ( [ 'git' , ' commit' , '-F' , '-' ] , { cwd : rootDir , input : commitMsg } ) ;
197+ runArgs ( [ ' git' , ' push' , '-u' , ' origin' , branch , ' --force' ] , { cwd : rootDir } ) ;
176198
177199 // Create or update PR
178200 const prBody = formatVersionPrBody ( plan , config . versionPr . preamble ) ;
179201
180202 if ( existingPr ) {
181- log . step ( `Updating existing PR #${ existingPr } ...` ) ;
182- await runAsync ( `gh pr edit ${ existingPr } --title "${ config . versionPr . title } " --body-file -` , {
203+ const validPr = validatePrNumber ( existingPr ) ;
204+ log . step ( `Updating existing PR #${ validPr } ...` ) ;
205+ await runArgsAsync ( [ 'gh' , 'pr' , 'edit' , validPr , '--title' , config . versionPr . title , '--body-file' , '-' ] , {
183206 cwd : rootDir ,
184207 input : prBody ,
185208 } ) ;
186- log . success ( `Updated PR #${ existingPr } ` ) ;
209+ log . success ( `Updated PR #${ validPr } ` ) ;
187210 } else {
188211 log . step ( 'Creating version PR...' ) ;
189212 const prTitle = config . versionPr . title ;
190- const result = await runAsync (
191- `gh pr create --title " ${ prTitle } " --body-file - --base " ${ baseBranch } " --head " ${ branch } "` ,
213+ const result = await runArgsAsync (
214+ [ 'gh' , 'pr' , ' create' , ' --title' , prTitle , ' --body-file' , '-' , ' --base' , baseBranch , ' --head' , branch ] ,
192215 { cwd : rootDir , input : prBody } ,
193216 ) ;
194217 log . success ( `Created PR: ${ result } ` ) ;
195218 }
196219
197220 // Switch back to the base branch
198- run ( ` git checkout ${ baseBranch } ` , { cwd : rootDir } ) ;
221+ runArgs ( [ ' git' , ' checkout' , baseBranch ] , { cwd : rootDir } ) ;
199222}
200223
201224// ---- PR comment helpers ----
@@ -277,23 +300,27 @@ function formatVersionPrBody(plan: ReleasePlan, preamble: string): string {
277300const COMMENT_MARKER = '<!-- bumpy-release-plan -->' ;
278301
279302async function postOrUpdatePrComment ( prNumber : string , body : string , rootDir : string ) : Promise < void > {
303+ const validPr = validatePrNumber ( prNumber ) ;
280304 const markedBody = `${ COMMENT_MARKER } \n${ body } ` ;
281305
282306 try {
283- // Find existing bumpy comment
284- const existingComment = tryRun (
285- `gh pr view ${ prNumber } --json comments --jq '.comments[] | select(.body | startswith("${ COMMENT_MARKER } ")) | .id' | head -1` ,
286- { cwd : rootDir } ,
287- ) ;
307+ // Find existing bumpy comment using gh with jq
308+ const jqFilter = `.comments[] | select(.body | startswith("${ COMMENT_MARKER } ")) | .id` ;
309+ const existingComment = tryRunArgs ( [ 'gh' , 'pr' , 'view' , validPr , '--json' , 'comments' , '--jq' , jqFilter ] , {
310+ cwd : rootDir ,
311+ } ) ;
312+
313+ // Take the first result if multiple
314+ const commentId = existingComment ?. split ( '\n' ) [ 0 ] ?. trim ( ) ;
288315
289- if ( existingComment ) {
290- await runAsync ( `gh api repos/{owner}/{repo}/issues/comments/ ${ existingComment } -X PATCH -f body=@-` , {
291- cwd : rootDir ,
292- input : markedBody ,
293- } ) ;
316+ if ( commentId ) {
317+ await runArgsAsync (
318+ [ 'gh' , 'api' , `repos/{owner}/{repo}/issues/comments/ ${ commentId } ` , '-X' , 'PATCH' , '-f' , 'body=@-' ] ,
319+ { cwd : rootDir , input : markedBody } ,
320+ ) ;
294321 log . dim ( ' Updated PR comment' ) ;
295322 } else {
296- await runAsync ( `gh pr comment ${ prNumber } --body-file -` , { cwd : rootDir , input : markedBody } ) ;
323+ await runArgsAsync ( [ 'gh' , 'pr' , ' comment' , validPr , ' --body-file' , '-' ] , { cwd : rootDir , input : markedBody } ) ;
297324 log . dim ( ' Posted PR comment' ) ;
298325 }
299326 } catch ( err ) {
@@ -308,6 +335,11 @@ function detectPrNumber(): string | null {
308335 const match = process . env . GITHUB_REF ?. match ( / r e f s \/ p u l l \/ ( \d + ) \/ / ) ;
309336 if ( match ) return match [ 1 ] ! ;
310337 }
311- // Also check for explicit env var
312- return process . env . BUMPY_PR_NUMBER || process . env . PR_NUMBER || null ;
338+ // Also check for explicit env var — validate it's numeric
339+ const envPr = process . env . BUMPY_PR_NUMBER || process . env . PR_NUMBER || null ;
340+ if ( envPr && ! / ^ \d + $ / . test ( envPr ) ) {
341+ log . warn ( `Ignoring invalid PR number from environment: ${ envPr } ` ) ;
342+ return null ;
343+ }
344+ return envPr ;
313345}
0 commit comments