Skip to content

Commit 13f622b

Browse files
Fix release proposal workflow and add post-release branch sync
- Fix version extraction to use clean versions without .devN suffix by adding get_release_version() function to towncrier scheme - Release PRs now target their source branch (main→main, develop→develop) - Update existing PRs including base branch when source changes - Fix tag patterns in python-tests.yml to match actual tag format (setuptools-scm-v*, vcs-versioning-v*) - Rewrite create-release-tags.yml to use GitHub API instead of checkout - Add branch-sync.yml workflow that triggers after successful uploads: - Verifies all releases at commit have GitHub releases - Creates sync PR from develop→main using temporary branch - Uses exact commit SHA to avoid including extra commits - Enables auto-merge for conflict-free syncs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 04b3430 commit 13f622b

File tree

6 files changed

+602
-167
lines changed

6 files changed

+602
-167
lines changed

.github/workflows/branch-sync.yml

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
name: Branch Synchronization
2+
3+
# Synchronize branches after successful releases:
4+
# - Triggers after upload jobs complete
5+
# - Verifies all expected releases for the commit succeeded
6+
# - Creates sync PRs between branches
7+
8+
on:
9+
workflow_run:
10+
workflows: ["python tests+artifacts+release"]
11+
types: [completed]
12+
branches: [main, develop]
13+
14+
permissions:
15+
contents: write
16+
pull-requests: write
17+
18+
jobs:
19+
# Verify all releases completed and sync branches
20+
sync-after-release:
21+
# Only run if the triggering workflow succeeded and was triggered by a tag push
22+
if: |
23+
github.event.workflow_run.conclusion == 'success' &&
24+
startsWith(github.event.workflow_run.head_branch, 'setuptools-scm-v') ||
25+
startsWith(github.event.workflow_run.head_branch, 'vcs-versioning-v')
26+
runs-on: ubuntu-latest
27+
steps:
28+
- name: Verify releases and create sync PR
29+
uses: actions/github-script@v8
30+
with:
31+
script: |
32+
const workflowRun = context.payload.workflow_run;
33+
const headSha = workflowRun.head_sha;
34+
const headBranch = workflowRun.head_branch;
35+
36+
console.log(`Workflow completed for: ${headBranch} at ${headSha}`);
37+
38+
// Get all tags pointing to this commit
39+
const { data: tagsResponse } = await github.rest.repos.listTags({
40+
owner: context.repo.owner,
41+
repo: context.repo.repo,
42+
per_page: 100
43+
});
44+
45+
const commitTags = tagsResponse.filter(tag => tag.commit.sha === headSha);
46+
console.log(`Tags at commit ${headSha}: ${commitTags.map(t => t.name).join(', ') || 'none'}`);
47+
48+
if (commitTags.length === 0) {
49+
console.log('No tags found at this commit, nothing to sync');
50+
return;
51+
}
52+
53+
// Check which packages have tags at this commit
54+
const setupToolsTag = commitTags.find(t => t.name.startsWith('setuptools-scm-v'));
55+
const vcsVersioningTag = commitTags.find(t => t.name.startsWith('vcs-versioning-v'));
56+
57+
console.log(`setuptools-scm tag: ${setupToolsTag?.name || 'none'}`);
58+
console.log(`vcs-versioning tag: ${vcsVersioningTag?.name || 'none'}`);
59+
60+
// Verify all expected releases have GitHub releases (created after successful upload)
61+
const releasesToVerify = [];
62+
if (setupToolsTag) releasesToVerify.push(setupToolsTag.name);
63+
if (vcsVersioningTag) releasesToVerify.push(vcsVersioningTag.name);
64+
65+
for (const tagName of releasesToVerify) {
66+
try {
67+
const { data: release } = await github.rest.repos.getReleaseByTag({
68+
owner: context.repo.owner,
69+
repo: context.repo.repo,
70+
tag: tagName
71+
});
72+
console.log(`✓ Release exists for ${tagName}: ${release.html_url}`);
73+
} catch (error) {
74+
if (error.status === 404) {
75+
console.log(`✗ Release not found for ${tagName}, waiting for all releases to complete`);
76+
return;
77+
}
78+
throw error;
79+
}
80+
}
81+
82+
console.log('All expected releases verified successfully');
83+
84+
// Determine which branch this commit is on
85+
// Check if this commit is on develop (for develop→main sync)
86+
let isDevelopRelease = false;
87+
try {
88+
const { data: developBranch } = await github.rest.repos.getBranch({
89+
owner: context.repo.owner,
90+
repo: context.repo.repo,
91+
branch: 'develop'
92+
});
93+
94+
// Check if this commit is an ancestor of develop
95+
const { data: comparison } = await github.rest.repos.compareCommits({
96+
owner: context.repo.owner,
97+
repo: context.repo.repo,
98+
base: headSha,
99+
head: 'develop'
100+
});
101+
102+
// If develop is at or ahead of this commit, it's a develop release
103+
isDevelopRelease = comparison.status === 'identical' || comparison.status === 'ahead';
104+
console.log(`Commit is on develop: ${isDevelopRelease}`);
105+
} catch (error) {
106+
if (error.status === 404) {
107+
console.log('develop branch does not exist');
108+
} else {
109+
throw error;
110+
}
111+
}
112+
113+
if (!isDevelopRelease) {
114+
console.log('This is a main branch release, no sync needed (main→develop sync happens on PR merge)');
115+
return;
116+
}
117+
118+
// For develop releases, create sync PR to main
119+
console.log('Creating sync PR from develop release to main');
120+
121+
// Use short commit SHA for branch name (simple and unique)
122+
const tempBranchName = `sync/develop-to-main-${headSha.substring(0, 8)}`;
123+
124+
console.log(`Creating temporary branch ${tempBranchName} from commit ${headSha}`);
125+
126+
// Check if the commit has changes compared to main
127+
const mainComparison = await github.rest.repos.compareCommits({
128+
owner: context.repo.owner,
129+
repo: context.repo.repo,
130+
base: 'main',
131+
head: headSha
132+
});
133+
134+
if (mainComparison.data.ahead_by === 0) {
135+
console.log('Commit has no new changes for main, skipping');
136+
return;
137+
}
138+
139+
console.log(`Commit has ${mainComparison.data.ahead_by} commits not on main`);
140+
141+
// Check for existing sync PR
142+
const existingPRs = await github.rest.pulls.list({
143+
owner: context.repo.owner,
144+
repo: context.repo.repo,
145+
state: 'open',
146+
head: `${context.repo.owner}:${tempBranchName}`,
147+
base: 'main'
148+
});
149+
150+
if (existingPRs.data.length > 0) {
151+
console.log(`Sync PR already exists: #${existingPRs.data[0].number}`);
152+
return;
153+
}
154+
155+
// Create temporary branch from the exact commit SHA
156+
try {
157+
await github.rest.git.createRef({
158+
owner: context.repo.owner,
159+
repo: context.repo.repo,
160+
ref: `refs/heads/${tempBranchName}`,
161+
sha: headSha
162+
});
163+
console.log(`Created temporary branch ${tempBranchName}`);
164+
} catch (error) {
165+
if (error.status === 422) {
166+
console.log(`Branch ${tempBranchName} already exists`);
167+
} else {
168+
throw error;
169+
}
170+
}
171+
172+
// Build release info for PR body
173+
const releaseInfo = releasesToVerify.map(tag => `- \`${tag}\``).join('\n');
174+
175+
// Build PR body
176+
const body = [
177+
'## Branch Synchronization',
178+
'',
179+
'This PR syncs the release from `develop` to `main`.',
180+
'',
181+
'**Released tags:**',
182+
releaseInfo,
183+
'',
184+
`**Commit:** ${headSha}`,
185+
`**Temporary branch:** \`${tempBranchName}\``,
186+
'',
187+
'This PR uses a temporary branch created from the exact release commit',
188+
'to ensure only the release changes are included (no extra commits).',
189+
'',
190+
'All PyPI uploads have been verified successful before creating this PR.',
191+
'',
192+
'This is an automated PR created by the branch-sync workflow.',
193+
'If there are no conflicts, this PR will be auto-merged.',
194+
'',
195+
'The temporary branch will be deleted when this PR is merged or closed.'
196+
].join('\n');
197+
198+
// Build title with tag names
199+
const title = `Sync: develop → main (${releasesToVerify.join(', ')})`;
200+
201+
// Create new sync PR from the temporary branch
202+
const { data: pr } = await github.rest.pulls.create({
203+
owner: context.repo.owner,
204+
repo: context.repo.repo,
205+
title: title,
206+
body: body,
207+
head: tempBranchName,
208+
base: 'main'
209+
});
210+
211+
console.log(`Created sync PR #${pr.number}`);
212+
213+
// Add labels
214+
await github.rest.issues.addLabels({
215+
owner: context.repo.owner,
216+
repo: context.repo.repo,
217+
issue_number: pr.number,
218+
labels: ['sync', 'auto-merge']
219+
});
220+
221+
// Try to enable auto-merge
222+
try {
223+
await github.graphql(`
224+
mutation($pullRequestId: ID!) {
225+
enablePullRequestAutoMerge(input: {pullRequestId: $pullRequestId, mergeMethod: MERGE}) {
226+
pullRequest {
227+
autoMergeRequest {
228+
enabledAt
229+
}
230+
}
231+
}
232+
}
233+
`, {
234+
pullRequestId: pr.node_id
235+
});
236+
console.log('Auto-merge enabled');
237+
} catch (error) {
238+
console.log('Could not enable auto-merge (may require branch protection rules):', error.message);
239+
}
240+
241+
# Forward-port: main → develop
242+
# When a release PR is merged to main, create PR to keep develop in sync
243+
sync-main-to-develop:
244+
if: |
245+
github.event.workflow_run.conclusion == 'success' &&
246+
(startsWith(github.event.workflow_run.head_branch, 'setuptools-scm-v') ||
247+
startsWith(github.event.workflow_run.head_branch, 'vcs-versioning-v'))
248+
runs-on: ubuntu-latest
249+
steps:
250+
- name: Create PR main → develop if needed
251+
uses: actions/github-script@v8
252+
with:
253+
script: |
254+
const workflowRun = context.payload.workflow_run;
255+
const headSha = workflowRun.head_sha;
256+
257+
// Check if develop branch exists
258+
let developExists = true;
259+
try {
260+
await github.rest.repos.getBranch({
261+
owner: context.repo.owner,
262+
repo: context.repo.repo,
263+
branch: 'develop'
264+
});
265+
} catch (error) {
266+
if (error.status === 404) {
267+
console.log('develop branch does not exist, skipping');
268+
developExists = false;
269+
} else {
270+
throw error;
271+
}
272+
}
273+
274+
if (!developExists) return;
275+
276+
// Check if this commit is on main but not on develop
277+
const { data: mainBranch } = await github.rest.repos.getBranch({
278+
owner: context.repo.owner,
279+
repo: context.repo.repo,
280+
branch: 'main'
281+
});
282+
283+
// Check if commit is an ancestor of main
284+
let isMainRelease = false;
285+
try {
286+
const { data: comparison } = await github.rest.repos.compareCommits({
287+
owner: context.repo.owner,
288+
repo: context.repo.repo,
289+
base: headSha,
290+
head: 'main'
291+
});
292+
isMainRelease = comparison.status === 'identical' || comparison.status === 'ahead';
293+
} catch {
294+
isMainRelease = false;
295+
}
296+
297+
if (!isMainRelease) {
298+
console.log('This is not a main branch release, skipping main→develop sync');
299+
return;
300+
}
301+
302+
// Check if main has commits that develop doesn't have
303+
const comparison = await github.rest.repos.compareCommits({
304+
owner: context.repo.owner,
305+
repo: context.repo.repo,
306+
base: 'develop',
307+
head: 'main'
308+
});
309+
310+
if (comparison.data.ahead_by === 0) {
311+
console.log('main has no new commits for develop, skipping');
312+
return;
313+
}
314+
315+
console.log(`main has ${comparison.data.ahead_by} commits not on develop`);
316+
317+
// Check for existing sync PR
318+
const existingPRs = await github.rest.pulls.list({
319+
owner: context.repo.owner,
320+
repo: context.repo.repo,
321+
state: 'open',
322+
head: `${context.repo.owner}:main`,
323+
base: 'develop'
324+
});
325+
326+
if (existingPRs.data.length > 0) {
327+
console.log(`Sync PR already exists: #${existingPRs.data[0].number}`);
328+
return;
329+
}
330+
331+
// Build PR body
332+
const body = [
333+
'## Branch Synchronization',
334+
'',
335+
'This PR syncs the release from `main` to `develop`.',
336+
'',
337+
`**Commit:** ${headSha}`,
338+
'',
339+
'This is an automated PR created by the branch-sync workflow.',
340+
'Review and merge to keep `develop` up to date with `main`.'
341+
].join('\n');
342+
343+
// Create new sync PR
344+
const { data: pr } = await github.rest.pulls.create({
345+
owner: context.repo.owner,
346+
repo: context.repo.repo,
347+
title: `Sync: main → develop`,
348+
body: body,
349+
head: 'main',
350+
base: 'develop'
351+
});
352+
353+
console.log(`Created sync PR #${pr.number}`);
354+
355+
// Add label
356+
await github.rest.issues.addLabels({
357+
owner: context.repo.owner,
358+
repo: context.repo.repo,
359+
issue_number: pr.number,
360+
labels: ['sync']
361+
});

0 commit comments

Comments
 (0)