From 0ba8f4079124774a9397bcc62bb6bbc5765cc124 Mon Sep 17 00:00:00 2001
From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com>
Date: Mon, 6 Apr 2026 03:46:32 +0000
Subject: [PATCH 1/3] feat: add apply-rulesets.sh to create code-quality
 ruleset
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Adds scripts/apply-rulesets.sh — an IaC script that creates or updates
the code-quality repository ruleset (required status checks) for any
petry-projects repo via the GitHub API.

The script:
- Auto-detects which CI workflows are present and derives the correct
  GitHub Actions check context strings (<workflow-name> / <job-name>)
- Supports --dry-run to preview without applying
- Creates or updates the ruleset idempotently (POST or PUT)
- Supports --all to run across every non-archived org repo

Closes #49

Co-authored-by: don-petry <don-petry@users.noreply.github.com>
---
 scripts/apply-rulesets.sh | 299 ++++++++++++++++++++++++++++++++++++++
 1 file changed, 299 insertions(+)
 create mode 100755 scripts/apply-rulesets.sh

diff --git a/scripts/apply-rulesets.sh b/scripts/apply-rulesets.sh
new file mode 100755
index 00000000..91c17524
--- /dev/null
+++ b/scripts/apply-rulesets.sh
@@ -0,0 +1,299 @@
+#!/usr/bin/env bash
+# apply-rulesets.sh — Apply standard repository rulesets to petry-projects repos
+#
+# Companion script to compliance-audit.sh. Creates or updates the rulesets defined in:
+#   standards/github-settings.md#repository-rulesets
+#
+# Rulesets managed:
+#   code-quality  — required status checks (CI, SonarCloud, CodeQL, Claude Code)
+#
+# Usage:
+#   # Apply to a specific repo:
+#   GH_TOKEN=<admin-token> ./scripts/apply-rulesets.sh <repo-name>
+#
+#   # Apply to all non-archived org repos:
+#   GH_TOKEN=<admin-token> ./scripts/apply-rulesets.sh --all
+#
+#   # Dry run (show what would be changed):
+#   GH_TOKEN=<admin-token> ./scripts/apply-rulesets.sh <repo-name> --dry-run
+#
+# Requirements:
+#   - GH_TOKEN must have administration:write scope on the target repo(s)
+#   - gh CLI must be installed
+#   - jq must be installed
+
+set -euo pipefail
+
+ORG="petry-projects"
+DRY_RUN=false
+
+info()  { echo "[INFO]  $*"; }
+ok()    { echo "[OK]    $*"; }
+err()   { echo "[ERROR] $*" >&2; }
+skip()  { echo "[SKIP]  $*"; }
+
+usage() {
+  echo "Usage: $0 <repo-name> [--dry-run]"
+  echo "       $0 --all [--dry-run]"
+  echo ""
+  echo "Environment variables:"
+  echo "  GH_TOKEN   GitHub token with administration:write scope (required)"
+  exit 1
+}
+
+# ---------------------------------------------------------------------------
+# Detect required status checks from a repo's workflow files
+# ---------------------------------------------------------------------------
+detect_required_checks() {
+  local repo="$1"
+  local checks=()
+
+  # Fetch list of workflow files present in the repo
+  local workflows
+  workflows=$(gh api "repos/$ORG/$repo/contents/.github/workflows" \
+    --jq '.[].name' 2>/dev/null || echo "")
+
+  # Helper: fetch workflow file content and extract the top-level `name:` field
+  workflow_name() {
+    local file="$1"
+    gh api "repos/$ORG/$repo/contents/.github/workflows/$file" \
+      --jq '.content' 2>/dev/null \
+      | base64 -d 2>/dev/null \
+      | grep -m1 '^name:' \
+      | sed 's/^name:[[:space:]]*//' \
+      | tr -d '"'"'" \
+      | sed "s/'//g" \
+      || echo ""
+  }
+
+  # Helper: fetch all job display names (name: field, or job id if no name)
+  workflow_job_names() {
+    local file="$1"
+    local content
+    content=$(gh api "repos/$ORG/$repo/contents/.github/workflows/$file" \
+      --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || echo "")
+    # Extract job names from YAML (lines under `jobs:` section with `  name:`)
+    echo "$content" | awk '
+      /^jobs:/ { in_jobs=1; next }
+      in_jobs && /^  [a-zA-Z0-9_-]+:/ { job_id=substr($0, 3); gsub(/:.*/, "", job_id); current_job=job_id; has_name=0; next }
+      in_jobs && /^    name:/ { name=substr($0, 11); gsub(/^[ \t]+|[ \t]+$/, "", name); gsub(/["\x27]/, "", name); print name; has_name=1; next }
+      in_jobs && /^  [a-zA-Z0-9_-]+:/ && !has_name { print current_job; next }
+    '
+  }
+
+  # --- SonarCloud ---
+  if echo "$workflows" | grep -qx "sonarcloud.yml"; then
+    local sc_wf_name
+    sc_wf_name=$(workflow_name "sonarcloud.yml")
+    if [ -n "$sc_wf_name" ]; then
+      checks+=("$sc_wf_name / SonarCloud")
+    else
+      checks+=("SonarCloud")
+    fi
+  fi
+
+  # --- CodeQL ---
+  if echo "$workflows" | grep -qx "codeql.yml"; then
+    local cq_wf_name
+    cq_wf_name=$(workflow_name "codeql.yml")
+    if [ -n "$cq_wf_name" ]; then
+      # CodeQL uses "Analyze" or "Analyze (<language>)" as job names;
+      # add the generic "Analyze" and language-specific variants below
+      checks+=("$cq_wf_name / Analyze")
+    else
+      checks+=("Analyze")
+    fi
+  fi
+
+  # --- Claude Code ---
+  if echo "$workflows" | grep -qx "claude.yml"; then
+    local cl_wf_name
+    cl_wf_name=$(workflow_name "claude.yml")
+    if [ -n "$cl_wf_name" ]; then
+      checks+=("$cl_wf_name / claude")
+    else
+      checks+=("claude")
+    fi
+  fi
+
+  # --- CI Pipeline ---
+  if echo "$workflows" | grep -qx "ci.yml"; then
+    local ci_wf_name
+    ci_wf_name=$(workflow_name "ci.yml")
+    # Fetch the first job name from ci.yml
+    local ci_job_name
+    ci_job_name=$(gh api "repos/$ORG/$repo/contents/.github/workflows/ci.yml" \
+      --jq '.content' 2>/dev/null \
+      | base64 -d 2>/dev/null \
+      | awk '
+          /^jobs:/ { in_jobs=1; found=0; next }
+          in_jobs && /^  [a-zA-Z0-9_-]+:/ && !found {
+            job_id=substr($0, 3); gsub(/:.*/, "", job_id); current_job=job_id; next
+          }
+          in_jobs && /^    name:/ && !found {
+            name=substr($0, 11); gsub(/^[ \t]+|[ \t]+$/, "", name); gsub(/["\x27]/, "", name)
+            print name; found=1; exit
+          }
+        ' 2>/dev/null || echo "")
+    if [ -z "$ci_job_name" ]; then
+      ci_job_name="build"
+    fi
+    if [ -n "$ci_wf_name" ]; then
+      checks+=("$ci_wf_name / $ci_job_name")
+    else
+      checks+=("$ci_job_name")
+    fi
+  fi
+
+  # Output as newline-separated list
+  printf '%s\n' "${checks[@]}"
+}
+
+# ---------------------------------------------------------------------------
+# Build the ruleset JSON payload
+# ---------------------------------------------------------------------------
+build_ruleset_json() {
+  local name="$1"
+  shift
+  local checks=("$@")
+
+  # Build the required_status_checks array
+  local checks_json
+  checks_json=$(printf '%s\n' "${checks[@]}" | jq -R '{"context": .}' | jq -s '.')
+
+  jq -n \
+    --arg name "$name" \
+    --argjson checks "$checks_json" \
+    '{
+      name: $name,
+      target: "branch",
+      enforcement: "active",
+      conditions: {
+        ref_name: {
+          include: ["~DEFAULT_BRANCH"],
+          exclude: []
+        }
+      },
+      rules: [
+        {
+          type: "required_status_checks",
+          parameters: {
+            strict_required_status_checks_policy: true,
+            do_not_enforce_on_create: false,
+            required_status_checks: $checks
+          }
+        }
+      ],
+      bypass_actors: []
+    }'
+}
+
+# ---------------------------------------------------------------------------
+# Apply rulesets to a single repo
+# ---------------------------------------------------------------------------
+apply_rulesets() {
+  local repo="$1"
+  info "Processing $ORG/$repo ..."
+
+  # Fetch existing rulesets
+  local existing_rulesets
+  existing_rulesets=$(gh api "repos/$ORG/$repo/rulesets" 2>/dev/null || echo "[]")
+
+  # --- code-quality ruleset ---
+  local existing_id
+  existing_id=$(echo "$existing_rulesets" | jq -r '.[] | select(.name == "code-quality") | .id' 2>/dev/null || echo "")
+
+  # Detect required checks for this repo
+  local checks=()
+  mapfile -t checks < <(detect_required_checks "$repo")
+
+  if [ "${#checks[@]}" -eq 0 ]; then
+    err "  No required checks detected for $ORG/$repo — skipping code-quality ruleset"
+    return 1
+  fi
+
+  info "  Detected required checks:"
+  for c in "${checks[@]}"; do
+    info "    - $c"
+  done
+
+  local payload
+  payload=$(build_ruleset_json "code-quality" "${checks[@]}")
+
+  if [ "$DRY_RUN" = true ]; then
+    if [ -n "$existing_id" ]; then
+      skip "  DRY_RUN — would UPDATE code-quality ruleset (id=$existing_id) for $ORG/$repo"
+    else
+      skip "  DRY_RUN — would CREATE code-quality ruleset for $ORG/$repo"
+    fi
+    echo "$payload" | jq '.'
+    return 0
+  fi
+
+  if [ -n "$existing_id" ]; then
+    info "  Updating existing code-quality ruleset (id=$existing_id) ..."
+    gh api -X PUT "repos/$ORG/$repo/rulesets/$existing_id" \
+      --input <(echo "$payload") > /dev/null
+    ok "  code-quality ruleset updated for $ORG/$repo"
+  else
+    info "  Creating code-quality ruleset ..."
+    gh api -X POST "repos/$ORG/$repo/rulesets" \
+      --input <(echo "$payload") > /dev/null
+    ok "  code-quality ruleset created for $ORG/$repo"
+  fi
+}
+
+# ---------------------------------------------------------------------------
+# Parse arguments
+# ---------------------------------------------------------------------------
+if [ $# -eq 0 ]; then
+  usage
+fi
+
+if [ -z "${GH_TOKEN:-}" ]; then
+  err "GH_TOKEN is required — provide a token with administration:write scope"
+  exit 1
+fi
+
+export GH_TOKEN
+
+TARGET=""
+for arg in "$@"; do
+  case "$arg" in
+    --dry-run) DRY_RUN=true ;;
+    --all)     TARGET="--all" ;;
+    -*)        err "Unknown flag: $arg"; usage ;;
+    *)         TARGET="$arg" ;;
+  esac
+done
+
+if [ -z "$TARGET" ]; then
+  usage
+fi
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+if [ "$TARGET" = "--all" ]; then
+  info "Fetching all non-archived repos in $ORG ..."
+  repos=$(gh repo list "$ORG" --no-archived --json name -q '.[].name' --limit 500)
+
+  if [ -z "$repos" ]; then
+    err "No repositories found in $ORG — check GH_TOKEN permissions"
+    exit 1
+  fi
+
+  failed=0
+  for repo in $repos; do
+    apply_rulesets "$repo" || failed=$((failed + 1))
+  done
+
+  if [ "$failed" -gt 0 ]; then
+    err "$failed repo(s) failed — check output above for details"
+    exit 1
+  fi
+
+  ok "All repos processed successfully"
+else
+  apply_rulesets "$TARGET"
+fi

From a67e2319d9843d9016688cc5782fc8858c58917f Mon Sep 17 00:00:00 2001
From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com>
Date: Mon, 6 Apr 2026 03:47:56 +0000
Subject: [PATCH 2/3] fix: remove dead workflow_job_names function; guard empty
 checks array
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Remove unused workflow_job_names() helper — dead code never called
- Guard printf against empty array with set -u on older bash versions

Co-authored-by: don-petry <don-petry@users.noreply.github.com>
---
 scripts/apply-rulesets.sh | 19 ++-----------------
 1 file changed, 2 insertions(+), 17 deletions(-)
 mode change 100755 => 100644 scripts/apply-rulesets.sh

diff --git a/scripts/apply-rulesets.sh b/scripts/apply-rulesets.sh
old mode 100755
new mode 100644
index 91c17524..89465612
--- a/scripts/apply-rulesets.sh
+++ b/scripts/apply-rulesets.sh
@@ -66,21 +66,6 @@ detect_required_checks() {
       || echo ""
   }
 
-  # Helper: fetch all job display names (name: field, or job id if no name)
-  workflow_job_names() {
-    local file="$1"
-    local content
-    content=$(gh api "repos/$ORG/$repo/contents/.github/workflows/$file" \
-      --jq '.content' 2>/dev/null | base64 -d 2>/dev/null || echo "")
-    # Extract job names from YAML (lines under `jobs:` section with `  name:`)
-    echo "$content" | awk '
-      /^jobs:/ { in_jobs=1; next }
-      in_jobs && /^  [a-zA-Z0-9_-]+:/ { job_id=substr($0, 3); gsub(/:.*/, "", job_id); current_job=job_id; has_name=0; next }
-      in_jobs && /^    name:/ { name=substr($0, 11); gsub(/^[ \t]+|[ \t]+$/, "", name); gsub(/["\x27]/, "", name); print name; has_name=1; next }
-      in_jobs && /^  [a-zA-Z0-9_-]+:/ && !has_name { print current_job; next }
-    '
-  }
-
   # --- SonarCloud ---
   if echo "$workflows" | grep -qx "sonarcloud.yml"; then
     local sc_wf_name
@@ -145,8 +130,8 @@ detect_required_checks() {
     fi
   fi
 
-  # Output as newline-separated list
-  printf '%s\n' "${checks[@]}"
+  # Output as newline-separated list (guard against empty array with set -u)
+  [ "${#checks[@]}" -gt 0 ] && printf '%s\n' "${checks[@]}" || true
 }
 
 # ---------------------------------------------------------------------------

From f6db283a3f59275c6cb1fa570f83be617cbacbce Mon Sep 17 00:00:00 2001
From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com>
Date: Mon, 6 Apr 2026 03:48:02 +0000
Subject: [PATCH 3/3] fix: restore executable bit on apply-rulesets.sh

Co-authored-by: don-petry <don-petry@users.noreply.github.com>
---
 scripts/apply-rulesets.sh | 0
 1 file changed, 0 insertions(+), 0 deletions(-)
 mode change 100644 => 100755 scripts/apply-rulesets.sh

diff --git a/scripts/apply-rulesets.sh b/scripts/apply-rulesets.sh
old mode 100644
new mode 100755
