Skip to content

Commit 5ae13fa

Browse files
feat: add security keyword gating and fetch release notes functionality
1 parent f1246c0 commit 5ae13fa

File tree

15 files changed

+435
-8
lines changed

15 files changed

+435
-8
lines changed

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ CPython Patch PR Action is a GitHub Action that automatically scans your reposit
6363
| `paths` | false | _(see default globs)_ | Newline-separated glob patterns to scan. |
6464
| `automerge` | false | `false` | Label or merge the bump PR once checks pass. |
6565
| `dry_run` | false | `false` | Skip file writes and emit a change summary instead. |
66+
| `security_keywords` | false | _(empty)_ | Require the release notes to contain at least one of the provided keywords before upgrading. |
6667
| `use_external_pr_action` | false | `false` | Emit outputs for `peter-evans/create-pull-request` instead of using Octokit internally. |
6768

6869
**Default globs**
@@ -133,6 +134,33 @@ with:
133134
134135
Set `automerge: true` and wire a follow-up job that applies your preferred automerge strategy (label-based, direct merge, etc.) based on the outputs emitted by the action.
135136

137+
### Security keyword gate
138+
139+
Supply `security_keywords` (one per line) to require matching terms inside the CPython release notes before applying an update. This is useful for only auto-rolling releases that contain security fixes:
140+
141+
```yaml
142+
with:
143+
security_keywords: |
144+
CVE
145+
security
146+
```
147+
148+
When the keywords are provided, the action fetches the GitHub release notes for the resolved tag (or uses the optional `RELEASE_NOTES_SNAPSHOT` offline input) and skips the run unless at least one keyword is present.
149+
150+
### Renovate/Dependabot coexistence
151+
152+
If Renovate or Dependabot also try to bump CPython patch versions, they will race with this action
153+
and open competing pull requests. Use the sample configurations below to disable CPython patch bumps
154+
while still allowing those tools to manage other dependencies:
155+
156+
- `examples/coexistence/renovate.json` disables patch updates for the `python` base image in Dockerfiles
157+
and any custom regex managers that match CPython pins.
158+
- `examples/coexistence/dependabot.yml` ignores semver patch updates for the `python` Docker image
159+
while keeping other ecosystems enabled.
160+
161+
Both samples are validated by the test suite so you can copy them verbatim and adjust schedules or
162+
additional dependency rules as needed.
163+
136164
---
137165

138166
## Example consumer repositories
@@ -155,6 +183,7 @@ external endpoints:
155183
- `CPYTHON_TAGS_SNAPSHOT` – JSON array of CPython tag objects.
156184
- `PYTHON_ORG_HTML_SNAPSHOT` – Raw HTML or path to a saved python.org releases page.
157185
- `RUNNER_MANIFEST_SNAPSHOT` – JSON manifest compatible with `actions/python-versions`.
186+
- `RELEASE_NOTES_SNAPSHOT` – JSON object mapping tags or versions to release note strings.
158187

159188
Each variable accepts either the data directly or a path to a file containing the snapshot. When
160189
offline mode is enabled and a snapshot is missing, the run will fail fast with a clear message.

action.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ inputs:
2828
description: Skip file modifications and output the planned changes only.
2929
required: false
3030
default: 'false'
31+
security_keywords:
32+
description: Newline-separated keywords that must appear in the release notes before applying an update.
33+
required: false
34+
default: ''
3135
outputs:
3236
new_version:
3337
description: Resolved CPython patch version (for example 3.13.5).

docs/tasks.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,11 +247,11 @@ Use Context7 MCP for up to date documentation.
247247

248248
## 10) Optional compatibility and polish
249249

250-
41. [ ] **Renovate/Dependabot coexistence docs**
250+
41. [x] **Renovate/Dependabot coexistence docs**
251251
Provide ignore rules to avoid flapping.
252252
Verify: Example configs tested.
253253

254-
42. [ ] **Security keyword gating**
254+
42. [x] **Security keyword gating**
255255
Input `security_keywords` to gate bumps by release notes.
256256
Verify: Mock notes trigger gate.
257257

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
version: 2
2+
updates:
3+
- package-ecosystem: "docker"
4+
directory: "/"
5+
schedule:
6+
interval: "weekly"
7+
ignore:
8+
- dependency-name: "python"
9+
update-types:
10+
- "version-update:semver-patch"
11+
# CPython patch bumps are managed by python-version-patch-pr
12+
- package-ecosystem: "github-actions"
13+
directory: "/"
14+
schedule:
15+
interval: "weekly"

examples/coexistence/renovate.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
3+
"extends": ["config:recommended"],
4+
"packageRules": [
5+
{
6+
"description": "Let python-version-patch-pr handle CPython patch bumps found in Dockerfiles and regex managers",
7+
"matchManagers": ["dockerfile", "regex"],
8+
"matchPackagePatterns": ["^python$"],
9+
"matchUpdateTypes": ["patch"],
10+
"enabled": false
11+
}
12+
]
13+
}

package-lock.json

Lines changed: 15 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"rimraf": "^6.0.1",
6464
"tsx": "^4.20.6",
6565
"typescript": "^5.4.5",
66-
"vitest": "^3.2.4"
66+
"vitest": "^3.2.4",
67+
"yaml": "^2.8.1"
6768
}
6869
}

src/action-execution.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
enforcePreReleaseGuard,
99
fetchLatestFromPythonOrg,
1010
fetchRunnerAvailability,
11+
fetchReleaseNotes,
1112
resolveLatestPatch,
1213
type LatestPatchResult,
1314
} from './versioning';
@@ -22,7 +23,8 @@ export type SkipReason =
2223
| 'already_latest'
2324
| 'pr_exists'
2425
| 'pr_creation_failed'
25-
| 'pre_release_guarded';
26+
| 'pre_release_guarded'
27+
| 'security_gate_blocked';
2628

2729
export interface ExecuteOptions {
2830
workspace: string;
@@ -36,10 +38,12 @@ export interface ExecuteOptions {
3638
defaultBranch?: string;
3739
allowPrCreation?: boolean;
3840
noNetworkFallback?: boolean;
41+
securityKeywords?: string[];
3942
snapshots?: {
4043
cpythonTags?: StableTag[];
4144
pythonOrgHtml?: string;
4245
runnerManifest?: unknown;
46+
releaseNotes?: Record<string, string>;
4347
};
4448
}
4549

@@ -52,6 +56,7 @@ export interface ExecuteDependencies {
5256
fetchRunnerAvailability: typeof fetchRunnerAvailability;
5357
findExistingPullRequest?: typeof findExistingPullRequest;
5458
createOrUpdatePullRequest?: typeof createOrUpdatePullRequest;
59+
fetchReleaseNotes?: typeof fetchReleaseNotes;
5560
}
5661

5762
export interface SkipResult {
@@ -122,6 +127,7 @@ export async function executeAction(
122127
defaultBranch = 'main',
123128
allowPrCreation = false,
124129
noNetworkFallback = false,
130+
securityKeywords = [],
125131
snapshots,
126132
} = options;
127133

@@ -171,6 +177,73 @@ export async function executeAction(
171177
} satisfies SkipResult;
172178
}
173179

180+
const normalizedSecurityKeywords = securityKeywords
181+
.map((keyword) => keyword.trim())
182+
.filter((keyword) => keyword.length > 0);
183+
184+
if (normalizedSecurityKeywords.length > 0) {
185+
const releaseNotesLookup = snapshots?.releaseNotes;
186+
const releaseNotesFromSnapshot =
187+
releaseNotesLookup &&
188+
[latestPatch?.tagName, `v${latestVersion}`, latestVersion].reduce<string | undefined>(
189+
(acc, key) => {
190+
if (acc) {
191+
return acc;
192+
}
193+
if (!key) {
194+
return undefined;
195+
}
196+
const direct = releaseNotesLookup[key];
197+
if (typeof direct === 'string') {
198+
return direct;
199+
}
200+
return undefined;
201+
},
202+
undefined,
203+
);
204+
205+
let releaseNotesText = releaseNotesFromSnapshot;
206+
let releaseNotesFound = releaseNotesText != null;
207+
208+
if (!releaseNotesText && !noNetworkFallback && dependencies.fetchReleaseNotes) {
209+
const tagForNotes = latestPatch?.tagName ?? `v${latestVersion}`;
210+
releaseNotesText =
211+
(await dependencies.fetchReleaseNotes(tagForNotes, {
212+
token: githubToken,
213+
})) ?? undefined;
214+
releaseNotesFound = releaseNotesText != null;
215+
}
216+
217+
if (releaseNotesText == null) {
218+
return {
219+
status: 'skip',
220+
reason: 'security_gate_blocked',
221+
newVersion: latestVersion,
222+
details: {
223+
keywords: normalizedSecurityKeywords,
224+
releaseNotesFound,
225+
},
226+
} satisfies SkipResult;
227+
}
228+
229+
const lowerCaseNotes = releaseNotesText.toLowerCase();
230+
const matchedKeyword = normalizedSecurityKeywords.find((keyword) =>
231+
lowerCaseNotes.includes(keyword.toLowerCase()),
232+
);
233+
234+
if (!matchedKeyword) {
235+
return {
236+
status: 'skip',
237+
reason: 'security_gate_blocked',
238+
newVersion: latestVersion,
239+
details: {
240+
keywords: normalizedSecurityKeywords,
241+
releaseNotesFound: true,
242+
},
243+
} satisfies SkipResult;
244+
}
245+
}
246+
174247
const availability = await dependencies.fetchRunnerAvailability(latestVersion, {
175248
manifestSnapshot: snapshots?.runnerManifest,
176249
noNetworkFallback,

src/index.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
enforcePreReleaseGuard,
1515
fetchLatestFromPythonOrg,
1616
fetchRunnerAvailability,
17+
fetchReleaseNotes,
1718
resolveLatestPatch,
1819
} from './versioning';
1920
import { createOrUpdatePullRequest, findExistingPullRequest } from './git';
@@ -53,6 +54,13 @@ function resolvePathsInput(): string[] {
5354
return explicitPaths.length > 0 ? explicitPaths : DEFAULT_PATHS;
5455
}
5556

57+
function resolveSecurityKeywords(): string[] {
58+
return core
59+
.getMultilineInput('security_keywords', { trimWhitespace: true })
60+
.map((keyword) => keyword.trim())
61+
.filter((keyword) => keyword.length > 0);
62+
}
63+
5664
function loadJsonSnapshot(envName: string): unknown | undefined {
5765
const raw = process.env[envName];
5866
if (!raw || raw.trim() === '') {
@@ -151,6 +159,15 @@ function logSkip(result: SkipResult): void {
151159
case 'pre_release_guarded':
152160
core.info('Latest tag is a pre-release and include_prerelease is false; skipping.');
153161
break;
162+
case 'security_gate_blocked': {
163+
const configuredKeywords = Array.isArray(result.details?.keywords)
164+
? (result.details?.keywords as string[]).join(', ')
165+
: 'none';
166+
core.info(
167+
`Release notes do not contain the configured security keywords (${configuredKeywords}); skipping.`,
168+
);
169+
break;
170+
}
154171
default:
155172
core.info(`Skipping with reason ${result.reason}.`);
156173
break;
@@ -167,6 +184,7 @@ function buildDependencies(): ExecuteDependencies {
167184
fetchRunnerAvailability,
168185
findExistingPullRequest,
169186
createOrUpdatePullRequest,
187+
fetchReleaseNotes,
170188
};
171189
}
172190

@@ -204,6 +222,7 @@ export async function run(): Promise<void> {
204222
const automerge = getBooleanInput('automerge', false);
205223
const dryRun = getBooleanInput('dry_run', false);
206224
const effectivePaths = resolvePathsInput();
225+
const securityKeywords = resolveSecurityKeywords();
207226

208227
const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd();
209228
const repository = parseRepository(process.env.GITHUB_REPOSITORY);
@@ -214,6 +233,7 @@ export async function run(): Promise<void> {
214233
let cpythonTagsSnapshot: StableTag[] | undefined;
215234
let pythonOrgHtmlSnapshot: string | undefined;
216235
let runnerManifestSnapshot: unknown | undefined;
236+
let releaseNotesSnapshot: Record<string, string> | undefined;
217237

218238
try {
219239
const rawTagsSnapshot = loadJsonSnapshot('CPYTHON_TAGS_SNAPSHOT');
@@ -226,6 +246,22 @@ export async function run(): Promise<void> {
226246

227247
pythonOrgHtmlSnapshot = loadTextSnapshot('PYTHON_ORG_HTML_SNAPSHOT');
228248
runnerManifestSnapshot = loadJsonSnapshot('RUNNER_MANIFEST_SNAPSHOT');
249+
250+
const rawReleaseNotesSnapshot = loadJsonSnapshot('RELEASE_NOTES_SNAPSHOT');
251+
if (rawReleaseNotesSnapshot !== undefined) {
252+
if (typeof rawReleaseNotesSnapshot !== 'object' || rawReleaseNotesSnapshot === null) {
253+
throw new Error('RELEASE_NOTES_SNAPSHOT must be a JSON object mapping versions or tags to release note strings.');
254+
}
255+
256+
const entries = Object.entries(rawReleaseNotesSnapshot as Record<string, unknown>);
257+
for (const [, value] of entries) {
258+
if (typeof value !== 'string') {
259+
throw new Error('RELEASE_NOTES_SNAPSHOT values must be strings.');
260+
}
261+
}
262+
263+
releaseNotesSnapshot = Object.fromEntries(entries) as Record<string, string>;
264+
}
229265
} catch (snapshotError) {
230266
if (snapshotError instanceof Error) {
231267
core.setFailed(snapshotError.message);
@@ -240,6 +276,9 @@ export async function run(): Promise<void> {
240276
core.info(`track: ${validatedTrack}`);
241277
core.info(`include_prerelease: ${includePrerelease}`);
242278
core.info(`paths (${effectivePaths.length}): ${effectivePaths.join(', ')}`);
279+
core.info(
280+
`security_keywords (${securityKeywords.length}): ${securityKeywords.length > 0 ? securityKeywords.join(', ') : '(none)'}`,
281+
);
243282
core.info(`automerge: ${automerge}`);
244283
core.info(`dry_run: ${dryRun}`);
245284
core.info(`no_network_fallback: ${noNetworkFallback}`);
@@ -267,10 +306,12 @@ export async function run(): Promise<void> {
267306
defaultBranch,
268307
allowPrCreation: false,
269308
noNetworkFallback,
309+
securityKeywords,
270310
snapshots: {
271311
cpythonTags: cpythonTagsSnapshot,
272312
pythonOrgHtml: pythonOrgHtmlSnapshot,
273313
runnerManifest: runnerManifestSnapshot,
314+
releaseNotes: releaseNotesSnapshot,
274315
},
275316
},
276317
dependencies,

src/versioning/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@ export type { RunnerAvailability, RunnerAvailabilityOptions } from './runner-ava
99

1010
export { enforcePreReleaseGuard } from './pre-release-guard';
1111
export type { PreReleaseGuardResult } from './pre-release-guard';
12+
13+
export { fetchReleaseNotes } from './release-notes';
14+
export type { FetchReleaseNotesOptions } from './release-notes';

0 commit comments

Comments
 (0)