Skip to content

Commit 1707ba2

Browse files
feat: add workflow permission checks and update pull request body for skipped workflow edits
1 parent cd4f408 commit 1707ba2

File tree

8 files changed

+229
-33
lines changed

8 files changed

+229
-33
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ The project follows [Semantic Versioning](https://semver.org/) and adheres to th
88

99
- Nothing yet.
1010

11+
## [1.6.0] - 2025-10-10
12+
13+
### Added
14+
15+
- Include a workflow warning in generated pull request bodies when `.github/workflows/**` files change.
16+
17+
### Fixed
18+
19+
- Skip runs that would edit `.github/workflows/**` unless the provided token is a personal access token with the `workflow` scope, preventing GitHub App permission errors.
20+
1121
## [1.5.0] - 2025-10-10
1222

1323
### Fixed

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,11 +217,13 @@ This workflow requires:
217217
permissions:
218218
contents: write
219219
pull-requests: write
220+
```
220221

221222
In addition to per-job permissions, the repository (or organization) wide setting under **Settings → Actions → General → Workflow permissions** must grant **Read and write permissions** and enable **“Allow GitHub Actions to create and approve pull requests”**. If that toggle cannot be enabled, provide a classic personal access token with `repo` scope via a secret (for example `PATCH_PR_TOKEN`) and export it as `GITHUB_TOKEN` when running the action.
222223

223224
Commits authored by the action default to the current `GITHUB_ACTOR` (falling back to `github-actions[bot]`). Override this by setting `GIT_AUTHOR_NAME` and `GIT_AUTHOR_EMAIL` (and matching `GIT_COMMITTER_*`) in the workflow environment before invoking the action if you need a custom identity.
224-
```
225+
226+
> **Note:** The default `GITHUB_TOKEN` cannot push updates to `.github/workflows/**`. If the action needs to rewrite workflow files, supply a personal access token that includes the `workflow` scope (often via a secret mapped to `GITHUB_TOKEN`). Runs without that scope will skip applying workflow changes and exit with `workflow_permission_required`.
225227

226228
## FAQ
227229

@@ -253,3 +255,7 @@ MIT. See `LICENSE`.
253255
> Like this Action? Star the repo. Adopt it in your org. Share feedback via issues or PRs.
254256

255257
GitHub Action for zero-maintenance CPython patch updates across your repo.
258+
259+
```
260+
261+
```

dist/index.js

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -80208,6 +80208,16 @@ function groupMatchesByFile(matches) {
8020880208
}
8020980209
return grouped;
8021080210
}
80211+
function isPersonalAccessToken(token) {
80212+
if (!token) {
80213+
return false;
80214+
}
80215+
const lowered = token.toLowerCase();
80216+
return (lowered.startsWith('ghp_') ||
80217+
lowered.startsWith('github_pat_') ||
80218+
lowered.startsWith('gho_') ||
80219+
lowered.startsWith('pat_'));
80220+
}
8021180221
async function applyVersionUpdates(workspace, groupedMatches, newVersion) {
8021280222
for (const [relativePath, fileMatches] of groupedMatches) {
8021380223
const absolutePath = node_path_1.default.join(workspace, relativePath);
@@ -80371,7 +80381,7 @@ async function executeAction(options, dependencies) {
8037180381
filesChanged: uniqueFiles(scanResult.matches),
8037280382
};
8037380383
}
80374-
const matchesNeedingUpdate = scanResult.matches.filter((match) => match.matched !== latestVersion);
80384+
let matchesNeedingUpdate = scanResult.matches.filter((match) => match.matched !== latestVersion);
8037580385
if (matchesNeedingUpdate.length === 0) {
8037680386
return {
8037780387
status: 'skip',
@@ -80380,6 +80390,22 @@ async function executeAction(options, dependencies) {
8038080390
filesChanged: [],
8038180391
};
8038280392
}
80393+
const hasPersonalAccessToken = isPersonalAccessToken(githubToken);
80394+
const workflowMatches = matchesNeedingUpdate.filter((match) => match.file.startsWith('.github/workflows/'));
80395+
let skippedWorkflowFiles = [];
80396+
if (!hasPersonalAccessToken && workflowMatches.length > 0) {
80397+
skippedWorkflowFiles = uniqueFiles(workflowMatches);
80398+
matchesNeedingUpdate = matchesNeedingUpdate.filter((match) => !match.file.startsWith('.github/workflows/'));
80399+
if (matchesNeedingUpdate.length === 0) {
80400+
return {
80401+
status: 'skip',
80402+
reason: 'workflow_permission_required',
80403+
newVersion: latestVersion,
80404+
filesChanged: [],
80405+
details: { files: skippedWorkflowFiles },
80406+
};
80407+
}
80408+
}
8038380409
const filesChanged = uniqueFiles(matchesNeedingUpdate);
8038480410
const groupedMatches = groupMatchesByFile(matchesNeedingUpdate);
8038580411
if (dryRun || !allowPrCreation) {
@@ -80388,6 +80414,7 @@ async function executeAction(options, dependencies) {
8038880414
newVersion: latestVersion,
8038980415
filesChanged,
8039080416
dryRun: true,
80417+
workflowFilesSkipped: skippedWorkflowFiles.length > 0 ? skippedWorkflowFiles : undefined,
8039180418
};
8039280419
}
8039380420
if (!dependencies.createBranchAndCommit || !dependencies.pushBranch) {
@@ -80396,6 +80423,7 @@ async function executeAction(options, dependencies) {
8039680423
newVersion: latestVersion,
8039780424
filesChanged,
8039880425
dryRun: false,
80426+
workflowFilesSkipped: skippedWorkflowFiles.length > 0 ? skippedWorkflowFiles : undefined,
8039980427
};
8040080428
}
8040180429
if (!githubToken || !repository) {
@@ -80404,6 +80432,7 @@ async function executeAction(options, dependencies) {
8040480432
newVersion: latestVersion,
8040580433
filesChanged,
8040680434
dryRun: false,
80435+
workflowFilesSkipped: skippedWorkflowFiles.length > 0 ? skippedWorkflowFiles : undefined,
8040780436
};
8040880437
}
8040980438
const branchName = `chore/bump-python-${track}`;
@@ -80449,6 +80478,7 @@ async function executeAction(options, dependencies) {
8044980478
newVersion: latestVersion,
8045080479
filesChanged,
8045180480
dryRun: false,
80481+
workflowFilesSkipped: skippedWorkflowFiles.length > 0 ? skippedWorkflowFiles : undefined,
8045280482
};
8045380483
}
8045480484
await dependencies.pushBranch({
@@ -80461,6 +80491,7 @@ async function executeAction(options, dependencies) {
8046180491
filesChanged,
8046280492
branchName: commitResult.branch,
8046380493
defaultBranch,
80494+
skippedWorkflowFiles,
8046480495
});
8046580496
const pullRequest = await dependencies.createOrUpdatePullRequest({
8046680497
owner: repository.owner,
@@ -80477,6 +80508,7 @@ async function executeAction(options, dependencies) {
8047780508
filesChanged,
8047880509
dryRun: false,
8047980510
pullRequest,
80511+
workflowFilesSkipped: skippedWorkflowFiles.length > 0 ? skippedWorkflowFiles : undefined,
8048080512
};
8048180513
}
8048280514
catch (error) {
@@ -80980,6 +81012,13 @@ function logSkip(result) {
8098081012
core.info(`Release notes do not contain the configured security keywords (${configuredKeywords}); skipping.`);
8098181013
break;
8098281014
}
81015+
case 'workflow_permission_required': {
81016+
const workflows = Array.isArray(result.details?.files)
81017+
? (result.details?.files).join(', ')
81018+
: '.github/workflows';
81019+
core.warning(`This run detected workflow changes (${workflows}) but the provided token lacks workflow write permissions. Provide a personal access token with the "workflow" scope and set it as GITHUB_TOKEN to apply these updates.`);
81020+
break;
81021+
}
8098381022
default:
8098481023
core.info(`Skipping with reason ${result.reason}.`);
8098581024
break;
@@ -81016,6 +81055,9 @@ function summarizeResult(result) {
8101681055
const { action, number, url } = result.pullRequest;
8101781056
core.info(`Pull request ${action}: #${number}${url ? ` (${url})` : ''}`);
8101881057
}
81058+
if (result.workflowFilesSkipped && result.workflowFilesSkipped.length > 0) {
81059+
core.warning(`Skipped updating workflow files due to missing workflow permissions: ${result.workflowFilesSkipped.join(', ')}`);
81060+
}
8101981061
}
8102081062
else {
8102181063
logSkip(result);
@@ -81163,11 +81205,11 @@ if (require.main === require.cache[eval('__filename')]) {
8116381205
Object.defineProperty(exports, "__esModule", ({ value: true }));
8116481206
exports.generatePullRequestBody = generatePullRequestBody;
8116581207
function generatePullRequestBody(options) {
81166-
const { track, newVersion, filesChanged, branchName, defaultBranch } = options;
81208+
const { track, newVersion, filesChanged, branchName, defaultBranch, skippedWorkflowFiles } = options;
8116781209
const filesSection = filesChanged.length
8116881210
? filesChanged.map((file) => `- \`${file}\``).join('\n')
8116981211
: 'No files were modified in this bump.';
81170-
return [
81212+
const bodySections = [
8117181213
'## Summary',
8117281214
'',
8117381215
`- Bump CPython ${track} pins to \`${newVersion}\`.`,
@@ -81176,25 +81218,12 @@ function generatePullRequestBody(options) {
8117681218
'',
8117781219
filesSection,
8117881220
'',
81179-
'## Rollback',
81180-
'',
81181-
'Before merge, close this PR and delete the branch:',
81182-
'',
81183-
'```sh',
81184-
`git push origin --delete ${branchName}`,
81185-
'```',
81186-
'',
81187-
`After merge, revert the change on ${defaultBranch}:`,
81188-
'',
81189-
'```sh',
81190-
`git checkout ${defaultBranch}`,
81191-
`git pull --ff-only origin ${defaultBranch}`,
81192-
'git revert --no-edit <merge_commit_sha>',
81193-
`git push origin ${defaultBranch}`,
81194-
'```',
81195-
'',
81196-
'Replace `<merge_commit_sha>` with the SHA of the merge commit if rollback is required.',
81197-
].join('\n');
81221+
];
81222+
if (skippedWorkflowFiles && skippedWorkflowFiles.length > 0) {
81223+
bodySections.push('## ⚠️ Workflow File Notice', '', 'The following workflow files were detected but left unchanged because the provided token lacks the `workflow` scope:', '', ...skippedWorkflowFiles.map((file) => `- \`${file}\``), '', 'Provide a personal access token with the `workflow` scope (for example via `GITHUB_TOKEN`) before rerunning to update these files automatically.', '');
81224+
}
81225+
bodySections.push('## Rollback', '', 'Before merge, close this PR and delete the branch:', '', '```sh', `git push origin --delete ${branchName}`, '```', '', `After merge, revert the change on ${defaultBranch}:`, '', '```sh', `git checkout ${defaultBranch}`, `git pull --ff-only origin ${defaultBranch}`, 'git revert --no-edit <merge_commit_sha>', `git push origin ${defaultBranch}`, '```', '', 'Replace `<merge_commit_sha>` with the SHA of the merge commit if rollback is required.');
81226+
return bodySections.join('\n');
8119881227
}
8119981228

8120081229

src/action-execution.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ export type SkipReason =
3333
| 'pr_exists'
3434
| 'pr_creation_failed'
3535
| 'pre_release_guarded'
36-
| 'security_gate_blocked';
36+
| 'security_gate_blocked'
37+
| 'workflow_permission_required';
3738

3839
export interface ExecuteOptions {
3940
workspace: string;
@@ -84,6 +85,7 @@ export interface SuccessResult {
8485
filesChanged: string[];
8586
dryRun: boolean;
8687
pullRequest?: PullRequestResult;
88+
workflowFilesSkipped?: string[];
8789
}
8890

8991
export type ExecuteResult = SkipResult | SuccessResult;
@@ -109,6 +111,20 @@ function groupMatchesByFile(matches: VersionMatch[]): Map<string, VersionMatch[]
109111
return grouped;
110112
}
111113

114+
function isPersonalAccessToken(token: string | undefined): boolean {
115+
if (!token) {
116+
return false;
117+
}
118+
119+
const lowered = token.toLowerCase();
120+
return (
121+
lowered.startsWith('ghp_') ||
122+
lowered.startsWith('github_pat_') ||
123+
lowered.startsWith('gho_') ||
124+
lowered.startsWith('pat_')
125+
);
126+
}
127+
112128
async function applyVersionUpdates(
113129
workspace: string,
114130
groupedMatches: Map<string, VersionMatch[]>,
@@ -334,9 +350,7 @@ export async function executeAction(
334350
} satisfies SkipResult;
335351
}
336352

337-
const matchesNeedingUpdate = scanResult.matches.filter(
338-
(match) => match.matched !== latestVersion,
339-
);
353+
let matchesNeedingUpdate = scanResult.matches.filter((match) => match.matched !== latestVersion);
340354

341355
if (matchesNeedingUpdate.length === 0) {
342356
return {
@@ -347,6 +361,30 @@ export async function executeAction(
347361
} satisfies SkipResult;
348362
}
349363

364+
const hasPersonalAccessToken = isPersonalAccessToken(githubToken);
365+
const workflowMatches = matchesNeedingUpdate.filter((match) =>
366+
match.file.startsWith('.github/workflows/'),
367+
);
368+
369+
let skippedWorkflowFiles: string[] = [];
370+
371+
if (!hasPersonalAccessToken && workflowMatches.length > 0) {
372+
skippedWorkflowFiles = uniqueFiles(workflowMatches);
373+
matchesNeedingUpdate = matchesNeedingUpdate.filter(
374+
(match) => !match.file.startsWith('.github/workflows/'),
375+
);
376+
377+
if (matchesNeedingUpdate.length === 0) {
378+
return {
379+
status: 'skip',
380+
reason: 'workflow_permission_required',
381+
newVersion: latestVersion,
382+
filesChanged: [],
383+
details: { files: skippedWorkflowFiles },
384+
} satisfies SkipResult;
385+
}
386+
}
387+
350388
const filesChanged = uniqueFiles(matchesNeedingUpdate);
351389
const groupedMatches = groupMatchesByFile(matchesNeedingUpdate);
352390

@@ -356,6 +394,7 @@ export async function executeAction(
356394
newVersion: latestVersion,
357395
filesChanged,
358396
dryRun: true,
397+
workflowFilesSkipped: skippedWorkflowFiles.length > 0 ? skippedWorkflowFiles : undefined,
359398
} satisfies SuccessResult;
360399
}
361400

@@ -365,6 +404,7 @@ export async function executeAction(
365404
newVersion: latestVersion,
366405
filesChanged,
367406
dryRun: false,
407+
workflowFilesSkipped: skippedWorkflowFiles.length > 0 ? skippedWorkflowFiles : undefined,
368408
} satisfies SuccessResult;
369409
}
370410

@@ -374,6 +414,7 @@ export async function executeAction(
374414
newVersion: latestVersion,
375415
filesChanged,
376416
dryRun: false,
417+
workflowFilesSkipped: skippedWorkflowFiles.length > 0 ? skippedWorkflowFiles : undefined,
377418
} satisfies SuccessResult;
378419
}
379420

@@ -427,6 +468,7 @@ export async function executeAction(
427468
newVersion: latestVersion,
428469
filesChanged,
429470
dryRun: false,
471+
workflowFilesSkipped: skippedWorkflowFiles.length > 0 ? skippedWorkflowFiles : undefined,
430472
} satisfies SuccessResult;
431473
}
432474

@@ -441,6 +483,7 @@ export async function executeAction(
441483
filesChanged,
442484
branchName: commitResult.branch,
443485
defaultBranch,
486+
skippedWorkflowFiles,
444487
});
445488

446489
const pullRequest = await dependencies.createOrUpdatePullRequest({
@@ -459,6 +502,7 @@ export async function executeAction(
459502
filesChanged,
460503
dryRun: false,
461504
pullRequest,
505+
workflowFilesSkipped: skippedWorkflowFiles.length > 0 ? skippedWorkflowFiles : undefined,
462506
} satisfies SuccessResult;
463507
} catch (error) {
464508
const message = error instanceof Error ? error.message : String(error);

src/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,15 @@ function logSkip(result: SkipResult): void {
173173
);
174174
break;
175175
}
176+
case 'workflow_permission_required': {
177+
const workflows = Array.isArray(result.details?.files)
178+
? (result.details?.files as string[]).join(', ')
179+
: '.github/workflows';
180+
core.warning(
181+
`This run detected workflow changes (${workflows}) but the provided token lacks workflow write permissions. Provide a personal access token with the "workflow" scope and set it as GITHUB_TOKEN to apply these updates.`,
182+
);
183+
break;
184+
}
176185
default:
177186
core.info(`Skipping with reason ${result.reason}.`);
178187
break;
@@ -214,6 +223,11 @@ function summarizeResult(result: ExecuteResult): void {
214223
const { action, number, url } = result.pullRequest;
215224
core.info(`Pull request ${action}: #${number}${url ? ` (${url})` : ''}`);
216225
}
226+
if (result.workflowFilesSkipped && result.workflowFilesSkipped.length > 0) {
227+
core.warning(
228+
`Skipped updating workflow files due to missing workflow permissions: ${result.workflowFilesSkipped.join(', ')}`,
229+
);
230+
}
217231
} else {
218232
logSkip(result);
219233
}

0 commit comments

Comments
 (0)