Skip to content

CI: binary pg_upgrade testing, docs-only gate, single-source PG matrix - #35

Open
jnasbyupgrade wants to merge 13 commits into
advanced-testing/foundationfrom
advanced-testing/ci
Open

CI: binary pg_upgrade testing, docs-only gate, single-source PG matrix#35
jnasbyupgrade wants to merge 13 commits into
advanced-testing/foundationfrom
advanced-testing/ci

Conversation

@jnasbyupgrade

Copy link
Copy Markdown
Collaborator

Rebase note + follow-up fixes

Rebased onto the updated advanced-testing/foundation (this stack is sync-pgxntool-2.3.0 -> add-test-build (#26) -> advanced-testing/foundation (#22) -> this PR; coordinator is handling gh stack link separately). advanced-testing/ci had a merge commit bringing in the OLD advanced-testing/foundation history directly, so a plain git rebase tried to replay that entire flattened history against the new base -- used git rebase --onto <new-foundation> <old-foundation-tip> instead, to replay only this branch's own unique commits.

Real conflicts requiring judgment (not mechanical): .github/workflows/ci.yml (this PR's changes/pg-upgrade-test/all-checks-passed structure vs. the now-simplified lint+test structure inherited from #26/the pgxntool 2.3.0 sync -- merged both), test/expected/pgtap.out (this PR's ORDER BY fix for test/helpers/create.sql's security-definer-function check reorders that output; regenerated the fresh-mode expected file from a real run once the conflict was resolved -- pgtap_1.out, the existing-mode alternate, already had the correct sorted order and needed no change), and the same test/sql/install.sql/pgtap.sql mode-gating reconciliation #22 already needed against #26's trim (nothing new here, just replayed).

Fixes applied per this round of review, all verified locally before pushing

(a) pg-upgrade-test now gates on test, plus added concurrency. Previously needs: [changes] only, so a trivially-broken PR still burned the full binary pg_upgrade matrix. Now needs: [changes, test]. Added a top-level concurrency: {group: "${{ github.workflow }}-${{ github.ref }}", cancel-in-progress: true} so a superseded push cancels an in-flight run. all-checks-passed's own needs/self-check invariant is unaffected (job set didn't change shape).

(b) bin/test_existing's run_suite() was running the full suite twice. make test followed immediately by make verify-results -- verify-results already depends on test (pgxntool's base.mk), so every pg-upgrade-test leg paid for two full pg_regress --use-existing runs against the real migrated database. Dropped the redundant make test call. Verified this is still correct post-pgxntool-2.3.0 by reproducing a REAL pg_upgrade leg end to end locally (16 -> 17 at the time, using throwaway pg_createcluster data directories on custom ports -- never touching this container's shared clusters): recreated the old cluster with data checksums, installed pgtap + test_factory, prepared it via bin/test_existing prepare-old, created the new cluster, installed pgtap + test_factory there too, ran the actual pg_upgrade binary, started the new cluster, then ran bin/test_existing run-suite against it -- all 3 tests passed with the deduplicated run_suite().

(c) actions/checkout@v4 -> @v7 (current latest major -- v5/v6 have both released since v4), all 5 occurrences (lint, changes, test, pg-upgrade-test, all-checks-passed -- one more than the original "4" since the lint job wasn't part of this branch when that count was written; it arrived via the pgxntool 2.3.0 sync / #26 stacked below). Coordination note: PR #29 (ci/bump-actions-versions, someone else's, out of scope for me to touch) also bumps this same pin elsewhere in this file for the original, not-yet-restructured job set -- whichever of these two efforts merges second will need a final grep -rn 'actions/checkout@v4' sweep to make sure nothing was missed.

(d) NEWEST bumped 17 -> 18. The advanced-extension-testing doc's reference implementation (cat_tools) is already on NEWEST=18. Rather than assume PG18 installs cleanly in the pgxn/pgxn-tools image, confirmed it via an actual CI run first (pushed the NEWEST=18 bump alone, watched the new "PostgreSQL 18" job) -- it installed postgresql-18 18.4-1.pgdg13+1 via pg-start and the fresh-install suite passed. With 18 now the newest major, also shifted pg-upgrade-test's two legs to match its own stated rationale (widest catalog distance; most likely to hit a new major's catalog change first): oldest-to-newest is now 10 -> 18 (was 10 -> 17), and the newest-boundary leg is now 17 -> 18 (was 16 -> 17).

Verification after all fixes

  • make lint and make verify-results (fresh mode) pass locally.
  • bash -n on both shell scripts, YAML parse check on ci.yml.
  • A full local binary pg_upgrade leg (see (b) above) passes with the deduplicated script.
  • Pushed to this PR and watched: changes, all test matrix legs (10-18), pg-upgrade-test's two legs (10->18, 17->18), and all-checks-passed.

Summary

PR 2 of the advanced update+upgrade (U&U) testing stack, on top of #22
(advanced-testing/foundation, not yet merged -- this PR targets that
branch, not master). Implements the remaining CI-structure items from the
advanced-extension-testing pattern, modeled on Postgres-Extensions/cat_tools's
actual ci.yml/bin/test_existing (read directly off its master, not just
prose about it) and scoped down deliberately per the plan agreed before
starting.

Note: advanced-testing/foundation was pushed to this repo (unchanged, same
commit as the fork's branch backing #22) purely so this PR's base could
reference it directly -- it is not new work, just a ref needed for a clean
stacked diff. Once #22 merges, this PR should be retargeted to master as a
follow-up (not done here).

What shipped

  • Fixed the double-triggered-CI bug: push scoped to branches: [master]; pull_request stays unrestricted. test_factory was explicitly
    named as one of the repos still needing this.
  • Job-level changes/docs-only gate instead of workflow-level
    paths-ignore -- computes the real per-push diff, fail-safe (docs_only=false)
    written as the literal first line so any early exit/error leaves the safe
    default in place. Avoids the stuck-Pending-required-check trap a
    workflow-level paths-ignore would cause.
  • Single source of truth for the supported-PostgreSQL-major list
    (NEWEST=18, FLOOR=10), derived
    once in the changes job and consumed via fromJSON by both the test
    and new pg-upgrade-test matrices.
  • all-checks-passed gate, self-verifying its own needs: list matches
    the actual job set. This is the check to wire up as required in branch
    protection -- I don't have permission to change that setting myself.
  • New pg-upgrade-test job: binary pg_upgrade legs (10 -> 18,
    17 -> 18, updated from the original 10 -> 17/16 -> 17 once NEWEST was bumped -- see the rebase note above) -- install the current version on an old cluster, pg_upgrade
    to a newer major, run the suite against the migrated objects in existing
    mode. No bridge step: test_factory has shipped only one version (0.5.0),
    so every leg installs current directly on the old cluster -- there's no
    older, pg_upgrade-unsafe install to carry forward.
  • bin/test_existing (new, test_factory-specific) and
    .github/scripts/pg_upgrade_cluster (new, generic pg_upgrade CI
    mechanics) -- the install -> pg_upgrade -> assert -> run-suite flow
    factored into committed scripts instead of inline YAML per job, per the
    doc's own guidance. bin/test_existing is much smaller than cat_tools's
    own: no bridge/multi-origin subcommands, just prepare-old + run-suite.
  • test job now gates on make verify-results, not pgxn-tools'
    pg-build-test. Found while rewriting this job: pg-build-test's own
    make installcheck || status=$? never actually catches a failure, because
    pgxntool marks installcheck .IGNORE -- make itself exits 0 there
    regardless of regression.diffs. The old CI was silently green on a real
    regression. PGXNTOOL_ENABLE_VERIFY_RESULTS was already pgxntool's own
    default, but I pinned it explicitly in the Makefile (matching
    ENABLE_TEST_INSTALL's existing explicit-over-implicit convention) so a
    future pgxntool default change can't silently disable the gate.
  • Dynamic version assertion: bin/test_existing's assert_version
    derives the expected version from make -s print-PGXNVERSION, never
    hardcoded, with empty-value guards on both sides ("" != "" is false, so a
    broken extraction can't silently pass).

A real bug the local pg_upgrade dry run actually found

Per the brief, I ran the full old-cluster-install -> pg_upgrade ->
new-cluster-suite-run cycle locally before trusting any of this in CI (PG12
-> PG17 and PG12 -> PG16, using throwaway initdb data directories, never
touching the container's shared clusters) -- and it surfaced a real, if
low-stakes, bug: test/helpers/create.sql's security-definer-function check
had no ORDER BY, so its row order depended on pg_proc's physical layout.
That happens to match creation order on a fresh CREATE EXTENSION but is
not preserved by pg_upgrade's dump/restore (which reconstructs it in a
different, apparently name-sorted, order) -- producing a real but harmless
text diff (every individual pgTAP assertion still said ok, just reordered)
against the fresh-install expected output. Fixed with an explicit
ORDER BY p.oid::regproc::text.

Bonus: this makes ONE set of expected-output files valid for fresh,
existing, and pg_upgraded modes alike -- no third numbered alternate file
needed (unlike the doc's more general guidance for genuinely different
axes like TEST_SCHEMA), since the divergence here was pure incidental
non-determinism, not a legitimate different-but-correct scenario. Simpler
than it first looked once actually run.

Convergence / divergence from cat_tools PR #16 (and its master since)

Took near-verbatim (generic CI mechanics, no cat_tools-specific
content): the shape of .github/scripts/pg_upgrade_cluster
(recreate-old/upgrade subcommands, INITDB_OPTS convention, pg_upgrade
log capture for both PG17+'s pg_upgrade_output.d/ and older's CWD), the
changes job's docs-only fail-safe-first-line pattern, the
all-checks-passed self-verifying needs check, and the dynamic
version-assertion empty-guard pattern.

Adapted: bin/test_existing keeps only two subcommands
(prepare-old/run-suite) instead of cat_tools's six -- no
plant-guard/update/update-scenario/update-check, because
test_factory's dependency guard is planted and proved entirely inside
test/install/load.sql's existing-mode branch (from PR #22), not by this
script, and there's no update path to exercise yet. The changes job also
drops cat_tools's "find the last commit where real code changed" reporting
machinery -- useful polish, but not part of the checklist items this PR
scoped to; noting it here as a real simplification, not an oversight, in
case a human wants it added later.

Skipped, and why (all decided before starting, confirmed still correct
after doing the work):

  • extension-update-test job -- test_factory has shipped only one version
    (0.5.0), so TEST_LOAD_SOURCE=update has no real historical update script
    to exercise yet. The mechanism exists (PR Add test/install foundation for update+upgrade testing (fresh/update/existing) #22); no CI job drives it.
  • Bridge/multi-origin update machinery -- cat_tools-specific technical debt
    (recovering from old pg_upgrade-unsafe releases). test_factory has no
    catalog-touching views and only one shipped version, so there's nothing to
    bridge from.
  • pg-upgrade-stepwise (every-major climb) -- test_factory has no
    catalog-internals-touching views/functions (no SELECT * over a system
    catalog), so the per-major-boundary risk this protects against is low. A
    human may disagree and ask for it later; flagging explicitly rather than
    silently omitting.
  • pg_tle testing -- no evidence test_factory targets pg_tle/RDS/Aurora
    deployment.
  • The stable pseudo-version -- real feature work, not part of a
    testing-infrastructure PR, not requested.

Verification

  • make verify-results passes cleanly (fresh mode) on both PG12 and PG17.
  • A full old-cluster-install -> pg_upgrade -> new-cluster-suite-run cycle,
    using the actual committed bin/test_existing and
    .github/scripts/pg_upgrade_cluster (not just ad hoc commands), passes
    with zero raw not ok TAP lines -- run twice, PG12->PG17 and PG12->PG16,
    in scratch data directories that never touched the container's shared
    clusters.
  • .github/workflows/ci.yml parses cleanly under PyYAML; both new shell
    scripts pass bash -n.

Test plan

🤖 Generated with Claude Code


Recreated from #23 (fork-headed) as an upstream-branch PR so it can be part of a formal GitHub stack (gh stack link), which refuses fork PRs. Same content/commits, same CI results.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0a31cb3f-b0ad-4504-bc2f-0c3e823e59d0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@jnasbyupgrade

Copy link
Copy Markdown
Collaborator Author

Added a commit fixing claude-review's fork-PR checkout refusal (allow-unsafe-pr-checkout: true on claude-code-review.yml, safe here since the trust gate + read-only handling are both already in place), strengthened the trust-gate's own warning comment, and bumped actions/checkout@v4→v7 on both claude-code-review.yml and claude.yml.

Note on verification: this PR's own claude-review check can't demonstrate the fix -- pull_request_target always runs the workflow from the PR's base branch, and separately, this whole stack is upstream-headed (not fork-headed), so the trust gate itself evaluates false and the job shows "skipping" regardless. Real verification happens on the next fork-headed PR (the normal pattern for this repo) based on a branch that includes this commit.

@jnasbyupgrade

Copy link
Copy Markdown
Collaborator Author

Correction to my earlier fix: the allow-unsafe-pr-checkout: true approach above was solving the wrong problem. Found via Postgres-Extensions/extension_tools#28, which hit and fixed the identical mistake: claude-code-action fetches and reads a PR's content itself internally (src/github/operations/branch.ts: for a fork PR it fetches origin's refs/pull/<n>/head, a ref GitHub maintains on the BASE repo for any PR). Redirecting the checkout step's origin to the fork (via repository:/ref:) breaks that internal fetch, since refs/pull/<n>/head doesn't exist on the fork's own remote -- would have failed with fatal: couldn't find remote ref pull/<n>/head on the next real fork-headed PR, trading one broken check for another.

Fixed: removed the repository:/ref:/allow-unsafe-pr-checkout overrides entirely. Just uses: actions/checkout@v7 with no inputs -- checks out this repo's own base branch, matching claude-code-action's own documented preferred pattern (its docs/security.md names the fork-ref-into-workspace pattern as the anti-pattern to avoid). The trust-gate if: is unchanged and still real defense-in-depth, even though the checkout itself is now safe by construction regardless.

Same self-verification limitation applies -- can't demonstrate this on this PR's own check.

@jnasbyupgrade

Copy link
Copy Markdown
Collaborator Author

Two more gotchas from the updated ~/security-notice.md handoff doc (Postgres-Extensions/cat_tools PR #62 and #47), both silent failure modes that don't affect a job's pass/fail status:

  1. Inline comments silently dropped: claude-code-review.yml drives claude-code-action via a bare prompt: (no @claude mention) = "agent mode", which decides which MCP servers to start from --allowedTools inside claude_args -- NOT from the invoked plugin's own allowed-tools frontmatter. Without claude_args: '--allowedTools mcp__github_inline_comment__create_inline_comment', the inline-comment tool never starts at all, so every review before this fix silently fell back to one consolidated PR comment instead of real per-line inline comments.
  2. Silent cache-write failures: neither claude-code-review.yml nor claude.yml's permissions: block had actions: write (both had read or nothing). There's no narrower scope for cache-write alone -- without it, claude-code-action's own internal setup silently fails to save its Actions cache (Cache reservation failed: cache write denied...), a warning not a hard failure. Fixed both files (claude.yml's existing actions: read upgraded to write, which still covers its original "read CI results" purpose).

Same self-verification limitation as before -- neither is visible from this PR's own claude-review check.

jnasbyupgrade and others added 13 commits August 6, 2026 18:46
…trix

Implements the remaining CI-structure items from the advanced update+upgrade
testing pattern (modeled on Postgres-Extensions/cat_tools's ci.yml/
bin/test_existing and what has landed on its master since), stacked on the
foundation from PR #22 (TEST_LOAD_SOURCE, dependency guard, load.sql):

- Scope `push` to `branches: [master]`; `pull_request` stays unrestricted --
  fixes the double-triggered-CI-on-every-PR-commit bug (test_factory was
  named as one of the repos still needing this).
- Job-level `changes` gate: computes a per-push docs-only diff (fail-safe =
  not-docs-only as the literal first line) instead of a workflow-level
  `paths-ignore`, so heavy jobs can skip on doc-only pushes without leaving
  all-checks-passed stuck Pending.
- Single source of truth for the supported-PostgreSQL-major list (NEWEST=17,
  FLOOR=10, matching the existing matrix), derived once in `changes` and
  consumed via fromJSON by both the `test` and new `pg-upgrade-test`
  matrices.
- `all-checks-passed` gate, self-checking that its `needs` list matches the
  actual job set.
- New `pg-upgrade-test` job: binary pg_upgrade legs (10->17, 16->17) --
  install the current version on an old cluster, pg_upgrade to a newer
  major, then run the suite against the migrated objects in existing mode.
  No bridge step needed: test_factory has shipped only one version, so
  every leg installs current directly on the old cluster. Mechanics
  factored into `.github/scripts/pg_upgrade_cluster` (generic, modeled on
  cat_tools's script of the same name) and `bin/test_existing`
  (test_factory-specific, much smaller than cat_tools's own since there's
  no bridge/multi-origin machinery to carry -- prepare-old + run-suite is
  the whole surface).
- `test` job now gates on `make verify-results` instead of pgxn-tools'
  `pg-build-test`: pgxntool marks installcheck `.IGNORE`, so the old job
  was silently exiting 0 even when regression.diffs was nonempty.
  PGXNTOOL_ENABLE_VERIFY_RESULTS is already pgxntool's own default but is
  now pinned explicitly in the Makefile, matching ENABLE_TEST_INSTALL's
  existing explicit-over-implicit convention.
- Dynamic version assertion (bin/test_existing's assert_version): the
  installed version is always derived from `make -s print-PGXNVERSION`,
  never hardcoded, with empty-value guards on both sides.

Real bug found by actually running the pg_upgrade dry run locally (PG12/16
-> PG17, using throwaway data directories, per the verification requirement
-- not just written and trusted): test/helpers/create.sql's security-definer
function check had no ORDER BY, so its row order depended on pg_proc's
physical layout. That happens to match creation order on a fresh CREATE
EXTENSION but is NOT preserved by pg_upgrade's dump/restore, which produced
a real (but harmless -- every individual assertion still said "ok") text
diff against the fresh-install expected output. Fixed with an explicit
ORDER BY, which turns out to make one set of expected-output files valid
for fresh, existing, AND pg_upgraded modes alike -- no third numbered
alternate file needed, simpler than it first looked.

Skipped, per the scoping decided before starting (see PR description for
full reasoning): extension-update-test job (no second version has ever
shipped), bridge/multi-origin update machinery (cat_tools-specific technical
debt test_factory doesn't have), pg-upgrade-stepwise (test_factory has no
catalog-internals-touching views/functions), pg_tle testing (not a
deployment target), the `stable` pseudo-version (real feature work, not
requested).

Verified locally: make verify-results passes cleanly on both PG12 and PG17
(shared clusters); a full old-cluster-install -> pg_upgrade -> new-cluster
existing-mode suite run (PG12->PG17 and PG12->PG16, using scratch data
directories, never touching the shared clusters) passes with zero raw "not
ok" TAP lines using the actual committed bin/test_existing and
pg_upgrade_cluster scripts, not just ad hoc commands.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- test job: `make verify-results` alone races install vs installcheck under
  this container's ambient parallel make (they're independent prerequisites
  of the same `test` target) -- pg_regress could start, and fail with
  "extension ... is not available", before install's file copy finished.
  Split into two separate `make` invocations, which can't race with each
  other. Reproduced the failure mode's shape locally (though not the race
  itself -- couldn't get this container's make to lose the race on demand)
  and confirmed the two-step form still passes.

- pg-upgrade-test job: never installed pgtap system-wide on either cluster.
  It worked by accident in local dry-runs only because this dev container
  already had pgtap installed for some PG majors from earlier testing --
  confirmed by deliberately clearing /usr/share/postgresql/16/extension/
  (a major this container had never used) and re-running the full
  recreate-old -> prepare-old -> pg_upgrade -> run-suite cycle end to end:
  it failed the same way PR #23's CI did ("extension pgtap is not
  available"), then passed once both `pgxn install pgtap --sudo
  --pg_config ...` steps were added (old cluster before prepare-old, new
  cluster before its make install -- pg_upgrade itself needs pgtap
  available on the new cluster too, not just post-upgrade). --pg_config is
  explicit on both, not left to rely on pg-start's PATH-switching, since
  that's exactly the kind of ambient-state assumption that already broke
  once in this same job.

Verified locally end-to-end (real pg_ctlcluster/pg_createcluster/pg_upgrade,
not just make test): old=12/new=16, a pair this container had never
exercised before, all the way through bin/test_existing run-suite with zero
raw "not ok" TAP lines. Also re-confirmed plain `make test` still passes on
PG12 and PG17 after these changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The ORDER BY rationale added for the pg_upgrade row-ordering fix used
consecutive -- lines for one continuous remark; convert it to a /* */
block per the repo's comment convention (see the sibling foundation
fix in advanced-testing/foundation).
…bump checkout

- ci.yml: pg-upgrade-test now needs [changes, test], not just [changes] --
  a trivially-broken PR (fails the cheap fresh-install matrix) no longer
  also burns the full, expensive binary pg_upgrade matrix. Added a
  top-level concurrency block (cancel-in-progress) so a superseded push
  cancels an in-flight run instead of letting that expensive matrix run to
  completion for nothing. all-checks-passed's own needs/self-check
  invariant is unaffected (its needs list and job set didn't change shape).
- bin/test_existing: run_suite() called make test then make
  verify-results back to back -- verify-results already depends on test
  (pgxntool's base.mk), so every pg-upgrade-test CI leg paid for two full
  pg_regress --use-existing runs against the real migrated database
  instead of one. Dropped the redundant call.
- actions/checkout@v4 -> @v7 (current latest major), all 5 occurrences
  (lint, changes, test, pg-upgrade-test, all-checks-passed -- one more
  than the brief's "4" since the lint job wasn't part of this branch when
  that count was written).

Verified locally: full make lint + make verify-results (fresh mode)
still pass. Reproduced a REAL binary pg_upgrade leg end to end (16 -> 17,
using throwaway pg_createcluster data directories on custom ports, never
touching this container's shared main clusters): recreated old cluster
with data checksums, installed pgtap + test_factory, prepared it via
bin/test_existing prepare-old, created the new cluster, installed pgtap +
test_factory there too, ran the actual pg_upgrade binary, started the new
cluster, then ran bin/test_existing run-suite against it -- all 3 tests
passed with the deduplicated run_suite().
The advanced-extension-testing doc's reference implementation
(cat_tools) is on NEWEST=18; this repo was still on 17. Rather than
assume pg-start's `apt.postgresql.org.sh -i -p -v "$PGVERSION"` can
install PG18 (a matrix expansion that silently failed to install would
be a much worse failure mode than not bumping), confirmed it via an
actual CI run: pushed NEWEST=18 alone first and watched the new
"PostgreSQL 18" job -- it installed postgresql-18 18.4-1.pgdg13+1 via
pg-start and the full fresh-install suite passed.

With NEWEST=18, also shifted pg-upgrade-test's "newest-boundary" leg
from 16->17 to 17->18 and its "oldest-to-newest" leg from 10->17 to
10->18, keeping both legs matching the job's own stated rationale
(widest catalog distance; most likely to hit a *new* major's catalog
change first) now that 18 is the newest major instead of 17.
Caught by the job's own self-verification step on the first full CI run
of this rebased branch (confirmed via a real run, not just local YAML
parsing): the `lint` job (SQL Lint, inherited via the pgxntool 2.3.0
sync stacked below this PR -- not part of this PR's own original
commits) was present in the workflow but missing from
all-checks-passed's needs: list, since that list was carried over
unmodified from before `lint` existed on this branch. all-checks-passed
would otherwise silently ignore SQL Lint results entirely -- exactly
the class of bug its own self-check step exists to catch, which is
what actually caught it here.
…orkflows

claude-code-review.yml's "Check out PR head" step has been failing on every
fork-headed PR all session ("Refusing to check out fork pull request code
from a 'pull_request_target' workflow") -- actions/checkout v4.4.0+
(backported to every floating major tag, including the @v4 this workflow
was pinned to) added a default-on refusal to check out a fork PR's head
under pull_request_target, requiring an explicit opt-in
(allow-unsafe-pr-checkout: true). This had been treated as a pre-existing,
unrelated failure; it is not -- it's a real, fixable gap.

Safe to opt in here specifically because both halves of the required
two-layer defense already hold: the job's trust gate
(head.repo.owner.login == 'jnasbyupgrade') restricts this to PRs from the
trusted fork only, and the checked-out code is read-only from there on
(persist-credentials: false, fed only to the review action, never built or
executed). Added allow-unsafe-pr-checkout: true, and strengthened the
trust-gate's own comment to a loud, unmissable warning -- once this flag is
set, that condition is the entire security boundary between an arbitrary
external fork and this job's secrets + a checked-out copy of that fork's
code, so a future edit that loosens it must not be able to do so quietly.

Also bumped actions/checkout@v4 -> @v7 on both claude-code-review.yml and
claude.yml (the Node.js-20-deprecation-warning fix requested for these two
files specifically; claude.yml doesn't need allow-unsafe-pr-checkout since
its checkout step never targets a fork's PR head at all -- it's triggered
by issue_comment/pull_request_review*, not pull_request_target, and checks
out the plain default ref).

Verification note (structural limitation, not a gap in this PR): this PR's
OWN claude-review check cannot demonstrate this fix -- pull_request_target
always runs the workflow file from the PR's base branch, never the PR's own
copy, and separately, all of PRs #32-35 are upstream-headed (not
jnasbyupgrade-fork-headed), so the trust gate itself evaluates false and the
job shows "skipping" regardless of this fix. Real verification only happens
on a subsequent fork-headed PR (the normal PR pattern for this repo) whose
base branch already includes this commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Standing instruction: a PR whose diff is confined entirely to
.github/workflows/ should be titled "CI: ...". Applied retroactively to
PR #29 (the only currently-open PR that qualifies).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ion fetch the PR itself

The previous commit's allow-unsafe-pr-checkout fix solved the checkout-refusal
error but traded it for a different, worse one: anthropics/claude-code-action
fetches and reads a PR's actual content itself (src/github/operations/
branch.ts: for a fork PR it fetches origin's refs/pull/<n>/head, a ref GitHub
maintains on the BASE repo for any PR, fork or not). Redirecting the checkout
step's `origin` to the fork (via repository:/ref:) breaks that internal
fetch, since refs/pull/<n>/head doesn't exist on the fork's own remote --
`fatal: couldn't find remote ref pull/<n>/head`.

Caught before it ever hit CI here by checking
Postgres-Extensions/extension_tools#28, which hit and fixed the exact same
mistake (their PR #15 was the same allow-unsafe-pr-checkout approach; #28
corrected it). anthropics/claude-code-action's own docs/security.md names
this checkout pattern (checking out the PR's own untrusted ref into the
workspace) as the anti-pattern to avoid in the first place; its preferred
pattern is a plain checkout of the base ref, nothing more.

Fix: remove the repository:/ref:/allow-unsafe-pr-checkout overrides entirely
-- just `uses: actions/checkout@v7` with no inputs, checking out this repo's
own base branch. The if: trust gate is unchanged and still load-bearing
defense-in-depth, even though the checkout itself is now safe by
construction regardless of that check.

Same self-verification limitation as before: this PR's own claude-review
check runs the OLD workflow from the base branch and can't demonstrate this
on itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two more gotchas from the updated ~/security-notice.md handoff doc
(Postgres-Extensions/cat_tools PR #62 and #47), both silent failure modes
that don't change the job's overall pass/fail status:

- claude_args: '--allowedTools mcp__github_inline_comment__create_inline_comment'
  A bare `prompt:` (no @claude mention) runs claude-code-action in "agent
  mode", which decides which MCP servers to start from --allowedTools inside
  claude_args, NOT from the invoked plugin's own allowed-tools frontmatter.
  Without this, the inline-comment MCP server never starts at all, so the
  code-review plugin silently falls back to one consolidated PR comment
  instead of real per-line inline comments -- no error, no warning, every
  review before this fix used the wrong output shape.

- permissions: actions: write
  There is no narrower scope for cache-write alone. Without it, any
  cache-save claude-code-action's own internal setup does silently fails
  ("Cache reservation failed: cache write denied: token has no writable
  scopes") -- a warning, not a hard failure, so this was invisible from the
  job's pass/fail status alone.

Same self-verification limitation as the checkout fixes in this file's
other recent commits: neither of these is visible from this PR's own
claude-review check (which runs the OLD workflow from the base branch, and
in this stack's case is also upstream-headed so the trust gate skips it
entirely regardless).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same anthropics/claude-code-action cache-write gotcha as the previous
commit's claude-code-review.yml fix (Postgres-Extensions/cat_tools PR #47) --
this workflow calls the same action, so its own internal setup hits the
same silent "Cache reservation failed" warning without actions: write.
`write` still covers the existing "read CI results" need (write implies
read here), so this replaces the read with write rather than adding a
duplicate key.

claude.yml does NOT need the --allowedTools inline-comments fix from the
previous commit: it never sets prompt: (it responds to actual @claude
mention text, not agent mode), so that specific gotcha doesn't apply here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rebase fallout from advanced-testing/foundation's redesign (test/install/
load.sql now owns installation in every mode; test/sql/pgtap.sql's
dependency check moved to pg_depend inspection) -- same content, reordered
by this branch's own pre-existing ORDER BY fix on the security-definer
function query.
head.repo.owner.login only distinguishes "who owns the fork" for
fork-headed PRs. For an upstream-branch-headed PR (base and head both in
this repo -- what `gh stack` requires), head.repo.owner.login is always
this repo's own org, never the actual author, so the gate silently skipped
review on every PR in this session's stack regardless of who opened it.

Confirmed via the check-runs API that claude-review's conclusion was
"skipped" on PRs #33/#34/#35 -- all legitimately jnasbyupgrade's own work,
recreated as upstream-branch PRs specifically so `gh stack` could link
them.

PR author can't be spoofed by a third party any more than head repo owner
can, and it's the more direct question for this gate's actual purpose:
trusting the PERSON asking for a review to run with this repo's secrets,
not the repository their branch happens to live in.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant