From 3fda771261a7af7e162e3934024cea393dd7b35c Mon Sep 17 00:00:00 2001 From: Code Maze Date: Thu, 17 Sep 2026 14:07:57 +0200 Subject: [PATCH] ci: fail PRs that carry AI attribution Adds a no-ai-attribution job to PR Build. It checks the PR title, the PR description and every PR commit message for AI co-author trailers and generated-with footers. CI result now depends on it, so a match blocks the merge. The workflow also re-runs when the PR title or description is edited. Also corrects the header comment: CI result is a required check on main. --- .github/workflows/pr-build.yml | 454 ++++++++++++++++++--------------- 1 file changed, 255 insertions(+), 199 deletions(-) diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index e046ff6f5..a21b7a6a9 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -1,199 +1,255 @@ -# Advisory only. This check is never marked as a required status check, and main -# has no branch protection rule referencing it. It builds and tests exactly the -# article folder(s) a PR touches -- nothing else. A folder is classified once: -# "dotnet" when it holds a .sln, "npm" when it holds a package.json at its root -# or in an immediate subfolder (the layout an Angular series folder uses). -# Folders in .github/ci-skip-folders.txt are filtered out before the matrix is -# built, so they never show up as a run, passing or failing (edit that file, not -# this one, to change what's excluded). -name: PR Build - -on: - pull_request: - branches: [main] - -# Cancel superseded runs on the same PR when new commits land. -concurrency: - group: pr-build-${{ github.event.pull_request.number }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - detect-changes: - name: Detect changed article folders - runs-on: ubuntu-latest - outputs: - folders: ${{ steps.compute.outputs.folders }} - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Compute changed, buildable folders - id: compute - shell: bash - run: | - set -euo pipefail - - BASE_SHA="${{ github.event.pull_request.base.sha }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - SKIP_FILE=".github/ci-skip-folders.txt" - - # Skip list: strip comments and blank lines. - mapfile -t skip_list < <( - sed 's/\r$//' "$SKIP_FILE" | grep -vE '^\s*#' | grep -vE '^\s*$' - ) - - is_skipped() { - local f="$1" - for s in "${skip_list[@]:-}"; do - [ "$f" = "$s" ] && return 0 - done - return 1 - } - - # Reduce every changed path to its "/
" prefix. - # Every article folder in this repo is exactly two levels deep. - mapfile -t folders < <( - git diff --name-only "$BASE_SHA" "$HEAD_SHA" \ - | awk -F/ 'NF>=2 {print $1"/"$2}' \ - | sort -u - ) - - entries="[]" - for f in "${folders[@]:-}"; do - [ -z "$f" ] && continue - - if is_skipped "$f"; then - echo "::notice::Skipping '$f' -- listed in $SKIP_FILE." - continue - fi - - # Classify the folder. A .sln makes it a .NET folder; otherwise a - # package.json at the folder root or one level down makes it an npm - # folder. Anything else has nothing to build. - kind="" - if compgen -G "$f"/*.sln > /dev/null 2>&1; then - kind="dotnet" - elif [ -f "$f/package.json" ] || compgen -G "$f"/*/package.json > /dev/null 2>&1; then - kind="npm" - fi - - # Folder may have been deleted in this PR -- skip if nothing buildable remains at HEAD. - if [ -z "$kind" ]; then - echo "::notice::Skipping '$f' -- no .sln at HEAD (deleted or restructured)." - continue - fi - - entries=$(jq -c --arg folder "$f" --arg kind "$kind" '. + [{folder: $folder, kind: $kind}]' <<<"$entries") - done - - echo "folders=$entries" >> "$GITHUB_OUTPUT" - echo "Changed, buildable folders:" - echo "$entries" | jq . - - build-and-test: - name: Build & test (${{ matrix.folder }}) - needs: detect-changes - if: needs.detect-changes.outputs.folders != '[]' - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: ${{ fromJson(needs.detect-changes.outputs.folders) }} - steps: - - name: Checkout (sparse) - uses: actions/checkout@v4 - with: - sparse-checkout: | - ${{ matrix.folder }} - sparse-checkout-cone-mode: true - - - name: Setup .NET SDKs - if: matrix.kind == 'dotnet' - uses: actions/setup-dotnet@v4 - with: - dotnet-version: | - 6.0.x - 7.0.x - 8.0.x - 9.0.x - 10.0.x - - - name: Build and test - if: matrix.kind == 'dotnet' - working-directory: ${{ matrix.folder }} - shell: bash - run: | - set -euo pipefail - sln=$(find . -maxdepth 1 -iname "*.sln" | head -n1) - if [ -z "$sln" ]; then - echo "::notice::No .sln found in ${{ matrix.folder }} -- skipping." - exit 0 - fi - echo "Building $sln" - dotnet build "$sln" --configuration Release - echo "Testing $sln (no-op for folders with no test project)" - dotnet test "$sln" --configuration Release --no-build --filter "FullyQualifiedName!~Live" - - - name: Setup Node - if: matrix.kind == 'npm' - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - cache-dependency-path: ${{ matrix.folder }}/**/package-lock.json - - - name: Build (npm) - if: matrix.kind == 'npm' - working-directory: ${{ matrix.folder }} - shell: bash - run: | - set -euo pipefail - # Every directory at depth 0 or 1 that holds a package.json is its own - # project: one folder may carry a single application or a set of them. - mapfile -t projects < <( - find . -maxdepth 2 -name package.json -not -path "*/node_modules/*" -printf '%h\n' \ - | sort -u - ) - if [ ${#projects[@]} -eq 0 ]; then - echo "::error::No package.json found in ${{ matrix.folder }}." - exit 1 - fi - for p in "${projects[@]}"; do - echo "::group::${{ matrix.folder }}/${p#./} -- npm ci && npm run build" - ( - cd "$p" - npm ci - npm run build - ) - echo "::endgroup::" - done - - # Stable, folder-independent name for branch protection to require -- the - # build-and-test job's displayed name varies with the matrix (one leg per - # changed folder), so it can't be set as a required check directly. This - # job always runs (even if earlier jobs are skipped) and only fails when a - # needed job actually failed or was cancelled; an empty build matrix (a PR - # touching no buildable folders) is a skip, not a failure, and passes here. - ci-result: - name: CI result - needs: [detect-changes, build-and-test] - if: always() - runs-on: ubuntu-latest - steps: - - name: Check required jobs - shell: bash - run: | - detect="${{ needs.detect-changes.result }}" - build="${{ needs.build-and-test.result }}" - echo "detect-changes: $detect" - echo "build-and-test: $build" - if [ "$detect" = "failure" ] || [ "$detect" = "cancelled" ] || [ "$build" = "failure" ] || [ "$build" = "cancelled" ]; then - echo "A required job failed or was cancelled." - exit 1 - fi - echo "All required jobs passed (or were skipped)." +# "CI result" (the last job) is a required status check on main. The workflow +# builds and tests exactly the article folder(s) a PR touches -- nothing else -- +# and checks that the PR carries no AI attribution (see no-ai-attribution). +# A folder is classified once: +# "dotnet" when it holds a .sln, "npm" when it holds a package.json at its root +# or in an immediate subfolder (the layout an Angular series folder uses). +# Folders in .github/ci-skip-folders.txt are filtered out before the matrix is +# built, so they never show up as a run, passing or failing (edit that file, not +# this one, to change what's excluded). +name: PR Build + +on: + pull_request: + branches: [main] + # "edited" re-runs the attribution check when the title or description changes. + types: [opened, synchronize, reopened, edited] + +# Cancel superseded runs on the same PR when new commits land. +concurrency: + group: pr-build-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + detect-changes: + name: Detect changed article folders + runs-on: ubuntu-latest + outputs: + folders: ${{ steps.compute.outputs.folders }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Compute changed, buildable folders + id: compute + shell: bash + run: | + set -euo pipefail + + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + SKIP_FILE=".github/ci-skip-folders.txt" + + # Skip list: strip comments and blank lines. + mapfile -t skip_list < <( + sed 's/\r$//' "$SKIP_FILE" | grep -vE '^\s*#' | grep -vE '^\s*$' + ) + + is_skipped() { + local f="$1" + for s in "${skip_list[@]:-}"; do + [ "$f" = "$s" ] && return 0 + done + return 1 + } + + # Reduce every changed path to its "/
" prefix. + # Every article folder in this repo is exactly two levels deep. + mapfile -t folders < <( + git diff --name-only "$BASE_SHA" "$HEAD_SHA" \ + | awk -F/ 'NF>=2 {print $1"/"$2}' \ + | sort -u + ) + + entries="[]" + for f in "${folders[@]:-}"; do + [ -z "$f" ] && continue + + if is_skipped "$f"; then + echo "::notice::Skipping '$f' -- listed in $SKIP_FILE." + continue + fi + + # Classify the folder. A .sln makes it a .NET folder; otherwise a + # package.json at the folder root or one level down makes it an npm + # folder. Anything else has nothing to build. + kind="" + if compgen -G "$f"/*.sln > /dev/null 2>&1; then + kind="dotnet" + elif [ -f "$f/package.json" ] || compgen -G "$f"/*/package.json > /dev/null 2>&1; then + kind="npm" + fi + + # Folder may have been deleted in this PR -- skip if nothing buildable remains at HEAD. + if [ -z "$kind" ]; then + echo "::notice::Skipping '$f' -- no .sln at HEAD (deleted or restructured)." + continue + fi + + entries=$(jq -c --arg folder "$f" --arg kind "$kind" '. + [{folder: $folder, kind: $kind}]' <<<"$entries") + done + + echo "folders=$entries" >> "$GITHUB_OUTPUT" + echo "Changed, buildable folders:" + echo "$entries" | jq . + + # Public repo: commits and PRs go out under the Code Maze name only. Fails when a + # PR commit message, the PR title or the PR description carries an AI co-author + # trailer or a "generated with" footer. Squash merges build their message from + # these commits, so a clean PR also means a clean commit on main. + no-ai-attribution: + name: No AI attribution + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - name: Check commit messages, title and description + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + set -euo pipefail + pattern='co-authored-by:.*(claude|anthropic|copilot|chatgpt|openai)|generated (with|by) .{0,3}(claude|chatgpt|copilot)|noreply@anthropic\.com' + + found=0 + check() { + local where="$1" text="$2" + local hits + hits=$(printf '%s\n' "$text" | grep -Ein "$pattern" || true) + if [ -n "$hits" ]; then + echo "::error::AI attribution in $where:" + printf '%s\n' "$hits" + found=1 + fi + } + + check "PR title" "$PR_TITLE" + check "PR description" "$PR_BODY" + while IFS= read -r sha; do + check "commit ${sha:0:7}" "$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha" --jq .commit.message)" + done < <(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/commits" --jq '.[].sha') + + if [ "$found" -ne 0 ]; then + echo "Remove the lines above (amend or reword the commits, edit the PR), then push again." + exit 1 + fi + echo "No AI attribution found." + + build-and-test: + name: Build & test (${{ matrix.folder }}) + needs: detect-changes + if: needs.detect-changes.outputs.folders != '[]' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.detect-changes.outputs.folders) }} + steps: + - name: Checkout (sparse) + uses: actions/checkout@v4 + with: + sparse-checkout: | + ${{ matrix.folder }} + sparse-checkout-cone-mode: true + + - name: Setup .NET SDKs + if: matrix.kind == 'dotnet' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 6.0.x + 7.0.x + 8.0.x + 9.0.x + 10.0.x + + - name: Build and test + if: matrix.kind == 'dotnet' + working-directory: ${{ matrix.folder }} + shell: bash + run: | + set -euo pipefail + sln=$(find . -maxdepth 1 -iname "*.sln" | head -n1) + if [ -z "$sln" ]; then + echo "::notice::No .sln found in ${{ matrix.folder }} -- skipping." + exit 0 + fi + echo "Building $sln" + dotnet build "$sln" --configuration Release + echo "Testing $sln (no-op for folders with no test project)" + dotnet test "$sln" --configuration Release --no-build --filter "FullyQualifiedName!~Live" + + - name: Setup Node + if: matrix.kind == 'npm' + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: ${{ matrix.folder }}/**/package-lock.json + + - name: Build (npm) + if: matrix.kind == 'npm' + working-directory: ${{ matrix.folder }} + shell: bash + run: | + set -euo pipefail + # Every directory at depth 0 or 1 that holds a package.json is its own + # project: one folder may carry a single application or a set of them. + mapfile -t projects < <( + find . -maxdepth 2 -name package.json -not -path "*/node_modules/*" -printf '%h\n' \ + | sort -u + ) + if [ ${#projects[@]} -eq 0 ]; then + echo "::error::No package.json found in ${{ matrix.folder }}." + exit 1 + fi + for p in "${projects[@]}"; do + echo "::group::${{ matrix.folder }}/${p#./} -- npm ci && npm run build" + ( + cd "$p" + npm ci + npm run build + ) + echo "::endgroup::" + done + + # Stable, folder-independent name for branch protection to require -- the + # build-and-test job's displayed name varies with the matrix (one leg per + # changed folder), so it can't be set as a required check directly. This + # job always runs (even if earlier jobs are skipped) and only fails when a + # needed job actually failed or was cancelled; an empty build matrix (a PR + # touching no buildable folders) is a skip, not a failure, and passes here. + ci-result: + name: CI result + needs: [detect-changes, build-and-test, no-ai-attribution] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check required jobs + shell: bash + run: | + detect="${{ needs.detect-changes.result }}" + build="${{ needs.build-and-test.result }}" + attribution="${{ needs.no-ai-attribution.result }}" + echo "detect-changes: $detect" + echo "build-and-test: $build" + echo "no-ai-attribution: $attribution" + if [ "$detect" = "failure" ] || [ "$detect" = "cancelled" ] || [ "$build" = "failure" ] || [ "$build" = "cancelled" ]; then + echo "A required job failed or was cancelled." + exit 1 + fi + # The attribution check never legitimately skips, so anything but success fails. + if [ "$attribution" != "success" ]; then + echo "The AI attribution check did not pass." + exit 1 + fi + echo "All required jobs passed (or were skipped)."