@@ -133,17 +133,94 @@ function isWorkflowsScopeRejection(stderr) {
133133 return lower . includes ( "`workflows` scope" ) || lower . includes ( "workflow can be created or updated due to timeout" ) ;
134134}
135135
136+ /**
137+ * Returns the list of unique workflow file paths (.github/workflows/**) present in the
138+ * local branch history beyond the PR's base branch. This is used as a pre-flight check
139+ * before pushing a new branch ref: GitHub rejects such pushes when the token lacks the
140+ * 'workflows' scope, even if the current changeset itself does not touch workflow files
141+ * (the rejection is based on ALL commits reachable from the pushed ref).
142+ *
143+ * Uses `origin/${baseBranch}` as the exclusion baseline so that commits already on the
144+ * PR's target branch (which GitHub has already accepted) are excluded. Falls back to
145+ * `origin/HEAD` when `baseBranch` is not available, and to an empty array (no workflow
146+ * changes detected) when the baseline ref is not resolvable or the git command fails —
147+ * in that case the push is still attempted and any real 'workflows' scope rejection will
148+ * be caught and surfaced as the typed error downstream.
149+ *
150+ * Note: `origin/${baseBranch}` and `origin/HEAD` are intentionally different baselines
151+ * for their respective layers. `origin/${baseBranch}` limits detection to commits the
152+ * agent actually introduced (correct for the PR delta). Using `origin/HEAD` here would
153+ * traverse commits on the target branch itself for PRs targeting non-default branches,
154+ * producing false-positive `workflows_scope_required` errors.
155+ *
156+ * @param {{ getExecOutput: Function } } exec - @actions/exec module (or compatible mock)
157+ * @param {Record<string, any> } gitOptions - Base git exec options (cwd, env, etc.)
158+ * @param {string | undefined } baseBranch - PR base branch name (e.g. "main"); falls back to origin/HEAD when not provided
159+ * @param {typeof core } coreLogger - Actions core logger used for debug output
160+ * @returns {Promise<string[]> } Unique workflow file paths found in the branch history
161+ */
162+ async function detectWorkflowFileChanges ( exec , gitOptions , baseBranch , coreLogger ) {
163+ const baseline = baseBranch && baseBranch . trim ( ) ? `origin/${ baseBranch } ` : "origin/HEAD" ;
164+ try {
165+ const result = await exec . getExecOutput ( "git" , [ "log" , "--name-only" , "--pretty=format:" , "HEAD" , "--not" , baseline , "--" , ".github/workflows/" ] , { ...gitOptions , ignoreReturnCode : true } ) ;
166+ if ( result . exitCode !== 0 ) {
167+ // Non-zero exit means the baseline ref was not resolvable or git failed;
168+ // treat as no workflow changes so the push proceeds and any real scope
169+ // rejection surfaces downstream.
170+ coreLogger . debug ( `detectWorkflowFileChanges: git log exited ${ result . exitCode } (baseline '${ baseline } ' may be unavailable); skipping pre-flight` ) ;
171+ return [ ] ;
172+ }
173+ return [
174+ ...new Set (
175+ result . stdout
176+ . split ( "\n" )
177+ . map ( f => f . trim ( ) )
178+ . filter ( Boolean )
179+ ) ,
180+ ] ;
181+ } catch ( err ) {
182+ coreLogger . debug ( `detectWorkflowFileChanges: git log threw (baseline '${ baseline } '); skipping pre-flight: ${ err instanceof Error ? err . message : String ( err ) } ` ) ;
183+ return [ ] ;
184+ }
185+ }
186+
187+ /**
188+ * Performs a pre-flight workflow-scope check before pushing a new branch ref.
189+ * Returns the typed error object when the branch history contains workflow file changes
190+ * and `allowWorkflows` is false; returns null when the push may proceed.
191+ *
192+ * Extracts the duplicated guard that appears in both the review-branch and
193+ * fallback-branch push paths so future changes only need to be made in one place.
194+ *
195+ * @param {{ getExecOutput: Function } } exec - @actions/exec module (or compatible mock)
196+ * @param {Record<string, any> } gitOptions - Base git exec options (cwd, env, etc.)
197+ * @param {boolean } allowWorkflows - Whether the push token has the 'workflows' scope
198+ * @param {string | undefined } baseBranch - PR base branch name passed through to detectWorkflowFileChanges
199+ * @param {string } context - Short label for the push path (e.g. "Review branch", "Fallback branch")
200+ * @param {typeof core } coreLogger - Actions core logger
201+ * @returns {Promise<{ success: false, error_type: string, error: string } | null> }
202+ */
203+ async function runWorkflowScopePreflightCheck ( exec , gitOptions , allowWorkflows , baseBranch , context , coreLogger ) {
204+ if ( allowWorkflows ) return null ;
205+ const workflowFiles = await detectWorkflowFileChanges ( exec , gitOptions , baseBranch , coreLogger ) ;
206+ if ( workflowFiles . length > 0 ) {
207+ coreLogger . info ( `Pre-flight check: branch history contains workflow file changes (${ workflowFiles . join ( ", " ) } ). Failing before push attempt.` ) ;
208+ return buildWorkflowsScopeError ( `${ context } pre-flight` , coreLogger ) ;
209+ }
210+ return null ;
211+ }
212+
136213/**
137214 * Builds the typed result and logs actionable guidance when a branch push fails
138215 * because the token lacks the 'workflows' scope.
139216 *
140217 * @param {string } context - Short label identifying the push path (e.g. "Review branch", "Fallback branch")
141- * @param {typeof core } core - Actions core logger
218+ * @param {typeof core } coreLogger - Actions core logger
142219 * @returns {{ success: false, error_type: "workflows_scope_required", error: string } }
143220 */
144- function buildWorkflowsScopeError ( context , core ) {
145- core . error ( `${ context } push rejected: the branch includes changes to workflow files (.github/workflows/**) that require the 'workflows' scope on the push token.` ) ;
146- core . error ( "To allow this workflow to push workflow file changes, configure 'push-to-pull-request-branch.allow-workflows: true' together with a GitHub App in 'safe-outputs.github-app'." ) ;
221+ function buildWorkflowsScopeError ( context , coreLogger ) {
222+ coreLogger . error ( `${ context } push rejected: the branch includes changes to workflow files (.github/workflows/**) that require the 'workflows' scope on the push token.` ) ;
223+ coreLogger . error ( "To allow this workflow to push workflow file changes, configure 'push-to-pull-request-branch.allow-workflows: true' together with a GitHub App in 'safe-outputs.github-app'." ) ;
147224 return {
148225 success : false ,
149226 error_type : "workflows_scope_required" ,
@@ -170,6 +247,7 @@ async function main(config = {}) {
170247 const commitTitleSuffix = config . commit_title_suffix || "" ;
171248 const maxSizeKb = parsePositiveInteger ( config . max_patch_size ) ?? 4096 ;
172249 const maxCount = config . max || 0 ; // 0 means no limit
250+ const allowWorkflows = config . allow_workflows === true ;
173251
174252 // Cross-repo support: resolve target repository from config
175253 // This allows pushing to PRs in a different repository than the workflow
@@ -1041,6 +1119,15 @@ async function main(config = {}) {
10411119 // normalizeBranchName to enforce valid git ref characters + max length.
10421120 const reviewBranchName = normalizeBranchName ( `${ branchName } -review` , String ( Date . now ( ) ) ) ;
10431121 try {
1122+ // Pre-flight: check full branch history for workflow file changes.
1123+ // GitHub rejects pushes of new branch refs whose commit history contains
1124+ // .github/workflows/** changes when the token lacks the 'workflows' scope —
1125+ // even if the current changeset itself does not touch workflow files.
1126+ // Failing here avoids leaving the local branch in a renamed state after
1127+ // a rejected push, and surfaces the error before any side effects.
1128+ const preflightError = await runWorkflowScopePreflightCheck ( exec , baseGitOpts , allowWorkflows , pullRequest ?. base ?. ref , "Review branch" , core ) ;
1129+ if ( preflightError ) return preflightError ;
1130+
10441131 // Rename current local branch to review branch
10451132 await exec . exec ( "git" , [ "checkout" , "-b" , reviewBranchName ] , baseGitOpts ) ;
10461133 core . info ( `Created review branch: ${ reviewBranchName } ` ) ;
@@ -1210,6 +1297,12 @@ async function main(config = {}) {
12101297 const fallbackBranchName = normalizeBranchName ( `${ branchName } -fallback` , String ( Date . now ( ) ) ) ;
12111298 core . warning ( `Non-fast-forward push detected; creating fallback pull request from '${ fallbackBranchName } ' to '${ branchName } '` ) ;
12121299 try {
1300+ // Pre-flight: check full branch history for workflow file changes.
1301+ // Like the review branch path, creating a new fallback branch ref triggers
1302+ // GitHub's scope check on the full commit history, not just the new commits.
1303+ const preflightError = await runWorkflowScopePreflightCheck ( exec , baseGitOpts , allowWorkflows , pullRequest ?. base ?. ref , "Fallback branch" , core ) ;
1304+ if ( preflightError ) return preflightError ;
1305+
12131306 await exec . exec ( "git" , [ "checkout" , "-b" , fallbackBranchName ] , baseGitOpts ) ;
12141307 // Use getExecOutput to capture stderr for 'workflows' scope diagnostics
12151308 const fallbackPushOutput = await exec . getExecOutput ( "git" , [ "push" , "origin" , fallbackBranchName ] , {
0 commit comments