Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/actions/github/branch-protection/lock/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: 'Lock branch'
author: 'Pete Sramek'
description: 'Apply branch protection to prevent direct pushes. Requires PRs with at least one approval.'
inputs:
branch:
description: 'Branch name to lock.'
required: true

runs:
using: composite
steps:
- name: 'Lock branch ${{ inputs.branch }}'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
if ! gh api --method PUT /repos/${{ github.repository }}/branches/${{ inputs.branch }}/protection \
--input - << 'EOF'
{
"required_status_checks": null,
"enforce_admins": false,
"required_pull_request_reviews": {
"dismiss_stale_reviews": true,
"require_code_owner_reviews": false,
"required_approving_review_count": 1
},
"restrictions": null,
"allow_force_pushes": false,
"allow_deletions": false,
"lock_branch": false
}
EOF
then
echo "::error::Failed to apply branch protection to '${{ inputs.branch }}'. Ensure the token has 'administration: write' permission and the branch exists."
exit 1
fi
echo "🔒 Branch '${{ inputs.branch }}' is now protected." >> $GITHUB_STEP_SUMMARY
18 changes: 18 additions & 0 deletions .github/actions/github/branch-protection/unlock/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: 'Unlock branch'
author: 'Pete Sramek'
description: 'Remove branch protection to allow a workflow to push directly. Always re-lock after the operation.'
inputs:
branch:
description: 'Branch name to unlock.'
required: true

runs:
using: composite
steps:
- name: 'Unlock branch ${{ inputs.branch }}'
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api --method DELETE /repos/${{ github.repository }}/branches/${{ inputs.branch }}/protection || true
echo "🔓 Branch protection removed from '${{ inputs.branch }}'." >> $GITHUB_STEP_SUMMARY
157 changes: 157 additions & 0 deletions .github/workflows/bump-version.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
name: 'Bump version'

on:
workflow_dispatch:
inputs:
bump-type:
type: choice
options:
- 'minor'
- 'major'
description: 'Version bump type. Use ''minor'' to add features (keeps API files), ''major'' for breaking changes (resets API files).'
required: true

permissions:
actions: read
contents: write
pull-requests: write
administration: write

concurrency:
group: bump-version
cancel-in-progress: false

env:
dotnet-sdk-version: '10.x'

jobs:
detect-version:
name: 'Detect current version and calculate next'
runs-on: ubuntu-latest
outputs:
current-version: ${{ steps.detect.outputs.current-version }}
next-version: ${{ steps.calculate.outputs.next-version }}
target-branch: ${{ steps.calculate.outputs.target-branch }}
steps:
- name: 'Checkout main'
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0

- name: 'Detect highest release branch'
id: detect
run: |
git fetch origin
latest=$(git ls-remote --heads origin 'release/*' | grep -oP 'release/\K\d+\.\d+' | sort -V | tail -1)
if [[ -z "$latest" ]]; then
latest="0.0"
fi
echo "Detected current version: $latest"
echo "current-version=$latest" >> $GITHUB_OUTPUT

- name: 'Calculate next version'
id: calculate
run: |
current="${{ steps.detect.outputs.current-version }}"
major="${current%%.*}"
minor="${current##*.}"
if [[ "${{ inputs.bump-type }}" == "major" ]]; then
next_major=$((major + 1))
next_version="${next_major}.0"
else
next_minor=$((minor + 1))
next_version="${major}.${next_minor}"
fi
echo "Next version: $next_version"
echo "next-version=$next_version" >> $GITHUB_OUTPUT
echo "target-branch=develop/${next_version}" >> $GITHUB_OUTPUT

validate:
name: 'Validate bump'
needs: [detect-version]
runs-on: ubuntu-latest
steps:
- name: 'Checkout main'
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 1

- name: 'Validate workflow is running from main'
if: ${{ github.ref_name != 'main' }}
run: |
echo "This workflow must be run from the 'main' branch. Current branch: '${{ github.ref_name }}'."
exit 1

- name: 'Validate target branch does not exist'
run: |
set +e
git ls-remote --exit-code --heads origin "${{ needs.detect-version.outputs.target-branch }}"
if [[ $? -eq 0 ]]; then
echo "Target branch '${{ needs.detect-version.outputs.target-branch }}' already exists."
exit 1
fi
set -e

create-branch:
name: 'Create ${{ needs.detect-version.outputs.target-branch }}'
needs: [detect-version, validate]
runs-on: ubuntu-latest
env:
next-version: ${{ needs.detect-version.outputs.next-version }}
target-branch: ${{ needs.detect-version.outputs.target-branch }}
steps:
- name: 'Checkout main'
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0

- name: 'Setup .NET ${{ env.dotnet-sdk-version }}'
uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.dotnet-sdk-version }}

- name: 'Configure git'
run: |
git config user.name "$(git log -n 1 --pretty=format:%an)"
git config user.email "$(git log -n 1 --pretty=format:%ae)"

- name: 'Create develop branch from main'
run: |
git checkout -b ${{ env.target-branch }}

- name: 'Reset PublicAPI files for major bump'
if: ${{ inputs.bump-type == 'major' }}
run: |
find . \( -name "PublicAPI.Shipped.txt" -o -name "PublicAPI.Unshipped.txt" \) | while read file; do
printf '\xef\xbb\xbf#nullable enable\n' > "$file"
echo "Reset: $file"
done

- name: 'Commit API file reset'
if: ${{ inputs.bump-type == 'major' }}
run: |
git add .
git diff --staged --quiet || git commit -m "Reset PublicAPI files for major version ${{ env.next-version }}"

- name: 'Push develop branch'
run: |
git push --set-upstream origin ${{ env.target-branch }}

- name: 'Write summary'
run: |
echo "## ✅ Branch created: \`${{ env.target-branch }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| | |" >> $GITHUB_STEP_SUMMARY
echo "|---|---|" >> $GITHUB_STEP_SUMMARY
echo "| **Bump type** | ${{ inputs.bump-type }} |" >> $GITHUB_STEP_SUMMARY
echo "| **Previous version** | ${{ needs.detect-version.outputs.current-version }} |" >> $GITHUB_STEP_SUMMARY
echo "| **New version** | ${{ env.next-version }} |" >> $GITHUB_STEP_SUMMARY
echo "| **Target branch** | \`${{ env.target-branch }}\` |" >> $GITHUB_STEP_SUMMARY
if [[ "${{ inputs.bump-type }}" == "major" ]]; then
echo "| **API files** | Reset (major bump) |" >> $GITHUB_STEP_SUMMARY
else
echo "| **API files** | Preserved (minor bump) |" >> $GITHUB_STEP_SUMMARY
fi
18 changes: 15 additions & 3 deletions .github/workflows/promote-branch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ permissions:
id-token: write
contents: write
pull-requests: write
administration: write

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

jobs:
versioning:
Expand Down Expand Up @@ -159,10 +161,15 @@ jobs:
run: |
echo "Invalid promotion type ${{ inputs.promotion-type }}"
exit 1
- name: 'Validate current branch'
if: ${{ (env.is-development-branch == 'false') && (env.is-maintenance-branch == 'false') }}
- name: 'Validate source branch for preview promotion'
if: ${{ env.promotion-type == 'preview' && (env.is-development-branch == 'false') && (env.is-maintenance-branch == 'false') }}
run: |
echo "Invalid current branch '${{ github.ref_name }}'"
echo "Preview promotion requires a 'develop/**' or 'support/**' source branch. Current branch: '${{ github.ref_name }}'"
exit 1
- name: 'Validate source branch for release promotion'
if: ${{ env.promotion-type == 'release' && env.is-preview-branch == 'false' }}
run: |
echo "Release promotion requires a 'preview/**' source branch. Current branch: '${{ github.ref_name }}'"
exit 1
- name: 'Validate default and current branch'
if: ${{ env.base-branch == env.current-branch }}
Expand Down Expand Up @@ -206,6 +213,11 @@ jobs:
git switch ${{ needs.workflow-variables.outputs.base-branch }}
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 }}
git push --set-upstream origin ${{ needs.workflow-variables.outputs.target-branch }}
- name: 'Lock target branch'
if: ${{ needs.workflow-variables.outputs.target-branch-exists == 'false' }}
uses: './.github/actions/github/branch-protection/lock'
with:
branch: ${{ needs.workflow-variables.outputs.target-branch }}
- name: 'Create PR: "Promote ${{ env.current-branch }} to ${{ env.target-branch }}"'
if: ${{ needs.workflow-variables.outputs.pull-request-exists == 'false' }}
env:
Expand Down
57 changes: 57 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,60 @@ jobs:
release-version: ${{ env.release-version }}
is-preview: ${{ env.is-preview }}
notes-start-tag: ${{ steps.determine-notes-start-tag.outputs.notes-start-tag }}

merge-to-main:
name: 'Merge ${{ github.ref_name }} into main'
needs: [workflow-variables, release, versioning]
if: ${{ needs.workflow-variables.outputs.is-release == 'true' }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
current-branch: ${{ github.ref_name }}
current-version: ${{ needs.versioning.outputs.friendly-version }}
steps:
- name: 'Checkout ${{ github.head_ref || github.ref }}'
uses: actions/checkout@v6
with:
fetch-depth: 0

- name: 'Detect if current branch is the latest release'
id: detect-latest
run: |
git fetch origin
latest_version=$(git ls-remote --heads origin 'release/*' | grep -oP 'release/\K\d+\.\d+' | sort -V | tail -1)
current_version=$(echo "${{ env.current-version }}" | grep -oP '^\d+\.\d+')
echo "Latest release branch version: $latest_version"
echo "Current version (normalized): $current_version"
if [[ "$latest_version" == "$current_version" ]]; then
echo "is-latest=true" >> $GITHUB_OUTPUT
else
echo "is-latest=false" >> $GITHUB_OUTPUT
fi

- name: 'Check if PR to main already exists'
id: check-pr
if: ${{ steps.detect-latest.outputs.is-latest == 'true' }}
run: |
pr_count=$(gh pr list --head "${{ env.current-branch }}" --base main --state open --limit 1 --json id --jq '. | length')
if [[ $pr_count -gt 0 ]]; then
echo "pr-exists=true" >> $GITHUB_OUTPUT
else
echo "pr-exists=false" >> $GITHUB_OUTPUT
fi

- name: 'Create PR: Merge ${{ env.current-branch }} into main'
if: ${{ steps.detect-latest.outputs.is-latest == 'true' && steps.check-pr.outputs.pr-exists == 'false' }}
run: |
gh pr create \
--title "Merge ${{ env.current-branch }} into main" \
--fill \
--base main \
--head "${{ env.current-branch }}"

- name: 'Write merge summary'
run: |
if [[ "${{ steps.detect-latest.outputs.is-latest }}" == "true" ]]; then
echo "✅ PR created to merge **${{ env.current-branch }}** into **main**." >> $GITHUB_STEP_SUMMARY
else
echo "⏭️ Skipped merge to main: **${{ env.current-branch }}** is not the highest release branch." >> $GITHUB_STEP_SUMMARY
fi
Loading