From 510c9b945d748e7476a17171d32bc140362eb606 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 4 Jun 2026 15:37:31 +0200 Subject: [PATCH] chore: validate CODEOWNERS in CI and act Split workflow into validate (PR + act) and update (dev bot PR). Add structure checks per GitHub docs, API error validation, and act config. --- .actrc | 2 + .github/workflows/update-codeowners.yml | 63 ++++++++-- package.json | 1 + scripts/test-codeowners-act.sh | 17 +++ scripts/validate-codeowners.mts | 145 ++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 .actrc create mode 100755 scripts/test-codeowners-act.sh create mode 100644 scripts/validate-codeowners.mts diff --git a/.actrc b/.actrc new file mode 100644 index 000000000..a08e327d0 --- /dev/null +++ b/.actrc @@ -0,0 +1,2 @@ +-P ubuntu-latest=catthehacker/ubuntu:act-latest +--container-architecture linux/amd64 diff --git a/.github/workflows/update-codeowners.yml b/.github/workflows/update-codeowners.yml index e98e9e04e..d81568b20 100644 --- a/.github/workflows/update-codeowners.yml +++ b/.github/workflows/update-codeowners.yml @@ -2,6 +2,16 @@ name: Update CODEOWNERS on: workflow_dispatch: {} + pull_request: + paths: + - CODEOWNERS + - packages/widgets/src/** + - packages/integrations/src/** + - packages/api/src/router/widgets/** + - packages/cron-jobs/src/jobs/** + - scripts/generate-codeowners.mts + - scripts/validate-codeowners.mts + - scripts/codeowners-overrides.json push: paths: - packages/widgets/src/** @@ -9,20 +19,54 @@ on: - packages/api/src/router/widgets/** - packages/cron-jobs/src/jobs/** - scripts/generate-codeowners.mts + - scripts/validate-codeowners.mts - scripts/codeowners-overrides.json branches: - dev permissions: + contents: read pull-requests: write jobs: - update-codeowners: + validate: + name: Validate CODEOWNERS + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version-file: .nvmrc + + - uses: pnpm/action-setup@v6 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check CODEOWNERS is up to date + run: pnpm exec tsx ./scripts/generate-codeowners.mts --check + + - name: Validate CODEOWNERS + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: pnpm exec tsx ./scripts/validate-codeowners.mts + + update: + name: Open CODEOWNERS update PR + needs: validate + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + runs-on: ubuntu-latest + timeout-minutes: 10 concurrency: group: update-codeowners cancel-in-progress: false - runs-on: ubuntu-latest - timeout-minutes: 5 steps: - name: Obtain token id: obtainToken @@ -40,13 +84,12 @@ jobs: env: GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} - - uses: pnpm/action-setup@v6 - - name: Use Node.js uses: actions/setup-node@v6 with: - node-version: 22 - cache: pnpm + node-version-file: .nvmrc + + - uses: pnpm/action-setup@v6 - name: Install dependencies run: pnpm install --frozen-lockfile @@ -56,6 +99,12 @@ jobs: GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} run: pnpm scripts:generate-codeowners + - name: Validate CODEOWNERS + env: + GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: pnpm exec tsx ./scripts/validate-codeowners.mts + - name: Create Pull Request id: create-pull-request uses: peter-evans/create-pull-request@v8 diff --git a/package.json b/package.json index 3ad4870a1..3927963bf 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "scripts:update-bug-report-template": "tsx ./scripts/update-bug-report-template.mts", "scripts:update-readme-integrations": "tsx ./scripts/update-integration-list.mts", "scripts:generate-codeowners": "tsx ./scripts/generate-codeowners.mts", + "scripts:validate-codeowners": "tsx ./scripts/validate-codeowners.mts", "start": "concurrently \"pnpm with-env node apps/tasks/tasks.cjs\" \"pnpm with-env node apps/websocket/wssServer.cjs\" \"pnpm -F nextjs start\"", "test": "cross-env NODE_ENV=development CI=true vitest run --exclude e2e --coverage.enabled ", "test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e", diff --git a/scripts/test-codeowners-act.sh b/scripts/test-codeowners-act.sh new file mode 100755 index 000000000..61c9f24e3 --- /dev/null +++ b/scripts/test-codeowners-act.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==> Local CODEOWNERS checks" +pnpm exec tsx ./scripts/generate-codeowners.mts --check +export GITHUB_REPOSITORY="${GITHUB_REPOSITORY:-homarr-labs/homarr}" +if [ -z "${GITHUB_TOKEN:-}" ] && [ -z "${GH_TOKEN:-}" ]; then + GITHUB_TOKEN="$(gh auth token 2>/dev/null || true)" + export GITHUB_TOKEN +fi +pnpm exec tsx ./scripts/validate-codeowners.mts + +echo "==> act: validate job (requires Docker)" +act workflow_dispatch -W .github/workflows/update-codeowners.yml -j validate \ + -s GITHUB_TOKEN="${GITHUB_TOKEN:-}" diff --git a/scripts/validate-codeowners.mts b/scripts/validate-codeowners.mts new file mode 100644 index 000000000..43913a93d --- /dev/null +++ b/scripts/validate-codeowners.mts @@ -0,0 +1,145 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +const REPO_ROOT = join(import.meta.dirname, ".."); +const CODEOWNERS_PATH = join(REPO_ROOT, "CODEOWNERS"); + +const BEGIN_MARKER = "# BEGIN GENERATED widget/integration owners"; +const END_MARKER = "# END GENERATED widget/integration owners"; + +const UNSUPPORTED_PATTERN_CHECKS: { label: string; pattern: RegExp }[] = [ + { label: "negation (!)", pattern: /^\s*!/ }, + { label: "character range ([ ])", pattern: /^\s*[^#]*\[[^\]]+\]/ }, +]; + +const parseOwners = (line: string) => [...line.matchAll(/@[\w-]+(?:\/[\w-]+)?/g)].map((match) => match[0]); + +const parsePatterns = (line: string) => { + const withoutOwners = line.replace(/@[\w-]+(?:\/[\w-]+)?/g, "").trim(); + return withoutOwners.split(/\s+/).filter((token) => token.length > 0 && !token.startsWith("#")); +}; + +const validateLine = (line: string, lineNumber: number) => { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith("#")) return []; + + const errors: string[] = []; + + for (const check of UNSUPPORTED_PATTERN_CHECKS) { + if (check.pattern.test(line)) { + errors.push(`line ${lineNumber}: unsupported CODEOWNERS syntax (${check.label})`); + } + } + + const owners = parseOwners(line); + const patterns = parsePatterns(line); + + if (patterns.length === 0 && owners.length === 0) return errors; + + if (patterns.length > 0 && owners.length === 0) return errors; + + if (owners.length > 0 && patterns.length === 0) { + errors.push(`line ${lineNumber}: owners without file patterns`); + } + + for (const pattern of patterns) { + const allowedGlobal = pattern === "*"; + const hasValidShape = + allowedGlobal || pattern.startsWith("/") || pattern.includes("*") || pattern.includes("."); + if (!hasValidShape) { + errors.push(`line ${lineNumber}: pattern "${pattern}" should start with / or contain a wildcard`); + } + } + + for (const owner of owners) { + if (!owner.startsWith("@")) { + errors.push(`line ${lineNumber}: invalid owner "${owner}"`); + } + } + + return errors; +}; + +const validateStructure = (content: string) => { + const errors: string[] = []; + const lines = content.split("\n"); + + if (!content.includes("* @")) { + errors.push("missing default owner rule (* @team or * @user) — last matching pattern wins per GitHub docs"); + } + + const starIndex = lines.findIndex((line) => /^\*(\s+|$)/.test(line.trim())); + const beginIndex = lines.findIndex((line) => line.includes(BEGIN_MARKER)); + const endIndex = lines.findIndex((line) => line.includes(END_MARKER)); + + if (beginIndex === -1 || endIndex === -1) { + errors.push("missing generated CODEOWNERS markers"); + return errors; + } + + if (starIndex !== -1 && beginIndex !== -1 && starIndex > beginIndex) { + errors.push("global * rule must appear before generated feature rules so per-folder owners override maintainers"); + } + + if (endIndex <= beginIndex) { + errors.push("END GENERATED marker must follow BEGIN GENERATED marker"); + } + + for (let index = 0; index < lines.length; index += 1) { + const lineErrors = validateLine(lines[index] ?? "", index + 1); + errors.push(...lineErrors); + } + + return errors; +}; + +const validateGithubApi = async () => { + const token = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; + const repository = process.env.GITHUB_REPOSITORY ?? "homarr-labs/homarr"; + + if (!token) { + console.warn("Skipping GitHub API CODEOWNERS check (GITHUB_TOKEN not set)."); + return; + } + + const response = await fetch(`https://api.github.com/repos/${repository}/codeowners/errors`, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (!response.ok) { + throw new Error(`GitHub API codeowners/errors failed: ${response.status} ${response.statusText}`); + } + + const payload = (await response.json()) as { errors: unknown[] }; + const apiErrors = payload.errors ?? []; + + if (apiErrors.length > 0) { + console.error("GitHub reported CODEOWNERS syntax errors:"); + console.error(JSON.stringify(apiErrors, null, 2)); + process.exit(1); + } + + console.log("GitHub API: no CODEOWNERS syntax errors."); +}; + +const main = async () => { + const content = await readFile(CODEOWNERS_PATH, "utf8"); + const errors = validateStructure(content); + + if (errors.length > 0) { + console.error("CODEOWNERS validation failed:"); + for (const error of errors) { + console.error(` ${error}`); + } + process.exit(1); + } + + console.log("CODEOWNERS structure validation passed."); + await validateGithubApi(); +}; + +await main();