From 963b9e0afee2d514508a71da0e675c981bda79fc Mon Sep 17 00:00:00 2001 From: Niels Doucet Date: Mon, 27 Apr 2026 16:39:15 +0200 Subject: [PATCH 1/2] ARCH-4643 Add org-wide CODEOWNERS enforcement workflow Introduces a Required Workflow (org-workflows/enforce-codeowners-teams.yml) that blocks PRs across the entire organisation when CODEOWNERS entries name individual GitHub users instead of @org/team references. Logic is extracted to a testable shell script (scripts/check-codeowners.sh) with 16 BATS tests. A repo-local workflow (.github/workflows/test-scripts.yml) runs the BATS suite on changes to scripts/. Co-Authored-By: Claude Sonnet 4.6 --- .github/runs-on.yml | 1 + .github/workflows/test-scripts.yml | 24 ++++ .gitignore | 1 + README.md | 31 +++++- org-workflows/enforce-codeowners-teams.yml | 27 +++++ scripts/check-codeowners.bats | 124 +++++++++++++++++++++ scripts/check-codeowners.sh | 56 ++++++++++ 7 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 .github/runs-on.yml create mode 100644 .github/workflows/test-scripts.yml create mode 100644 .gitignore create mode 100644 org-workflows/enforce-codeowners-teams.yml create mode 100644 scripts/check-codeowners.bats create mode 100644 scripts/check-codeowners.sh diff --git a/.github/runs-on.yml b/.github/runs-on.yml new file mode 100644 index 0000000..b1bfde5 --- /dev/null +++ b/.github/runs-on.yml @@ -0,0 +1 @@ +_extends: .github-private diff --git a/.github/workflows/test-scripts.yml b/.github/workflows/test-scripts.yml new file mode 100644 index 0000000..757a1df --- /dev/null +++ b/.github/workflows/test-scripts.yml @@ -0,0 +1,24 @@ +name: Test scripts + +on: + push: + paths: ["scripts/**"] + pull_request: + paths: ["scripts/**"] + +jobs: + bats: + name: BATS + runs-on: + - runs-on + - runner=tn + - env=production-eu + - run-id=${{ github.run_id }} + steps: + - uses: actions/checkout@v6 + + - name: Install bats-core + run: sudo apt-get update && sudo apt-get install -y bats + + - name: Run tests + run: bats scripts/check-codeowners.bats diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f11b75 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea/ diff --git a/README.md b/README.md index 60451ae..f898a78 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,31 @@ # .github -A repo for org wide health files (e.g. PR template). +A repo for organization-wide configuration. + +## PULL_REQUEST_TEMPLATE.md + +Global PR template. This must be followed for auditing purposes. +Teams may only set specific PR templates that deviate from this template with proper approval. + +## org-workflows/ + +Workflows applied across the entire organization via **Required Workflows** +(Org Settings → Actions → Required workflows). Each workflow must be registered +there by an org admin, pointing at `collibra/.github/@main`. + +| File | Purpose | +|----------------------------------------------|-------------------------------------------------------------------| +| `org-workflows/enforce-codeowners-teams.yml` | Rejects CODEOWNERS entries that name individuals instead of teams | + +## .github/workflows/ + +Regular workflows that run only within this repository (e.g. CI for the scripts +in `scripts/`). + +## scripts/ + +Shell scripts used by the workflows in `org-workflows/`, with BATS tests alongside them. +Run tests locally with: + +```sh +bats scripts/check-codeowners.bats +``` diff --git a/org-workflows/enforce-codeowners-teams.yml b/org-workflows/enforce-codeowners-teams.yml new file mode 100644 index 0000000..7bae48e --- /dev/null +++ b/org-workflows/enforce-codeowners-teams.yml @@ -0,0 +1,27 @@ +# This workflow is configured as a Required Workflow at the organization level +# (Org Settings → Actions → Required workflows) so that it runs on every pull +# request across all repositories. Do not remove the pull_request trigger or +# narrow its scope — doing so would break org-wide enforcement. +name: Enforce team-only CODEOWNERS + +on: + pull_request: + branches: ["**"] + +jobs: + check-codeowners: + name: CODEOWNERS — teams only + runs-on: + - runs-on + - runner=tn + - env=production-eu + - run-id=${{ github.run_id }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/checkout@v6 + with: + repository: ${{ github.repository_owner }}/.github + path: .github-shared + + - run: bash .github-shared/scripts/check-codeowners.sh diff --git a/scripts/check-codeowners.bats b/scripts/check-codeowners.bats new file mode 100644 index 0000000..0b60be0 --- /dev/null +++ b/scripts/check-codeowners.bats @@ -0,0 +1,124 @@ +#!/usr/bin/env bats + +setup() { + # shellcheck source=check-codeowners.sh + source "$BATS_TEST_DIRNAME/check-codeowners.sh" + FIXTURE_DIR="$(mktemp -d)" +} + +teardown() { + rm -rf "$FIXTURE_DIR" +} + +# --------------------------------------------------------------------------- +# extract_individuals +# --------------------------------------------------------------------------- + +@test "extract_individuals: empty output for team-only entries" { + printf "* @org/backend-team\n/src @org/frontend-team\n" > "$FIXTURE_DIR/CODEOWNERS" + run extract_individuals "$FIXTURE_DIR/CODEOWNERS" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "extract_individuals: detects a single individual" { + printf "* @johndoe\n" > "$FIXTURE_DIR/CODEOWNERS" + run extract_individuals "$FIXTURE_DIR/CODEOWNERS" + [ "$output" = "@johndoe" ] +} + +@test "extract_individuals: detects individual mixed with teams on the same line" { + printf "* @org/team @johndoe\n" > "$FIXTURE_DIR/CODEOWNERS" + run extract_individuals "$FIXTURE_DIR/CODEOWNERS" + [ "$output" = "@johndoe" ] +} + +@test "extract_individuals: detects multiple individuals across lines" { + printf "* @alice\n/docs @bob\n" > "$FIXTURE_DIR/CODEOWNERS" + run extract_individuals "$FIXTURE_DIR/CODEOWNERS" + [ "$output" = "@alice"$'\n'"@bob" ] +} + +@test "extract_individuals: ignores comment lines" { + printf "# @individual-in-comment\n* @org/team\n" > "$FIXTURE_DIR/CODEOWNERS" + run extract_individuals "$FIXTURE_DIR/CODEOWNERS" + [ -z "$output" ] +} + +@test "extract_individuals: ignores email addresses" { + printf "* user@example.com\n" > "$FIXTURE_DIR/CODEOWNERS" + run extract_individuals "$FIXTURE_DIR/CODEOWNERS" + [ -z "$output" ] +} + +@test "extract_individuals: empty output for empty file" { + touch "$FIXTURE_DIR/CODEOWNERS" + run extract_individuals "$FIXTURE_DIR/CODEOWNERS" + [ -z "$output" ] +} + +@test "extract_individuals: ignores pattern-only lines with no owner (unset ownership)" { + printf "*.lockfile\n/some/path\n* @org/team\n" > "$FIXTURE_DIR/CODEOWNERS" + run extract_individuals "$FIXTURE_DIR/CODEOWNERS" + [ -z "$output" ] +} + +# --------------------------------------------------------------------------- +# find_codeowners +# --------------------------------------------------------------------------- + +@test "find_codeowners: finds CODEOWNERS at root" { + touch "$FIXTURE_DIR/CODEOWNERS" + run find_codeowners "$FIXTURE_DIR" + [ "$status" -eq 0 ] + [ "$output" = "$FIXTURE_DIR/CODEOWNERS" ] +} + +@test "find_codeowners: finds CODEOWNERS under .github/" { + mkdir -p "$FIXTURE_DIR/.github" + touch "$FIXTURE_DIR/.github/CODEOWNERS" + run find_codeowners "$FIXTURE_DIR" + [ "$status" -eq 0 ] + [ "$output" = "$FIXTURE_DIR/.github/CODEOWNERS" ] +} + +@test "find_codeowners: finds CODEOWNERS under docs/" { + mkdir -p "$FIXTURE_DIR/docs" + touch "$FIXTURE_DIR/docs/CODEOWNERS" + run find_codeowners "$FIXTURE_DIR" + [ "$status" -eq 0 ] + [ "$output" = "$FIXTURE_DIR/docs/CODEOWNERS" ] +} + +@test "find_codeowners: root takes priority over .github/" { + mkdir -p "$FIXTURE_DIR/.github" + touch "$FIXTURE_DIR/CODEOWNERS" "$FIXTURE_DIR/.github/CODEOWNERS" + run find_codeowners "$FIXTURE_DIR" + [ "$output" = "$FIXTURE_DIR/CODEOWNERS" ] +} + +@test "find_codeowners: returns non-zero when no CODEOWNERS exists" { + run find_codeowners "$FIXTURE_DIR" + [ "$status" -ne 0 ] +} + +# --------------------------------------------------------------------------- +# main (integration) +# --------------------------------------------------------------------------- + +@test "main: passes for team-only CODEOWNERS" { + printf "* @org/team\n/src @org/other-team\n" > "$FIXTURE_DIR/CODEOWNERS" + run main "$FIXTURE_DIR" + [ "$status" -eq 0 ] +} + +@test "main: fails for individual user in CODEOWNERS" { + printf "* @username\n" > "$FIXTURE_DIR/CODEOWNERS" + run main "$FIXTURE_DIR" + [ "$status" -eq 1 ] +} + +@test "main: passes when no CODEOWNERS file exists" { + run main "$FIXTURE_DIR" + [ "$status" -eq 0 ] +} diff --git a/scripts/check-codeowners.sh b/scripts/check-codeowners.sh new file mode 100644 index 0000000..9af7264 --- /dev/null +++ b/scripts/check-codeowners.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +# Locate a CODEOWNERS file under ROOT (defaults to cwd). +# GitHub resolves CODEOWNERS from three locations in priority order. +find_codeowners() { + local root="${1:-.}" + for path in CODEOWNERS .github/CODEOWNERS docs/CODEOWNERS; do + if [[ -f "$root/$path" ]]; then + echo "$root/$path" + return 0 + fi + done + return 1 +} + +# Print individual-user @mentions from FILE (one per line). +# Team references (@org/team) and email addresses (user@host) are excluded. +# Splits on whitespace first so that embedded @ (e.g. user@host) is never matched. +extract_individuals() { + local file="$1" + grep -v '^\s*#' "$file" \ + | tr ' \t' '\n' \ + | grep -E '^@[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)?$' \ + | grep -v '/' \ + || true +} + +main() { + local root="${1:-.}" + local codeowners_file + + if ! codeowners_file=$(find_codeowners "$root"); then + echo "No CODEOWNERS file found — nothing to check." + exit 0 + fi + + echo "Checking $codeowners_file ..." + + local individuals + individuals=$(extract_individuals "$codeowners_file") + + if [[ -n "$individuals" ]]; then + echo "::error file=$codeowners_file::Individual users are not permitted in CODEOWNERS — use @org/team references instead." + echo "" + echo "Offending entries:" + echo "$individuals" + exit 1 + fi + + echo "All CODEOWNERS entries are team references." +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + set -euo pipefail + main "$@" +fi From 5573dc89abb83fe9af41289d8044c380d491171e Mon Sep 17 00:00:00 2001 From: Niels Doucet Date: Fri, 8 May 2026 15:07:54 +0200 Subject: [PATCH 2/2] ARCH-4643 adjust runs-on and add tag Co-authored-by: Steven Rathbauer --- .github/workflows/test-scripts.yml | 4 ++-- org-workflows/enforce-codeowners-teams.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-scripts.yml b/.github/workflows/test-scripts.yml index 757a1df..0e2ef12 100644 --- a/.github/workflows/test-scripts.yml +++ b/.github/workflows/test-scripts.yml @@ -10,10 +10,10 @@ jobs: bats: name: BATS runs-on: - - runs-on + - runs-on=${{ github.run_id }} - runner=tn - env=production-eu - - run-id=${{ github.run_id }} + - tag=bats steps: - uses: actions/checkout@v6 diff --git a/org-workflows/enforce-codeowners-teams.yml b/org-workflows/enforce-codeowners-teams.yml index 7bae48e..896a4f2 100644 --- a/org-workflows/enforce-codeowners-teams.yml +++ b/org-workflows/enforce-codeowners-teams.yml @@ -12,10 +12,10 @@ jobs: check-codeowners: name: CODEOWNERS — teams only runs-on: - - runs-on + - runs-on=${{ github.run_id }} - runner=tn - env=production-eu - - run-id=${{ github.run_id }} + - tag=check-codeowners steps: - uses: actions/checkout@v6