From 778e1126c8727e313d476223f29644a558227a18 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Sat, 18 Jul 2026 14:17:17 -0500 Subject: [PATCH 1/5] ci: switch doc-skip from workflow paths-ignore to a job-level gate paths-ignore on the workflow trigger only skips CI when the entire push/PR diff is docs-only; for a PR that also touches code, every commit runs the full matrix even if that commit is doc-only. It's also the classic "stuck Pending" hazard for a required status check: if the whole workflow (including all-checks-passed) never runs, the required check never reports. Add a cheap `changes` job that computes the per-push changed file set (github.event.before/after for push and PR synchronize; PR base/head sha otherwise) and sets docs_only only when every changed file matches .md/.asc, failing safe (docs_only=false) on any diff failure or ambiguous base. Gate `test`, `pg-upgrade-test`, and `extension-update-test` on `needs.changes.outputs.docs_only != 'true'`, and add `changes` to all-checks-passed's needs so it keeps running and reporting on every commit. --- .github/workflows/ci.yml | 86 +++++++++++++++++++++++++++++++++++----- 1 file changed, 77 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 703a984..3088ee2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,17 +3,81 @@ on: push: branches: - master - paths-ignore: - - '**.md' - - '**.asc' pull_request: - paths-ignore: - - '**.md' - - '**.asc' env: PGUSER: postgres jobs: + # Cheap gate that lets the heavy jobs below skip themselves on commits + # that touch only docs. This must run on every push/pull_request (no + # paths-ignore on the workflow itself), otherwise the required + # all-checks-passed check would never report on doc-only pushes and get + # stuck Pending in branch protection. + changes: + name: 🔍 Detect docs-only changes + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.diff.outputs.docs_only }} + steps: + - name: Check out the repo + uses: actions/checkout@v6 + with: + # Full history needed so BASE and HEAD below are both reachable + # for `git diff`. + fetch-depth: 0 + - name: Compute per-push changed files + id: diff + run: | + if [ "${{ github.event_name }}" = "pull_request" ] && \ + [ "${{ github.event.action }}" = "synchronize" ] && \ + [ -n "${{ github.event.before }}" ]; then + # A push to an already-open PR: before/after give the true + # per-push diff, same as for a branch push. + BASE="${{ github.event.before }}" + HEAD="${{ github.event.after }}" + elif [ "${{ github.event_name }}" = "pull_request" ]; then + # First run for this PR (opened/reopened/etc, or synchronize + # without a usable before): fall back to the whole base...head + # diff. + BASE="${{ github.event.pull_request.base.sha }}" + HEAD="${{ github.event.pull_request.head.sha }}" + else + BASE="${{ github.event.before }}" + HEAD="${{ github.event.after }}" + fi + + echo "base=$BASE" + echo "head=$HEAD" + + # Fail-safe: a missing HEAD, or an all-zeros BASE (e.g. a new + # branch's first push, where GitHub reports no prior commit), + # means we can't compute a real diff. Run the full matrix rather + # than risk skipping tests. + if [ -z "$HEAD" ] || [ -z "$BASE" ] || [[ "$BASE" =~ ^0+$ ]]; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + CHANGED=$(git diff --name-only "$BASE" "$HEAD" || echo __DIFF_FAILED__) + + DOCS_ONLY=true + if [ "$CHANGED" = "__DIFF_FAILED__" ] || [ -z "$CHANGED" ]; then + DOCS_ONLY=false + else + while IFS= read -r f; do + if ! [[ "$f" =~ \.(md|asc)$ ]]; then + DOCS_ONLY=false + break + fi + done <<< "$CHANGED" + fi + + echo "changed files:" + echo "$CHANGED" + echo "docs_only=$DOCS_ONLY" >> "$GITHUB_OUTPUT" + test: + needs: [changes] + if: needs.changes.outputs.docs_only != 'true' strategy: matrix: pg: [18, 17, 16, 15, 14, 13, 12, 11, 10] @@ -31,6 +95,8 @@ jobs: run: make test pg-upgrade-test: + needs: [changes] + if: needs.changes.outputs.docs_only != 'true' strategy: matrix: include: @@ -117,6 +183,8 @@ jobs: # we cannot pg_upgrade from a cluster that has them installed. extension-update-test: + needs: [changes] + if: needs.changes.outputs.docs_only != 'true' strategy: matrix: # Restricted to PG10 only because the pre-0.2.2 install scripts use @@ -181,10 +249,10 @@ jobs: # protection rules. Matrix jobs produce check names like "🐘 PostgreSQL 14" # which would all need to be listed individually and updated whenever the # matrix changes. This job passes if all others passed or were skipped (e.g. - # on a docs-only push with paths-ignore), and fails if any failed or were - # cancelled. + # the heavy jobs gated off by the `changes` job on a docs-only push), and + # fails if any failed or were cancelled. all-checks-passed: - needs: [test, pg-upgrade-test, extension-update-test] + needs: [changes, test, pg-upgrade-test, extension-update-test] if: always() runs-on: ubuntu-latest steps: From e5dbd7c55fe8253ef76440651fa404b643db796d Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Sat, 18 Jul 2026 16:16:35 -0500 Subject: [PATCH 2/5] ci: bump deprecated actions/checkout@v4 -> v6 in claude workflows actions/checkout@v4 pins to the deprecated Node 20 runtime, which GitHub Actions warns about on every run. ci.yml already uses @v6; align claude-code-review.yml and claude.yml so all workflows are on the same, non-deprecated major version. --- .github/workflows/claude-code-review.yml | 2 +- .github/workflows/claude.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index fc7e26c..9878f0b 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -78,7 +78,7 @@ jobs: if: steps.gate.outputs.decision == 'run' # Intentionally tracks the major-version tag (not a pinned SHA) so # upstream fixes are picked up automatically. - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index c85ec00..9c9cdb6 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -31,7 +31,7 @@ jobs: - name: Checkout repository # Intentionally tracks the major-version tag (not a pinned SHA) so # upstream fixes are picked up automatically. - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 persist-credentials: false From e84e2ec816da51c0836fdc66c493cca26bee9724 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Mon, 20 Jul 2026 14:59:07 -0500 Subject: [PATCH 3/5] ci: allow the vetted fork-checkout in claude-code-review's pull_request_target job actions/checkout added a guard that refuses to check out fork PR heads under pull_request_target/workflow_run by default (a "pwn request" mitigation), plus an allow-unsafe-pr-checkout escape hatch for the reviewed cases where that is intentional. This job is exactly that case: it already gates on the trusted fork owner, checks out read-only (persist-credentials: false), and the workflow file itself always comes from master under pull_request_target, so a PR can't alter the job that reviews it. The guard/input shipped in actions/checkout v7.0.0 and has since been backported to the v4/v5/v6 floating major tags too (confirmed directly against actions/checkout's live action.yml on each tag), so no major-version bump beyond the existing @v6 is needed to use it here. claude.yml is unaffected: it doesn't trigger on pull_request_target/workflow_run and its checkout step doesn't target a fork PR head, so it isn't subject to this guard. --- .github/workflows/claude-code-review.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 9878f0b..3c794dd 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -84,6 +84,18 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 1 persist-credentials: false + # actions/checkout added a guard that refuses to check out fork + # PR heads under pull_request_target/workflow_run by default + # (a "pwn request" mitigation), with this escape hatch for + # vetted cases where that's intentional. Originally shipped in + # v7.0.0, since backported to the v4/v5/v6 floating major tags + # too (confirmed against actions/checkout's live action.yml on + # each), so no major-version bump is needed to use it here. + # Safe here: gated to the trusted fork owner above, read-only + # (persist-credentials: false), and this workflow file itself + # always comes from master under pull_request_target, so a PR + # can't alter the job that reviews it. + allow-unsafe-pr-checkout: true - name: Run Claude Code Review if: steps.gate.outputs.decision == 'run' From 4fbd6ab6465d4eb490fe20c326b2daa4dbf80122 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Mon, 20 Jul 2026 14:56:20 -0500 Subject: [PATCH 4/5] CLAUDE.md: describe the job-level docs_only gate, not the retired paths-ignore The GitHub CI note still described workflow-level paths-ignore, which this PR (#39) replaces with a per-push changes/docs_only job-level gate. Update the note so it reflects the mechanism actually in ci.yml. --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 93f2fd0..f147437 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ After **every** push, monitor GitHub CI in a background subagent until all jobs pass or a failure is confirmed. Use `gh pr checks --watch` when the branch has an open PR; otherwise (a branch with no PR yet, or a push to `master`) use `gh run watch` for the pushed commit. Investigate and fix failures immediately rather than leaving them for the user to notice. -(`paths-ignore` in `.github/workflows/ci.yml` skips CI only when the *entire* change set is docs-only — e.g. a docs-only push to `master`, or a PR whose whole diff is `**.md`/`**.asc`. A docs-only commit on a PR that also touches code still triggers CI on the full PR diff. When unsure, check `gh run list` for the pushed commit and monitor whatever run appears; if none does, there is nothing to watch.) +(`.github/workflows/ci.yml`'s `changes` job computes the actual per-push changed file set and exposes `docs_only`; the heavy `test`, `pg-upgrade-test`, and `extension-update-test` jobs skip when `needs.changes.outputs.docs_only == 'true'` — i.e. every changed file in that push matches `**.md`/`**.asc`. Unlike the old workflow-level `paths-ignore`, this is evaluated per push/commit, not over the whole PR diff, so a doc-only commit on a PR that also touches code still gets the skip, and the workflow (including the required `all-checks-passed` check) always triggers and reports rather than being skipped outright by GitHub. When unsure, check `gh run list` for the pushed commit and monitor whatever run appears; if none does, there is nothing to watch.) ## Build/test system (pgxntool) From da986fe7b4cb7623fe6f6ddbf681a079c43d4dad Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Mon, 20 Jul 2026 17:33:33 -0500 Subject: [PATCH 5/5] ci: move the fork-checkout safety rationale to the control that enforces it The allow-unsafe-pr-checkout comment listed several reasons the fork checkout was "safe" (owner gate, persist-credentials: false, read-only, workflow sourced from master). A laundry list like that rots: any one item can change later while the comment keeps reassuring, and the actual enforcing control is easy to miss among the secondary points. Move the security-critical statement to the owner-only `if:` on the claude-review job, since that gate is the one thing that makes the fork checkout acceptable at all: it stops arbitrary fork code from being checked out in the trusted pull_request_target context (a "pwn request"). The allow-unsafe-pr-checkout comment now just states it's unsafe by default and points to that gate, instead of re-deriving the safety case inline. --- .github/workflows/claude-code-review.yml | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 3c794dd..610f10d 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -23,6 +23,14 @@ jobs: claude-review: # Trusted fork only, and skip drafts (don't spend API/CI on unfinished PRs). # To add more trusted owners, extend the head-owner check. + # + # SECURITY-CRITICAL: this owner check is the ONLY thing that makes + # `allow-unsafe-pr-checkout: true` below acceptable. Without it, this + # pull_request_target job would check out and let Claude act on + # arbitrary fork code while holding base-repo secrets/token — a "pwn + # request". Do not remove or loosen this condition (e.g. drop the + # owner check, or allow non-owner forks) without re-evaluating the + # fork-checkout step's safety. if: >- github.event.pull_request.draft == false && github.event.pull_request.head.repo.owner.login == 'jnasbyupgrade' @@ -84,17 +92,11 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 1 persist-credentials: false - # actions/checkout added a guard that refuses to check out fork - # PR heads under pull_request_target/workflow_run by default - # (a "pwn request" mitigation), with this escape hatch for - # vetted cases where that's intentional. Originally shipped in - # v7.0.0, since backported to the v4/v5/v6 floating major tags - # too (confirmed against actions/checkout's live action.yml on - # each), so no major-version bump is needed to use it here. - # Safe here: gated to the trusted fork owner above, read-only - # (persist-credentials: false), and this workflow file itself - # always comes from master under pull_request_target, so a PR - # can't alter the job that reviews it. + # Unsafe by default (actions/checkout refuses fork-PR checkouts + # under pull_request_target/workflow_run). Only OK here because + # the job is gated to the project owner's forks — see the `if:` + # on the `claude-review` job above; that check is what makes + # this safe. allow-unsafe-pr-checkout: true - name: Run Claude Code Review