Skip to content

Commit 6e0a0c7

Browse files
Copilotpetesramek
andauthored
feat: auto merge-to-main, bump-version workflow, and branch locking (#154)
The release-to-main promotion was manual and error-prone, with no way to determine which `release/**` branch is "latest" without scanning all of them. Additionally, there was no automated way to start a new major/minor version cycle or enforce push restrictions on protected branches. ## merge-to-main (automatic) - Added `merge-to-main` job to `release.yml`, runs only on `release/**` after GitHub Release is created - Finds the highest `release/**` branch via `git ls-remote | sort -V | tail -1` - Normalizes both versions to `M.m` before comparing — only the latest branch triggers a PR to `main` - Skips silently with a step summary note if not the latest (e.g. hotfix on an older branch) ## bump-version workflow - New `workflow_dispatch` workflow (`bump-type: minor | major`), must run from `main` - Auto-detects current version from highest `release/**` branch; calculates next `M.m` - Creates `develop/M.m` from `main` - **Major bump**: resets all `PublicAPI.Shipped.txt` and `PublicAPI.Unshipped.txt` to `#nullable enable` — fresh API surface for breaking-change cycles - **Minor bump**: preserves API files — existing shipped API remains the baseline ## Branch locking - New composite actions: `branch-protection/lock` (sets PR-required + no force-push + no deletions via GitHub API) and `branch-protection/unlock` (removes protection) - `promote-branch.yml` now locks newly created `preview/**` and `release/**` branches immediately after the initial push - Added `administration: write` permission to `promote-branch.yml` ## promote-branch source-branch enforcement - Previously, any `develop/**` or `support/**` branch could be promoted directly to `release/**` - Now validates per promotion type: `preview` ← `develop/**`/`support/**`; `release` ← `preview/**` only --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: petesramek <2333452+petesramek@users.noreply.github.com>
1 parent d7bee6d commit 6e0a0c7

5 files changed

Lines changed: 284 additions & 3 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: 'Lock branch'
2+
author: 'Pete Sramek'
3+
description: 'Apply branch protection to prevent direct pushes. Requires PRs with at least one approval.'
4+
inputs:
5+
branch:
6+
description: 'Branch name to lock.'
7+
required: true
8+
9+
runs:
10+
using: composite
11+
steps:
12+
- name: 'Lock branch ${{ inputs.branch }}'
13+
shell: bash
14+
env:
15+
GH_TOKEN: ${{ github.token }}
16+
run: |
17+
if ! gh api --method PUT /repos/${{ github.repository }}/branches/${{ inputs.branch }}/protection \
18+
--input - << 'EOF'
19+
{
20+
"required_status_checks": null,
21+
"enforce_admins": false,
22+
"required_pull_request_reviews": {
23+
"dismiss_stale_reviews": true,
24+
"require_code_owner_reviews": false,
25+
"required_approving_review_count": 1
26+
},
27+
"restrictions": null,
28+
"allow_force_pushes": false,
29+
"allow_deletions": false,
30+
"lock_branch": false
31+
}
32+
EOF
33+
then
34+
echo "::error::Failed to apply branch protection to '${{ inputs.branch }}'. Ensure the token has 'administration: write' permission and the branch exists."
35+
exit 1
36+
fi
37+
echo "🔒 Branch '${{ inputs.branch }}' is now protected." >> $GITHUB_STEP_SUMMARY
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
name: 'Unlock branch'
2+
author: 'Pete Sramek'
3+
description: 'Remove branch protection to allow a workflow to push directly. Always re-lock after the operation.'
4+
inputs:
5+
branch:
6+
description: 'Branch name to unlock.'
7+
required: true
8+
9+
runs:
10+
using: composite
11+
steps:
12+
- name: 'Unlock branch ${{ inputs.branch }}'
13+
shell: bash
14+
env:
15+
GH_TOKEN: ${{ github.token }}
16+
run: |
17+
gh api --method DELETE /repos/${{ github.repository }}/branches/${{ inputs.branch }}/protection || true
18+
echo "🔓 Branch protection removed from '${{ inputs.branch }}'." >> $GITHUB_STEP_SUMMARY

.github/workflows/bump-version.yml

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
name: 'Bump version'
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
bump-type:
7+
type: choice
8+
options:
9+
- 'minor'
10+
- 'major'
11+
description: 'Version bump type. Use ''minor'' to add features (keeps API files), ''major'' for breaking changes (resets API files).'
12+
required: true
13+
14+
permissions:
15+
actions: read
16+
contents: write
17+
pull-requests: write
18+
administration: write
19+
20+
concurrency:
21+
group: bump-version
22+
cancel-in-progress: false
23+
24+
env:
25+
dotnet-sdk-version: '10.x'
26+
27+
jobs:
28+
detect-version:
29+
name: 'Detect current version and calculate next'
30+
runs-on: ubuntu-latest
31+
outputs:
32+
current-version: ${{ steps.detect.outputs.current-version }}
33+
next-version: ${{ steps.calculate.outputs.next-version }}
34+
target-branch: ${{ steps.calculate.outputs.target-branch }}
35+
steps:
36+
- name: 'Checkout main'
37+
uses: actions/checkout@v6
38+
with:
39+
ref: main
40+
fetch-depth: 0
41+
42+
- name: 'Detect highest release branch'
43+
id: detect
44+
run: |
45+
git fetch origin
46+
latest=$(git ls-remote --heads origin 'release/*' | grep -oP 'release/\K\d+\.\d+' | sort -V | tail -1)
47+
if [[ -z "$latest" ]]; then
48+
latest="0.0"
49+
fi
50+
echo "Detected current version: $latest"
51+
echo "current-version=$latest" >> $GITHUB_OUTPUT
52+
53+
- name: 'Calculate next version'
54+
id: calculate
55+
run: |
56+
current="${{ steps.detect.outputs.current-version }}"
57+
major="${current%%.*}"
58+
minor="${current##*.}"
59+
if [[ "${{ inputs.bump-type }}" == "major" ]]; then
60+
next_major=$((major + 1))
61+
next_version="${next_major}.0"
62+
else
63+
next_minor=$((minor + 1))
64+
next_version="${major}.${next_minor}"
65+
fi
66+
echo "Next version: $next_version"
67+
echo "next-version=$next_version" >> $GITHUB_OUTPUT
68+
echo "target-branch=develop/${next_version}" >> $GITHUB_OUTPUT
69+
70+
validate:
71+
name: 'Validate bump'
72+
needs: [detect-version]
73+
runs-on: ubuntu-latest
74+
steps:
75+
- name: 'Checkout main'
76+
uses: actions/checkout@v6
77+
with:
78+
ref: main
79+
fetch-depth: 1
80+
81+
- name: 'Validate workflow is running from main'
82+
if: ${{ github.ref_name != 'main' }}
83+
run: |
84+
echo "This workflow must be run from the 'main' branch. Current branch: '${{ github.ref_name }}'."
85+
exit 1
86+
87+
- name: 'Validate target branch does not exist'
88+
run: |
89+
set +e
90+
git ls-remote --exit-code --heads origin "${{ needs.detect-version.outputs.target-branch }}"
91+
if [[ $? -eq 0 ]]; then
92+
echo "Target branch '${{ needs.detect-version.outputs.target-branch }}' already exists."
93+
exit 1
94+
fi
95+
set -e
96+
97+
create-branch:
98+
name: 'Create ${{ needs.detect-version.outputs.target-branch }}'
99+
needs: [detect-version, validate]
100+
runs-on: ubuntu-latest
101+
env:
102+
next-version: ${{ needs.detect-version.outputs.next-version }}
103+
target-branch: ${{ needs.detect-version.outputs.target-branch }}
104+
steps:
105+
- name: 'Checkout main'
106+
uses: actions/checkout@v6
107+
with:
108+
ref: main
109+
fetch-depth: 0
110+
111+
- name: 'Setup .NET ${{ env.dotnet-sdk-version }}'
112+
uses: actions/setup-dotnet@v5
113+
with:
114+
dotnet-version: ${{ env.dotnet-sdk-version }}
115+
116+
- name: 'Configure git'
117+
run: |
118+
git config user.name "$(git log -n 1 --pretty=format:%an)"
119+
git config user.email "$(git log -n 1 --pretty=format:%ae)"
120+
121+
- name: 'Create develop branch from main'
122+
run: |
123+
git checkout -b ${{ env.target-branch }}
124+
125+
- name: 'Reset PublicAPI files for major bump'
126+
if: ${{ inputs.bump-type == 'major' }}
127+
run: |
128+
find . \( -name "PublicAPI.Shipped.txt" -o -name "PublicAPI.Unshipped.txt" \) | while read file; do
129+
printf '\xef\xbb\xbf#nullable enable\n' > "$file"
130+
echo "Reset: $file"
131+
done
132+
133+
- name: 'Commit API file reset'
134+
if: ${{ inputs.bump-type == 'major' }}
135+
run: |
136+
git add .
137+
git diff --staged --quiet || git commit -m "Reset PublicAPI files for major version ${{ env.next-version }}"
138+
139+
- name: 'Push develop branch'
140+
run: |
141+
git push --set-upstream origin ${{ env.target-branch }}
142+
143+
- name: 'Write summary'
144+
run: |
145+
echo "## ✅ Branch created: \`${{ env.target-branch }}\`" >> $GITHUB_STEP_SUMMARY
146+
echo "" >> $GITHUB_STEP_SUMMARY
147+
echo "| | |" >> $GITHUB_STEP_SUMMARY
148+
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
149+
echo "| **Bump type** | ${{ inputs.bump-type }} |" >> $GITHUB_STEP_SUMMARY
150+
echo "| **Previous version** | ${{ needs.detect-version.outputs.current-version }} |" >> $GITHUB_STEP_SUMMARY
151+
echo "| **New version** | ${{ env.next-version }} |" >> $GITHUB_STEP_SUMMARY
152+
echo "| **Target branch** | \`${{ env.target-branch }}\` |" >> $GITHUB_STEP_SUMMARY
153+
if [[ "${{ inputs.bump-type }}" == "major" ]]; then
154+
echo "| **API files** | Reset (major bump) |" >> $GITHUB_STEP_SUMMARY
155+
else
156+
echo "| **API files** | Preserved (minor bump) |" >> $GITHUB_STEP_SUMMARY
157+
fi

.github/workflows/promote-branch.yml

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ permissions:
2121
id-token: write
2222
contents: write
2323
pull-requests: write
24+
administration: write
2425

2526
concurrency:
2627
group: 'promote-branch-${{ inputs.promotion-type }}-${{github.ref_name }}'
@@ -30,6 +31,7 @@ env:
3031
dotnet-sdk-version: '10.x'
3132
is-development-branch: ${{ startsWith(github.ref_name, 'develop') }}
3233
is-maintenance-branch: ${{ startsWith(github.ref_name, 'support') }}
34+
is-preview-branch: ${{ startsWith(github.ref_name, 'preview') }}
3335

3436
jobs:
3537
versioning:
@@ -159,10 +161,15 @@ jobs:
159161
run: |
160162
echo "Invalid promotion type ${{ inputs.promotion-type }}"
161163
exit 1
162-
- name: 'Validate current branch'
163-
if: ${{ (env.is-development-branch == 'false') && (env.is-maintenance-branch == 'false') }}
164+
- name: 'Validate source branch for preview promotion'
165+
if: ${{ env.promotion-type == 'preview' && (env.is-development-branch == 'false') && (env.is-maintenance-branch == 'false') }}
164166
run: |
165-
echo "Invalid current branch '${{ github.ref_name }}'"
167+
echo "Preview promotion requires a 'develop/**' or 'support/**' source branch. Current branch: '${{ github.ref_name }}'"
168+
exit 1
169+
- name: 'Validate source branch for release promotion'
170+
if: ${{ env.promotion-type == 'release' && env.is-preview-branch == 'false' }}
171+
run: |
172+
echo "Release promotion requires a 'preview/**' source branch. Current branch: '${{ github.ref_name }}'"
166173
exit 1
167174
- name: 'Validate default and current branch'
168175
if: ${{ env.base-branch == env.current-branch }}
@@ -206,6 +213,11 @@ jobs:
206213
git switch ${{ needs.workflow-variables.outputs.base-branch }}
207214
git checkout -b ${{ needs.workflow-variables.outputs.target-branch }} origin/${{ needs.workflow-variables.outputs.target-branch }} || git checkout -b ${{ needs.workflow-variables.outputs.target-branch }}
208215
git push --set-upstream origin ${{ needs.workflow-variables.outputs.target-branch }}
216+
- name: 'Lock target branch'
217+
if: ${{ needs.workflow-variables.outputs.target-branch-exists == 'false' }}
218+
uses: './.github/actions/github/branch-protection/lock'
219+
with:
220+
branch: ${{ needs.workflow-variables.outputs.target-branch }}
209221
- name: 'Create PR: "Promote ${{ env.current-branch }} to ${{ env.target-branch }}"'
210222
if: ${{ needs.workflow-variables.outputs.pull-request-exists == 'false' }}
211223
env:

.github/workflows/release.yml

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,60 @@ jobs:
257257
release-version: ${{ env.release-version }}
258258
is-preview: ${{ env.is-preview }}
259259
notes-start-tag: ${{ steps.determine-notes-start-tag.outputs.notes-start-tag }}
260+
261+
merge-to-main:
262+
name: 'Merge ${{ github.ref_name }} into main'
263+
needs: [workflow-variables, release, versioning]
264+
if: ${{ needs.workflow-variables.outputs.is-release == 'true' }}
265+
runs-on: ubuntu-latest
266+
env:
267+
GH_TOKEN: ${{ github.token }}
268+
current-branch: ${{ github.ref_name }}
269+
current-version: ${{ needs.versioning.outputs.friendly-version }}
270+
steps:
271+
- name: 'Checkout ${{ github.head_ref || github.ref }}'
272+
uses: actions/checkout@v6
273+
with:
274+
fetch-depth: 0
275+
276+
- name: 'Detect if current branch is the latest release'
277+
id: detect-latest
278+
run: |
279+
git fetch origin
280+
latest_version=$(git ls-remote --heads origin 'release/*' | grep -oP 'release/\K\d+\.\d+' | sort -V | tail -1)
281+
current_version=$(echo "${{ env.current-version }}" | grep -oP '^\d+\.\d+')
282+
echo "Latest release branch version: $latest_version"
283+
echo "Current version (normalized): $current_version"
284+
if [[ "$latest_version" == "$current_version" ]]; then
285+
echo "is-latest=true" >> $GITHUB_OUTPUT
286+
else
287+
echo "is-latest=false" >> $GITHUB_OUTPUT
288+
fi
289+
290+
- name: 'Check if PR to main already exists'
291+
id: check-pr
292+
if: ${{ steps.detect-latest.outputs.is-latest == 'true' }}
293+
run: |
294+
pr_count=$(gh pr list --head "${{ env.current-branch }}" --base main --state open --limit 1 --json id --jq '. | length')
295+
if [[ $pr_count -gt 0 ]]; then
296+
echo "pr-exists=true" >> $GITHUB_OUTPUT
297+
else
298+
echo "pr-exists=false" >> $GITHUB_OUTPUT
299+
fi
300+
301+
- name: 'Create PR: Merge ${{ env.current-branch }} into main'
302+
if: ${{ steps.detect-latest.outputs.is-latest == 'true' && steps.check-pr.outputs.pr-exists == 'false' }}
303+
run: |
304+
gh pr create \
305+
--title "Merge ${{ env.current-branch }} into main" \
306+
--fill \
307+
--base main \
308+
--head "${{ env.current-branch }}"
309+
310+
- name: 'Write merge summary'
311+
run: |
312+
if [[ "${{ steps.detect-latest.outputs.is-latest }}" == "true" ]]; then
313+
echo "✅ PR created to merge **${{ env.current-branch }}** into **main**." >> $GITHUB_STEP_SUMMARY
314+
else
315+
echo "⏭️ Skipped merge to main: **${{ env.current-branch }}** is not the highest release branch." >> $GITHUB_STEP_SUMMARY
316+
fi

0 commit comments

Comments
 (0)