diff --git a/.github/actions/github/branch-protection/lock/action.yml b/.github/actions/github/branch-protection/lock/action.yml index fa7a96e1..2cc990d6 100644 --- a/.github/actions/github/branch-protection/lock/action.yml +++ b/.github/actions/github/branch-protection/lock/action.yml @@ -1,6 +1,6 @@ name: 'Lock branch' author: 'Pete Sramek' -description: 'Apply branch protection to prevent direct pushes. Requires PRs with at least one approval.' +description: 'Apply branch protection to prevent direct pushes. Requires PRs with configurable approval count; admins can optionally bypass all restrictions.' inputs: branch: description: 'Branch name to lock.' @@ -9,7 +9,23 @@ inputs: description: 'GitHub token with administration:write (repo admin) permission. Use a PAT; GITHUB_TOKEN cannot call the branch protection API.' required: true lock-branch: - description: 'When true, sets lock_branch to prevent even PR merges (use during automated operations). When false (default), only direct pushes are blocked; PRs with required reviews can still be merged.' + description: 'When true, sets lock_branch to prevent even PR merges (use during automated operations). When false (default), only direct pushes are blocked; PRs can still be merged.' + required: false + default: 'false' + required-approving-review-count: + description: 'Number of approving reviews required before a PR can be merged. Set to 0 to require PRs without requiring approvals.' + required: false + default: '1' + dismiss-stale-reviews: + description: 'When true, approved reviews are dismissed when new commits are pushed to the branch.' + required: false + default: 'true' + bypass-admins: + description: 'When true, repository admins are exempt from all branch protection rules (enforce_admins is disabled). When false (default), admins are also subject to the rules.' + required: false + default: 'false' + skip-pull-request-reviews: + description: 'When true, sets required_pull_request_reviews to null (removes PR review requirement). Use temporarily before automated merges so the merge API is not blocked. When false (default), PR reviews are required as configured.' required: false default: 'false' @@ -21,23 +37,32 @@ runs: env: GH_TOKEN: ${{ inputs.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": ${{ inputs.lock-branch }} - } - EOF - then + ENFORCE_ADMINS=true + if [ '${{ inputs.bypass-admins }}' = 'true' ]; then + ENFORCE_ADMINS=false + fi + + PAYLOAD=$(jq -n \ + --argjson review_count '${{ inputs.required-approving-review-count }}' \ + --argjson dismiss_stale '${{ inputs.dismiss-stale-reviews }}' \ + --argjson enforce_admins "$ENFORCE_ADMINS" \ + --argjson lock_branch '${{ inputs.lock-branch }}' \ + --argjson skip_reviews '${{ inputs.skip-pull-request-reviews }}' \ + '{ + "required_status_checks": null, + "enforce_admins": $enforce_admins, + "required_pull_request_reviews": (if $skip_reviews then null else { + "dismiss_stale_reviews": $dismiss_stale, + "require_code_owner_reviews": false, + "required_approving_review_count": $review_count + } end), + "restrictions": null, + "allow_force_pushes": false, + "allow_deletions": false, + "lock_branch": $lock_branch + }') + + if ! echo "$PAYLOAD" | gh api --method PUT /repos/${{ github.repository }}/branches/${{ inputs.branch }}/protection --input -; 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 diff --git a/.github/actions/github/branch-protection/unlock/action.yml b/.github/actions/github/branch-protection/unlock/action.yml index 9c41d395..1f21af0a 100644 --- a/.github/actions/github/branch-protection/unlock/action.yml +++ b/.github/actions/github/branch-protection/unlock/action.yml @@ -8,6 +8,10 @@ inputs: token: description: 'GitHub token with administration:write (repo admin) permission. Use a PAT; GITHUB_TOKEN cannot call the branch protection API.' required: true + fail-on-error: + description: 'When true, the step fails if branch protection cannot be removed. When false (default), errors are silently ignored.' + required: false + default: 'false' runs: using: composite @@ -17,5 +21,9 @@ runs: env: GH_TOKEN: ${{ inputs.token }} run: | - gh api --method DELETE /repos/${{ github.repository }}/branches/${{ inputs.branch }}/protection || true + if [ '${{ inputs.fail-on-error }}' = 'true' ]; then + gh api --method DELETE /repos/${{ github.repository }}/branches/${{ inputs.branch }}/protection + else + gh api --method DELETE /repos/${{ github.repository }}/branches/${{ inputs.branch }}/protection || true + fi echo "🔓 Branch protection removed from '${{ inputs.branch }}'." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/backtrack.yml b/.github/workflows/backtrack.yml index e15e1273..82f5483b 100644 --- a/.github/workflows/backtrack.yml +++ b/.github/workflows/backtrack.yml @@ -87,6 +87,15 @@ jobs: token: ${{ secrets.GH_ADMIN_TOKEN }} lock-branch: 'true' + - name: 'Unlock preview branch for merge' + if: ${{ steps.targets.outputs.preview-branch != '' }} + uses: './.github/actions/github/branch-protection/lock' + with: + branch: ${{ steps.targets.outputs.preview-branch }} + token: ${{ secrets.GH_ADMIN_TOKEN }} + lock-branch: 'false' + skip-pull-request-reviews: 'true' + - name: 'Backtrack: merge ${{ github.base_ref }} into ${{ steps.targets.outputs.preview-branch }}' if: ${{ steps.targets.outputs.preview-branch != '' }} env: @@ -97,6 +106,15 @@ jobs: --field head="${{ github.base_ref }}" \ --field commit_message="Backtrack: merge ${{ github.base_ref }} into ${{ steps.targets.outputs.preview-branch }}" + - name: 'Unlock develop branch for merge' + if: ${{ steps.check-develop.outputs.exists == 'true' }} + uses: './.github/actions/github/branch-protection/lock' + with: + branch: ${{ steps.targets.outputs.develop-branch }} + token: ${{ secrets.GH_ADMIN_TOKEN }} + lock-branch: 'false' + skip-pull-request-reviews: 'true' + - name: 'Backtrack: merge ${{ steps.targets.outputs.merge-source }} into ${{ steps.targets.outputs.develop-branch }}' if: ${{ steps.check-develop.outputs.exists == 'true' }} env: @@ -109,27 +127,27 @@ jobs: - name: 'Restore protection: base branch' if: ${{ always() && steps.lock-base.outcome == 'success' }} - uses: './.github/actions/github/branch-protection/lock' + uses: './.github/actions/github/branch-protection/unlock' with: branch: ${{ github.base_ref }} token: ${{ secrets.GH_ADMIN_TOKEN }} - lock-branch: 'false' + fail-on-error: 'true' - name: 'Restore protection: preview branch' if: ${{ always() && steps.lock-preview.outcome == 'success' }} - uses: './.github/actions/github/branch-protection/lock' + uses: './.github/actions/github/branch-protection/unlock' with: branch: ${{ steps.targets.outputs.preview-branch }} token: ${{ secrets.GH_ADMIN_TOKEN }} - lock-branch: 'false' + fail-on-error: 'true' - name: 'Restore protection: develop branch' if: ${{ always() && steps.lock-develop.outcome == 'success' }} - uses: './.github/actions/github/branch-protection/lock' + uses: './.github/actions/github/branch-protection/unlock' with: branch: ${{ steps.targets.outputs.develop-branch }} token: ${{ secrets.GH_ADMIN_TOKEN }} - lock-branch: 'false' + fail-on-error: 'true' - name: 'Write backtrack summary' if: ${{ always() }}