Skip to content

Commit af16740

Browse files
feat: enhance versioning workflow with branch creation and automatic patch application
1 parent 767524b commit af16740

File tree

8 files changed

+262
-24
lines changed

8 files changed

+262
-24
lines changed

CHANGELOG.md

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

99
- Nothing yet.
1010

11+
## [1.4.0] - 2025-10-10
12+
13+
### Added
14+
15+
- Push the generated `chore/bump-python-<track>` branch before creating the pull request so the GitHub API accepts the head reference.
16+
- Apply resolved patch versions to the working tree automatically when not in dry-run mode, preserving the minimal diff behaviour.
17+
18+
### Fixed
19+
20+
- Documented the repository-level workflow permission toggle required for PR creation.
21+
1122
## [1.3.0] - 2025-10-10
1223

1324
- Detect the default branch correctly even when `GITHUB_BASE_REF` is present but empty in scheduled workflows.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,8 @@ This workflow requires:
217217
permissions:
218218
contents: write
219219
pull-requests: write
220+
221+
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.
220222
```
221223

222224
## FAQ

dist/index.js

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80178,17 +80178,56 @@ exports.collectAnHTTPQuotedString = (input, position) => {
8017880178
/***/ }),
8017980179

8018080180
/***/ 26454:
80181-
/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => {
80181+
/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) {
8018280182

8018380183
"use strict";
8018480184

80185+
var __importDefault = (this && this.__importDefault) || function (mod) {
80186+
return (mod && mod.__esModule) ? mod : { "default": mod };
80187+
};
8018580188
Object.defineProperty(exports, "__esModule", ({ value: true }));
8018680189
exports.executeAction = executeAction;
80190+
const promises_1 = __nccwpck_require__(51455);
80191+
const node_path_1 = __importDefault(__nccwpck_require__(76760));
8018780192
const pr_body_1 = __nccwpck_require__(62706);
8018880193
const DEFAULT_IGNORES = ['**/node_modules/**', '**/.git/**', '**/dist/**'];
8018980194
function uniqueFiles(matches) {
8019080195
return Array.from(new Set(matches.map((match) => match.file))).sort();
8019180196
}
80197+
function groupMatchesByFile(matches) {
80198+
const grouped = new Map();
80199+
for (const match of matches) {
80200+
const existing = grouped.get(match.file);
80201+
if (existing) {
80202+
existing.push(match);
80203+
}
80204+
else {
80205+
grouped.set(match.file, [match]);
80206+
}
80207+
}
80208+
return grouped;
80209+
}
80210+
async function applyVersionUpdates(workspace, groupedMatches, newVersion) {
80211+
for (const [relativePath, fileMatches] of groupedMatches) {
80212+
const absolutePath = node_path_1.default.join(workspace, relativePath);
80213+
const originalContent = await (0, promises_1.readFile)(absolutePath, 'utf8');
80214+
const sortedMatches = [...fileMatches].sort((a, b) => b.index - a.index);
80215+
let updatedContent = originalContent;
80216+
let changed = false;
80217+
for (const match of sortedMatches) {
80218+
if (match.matched === newVersion) {
80219+
continue;
80220+
}
80221+
const start = match.index;
80222+
const end = start + match.matched.length;
80223+
updatedContent = updatedContent.slice(0, start) + newVersion + updatedContent.slice(end);
80224+
changed = true;
80225+
}
80226+
if (changed && updatedContent !== originalContent) {
80227+
await (0, promises_1.writeFile)(absolutePath, updatedContent, 'utf8');
80228+
}
80229+
}
80230+
}
8019280231
function determineMissingRunners(availability) {
8019380232
if (!availability) {
8019480233
return ['linux', 'mac', 'win'];
@@ -80328,6 +80367,7 @@ async function executeAction(options, dependencies) {
8032880367
};
8032980368
}
8033080369
const filesChanged = uniqueFiles(matchesNeedingUpdate);
80370+
const groupedMatches = groupMatchesByFile(matchesNeedingUpdate);
8033180371
if (dryRun || !allowPrCreation) {
8033280372
return {
8033380373
status: 'success',
@@ -80336,6 +80376,14 @@ async function executeAction(options, dependencies) {
8033680376
dryRun: true,
8033780377
};
8033880378
}
80379+
if (!dependencies.createBranchAndCommit || !dependencies.pushBranch) {
80380+
return {
80381+
status: 'success',
80382+
newVersion: latestVersion,
80383+
filesChanged,
80384+
dryRun: false,
80385+
};
80386+
}
8033980387
if (!githubToken || !repository) {
8034080388
return {
8034180389
status: 'success',
@@ -80371,17 +80419,36 @@ async function executeAction(options, dependencies) {
8037180419
};
8037280420
}
8037380421
try {
80422+
await applyVersionUpdates(workspace, groupedMatches, latestVersion);
80423+
const commitResult = await dependencies.createBranchAndCommit({
80424+
repoPath: workspace,
80425+
track,
80426+
files: filesChanged,
80427+
commitMessage: `chore: bump python ${track} to ${latestVersion}`,
80428+
});
80429+
if (!commitResult.commitCreated) {
80430+
return {
80431+
status: 'success',
80432+
newVersion: latestVersion,
80433+
filesChanged,
80434+
dryRun: false,
80435+
};
80436+
}
80437+
await dependencies.pushBranch({
80438+
repoPath: workspace,
80439+
branch: commitResult.branch,
80440+
});
8037480441
const prBody = (0, pr_body_1.generatePullRequestBody)({
8037580442
track,
8037680443
newVersion: latestVersion,
8037780444
filesChanged,
80378-
branchName,
80445+
branchName: commitResult.branch,
8037980446
defaultBranch,
8038080447
});
8038180448
const pullRequest = await dependencies.createOrUpdatePullRequest({
8038280449
owner: repository.owner,
8038380450
repo: repository.repo,
80384-
head: branchName,
80451+
head: commitResult.branch,
8038580452
base: defaultBranch,
8038680453
title: `chore: bump python ${track} to ${latestVersion}`,
8038780454
body: prBody,
@@ -80442,6 +80509,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
8044280509
};
8044380510
Object.defineProperty(exports, "__esModule", ({ value: true }));
8044480511
exports.createBranchAndCommit = createBranchAndCommit;
80512+
exports.pushBranch = pushBranch;
8044580513
const node_child_process_1 = __nccwpck_require__(31421);
8044680514
const node_process_1 = __importDefault(__nccwpck_require__(1708));
8044780515
const node_util_1 = __nccwpck_require__(57975);
@@ -80510,6 +80578,18 @@ async function createBranchAndCommit(options) {
8051080578
});
8051180579
return { branch, commitCreated: true, filesCommitted: stagedFiles };
8051280580
}
80581+
async function pushBranch(options) {
80582+
const { repoPath, branch, remote = 'origin', forceWithLease = true, setUpstream = true, } = options;
80583+
const args = ['push'];
80584+
if (setUpstream) {
80585+
args.push('--set-upstream');
80586+
}
80587+
if (forceWithLease) {
80588+
args.push('--force-with-lease');
80589+
}
80590+
args.push(remote, branch);
80591+
await runGit(args, repoPath);
80592+
}
8051380593

8051480594

8051580595
/***/ }),
@@ -80520,9 +80600,10 @@ async function createBranchAndCommit(options) {
8052080600
"use strict";
8052180601

8052280602
Object.defineProperty(exports, "__esModule", ({ value: true }));
80523-
exports.findExistingPullRequest = exports.createOrUpdatePullRequest = exports.createBranchAndCommit = void 0;
80603+
exports.findExistingPullRequest = exports.createOrUpdatePullRequest = exports.pushBranch = exports.createBranchAndCommit = void 0;
8052480604
var branch_1 = __nccwpck_require__(28030);
8052580605
Object.defineProperty(exports, "createBranchAndCommit", ({ enumerable: true, get: function () { return branch_1.createBranchAndCommit; } }));
80606+
Object.defineProperty(exports, "pushBranch", ({ enumerable: true, get: function () { return branch_1.pushBranch; } }));
8052680607
var pull_request_1 = __nccwpck_require__(10689);
8052780608
Object.defineProperty(exports, "createOrUpdatePullRequest", ({ enumerable: true, get: function () { return pull_request_1.createOrUpdatePullRequest; } }));
8052880609
Object.defineProperty(exports, "findExistingPullRequest", ({ enumerable: true, get: function () { return pull_request_1.findExistingPullRequest; } }));
@@ -80895,6 +80976,8 @@ function buildDependencies() {
8089580976
fetchLatestFromPythonOrg: versioning_1.fetchLatestFromPythonOrg,
8089680977
enforcePreReleaseGuard: versioning_1.enforcePreReleaseGuard,
8089780978
fetchRunnerAvailability: versioning_1.fetchRunnerAvailability,
80979+
createBranchAndCommit: git_1.createBranchAndCommit,
80980+
pushBranch: git_1.pushBranch,
8089880981
findExistingPullRequest: git_1.findExistingPullRequest,
8089980982
createOrUpdatePullRequest: git_1.createOrUpdatePullRequest,
8090080983
fetchReleaseNotes: versioning_1.fetchReleaseNotes,

src/action-execution.ts

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { readFile, writeFile } from 'node:fs/promises';
2+
import path from 'node:path';
13
import {
24
determineSingleTrack,
35
type TrackAlignmentResult,
@@ -12,7 +14,13 @@ import {
1214
resolveLatestPatch,
1315
type LatestPatchResult,
1416
} from './versioning';
15-
import { createOrUpdatePullRequest, findExistingPullRequest, type PullRequestResult } from './git';
17+
import {
18+
createBranchAndCommit,
19+
createOrUpdatePullRequest,
20+
findExistingPullRequest,
21+
pushBranch,
22+
type PullRequestResult,
23+
} from './git';
1624
import { generatePullRequestBody } from './pr-body';
1725
import type { StableTag } from './github';
1826

@@ -54,6 +62,8 @@ export interface ExecuteDependencies {
5462
fetchLatestFromPythonOrg: typeof fetchLatestFromPythonOrg;
5563
enforcePreReleaseGuard: typeof enforcePreReleaseGuard;
5664
fetchRunnerAvailability: typeof fetchRunnerAvailability;
65+
createBranchAndCommit?: typeof createBranchAndCommit;
66+
pushBranch?: typeof pushBranch;
5767
findExistingPullRequest?: typeof findExistingPullRequest;
5868
createOrUpdatePullRequest?: typeof createOrUpdatePullRequest;
5969
fetchReleaseNotes?: typeof fetchReleaseNotes;
@@ -83,6 +93,52 @@ function uniqueFiles(matches: VersionMatch[]): string[] {
8393
return Array.from(new Set(matches.map((match) => match.file))).sort();
8494
}
8595

96+
function groupMatchesByFile(matches: VersionMatch[]): Map<string, VersionMatch[]> {
97+
const grouped = new Map<string, VersionMatch[]>();
98+
99+
for (const match of matches) {
100+
const existing = grouped.get(match.file);
101+
if (existing) {
102+
existing.push(match);
103+
} else {
104+
grouped.set(match.file, [match]);
105+
}
106+
}
107+
108+
return grouped;
109+
}
110+
111+
async function applyVersionUpdates(
112+
workspace: string,
113+
groupedMatches: Map<string, VersionMatch[]>,
114+
newVersion: string,
115+
): Promise<void> {
116+
for (const [relativePath, fileMatches] of groupedMatches) {
117+
const absolutePath = path.join(workspace, relativePath);
118+
const originalContent = await readFile(absolutePath, 'utf8');
119+
120+
const sortedMatches = [...fileMatches].sort((a, b) => b.index - a.index);
121+
122+
let updatedContent = originalContent;
123+
let changed = false;
124+
125+
for (const match of sortedMatches) {
126+
if (match.matched === newVersion) {
127+
continue;
128+
}
129+
130+
const start = match.index;
131+
const end = start + match.matched.length;
132+
updatedContent = updatedContent.slice(0, start) + newVersion + updatedContent.slice(end);
133+
changed = true;
134+
}
135+
136+
if (changed && updatedContent !== originalContent) {
137+
await writeFile(absolutePath, updatedContent, 'utf8');
138+
}
139+
}
140+
}
141+
86142
function determineMissingRunners(
87143
availability: Awaited<ReturnType<typeof fetchRunnerAvailability>>,
88144
): string[] {
@@ -273,6 +329,7 @@ export async function executeAction(
273329
}
274330

275331
const filesChanged = uniqueFiles(matchesNeedingUpdate);
332+
const groupedMatches = groupMatchesByFile(matchesNeedingUpdate);
276333

277334
if (dryRun || !allowPrCreation) {
278335
return {
@@ -283,6 +340,15 @@ export async function executeAction(
283340
} satisfies SuccessResult;
284341
}
285342

343+
if (!dependencies.createBranchAndCommit || !dependencies.pushBranch) {
344+
return {
345+
status: 'success',
346+
newVersion: latestVersion,
347+
filesChanged,
348+
dryRun: false,
349+
} satisfies SuccessResult;
350+
}
351+
286352
if (!githubToken || !repository) {
287353
return {
288354
status: 'success',
@@ -323,18 +389,41 @@ export async function executeAction(
323389
}
324390

325391
try {
392+
await applyVersionUpdates(workspace, groupedMatches, latestVersion);
393+
394+
const commitResult = await dependencies.createBranchAndCommit({
395+
repoPath: workspace,
396+
track,
397+
files: filesChanged,
398+
commitMessage: `chore: bump python ${track} to ${latestVersion}`,
399+
});
400+
401+
if (!commitResult.commitCreated) {
402+
return {
403+
status: 'success',
404+
newVersion: latestVersion,
405+
filesChanged,
406+
dryRun: false,
407+
} satisfies SuccessResult;
408+
}
409+
410+
await dependencies.pushBranch({
411+
repoPath: workspace,
412+
branch: commitResult.branch,
413+
});
414+
326415
const prBody = generatePullRequestBody({
327416
track,
328417
newVersion: latestVersion,
329418
filesChanged,
330-
branchName,
419+
branchName: commitResult.branch,
331420
defaultBranch,
332421
});
333422

334423
const pullRequest = await dependencies.createOrUpdatePullRequest({
335424
owner: repository.owner,
336425
repo: repository.repo,
337-
head: branchName,
426+
head: commitResult.branch,
338427
base: defaultBranch,
339428
title: `chore: bump python ${track} to ${latestVersion}`,
340429
body: prBody,

src/git/branch.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ export interface BranchCommitResult {
2020
filesCommitted: string[];
2121
}
2222

23+
export interface PushBranchOptions {
24+
repoPath: string;
25+
branch: string;
26+
remote?: string;
27+
forceWithLease?: boolean;
28+
setUpstream?: boolean;
29+
}
30+
2331
async function branchExists(branch: string, repoPath: string): Promise<boolean> {
2432
try {
2533
await runGit(['show-ref', '--verify', `refs/heads/${branch}`], repoPath);
@@ -105,3 +113,27 @@ export async function createBranchAndCommit(
105113

106114
return { branch, commitCreated: true, filesCommitted: stagedFiles };
107115
}
116+
117+
export async function pushBranch(options: PushBranchOptions): Promise<void> {
118+
const {
119+
repoPath,
120+
branch,
121+
remote = 'origin',
122+
forceWithLease = true,
123+
setUpstream = true,
124+
} = options;
125+
126+
const args = ['push'];
127+
128+
if (setUpstream) {
129+
args.push('--set-upstream');
130+
}
131+
132+
if (forceWithLease) {
133+
args.push('--force-with-lease');
134+
}
135+
136+
args.push(remote, branch);
137+
138+
await runGit(args, repoPath);
139+
}

src/git/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
export { createBranchAndCommit } from './branch';
2-
export type { BranchCommitOptions, BranchCommitResult } from './branch';
1+
export { createBranchAndCommit, pushBranch } from './branch';
2+
export type { BranchCommitOptions, BranchCommitResult, PushBranchOptions } from './branch';
33

44
export { createOrUpdatePullRequest, findExistingPullRequest } from './pull-request';
55
export type { PullRequestOptions, PullRequestResult, OctokitClient } from './pull-request';

0 commit comments

Comments
 (0)