diff --git a/.claude/skills/add-repo-override/SKILL.md b/.claude/skills/add-repo-override/SKILL.md new file mode 100644 index 0000000..7f5cba8 --- /dev/null +++ b/.claude/skills/add-repo-override/SKILL.md @@ -0,0 +1,27 @@ +--- +name: add-repo-override +description: Add a per-repo settings override to overrides.json +user-invocable: true +--- + +# Add Repository Override + +Add a per-repo exception to `config/overrides.json`. + +## Arguments + +`$ARGUMENTS` should be in the format: ` ` + +Examples: + +- `my-repo branch_protection.required_status_checks.contexts '["Build","Test"]'` +- `my-repo repo_settings.has_wiki true` + +## Steps + +1. Read the current `config/overrides.json` +2. Parse `$ARGUMENTS` to extract repo name, setting path, and value +3. Add or update the override for the specified repo +4. Validate the resulting JSON with `jq empty` +5. Show the diff of what changed +6. Remind the user to create a PR for the change diff --git a/.claude/skills/audit/SKILL.md b/.claude/skills/audit/SKILL.md new file mode 100644 index 0000000..4dfcddd --- /dev/null +++ b/.claude/skills/audit/SKILL.md @@ -0,0 +1,24 @@ +--- +name: audit +description: Run a dry-run settings audit across all repositories +user-invocable: true +disable-model-invocation: true +--- + +# Audit Repository Settings + +Run a dry-run sync to check for drift without applying changes. + +## Steps + +1. Run the sync script in dry-run mode: + +```bash +./scripts/sync-repo-settings.sh --dry-run +``` + +1. Display the report: + +```bash +cat reports/sync-report.md +``` diff --git a/.claude/skills/exclude-repo/SKILL.md b/.claude/skills/exclude-repo/SKILL.md new file mode 100644 index 0000000..00e07d6 --- /dev/null +++ b/.claude/skills/exclude-repo/SKILL.md @@ -0,0 +1,21 @@ +--- +name: exclude-repo +description: Exclude a repository from settings governance +user-invocable: true +--- + +# Exclude Repository + +Add a repository to the exclusion list in `config/overrides.json`. + +## Arguments + +`$ARGUMENTS` should be the repository name to exclude. + +## Steps + +1. Read the current `config/overrides.json` +2. Add `$ARGUMENTS` to the `excluded` array (if not already present) +3. Validate the resulting JSON with `jq empty` +4. Show the updated exclusion list +5. Remind the user to create a PR for the change diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e480ec7..06dff58 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,5 @@ +# Pull Request + ## What changed diff --git a/.github/actions/security-scan/action.yml b/.github/actions/security-scan/action.yml new file mode 100644 index 0000000..c1dba5c --- /dev/null +++ b/.github/actions/security-scan/action.yml @@ -0,0 +1,31 @@ +name: Security Scan +description: Run SAST and SCA security scans + +inputs: + scan-path: + description: Path to scan + required: false + default: "." + +runs: + using: composite + steps: + - name: Run Semgrep SAST + uses: semgrep/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1.0.0 + with: + config: auto + + - name: Run Trivy vulnerability scanner + if: always() + uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 + with: + scan-type: fs + scan-ref: ${{ inputs.scan-path }} + format: sarif + output: trivy-results.sarif + + - name: Upload Trivy results to GitHub Security + uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 + if: always() + with: + sarif_file: trivy-results.sarif diff --git a/.github/actions/sync-settings/action.yml b/.github/actions/sync-settings/action.yml new file mode 100644 index 0000000..b79b15a --- /dev/null +++ b/.github/actions/sync-settings/action.yml @@ -0,0 +1,47 @@ +name: Sync Repository Settings +description: Compare and apply GitHub settings across all repos against a baseline + +inputs: + mode: + description: "Run mode: --dry-run or --apply" + required: true + default: "--dry-run" + github_token: + description: PAT with repo and admin scopes + required: true + +outputs: + report_file: + description: Path to the generated report + value: ${{ steps.sync.outputs.report_file }} + total_repos: + description: Number of repos scanned + value: ${{ steps.parse.outputs.total_repos }} + compliant: + description: Number of compliant repos + value: ${{ steps.parse.outputs.compliant }} + drift: + description: Number of repos with drift + value: ${{ steps.parse.outputs.drift }} + has_drift: + description: Whether any drift was detected + value: ${{ steps.parse.outputs.has_drift }} + +runs: + using: composite + steps: + - name: Run settings sync + id: sync + shell: bash + env: + GH_TOKEN: ${{ inputs.github_token }} + REPORT_FILE: reports/sync-report.md + SYNC_MODE: ${{ inputs.mode }} + run: | + ./scripts/sync-repo-settings.sh "$SYNC_MODE" + echo "report_file=reports/sync-report.md" >> "$GITHUB_OUTPUT" + + - name: Parse report + id: parse + shell: bash + run: ./scripts/generate-report.sh reports/sync-report.md diff --git a/.github/actions/update-pre-commit-composite/action.yml b/.github/actions/update-pre-commit-composite/action.yml new file mode 100644 index 0000000..7524af9 --- /dev/null +++ b/.github/actions/update-pre-commit-composite/action.yml @@ -0,0 +1,36 @@ +name: Update Pre-commit Hooks Composite Action +description: Updates pre-commit hook versions and creates a PR + +inputs: + github_token: + description: GitHub token for creating PRs + required: true + +runs: + using: composite + steps: + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.x" + + - name: Install pre-commit + shell: bash + run: pip install pre-commit + + - name: Update hooks + shell: bash + run: pre-commit autoupdate + + - name: Create Pull Request + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 + with: + token: ${{ inputs.github_token }} + commit-message: "chore: update pre-commit hook versions" + title: "chore: update pre-commit hook versions" + body: | + Automated update of pre-commit hook versions. + + Review the changes to `.pre-commit-config.yaml` and merge if CI passes. + branch: chore/update-pre-commit-hooks + delete-branch: true diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9474943..d642b9e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,4 +1,4 @@ -Review priorities for this repository: +# Review Priorities 1. Shell script quality: shellcheck and shellharden compliance, proper quoting, error handling (set -euo pipefail), no hardcoded tokens diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index fae9d8a..a9a3d6d 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -15,20 +15,24 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Lint markdown - uses: DavidAnson/markdownlint-cli2-action@db4c2f7b1e4a6de4660458dd8d547f94deaac667 # v22.0.0 + uses: DavidAnson/markdownlint-cli2-action@07035fd053f7be764496c0f8d8f9f41f98305101 # v22.0.0 yaml-lint: name: YAML Validation runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Lint YAML - uses: ibiqlik/action-yamllint@2576f72e4b4e5aef56e60fc8a24fa17e25be1fef # v3.1.1 + uses: ibiqlik/action-yamllint@2576378a8e339169678f9939646ee3ee325e845c # v3.1.1 with: config_file: .yamllint.yml @@ -37,7 +41,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Run ShellCheck uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # v2.0.0 @@ -47,7 +53,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Check required files run: | @@ -79,20 +87,44 @@ jobs: fi done + - name: Validate baseline schema + run: | + ERRORS=0 + for section in repo_settings security branch_protection labels required_files; do + if ! jq -e ".$section" config/baseline.json > /dev/null 2>&1; then + echo "ERROR: Missing section '$section' in baseline.json" + ERRORS=$((ERRORS + 1)) + else + echo "OK: section '$section' present" + fi + done + # Validate label structure + LABEL_ERRORS=$(jq '[.labels[] | select(.name == null or .color == null or .description == null)] | length' config/baseline.json) + if [ "$LABEL_ERRORS" -gt 0 ]; then + echo "ERROR: $LABEL_ERRORS labels missing required fields (name, color, description)" + ERRORS=$((ERRORS + LABEL_ERRORS)) + fi + if [ "$ERRORS" -gt 0 ]; then + echo "ERROR: baseline.json schema validation failed" + exit 1 + fi + actions-security: name: Actions Security runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Install zizmor run: | - ZIZMOR_VERSION="1.5.0" + ZIZMOR_VERSION="1.23.1" curl -sL "https://github.com/woodruffw/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-x86_64-unknown-linux-gnu.tar.gz" -o /tmp/zizmor.tar.gz mkdir -p /tmp/zizmor-extract tar -xzf /tmp/zizmor.tar.gz -C /tmp/zizmor-extract - sudo mv /tmp/zizmor-extract/zizmor /usr/local/bin/zizmor + find /tmp/zizmor-extract -name zizmor -type f -exec sudo mv {} /usr/local/bin/zizmor \; chmod +x /usr/local/bin/zizmor - name: Run zizmor diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 38501aa..7730105 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -9,6 +9,7 @@ on: permissions: contents: read security-events: write + actions: read jobs: security-scan: @@ -16,22 +17,11 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Run Semgrep - uses: returntocorp/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1.0.0 - with: - config: auto - - - name: Run Trivy - uses: aquasecurity/trivy-action@18f2510ee396bbf400402947e7f3b01483832965 # v0.31.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - scan-type: fs - format: sarif - output: trivy-results.sarif + persist-credentials: false - - name: Upload Trivy SARIF - uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 - if: always() + - name: Run Security Scan + uses: ./.github/actions/security-scan with: - sarif_file: trivy-results.sarif + scan-path: "." diff --git a/.github/workflows/sync-settings.yml b/.github/workflows/sync-settings.yml index 16becc8..150fba7 100644 --- a/.github/workflows/sync-settings.yml +++ b/.github/workflows/sync-settings.yml @@ -15,6 +15,10 @@ on: - "--dry-run" - "--apply" +concurrency: + group: settings-sync + cancel-in-progress: false + permissions: contents: read @@ -22,99 +26,148 @@ jobs: sync: name: Sync Settings runs-on: ubuntu-latest - permissions: - contents: read steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Sync repository settings - env: - GH_TOKEN: ${{ secrets.ORG_SETTINGS_PAT }} - REPORT_FILE: reports/sync-report.md - run: | - MODE="${{ github.event.inputs.mode || '--apply' }}" - ./scripts/sync-repo-settings.sh "$MODE" + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - - name: Parse report - id: report - run: ./scripts/generate-report.sh reports/sync-report.md + - name: Run settings sync + id: sync + uses: ./.github/actions/sync-settings + with: + mode: ${{ github.event.inputs.mode || '--apply' }} + github_token: ${{ secrets.ORG_SETTINGS_PAT }} - name: Upload report artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: sync-report + name: sync-report-${{ github.run_number }} path: reports/sync-report.md retention-days: 90 - - name: Send email report + - name: Post job summary if: always() - uses: dawidd6/action-send-mail@v3.12.0 - with: - server_address: smtp.gmail.com - server_port: 587 - username: ${{ secrets.EMAIL_USERNAME }} - password: ${{ secrets.EMAIL_PASSWORD }} - subject: | - GitHub Settings Sync Report — ${{ steps.report.outputs.drift > 0 && 'Drift Detected' || 'All Compliant' }} - to: gamaware@gmail.com - from: GitHub Org Settings <${{ secrets.EMAIL_USERNAME }}> - body: | - GitHub Organization Settings Sync Report - ========================================== - - Date: ${{ github.event.head_commit.timestamp || github.event.repository.updated_at }} - Mode: ${{ github.event.inputs.mode || '--apply' }} - Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - - Repositories scanned: ${{ steps.report.outputs.total_repos }} - Compliant: ${{ steps.report.outputs.compliant }} - Drift detected: ${{ steps.report.outputs.drift }} - - Full report is attached and available as a workflow artifact. - attachments: reports/sync-report.md + run: | + { + echo "## Settings Sync Results" + echo "" + echo "| Metric | Value |" + echo "| --- | --- |" + echo "| Repositories scanned | $TOTAL_REPOS |" + echo "| Compliant | $COMPLIANT |" + echo "| Drift detected | $DRIFT |" + echo "| Mode | $MODE |" + echo "" + echo "### Full Report" + echo "" + cat reports/sync-report.md + } >> "$GITHUB_STEP_SUMMARY" + env: + TOTAL_REPOS: ${{ steps.sync.outputs.total_repos }} + COMPLIANT: ${{ steps.sync.outputs.compliant }} + DRIFT: ${{ steps.sync.outputs.drift }} + MODE: ${{ github.event.inputs.mode || '--apply' }} + + - name: Create drift issue + if: steps.sync.outputs.has_drift == 'true' + env: + GH_TOKEN: ${{ secrets.ORG_SETTINGS_PAT }} + DRIFT: ${{ steps.sync.outputs.drift }} + TOTAL_REPOS: ${{ steps.sync.outputs.total_repos }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + SYNC_MODE: ${{ github.event.inputs.mode || '--apply' }} + run: | + TITLE="chore: settings drift detected — $(date '+%Y-%m-%d')" + BODY="## Settings Drift Report + + **Run**: $RUN_URL + **Mode**: $SYNC_MODE + **Repos with drift**: $DRIFT / $TOTAL_REPOS + + See the [workflow run]($RUN_URL) for the full report." + + # Create new issue first, then close old ones + if ! gh issue create --title "$TITLE" --body "$BODY" --label "settings-drift"; then + echo "::error::Failed to create drift issue" + exit 1 + fi + + # Close previous drift issues (all except the one just created) + LATEST=$(gh issue list --label "settings-drift" --state open --json number --jq '.[0].number') + gh issue list --label "settings-drift" --state open --json number --jq '.[].number' | while read -r num; do + if [ "$num" != "$LATEST" ]; then + gh issue close "$num" --comment "Superseded by new sync run." + fi + done + + - name: Close drift issue if compliant + if: steps.sync.outputs.has_drift == 'false' + env: + GH_TOKEN: ${{ secrets.ORG_SETTINGS_PAT }} + run: | + gh issue list --label "settings-drift" --state open --json number --jq '.[].number' | while read -r num; do + gh issue close "$num" --comment "All repositories are now compliant." + done new-repo-check: name: Discover New Repositories runs-on: ubuntu-latest - permissions: - contents: read steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Check for new repos + id: newrepos env: GH_TOKEN: ${{ secrets.ORG_SETTINGS_PAT }} run: | - echo "## New Repository Discovery" > reports/new-repos.md - echo "" >> reports/new-repos.md - - # Get all current repos - gh repo list gamaware --no-archived --json name,createdAt --jq '.[] | "\(.name) (created: \(.createdAt))"' --limit 200 > /tmp/all-repos.txt - - # Check repos created in the last 7 days - WEEK_AGO=$(date -u -d '7 days ago' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || date -u -v-7d '+%Y-%m-%dT%H:%M:%SZ') + mkdir -p reports + { + echo "## New Repository Discovery" + echo "" + } > reports/new-repos.md + + WEEK_AGO=$(date -u -d '7 days ago' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \ + || date -u -v-7d '+%Y-%m-%dT%H:%M:%SZ') NEW_REPOS=$(gh repo list gamaware --no-archived --json name,createdAt \ - --jq "[.[] | select(.createdAt > \"$WEEK_AGO\")] | .[].name" --limit 200 || echo "") + --jq "[.[] | select(.createdAt > \"$WEEK_AGO\")] | .[].name" \ + --limit 1000 || echo "") if [ -n "$NEW_REPOS" ]; then - echo "New repositories found in the last 7 days:" >> reports/new-repos.md - echo "" >> reports/new-repos.md - echo "$NEW_REPOS" | while read -r repo; do - echo "- **$repo**" >> reports/new-repos.md - done - echo "" >> reports/new-repos.md - echo "These repositories will be included in the next settings sync." >> reports/new-repos.md + echo "has_new=true" >> "$GITHUB_OUTPUT" + { + echo "New repositories found in the last 7 days:" + echo "" + echo "$NEW_REPOS" | while read -r repo; do + echo "- **$repo**" + done + echo "" + echo "These repositories will be included in the next settings sync." + } >> reports/new-repos.md else + echo "has_new=false" >> "$GITHUB_OUTPUT" echo "No new repositories found in the last 7 days." >> reports/new-repos.md fi - cat reports/new-repos.md + cat reports/new-repos.md >> "$GITHUB_STEP_SUMMARY" + + - name: Create issue for new repos + if: steps.newrepos.outputs.has_new == 'true' + env: + GH_TOKEN: ${{ secrets.ORG_SETTINGS_PAT }} + run: | + BODY=$(cat reports/new-repos.md) + gh issue create \ + --title "chore: new repositories discovered — $(date '+%Y-%m-%d')" \ + --body "$BODY" \ + --label "new-repo" - name: Upload new repos report - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: new-repos-report + name: new-repos-report-${{ github.run_number }} path: reports/new-repos.md retention-days: 30 diff --git a/.github/workflows/update-pre-commit-hooks.yml b/.github/workflows/update-pre-commit-hooks.yml index 6acb3a7..3331a7a 100644 --- a/.github/workflows/update-pre-commit-hooks.yml +++ b/.github/workflows/update-pre-commit-hooks.yml @@ -10,32 +10,16 @@ permissions: pull-requests: write jobs: - update: - name: Update Hooks + update-hooks: + name: Update Pre-commit Hooks runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - python-version: "3.x" - - - name: Install pre-commit - run: pip install pre-commit + persist-credentials: false - - name: Auto-update hooks - run: pre-commit autoupdate - - - name: Create PR - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + - name: Update Hooks + uses: ./.github/actions/update-pre-commit-composite with: - commit-message: "chore: update pre-commit hook versions" - title: "chore: update pre-commit hook versions" - body: | - Automated update of pre-commit hook versions. - - Review the changes to `.pre-commit-config.yaml` and merge if CI passes. - branch: chore/update-pre-commit-hooks - delete-branch: true + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.secrets.baseline b/.secrets.baseline index d83305e..70c93b1 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -123,5 +123,5 @@ } ], "results": {}, - "generated_at": "2026-03-10T05:54:17Z" + "generated_at": "2026-03-10T16:02:48Z" } diff --git a/CLAUDE.md b/CLAUDE.md index 36c5160..6573ac7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,16 +3,23 @@ ## Repository Overview Automated governance for GitHub repository settings across the -`gamaware` organization. Contains shell scripts and GitHub Actions -workflows that discover repos, compare settings against a baseline, -apply corrections, and send email reports. +`gamaware` organization. Contains shell scripts, composite GitHub +Actions, and workflows that discover repos, compare settings against +a baseline, apply corrections, and report drift via GitHub Issues. ## Repository Structure - `scripts/` — Shell scripts for syncing settings and generating reports - `config/` — Baseline settings JSON and per-repo overrides - `.github/workflows/` — CI/CD and scheduled sync workflows +- `.github/actions/` — Composite actions (security-scan, sync-settings, + update-pre-commit-composite) +- `.claude/skills/` — Reusable skills (`/audit`, `/add-repo-override`, + `/exclude-repo`) +- `docs/architecture.md` — System architecture overview - `docs/adr/` — Architecture Decision Records +- `docs/runbooks/` — Operational procedures (exclude repo, add setting, + handle drift, onboard repo) ## Git Workflow @@ -56,13 +63,47 @@ commit hooks — see `.pre-commit-config.yaml` for the full list. ## CI/CD Pipelines -- `sync-settings.yml` — weekly settings sync + email report -- `quality-checks.yml` — markdown, YAML, shell, structure validation -- `security.yml` — Semgrep SAST + Trivy SCA -- `update-pre-commit-hooks.yml` — weekly auto-update via PR +- `sync-settings.yml` — weekly settings sync + GitHub Issue reports +- `quality-checks.yml` — markdown, YAML, shell, structure, JSON + schema validation +- `security.yml` — Semgrep SAST + Trivy SCA (via composite action) +- `update-pre-commit-hooks.yml` — weekly auto-update via PR (via + composite action) + +## Composite Actions + +- `.github/actions/security-scan/` — reusable Semgrep + Trivy scan +- `.github/actions/sync-settings/` — reusable settings sync with + outputs for drift detection +- `.github/actions/update-pre-commit-composite/` — reusable + pre-commit autoupdate + PR creation + +## Claude Code Skills + +- `/audit` — run a dry-run settings check across all repos +- `/add-repo-override` — add a per-repo exception to overrides.json +- `/exclude-repo` — exclude a repository from governance ## Code Review - CodeRabbit auto-review via `.coderabbit.yaml` - GitHub Copilot auto-review via ruleset - Both reviewers run on every PR + +## Settings Sync Details + +The sync script (`scripts/sync-repo-settings.sh`) enforces: + +1. **Repo settings**: merge strategy, features, auto-merge, branch + cleanup +2. **Security**: secret scanning, push protection, vulnerability + alerts +3. **Branch protection**: reviews, CODEOWNERS, linear history, + conversation resolution +4. **Labels**: standard issue labels across all repos +5. **Default branch**: ensures all repos use `main` +6. **Metadata**: flags missing descriptions and topics (advisory) +7. **Required files**: LICENSE, README, CODEOWNERS, etc. + +Configuration lives in `config/baseline.json` with per-repo +overrides in `config/overrides.json`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f4b836..692d440 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,4 +25,4 @@ Open an issue using the appropriate template: ## Contact -alejandrogarcia@iteso.mx + diff --git a/README.md b/README.md index bf70009..ab6a05e 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,172 @@ # GitHub Organization Settings Automated governance for GitHub repository settings across the -`gamaware` organization. Ensures consistent configuration, branch -protection, and security practices across all repositories. +`gamaware` organization. Discovers all repositories, compares their +configuration against a defined baseline, applies corrections, and +reports drift via GitHub Issues. -## What It Does +## Why This Exists -- Discovers all repositories in the `gamaware` GitHub account -- Compares current settings against a defined baseline -- Applies standardized settings (merge strategies, branch protection, - security scanning) -- Runs weekly on a schedule and on every push for validation -- Sends an email report summarizing changes and drift +Manually configuring GitHub repository settings is error-prone and +does not scale. When you create a new repository or change a policy +(e.g., requiring linear history), every repo needs updating. This +automation ensures that all repositories under `gamaware` converge to +a single standard — the same merge strategies, branch protection +rules, security scanning, and scaffolding files. -## Settings Enforced +## How It Works -| Category | Setting | Value | +```text +Weekly (Sunday 00:00 UTC) + | + v ++-------------------+ +| Discover repos | -- gh repo list (excludes archived + excluded) ++-------------------+ + | + v ++-------------------+ +| Compare settings | -- current state vs config/baseline.json ++-------------------+ + | + v ++-------------------+ +| Apply corrections | -- PATCH/PUT via GitHub API (--apply mode) ++-------------------+ + | + v ++-------------------+ +| Generate report | -- Markdown report + GitHub Issue if drift found ++-------------------+ +``` + +The workflow also checks for repositories created in the last 7 days +and opens a GitHub Issue to flag them. + +## What Gets Enforced + +### Merge Strategy + +Every repository is locked to squash-merge only. This keeps `main` +history linear and readable. + +| Setting | Value | Why | | --- | --- | --- | -| Merge | Squash merge only | `true` | -| Merge | Merge commit | `false` | -| Merge | Rebase merge | `false` | -| Merge | Squash commit title | `PR_TITLE` | -| Merge | Delete branch on merge | `true` | -| Merge | Allow auto merge | `true` | -| Merge | Allow update branch | `true` | -| Features | Wiki | `false` | -| Features | Projects | `false` | -| Features | Discussions | `false` | -| Features | Issues | `true` | -| Security | Secret scanning | `enabled` | -| Security | Push protection | `enabled` | -| Branch Protection | Required reviews | `1` | -| Branch Protection | Dismiss stale reviews | `true` | -| Branch Protection | Require CODEOWNERS | `true` | -| Branch Protection | Required status checks (strict) | `true` | -| Branch Protection | Required linear history | `true` | -| Branch Protection | Required conversation resolution | `true` | -| Branch Protection | Enforce admins | `false` | +| `allow_squash_merge` | `true` | Single clean commit per PR | +| `allow_merge_commit` | `false` | Prevents noisy merge commits | +| `allow_rebase_merge` | `false` | Prevents rebase without squash | +| `squash_merge_commit_title` | `PR_TITLE` | Consistent commit messages | +| `squash_merge_commit_message` | `PR_BODY` | PR description becomes commit body | +| `delete_branch_on_merge` | `true` | Auto-cleanup merged branches | +| `allow_auto_merge` | `true` | Merge automatically when checks pass | +| `allow_update_branch` | `true` | Keep PR branches current with base | + +### Repository Features + +Unused features are disabled to reduce attack surface and clutter. + +| Setting | Value | Why | +| --- | --- | --- | +| `has_wiki` | `false` | Documentation lives in the repo | +| `has_projects` | `false` | Not used for project tracking | +| `has_discussions` | `false` | Not used for discussions | +| `has_issues` | `true` | Primary issue tracker | + +### Security + +Secret scanning catches leaked credentials before they reach `main`. +Vulnerability alerts flag known CVEs in dependencies. + +| Setting | Value | Why | +| --- | --- | --- | +| Secret scanning | `enabled` | Detect leaked tokens and keys | +| Push protection | `enabled` | Block pushes containing secrets | +| Vulnerability alerts | `enabled` | Dependabot CVE notifications | + +### Branch Protection (main) + +All changes go through PRs with at least one review. Linear history +ensures clean `git log` and bisectability. + +| Setting | Value | Why | +| --- | --- | --- | +| Required reviews | `1` | At least one approval before merge | +| Dismiss stale reviews | `true` | New pushes invalidate old approvals | +| Require CODEOWNERS | `true` | Owners must review their areas | +| Required status checks | `strict` | Branch must be up to date | +| Required linear history | `true` | No merge commits on main | +| Required conversation resolution | `true` | All comments must be resolved | +| Enforce admins | `false` | Admins can bypass when needed | + +### Labels + +Standard labels are created on every repo for consistent issue +triage. + +| Label | Color | Purpose | +| --- | --- | --- | +| `bug` | red | Something is broken | +| `enhancement` | cyan | New feature or improvement | +| `documentation` | blue | Docs updates | +| `security` | yellow | Security-related | +| `settings-drift` | gold | Settings mismatch (used by this repo) | +| `new-repo` | green | New repo discovered (used by this repo) | + +### Required Files + +Every repo must contain these files. Missing files are flagged in the +report (not auto-created, since content is repo-specific). + +| File | Purpose | +| --- | --- | +| `LICENSE` | Legal terms (MIT) | +| `README.md` | Project overview | +| `.gitignore` | Exclude build artifacts and secrets | +| `CODEOWNERS` | Assign default reviewers | +| `CONTRIBUTING.md` | Contribution guidelines | +| `SECURITY.md` | Vulnerability disclosure policy | +| `.pre-commit-config.yaml` | Local linting and validation | +| `.github/dependabot.yml` | Automated dependency updates | + +### Metadata Checks (Advisory) + +Repos missing a description or topics are flagged in the report for +manual attention. These are not auto-fixed because they require +human judgment. + +### Default Branch Check + +Repos not using `main` as the default branch are flagged and can be +corrected in `--apply` mode. ## Repository Structure ```text github-org-settings/ ├── .claude/ -│ ├── settings.json -│ └── hooks/ -│ └── post-edit.sh +│ ├── settings.json # Claude Code hooks config +│ ├── hooks/ +│ │ └── post-edit.sh # Auto-format on edit +│ └── skills/ +│ ├── audit/ # /audit — dry-run settings check +│ │ └── SKILL.md +│ ├── add-repo-override/ # /add-repo-override — add exception +│ │ └── SKILL.md +│ └── exclude-repo/ # /exclude-repo — exclude a repo +│ └── SKILL.md ├── .github/ -│ ├── workflows/ -│ │ ├── sync-settings.yml -│ │ ├── quality-checks.yml -│ │ ├── security.yml -│ │ └── update-pre-commit-hooks.yml │ ├── actions/ -│ │ ├── update-pre-commit-composite/ +│ │ ├── security-scan/ # Composite: Semgrep + Trivy │ │ │ └── action.yml -│ │ └── security-scan/ +│ │ ├── sync-settings/ # Composite: settings sync +│ │ │ └── action.yml +│ │ └── update-pre-commit-composite/ # Composite: hook updates │ │ └── action.yml +│ ├── workflows/ +│ │ ├── sync-settings.yml # Weekly settings sync + reports +│ │ ├── quality-checks.yml # PR/push linting and validation +│ │ ├── security.yml # SAST + SCA scanning +│ │ └── update-pre-commit-hooks.yml # Weekly hook updates │ ├── ISSUE_TEMPLATE/ │ │ ├── settings-bug.md │ │ └── settings-request.md @@ -64,23 +174,31 @@ github-org-settings/ │ ├── copilot-instructions.md │ └── dependabot.yml ├── scripts/ -│ ├── sync-repo-settings.sh -│ └── generate-report.sh +│ ├── sync-repo-settings.sh # Main sync logic +│ └── generate-report.sh # Report parser for CI ├── config/ -│ ├── baseline.json -│ └── overrides.json +│ ├── baseline.json # Settings enforced on all repos +│ └── overrides.json # Per-repo exceptions ├── docs/ -│ └── adr/ +│ ├── architecture.md # System architecture overview +│ ├── adr/ +│ │ ├── README.md +│ │ ├── 001-settings-governance.md +│ │ └── 002-sync-architecture.md +│ └── runbooks/ │ ├── README.md -│ └── 001-settings-governance.md -├── .coderabbit.yaml +│ ├── add-setting.md # How to add a new setting +│ ├── exclude-repo.md # How to exclude a repo +│ ├── handle-drift.md # How to respond to drift +│ └── onboard-repo.md # How to onboard a new repo +├── .coderabbit.yaml # CodeRabbit auto-review config ├── .gitignore ├── .markdownlint.yaml ├── .yamllint.yml ├── .pre-commit-config.yaml ├── .secrets.baseline -├── zizmor.yml -├── CLAUDE.md +├── zizmor.yml # GitHub Actions security config +├── CLAUDE.md # Claude Code project instructions ├── CODEOWNERS ├── CONTRIBUTING.md ├── LICENSE @@ -90,19 +208,36 @@ github-org-settings/ ## Usage -### Manual Run +### Automatic (Weekly) + +The `sync-settings.yml` workflow runs every Sunday at midnight UTC in +`--apply` mode. It: + +1. Discovers all non-archived repos +2. Compares settings against `config/baseline.json` +3. Applies corrections via the GitHub API +4. Posts a Job Summary with the full report +5. Opens a GitHub Issue if drift was detected +6. Closes previous drift issues if all repos are compliant +7. Flags new repos created in the last 7 days + +### Manual Trigger ```bash -gh workflow run sync-settings.yml +# Dry run (validation only) +gh workflow run sync-settings.yml -f mode="--dry-run" + +# Apply settings +gh workflow run sync-settings.yml -f mode="--apply" ``` ### Local Testing ```bash -# Dry run (validate only, no changes applied) +# Dry run ./scripts/sync-repo-settings.sh --dry-run -# Apply settings +# Apply ./scripts/sync-repo-settings.sh --apply ``` @@ -110,22 +245,49 @@ gh workflow run sync-settings.yml ### Baseline Settings -Edit `config/baseline.json` to change the enforced settings across all -repositories. +Edit `config/baseline.json` to change the enforced settings. Changes +go through PR review like any code change. ### Per-Repo Overrides -Edit `config/overrides.json` to exempt specific repositories from -certain settings. +Edit `config/overrides.json` to set repo-specific exceptions. The +most common override is `required_status_checks.contexts` since +each repo has different CI jobs. + +### Excluding Repositories + +Add repo names to the `excluded` array in `config/overrides.json`: + +```json +{ + "excluded": ["some-repo-to-skip"] +} +``` + +## Secrets Required + +| Secret | Purpose | Scopes | +| --- | --- | --- | +| `ORG_SETTINGS_PAT` | GitHub PAT for API access | `repo`, `admin:org` | + +## Code Review + +- **CodeRabbit**: auto-review on PRs via `.coderabbit.yaml` +- **GitHub Copilot**: auto-review via ruleset with custom instructions -### Excluded Repositories +## CI/CD Pipelines -Repositories can be excluded entirely by adding them to the `excluded` -array in `config/overrides.json`. +| Workflow | Trigger | Purpose | +| --- | --- | --- | +| `sync-settings.yml` | Weekly + manual | Settings enforcement | +| `quality-checks.yml` | PR + push to main | Markdown, YAML, shell, structure | +| `security.yml` | PR + push to main | Semgrep SAST + Trivy SCA | +| `update-pre-commit-hooks.yml` | Weekly + manual | Auto-update hook versions | ## Author -Jorge Alejandro Garcia Martinez ([@gamaware](https://github.com/gamaware)) +Jorge Alejandro Garcia Martinez +([@gamaware](https://github.com/gamaware)) ## License diff --git a/SECURITY.md b/SECURITY.md index cf0321f..3df2eba 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,7 +10,7 @@ manage is critical. If you discover a security issue, please report it privately: -- Email: alejandrogarcia@iteso.mx +- Email: - Include: affected file, description, reproduction steps Do not open a public issue for security vulnerabilities. diff --git a/config/baseline.json b/config/baseline.json index 7ba5dbe..de3a9f2 100644 --- a/config/baseline.json +++ b/config/baseline.json @@ -15,7 +15,8 @@ }, "security": { "secret_scanning": true, - "secret_scanning_push_protection": true + "secret_scanning_push_protection": true, + "vulnerability_alerts": true }, "branch_protection": { "branch": "main", @@ -33,6 +34,38 @@ "allow_force_pushes": false, "allow_deletions": false }, + "labels": [ + { + "name": "bug", + "color": "d73a4a", + "description": "Something is not working" + }, + { + "name": "enhancement", + "color": "a2eeef", + "description": "New feature or improvement" + }, + { + "name": "documentation", + "color": "0075ca", + "description": "Documentation updates" + }, + { + "name": "security", + "color": "e4e669", + "description": "Security-related issue" + }, + { + "name": "settings-drift", + "color": "fbca04", + "description": "Repository settings do not match baseline" + }, + { + "name": "new-repo", + "color": "0e8a16", + "description": "New repository discovered" + } + ], "required_files": [ "LICENSE", "README.md", diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..bb11f4c --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,104 @@ +# Architecture Overview + +## System Context + +This repository is the single source of truth for GitHub repository +settings across all `gamaware` repositories. It acts as a control +plane that reads a desired state from JSON configuration and +reconciles it against the actual state via the GitHub API. + +```text ++---------------------+ +------------------+ +| config/ | | GitHub API | +| baseline.json |------>| (repos, branches,| +| overrides.json | | labels, security| ++---------------------+ +------------------+ + | ^ + v | ++---------------------+ | +| scripts/ | | +| sync-repo-settings |---------------+ +| generate-report | ++---------------------+ + | + v ++---------------------+ +| GitHub Issues | +| Job Summaries | +| Artifacts (90 days) | ++---------------------+ +``` + +## Components + +### Configuration Layer (`config/`) + +- `baseline.json` — settings enforced on every repository +- `overrides.json` — per-repo exceptions (status checks, exclusions) + +The effective configuration for a repo is the baseline deep-merged +with its overrides. If no override exists, the baseline is used +as-is. + +### Sync Engine (`scripts/`) + +`sync-repo-settings.sh` is the core reconciliation loop: + +1. **Discovery** — `gh repo list` finds all non-archived repos +2. **Comparison** — for each repo, current state is fetched via API + and compared field-by-field against the effective config +3. **Remediation** — in `--apply` mode, PATCH/PUT calls correct drift +4. **Reporting** — a markdown report is generated with per-repo diffs + +The script handles 7 categories independently: + +| Category | API Endpoint | Method | +| --- | --- | --- | +| Repo settings | `repos/{owner}/{repo}` | PATCH | +| Security | `repos/{owner}/{repo}` | PATCH | +| Vulnerability alerts | `repos/{owner}/{repo}/vulnerability-alerts` | PUT | +| Branch protection | `repos/{owner}/{repo}/branches/main/protection` | PUT | +| Labels | `repos/{owner}/{repo}/labels` | POST | +| Default branch | `repos/{owner}/{repo}` | PATCH | +| Metadata | (advisory only) | — | + +### Composite Actions (`.github/actions/`) + +Reusable building blocks consumed by workflows: + +- `security-scan/` — Semgrep SAST + Trivy SCA with SARIF upload +- `sync-settings/` — wraps the sync script with structured outputs +- `update-pre-commit-composite/` — autoupdates hooks and creates PR + +### Workflows (`.github/workflows/`) + +| Workflow | Schedule | Purpose | +| --- | --- | --- | +| `sync-settings.yml` | Weekly Sunday 00:00 UTC | Settings enforcement | +| `quality-checks.yml` | PR + push | Linting and validation | +| `security.yml` | PR + push | SAST + SCA | +| `update-pre-commit-hooks.yml` | Weekly Sunday 00:00 UTC | Hook version updates | + +### Reporting + +Drift is communicated through three channels: + +1. **GitHub Issues** — auto-created with `settings-drift` label when + drift is found, auto-closed when all repos are compliant +2. **Job Summaries** — visible in the Actions run page +3. **Artifacts** — full markdown report stored for 90 days + +## Security Model + +- The `ORG_SETTINGS_PAT` secret provides API access with `repo` and + `admin:org` scopes +- Workflows use least-privilege `permissions` blocks +- All third-party actions are pinned to SHA commits +- Secret scanning and push protection are enforced on this repo too + +## Design Decisions + +Detailed rationale is documented in Architecture Decision Records: + +- [ADR 001: Settings Governance](adr/001-settings-governance.md) +- [ADR 002: Sync Architecture](adr/002-sync-architecture.md) diff --git a/docs/runbooks/README.md b/docs/runbooks/README.md new file mode 100644 index 0000000..1d712e2 --- /dev/null +++ b/docs/runbooks/README.md @@ -0,0 +1,10 @@ +# Runbooks + +Operational procedures for managing GitHub organization settings. + +| Runbook | Purpose | +| --- | --- | +| [Exclude a Repository](exclude-repo.md) | Stop syncing settings to a repo | +| [Add a New Setting](add-setting.md) | Enforce a new setting across all repos | +| [Handle Drift](handle-drift.md) | Respond to drift detection issues | +| [Onboard New Repository](onboard-repo.md) | Prepare a new repo for governance | diff --git a/docs/runbooks/add-setting.md b/docs/runbooks/add-setting.md new file mode 100644 index 0000000..9016f8c --- /dev/null +++ b/docs/runbooks/add-setting.md @@ -0,0 +1,41 @@ +# Add a New Setting + +Enforce a new GitHub setting across all repositories. + +## Steps + +1. Identify the GitHub API field name for the setting. Check the + [GitHub REST API docs](https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#update-a-repository) + +2. Add the field to the appropriate section in `config/baseline.json`: + - `repo_settings` — repository-level toggles + - `security` — security features + - `branch_protection` — branch protection rules + - `labels` — standard labels + - `required_files` — files that must exist + +3. Update `scripts/sync-repo-settings.sh` if the new setting requires + a different API endpoint or comparison logic + +4. Update `README.md` to document the new setting and its purpose + +5. Test locally: + + ```bash + ./scripts/sync-repo-settings.sh --dry-run + ``` + +6. Create a PR. Review the dry-run output to confirm the expected + changes + +7. After merge, the next weekly run will apply the setting. For + immediate application, trigger manually: + + ```bash + gh workflow run sync-settings.yml -f mode="--apply" + ``` + +## Per-Repo Exceptions + +If a repo legitimately needs a different value, add an override in +`config/overrides.json` under the repo name. diff --git a/docs/runbooks/exclude-repo.md b/docs/runbooks/exclude-repo.md new file mode 100644 index 0000000..6658052 --- /dev/null +++ b/docs/runbooks/exclude-repo.md @@ -0,0 +1,28 @@ +# Exclude a Repository + +Stop syncing settings to a specific repository. + +## When to Use + +- Third-party forks that must keep upstream settings +- Temporary repos that will be deleted soon +- Repos with fundamentally different governance needs + +## Steps + +1. Open `config/overrides.json` +2. Add the repo name to the `excluded` array: + + ```json + { + "excluded": ["repo-to-exclude"] + } + ``` + +3. Create a PR with the change +4. After merge, the next sync run will skip this repo + +## Reverting + +Remove the repo name from the `excluded` array and merge the PR. +The next sync run will pick it up again. diff --git a/docs/runbooks/handle-drift.md b/docs/runbooks/handle-drift.md new file mode 100644 index 0000000..98449e8 --- /dev/null +++ b/docs/runbooks/handle-drift.md @@ -0,0 +1,40 @@ +# Handle Drift Detection + +Respond to a `settings-drift` issue created by the sync workflow. + +## Triage + +1. Open the linked workflow run from the issue body +2. Review the Job Summary to see which repos drifted and what changed +3. Download the report artifact for full details + +## Common Causes + +| Cause | Action | +| --- | --- | +| Manual settings change via GitHub UI | Let the next `--apply` run fix it | +| New repo created without governance | The sync will apply baseline settings | +| Legitimate exception needed | Add an override in `config/overrides.json` | +| PAT expired or lacks scopes | Rotate the `ORG_SETTINGS_PAT` secret | + +## Manual Fix + +If you need to fix drift immediately without waiting for the +weekly run: + +```bash +gh workflow run sync-settings.yml -f mode="--apply" +``` + +Or locally: + +```bash +./scripts/sync-repo-settings.sh --apply +``` + +## Issue Lifecycle + +- The workflow auto-creates a new issue each time drift is found +- Previous drift issues are auto-closed with a superseded comment +- When all repos are compliant, open drift issues are auto-closed + with a compliant comment diff --git a/docs/runbooks/onboard-repo.md b/docs/runbooks/onboard-repo.md new file mode 100644 index 0000000..6d91589 --- /dev/null +++ b/docs/runbooks/onboard-repo.md @@ -0,0 +1,60 @@ +# Onboard a New Repository + +Prepare a new repository for settings governance. + +## Automatic Discovery + +New repos are discovered automatically by the weekly sync. The +workflow creates a GitHub Issue with the `new-repo` label listing +repos created in the last 7 days. + +## What the Sync Handles Automatically + +- Repository settings (merge strategy, features, auto-merge) +- Security settings (secret scanning, push protection, vulnerability + alerts) +- Branch protection rules +- Standard labels + +## What Needs Manual Setup + +The sync reports missing files but does not create them, since their +content is repo-specific: + +1. `LICENSE` — choose the appropriate license +2. `README.md` — write project overview +3. `.gitignore` — configure for the project's languages +4. `CODEOWNERS` — assign default reviewers +5. `CONTRIBUTING.md` — contribution guidelines +6. `SECURITY.md` — vulnerability disclosure policy +7. `.pre-commit-config.yaml` — configure hooks for the project's stack +8. `.github/dependabot.yml` — configure dependency updates + +## Adding Status Check Overrides + +Each repo has different CI jobs. Add the required status check names +to `config/overrides.json`: + +```json +{ + "repos": { + "new-repo-name": { + "branch_protection": { + "required_status_checks": { + "contexts": ["Build", "Test", "Lint"] + } + } + } + } +} +``` + +## Adding Description and Topics + +The sync flags repos without a description or topics. Set them via: + +```bash +gh repo edit gamaware/new-repo-name \ + --description "Short description" \ + --add-topic topic1 --add-topic topic2 +``` diff --git a/scripts/generate-report.sh b/scripts/generate-report.sh index f4d1e38..5e7e9af 100755 --- a/scripts/generate-report.sh +++ b/scripts/generate-report.sh @@ -13,7 +13,6 @@ if [ ! -f "$REPORT_FILE" ]; then fi # Extract summary metrics for the workflow -drift_count=$(grep -c "^## " "$REPORT_FILE" | tr -d ' ' || echo "0") total_repos=$(grep "Total repositories" "$REPORT_FILE" | grep -oE '[0-9]+' || echo "0") compliant=$(grep "Compliant" "$REPORT_FILE" | grep -oE '[0-9]+' || echo "0") drift=$(grep "Drift detected" "$REPORT_FILE" | grep -oE '[0-9]+' || echo "0") diff --git a/scripts/sync-repo-settings.sh b/scripts/sync-repo-settings.sh index fa4dc02..8ff73f1 100755 --- a/scripts/sync-repo-settings.sh +++ b/scripts/sync-repo-settings.sh @@ -22,8 +22,8 @@ log() { get_repos() { local excluded excluded=$(jq -r '.excluded[]' "$OVERRIDES" 2>/dev/null || echo "") - gh repo list "$OWNER" --no-archived --json name --jq '.[].name' --limit 200 | while read -r repo; do - if ! echo "$excluded" | grep -qx "$repo"; then + gh repo list "$OWNER" --no-archived --json name --jq '.[].name' --limit 1000 | while read -r repo; do + if ! echo "$excluded" | grep -qxF "$repo"; then echo "$repo" fi done @@ -134,6 +134,47 @@ SECURITY_EOF echo -e "$changes" } +# Enable or disable Dependabot vulnerability alerts +sync_vulnerability_alerts() { + local repo="$1" + local effective + effective=$(get_effective_settings "$repo") + local changes="" + + local want_alerts + want_alerts=$(echo "$effective" | jq -r '.security.vulnerability_alerts // "true"') + + local current_alerts + current_alerts=$(gh api "repos/$OWNER/$repo/vulnerability-alerts" -i 2>/dev/null | head -1 || echo "") + local is_enabled=false + if echo "$current_alerts" | grep -q "204"; then + is_enabled=true + fi + + if [ "$want_alerts" = "true" ] && [ "$is_enabled" = "false" ]; then + changes="- Vulnerability alerts: \`disabled\` -> \`enabled\`\n" + if [ "$MODE" = "--apply" ]; then + gh api -X PUT "repos/$OWNER/$repo/vulnerability-alerts" > /dev/null 2>&1 \ + || log "WARN: Could not enable vulnerability alerts for $repo" + log "APPLIED vulnerability alerts for $repo" + else + log "DRIFT detected in vulnerability alerts for $repo" + fi + elif [ "$want_alerts" = "false" ] && [ "$is_enabled" = "true" ]; then + changes="- Vulnerability alerts: \`enabled\` -> \`disabled\`\n" + if [ "$MODE" = "--apply" ]; then + gh api -X DELETE "repos/$OWNER/$repo/vulnerability-alerts" > /dev/null 2>&1 \ + || log "WARN: Could not disable vulnerability alerts for $repo" + log "APPLIED vulnerability alerts for $repo" + else + log "DRIFT detected in vulnerability alerts for $repo" + fi + else + log "OK: vulnerability alerts for $repo" + fi + echo -e "$changes" +} + # Compare and optionally apply branch protection sync_branch_protection() { local repo="$1" @@ -230,11 +271,22 @@ apply_branch_protection() { local branch="$2" local effective="$3" + # Preserve existing status check contexts if no override is defined. + # The PUT endpoint replaces all protection, so we must carry forward + # the repo's current contexts to avoid wiping repo-specific CI checks. local contexts="[]" local override_contexts override_contexts=$(jq -r --arg r "$repo" '.repos[$r].branch_protection.required_status_checks.contexts // empty' "$OVERRIDES" 2>/dev/null || echo "") if [ -n "$override_contexts" ]; then contexts=$(jq -r --arg r "$repo" '.repos[$r].branch_protection.required_status_checks.contexts' "$OVERRIDES") + else + # No override — read current contexts from the repo so we don't wipe them + local current_contexts + current_contexts=$(gh api "repos/$OWNER/$repo/branches/$branch/protection/required_status_checks" --jq '.contexts // []' 2>/dev/null || echo "[]") + if [ -n "$current_contexts" ] && [ "$current_contexts" != "[]" ]; then + contexts="$current_contexts" + log "INFO: Preserving existing status check contexts for $repo" + fi fi local strict @@ -278,6 +330,104 @@ PROTECT_EOF ) > /dev/null 2>&1 } +# Sync standard labels across repos. +# Uses process substitution to avoid subshell scoping issues with piped loops. +sync_labels() { + local repo="$1" + local effective + effective=$(get_effective_settings "$repo") + local changes="" + + local label_count + label_count=$(echo "$effective" | jq -r '.labels // [] | length') + if [ "$label_count" = "0" ]; then + echo "" + return + fi + + local current_labels + current_labels=$(gh api "repos/$OWNER/$repo/labels" --jq '.[].name' 2>/dev/null || echo "") + + while read -r label_json; do + [ -z "$label_json" ] && continue + local name color description + name=$(echo "$label_json" | jq -r '.name') + color=$(echo "$label_json" | jq -r '.color') + description=$(echo "$label_json" | jq -r '.description') + + if ! echo "$current_labels" | grep -qxF "$name"; then + changes="${changes}- Label missing: \`$name\`\n" + if [ "$MODE" = "--apply" ]; then + gh api -X POST "repos/$OWNER/$repo/labels" \ + -f name="$name" -f color="$color" -f description="$description" \ + > /dev/null 2>&1 || log "WARN: Could not create label $name for $repo" + fi + fi + done < <(echo "$effective" | jq -c '.labels[]' 2>/dev/null) + + if [ -n "$changes" ]; then + log "DRIFT detected in labels for $repo" + else + log "OK: labels for $repo" + fi + echo -e "$changes" +} + +# Check default branch matches config +check_default_branch() { + local repo="$1" + local effective + effective=$(get_effective_settings "$repo") + local changes="" + + local expected_branch + expected_branch=$(echo "$effective" | jq -r '.branch_protection.branch') + local default_branch + default_branch=$(gh api "repos/$OWNER/$repo" --jq '.default_branch' 2>/dev/null || echo "") + + if [ "$default_branch" != "$expected_branch" ] && [ -n "$default_branch" ]; then + changes="- Default branch: \`$default_branch\` (expected \`$expected_branch\`)\n" + if [ "$MODE" = "--apply" ]; then + gh api -X PATCH "repos/$OWNER/$repo" -f default_branch="$expected_branch" > /dev/null 2>&1 \ + || log "WARN: Could not rename default branch for $repo (may need manual rename)" + log "APPLIED default branch rename for $repo" + else + log "DRIFT detected in default branch for $repo" + fi + else + log "OK: default branch for $repo" + fi + echo -e "$changes" +} + +# Check for missing description and topics +check_repo_metadata() { + local repo="$1" + local changes="" + + local current + current=$(gh api "repos/$OWNER/$repo" 2>/dev/null) || return + + local description + description=$(echo "$current" | jq -r '.description // ""') + if [ -z "$description" ] || [ "$description" = "null" ]; then + changes="${changes}- **Missing repository description**\n" + fi + + local topics + topics=$(echo "$current" | jq -r '.topics | length') + if [ "$topics" = "0" ]; then + changes="${changes}- **No repository topics configured**\n" + fi + + if [ -n "$changes" ]; then + log "WARN: metadata gaps for $repo" + else + log "OK: metadata for $repo" + fi + echo -e "$changes" +} + # Check for required files check_required_files() { local repo="$1" @@ -328,6 +478,13 @@ REPORT_HEADER local repo_drift="" + # Default branch + local branch_changes + branch_changes=$(check_default_branch "$repo") + if [ -n "$branch_changes" ]; then + repo_drift="${repo_drift}### Default Branch\n\n${branch_changes}\n" + fi + # Repo settings local repo_changes repo_changes=$(sync_repo_settings "$repo") @@ -342,6 +499,13 @@ REPORT_HEADER repo_drift="${repo_drift}### Security\n\n${security_changes}\n" fi + # Vulnerability alerts + local vuln_changes + vuln_changes=$(sync_vulnerability_alerts "$repo") + if [ -n "$vuln_changes" ]; then + repo_drift="${repo_drift}### Vulnerability Alerts\n\n${vuln_changes}\n" + fi + # Branch protection local protection_changes protection_changes=$(sync_branch_protection "$repo") @@ -349,6 +513,20 @@ REPORT_HEADER repo_drift="${repo_drift}### Branch Protection\n\n${protection_changes}\n" fi + # Labels + local label_changes + label_changes=$(sync_labels "$repo") + if [ -n "$label_changes" ]; then + repo_drift="${repo_drift}### Labels\n\n${label_changes}\n" + fi + + # Metadata (advisory only — not auto-fixed) + local metadata_changes + metadata_changes=$(check_repo_metadata "$repo") + if [ -n "$metadata_changes" ]; then + repo_drift="${repo_drift}### Metadata (Manual Action Needed)\n\n${metadata_changes}\n" + fi + # Required files local missing_files missing_files=$(check_required_files "$repo") diff --git a/zizmor.yml b/zizmor.yml index 00ea2bb..68f89d0 100644 --- a/zizmor.yml +++ b/zizmor.yml @@ -3,3 +3,7 @@ rules: config: policies: "*": ref-pin + secrets-outside-env: + ignore: + - sync-settings.yml + - update-pre-commit-hooks.yml:25