Skip to content

Add CI workflows and multi-session PR guard #14

Add CI workflows and multi-session PR guard

Add CI workflows and multi-session PR guard #14

Workflow file for this run

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:
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
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
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;
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.
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;
}
// 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) {
// 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;
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 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 '${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