diff --git a/.github/workflows/branch-protection-rule.yaml b/.github/workflows/branch-protection-rule.yaml new file mode 100644 index 00000000..6fa9ef61 --- /dev/null +++ b/.github/workflows/branch-protection-rule.yaml @@ -0,0 +1,102 @@ +name: Apply Branch Protection Rules + +on: + workflow_dispatch: + inputs: + mode: + description: "single = one repo | bulk = all repos in branch-protection.txt" + required: true + type: choice + options: + - single + - bulk + default: single + + target_repo: + description: "[single mode] Repo to protect e.g. mosip/" + required: false + type: string + default: "" + + branch_pattern: + description: "[single mode] Branch to protect e.g. release-1.2.x" + required: false + type: string + default: "" + + rules: + description: "Choose which branch protection rules to apply" + required: true + type: choice + options: + - default + - custom + default: default + +jobs: + resolve-config: + name: "Resolve config repo details" + runs-on: ubuntu-latest + outputs: + config_repo: ${{ steps.info.outputs.config_repo }} + config_repo_ref: ${{ steps.info.outputs.config_repo_ref }} + steps: + - name: Capture repo and branch + id: info + run: | + echo "config_repo=${{ github.repository }}" >> $GITHUB_OUTPUT + echo "config_repo_ref=${{ github.ref_name }}" >> $GITHUB_OUTPUT + + single-repo: + name: "Single -> ${{ inputs.target_repo }} @ ${{ inputs.branch_pattern }}" + needs: resolve-config + if: inputs.mode == 'single' && inputs.target_repo != '' && inputs.branch_pattern != '' + uses: mosip/kattu/.github/workflows/branch-protection-rule.yaml@master-MOSIP-30370-branching-rule + with: + target_repo: ${{ inputs.target_repo }} + branch_pattern: ${{ inputs.branch_pattern }} + use_custom_rules: ${{ inputs.rules == 'custom' }} + config_repo: ${{ needs.resolve-config.outputs.config_repo }} + config_repo_ref: ${{ needs.resolve-config.outputs.config_repo_ref }} + secrets: + ACTION_PAT: ${{ secrets.ACTION_PAT }} + ALLOWED_TEAM: ${{ secrets.ALLOWED_TEAM }} + + prepare-matrix: + name: "Parse branch-protection.txt" + needs: resolve-config + if: inputs.mode == 'bulk' + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.parse.outputs.matrix }} + steps: + - name: Checkout release-script repo + uses: actions/checkout@v4 + with: + repository: ${{ needs.resolve-config.outputs.config_repo }} + ref: ${{ needs.resolve-config.outputs.config_repo_ref }} + token: ${{ secrets.ACTION_PAT }} + + - name: Parse branch-protection.txt + id: parse + run: | + echo "aW1wb3J0IGpzb24sIHN5cywgb3MKCmZpbGVwYXRoID0gInJlbGVhc2UvYnJhbmNoaW5nLXJ1bGUvYnJhbmNoLXByb3RlY3Rpb24udHh0IgoKaWYgbm90IG9zLnBhdGguZXhpc3RzKGZpbGVwYXRoKToKICAgIHByaW50KCI6OmVycm9yOjoiICsgZmlsZXBhdGggKyAiIG5vdCBmb3VuZCEiKQogICAgc3lzLmV4aXQoMSkKCmVudHJpZXMgPSBbXQpjdXJyZW50X3JlcG8gPSBOb25lCmN1cnJlbnRfYnJhbmNoID0gTm9uZQoKd2l0aCBvcGVuKGZpbGVwYXRoKSBhcyBmOgogICAgZm9yIGxpbmUgaW4gZjoKICAgICAgICBzdHJpcHBlZCA9IGxpbmUuc3RyaXAoKQogICAgICAgIGlmIHN0cmlwcGVkLnN0YXJ0c3dpdGgoIi0gcmVwbzoiKToKICAgICAgICAgICAgaWYgY3VycmVudF9yZXBvIGFuZCBjdXJyZW50X2JyYW5jaDoKICAgICAgICAgICAgICAgIGVudHJpZXMuYXBwZW5kKHsidGFyZ2V0X3JlcG8iOiBjdXJyZW50X3JlcG8sICJicmFuY2hfcGF0dGVybiI6IGN1cnJlbnRfYnJhbmNofSkKICAgICAgICAgICAgY3VycmVudF9yZXBvID0gc3RyaXBwZWQuc3BsaXQoIi0gcmVwbzoiKVsxXS5zdHJpcCgpCiAgICAgICAgICAgIGN1cnJlbnRfYnJhbmNoID0gTm9uZQogICAgICAgIGVsaWYgc3RyaXBwZWQuc3RhcnRzd2l0aCgiYnJhbmNoOiIpOgogICAgICAgICAgICBjdXJyZW50X2JyYW5jaCA9IHN0cmlwcGVkLnNwbGl0KCJicmFuY2g6IilbMV0uc3RyaXAoKQoKaWYgY3VycmVudF9yZXBvIGFuZCBjdXJyZW50X2JyYW5jaDoKICAgIGVudHJpZXMuYXBwZW5kKHsidGFyZ2V0X3JlcG8iOiBjdXJyZW50X3JlcG8sICJicmFuY2hfcGF0dGVybiI6IGN1cnJlbnRfYnJhbmNofSkKCmlmIG5vdCBlbnRyaWVzOgogICAgcHJpbnQoIjo6ZXJyb3I6Ok5vIHZhbGlkIGVudHJpZXMgZm91bmQgaW4gYnJhbmNoLXByb3RlY3Rpb24udHh0ISIpCiAgICBzeXMuZXhpdCgxKQoKcHJpbnQoIkZvdW5kICIgKyBzdHIobGVuKGVudHJpZXMpKSArICIgcmVwbyhzKSB0byBwcm90ZWN0OiIpCmZvciBlIGluIGVudHJpZXM6CiAgICBwcmludCgiICAiICsgZVsidGFyZ2V0X3JlcG8iXSArICIgIC0+ICAiICsgZVsiYnJhbmNoX3BhdHRlcm4iXSkKCndpdGggb3BlbigiL3RtcC9tYXRyaXguanNvbiIsICJ3IikgYXMgZjoKICAgIGYud3JpdGUoanNvbi5kdW1wcyh7ImluY2x1ZGUiOiBlbnRyaWVzfSkpCg==" | base64 -d > /tmp/parse_repos.py + python3 /tmp/parse_repos.py + echo "matrix=$(cat /tmp/matrix.json)" >> $GITHUB_OUTPUT + + bulk-apply: + name: "Bulk -> ${{ matrix.target_repo }} @ ${{ matrix.branch_pattern }}" + needs: [resolve-config, prepare-matrix] + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.prepare-matrix.outputs.matrix) }} + uses: mosip/kattu/.github/workflows/branch-protection-rule.yaml@master + with: + target_repo: ${{ matrix.target_repo }} + branch_pattern: ${{ matrix.branch_pattern }} + use_custom_rules: ${{ inputs.rules == 'custom' }} + config_repo: ${{ needs.resolve-config.outputs.config_repo }} + config_repo_ref: ${{ needs.resolve-config.outputs.config_repo_ref }} + secrets: + ACTION_PAT: ${{ secrets.ACTION_PAT }} + ALLOWED_TEAM: ${{ secrets.ALLOWED_TEAM }} diff --git a/release/branching-rule/README.md b/release/branching-rule/README.md new file mode 100644 index 00000000..459f1f35 --- /dev/null +++ b/release/branching-rule/README.md @@ -0,0 +1,217 @@ +# Branch Protection Rules + +Automates applying GitHub branch protection rules across personal and organization repositories using a reusable GitHub Actions workflow. + +--- + +## Repository Structure + +``` +release-script/ ← Caller repo (trigger workflow here) +├── .github/ +│ └── workflows/ +│ └── branch-protection.yaml ← Trigger this workflow +└── release/ + └── branching-rule/ + ├── branch-protection.txt ← Repo + branch list (bulk mode) + └── custom-branch-protection-rule.yaml ← Custom rules (optional) + +kattu/ ← Reusable workflow repo +└── .github/ + └── workflows/ + └── branch-protection-rule.yaml ← Central logic (do not trigger directly) +``` + +--- + +## How to Add Repos for Bulk Mode + +Edit `release/branching-rule/branch-protection.txt`: + +``` +repositories: + - repo: mosip/admin-services + branch: release-1.2.x + + - repo: inji/vc-verifier + branch: release-1.8.x + + - repo: Prafulrakhade/admin-services + branch: develop +``` + +- `repo` — full name in `org/repo-name` or `user/repo-name` format +- `branch` — exact branch name to protect +- Lines starting with `#` are ignored + +--- + +## How to Run the Workflow + +Go to `release-script` → **Actions** → **Apply Branch Protection Rules** → **Run workflow** + +### Inputs + +| Input | Options | Description | +|---|---|---| +| `mode` | `single` / `bulk` | Single repo or all repos in `branch-protection.txt` | +| `target_repo` | e.g. `mosip/admin-services` | Single mode only — leave blank for bulk | +| `branch_pattern` | e.g. `release-1.2.x` | Single mode only — leave blank for bulk | +| `rules` | `default` / `custom` | Which rules to apply | + +### Single Mode +``` +mode → single +target_repo → mosip/admin-services +branch_pattern → release-1.2.x +rules → default +``` + +### Bulk Mode +``` +mode → bulk +target_repo → (leave blank) +branch_pattern → (leave blank) +rules → default +``` + +--- + +## Access Control + +Only users with **Admin** or **Maintain** role on the target repository can trigger the workflow. The check runs in 3 levels: + +| Check | Condition | Result | +|---|---|---| +| Check 0 | Actor is the repo owner | Authorized | +| Check 1 | Actor is a direct collaborator with `admin` or `maintain` role | Authorized | +| Check 2 | Actor is a member of any team that has `admin` or `maintain` on the repo | Authorized | + +**Blocked roles:** + +| Role | Can trigger? | +|---|---| +| Admin | ✅ Yes | +| Maintain | ✅ Yes | +| Write | ❌ No | +| Triage | ❌ No | +| Read | ❌ No | +| Outside collaborator (non-admin/maintain) | ❌ No | + +If access is denied, the workflow prints the user's actual role: +``` +[DENIED] abhishek8shankar has 'write' role on mosip/admin-services +[DENIED] Only 'admin' and 'maintain' roles can create branch protection rules +``` + +--- + +## Existing Branch Protection + +If branch protection rules **already exist** on the target branch, the workflow **fails** with a clear error: + +``` +[FAIL] Branch 'develop' on 'mosip/admin-services' + already has branch protection rules. + Please remove the existing rules first if you want to reapply. +``` + +> Remove the existing rules from GitHub Settings → Branches → Edit, then re-run the workflow. + +--- + +## Default Branch Protection Rules + +Applied when `rules → default`: + +| Rule | Value | +|---|---| +| Required PR approvals | 1 | +| Dismiss stale reviews | false | +| Require CODEOWNER review | false | +| Require status checks | Auto-discovered | +| Require conversation resolution | true | +| Enforce admins (no bypassing) | true | +| Allow force pushes | false | +| Allow branch deletion | false | +| Require linear history | false | +| Push restrictions (users) | gsasikumar, vishwa-vyom, ckm007 *(org repos only)* | + +--- + +## Custom Branch Protection Rules + +Edit `release/branching-rule/custom-branch-protection-rule.yaml` and change only what you need: + +```yaml +rules: + required_approving_review_count: 2 # default: 1 + dismiss_stale_reviews: false + require_code_owner_reviews: false + require_last_push_approval: false + require_status_checks: false + strict_status_checks: false + status_check_contexts: "" + required_conversation_resolution: true + required_linear_history: false + enforce_admins: true + allow_force_pushes: false + allow_deletions: false + push_users: "gsasikumar,vishwa-vyom,ckm007" + push_teams: "" +``` + +Then run workflow with `rules → custom`. + +> Inline comments (`# ...`) are supported and ignored by the parser. + +--- + +## Status Checks — Auto Discovery + +For `default` mode, the workflow automatically reads workflow files in the **target repo** and registers: + +- Jobs using `maven-build.yml` → ` / maven-build` +- Jobs using `docker-build.yml` → ` / build-dockers` + +If no matching jobs found → branch protection is still applied without status checks. + +--- + +## Personal vs Organization Repos + +| Feature | Personal repo | Org repo | +|---|---|---| +| All protection rules | ✅ | ✅ | +| Push restrictions (users/teams) | ❌ Skipped automatically | ✅ | + +Push restrictions are silently skipped for personal repos — GitHub API does not support them for personal accounts. + +--- + +## Required Secrets + +Configure in `release-script` → **Settings → Secrets → Actions**: + +| Secret | Required | Description | +|---|---|---| +| `ACTION_PAT` | ✅ Yes | PAT with `repo` + `admin:org` scopes | +| `ALLOWED_TEAM` | ❌ No | Legacy — no longer required, team check is automatic | + +### Creating the PAT +1. GitHub → **Settings → Developer settings → Personal access tokens → Tokens (classic)** +2. Select scopes: `repo` (full) + `admin:org` +3. Save as `ACTION_PAT` secret in `release-script` + +--- + +## Troubleshooting + +| Error | Cause | Fix | +|---|---|---| +| `branch-protection.txt not found` | File not at `release/branching-rule/branch-protection.txt` | Check file path and branch | +| `Access denied` | User does not have Admin or Maintain role | Grant correct role on target repo | +| `Already has branch protection rules` | Rules exist on target branch | Remove existing rules first | +| `HTTP 422 Validation Failed` | Push restrictions on personal repo | Expected — handled automatically | +| `HTTP 404 on branch` | Branch does not exist | Create the branch first | +| `Custom rules file not found` | File missing | Ensure file exists at `release/branching-rule/custom-branch-protection-rule.yaml` | diff --git a/release/branching-rule/branch-protection.txt b/release/branching-rule/branch-protection.txt new file mode 100644 index 00000000..7cee91b3 --- /dev/null +++ b/release/branching-rule/branch-protection.txt @@ -0,0 +1,5 @@ +repositories: + - repo: abhishek8shankar/id-authentication + branch: master + - repo: Prafulrakhade/admin-services + branch: release-1.3.x diff --git a/release/branching-rule/custom-branch-protection-rule.yaml b/release/branching-rule/custom-branch-protection-rule.yaml new file mode 100644 index 00000000..31b5716b --- /dev/null +++ b/release/branching-rule/custom-branch-protection-rule.yaml @@ -0,0 +1,44 @@ +# ============================================================ +# FILE: branching-rule/branch-protection-rules.yaml +# +# Edit this file to define your own branch protection rules. +# Trigger the workflow with "Use custom rules file" = true +# to apply these instead of the default hardcoded rules. +# +# DEFAULT values below match the standard hardcoded defaults +# in the workflow — change only what you need. +# ============================================================ + +rules: + + # ── Pull Request Reviews ─────────────────────────────────── + required_approving_review_count: 2 # Number of approvals required (0–6) + dismiss_stale_reviews: false # Dismiss approvals when new commits are pushed + require_code_owner_reviews: false # Require review from a CODEOWNER + require_last_push_approval: false # Last pusher cannot approve their own PR + + # ── Status Checks ───────────────────────────────────────── + require_status_checks: false # Require CI checks to pass before merging + strict_status_checks: false # Branch must be up-to-date before merging + status_check_contexts: "" # Comma-separated check names e.g. "build,test,lint" + # Leave empty if require_status_checks is false + + # ── Conversation & History ───────────────────────────────── + required_conversation_resolution: true # All comments must be resolved before merging + required_linear_history: false # Prevent merge commits (require squash or rebase) + + # ── Admin & Bypass ──────────────────────────────────────── + enforce_admins: true # Apply rules to admins too (no bypassing) + + # ── Push & Deletion ─────────────────────────────────────── + allow_force_pushes: false # Allow force pushes to the branch + allow_deletions: false # Allow the branch to be deleted + + # ── Push Restrictions ───────────────────────────────────── + # Comma-separated GitHub usernames allowed to push. + # Must already be collaborators on the repo. + push_users: "gsasikumar,vishwa-vyom,ckm007" + + # Comma-separated team slugs allowed to push. + # Leave empty if no team restriction needed. + push_teams: ""