diff --git a/.mokogitea/workflows/branch-protection-enforce.yml b/.mokogitea/workflows/branch-protection-enforce.yml new file mode 100644 index 0000000..6a6b217 --- /dev/null +++ b/.mokogitea/workflows/branch-protection-enforce.yml @@ -0,0 +1,54 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Enforce branch protection rules across all org repos. +# Runs weekly and on manual dispatch. + +name: "Org: Enforce Branch Protections" + +on: + schedule: + - cron: '0 6 * * 1' # Every Monday at 6am UTC + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run (show changes without applying)' + required: false + type: boolean + default: false + +jobs: + enforce: + name: Enforce Branch Protections + runs-on: release + + steps: + - name: Checkout MokoCLI + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + token: ${{ secrets.MOKOGITEA_TOKEN }} + + - name: Setup PHP + run: | + if ! command -v php > /dev/null 2>&1; then + sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-curl > /dev/null 2>&1 + fi + + - name: Run branch protection enforcement + run: | + DRY_RUN="" + if [ "${{ inputs.dry_run }}" = "true" ]; then + DRY_RUN="--dry-run" + fi + php cli/branch_protect_org.php \ + --token "${{ secrets.MOKOGITEA_TOKEN }}" \ + --org "MokoConsulting" \ + $DRY_RUN + + - name: Summary + if: always() + run: | + echo "## Branch Protection Enforcement" >> $GITHUB_STEP_SUMMARY + echo "All repos checked for main, dev, rc, beta, alpha protections" >> $GITHUB_STEP_SUMMARY + echo "Push whitelist: jmiller only" >> $GITHUB_STEP_SUMMARY diff --git a/cli/branch_protect_org.php b/cli/branch_protect_org.php new file mode 100644 index 0000000..1df554b --- /dev/null +++ b/cli/branch_protect_org.php @@ -0,0 +1,149 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Enforce branch protection rules across all repos in the org. + * + * Usage: + * php cli/branch_protect_org.php --token TOKEN [--org MokoConsulting] [--dry-run] + * + * Branch flow: feature/* -> dev -> rc -> main + * main, dev, rc: push whitelist only (no direct push) + * alpha, beta: push whitelist only (pre-release) + */ + +declare(strict_types=1); + +$options = getopt('', ['token:', 'org:', 'api-base:', 'dry-run', 'help']); + +if (isset($options['help']) || empty($options['token'])) { + echo "Usage: php cli/branch_protect_org.php --token TOKEN [--org ORG] [--api-base URL] [--dry-run]\n"; + echo "\n"; + echo "Options:\n"; + echo " --token Gitea API token (required)\n"; + echo " --org Organization name (default: MokoConsulting)\n"; + echo " --api-base API base URL (default: https://git.mokoconsulting.tech/api/v1)\n"; + echo " --dry-run Show what would be changed without making changes\n"; + exit(0); +} + +$token = $options['token']; +$org = $options['org'] ?? 'MokoConsulting'; +$apiBase = rtrim($options['api-base'] ?? 'https://git.mokoconsulting.tech/api/v1', '/'); +$dryRun = isset($options['dry-run']); + +// Protected branches and their rules +$branchRules = [ + 'main' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], + 'dev' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], + 'rc' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], + 'beta' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], + 'alpha' => ['enable_push' => true, 'enable_push_whitelist' => true, 'push_whitelist_usernames' => ['jmiller']], +]; + +function apiRequest(string $method, string $url, string $token, ?array $body = null): array +{ + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: token ' . $token, + 'Content-Type: application/json', + 'Accept: application/json', + ], + CURLOPT_TIMEOUT => 30, + ]); + + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); + } + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + return [ + 'status' => $httpCode, + 'data' => json_decode($response, true) ?: [], + ]; +} + +// 1. List all org repos +echo "Fetching repos for {$org}...\n"; +$page = 1; +$repos = []; + +do { + $result = apiRequest('GET', "{$apiBase}/orgs/{$org}/repos?limit=50&page={$page}", $token); + $batch = $result['data']; + $repos = array_merge($repos, $batch); + $page++; +} while (count($batch) === 50); + +echo sprintf("Found %d repos\n\n", count($repos)); + +$summary = ['protected' => 0, 'added' => 0, 'skipped' => 0, 'errors' => 0]; + +foreach ($repos as $repo) { + $repoName = $repo['name']; + + if ($repo['archived'] ?? false) { + continue; + } + + // Get existing protections + $existing = apiRequest('GET', "{$apiBase}/repos/{$org}/{$repoName}/branch_protections", $token); + $existingNames = array_map(fn($p) => $p['branch_name'] ?? '', $existing['data'] ?: []); + + $added = []; + $skipped = []; + + foreach ($branchRules as $branch => $rules) { + if (in_array($branch, $existingNames, true)) { + $skipped[] = $branch; + $summary['skipped']++; + continue; + } + + if ($dryRun) { + $added[] = $branch; + $summary['added']++; + continue; + } + + $body = array_merge($rules, ['branch_name' => $branch]); + $result = apiRequest('POST', "{$apiBase}/repos/{$org}/{$repoName}/branch_protections", $token, $body); + + if ($result['status'] >= 200 && $result['status'] < 300) { + $added[] = $branch; + $summary['added']++; + } elseif ($result['status'] === 422) { + $skipped[] = $branch; + $summary['skipped']++; + } else { + $added[] = "{$branch}(ERR:{$result['status']})"; + $summary['errors']++; + } + } + + $summary['protected']++; + + if (!empty($added)) { + $prefix = $dryRun ? '[DRY-RUN] ' : ''; + echo sprintf(" %s%-35s added: %s\n", $prefix, $repoName, implode(', ', $added)); + } +} + +echo "\n"; +echo sprintf("Summary: %d repos, %d rules added, %d already existed, %d errors\n", + $summary['protected'], $summary['added'], $summary['skipped'], $summary['errors']); + +if ($dryRun) { + echo "\n(Dry run - no changes made)\n"; +}