diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml new file mode 100644 index 0000000..59eccc2 --- /dev/null +++ b/.github/workflows/governance.yml @@ -0,0 +1,223 @@ +# Governance (Monolithic) +# +# Deployed into each repository as: .github/workflows/governance.yml +# +# Rationale: self-contained workflow avoids cross-org reusable workflow restrictions +# and allows the governance gate to run on the bootstrap PR itself. +# + +name: Governance + +on: + pull_request: + branches: + - main + - master + - production + - 'release/**' + +permissions: + contents: read + pull-requests: read + +jobs: + governance: + name: PR Governance Check + runs-on: ubuntu-latest + steps: + - name: Checkout PR + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch Governance Rules + run: | + # Public distribution repo (rules are signed; safe to publish) + curl -sL "https://raw.githubusercontent.com/chittyfoundation/.github/main/governance/rules.json" -o rules.json + curl -sL "https://raw.githubusercontent.com/chittyfoundation/.github/main/governance/CANON_PUBLIC_KEY" -o CANON_PUBLIC_KEY + + - name: Verify Rules Signature + run: | + SIGNATURE=$(jq -r '.signature.signature // empty' rules.json) + if [[ -z "$SIGNATURE" ]]; then + echo "::error::Governance rules are NOT SIGNED. Human must run sign-rules.sh and publish CANON_PUBLIC_KEY + signed rules.json." + exit 1 + fi + + CANONICAL=$(jq -cS 'del(.signature.signature) | del(.signature.signed_at)' rules.json) + echo -n "$CANONICAL" > canonical.json + echo "$SIGNATURE" | base64 -d > signature.bin + + openssl pkeyutl -verify -pubin -inkey CANON_PUBLIC_KEY -sigfile signature.bin -in canonical.json + + - name: Require CODEOWNERS + run: | + if [[ -f "CODEOWNERS" || -f ".github/CODEOWNERS" || -f "docs/CODEOWNERS" ]]; then + exit 0 + fi + echo "::error::Missing CODEOWNERS" + exit 1 + + - name: Check Protected File Changes + run: | + CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }}) + echo "Changed files:" + echo "$CHANGED_FILES" + echo "" + + PROTECTED_PATTERNS=$(jq -r '.rules.protected_files.files[]' rules.json) + VIOLATIONS="" + + for pattern in $PROTECTED_PATTERNS; do + REGEX=$(echo "$pattern" | sed 's/\*\*/.*/' | sed 's/\*/.*/g') + while IFS= read -r file; do + if [[ "$file" =~ $REGEX ]]; then + VIOLATIONS="${VIOLATIONS}${file} matches protected pattern: ${pattern}\n" + fi + done <<< "$CHANGED_FILES" + done + + if [[ -n "$VIOLATIONS" ]]; then + echo "::warning::Protected files modified (extra scrutiny required):" + echo -e "$VIOLATIONS" + fi + + - name: Require Human Approval + uses: actions/github-script@v7 + with: + script: | + const { data: reviews } = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number + }); + const humanApprovals = reviews.filter(r => r.state === 'APPROVED' && !r.user.login.includes('[bot]')); + const required = 1; + if (humanApprovals.length < required) { + core.setFailed(`Requires at least ${required} human approval(s). Current: ${humanApprovals.length}`); + } + + hardening: + name: Portfolio Hardening Check + runs-on: ubuntu-latest + steps: + - name: Checkout PR + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Baseline Hardening + shell: bash + run: | + set -euo pipefail + fail() { echo "::error::$1"; exit 1; } + warn() { echo "::warning::$1"; } + + OWNERS_URL="https://raw.githubusercontent.com/chittyfoundation/.github/main/governance/owners.json" + REQS_URL="https://raw.githubusercontent.com/chittyfoundation/.github/main/governance/repo_requirements.json" + curl -sL "$OWNERS_URL" -o owners.json || fail "Unable to fetch ownership registry: $OWNERS_URL" + curl -sL "$REQS_URL" -o repo_requirements.json || fail "Unable to fetch repo requirements: $REQS_URL" + + if [[ ! -f "SECURITY.md" && ! -f ".github/SECURITY.md" ]]; then + fail "Missing SECURITY.md" + fi + if git ls-files --error-unmatch .env >/dev/null 2>&1; then + fail "Tracked .env detected" + fi + if git ls-files | grep -qE '(^|/)node_modules/'; then + fail "Tracked node_modules detected" + fi + if git ls-files | grep -qE '(^|/)\\.wrangler/'; then + fail "Tracked .wrangler detected" + fi + if [[ -f "package.json" ]]; then + LOCKS=0 + [[ -f "package-lock.json" ]] && LOCKS=$((LOCKS+1)) + [[ -f "pnpm-lock.yaml" ]] && LOCKS=$((LOCKS+1)) + [[ -f "yarn.lock" ]] && LOCKS=$((LOCKS+1)) + if [[ "$LOCKS" -eq 0 ]]; then + fail "package.json present but no lockfile found" + fi + fi + + # Eligibility gating (warn-only until registry is populated) + OWNERS_FILE="CODEOWNERS" + [[ -f ".github/CODEOWNERS" ]] && OWNERS_FILE=".github/CODEOWNERS" + PRINCIPALS=$(sed 's/#.*$//' "$OWNERS_FILE" | tr '\t' ' ' | tr ' ' '\n' | grep '^@' | sort -u || true) + [[ -n "$PRINCIPALS" ]] || exit 0 + + FULL_REPO="${GITHUB_REPOSITORY}" + REQUIREMENT=$(jq -c --arg repo "$FULL_REPO" ' + def glob_to_re($g): + "^" + ($g + | gsub("\\."; "\\\\.") + | gsub("\\*\\*"; ".*") + | gsub("\\*"; "[^/]*") + ) + "$"; + .repos + | map(. + { re: (glob_to_re(.repo)) }) + | map(select($repo | test(.re))) + | .[0] // empty + ' repo_requirements.json) + + [[ -n "$REQUIREMENT" ]] || exit 0 + + REQ_TY=$(echo "$REQUIREMENT" | jq -r '.requires.ty[]?' 2>/dev/null || true) + REQ_VY=$(echo "$REQUIREMENT" | jq -r '.requires.vy[]?' 2>/dev/null || true) + REQ_RY=$(echo "$REQUIREMENT" | jq -r '.requires.ry[]?' 2>/dev/null || true) + + now_epoch=$(date -u +%s) + term_present() { + local row_json="$1"; local dim="$2"; local term="$3" + echo "$row_json" | jq -e --arg dim "$dim" --arg term "$term" ' + ((.[ $dim ].given // []) | any(.term == $term)) or + ((.[ $dim ].earned // []) | any(.term == $term)) + ' >/dev/null + } + term_is_given() { + local row_json="$1"; local dim="$2"; local term="$3" + echo "$row_json" | jq -e --arg dim "$dim" --arg term "$term" ' + ((.[ $dim ].given // []) | any(.term == $term)) + ' >/dev/null + } + given_expiry_ok() { + local row_json="$1"; local dim="$2"; local term="$3"; local exp; local exp_epoch + exp=$(echo "$row_json" | jq -r --arg dim "$dim" --arg term "$term" ' + ([.[ $dim ].given[]? | select(.term == $term) | .expires_at] | .[0]) // empty + ') + [[ -n "$exp" ]] || return 2 + exp_epoch=$(date -u -d "$exp" +%s 2>/dev/null || echo "") + [[ -n "$exp_epoch" ]] || return 3 + [[ "$exp_epoch" -ge "$now_epoch" ]] + } + + while IFS= read -r principal; do + [[ -z "$principal" ]] && continue + row=$(jq -c --arg gh "$principal" '.principals[]? | select(.github==$gh)' owners.json || true) + if [[ -z "$row" ]]; then + warn "CODEOWNERS principal missing from registry: $principal" + continue + fi + ok="true" + for t in $REQ_TY; do + term_present "$row" "ty" "$t" || ok="false" + if term_is_given "$row" "ty" "$t"; then + if ! given_expiry_ok "$row" "ty" "$t"; then ok="false"; fi + fi + done + for v in $REQ_VY; do + term_present "$row" "vy" "$v" || ok="false" + if term_is_given "$row" "vy" "$v"; then + if ! given_expiry_ok "$row" "vy" "$v"; then ok="false"; fi + fi + done + for r in $REQ_RY; do + term_present "$row" "ry" "$r" || ok="false" + if term_is_given "$row" "ry" "$r"; then + if ! given_expiry_ok "$row" "ry" "$r"; then ok="false"; fi + fi + done + if [[ "$ok" != "true" ]]; then + warn "CODEOWNERS principal fails TY/VY/RY eligibility: $principal" + fi + done <<< "$PRINCIPALS" \ No newline at end of file