diff --git a/.github/actions/github/branch-protection/lock/action.yml b/.github/actions/github/branch-protection/lock/action.yml new file mode 100644 index 00000000..7a493cce --- /dev/null +++ b/.github/actions/github/branch-protection/lock/action.yml @@ -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 diff --git a/.github/actions/github/branch-protection/unlock/action.yml b/.github/actions/github/branch-protection/unlock/action.yml new file mode 100644 index 00000000..5bd37baa --- /dev/null +++ b/.github/actions/github/branch-protection/unlock/action.yml @@ -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 diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 00000000..5fa8e79f --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -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 diff --git a/.github/workflows/promote-branch.yml b/.github/workflows/promote-branch.yml index 03df4b47..645d712c 100644 --- a/.github/workflows/promote-branch.yml +++ b/.github/workflows/promote-branch.yml @@ -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 }}' @@ -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: @@ -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 }} @@ -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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 30379531..cfdc85a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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