diff --git a/.github/workflows/Semgrep.yml b/.github/workflows/Semgrep.yml index f31369bec..db7c26752 100644 --- a/.github/workflows/Semgrep.yml +++ b/.github/workflows/Semgrep.yml @@ -16,8 +16,15 @@ permissions: contents: read jobs: + # Docs-only PRs skip the semgrep scan; a skipped required check is treated as + # passing by branch protection. Non-PR events (push, schedule) always scan. + # See .github/workflows/_changes.yml. + changes: + uses: ./.github/workflows/_changes.yml + semgrep: # User definable name of this GitHub Actions job. + needs: [changes] permissions: contents: read # for actions/checkout to fetch code security-events: write # for github/codeql-action/upload-sarif to upload SARIF results @@ -38,11 +45,14 @@ jobs: # branch — the Create Release PR workflow posts the required checks for it). # The release gate is keyed on the PR author, not the branch name (which any # contributor controls), so a "release/…" branch can't bypass required checks. + # Also skipped for docs-only PRs (changes.code == 'false'). if: >- ${{ github.actor != 'dependabot[bot]' && !(github.event.pull_request.user.login == 'github-actions[bot]' && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release/')) }} + && startsWith(github.head_ref, 'release/')) + && (github.event_name != 'pull_request' + || needs.changes.outputs.code == 'true') }} steps: # Fetch project source with GitHub Actions Checkout. @@ -57,4 +67,4 @@ jobs: uses: github/codeql-action/upload-sarif@3c3833e0f8c1c83d449a7478aa59c036a9165498 # v3.29.11 with: sarif_file: semgrep.sarif - if: always() \ No newline at end of file + if: always() diff --git a/.github/workflows/_changes.yml b/.github/workflows/_changes.yml new file mode 100644 index 000000000..42fcc44dc --- /dev/null +++ b/.github/workflows/_changes.yml @@ -0,0 +1,54 @@ +name: changes + +# Reusable detector shared by the PR-triggered CI workflows (lint, typecheck, +# test, windows, executable-check, semgrep). It reports a single output, `code`, +# which is 'true' when the triggering pull request changes anything other than +# documentation, and 'false' for docs-only PRs. +# +# Docs allowlist: *.md, *.txt, README*, LICENSE, docs/**. Anything else -- +# source, lockfiles, Dockerfiles, Makefiles, the workflows themselves -- counts +# as code and forces the full suite to run. +# +# Callers gate their heavy jobs on `needs.changes.outputs.code == 'true'`. A +# docs-only PR therefore SKIPS those jobs, and GitHub branch protection treats a +# skipped required check as passing, so the PR can still merge with zero tests. +# +# Fail-safe by construction: only a clean docs-only `pull_request` yields +# 'false'. Any other event (push, workflow_dispatch, schedule), an empty or +# failed diff, or any non-docs path yields 'true' (full CI). +on: + workflow_call: + outputs: + code: + description: "true when non-docs files changed (or for non-PR events)" + value: ${{ jobs.detect.outputs.code }} + +permissions: + contents: read + +jobs: + detect: + name: Detect changes + runs-on: ubuntu-latest + outputs: + code: ${{ steps.detect.outputs.code }} + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 0 + - id: detect + name: Detect non-docs changes + env: + EVENT_NAME: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + run: | + code=true + if [ "$EVENT_NAME" = "pull_request" ]; then + files="$(git diff --name-only "origin/${BASE_REF}...HEAD" || true)" + echo "Changed files:"; printf '%s\n' "$files" + if [ -n "$files" ] && ! printf '%s\n' "$files" | grep -qvE '(\.md|\.txt)$|(^|/)README[^/]*$|(^|/)LICENSE$|^docs/'; then + code=false + fi + fi + echo "Resolved code=$code (docs-only PR => code=false => skip CI)" + echo "code=$code" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/executable-check.yml b/.github/workflows/executable-check.yml index 335905545..f16c22a9b 100644 --- a/.github/workflows/executable-check.yml +++ b/.github/workflows/executable-check.yml @@ -21,17 +21,25 @@ concurrency: cancel-in-progress: true jobs: + # Docs-only PRs skip the executable build; see .github/workflows/_changes.yml. + changes: + uses: ./.github/workflows/_changes.yml + verify: name: Build & verify executable + needs: [changes] # Skip CI only for the automated release PR: opened by github-actions[bot], # from this same repo, on a release/* branch (the Create Release PR workflow # posts the required checks as passing for it). Keyed on the PR author, not the # branch name, so a "release/…" branch can't be used to bypass required checks. + # Also skipped for docs-only PRs (changes.code == 'false'). if: >- ${{ !(github.event_name == 'pull_request' && github.event.pull_request.user.login == 'github-actions[bot]' && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release/')) }} + && startsWith(github.head_ref, 'release/')) + && (github.event_name != 'pull_request' + || needs.changes.outputs.code == 'true') }} runs-on: macos-latest steps: - uses: actions/checkout@v5 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c86ab011a..894ed1788 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,17 +8,27 @@ permissions: contents: read jobs: + # Resolve whether this PR touches anything other than docs. Docs-only PRs skip + # the lint job below; a skipped required check is treated as passing by branch + # protection. See .github/workflows/_changes.yml. + changes: + uses: ./.github/workflows/_changes.yml + lint: name: Lint + needs: [changes] # Skip CI only for the automated release PR: opened by github-actions[bot], # from this same repo, on a release/* branch (the Create Release PR workflow # posts the required checks as passing for it). Keyed on the PR author, not the # branch name, so a "release/…" branch can't be used to bypass required checks. + # Also skipped for docs-only PRs (changes.code == 'false'). if: >- ${{ !(github.event_name == 'pull_request' && github.event.pull_request.user.login == 'github-actions[bot]' && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release/')) }} + && startsWith(github.head_ref, 'release/')) + && (github.event_name != 'pull_request' + || needs.changes.outputs.code == 'true') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d83506518..baa868b0f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,18 +5,27 @@ on: pull_request: workflow_dispatch: jobs: + # Docs-only PRs skip build/test/regression below; a skipped required check is + # treated as passing by branch protection. See .github/workflows/_changes.yml. + changes: + uses: ./.github/workflows/_changes.yml + build: name: Build + needs: [changes] # Skip CI only for the automated release PR: opened by github-actions[bot], # from this same repo, on a release/* branch (the Create Release PR workflow # posts the required checks as passing for it). The gate is keyed on the PR # author — NOT the branch name, which any contributor controls — so a PR from # a "release/…" branch can't be used to bypass required checks. + # Also skipped for docs-only PRs (changes.code == 'false'). if: >- ${{ !(github.event_name == 'pull_request' && github.event.pull_request.user.login == 'github-actions[bot]' && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release/')) }} + && startsWith(github.head_ref, 'release/')) + && (github.event_name != 'pull_request' + || needs.changes.outputs.code == 'true') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -46,12 +55,15 @@ jobs: test: name: Test ${{ matrix.package }} # Skip only for the automated release PR (see the build job for rationale). + # Also skipped for docs-only PRs (changes.code == 'false'). if: >- ${{ !(github.event_name == 'pull_request' && github.event.pull_request.user.login == 'github-actions[bot]' && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release/')) }} - needs: [build] + && startsWith(github.head_ref, 'release/')) + && (github.event_name != 'pull_request' + || needs.changes.outputs.code == 'true') }} + needs: [changes, build] strategy: matrix: os: [ubuntu-latest] @@ -154,12 +166,15 @@ jobs: regression: name: Regression # Skip only for the automated release PR (see the build job for rationale). + # Also skipped for docs-only PRs (changes.code == 'false'). if: >- ${{ !(github.event_name == 'pull_request' && github.event.pull_request.user.login == 'github-actions[bot]' && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release/')) }} - needs: [build] + && startsWith(github.head_ref, 'release/')) + && (github.event_name != 'pull_request' + || needs.changes.outputs.code == 'true') }} + needs: [changes, build] runs-on: ubuntu-latest timeout-minutes: 15 steps: diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 28284162e..5f83f1796 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -8,17 +8,25 @@ permissions: contents: read jobs: + # Docs-only PRs skip typecheck; see .github/workflows/_changes.yml. + changes: + uses: ./.github/workflows/_changes.yml + typecheck: name: Typecheck + needs: [changes] # Skip CI only for the automated release PR: opened by github-actions[bot], # from this same repo, on a release/* branch (the Create Release PR workflow # posts the required checks as passing for it). Keyed on the PR author, not the # branch name, so a "release/…" branch can't be used to bypass required checks. + # Also skipped for docs-only PRs (changes.code == 'false'). if: >- ${{ !(github.event_name == 'pull_request' && github.event.pull_request.user.login == 'github-actions[bot]' && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release/')) }} + && startsWith(github.head_ref, 'release/')) + && (github.event_name != 'pull_request' + || needs.changes.outputs.code == 'true') }} runs-on: ubuntu-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 6ab2edb02..cd45d28ae 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -5,17 +5,26 @@ on: pull_request: workflow_dispatch: jobs: + # Docs-only PRs skip build/test below; a skipped required check is treated as + # passing by branch protection. See .github/workflows/_changes.yml. + changes: + uses: ./.github/workflows/_changes.yml + build: name: Build + needs: [changes] # Skip CI only for the automated release PR: opened by github-actions[bot], # from this same repo, on a release/* branch (the Create Release PR workflow # posts the required checks as passing for it). Keyed on the PR author, not the # branch name, so a "release/…" branch can't be used to bypass required checks. + # Also skipped for docs-only PRs (changes.code == 'false'). if: >- ${{ !(github.event_name == 'pull_request' && github.event.pull_request.user.login == 'github-actions[bot]' && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release/')) }} + && startsWith(github.head_ref, 'release/')) + && (github.event_name != 'pull_request' + || needs.changes.outputs.code == 'true') }} runs-on: windows-latest steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 @@ -45,12 +54,15 @@ jobs: test: name: Test ${{ matrix.package }} # Skip only for the automated release PR (see the build job for rationale). + # Also skipped for docs-only PRs (changes.code == 'false'). if: >- ${{ !(github.event_name == 'pull_request' && github.event.pull_request.user.login == 'github-actions[bot]' && github.event.pull_request.head.repo.full_name == github.repository - && startsWith(github.head_ref, 'release/')) }} - needs: [build] + && startsWith(github.head_ref, 'release/')) + && (github.event_name != 'pull_request' + || needs.changes.outputs.code == 'true') }} + needs: [changes, build] strategy: fail-fast: false matrix: