From 359209900f429257d8f87ca9697eef1e3a119d82 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Sat, 1 Aug 2026 16:30:16 -0500 Subject: [PATCH 1/4] ci: prioritize cheap, high-signal jobs ahead of expensive matrices Gate the five expensive jobs (pg-upgrade-test, pg-upgrade-stepwise, extension-update-test, pg-tle-test, pg-tle-upgrade-test) behind test+lint via `needs:`, so a broken fresh-install test or lint violation is caught before ~24 costly matrix legs (several rebuilding pg_tle from source, several doing binary pg_upgrades) get queued on the same broken baseline. GitHub Actions has no native job-priority mechanism -- the dependency graph is the only real lever for controlling which jobs claim runner-request slots first when concurrent-job caps are hit. Also add a workflow-level concurrency group with cancel-in-progress for non-master refs, so a superseded PR push no longer lets its full matrix run to completion for nothing. --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f08de6..1fe088d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,13 @@ on: branches: - master pull_request: +concurrency: + # Don't let a superseded push's full matrix (dozens of legs, several + # compiling pg_tle from source) keep occupying runner slots once a newer + # push on the same ref supersedes it. Not for master: every push there is + # its own point in history and should get a complete, uncancelled run. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} env: PGUSER: postgres jobs: @@ -338,8 +345,14 @@ jobs: # new one. Every leg's new_pg supports the current version (PG12+); the # version-by-version climb through EVERY major is pg-upgrade-stepwise. pg-upgrade-test: - needs: [changes] - if: needs.changes.outputs.docs_only != 'true' + # Gated behind test+lint (not just changes): this matrix is expensive + # (multiple binary pg_upgrades per leg), and every leg would fail anyway + # on a baseline that's already broken by a failing fresh-install test or + # lint violation. success() is required explicitly once a job's `if:` + # references anything -- GitHub only assumes success() as a default when + # no `if:` is written at all. + needs: [changes, test, lint] + if: success() && needs.changes.outputs.docs_only != 'true' strategy: matrix: include: @@ -457,8 +470,9 @@ jobs: # the guard survived each step. CI-heavy on purpose: it installs PostgreSQL 10 # through 18, performs 8 sequential pg_upgrades, and runs the suite 7 times. pg-upgrade-stepwise: - needs: [changes] - if: needs.changes.outputs.docs_only != 'true' + # Gated behind test+lint -- see the comment on pg-upgrade-test's needs. + needs: [changes, test, lint] + if: success() && needs.changes.outputs.docs_only != 'true' name: 🪜 Stepwise pg_upgrade 10 → 18 (from 0.2.0) runs-on: ubuntu-latest container: pgxn/pgxn-tools @@ -558,8 +572,9 @@ jobs: # sole version where they still load. Complements pg-upgrade-test, which covers # the cross-major-version binary upgrade instead. extension-update-test: - needs: [changes] - if: needs.changes.outputs.docs_only != 'true' + # Gated behind test+lint -- see the comment on pg-upgrade-test's needs. + needs: [changes, test, lint] + if: success() && needs.changes.outputs.docs_only != 'true' strategy: matrix: # PG12+: exercise the WIDEST update path we support — CREATE EXTENSION at @@ -641,8 +656,9 @@ jobs: run: bin/test_existing update-scenario cat_tools_update 0.2.2 pg-tle-test: - needs: [changes] - if: needs.changes.outputs.docs_only != 'true' + # Gated behind test+lint -- see the comment on pg-upgrade-test's needs. + needs: [changes, test, lint] + if: success() && needs.changes.outputs.docs_only != 'true' strategy: matrix: # Current-supported majors, from the single source in the changes job @@ -768,8 +784,9 @@ jobs: run: bin/assert_fs_clean verify ${{ matrix.pg }} /tmp/control_baseline.txt pg-tle-upgrade-test: - needs: [changes] - if: needs.changes.outputs.docs_only != 'true' + # Gated behind test+lint -- see the comment on pg-upgrade-test's needs. + needs: [changes, test, lint] + if: success() && needs.changes.outputs.docs_only != 'true' strategy: matrix: include: From 83d93b22705af86db0355295ad6903a7a373e902 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Sat, 1 Aug 2026 16:50:06 -0500 Subject: [PATCH 2/4] ci: cancel in-flight CI/claude-review runs when a PR closes or merges Neither ci.yml's push/pull_request triggers nor claude-code-review.yml's pull_request_target trigger include the "closed" activity type, so a PR that merges or closes today lets any run already in flight for it -- including the paid Claude review -- finish on its own instead of being cancelled. Add a dedicated workflow triggered only on PR close whose two jobs do nothing except join the exact concurrency groups ci.yml and claude-code-review.yml already use; joining a group with cancel-in-progress: true cancels whatever's still running there. claude.yml (the @claude mention workflow) is deliberately left out -- its own comment explains it has no concurrency group because those requests are independent and shouldn't be dropped by a PR closing. --- .github/workflows/cancel-on-close.yml | 43 +++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/cancel-on-close.yml diff --git a/.github/workflows/cancel-on-close.yml b/.github/workflows/cancel-on-close.yml new file mode 100644 index 0000000..cf340a7 --- /dev/null +++ b/.github/workflows/cancel-on-close.yml @@ -0,0 +1,43 @@ +name: Cancel stale runs on PR close + +# GitHub Actions has no direct "cancel in-flight runs when a PR closes/merges" +# trigger -- concurrency groups only cancel a running job/workflow when a NEW +# entrant joins the SAME group. These two jobs deliberately do nothing except +# join the exact concurrency groups that ci.yml and claude-code-review.yml +# already use, so a PR closing (merged or not) cancels any CI matrix -- and, +# more importantly, any still-running (money-costing) Claude review -- for a +# PR nobody can act on anymore. Concurrency groups are enforced repo-wide, +# not scoped to the workflow file that declares them, so joining the same +# group string from here works. +# +# COUPLING: these group strings are copies, not references -- if ci.yml's +# `name:` or its `concurrency.group:` formula, or claude-code-review.yml's +# `concurrency.group:` formula, ever changes, update the matching string +# below or cancellation here silently stops matching (no error, it just +# becomes its own no-op group). +on: + pull_request: + types: [closed] + +permissions: {} + +jobs: + cancel-ci: + # Matches ci.yml's `group: ${{ github.workflow }}-${{ github.ref }}`, + # where github.workflow is that file's `name: CI` and github.ref for a + # pull_request-triggered run is refs/pull//merge. + concurrency: + group: CI-refs/pull/${{ github.event.pull_request.number }}/merge + cancel-in-progress: true + runs-on: ubuntu-latest + steps: + - run: echo "Cancelled any in-flight CI run for PR #${{ github.event.pull_request.number }}" + + cancel-claude-review: + # Matches claude-code-review.yml's `group: claude-review-`. + concurrency: + group: claude-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + runs-on: ubuntu-latest + steps: + - run: echo "Cancelled any in-flight Claude review for PR #${{ github.event.pull_request.number }}" From f826d3b31b7f3e64aa04279bdb08251855756498 Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Sat, 1 Aug 2026 17:08:55 -0500 Subject: [PATCH 3/4] ci: fail CI if cancel-on-close.yml's target workflows drift cancel-on-close.yml cancels in-flight runs by joining ci.yml's and claude-code-review.yml's concurrency groups using hand-copied strings, not references (there's no way to reference another workflow's group formula). If either file were renamed, its `name:` changed, or its concurrency.group formula edited, that copy would silently stop matching -- cancellation would just quietly become a no-op, with no error anywhere. Add a job that parses both files on every push and fails loudly if any of cancel-on-close.yml's assumptions (file exists, ci.yml's name is "CI", both concurrency.group formulas match, claude-review job still exists) no longer hold, telling whoever broke it to update cancel-on-close.yml's copies. --- .github/workflows/cancel-on-close.yml | 4 +- .github/workflows/ci.yml | 58 ++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cancel-on-close.yml b/.github/workflows/cancel-on-close.yml index cf340a7..8ba7d92 100644 --- a/.github/workflows/cancel-on-close.yml +++ b/.github/workflows/cancel-on-close.yml @@ -14,7 +14,9 @@ name: Cancel stale runs on PR close # `name:` or its `concurrency.group:` formula, or claude-code-review.yml's # `concurrency.group:` formula, ever changes, update the matching string # below or cancellation here silently stops matching (no error, it just -# becomes its own no-op group). +# becomes its own no-op group). ci.yml's verify-cancel-on-close-coupling job +# guards this automatically on every push -- it fails CI if those files or +# fields drift, so this comment is a "why", not the only line of defense. on: pull_request: types: [closed] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fe088d..9a72e52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -335,6 +335,62 @@ jobs: - name: Lint SQL run: make lint + # cancel-on-close.yml cancels in-flight CI/claude-review runs on PR close by + # joining the SAME concurrency group strings this workflow and + # claude-code-review.yml use -- but it only works because those strings are + # hand-copied there, not referenced. No `if: docs_only` gate here on + # purpose: this is a near-instant metadata check, not worth ever skipping, + # and workflow files aren't "docs" anyway. + verify-cancel-on-close-coupling: + name: 🔗 Verify cancel-on-close.yml coupling + runs-on: ubuntu-latest + steps: + - name: Check out the repo + uses: actions/checkout@v6 + - name: Confirm ci.yml / claude-code-review.yml still match cancel-on-close.yml's assumptions + run: | + python3 - <<'PY' + import os, sys, yaml + + def load(path): + if not os.path.isfile(path): + return None, f"{path} does not exist" + with open(path) as f: + return yaml.safe_load(f), None + + errors = [] + + ci, err = load('.github/workflows/ci.yml') + if err: + errors.append(err) + else: + if ci.get('name') != 'CI': + errors.append(f"ci.yml name is {ci.get('name')!r}, expected 'CI'") + ci_group = (ci.get('concurrency') or {}).get('group') + expected = "${{ github.workflow }}-${{ github.ref }}" + if ci_group != expected: + errors.append(f"ci.yml concurrency.group is {ci_group!r}, expected {expected!r}") + + review, err = load('.github/workflows/claude-code-review.yml') + if err: + errors.append(err) + elif not review.get('jobs', {}).get('claude-review'): + errors.append("claude-code-review.yml no longer has a 'claude-review' job") + else: + review_group = (review.get('concurrency') or {}).get('group') + expected = "claude-review-${{ github.event.pull_request.number }}" + if review_group != expected: + errors.append(f"claude-code-review.yml concurrency.group is {review_group!r}, expected {expected!r}") + + if errors: + print("cancel-on-close.yml's assumptions about ci.yml / claude-code-review.yml no longer hold:") + for e in errors: + print(f" - {e}") + print("Update the hardcoded group: strings in .github/workflows/cancel-on-close.yml (or restore the files/fields) to match.") + sys.exit(1) + print("OK: cancel-on-close.yml's group strings still match ci.yml and claude-code-review.yml.") + PY + # Proves cat_tools survives a BINARY pg_upgrade (in-place catalog migration to a # newer PostgreSQL major), not just an in-place extension update. Each leg is a # SINGLE jump: install an old cat_tools on an old cluster, plant a dependency @@ -909,7 +965,7 @@ jobs: # the heavy jobs gated off by the `changes` job on a docs-only push), and # fails if any failed or were cancelled. all-checks-passed: - needs: [changes, test, lint, pg-upgrade-test, pg-upgrade-stepwise, extension-update-test, pg-tle-test, pg-tle-upgrade-test] + needs: [changes, test, lint, verify-cancel-on-close-coupling, pg-upgrade-test, pg-upgrade-stepwise, extension-update-test, pg-tle-test, pg-tle-upgrade-test] if: always() runs-on: ubuntu-latest steps: From 2cc599bf74695417f84e0129ee12d043567b5a7b Mon Sep 17 00:00:00 2001 From: jnasbyupgrade Date: Sat, 1 Aug 2026 17:13:22 -0500 Subject: [PATCH 4/4] ci: fix verify-cancel-on-close-coupling comparing against itself The "expected" strings were written as literal ${{ ... }} text meant to be compared, unevaluated, against what PyYAML reads from disk. But GitHub Actions pre-substitutes ANY ${{ ... }} it finds anywhere in a run: block's raw text -- including inside this heredoc -- before the script ever runs. So `expected = "${{ github.workflow }}-${{ github.ref }}"` never reached Python as written: Actions evaluated it first into this run's own actual values (e.g. "CI-refs/pull/63/merge"), while the value read from disk via PyYAML is the genuine unevaluated literal text -- the two could never match, on any run, regardless of whether the files actually drifted. Confirmed failing exactly this way on cat_tools#63. Fix: build each expected string by concatenating a bare "$" with the "{{ ... }}" text, so the four-character expression-opener sequence never appears contiguously anywhere in this file for Actions to pre-substitute. --- .github/workflows/ci.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a72e52..11b225c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -352,6 +352,18 @@ jobs: python3 - <<'PY' import os, sys, yaml + # GitHub Actions pre-substitutes any expression marker (dollar sign + # directly followed by double-open-brace) it finds anywhere in a + # run: block's raw text -- including inside this heredoc -- before + # the script ever runs, regardless of quoting. D keeps that four- + # character sequence from ever appearing contiguously in this file + # (not even in this comment -- deliberately not spelled out here), + # so the expected strings below stay literal text for Python to + # compare against, instead of being evaluated away by Actions + # itself (which would make this check compare each expression's + # live value against itself -- always "equal", catching nothing). + D = "$" + def load(path): if not os.path.isfile(path): return None, f"{path} does not exist" @@ -367,7 +379,7 @@ jobs: if ci.get('name') != 'CI': errors.append(f"ci.yml name is {ci.get('name')!r}, expected 'CI'") ci_group = (ci.get('concurrency') or {}).get('group') - expected = "${{ github.workflow }}-${{ github.ref }}" + expected = D + "{{ github.workflow }}-" + D + "{{ github.ref }}" if ci_group != expected: errors.append(f"ci.yml concurrency.group is {ci_group!r}, expected {expected!r}") @@ -378,7 +390,7 @@ jobs: errors.append("claude-code-review.yml no longer has a 'claude-review' job") else: review_group = (review.get('concurrency') or {}).get('group') - expected = "claude-review-${{ github.event.pull_request.number }}" + expected = "claude-review-" + D + "{{ github.event.pull_request.number }}" if review_group != expected: errors.append(f"claude-code-review.yml concurrency.group is {review_group!r}, expected {expected!r}")