From 52945a7ee14c7b91eed614e65acc1db0c763e42e Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Fri, 15 May 2026 15:10:05 -0500 Subject: [PATCH 01/15] Add CI workflows and multi-session PR guard - .github/workflows/ci.yml: wait for corresponding pgxntool-test PR CI to pass before running tests; falls back to pgxntool-test/master after 5 minutes if no test PR exists (requires no-test-pr label override) - .github/workflows/protect-label.yml: enforce write-access-only on the no-test-pr label; handles non-collaborator 404, bot loop prevention, and org team membership edge cases - .claude/CLAUDE.md: warn before touching existing PRs not opened in this session (multiple concurrent Claude sessions are common) Co-Authored-By: Claude Sonnet 4.6 --- .claude/CLAUDE.md | 17 ++ .github/workflows/ci.yml | 249 ++++++++++++++++++++++++++++ .github/workflows/protect-label.yml | 137 +++++++++++++++ 3 files changed, 403 insertions(+) create mode 100644 .claude/CLAUDE.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/protect-label.yml diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..dafc32a --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,17 @@ +# Claude Development Notes + +This file contains guidance for Claude Code when working in this repository. +It is excluded from distributions via `.gitattributes export-ignore`. + +## Multiple Concurrent Sessions + +It is common to have multiple Claude Code sessions open simultaneously across +pgxntool and pgxntool-test. To avoid cross-session interference: + +**If you are asked to do something on an existing PR that you did not open or +are not already working on in this session, immediately ask for confirmation +before proceeding.** For example: "I see PR #32 exists. Were you asking me to +work on that, or did you mean to send this to a different session?" + +This applies to: editing PR branches, pushing to them, closing/reopening them, +adding commits, modifying PR descriptions, or any other PR-level action. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ccff23b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,249 @@ +name: CI + +on: + pull_request: + # We use 'pull_request' (not 'pull_request_target') deliberately. + # 'pull_request_target' runs with write access to the base repo, which is + # a security risk for untrusted fork code. Since this workflow only reads + # from other public repos (no secrets needed), 'pull_request' is correct + # and safe even for fork PRs. + +jobs: + resolve-test-ref: + name: Wait for pgxntool-test PR + runs-on: ubuntu-latest + # Hard cap on the job. Our polling logic exits after 5 minutes internally, + # but this prevents the job from hanging indefinitely if something goes wrong + # in the script itself. + timeout-minutes: 8 + outputs: + test-ref: ${{ steps.wait.outputs.test_ref }} + + steps: + - name: Find matching pgxntool-test PR and wait for its CI + id: wait + uses: actions/github-script@v7 + with: + # GITHUB_TOKEN is sufficient for reading public repos. If these repos + # are ever made private, replace with a PAT stored as a secret with + # 'repo' scope on both repos. Note: PAT expiration causes silent + # failures here — the API returns 401 and the job errors out instead + # of failing gracefully with a useful message. + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const branch = context.payload.pull_request.head.ref; + + // Master-to-master PRs don't need a corresponding test PR. + if (branch === 'master') { + core.setOutput('test_ref', 'master'); + return; + } + + const deadline = Date.now() + 5 * 60 * 1000; // 5-minute window + let testPR = null; + + core.info(`Searching for pgxntool-test PR with head branch: ${branch}`); + + // Poll for a matching test PR. We wait because contributors typically + // open the pgxntool PR first, then the test PR shortly after. Without + // this wait, CI would immediately fail for every paired PR. + while (!testPR && Date.now() < deadline) { + // The GitHub API's 'head' filter requires "owner:branch" format, + // but we don't know which fork account the contributor used for + // pgxntool-test (it may differ from their pgxntool fork). So we + // list all open PRs and filter locally by branch name. + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: 'pgxntool-test', + state: 'open', + per_page: 100 + }); + + const matching = prs.filter(pr => pr.head.ref === branch); + + if (matching.length > 1) { + // Multiple open PRs with the same branch name from different + // forks. Prefer the one from the same fork owner as this PR. + const prOwner = context.payload.pull_request.head.repo?.owner?.login; + const sameOwner = matching.find( + pr => pr.head.repo?.owner?.login === prOwner + ); + testPR = sameOwner ?? matching[0]; + core.info( + `Multiple pgxntool-test PRs match branch '${branch}'; ` + + `using #${testPR.number} from ${testPR.head.repo?.owner?.login}` + ); + } else if (matching.length === 1) { + testPR = matching[0]; + } + + if (!testPR) { + const remaining = Math.round((deadline - Date.now()) / 1000); + core.info(`No matching PR yet; retrying in 30s (${remaining}s remaining)`); + await new Promise(r => setTimeout(r, 30000)); + } + } + + if (!testPR) { + // No test PR found. Make a live API call for current labels rather + // than reading from the event payload. The payload is a snapshot + // from when the workflow was triggered — a maintainer may have + // added 'no-test-pr' during our 5-minute wait window. + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number + }); + const hasLabel = pr.labels.some(l => l.name === 'no-test-pr'); + + if (hasLabel) { + core.info( + "'no-test-pr' label is set; proceeding with pgxntool-test master. " + + "The protect-label workflow ensures only maintainers can set this label." + ); + core.setOutput('test_ref', 'master'); + return; + } + + core.setFailed( + `No matching pgxntool-test PR found for branch '${branch}' after 5 minutes, ` + + `and no 'no-test-pr' label on this PR.\n\n` + + `To fix:\n` + + ` - Open a PR in pgxntool-test from a branch also named '${branch}', OR\n` + + ` - Ask a maintainer to add the 'no-test-pr' label if no test changes are needed.` + ); + return; + } + + core.info( + `Found pgxntool-test PR #${testPR.number} at ${testPR.head.sha}. ` + + `Waiting for its CI to complete...` + ); + + const sha = testPR.head.sha; + + while (Date.now() < deadline) { + const { data: checks } = await github.rest.checks.listForRef({ + owner: context.repo.owner, + repo: 'pgxntool-test', + ref: sha, + per_page: 100 + }); + const runs = checks.check_runs; + + if (runs.length === 0) { + // CI hasn't started yet on the test PR. Normal — GitHub Actions + // queueing can take 10-30 seconds. Keep waiting rather than + // treating empty check_runs as "all passed". + core.info('pgxntool-test CI not yet started; waiting...'); + await new Promise(r => setTimeout(r, 30000)); + continue; + } + + const incomplete = runs.filter(r => r.status !== 'completed'); + if (incomplete.length > 0) { + core.info( + `${incomplete.length} check(s) still running on ` + + `pgxntool-test PR #${testPR.number}; waiting...` + ); + await new Promise(r => setTimeout(r, 30000)); + continue; + } + + // All checks completed. Evaluate results. + // 'success', 'skipped', 'neutral' are non-blocking. + // 'failure', 'cancelled', 'timed_out', 'action_required' block. + // We do NOT treat 'action_required' as passing — it means a human + // must approve something, and auto-unblocking would defeat the + // purpose of that gate. + const failed = runs.filter( + r => !['success', 'skipped', 'neutral'].includes(r.conclusion) + ); + + if (failed.length > 0) { + const names = failed.map(r => `${r.name} (${r.conclusion})`).join(', '); + core.setFailed( + `pgxntool-test PR #${testPR.number} CI failed: ${names}\n` + + `Fix the test PR CI before this PR can be merged.` + ); + return; + } + + core.info(`pgxntool-test PR #${testPR.number} CI passed`); + core.setOutput('test_ref', sha); + return; + } + + core.setFailed( + `Timed out waiting for pgxntool-test PR #${testPR.number} CI to complete. ` + + `The test PR may have a stuck or very slow CI run.` + ); + + test: + name: 🐘 PostgreSQL ${{ matrix.pg }} + needs: resolve-test-ref + runs-on: ubuntu-latest + container: pgxn/pgxn-tools + strategy: + matrix: + pg: [17, 16, 15, 14, 13, 12] + + steps: + - name: Start PostgreSQL ${{ matrix.pg }} + run: pg-start ${{ matrix.pg }} + + - name: Check out pgxntool + uses: actions/checkout@v4 + with: + path: pgxntool + submodules: false # pgxntool has no submodules + + - name: Check out pgxntool-test + uses: actions/checkout@v4 + with: + repository: Postgres-Extensions/pgxntool-test + ref: ${{ needs.resolve-test-ref.outputs.test-ref }} + path: pgxntool-test + # REQUIRED: pgxntool-test includes BATS as a git submodule at + # test/bats/. Without this, every test invocation fails with + # "bats: command not found" — an error that looks like a PATH issue. + submodules: recursive + + - name: Configure git for CI + run: | + # The container may run as a different UID than the checkout owner. + # Without safe.directory, git refuses to operate with "fatal: dubious + # ownership" — causing test failures that look like code bugs. + git config --global --add safe.directory "$GITHUB_WORKSPACE/pgxntool" + git config --global --add safe.directory "$GITHUB_WORKSPACE/pgxntool-test" + + # Required for git operations that create commits inside tests. + git config --global user.email "ci@github-actions" + git config --global user.name "GitHub Actions" + + # actions/checkout leaves HEAD detached. Tests that call 'git subtree + # add' need a real local branch, not a detached HEAD. -B force-creates + # the branch even if it already exists. + cd pgxntool + git checkout -B "${{ github.head_ref }}" + + - name: Install dependencies + run: | + apt-get update -qq + apt-get install -y -qq \ + rsync \ + ruby-asciidoctor \ + jq + # rsync: used throughout test infrastructure; absent rsync causes + # failures deep in BATS output that look like test logic bugs. + # ruby-asciidoctor: required by check-readme (called during test-setup). + # jq: required by assert_valid_meta_json(). + + - name: Run tests + working-directory: pgxntool-test + env: + # Bypass test infrastructure's branch auto-detection. In CI, pgxntool + # is checked out at a specific ref, not necessarily a remote branch + # tip. PGXNBRANCH tells the test infra which branch to treat it as. + PGXNBRANCH: ${{ github.head_ref }} + run: make test diff --git a/.github/workflows/protect-label.yml b/.github/workflows/protect-label.yml new file mode 100644 index 0000000..6834a51 --- /dev/null +++ b/.github/workflows/protect-label.yml @@ -0,0 +1,137 @@ +name: Protect 'no-test-pr' label + +on: + # IMPORTANT: Must use pull_request_target, NOT pull_request. + # + # 'pull_request' from a fork runs with a read-only GITHUB_TOKEN scoped to + # the fork. It cannot add or remove labels on the upstream repo (write + # operation), and cannot call getCollaboratorPermissionLevel (requires write + # permission to the target repo). + # + # 'pull_request_target' runs in the base repo's context with a token that + # has write access — exactly what we need here. + # + # Security: because pull_request_target has write access, never check out + # or execute code from the PR head in this workflow. This workflow only calls + # the GitHub API via actions/github-script and is safe. + pull_request_target: + types: [labeled, unlabeled] + +jobs: + protect: + # Only fire for the label we care about. All other label changes are + # unaffected by this workflow. + if: github.event.label.name == 'no-test-pr' + runs-on: ubuntu-latest + permissions: + pull-requests: write # To add/remove labels + issues: write # GitHub label API goes through the issues endpoint + + steps: + - name: Enforce write-access-only on 'no-test-pr' label + uses: actions/github-script@v7 + with: + script: | + const actor = context.actor; + const prNumber = context.payload.pull_request.number; + const action = context.payload.action; // 'labeled' or 'unlabeled' + + // When this workflow re-adds or removes the label itself, that fires + // this event again with actor = 'github-actions[bot]'. Without this + // guard the job loops forever. We match any '[bot]' suffix to also + // cover other automation (Dependabot, Renovate, etc.). + if (actor.endsWith('[bot]')) { + core.info(`Actor is a bot (${actor}); skipping permission check`); + return; + } + + // Check the actor's effective permission level in this repo. + // + // EDGE CASE — 404 for non-collaborators: This API returns 404 when + // the user is not an explicit collaborator. This is the normal case + // for contributors who forked and opened a PR. If we don't catch + // this error, the job crashes with an unhandled exception and the + // label stays in whatever state the contributor put it in — + // defeating the entire protection. + // + // EDGE CASE — org team members: Users with write access via org + // team membership (not a direct collaborator invite) correctly show + // as 'write' here because the API returns effective permission. + // Exception: if the org has "private member visibility" set and the + // token can't enumerate team membership, they may get a 404 instead. + // If that becomes an issue, add a fallback to + // github.rest.orgs.getMembershipForUser(). + // + // EDGE CASE — other errors: Network blips, API outages, and rate + // limiting all throw here. We fail safe by treating any unexpected + // error as "no write access" and logging for debugging. + let hasWrite = false; + try { + const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: actor + }); + hasWrite = ['admin', 'write'].includes(perm.permission); + } catch (e) { + if (e.status === 404) { + // Not a collaborator — no write access. Expected and normal. + hasWrite = false; + } else { + core.warning( + `Unexpected error checking permissions for ${actor} ` + + `(HTTP ${e.status}): ${e.message}. Treating as no write access.` + ); + hasWrite = false; + } + } + + if (action === 'labeled' && !hasWrite) { + core.info(`${actor} lacks write access; removing 'no-test-pr' label`); + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: 'no-test-pr' + }); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: + `@${actor} The \`no-test-pr\` label can only be applied by maintainers ` + + `with write access to this repository. ` + + `Please ask a maintainer to apply it if no test changes are needed for this PR.` + }); + + } else if (action === 'unlabeled' && !hasWrite) { + // Non-writer removed the label. Put it back. + // + // EDGE CASE — brief label-absent window: There is a short window + // between removal and this workflow re-adding the label. During + // that window the label genuinely does not exist. This is harmless + // in practice: the ci.yml workflow reads labels via a live API + // call (not from its cached payload), and the window is typically + // under 30 seconds — far less than the 5-minute polling interval. + core.info(`${actor} lacks write access; re-adding 'no-test-pr' label`); + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + labels: ['no-test-pr'] + }); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: + `@${actor} The \`no-test-pr\` label can only be removed by maintainers ` + + `with write access to this repository. ` + + `Contact a maintainer if you believe this label was applied in error.` + }); + + } else if (hasWrite) { + core.info( + `${actor} has write access; '${action}' on 'no-test-pr' label is approved` + ); + } From 39a3fce49adf10310ee45db57f521605919f71d3 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Fri, 15 May 2026 15:17:50 -0500 Subject: [PATCH 02/15] fix: use full clone for pgxntool checkout (git subtree requires it) git subtree add refuses to work with shallow clones; fails with "shallow roots are not allowed to be updated" which looks like a remote/ref problem rather than a depth issue. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccff23b..6589b49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -197,6 +197,11 @@ jobs: with: path: pgxntool submodules: false # pgxntool has no submodules + # REQUIRED: must be a full clone. 'git subtree add' refuses to work + # with shallow clones and fails with "shallow roots are not allowed + # to be updated" — an error that looks like a remote/ref problem + # rather than a depth issue. + fetch-depth: 0 - name: Check out pgxntool-test uses: actions/checkout@v4 From 8390e290c5be9e9d85aa5ea1ca4a7a51ecca3be6 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Fri, 15 May 2026 15:20:13 -0500 Subject: [PATCH 03/15] docs: require CI monitoring background task after every push Co-Authored-By: Claude Sonnet 4.6 --- .claude/CLAUDE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index dafc32a..e2e74fe 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -3,6 +3,17 @@ This file contains guidance for Claude Code when working in this repository. It is excluded from distributions via `.gitattributes export-ignore`. +## CI Monitoring After Every Push + +**REQUIRED**: After every `git push`, immediately start a background task to +monitor the CI run for that push. If you pushed to both pgxntool and +pgxntool-test, start a background task for each repo — do not monitor them +sequentially. + +Use `gh run watch` or poll with `gh run list` / `gh pr checks` in the +background task. Report failures to the user as soon as they are detected; +do not wait for all jobs to finish before reporting. + ## Multiple Concurrent Sessions It is common to have multiple Claude Code sessions open simultaneously across From e8b1abaadc7d778d98f5a6ca793d052fdc41ddc5 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Fri, 15 May 2026 15:25:46 -0500 Subject: [PATCH 04/15] fix: install asciidoctor via gem; add branch context reporting - gem install asciidoctor instead of apt ruby-asciidoctor (same fix as pgxntool-test) - Print '=== BRANCHES: ===' line at the start of each test job so both the pgxntool and pgxntool-test refs are visible in CI logs Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6589b49..d3e94f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -192,6 +192,12 @@ jobs: - name: Start PostgreSQL ${{ matrix.pg }} run: pg-start ${{ matrix.pg }} + - name: Report CI context + run: | + # Print both repos and exact refs at the top of every job so failures + # are easy to correlate to the right code, especially cross-repo issues. + echo "=== BRANCHES: pgxntool=${{ github.head_ref }} pgxntool-test=${{ needs.resolve-test-ref.outputs.test-ref }} ===" + - name: Check out pgxntool uses: actions/checkout@v4 with: @@ -235,13 +241,13 @@ jobs: - name: Install dependencies run: | apt-get update -qq - apt-get install -y -qq \ - rsync \ - ruby-asciidoctor \ - jq + apt-get install -y -qq rsync jq ruby + gem install asciidoctor --no-document --quiet # rsync: used throughout test infrastructure; absent rsync causes # failures deep in BATS output that look like test logic bugs. - # ruby-asciidoctor: required by check-readme (called during test-setup). + # asciidoctor via gem (not apt ruby-asciidoctor): the apt package + # installs the gem but its binary is not on PATH in pgxn-tools + # containers. gem install puts it in /usr/local/bin which IS on PATH. # jq: required by assert_valid_meta_json(). - name: Run tests From c5f753aa484334e77056ecac81fa97613ec5e1a8 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Fri, 15 May 2026 15:41:04 -0500 Subject: [PATCH 05/15] fix: exclude .github/ from distribution archives GitHub Actions workflows are development infrastructure and should not be included in PGXN distribution packages. Without this exclusion, `make dist` (which uses `git archive`) includes .github/workflows/, causing the dist test to fail: not ok 22 distribution contains exact expected files # ERROR: Distribution contents differ from expected manifest # > pgxntool/.github/ # > pgxntool/.github/workflows/ # > pgxntool/.github/workflows/ci.yml # > pgxntool/.github/workflows/protect-label.yml The .gitattributes `export-ignore` attribute controls what `git archive` excludes. Adding `.github/ export-ignore` ensures CI workflow files are stripped from distributions, matching the existing treatment of .claude/, .gitattributes, and documentation files. Co-Authored-By: Claude Sonnet 4.6 --- .gitattributes | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitattributes b/.gitattributes index a94d824..8dc1599 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,6 @@ .gitattributes export-ignore .claude/ export-ignore +.github/ export-ignore *.md export-ignore .DS_Store export-ignore *.asc export-ignore From 121f04d34f484cd888ab27d2a90ad6d4c23d153a Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Fri, 15 May 2026 16:25:12 -0500 Subject: [PATCH 06/15] refactor: redesign CI to check for paired test PR immediately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Key changes: 1. Replace 5-minute polling 'resolve-test-ref' job with a fast 'check-test-pr' job (seconds, not minutes). The test PR must exist when you push — no waiting. 2. When a paired pgxntool-test PR is found, pgxntool CI passes immediately without running tests. Tests run in the test PR's own CI — no duplication. 3. When no paired test PR exists and no override label is present, CI fails immediately with an actionable error message pointing to the README docs. 4. Rename 'no-test-pr' label to 'commit-with-no-tests'. This name better conveys that it's a merge-time decision (not just a CI skip), and that it's unusual and maintainer-only. 5. Add 'Pre-install pgtap' step to the test job (only runs in the commit-with-no-tests case). Prevents a concurrent-install race condition in concurrent-make-test.bats. 6. Update protect-label.yml to guard the new label name. The failure message now links to README#ci-and-contributing which explains branch naming requirements and how to request the label. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 251 +++++++++++++--------------- .github/workflows/protect-label.yml | 32 ++-- 2 files changed, 130 insertions(+), 153 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3e94f9..368cebd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,19 +9,19 @@ on: # and safe even for fork PRs. jobs: - resolve-test-ref: - name: Wait for pgxntool-test PR + check-test-pr: + name: Check for paired pgxntool-test PR runs-on: ubuntu-latest - # Hard cap on the job. Our polling logic exits after 5 minutes internally, - # but this prevents the job from hanging indefinitely if something goes wrong - # in the script itself. - timeout-minutes: 8 + # This is a fast check — no polling, just a single API call. + # If it takes longer than 2 minutes something is wrong with the runner. + timeout-minutes: 2 outputs: - test-ref: ${{ steps.wait.outputs.test_ref }} + run-tests: ${{ steps.check.outputs.run_tests }} + test-ref: ${{ steps.check.outputs.test_ref }} steps: - - name: Find matching pgxntool-test PR and wait for its CI - id: wait + - name: Find paired pgxntool-test PR or check commit-with-no-tests label + id: check uses: actions/github-script@v7 with: # GITHUB_TOKEN is sufficient for reading public repos. If these repos @@ -32,156 +32,113 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const branch = context.payload.pull_request.head.ref; + const prNumber = context.payload.pull_request.number; - // Master-to-master PRs don't need a corresponding test PR. + // master-to-master PRs have no paired test PR by convention. + // Run tests against pgxntool-test/master directly. if (branch === 'master') { + core.setOutput('run_tests', 'true'); core.setOutput('test_ref', 'master'); return; } - const deadline = Date.now() + 5 * 60 * 1000; // 5-minute window + // Look for an existing open pgxntool-test PR with the SAME branch + // name. No waiting — the test PR must already exist when this check + // runs. This keeps the check fast (seconds, not minutes). + // + // Branch names must match exactly. If the branch is named 'fix-foo' + // in pgxntool, the paired test PR must also be on 'fix-foo'. + // + // The GitHub API's 'head' filter requires "owner:branch" format and + // requires knowing the fork owner. Since a contributor's pgxntool-test + // fork may differ from their pgxntool fork, we list all open PRs and + // filter locally — a safe approach for repos with few open PRs. + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: 'pgxntool-test', + state: 'open', + per_page: 100 + }); + + const matching = prs.filter(pr => pr.head.ref === branch); let testPR = null; - core.info(`Searching for pgxntool-test PR with head branch: ${branch}`); - - // Poll for a matching test PR. We wait because contributors typically - // open the pgxntool PR first, then the test PR shortly after. Without - // this wait, CI would immediately fail for every paired PR. - while (!testPR && Date.now() < deadline) { - // The GitHub API's 'head' filter requires "owner:branch" format, - // but we don't know which fork account the contributor used for - // pgxntool-test (it may differ from their pgxntool fork). So we - // list all open PRs and filter locally by branch name. - const { data: prs } = await github.rest.pulls.list({ - owner: context.repo.owner, - repo: 'pgxntool-test', - state: 'open', - per_page: 100 - }); - - const matching = prs.filter(pr => pr.head.ref === branch); - - if (matching.length > 1) { - // Multiple open PRs with the same branch name from different - // forks. Prefer the one from the same fork owner as this PR. - const prOwner = context.payload.pull_request.head.repo?.owner?.login; - const sameOwner = matching.find( - pr => pr.head.repo?.owner?.login === prOwner - ); - testPR = sameOwner ?? matching[0]; - core.info( - `Multiple pgxntool-test PRs match branch '${branch}'; ` + - `using #${testPR.number} from ${testPR.head.repo?.owner?.login}` - ); - } else if (matching.length === 1) { - testPR = matching[0]; - } - - if (!testPR) { - const remaining = Math.round((deadline - Date.now()) / 1000); - core.info(`No matching PR yet; retrying in 30s (${remaining}s remaining)`); - await new Promise(r => setTimeout(r, 30000)); - } + if (matching.length > 1) { + // Multiple open PRs with the same branch name from different + // forks. Prefer the one from the same fork owner as this PR. + // If no owner match, fall back to the first result. + const prOwner = context.payload.pull_request.head.repo?.owner?.login; + testPR = matching.find( + pr => pr.head.repo?.owner?.login === prOwner + ) ?? matching[0]; + core.info( + `Multiple pgxntool-test PRs match branch '${branch}'; ` + + `using #${testPR.number} from ${testPR.head.repo?.owner?.login}` + ); + } else if (matching.length === 1) { + testPR = matching[0]; } - if (!testPR) { - // No test PR found. Make a live API call for current labels rather - // than reading from the event payload. The payload is a snapshot - // from when the workflow was triggered — a maintainer may have - // added 'no-test-pr' during our 5-minute wait window. - const { data: pr } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.payload.pull_request.number - }); - const hasLabel = pr.labels.some(l => l.name === 'no-test-pr'); - - if (hasLabel) { - core.info( - "'no-test-pr' label is set; proceeding with pgxntool-test master. " + - "The protect-label workflow ensures only maintainers can set this label." - ); - core.setOutput('test_ref', 'master'); - return; - } - - core.setFailed( - `No matching pgxntool-test PR found for branch '${branch}' after 5 minutes, ` + - `and no 'no-test-pr' label on this PR.\n\n` + - `To fix:\n` + - ` - Open a PR in pgxntool-test from a branch also named '${branch}', OR\n` + - ` - Ask a maintainer to add the 'no-test-pr' label if no test changes are needed.` + if (testPR) { + // A paired test PR exists. Its own CI handles test coverage for + // this pgxntool change — no need to run tests here too. + // This keeps pgxntool CI fast and avoids duplicating test runs. + core.info( + `Found paired pgxntool-test PR #${testPR.number} ` + + `(${testPR.head.sha.slice(0, 7)}) — tests run there, not here.` ); + core.setOutput('run_tests', 'false'); + core.setOutput('test_ref', testPR.head.sha); return; } - core.info( - `Found pgxntool-test PR #${testPR.number} at ${testPR.head.sha}. ` + - `Waiting for its CI to complete...` - ); - - const sha = testPR.head.sha; - - while (Date.now() < deadline) { - const { data: checks } = await github.rest.checks.listForRef({ - owner: context.repo.owner, - repo: 'pgxntool-test', - ref: sha, - per_page: 100 - }); - const runs = checks.check_runs; - - if (runs.length === 0) { - // CI hasn't started yet on the test PR. Normal — GitHub Actions - // queueing can take 10-30 seconds. Keep waiting rather than - // treating empty check_runs as "all passed". - core.info('pgxntool-test CI not yet started; waiting...'); - await new Promise(r => setTimeout(r, 30000)); - continue; - } - - const incomplete = runs.filter(r => r.status !== 'completed'); - if (incomplete.length > 0) { - core.info( - `${incomplete.length} check(s) still running on ` + - `pgxntool-test PR #${testPR.number}; waiting...` - ); - await new Promise(r => setTimeout(r, 30000)); - continue; - } - - // All checks completed. Evaluate results. - // 'success', 'skipped', 'neutral' are non-blocking. - // 'failure', 'cancelled', 'timed_out', 'action_required' block. - // We do NOT treat 'action_required' as passing — it means a human - // must approve something, and auto-unblocking would defeat the - // purpose of that gate. - const failed = runs.filter( - r => !['success', 'skipped', 'neutral'].includes(r.conclusion) + // No paired test PR found. Check for the 'commit-with-no-tests' + // label, which a maintainer can apply when a pgxntool change + // genuinely needs no test changes (unusual). + // + // We make a live API call rather than reading from the event + // payload. The payload is a snapshot from when this workflow was + // triggered — a maintainer may have added the label after that. + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + + if (pr.labels.some(l => l.name === 'commit-with-no-tests')) { + core.info( + "'commit-with-no-tests' label is present; running tests " + + "against pgxntool-test/master. The protect-label workflow " + + "ensures only maintainers can apply this label." ); - - if (failed.length > 0) { - const names = failed.map(r => `${r.name} (${r.conclusion})`).join(', '); - core.setFailed( - `pgxntool-test PR #${testPR.number} CI failed: ${names}\n` + - `Fix the test PR CI before this PR can be merged.` - ); - return; - } - - core.info(`pgxntool-test PR #${testPR.number} CI passed`); - core.setOutput('test_ref', sha); + core.setOutput('run_tests', 'true'); + core.setOutput('test_ref', 'master'); return; } + // Neither a paired test PR nor the override label was found. + // Fail with a clear, actionable message. core.setFailed( - `Timed out waiting for pgxntool-test PR #${testPR.number} CI to complete. ` + - `The test PR may have a stuck or very slow CI run.` + `No paired pgxntool-test PR found for branch '${branch}', ` + + `and no 'commit-with-no-tests' label on this PR.\n\n` + + `pgxntool changes should always be paired with matching test\n` + + `changes in pgxntool-test. This check enforces that pairing.\n\n` + + `To resolve:\n` + + ` 1. Open a PR in pgxntool-test from a branch ALSO named '${branch}'.\n` + + ` Branch names must match exactly for the pairing to work.\n\n` + + ` 2. If this pgxntool change truly needs no test updates (unusual),\n` + + ` ask a maintainer to apply the 'commit-with-no-tests' label.\n` + + ` Only maintainers can apply this label. It is not a normal\n` + + ` shortcut — most pgxntool changes require test updates.\n\n` + + `See: https://github.com/Postgres-Extensions/pgxntool-test#ci-and-contributing` ); test: name: 🐘 PostgreSQL ${{ matrix.pg }} - needs: resolve-test-ref + needs: check-test-pr + # Only run tests when there is no paired test PR. When a paired PR exists, + # tests run in pgxntool-test's own CI — we don't duplicate them here. + if: needs.check-test-pr.outputs.run-tests == 'true' runs-on: ubuntu-latest container: pgxn/pgxn-tools strategy: @@ -196,7 +153,7 @@ jobs: run: | # Print both repos and exact refs at the top of every job so failures # are easy to correlate to the right code, especially cross-repo issues. - echo "=== BRANCHES: pgxntool=${{ github.head_ref }} pgxntool-test=${{ needs.resolve-test-ref.outputs.test-ref }} ===" + echo "=== BRANCHES: pgxntool=${{ github.head_ref }} pgxntool-test=${{ needs.check-test-pr.outputs.test-ref }} ===" - name: Check out pgxntool uses: actions/checkout@v4 @@ -213,7 +170,7 @@ jobs: uses: actions/checkout@v4 with: repository: Postgres-Extensions/pgxntool-test - ref: ${{ needs.resolve-test-ref.outputs.test-ref }} + ref: ${{ needs.check-test-pr.outputs.test-ref }} path: pgxntool-test # REQUIRED: pgxntool-test includes BATS as a git submodule at # test/bats/. Without this, every test invocation fails with @@ -250,6 +207,24 @@ jobs: # containers. gem install puts it in /usr/local/bin which IS on PATH. # jq: required by assert_valid_meta_json(). + - name: Pre-install pgtap + run: | + # Pre-install pgtap before running tests to prevent a race condition + # in the concurrent-make-test.bats suite. + # + # The test "concurrent make test succeeds for both projects" runs two + # `make test` processes simultaneously. Both check for + # $(datadir)/extension/pgtap.control and, finding it absent, both + # invoke `pgxn install pgtap --sudo` at the same time. Their + # concurrent `gmake install` calls race to write files into the + # shared /usr/share/postgresql//extension/ directory, causing + # "File exists" and "No such file or directory" errors. + # + # This test step only runs in the 'commit-with-no-tests' case (when + # there is no paired pgxntool-test PR). When a paired test PR exists, + # that PR's own CI handles the pre-install. + pgxn install pgtap --sudo + - name: Run tests working-directory: pgxntool-test env: diff --git a/.github/workflows/protect-label.yml b/.github/workflows/protect-label.yml index 6834a51..18c0004 100644 --- a/.github/workflows/protect-label.yml +++ b/.github/workflows/protect-label.yml @@ -1,4 +1,4 @@ -name: Protect 'no-test-pr' label +name: Protect 'commit-with-no-tests' label on: # IMPORTANT: Must use pull_request_target, NOT pull_request. @@ -21,14 +21,14 @@ jobs: protect: # Only fire for the label we care about. All other label changes are # unaffected by this workflow. - if: github.event.label.name == 'no-test-pr' + if: github.event.label.name == 'commit-with-no-tests' runs-on: ubuntu-latest permissions: pull-requests: write # To add/remove labels issues: write # GitHub label API goes through the issues endpoint steps: - - name: Enforce write-access-only on 'no-test-pr' label + - name: Enforce write-access-only on 'commit-with-no-tests' label uses: actions/github-script@v7 with: script: | @@ -87,21 +87,23 @@ jobs: } if (action === 'labeled' && !hasWrite) { - core.info(`${actor} lacks write access; removing 'no-test-pr' label`); + core.info(`${actor} lacks write access; removing 'commit-with-no-tests' label`); await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, - name: 'no-test-pr' + name: 'commit-with-no-tests' }); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, body: - `@${actor} The \`no-test-pr\` label can only be applied by maintainers ` + - `with write access to this repository. ` + - `Please ask a maintainer to apply it if no test changes are needed for this PR.` + `@${actor} The \`commit-with-no-tests\` label can only be applied by ` + + `maintainers with write access to this repository.\n\n` + + `If you believe no test changes are needed for this PR, please ask a ` + + `maintainer to apply the label after reviewing. Note that most pgxntool ` + + `changes do require paired test updates — this label should be used sparingly.` }); } else if (action === 'unlabeled' && !hasWrite) { @@ -111,27 +113,27 @@ jobs: // between removal and this workflow re-adding the label. During // that window the label genuinely does not exist. This is harmless // in practice: the ci.yml workflow reads labels via a live API - // call (not from its cached payload), and the window is typically - // under 30 seconds — far less than the 5-minute polling interval. - core.info(`${actor} lacks write access; re-adding 'no-test-pr' label`); + // call (not from its cached payload), so a re-run after the label + // is restored will pick it up correctly. + core.info(`${actor} lacks write access; re-adding 'commit-with-no-tests' label`); await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, - labels: ['no-test-pr'] + labels: ['commit-with-no-tests'] }); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, body: - `@${actor} The \`no-test-pr\` label can only be removed by maintainers ` + - `with write access to this repository. ` + + `@${actor} The \`commit-with-no-tests\` label can only be removed by ` + + `maintainers with write access to this repository.\n\n` + `Contact a maintainer if you believe this label was applied in error.` }); } else if (hasWrite) { core.info( - `${actor} has write access; '${action}' on 'no-test-pr' label is approved` + `${actor} has write access; '${action}' on 'commit-with-no-tests' label is approved` ); } From 93a9eec97f8c271f6a66a5bb5598663fed77d125 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Fri, 15 May 2026 16:28:57 -0500 Subject: [PATCH 07/15] feat: verify test PR CI used current SHA and is recent When a paired pgxntool-test PR is found, verify: 1. CI has run for the exact current HEAD SHA (no stale runs from old commits) 2. All check runs completed successfully 3. The most recent passing run is within 7 days Each failure case provides URLs for both the test PR and the pgxntool re-run check, making it clear what to fix and how to re-trigger. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 98 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 92 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 368cebd..5a94aa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,15 +80,101 @@ jobs: } if (testPR) { - // A paired test PR exists. Its own CI handles test coverage for - // this pgxntool change — no need to run tests here too. - // This keeps pgxntool CI fast and avoids duplicating test runs. + // A paired test PR exists. Verify its CI passed for the exact + // current HEAD SHA and that the run is recent enough to be valid. + const sha = testPR.head.sha; + const testPRUrl = + `https://github.com/${context.repo.owner}/pgxntool-test/pull/${testPR.number}`; + const recheckUrl = + `https://github.com/${context.repo.owner}/${context.repo.repo}/pull/${prNumber}/checks`; + + core.info(`Found pgxntool-test PR #${testPR.number} (${sha.slice(0, 7)})`); + + // Fetch check runs for the exact HEAD SHA of the test PR. + // Using 'ref: sha' (not branch name) ensures we only see runs + // that used this specific commit — never stale runs from an older + // push on the same branch. + const { data: checks } = await github.rest.checks.listForRef({ + owner: context.repo.owner, + repo: 'pgxntool-test', + ref: sha, + per_page: 100 + }); + const runs = checks.check_runs; + + if (runs.length === 0) { + core.setFailed( + `pgxntool-test PR #${testPR.number} has no CI runs for its ` + + `current HEAD (${sha.slice(0, 7)}).\n\n` + + `Push a commit (or re-run CI) on the test PR to trigger its ` + + `CI, then re-run this check:\n` + + ` Test PR: ${testPRUrl}\n` + + ` Re-run this check: ${recheckUrl}` + ); + return; + } + + // If CI is still running, tell the user to wait and re-run. + const incomplete = runs.filter(r => r.status !== 'completed'); + if (incomplete.length > 0) { + const names = incomplete.map(r => r.name).join(', '); + core.setFailed( + `pgxntool-test PR #${testPR.number} CI is still running ` + + `for SHA ${sha.slice(0, 7)}: ${names}\n\n` + + `Wait for the test PR CI to finish, then re-run this check:\n` + + ` Test PR: ${testPRUrl}\n` + + ` Re-run this check: ${recheckUrl}` + ); + return; + } + + // All checks complete — look for failures. + // 'success', 'skipped', 'neutral' are non-blocking. + const failed = runs.filter( + r => !['success', 'skipped', 'neutral'].includes(r.conclusion) + ); + if (failed.length > 0) { + const names = failed.map(r => `${r.name} (${r.conclusion})`).join(', '); + core.setFailed( + `pgxntool-test PR #${testPR.number} CI failed for ` + + `SHA ${sha.slice(0, 7)}: ${names}\n\n` + + `Fix the test PR CI, then re-run this check:\n` + + ` Test PR: ${testPRUrl}\n` + + ` Re-run this check: ${recheckUrl}` + ); + return; + } + + // Verify the passing run is recent (within 7 days). + // Stale CI results may not reflect the current state of + // dependencies or the pgxn-tools container image. + const MAX_AGE_DAYS = 7; + const mostRecent = runs.reduce((a, b) => + new Date(a.completed_at) > new Date(b.completed_at) ? a : b + ); + const completedAt = new Date(mostRecent.completed_at); + const ageDays = (Date.now() - completedAt.getTime()) / 86400000; + + if (ageDays > MAX_AGE_DAYS) { + core.setFailed( + `pgxntool-test PR #${testPR.number} CI passed for ` + + `SHA ${sha.slice(0, 7)} but the run is ${Math.round(ageDays)} ` + + `days old (completed: ${completedAt.toISOString()}).\n\n` + + `CI results older than ${MAX_AGE_DAYS} days are not accepted.\n` + + `Push a commit or re-run CI on the test PR to refresh:\n` + + ` Test PR: ${testPRUrl}\n` + + ` Re-run this check: ${recheckUrl}` + ); + return; + } + + const ageHours = Math.round(ageDays * 24); core.info( - `Found paired pgxntool-test PR #${testPR.number} ` + - `(${testPR.head.sha.slice(0, 7)}) — tests run there, not here.` + `pgxntool-test PR #${testPR.number} CI passed for ` + + `SHA ${sha.slice(0, 7)} (${ageHours}h ago) — tests run there, not here.` ); core.setOutput('run_tests', 'false'); - core.setOutput('test_ref', testPR.head.sha); + core.setOutput('test_ref', sha); return; } From 4177d61c0bed1d3410c8d7937ac36d191c550c16 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 19 May 2026 14:38:14 -0500 Subject: [PATCH 08/15] fix(ci): owner-match forks, poll for in-progress CI, error on label+test-PR conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fork matching: require test PR to come from the same owner as the pgxntool PR; never fall back to a random contributor's fork - Race condition: poll (30s intervals, 20min max) instead of immediately failing when the paired test PR's CI hasn't started or is still running - Label conflict: fail if commit-with-no-tests label is set but a paired test PR also exists — that combination is contradictory - Label name: define as NO_TEST_LABEL const in ci.yml and LABEL const in protect-label.yml; replace all literals with those consts Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 171 +++++++++++++++++----------- .github/workflows/protect-label.yml | 15 ++- 2 files changed, 113 insertions(+), 73 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a94aa7..0d72b31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,9 @@ jobs: check-test-pr: name: Check for paired pgxntool-test PR runs-on: ubuntu-latest - # This is a fast check — no polling, just a single API call. - # If it takes longer than 2 minutes something is wrong with the runner. - timeout-minutes: 2 + # This check polls until the paired pgxntool-test CI run completes + # (up to 20 minutes). The job timeout gives a few minutes of headroom. + timeout-minutes: 25 outputs: run-tests: ${{ steps.check.outputs.run_tests }} test-ref: ${{ steps.check.outputs.test_ref }} @@ -33,6 +33,10 @@ jobs: script: | const branch = context.payload.pull_request.head.ref; const prNumber = context.payload.pull_request.number; + // Single source of truth for the label name. Must also match the + // literal string in the protect-label.yml job-level `if:` condition + // (YAML expressions can't reference JS constants). + const NO_TEST_LABEL = 'commit-with-no-tests'; // master-to-master PRs have no paired test PR by convention. // Run tests against pgxntool-test/master directly. @@ -42,17 +46,18 @@ jobs: return; } - // Look for an existing open pgxntool-test PR with the SAME branch - // name. No waiting — the test PR must already exist when this check - // runs. This keeps the check fast (seconds, not minutes). - // - // Branch names must match exactly. If the branch is named 'fix-foo' - // in pgxntool, the paired test PR must also be on 'fix-foo'. + // The owner of this PR's head repo — the contributor's fork owner + // for fork PRs, or the base repo owner for maintainer PRs. + // The paired pgxntool-test PR must come from the SAME owner. + // We never cross-match PRs across different contributors' forks. + const prOwner = context.payload.pull_request.head.repo?.owner?.login; + + // Look for open pgxntool-test PRs with the SAME branch name AND + // the same fork owner. Branch names must match exactly. // - // The GitHub API's 'head' filter requires "owner:branch" format and - // requires knowing the fork owner. Since a contributor's pgxntool-test - // fork may differ from their pgxntool fork, we list all open PRs and - // filter locally — a safe approach for repos with few open PRs. + // The GitHub API's 'head' filter requires "owner:branch" format. + // We list all open PRs and filter locally — safe for repos with + // few open PRs, and avoids needing to know the fork repo name. const { data: prs } = await github.rest.pulls.list({ owner: context.repo.owner, repo: 'pgxntool-test', @@ -60,26 +65,38 @@ jobs: per_page: 100 }); - const matching = prs.filter(pr => pr.head.ref === branch); - let testPR = null; + const matching = prs.filter(pr => + pr.head.ref === branch && + pr.head.repo?.owner?.login === prOwner + ); + const testPR = matching.length > 0 ? matching[0] : null; if (matching.length > 1) { - // Multiple open PRs with the same branch name from different - // forks. Prefer the one from the same fork owner as this PR. - // If no owner match, fall back to the first result. - const prOwner = context.payload.pull_request.head.repo?.owner?.login; - testPR = matching.find( - pr => pr.head.repo?.owner?.login === prOwner - ) ?? matching[0]; - core.info( - `Multiple pgxntool-test PRs match branch '${branch}'; ` + - `using #${testPR.number} from ${testPR.head.repo?.owner?.login}` + core.warning( + `Multiple pgxntool-test PRs from ${prOwner} match branch ` + + `'${branch}'; using #${testPR.number}.` ); - } else if (matching.length === 1) { - testPR = matching[0]; } if (testPR) { + // Error if the no-test label is also set — that's contradictory. + // Re-fetch the PR live (not from payload) in case the label was + // added after this workflow was triggered. + const { data: currentPR } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber + }); + if (currentPR.labels.some(l => l.name === NO_TEST_LABEL)) { + core.setFailed( + `PR has the '${NO_TEST_LABEL}' label, but a paired ` + + `pgxntool-test PR #${testPR.number} exists on branch '${branch}'.\n\n` + + `Remove the '${NO_TEST_LABEL}' label — it should only be used ` + + `when there is genuinely no paired test PR.` + ); + return; + } + // A paired test PR exists. Verify its CI passed for the exact // current HEAD SHA and that the run is recent enough to be valid. const sha = testPR.head.sha; @@ -90,42 +107,60 @@ jobs: core.info(`Found pgxntool-test PR #${testPR.number} (${sha.slice(0, 7)})`); - // Fetch check runs for the exact HEAD SHA of the test PR. - // Using 'ref: sha' (not branch name) ensures we only see runs - // that used this specific commit — never stale runs from an older - // push on the same branch. - const { data: checks } = await github.rest.checks.listForRef({ - owner: context.repo.owner, - repo: 'pgxntool-test', - ref: sha, - per_page: 100 - }); - const runs = checks.check_runs; + // Poll until all check runs for the exact HEAD SHA complete. + // Using 'ref: sha' (not branch name) ensures we only see runs for + // this commit — never stale runs from an older push on the same branch. + // + // We poll rather than fail immediately because both repos are often + // pushed close together. When that happens, pgxntool CI starts while + // pgxntool-test CI may not have queued yet. We wait up to 20 minutes. + const POLL_INTERVAL_MS = 30 * 1000; + const MAX_WAIT_MS = 20 * 60 * 1000; + const waitStart = Date.now(); + let runs; - if (runs.length === 0) { - core.setFailed( - `pgxntool-test PR #${testPR.number} has no CI runs for its ` + - `current HEAD (${sha.slice(0, 7)}).\n\n` + - `Push a commit (or re-run CI) on the test PR to trigger its ` + - `CI, then re-run this check:\n` + - ` Test PR: ${testPRUrl}\n` + - ` Re-run this check: ${recheckUrl}` - ); - return; - } + while (true) { + const { data: checks } = await github.rest.checks.listForRef({ + owner: context.repo.owner, + repo: 'pgxntool-test', + ref: sha, + per_page: 100 + }); + runs = checks.check_runs; - // If CI is still running, tell the user to wait and re-run. - const incomplete = runs.filter(r => r.status !== 'completed'); - if (incomplete.length > 0) { - const names = incomplete.map(r => r.name).join(', '); - core.setFailed( - `pgxntool-test PR #${testPR.number} CI is still running ` + - `for SHA ${sha.slice(0, 7)}: ${names}\n\n` + - `Wait for the test PR CI to finish, then re-run this check:\n` + - ` Test PR: ${testPRUrl}\n` + - ` Re-run this check: ${recheckUrl}` - ); - return; + const incomplete = runs.filter(r => r.status !== 'completed'); + if (runs.length > 0 && incomplete.length === 0) break; + + const elapsed = Date.now() - waitStart; + if (elapsed >= MAX_WAIT_MS) { + const mins = Math.round(elapsed / 60000); + if (runs.length === 0) { + core.setFailed( + `pgxntool-test PR #${testPR.number} has no CI runs for ` + + `SHA ${sha.slice(0, 7)} after waiting ${mins} min.\n\n` + + `Push a commit (or manually re-run CI) on the test PR:\n` + + ` Test PR: ${testPRUrl}\n` + + ` Re-run this check: ${recheckUrl}` + ); + } else { + const names = incomplete.map(r => r.name).join(', '); + core.setFailed( + `pgxntool-test PR #${testPR.number} CI did not finish within ` + + `${mins} min for SHA ${sha.slice(0, 7)}: ${names}\n\n` + + ` Test PR: ${testPRUrl}\n` + + ` Re-run this check: ${recheckUrl}` + ); + } + return; + } + + if (runs.length === 0) { + core.info(`No CI runs yet for pgxntool-test PR #${testPR.number} (${sha.slice(0, 7)}); waiting 30s...`); + } else { + const names = incomplete.map(r => r.name).join(', '); + core.info(`pgxntool-test CI still running (${names}); waiting 30s...`); + } + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); } // All checks complete — look for failures. @@ -178,9 +213,9 @@ jobs: return; } - // No paired test PR found. Check for the 'commit-with-no-tests' - // label, which a maintainer can apply when a pgxntool change - // genuinely needs no test changes (unusual). + // No paired test PR found. Check for the NO_TEST_LABEL label, + // which a maintainer can apply when a pgxntool change genuinely + // needs no test changes (unusual). // // We make a live API call rather than reading from the event // payload. The payload is a snapshot from when this workflow was @@ -191,9 +226,9 @@ jobs: pull_number: prNumber }); - if (pr.labels.some(l => l.name === 'commit-with-no-tests')) { + if (pr.labels.some(l => l.name === NO_TEST_LABEL)) { core.info( - "'commit-with-no-tests' label is present; running tests " + + `'${NO_TEST_LABEL}' label is present; running tests ` + "against pgxntool-test/master. The protect-label workflow " + "ensures only maintainers can apply this label." ); @@ -206,14 +241,14 @@ jobs: // Fail with a clear, actionable message. core.setFailed( `No paired pgxntool-test PR found for branch '${branch}', ` + - `and no 'commit-with-no-tests' label on this PR.\n\n` + + `and no '${NO_TEST_LABEL}' label on this PR.\n\n` + `pgxntool changes should always be paired with matching test\n` + `changes in pgxntool-test. This check enforces that pairing.\n\n` + `To resolve:\n` + ` 1. Open a PR in pgxntool-test from a branch ALSO named '${branch}'.\n` + ` Branch names must match exactly for the pairing to work.\n\n` + ` 2. If this pgxntool change truly needs no test updates (unusual),\n` + - ` ask a maintainer to apply the 'commit-with-no-tests' label.\n` + + ` ask a maintainer to apply the '${NO_TEST_LABEL}' label.\n` + ` Only maintainers can apply this label. It is not a normal\n` + ` shortcut — most pgxntool changes require test updates.\n\n` + `See: https://github.com/Postgres-Extensions/pgxntool-test#ci-and-contributing` diff --git a/.github/workflows/protect-label.yml b/.github/workflows/protect-label.yml index 18c0004..0f6d514 100644 --- a/.github/workflows/protect-label.yml +++ b/.github/workflows/protect-label.yml @@ -21,6 +21,7 @@ jobs: protect: # Only fire for the label we care about. All other label changes are # unaffected by this workflow. + # Note: this literal must match the LABEL const defined in the script below. if: github.event.label.name == 'commit-with-no-tests' runs-on: ubuntu-latest permissions: @@ -35,6 +36,10 @@ jobs: const actor = context.actor; const prNumber = context.payload.pull_request.number; const action = context.payload.action; // 'labeled' or 'unlabeled' + // Single source of truth for the label name within this script. + // Must also match the literal in the job-level `if:` condition above + // (YAML expressions can't reference JS constants). + const LABEL = 'commit-with-no-tests'; // When this workflow re-adds or removes the label itself, that fires // this event again with actor = 'github-actions[bot]'. Without this @@ -87,12 +92,12 @@ jobs: } if (action === 'labeled' && !hasWrite) { - core.info(`${actor} lacks write access; removing 'commit-with-no-tests' label`); + core.info(`${actor} lacks write access; removing '${LABEL}' label`); await github.rest.issues.removeLabel({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, - name: 'commit-with-no-tests' + name: LABEL }); await github.rest.issues.createComment({ owner: context.repo.owner, @@ -115,12 +120,12 @@ jobs: // in practice: the ci.yml workflow reads labels via a live API // call (not from its cached payload), so a re-run after the label // is restored will pick it up correctly. - core.info(`${actor} lacks write access; re-adding 'commit-with-no-tests' label`); + core.info(`${actor} lacks write access; re-adding '${LABEL}' label`); await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber, - labels: ['commit-with-no-tests'] + labels: [LABEL] }); await github.rest.issues.createComment({ owner: context.repo.owner, @@ -134,6 +139,6 @@ jobs: } else if (hasWrite) { core.info( - `${actor} has write access; '${action}' on 'commit-with-no-tests' label is approved` + `${actor} has write access; '${action}' on '${LABEL}' label is approved` ); } From 9b681e31e8a00edff0a56d37bd2ac1bcc0d2a162 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 19 May 2026 15:49:58 -0500 Subject: [PATCH 09/15] fix(ci): multiple test PRs is an error not a warning; drop recency check; add sync warning - Multiple matching test PRs from same owner: error+fail rather than warning (if we can't tell which one to use, that's a hard error) - Remove the 7-day recency check: since we already validate the exact SHA, forcing a re-run of test CI just to satisfy a time window adds friction (e.g. manual re-runs of pgxntool CI shouldn't require re-running test CI) - Add big-fat WARNING comment on the duplicated test job directing maintainers to keep it in sync with pgxntool-test/ci.yml; includes TODO for reusable workflow Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 51 +++++++++++++++++----------------------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d72b31..d19367c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,15 +69,17 @@ jobs: pr.head.ref === branch && pr.head.repo?.owner?.login === prOwner ); - const testPR = matching.length > 0 ? matching[0] : null; - if (matching.length > 1) { - core.warning( - `Multiple pgxntool-test PRs from ${prOwner} match branch ` + - `'${branch}'; using #${testPR.number}.` + core.setFailed( + `Multiple open pgxntool-test PRs from ${prOwner} match branch ` + + `'${branch}'. Cannot determine which one to use.\n\n` + + `Close all but one, then re-run this check.` ); + return; } + const testPR = matching.length === 1 ? matching[0] : null; + if (testPR) { // Error if the no-test label is also set — that's contradictory. // Re-fetch the PR live (not from payload) in case the label was @@ -180,33 +182,9 @@ jobs: return; } - // Verify the passing run is recent (within 7 days). - // Stale CI results may not reflect the current state of - // dependencies or the pgxn-tools container image. - const MAX_AGE_DAYS = 7; - const mostRecent = runs.reduce((a, b) => - new Date(a.completed_at) > new Date(b.completed_at) ? a : b - ); - const completedAt = new Date(mostRecent.completed_at); - const ageDays = (Date.now() - completedAt.getTime()) / 86400000; - - if (ageDays > MAX_AGE_DAYS) { - core.setFailed( - `pgxntool-test PR #${testPR.number} CI passed for ` + - `SHA ${sha.slice(0, 7)} but the run is ${Math.round(ageDays)} ` + - `days old (completed: ${completedAt.toISOString()}).\n\n` + - `CI results older than ${MAX_AGE_DAYS} days are not accepted.\n` + - `Push a commit or re-run CI on the test PR to refresh:\n` + - ` Test PR: ${testPRUrl}\n` + - ` Re-run this check: ${recheckUrl}` - ); - return; - } - - const ageHours = Math.round(ageDays * 24); core.info( `pgxntool-test PR #${testPR.number} CI passed for ` + - `SHA ${sha.slice(0, 7)} (${ageHours}h ago) — tests run there, not here.` + `SHA ${sha.slice(0, 7)} — tests run there, not here.` ); core.setOutput('run_tests', 'false'); core.setOutput('test_ref', sha); @@ -254,6 +232,19 @@ jobs: `See: https://github.com/Postgres-Extensions/pgxntool-test#ci-and-contributing` ); + # ============================================================ + # WARNING: DUPLICATED JOB — KEEP IN SYNC WITH pgxntool-test + # ============================================================ + # This job is an exact copy of the 'test' job in: + # Postgres-Extensions/pgxntool-test/.github/workflows/ci.yml + # + # It only runs when the 'commit-with-no-tests' label is set (no paired + # test PR). In all other cases, tests run in pgxntool-test's CI. + # + # TODO: Replace this duplication with a GitHub Actions reusable workflow + # (workflow_call) once the reusable workflow lands on pgxntool-test/master. + # See: https://docs.github.com/en/actions/sharing-automations/reusing-workflows + # ============================================================ test: name: 🐘 PostgreSQL ${{ matrix.pg }} needs: check-test-pr From cf4b6463dce60fb10206cce42201ce2eec83e5a2 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 19 May 2026 16:13:48 -0500 Subject: [PATCH 10/15] refactor(ci): replace duplicated test job with reusable workflow call Replace the 88-line duplicated test job (with its WARNING comment) with a 7-line call to Postgres-Extensions/pgxntool-test/.github/workflows/run-tests.yml. Single source of truth; no more risk of the two copies drifting apart. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 115 ++------------------------------------- 1 file changed, 5 insertions(+), 110 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d19367c..5c132aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -232,116 +232,11 @@ jobs: `See: https://github.com/Postgres-Extensions/pgxntool-test#ci-and-contributing` ); - # ============================================================ - # WARNING: DUPLICATED JOB — KEEP IN SYNC WITH pgxntool-test - # ============================================================ - # This job is an exact copy of the 'test' job in: - # Postgres-Extensions/pgxntool-test/.github/workflows/ci.yml - # - # It only runs when the 'commit-with-no-tests' label is set (no paired - # test PR). In all other cases, tests run in pgxntool-test's CI. - # - # TODO: Replace this duplication with a GitHub Actions reusable workflow - # (workflow_call) once the reusable workflow lands on pgxntool-test/master. - # See: https://docs.github.com/en/actions/sharing-automations/reusing-workflows - # ============================================================ test: - name: 🐘 PostgreSQL ${{ matrix.pg }} needs: check-test-pr - # Only run tests when there is no paired test PR. When a paired PR exists, - # tests run in pgxntool-test's own CI — we don't duplicate them here. if: needs.check-test-pr.outputs.run-tests == 'true' - runs-on: ubuntu-latest - container: pgxn/pgxn-tools - strategy: - matrix: - pg: [17, 16, 15, 14, 13, 12] - - steps: - - name: Start PostgreSQL ${{ matrix.pg }} - run: pg-start ${{ matrix.pg }} - - - name: Report CI context - run: | - # Print both repos and exact refs at the top of every job so failures - # are easy to correlate to the right code, especially cross-repo issues. - echo "=== BRANCHES: pgxntool=${{ github.head_ref }} pgxntool-test=${{ needs.check-test-pr.outputs.test-ref }} ===" - - - name: Check out pgxntool - uses: actions/checkout@v4 - with: - path: pgxntool - submodules: false # pgxntool has no submodules - # REQUIRED: must be a full clone. 'git subtree add' refuses to work - # with shallow clones and fails with "shallow roots are not allowed - # to be updated" — an error that looks like a remote/ref problem - # rather than a depth issue. - fetch-depth: 0 - - - name: Check out pgxntool-test - uses: actions/checkout@v4 - with: - repository: Postgres-Extensions/pgxntool-test - ref: ${{ needs.check-test-pr.outputs.test-ref }} - path: pgxntool-test - # REQUIRED: pgxntool-test includes BATS as a git submodule at - # test/bats/. Without this, every test invocation fails with - # "bats: command not found" — an error that looks like a PATH issue. - submodules: recursive - - - name: Configure git for CI - run: | - # The container may run as a different UID than the checkout owner. - # Without safe.directory, git refuses to operate with "fatal: dubious - # ownership" — causing test failures that look like code bugs. - git config --global --add safe.directory "$GITHUB_WORKSPACE/pgxntool" - git config --global --add safe.directory "$GITHUB_WORKSPACE/pgxntool-test" - - # Required for git operations that create commits inside tests. - git config --global user.email "ci@github-actions" - git config --global user.name "GitHub Actions" - - # actions/checkout leaves HEAD detached. Tests that call 'git subtree - # add' need a real local branch, not a detached HEAD. -B force-creates - # the branch even if it already exists. - cd pgxntool - git checkout -B "${{ github.head_ref }}" - - - name: Install dependencies - run: | - apt-get update -qq - apt-get install -y -qq rsync jq ruby - gem install asciidoctor --no-document --quiet - # rsync: used throughout test infrastructure; absent rsync causes - # failures deep in BATS output that look like test logic bugs. - # asciidoctor via gem (not apt ruby-asciidoctor): the apt package - # installs the gem but its binary is not on PATH in pgxn-tools - # containers. gem install puts it in /usr/local/bin which IS on PATH. - # jq: required by assert_valid_meta_json(). - - - name: Pre-install pgtap - run: | - # Pre-install pgtap before running tests to prevent a race condition - # in the concurrent-make-test.bats suite. - # - # The test "concurrent make test succeeds for both projects" runs two - # `make test` processes simultaneously. Both check for - # $(datadir)/extension/pgtap.control and, finding it absent, both - # invoke `pgxn install pgtap --sudo` at the same time. Their - # concurrent `gmake install` calls race to write files into the - # shared /usr/share/postgresql//extension/ directory, causing - # "File exists" and "No such file or directory" errors. - # - # This test step only runs in the 'commit-with-no-tests' case (when - # there is no paired pgxntool-test PR). When a paired test PR exists, - # that PR's own CI handles the pre-install. - pgxn install pgtap --sudo - - - name: Run tests - working-directory: pgxntool-test - env: - # Bypass test infrastructure's branch auto-detection. In CI, pgxntool - # is checked out at a specific ref, not necessarily a remote branch - # tip. PGXNBRANCH tells the test infra which branch to treat it as. - PGXNBRANCH: ${{ github.head_ref }} - run: make test + uses: Postgres-Extensions/pgxntool-test/.github/workflows/run-tests.yml@${{ needs.check-test-pr.outputs.test-ref }} + with: + pgxntool-ref: ${{ github.event.pull_request.head.ref }} + pgxntool-test-ref: ${{ needs.check-test-pr.outputs.test-ref }} + pgxntool-branch: ${{ github.event.pull_request.head.ref }} From 06e9dd3800dff33aa19bf7625b2b158aeee5f683 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 19 May 2026 16:16:29 -0500 Subject: [PATCH 11/15] fix(ci): hardcode @master in reusable workflow ref GitHub Actions does not support ${{ }} expressions in the uses: field. The test job only runs in the commit-with-no-tests path, which always sets test-ref=master, so @master is always correct here. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c132aa..075fd64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,8 +235,12 @@ jobs: test: needs: check-test-pr if: needs.check-test-pr.outputs.run-tests == 'true' - uses: Postgres-Extensions/pgxntool-test/.github/workflows/run-tests.yml@${{ needs.check-test-pr.outputs.test-ref }} + # test-ref is always 'master' when this job runs (only the + # commit-with-no-tests path sets run-tests=true, and that path always + # sets test-ref=master). GitHub Actions does not support expressions in + # the uses: field, so we hardcode @master here. + uses: Postgres-Extensions/pgxntool-test/.github/workflows/run-tests.yml@master with: pgxntool-ref: ${{ github.event.pull_request.head.ref }} - pgxntool-test-ref: ${{ needs.check-test-pr.outputs.test-ref }} + pgxntool-test-ref: master pgxntool-branch: ${{ github.event.pull_request.head.ref }} From 4e8b09665fb6f9447af70f75ab4465b8884907a2 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 19 May 2026 16:18:03 -0500 Subject: [PATCH 12/15] fix(ci): use @add-ci ref for run-tests.yml during development run-tests.yml doesn't exist on pgxntool-test/master yet (it's on add-ci). GitHub validates uses: references upfront even for skipped jobs, so the entire workflow fails if the file isn't found. Use @add-ci while developing; change to @master when pgxntool-test/add-ci merges. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 075fd64..76d7095 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -238,8 +238,10 @@ jobs: # test-ref is always 'master' when this job runs (only the # commit-with-no-tests path sets run-tests=true, and that path always # sets test-ref=master). GitHub Actions does not support expressions in - # the uses: field, so we hardcode @master here. - uses: Postgres-Extensions/pgxntool-test/.github/workflows/run-tests.yml@master + # the uses: field, so we hardcode the ref. + # TODO: change @add-ci to @master when pgxntool-test/add-ci is merged + # (run-tests.yml doesn't exist on master yet during development). + uses: Postgres-Extensions/pgxntool-test/.github/workflows/run-tests.yml@add-ci with: pgxntool-ref: ${{ github.event.pull_request.head.ref }} pgxntool-test-ref: master From d5eab0c5a8746deca077cc87ed56c4a2d72a3bee Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 19 May 2026 17:54:42 -0500 Subject: [PATCH 13/15] docs(ci): add CLAUDE.md with architecture notes; annotate @add-ci ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the cross-repo reusable workflow tradeoffs, the @add-ci→@master transition requirement, and merge order constraints. Expand the comment on the uses: line to make the temporary nature explicit. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/CLAUDE.md | 61 +++++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 19 ++++++++---- 2 files changed, 74 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/CLAUDE.md diff --git a/.github/workflows/CLAUDE.md b/.github/workflows/CLAUDE.md new file mode 100644 index 0000000..48c749c --- /dev/null +++ b/.github/workflows/CLAUDE.md @@ -0,0 +1,61 @@ +# .github/workflows — CI Architecture + +## Workflow files + +- **`ci.yml`** — main CI for pgxntool pull requests. Runs `check-test-pr` (verifies + the paired pgxntool-test PR's CI passed), then optionally runs `test` (only for the + commit-with-no-tests path — see below). +- **`protect-label.yml`** — enforces that only maintainers with write access can apply + or remove the `commit-with-no-tests` label. + +## Normal CI flow (paired test PR exists) + +When a pgxntool PR has a corresponding open PR in pgxntool-test with the same branch +name, the `check-test-pr` job polls (up to 20 minutes) for that test PR's CI to +complete and pass. If it passes, pgxntool CI passes — **no tests run here**. Tests run +exactly once, in pgxntool-test's own CI. + +## commit-with-no-tests path + +When a maintainer applies the `commit-with-no-tests` label (and no paired test PR +exists), the `test` job runs tests directly in pgxntool CI against pgxntool-test/master. +This is the rare exception, not the norm. + +## Cross-repo reusable workflow — tradeoffs and constraints + +The `test` job calls a reusable workflow from pgxntool-test: +```yaml +uses: Postgres-Extensions/pgxntool-test/.github/workflows/run-tests.yml@ +``` + +GitHub Actions requires the `uses:` ref to be a **static string** — expressions like +`${{ }}` are not supported in the repo/path portion or the `@ref` suffix in practice. + +### The @branch → @master ref + +While developing on a feature branch where pgxntool-test also has changes, this ref +is set to `@` so CI can find `run-tests.yml` before it lands on master. + +**IMPORTANT**: This ref must be updated to `@master` before pgxntool merges. The +correct merge order is: **pgxntool-test merges first**, then update this ref to +`@master`, then pgxntool merges. + +**For Claude**: Do NOT leave a `@` ref without explicit user approval. The +user merges directly from the PR page — there are no manual steps between merges. +See `.github/workflows/CLAUDE.md` in pgxntool-test for the full picture. + +### Changes to run-tests.yml + +`run-tests.yml` lives in pgxntool-test and is the single source of truth for all test +steps. If it changes, pgxntool's CI uses `@master` — so it won't see the new version +until pgxntool-test merges. This is acceptable because: +- Changes to `run-tests.yml` require a paired test PR (not commit-with-no-tests) +- When a paired test PR exists, pgxntool's `test` job is skipped anyway +- The two scenarios are mutually exclusive in practice + +## Label name + +The label `commit-with-no-tests` is defined as a const (`NO_TEST_LABEL`) in `ci.yml` +and as `LABEL` in `protect-label.yml`. The job-level `if:` condition in +`protect-label.yml` must also use the literal string (YAML can't reference JS consts) +— keep these in sync if the label name ever changes. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76d7095..09de216 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,12 +235,19 @@ jobs: test: needs: check-test-pr if: needs.check-test-pr.outputs.run-tests == 'true' - # test-ref is always 'master' when this job runs (only the - # commit-with-no-tests path sets run-tests=true, and that path always - # sets test-ref=master). GitHub Actions does not support expressions in - # the uses: field, so we hardcode the ref. - # TODO: change @add-ci to @master when pgxntool-test/add-ci is merged - # (run-tests.yml doesn't exist on master yet during development). + # ----------------------------------------------------------------------- + # CROSS-REPO REUSABLE WORKFLOW — READ BEFORE CHANGING THIS REF + # See: .github/workflows/CLAUDE.md for full architecture notes. + # + # The ref (@add-ci / @master) must be a static string — GitHub Actions + # does not support expressions in uses:. During development on a feature + # branch the ref is @ so CI can find run-tests.yml before it + # lands on master. Before this PR merges, two things must happen: + # 1. pgxntool-test/ merges to master first + # 2. This ref is updated from @ to @master + # + # CURRENT REF: @add-ci (temporary — pgxntool-test/add-ci not yet merged) + # ----------------------------------------------------------------------- uses: Postgres-Extensions/pgxntool-test/.github/workflows/run-tests.yml@add-ci with: pgxntool-ref: ${{ github.event.pull_request.head.ref }} From feb27cf27b6b36a515c83ae1b0495b1cc5151245 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 19 May 2026 18:47:21 -0500 Subject: [PATCH 14/15] fix(ci): address CodeRabbit review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add explicit permissions block (pull-requests: read, checks: read) for least-privilege GITHUB_TOKEN access. - Add concurrency block to cancel stale polling jobs on re-push. Without this, rapid pushes stack up redundant 20-min polling runs. - Warn (via core.warning annotation) when a fork PR's branch is named 'master'. Almost certainly a contributor mistake; we don't block it but make it visible. Can't post a PR comment here — pull_request trigger gives a read-only token for fork PRs. - Paginate pulls.list with github.paginate() instead of a bare per_page:100 call. Handles >100 open PRs correctly. - Paginate checks.listForRef similarly; uses a map fn since that endpoint wraps results in {check_runs:[]} rather than a plain array. - Collapse redundant pgxntool-ref + pgxntool-branch inputs into just pgxntool-branch. They were always the same value in every caller; the distinction (ref could be SHA) was forward-looking but unused. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 48 +++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09de216..3d684e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,14 @@ on: # from other public repos (no secrets needed), 'pull_request' is correct # and safe even for fork PRs. +permissions: + pull-requests: read + checks: read + +concurrency: + group: ci-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: check-test-pr: name: Check for paired pgxntool-test PR @@ -40,7 +48,25 @@ jobs: // master-to-master PRs have no paired test PR by convention. // Run tests against pgxntool-test/master directly. + // + // If a fork PR's branch is named 'master', that's almost certainly + // a mistake (contributors should use a feature branch), but we + // don't block it — just warn visibly as an annotation on the run. + // Note: pull_request gives a read-only token for fork PRs, so we + // can't post a PR comment back to the upstream repo from here. if (branch === 'master') { + const headRepo = context.payload.pull_request.head.repo; + const isBaseRepo = + headRepo?.owner?.login === context.repo.owner && + headRepo?.name === context.repo.repo; + if (!isBaseRepo) { + core.warning( + `PR head branch is named 'master' but comes from a fork ` + + `(${headRepo?.full_name ?? 'unknown'}). Contributors should ` + + `use a feature branch, not master. Proceeding with tests ` + + `against pgxntool-test/master.` + ); + } core.setOutput('run_tests', 'true'); core.setOutput('test_ref', 'master'); return; @@ -58,7 +84,9 @@ jobs: // The GitHub API's 'head' filter requires "owner:branch" format. // We list all open PRs and filter locally — safe for repos with // few open PRs, and avoids needing to know the fork repo name. - const { data: prs } = await github.rest.pulls.list({ + // paginate() fetches all pages automatically, so this is correct + // even if pgxntool-test ever exceeds 100 open PRs (the per_page cap). + const prs = await github.paginate(github.rest.pulls.list, { owner: context.repo.owner, repo: 'pgxntool-test', state: 'open', @@ -122,13 +150,14 @@ jobs: let runs; while (true) { - const { data: checks } = await github.rest.checks.listForRef({ - owner: context.repo.owner, - repo: 'pgxntool-test', - ref: sha, - per_page: 100 - }); - runs = checks.check_runs; + // listForRef wraps results in {check_runs:[], total_count:N} + // rather than a plain array, so we provide a map fn to extract + // the items. paginate() handles fetching all pages. + runs = await github.paginate( + github.rest.checks.listForRef, + { owner: context.repo.owner, repo: 'pgxntool-test', ref: sha, per_page: 100 }, + (response) => response.data.check_runs + ); const incomplete = runs.filter(r => r.status !== 'completed'); if (runs.length > 0 && incomplete.length === 0) break; @@ -250,6 +279,5 @@ jobs: # ----------------------------------------------------------------------- uses: Postgres-Extensions/pgxntool-test/.github/workflows/run-tests.yml@add-ci with: - pgxntool-ref: ${{ github.event.pull_request.head.ref }} - pgxntool-test-ref: master pgxntool-branch: ${{ github.event.pull_request.head.ref }} + pgxntool-test-ref: master From 3f7e5aef04deddb1aae82613bd527a9c7e341187 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Tue, 19 May 2026 18:49:58 -0500 Subject: [PATCH 15/15] fix(ci): revert listForRef to direct API call github.paginate() + a map function on listForRef (which returns {check_runs:[], total_count:N} rather than a plain array) produces undefined elements in the result, crashing the filter step. A single commit will not have 100+ CI check runs, so per_page:100 is correct and safe without pagination. A comment explains why we don't use paginate() here (unlike pulls.list above, which does). Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d684e9..70946b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,14 +150,17 @@ jobs: let runs; while (true) { - // listForRef wraps results in {check_runs:[], total_count:N} - // rather than a plain array, so we provide a map fn to extract - // the items. paginate() handles fetching all pages. - runs = await github.paginate( - github.rest.checks.listForRef, - { owner: context.repo.owner, repo: 'pgxntool-test', ref: sha, per_page: 100 }, - (response) => response.data.check_runs - ); + // per_page: 100 is intentional here — a single commit will + // not realistically have 100+ CI check runs, so pagination + // is unnecessary. (pulls.list uses paginate() above because + // an active repo could have many open PRs.) + const { data: checks } = await github.rest.checks.listForRef({ + owner: context.repo.owner, + repo: 'pgxntool-test', + ref: sha, + per_page: 100 + }); + runs = checks.check_runs; const incomplete = runs.filter(r => r.status !== 'completed'); if (runs.length > 0 && incomplete.length === 0) break;