-
Notifications
You must be signed in to change notification settings - Fork 1
Add CI workflows and multi-session PR guard #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+542
−0
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
52945a7
Add CI workflows and multi-session PR guard
jnasbyupgrade 39a3fce
fix: use full clone for pgxntool checkout (git subtree requires it)
jnasbyupgrade 8390e29
docs: require CI monitoring background task after every push
jnasbyupgrade e8b1aba
fix: install asciidoctor via gem; add branch context reporting
jnasbyupgrade c5f753a
fix: exclude .github/ from distribution archives
jnasbyupgrade 121f04d
refactor: redesign CI to check for paired test PR immediately
jnasbyupgrade 93a9eec
feat: verify test PR CI used current SHA and is recent
jnasbyupgrade 4177d61
fix(ci): owner-match forks, poll for in-progress CI, error on label+t…
jnasbyupgrade 9b681e3
fix(ci): multiple test PRs is an error not a warning; drop recency ch…
jnasbyupgrade cf4b646
refactor(ci): replace duplicated test job with reusable workflow call
jnasbyupgrade 06e9dd3
fix(ci): hardcode @master in reusable workflow ref
jnasbyupgrade 4e8b096
fix(ci): use @add-ci ref for run-tests.yml during development
jnasbyupgrade d5eab0c
docs(ci): add CLAUDE.md with architecture notes; annotate @add-ci ref
jnasbyupgrade feb27cf
fix(ci): address CodeRabbit review comments
jnasbyupgrade 3f7e5ae
fix(ci): revert listForRef to direct API call
jnasbyupgrade 4b4c49c
ci: address CodeRabbit review (permissions, pinned action, master gate)
jnasbyupgrade ef40b9e
docs: require CI monitoring after every push (mirror pgxntool-test)
jnasbyupgrade 2aac975
Merge remote-tracking branch 'upstream/master' into add-ci
jnasbyupgrade File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| # Claude Development Notes | ||
|
|
||
| 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 | ||
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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@<ref> | ||
| ``` | ||
|
|
||
| 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 `@<branch>` 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 `@<branch>` 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,292 @@ | ||
| 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. | ||
|
|
||
| permissions: | ||
| contents: read # required by actions/checkout in the reusable test workflow | ||
| 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 | ||
| runs-on: ubuntu-latest | ||
| # 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 | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| outputs: | ||
| run-tests: ${{ steps.check.outputs.run_tests }} | ||
| test-ref: ${{ steps.check.outputs.test_ref }} | ||
|
|
||
| steps: | ||
| - name: Find paired pgxntool-test PR or check commit-with-no-tests label | ||
| id: check | ||
| # Pinned to an immutable SHA (supply-chain hardening); comment tracks the tag. | ||
| uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # 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; | ||
| 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. | ||
| // | ||
| // 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. | ||
| // Gate on the BASE branch too: this shortcut is only for | ||
| // master-to-master PRs. A PR from master into some other base must | ||
| // still go through the normal paired-test lookup below. | ||
| if (branch === 'master' && context.payload.pull_request.base.ref === '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; | ||
| } | ||
|
jnasbyupgrade marked this conversation as resolved.
|
||
|
|
||
| // 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. | ||
| // We list all open PRs and filter locally — safe for repos with | ||
| // few open PRs, and avoids needing to know the fork repo name. | ||
| // 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', | ||
| per_page: 100 | ||
| }); | ||
|
|
||
| const matching = prs.filter(pr => | ||
| pr.head.ref === branch && | ||
| pr.head.repo?.owner?.login === prOwner | ||
| ); | ||
| if (matching.length > 1) { | ||
| 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 | ||
| // 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; | ||
| 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)})`); | ||
|
|
||
| // 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; | ||
|
|
||
| while (true) { | ||
| // 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; | ||
|
|
||
| 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. | ||
| // '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; | ||
| } | ||
|
|
||
| core.info( | ||
| `pgxntool-test PR #${testPR.number} CI passed for ` + | ||
| `SHA ${sha.slice(0, 7)} — tests run there, not here.` | ||
| ); | ||
| core.setOutput('run_tests', 'false'); | ||
| core.setOutput('test_ref', sha); | ||
| return; | ||
| } | ||
|
|
||
| // 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 | ||
| // 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 === NO_TEST_LABEL)) { | ||
| core.info( | ||
| `'${NO_TEST_LABEL}' label is present; running tests ` + | ||
| "against pgxntool-test/master. The protect-label workflow " + | ||
| "ensures only maintainers can apply this label." | ||
| ); | ||
| 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( | ||
| `No paired pgxntool-test PR found for branch '${branch}', ` + | ||
| `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 the SAME account (${prOwner}),\n` + | ||
| ` on a branch ALSO named '${branch}'. Both the branch name and\n` + | ||
| ` the head owner 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 '${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` | ||
| ); | ||
|
|
||
| test: | ||
| needs: check-test-pr | ||
| if: needs.check-test-pr.outputs.run-tests == 'true' | ||
| # ----------------------------------------------------------------------- | ||
| # 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 @<branch> so CI can find run-tests.yml before it | ||
| # lands on master. Before this PR merges, two things must happen: | ||
| # 1. pgxntool-test/<branch> merges to master first | ||
| # 2. This ref is updated from @<branch> 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-branch: ${{ github.event.pull_request.head.ref }} | ||
| pgxntool-test-ref: master | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.