diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9244cfd..d4b198c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -10,7 +10,7 @@ /Cargo.lock @theonlyhennygod # CI -/.github/workflows/** @willsarg +/.github/workflows/** @theonlyhennygod @willsarg /.github/codeql/** @willsarg /.github/dependabot.yml @willsarg diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index 753bb52..07d0f86 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -1,4 +1,4 @@ -name: Auto Response +name: PR Auto Responder on: issues: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4fbd33..93cc500 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: docs_only: ${{ steps.scope.outputs.docs_only }} docs_changed: ${{ steps.scope.outputs.docs_changed }} rust_changed: ${{ steps.scope.outputs.rust_changed }} + workflow_changed: ${{ steps.scope.outputs.workflow_changed }} docs_files: ${{ steps.scope.outputs.docs_files }} base_sha: ${{ steps.scope.outputs.base_sha }} steps: @@ -55,6 +56,7 @@ jobs: echo "docs_only=false" echo "docs_changed=false" echo "rust_changed=true" + echo "workflow_changed=false" echo "base_sha=" } >> "$GITHUB_OUTPUT" write_empty_docs_files @@ -67,6 +69,7 @@ jobs: echo "docs_only=false" echo "docs_changed=false" echo "rust_changed=false" + echo "workflow_changed=false" echo "base_sha=$BASE" } >> "$GITHUB_OUTPUT" write_empty_docs_files @@ -76,10 +79,15 @@ jobs: docs_only=true docs_changed=false rust_changed=false + workflow_changed=false docs_files=() while IFS= read -r file; do [ -z "$file" ] && continue + if [[ "$file" == .github/workflows/* ]]; then + workflow_changed=true + fi + if [[ "$file" == docs/* ]] \ || [[ "$file" == *.md ]] \ || [[ "$file" == *.mdx ]] \ @@ -112,6 +120,7 @@ jobs: echo "docs_only=$docs_only" echo "docs_changed=$docs_changed" echo "rust_changed=$rust_changed" + echo "workflow_changed=$workflow_changed" echo "base_sha=$BASE" echo "docs_files< login.trim().toLowerCase()) + .filter(Boolean); + + if (ownerAllowlist.length === 0) { + core.setFailed("WORKFLOW_OWNER_LOGINS is empty. Set a repository variable or use a fallback value."); + return; + } + + const files = await github.paginate(github.rest.pulls.listFiles, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + + const workflowFiles = files + .map((file) => file.filename) + .filter((name) => name.startsWith(".github/workflows/")); + + if (workflowFiles.length === 0) { + core.info("No workflow files changed in this PR."); + return; + } + + core.info(`Workflow files changed:\n- ${workflowFiles.join("\n- ")}`); + + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, + repo, + pull_number: prNumber, + per_page: 100, + }); + + const latestReviewByUser = new Map(); + for (const review of reviews) { + const login = review.user?.login; + if (!login) continue; + latestReviewByUser.set(login.toLowerCase(), review.state); + } + + const approvedUsers = [...latestReviewByUser.entries()] + .filter(([, state]) => state === "APPROVED") + .map(([login]) => login); + + if (approvedUsers.length === 0) { + core.setFailed("Workflow files changed but no approving review is present."); + return; + } + + const ownerApprover = approvedUsers.find((login) => ownerAllowlist.includes(login)); + if (!ownerApprover) { + core.setFailed( + `Workflow files changed. Approvals found (${approvedUsers.join(", ")}), but none match WORKFLOW_OWNER_LOGINS.`, + ); + return; + } + + core.info(`Workflow owner approval present: @${ownerApprover}`); + ci-required: name: CI Required Gate if: always() - needs: [changes, lint, lint-strict-delta, test, build, docs-only, non-rust, docs-quality] + needs: [changes, lint, lint-strict-delta, test, build, docs-only, non-rust, docs-quality, workflow-owner-approval] runs-on: blacksmith-2vcpu-ubuntu-2404 steps: - name: Enforce required status @@ -273,10 +366,17 @@ jobs: docs_changed="${{ needs.changes.outputs.docs_changed }}" rust_changed="${{ needs.changes.outputs.rust_changed }}" + workflow_changed="${{ needs.changes.outputs.workflow_changed }}" docs_result="${{ needs.docs-quality.result }}" + workflow_owner_result="${{ needs.workflow-owner-approval.result }}" if [ "${{ needs.changes.outputs.docs_only }}" = "true" ]; then echo "docs=${docs_result}" + echo "workflow_owner_approval=${workflow_owner_result}" + if [ "$workflow_changed" = "true" ] && [ "$workflow_owner_result" != "success" ]; then + echo "Workflow files changed but workflow owner approval gate did not pass." + exit 1 + fi if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then echo "Docs-only change touched markdown docs, but docs-quality did not pass." exit 1 @@ -288,6 +388,11 @@ jobs: if [ "$rust_changed" != "true" ]; then echo "rust_changed=false (non-rust fast path)" echo "docs=${docs_result}" + echo "workflow_owner_approval=${workflow_owner_result}" + if [ "$workflow_changed" = "true" ] && [ "$workflow_owner_result" != "success" ]; then + echo "Workflow files changed but workflow owner approval gate did not pass." + exit 1 + fi if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then echo "Docs changed but docs-quality did not pass." exit 1 @@ -306,12 +411,18 @@ jobs: echo "test=${test_result}" echo "build=${build_result}" echo "docs=${docs_result}" + echo "workflow_owner_approval=${workflow_owner_result}" if [ "$lint_result" != "success" ] || [ "$lint_strict_delta_result" != "success" ] || [ "$test_result" != "success" ] || [ "$build_result" != "success" ]; then echo "Required CI jobs did not pass." exit 1 fi + if [ "$workflow_changed" = "true" ] && [ "$workflow_owner_result" != "success" ]; then + echo "Workflow files changed but workflow owner approval gate did not pass." + exit 1 + fi + if [ "$docs_changed" = "true" ] && [ "$docs_result" != "success" ]; then echo "Docs changed but docs-quality did not pass." exit 1 diff --git a/.github/workflows/workflow-sanity.yml b/.github/workflows/workflow-sanity.yml index abad363..45b9cac 100644 --- a/.github/workflows/workflow-sanity.yml +++ b/.github/workflows/workflow-sanity.yml @@ -7,7 +7,6 @@ on: - ".github/*.yml" - ".github/*.yaml" push: - branches: [main] paths: - ".github/workflows/**" - ".github/*.yml" diff --git a/docs/ci-map.md b/docs/ci-map.md index 6a2260d..bdd471b 100644 --- a/docs/ci-map.md +++ b/docs/ci-map.md @@ -10,6 +10,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u - `.github/workflows/ci.yml` (`CI`) - Purpose: Rust validation (`cargo fmt --all -- --check`, `cargo clippy --locked --all-targets -- -D clippy::correctness`, strict delta lint gate on changed Rust lines, `test`, release build smoke) + docs quality checks when docs change (`markdownlint` blocks only issues on changed lines; link check scans only links added on changed lines) + - Additional behavior: PRs that change `.github/workflows/**` require at least one approving review from a login in `WORKFLOW_OWNER_LOGINS` (repository variable fallback: `theonlyhennygod,willsarg`) - Merge gate: `CI Required Gate` - `.github/workflows/workflow-sanity.yml` (`Workflow Sanity`) - Purpose: lint GitHub workflow files (`actionlint`, tab checks) @@ -39,7 +40,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u - Additional behavior: risk + size labels are auto-corrected on manual PR label edits (`labeled`/`unlabeled` events); apply `risk: manual` when maintainers intentionally override automated risk selection - High-risk heuristic paths: `src/security/**`, `src/runtime/**`, `src/gateway/**`, `src/tools/**`, `.github/workflows/**` - Guardrail: maintainers can apply `risk: manual` to freeze automated risk recalculation -- `.github/workflows/auto-response.yml` (`Auto Response`) +- `.github/workflows/auto-response.yml` (`PR Auto Responder`) - Purpose: first-time contributor onboarding + label-driven response routing (`r:support`, `r:needs-repro`, etc.) - Additional behavior: applies contributor tiers on issues by merged PR count (`trusted` >=5, `experienced` >=10, `principal` >=20, `distinguished` >=50), matching PR tier thresholds exactly - Additional behavior: contributor-tier labels are treated as automation-managed (manual add/remove on PR/issue is auto-corrected) @@ -59,7 +60,7 @@ Merge-blocking checks should stay small and deterministic. Optional checks are u - `Security Audit`: push to `main`, PRs to `main`, weekly schedule - `Workflow Sanity`: PR/push when `.github/workflows/**`, `.github/*.yml`, or `.github/*.yaml` change - `PR Labeler`: `pull_request_target` lifecycle events -- `Auto Response`: issue opened/labeled, `pull_request_target` opened/labeled +- `PR Auto Responder`: issue opened/labeled, `pull_request_target` opened/labeled - `Stale`: daily schedule, manual dispatch - `Dependabot`: weekly dependency maintenance windows - `PR Hygiene`: every 12 hours schedule, manual dispatch diff --git a/docs/pr-workflow.md b/docs/pr-workflow.md index 2c154ef..0afb9cd 100644 --- a/docs/pr-workflow.md +++ b/docs/pr-workflow.md @@ -41,6 +41,7 @@ Maintain these branch protection rules on `main`: - Require check `CI Required Gate`. - Require pull request reviews before merge. - Require CODEOWNERS review for protected paths. +- For `.github/workflows/**`, require owner approval via `CI Required Gate` (`WORKFLOW_OWNER_LOGINS`) and keep branch/ruleset bypass limited to org owners. - Dismiss stale approvals when new commits are pushed. - Restrict force-push on protected branches. @@ -55,7 +56,7 @@ Maintain these branch protection rules on `main`: - Maintainers can run `PR Labeler` manually (`workflow_dispatch`) in `audit` mode for drift visibility or `repair` mode to normalize managed label metadata repository-wide. - Hovering a label in GitHub shows its auto-managed description (rule/threshold summary). - Managed label colors are arranged by display order to create a smooth gradient across long label rows. -- `Auto Response` posts first-time guidance, handles label-driven routing for low-signal items, and auto-applies issue contributor tiers using the same thresholds as `PR Labeler` (`trusted` >=5, `experienced` >=10, `principal` >=20, `distinguished` >=50). +- `PR Auto Responder` posts first-time guidance, handles label-driven routing for low-signal items, and auto-applies issue contributor tiers using the same thresholds as `PR Labeler` (`trusted` >=5, `experienced` >=10, `principal` >=20, `distinguished` >=50). ### Step B: Validation @@ -159,7 +160,7 @@ Issue triage discipline: Automation side-effect guards: -- `Auto Response` deduplicates label-based comments to avoid spam. +- `PR Auto Responder` deduplicates label-based comments to avoid spam. - Automated close routes are limited to issues, not PRs. - Maintainers can freeze automated risk recalculation with `risk: manual` when context demands human override.