From 57a5808faeb195a694400cb66ca26a1b674ec98e Mon Sep 17 00:00:00 2001 From: James McCreight Date: Tue, 18 Aug 2026 09:28:09 -0600 Subject: [PATCH 1/6] CI skeleton/full split: light runs on branch pushes, full on PRs Pushes to any branch (in this repo or on forks) run a skeleton: installs, linting, domainless tests, the docs build, and example notebooks on ubuntu only. The full suite (7 domain test jobs x 3 platforms) runs for pull requests including drafts, pushes to develop/main, and workflow_dispatch. Concurrency groups cancel in-flight runs superseded by a newer push on the same ref, except on develop/main where every run completes. One full CI trigger is ~30 jobs / ~660 runner-minutes vs ~55 for the skeleton. Public-repo standard-runner minutes are free, but runner concurrency slots are shared across the organization; this keeps per-push feedback on forks while reserving the big matrix for PRs. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yaml | 51 ++++++++++++++++++++++++++++++ .github/workflows/ci_docs.yaml | 6 ++++ .github/workflows/ci_examples.yaml | 12 ++++++- doc/whats-new.rst | 8 +++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 564a2d2f..3f53ad78 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,5 +1,8 @@ name: CI on: + # Pushes to any branch (here or on forks) run a skeleton: installs, + # linting, and domainless tests. The full suite (the domain test jobs) + # runs for pull requests, pushes to develop/main, and workflow_dispatch. push: branches: - "*" @@ -17,6 +20,12 @@ on: required: false default: false +# Cancel in-flight runs superseded by a newer push on the same ref, but +# let every run on the mainline branches finish +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref_name != 'develop' && github.ref_name != 'main' }} + jobs: pws_install: name: standard installation @@ -233,6 +242,12 @@ jobs: test_sagehen_5yr: name: sagehen_5yr ${{ matrix.os }} py${{ matrix.python-version }} + # skeleton/full split: domain jobs run only for pull requests, + # pushes to develop/main, and workflow_dispatch + if: >- + github.event_name != 'push' || + github.ref_name == 'develop' || + github.ref_name == 'main' runs-on: ${{ matrix.os }} defaults: run: @@ -348,6 +363,12 @@ jobs: test_hru_1: name: hru_1 ${{ matrix.os }} py${{ matrix.python-version }} + # skeleton/full split: domain jobs run only for pull requests, + # pushes to develop/main, and workflow_dispatch + if: >- + github.event_name != 'push' || + github.ref_name == 'develop' || + github.ref_name == 'main' runs-on: ${{ matrix.os }} defaults: run: @@ -476,6 +497,12 @@ jobs: test_drb_2yr: name: drb_2yr ${{ matrix.os }} py${{ matrix.python-version }} + # skeleton/full split: domain jobs run only for pull requests, + # pushes to develop/main, and workflow_dispatch + if: >- + github.event_name != 'push' || + github.ref_name == 'develop' || + github.ref_name == 'main' runs-on: ${{ matrix.os }} defaults: run: @@ -712,6 +739,12 @@ jobs: test_ucb_2yr_nhm: name: ucb_2yr_nhm ${{ matrix.os }} py${{ matrix.python-version }} + # skeleton/full split: domain jobs run only for pull requests, + # pushes to develop/main, and workflow_dispatch + if: >- + github.event_name != 'push' || + github.ref_name == 'develop' || + github.ref_name == 'main' runs-on: ${{ matrix.os }} defaults: run: @@ -834,6 +867,12 @@ jobs: test_ucb_2yr_slow_tests: name: ucb_2yr_slow_tests ${{ matrix.os }} py${{ matrix.python-version }} + # skeleton/full split: domain jobs run only for pull requests, + # pushes to develop/main, and workflow_dispatch + if: >- + github.event_name != 'push' || + github.ref_name == 'develop' || + github.ref_name == 'main' runs-on: ${{ matrix.os }} defaults: run: @@ -944,6 +983,12 @@ jobs: test_fgr_ag_2yr_subset1: name: fgr_ag_2yr_subset1 ${{ matrix.os }} py${{ matrix.python-version }} + # skeleton/full split: domain jobs run only for pull requests, + # pushes to develop/main, and workflow_dispatch + if: >- + github.event_name != 'push' || + github.ref_name == 'develop' || + github.ref_name == 'main' runs-on: ${{ matrix.os }} defaults: run: @@ -1132,6 +1177,12 @@ jobs: test_fgr_ag_2yr_subset2: name: fgr_ag_2yr_subset2 ${{ matrix.os }} py${{ matrix.python-version }} + # skeleton/full split: domain jobs run only for pull requests, + # pushes to develop/main, and workflow_dispatch + if: >- + github.event_name != 'push' || + github.ref_name == 'develop' || + github.ref_name == 'main' runs-on: ${{ matrix.os }} defaults: run: diff --git a/.github/workflows/ci_docs.yaml b/.github/workflows/ci_docs.yaml index 8e978447..9df43ea9 100644 --- a/.github/workflows/ci_docs.yaml +++ b/.github/workflows/ci_docs.yaml @@ -5,6 +5,12 @@ on: pull_request: workflow_dispatch: +# Cancel in-flight runs superseded by a newer push on the same ref, but +# let every run on the mainline branches finish +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref_name != 'develop' && github.ref_name != 'main' }} + jobs: build_docs: name: Build Documentation diff --git a/.github/workflows/ci_examples.yaml b/.github/workflows/ci_examples.yaml index ca16cb48..f0874d14 100644 --- a/.github/workflows/ci_examples.yaml +++ b/.github/workflows/ci_examples.yaml @@ -1,6 +1,8 @@ name: CI example notebooks on: + # Pushes to any branch (here or on forks) run the notebooks on ubuntu + # only; all platforms run for pull requests and develop/main pushes. push: branches: - "*" @@ -10,6 +12,12 @@ on: - "*" - "!v[0-9]+.[0-9]+.[0-9]+*" +# Cancel in-flight runs superseded by a newer push on the same ref, but +# let every run on the mainline branches finish +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref_name != 'develop' && github.ref_name != 'main' }} + jobs: test: name: ${{ matrix.os}} py${{ matrix.python-version }} @@ -27,7 +35,9 @@ jobs: strategy: fail-fast: false matrix: - os: ["ubuntu-latest", "macos-latest", "windows-latest"] + # skeleton/full split: all platforms for pull requests and + # develop/main pushes; ubuntu only for other pushes + os: ${{ (github.event_name != 'push' || github.ref_name == 'develop' || github.ref_name == 'main') && fromJSON('["ubuntu-latest", "macos-latest", "windows-latest"]') || fromJSON('["ubuntu-latest"]') }} python-version: ["3.13"] steps: diff --git a/doc/whats-new.rst b/doc/whats-new.rst index a93de3aa..1e869d4e 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -27,6 +27,14 @@ Bug fixes Internal changes ~~~~~~~~~~~~~~~~ +- Reduce CI footprint with a skeleton/full split: pushes to any branch (in + this repository or on forks) run a skeleton — installs, linting, domainless + tests, the docs build, and example notebooks on ubuntu only — while the + full suite (domain test jobs, all platforms) runs for pull requests + (including drafts), pushes to ``develop``/``main``, and + ``workflow_dispatch``. ``concurrency`` groups cancel in-flight runs + superseded by a newer push on the same non-mainline ref. + (:pull:`407`) By `James McCreight `_. .. _whats-new.3.0.0: From 3b5334fc34df81221fe3bb4f249a7654a6fba93f Mon Sep 17 00:00:00 2001 From: James McCreight Date: Tue, 18 Aug 2026 09:50:22 -0600 Subject: [PATCH 2/6] CI: opt-in domain jobs on branch pushes via ci- Domain test jobs also run on branch pushes when a ci- appears in the branch name or the pushed head commit message: ci-all, ci-sagehen, ci-hru1, ci-drb, ci-ucb, ci-fgr. A branch named my_feature_ci-fgr runs both fgr jobs on every push with nothing to clean up before merge, since the opt-in lives in the branch name. Documented in DEVELOPER.md. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yaml | 75 ++++++++++++++++++++++++++++----------- DEVELOPER.md | 15 ++++++++ doc/whats-new.rst | 6 ++-- 3 files changed, 73 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3f53ad78..ec5ff882 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -3,6 +3,11 @@ on: # Pushes to any branch (here or on forks) run a skeleton: installs, # linting, and domainless tests. The full suite (the domain test jobs) # runs for pull requests, pushes to develop/main, and workflow_dispatch. + # To ALSO run domain jobs on branch pushes, put a ci- anywhere in + # the branch name or in the pushed (head) commit message. Tokens: + # ci-all, ci-sagehen, ci-hru1, ci-drb, ci-ucb, ci-fgr. For example, a + # branch named my_feature_ci-fgr runs both fgr jobs on every push, with + # no workflow edits to clean up before merging. push: branches: - "*" @@ -242,12 +247,16 @@ jobs: test_sagehen_5yr: name: sagehen_5yr ${{ matrix.os }} py${{ matrix.python-version }} - # skeleton/full split: domain jobs run only for pull requests, - # pushes to develop/main, and workflow_dispatch + # domain jobs skip plain branch pushes; see the ci- note + # in the trigger comment at the top of this file if: >- github.event_name != 'push' || github.ref_name == 'develop' || - github.ref_name == 'main' + github.ref_name == 'main' || + contains(github.ref_name, 'ci-all') || + contains(github.event.head_commit.message, 'ci-all') || + contains(github.ref_name, 'ci-sagehen') || + contains(github.event.head_commit.message, 'ci-sagehen') runs-on: ${{ matrix.os }} defaults: run: @@ -363,12 +372,16 @@ jobs: test_hru_1: name: hru_1 ${{ matrix.os }} py${{ matrix.python-version }} - # skeleton/full split: domain jobs run only for pull requests, - # pushes to develop/main, and workflow_dispatch + # domain jobs skip plain branch pushes; see the ci- note + # in the trigger comment at the top of this file if: >- github.event_name != 'push' || github.ref_name == 'develop' || - github.ref_name == 'main' + github.ref_name == 'main' || + contains(github.ref_name, 'ci-all') || + contains(github.event.head_commit.message, 'ci-all') || + contains(github.ref_name, 'ci-hru1') || + contains(github.event.head_commit.message, 'ci-hru1') runs-on: ${{ matrix.os }} defaults: run: @@ -497,12 +510,16 @@ jobs: test_drb_2yr: name: drb_2yr ${{ matrix.os }} py${{ matrix.python-version }} - # skeleton/full split: domain jobs run only for pull requests, - # pushes to develop/main, and workflow_dispatch + # domain jobs skip plain branch pushes; see the ci- note + # in the trigger comment at the top of this file if: >- github.event_name != 'push' || github.ref_name == 'develop' || - github.ref_name == 'main' + github.ref_name == 'main' || + contains(github.ref_name, 'ci-all') || + contains(github.event.head_commit.message, 'ci-all') || + contains(github.ref_name, 'ci-drb') || + contains(github.event.head_commit.message, 'ci-drb') runs-on: ${{ matrix.os }} defaults: run: @@ -739,12 +756,16 @@ jobs: test_ucb_2yr_nhm: name: ucb_2yr_nhm ${{ matrix.os }} py${{ matrix.python-version }} - # skeleton/full split: domain jobs run only for pull requests, - # pushes to develop/main, and workflow_dispatch + # domain jobs skip plain branch pushes; see the ci- note + # in the trigger comment at the top of this file if: >- github.event_name != 'push' || github.ref_name == 'develop' || - github.ref_name == 'main' + github.ref_name == 'main' || + contains(github.ref_name, 'ci-all') || + contains(github.event.head_commit.message, 'ci-all') || + contains(github.ref_name, 'ci-ucb') || + contains(github.event.head_commit.message, 'ci-ucb') runs-on: ${{ matrix.os }} defaults: run: @@ -867,12 +888,16 @@ jobs: test_ucb_2yr_slow_tests: name: ucb_2yr_slow_tests ${{ matrix.os }} py${{ matrix.python-version }} - # skeleton/full split: domain jobs run only for pull requests, - # pushes to develop/main, and workflow_dispatch + # domain jobs skip plain branch pushes; see the ci- note + # in the trigger comment at the top of this file if: >- github.event_name != 'push' || github.ref_name == 'develop' || - github.ref_name == 'main' + github.ref_name == 'main' || + contains(github.ref_name, 'ci-all') || + contains(github.event.head_commit.message, 'ci-all') || + contains(github.ref_name, 'ci-ucb') || + contains(github.event.head_commit.message, 'ci-ucb') runs-on: ${{ matrix.os }} defaults: run: @@ -983,12 +1008,16 @@ jobs: test_fgr_ag_2yr_subset1: name: fgr_ag_2yr_subset1 ${{ matrix.os }} py${{ matrix.python-version }} - # skeleton/full split: domain jobs run only for pull requests, - # pushes to develop/main, and workflow_dispatch + # domain jobs skip plain branch pushes; see the ci- note + # in the trigger comment at the top of this file if: >- github.event_name != 'push' || github.ref_name == 'develop' || - github.ref_name == 'main' + github.ref_name == 'main' || + contains(github.ref_name, 'ci-all') || + contains(github.event.head_commit.message, 'ci-all') || + contains(github.ref_name, 'ci-fgr') || + contains(github.event.head_commit.message, 'ci-fgr') runs-on: ${{ matrix.os }} defaults: run: @@ -1177,12 +1206,16 @@ jobs: test_fgr_ag_2yr_subset2: name: fgr_ag_2yr_subset2 ${{ matrix.os }} py${{ matrix.python-version }} - # skeleton/full split: domain jobs run only for pull requests, - # pushes to develop/main, and workflow_dispatch + # domain jobs skip plain branch pushes; see the ci- note + # in the trigger comment at the top of this file if: >- github.event_name != 'push' || github.ref_name == 'develop' || - github.ref_name == 'main' + github.ref_name == 'main' || + contains(github.ref_name, 'ci-all') || + contains(github.event.head_commit.message, 'ci-all') || + contains(github.ref_name, 'ci-fgr') || + contains(github.event.head_commit.message, 'ci-fgr') runs-on: ${{ matrix.os }} defaults: run: diff --git a/DEVELOPER.md b/DEVELOPER.md index f9e0d0fe..66949129 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -138,6 +138,21 @@ The automated practices of installing, linting, and testing described below are all formally encoded in `.github/workflows/ci.yaml` and `.github/workflows/ci_examples.yaml` files. +CI runs at two levels. Pushes to any branch — in this repository or on a fork — +run a skeleton: installs, linting, domainless tests, the documentation build, +and the example notebooks on ubuntu only. The full suite (the domain test jobs +on all three platforms) runs for pull requests (including drafts), pushes to +`develop`/`main`, and `workflow_dispatch`. + +To also run domain test jobs on branch pushes, put a `ci-` anywhere in +the branch name or in the pushed (head) commit message. The tokens are +`ci-all`, `ci-sagehen`, `ci-hru1`, `ci-drb`, `ci-ucb`, and `ci-fgr`. For +example, a branch named `my_feature_ci-fgr` runs both fgr domain jobs on every +push — with nothing to clean up before merging, since the opt-in lives in the +branch name. A commit message containing `ci-drb` runs the drb job for that +push only. Alternatively, open a draft PR or use the workflow dispatch button +(on your fork) to get the full suite on any branch. + ## Testing Once the dependencies are available, we want to verify the software by running diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 1e869d4e..3fa24ea1 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -32,8 +32,10 @@ Internal changes tests, the docs build, and example notebooks on ubuntu only — while the full suite (domain test jobs, all platforms) runs for pull requests (including drafts), pushes to ``develop``/``main``, and - ``workflow_dispatch``. ``concurrency`` groups cancel in-flight runs - superseded by a newer push on the same non-mainline ref. + ``workflow_dispatch``. Domain jobs can be opted in on branch pushes with a + ``ci-`` (e.g. ``ci-fgr``, ``ci-all``) in the branch name or head + commit message — see DEVELOPER.md. ``concurrency`` groups cancel in-flight + runs superseded by a newer push on the same non-mainline ref. (:pull:`407`) By `James McCreight `_. .. _whats-new.3.0.0: From ce4fdfc8abf50b400028e40f6e63bc635348f385 Mon Sep 17 00:00:00 2001 From: James McCreight Date: Tue, 18 Aug 2026 10:01:18 -0600 Subject: [PATCH 3/6] DEVELOPER.md: clarify ci-token semantics (sticky vs one-shot) Branch-name tokens apply to every push; commit-message tokens are evaluated fresh per push (head commit only) with no memory of earlier pushes. Add the empty-commit recipe for triggering a domain run without code changes. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yaml | 3 ++- DEVELOPER.md | 29 +++++++++++++++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ec5ff882..7528b6fc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,7 +7,8 @@ on: # the branch name or in the pushed (head) commit message. Tokens: # ci-all, ci-sagehen, ci-hru1, ci-drb, ci-ucb, ci-fgr. For example, a # branch named my_feature_ci-fgr runs both fgr jobs on every push, with - # no workflow edits to clean up before merging. + # no workflow edits to clean up before merging; a commit-message token + # applies to its push only. Details in DEVELOPER.md under "CI". push: branches: - "*" diff --git a/DEVELOPER.md b/DEVELOPER.md index 66949129..3e380a30 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -146,12 +146,29 @@ on all three platforms) runs for pull requests (including drafts), pushes to To also run domain test jobs on branch pushes, put a `ci-` anywhere in the branch name or in the pushed (head) commit message. The tokens are -`ci-all`, `ci-sagehen`, `ci-hru1`, `ci-drb`, `ci-ucb`, and `ci-fgr`. For -example, a branch named `my_feature_ci-fgr` runs both fgr domain jobs on every -push — with nothing to clean up before merging, since the opt-in lives in the -branch name. A commit message containing `ci-drb` runs the drb job for that -push only. Alternatively, open a draft PR or use the workflow dispatch button -(on your fork) to get the full suite on any branch. +`ci-all`, `ci-sagehen`, `ci-hru1`, `ci-drb`, `ci-ucb`, and `ci-fgr`. + +The two variants complement each other: + +- **Branch name — sticky.** A branch named `my_feature_ci-fgr` runs both fgr + domain jobs on every push, with nothing to clean up before merging since + the opt-in lives in the branch name. +- **Commit message — one shot.** The gate is evaluated fresh on each push + with no memory of earlier pushes: a push whose head commit message contains + `ci-drb` runs the drb job for that push only, even on a branch previously + pushed without any token, and later pushes without a token drop back to the + skeleton. Only the *head* (most recent) commit of a push is checked — when + pushing several commits at once, the token must be in the last one. To + trigger a domain run on the current state without changing any code, push + an empty commit: + + ```shell + git commit --allow-empty -m "trigger ci-fgr" + git push my_branch + ``` + +Alternatively, open a draft PR or use the workflow dispatch button (on your +fork) to get the full suite on any branch. ## Testing From 650a5542d827b8c3ffa73aa7008b5736ad43dc95 Mon Sep 17 00:00:00 2001 From: James McCreight Date: Tue, 18 Aug 2026 10:19:42 -0600 Subject: [PATCH 4/6] DEVELOPER.md: document ci-token substring semantics and gate maintenance Tokens are matched by plain substring test: they only add jobs, suffixes do not narrow selection, and messages mentioning a literal token trigger it. Maintainer notes: new domain jobs must carry the if: gate, and finer tokens must be mutually non-substring. Co-Authored-By: Claude Fable 5 --- DEVELOPER.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/DEVELOPER.md b/DEVELOPER.md index 3e380a30..3701f9ab 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -170,6 +170,31 @@ The two variants complement each other: Alternatively, open a draft PR or use the workflow dispatch button (on your fork) to get the full suite on any branch. +Token matching is a plain substring test — there is no parsing of token +lists, separators, or suffixes. Consequences: + +- Tokens can only add jobs, never subtract them: appending to a token + (`ci-sagehen_gridded`) does not narrow the selection, it matches the + `ci-sagehen` token and triggers every job in that family. +- A commit message that merely *mentions* a token triggers it — easy to do + accidentally in a commit message about the CI configuration itself. Write + `ci-` (as in this file) rather than a literal token when referring + to the mechanism. +- Tokens are matched anywhere in the branch name, so avoid naming a branch + with a literal token unless the sticky behavior is wanted. + +Notes for maintaining the gates in `ci.yaml`: + +- New domain test jobs must copy the `if:` gate from an existing domain job + (with an appropriate token) — an ungated job runs on every push to every + branch, silently defeating the skeleton/full split. +- When adding finer-grained tokens, no token may be a substring of another: + a bare `ci-sagehen` cannot coexist with a distinct `ci-sagehen-gridded` + switch, because writing the longer token always matches the shorter one. + To split a family, replace the family token with mutually non-substring + tokens, e.g. `ci-sagehen-5yr`, `ci-sagehen-gridded`, and `ci-sagehen-all` + for the whole family. + ## Testing Once the dependencies are available, we want to verify the software by running From e96c61344d0b2a556707e1f24b18d30a44890ba7 Mon Sep 17 00:00:00 2001 From: James McCreight Date: Tue, 18 Aug 2026 10:26:44 -0600 Subject: [PATCH 5/6] Document ci-token semantics and gate maintenance DEVELOPER.md: tokens are matched by plain substring test - they only add jobs, suffixes do not narrow selection, and a message mentioning a literal token triggers it; sticky (branch name) vs one-shot (head commit message) variants; empty-commit recipe. Maintainer notes: new domain jobs must carry the if: gate, finer tokens must be mutually non-substring. CLAUDE.md: gate-copying rule, especially when merging develop into branches that predate the gates. Workflow comments point to DEVELOPER.md under "CI". Co-Authored-By: Claude Fable 5 --- .github/workflows/ci_examples.yaml | 1 + CLAUDE.md | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/ci_examples.yaml b/.github/workflows/ci_examples.yaml index f0874d14..8369fc11 100644 --- a/.github/workflows/ci_examples.yaml +++ b/.github/workflows/ci_examples.yaml @@ -3,6 +3,7 @@ name: CI example notebooks on: # Pushes to any branch (here or on forks) run the notebooks on ubuntu # only; all platforms run for pull requests and develop/main pushes. + # See DEVELOPER.md under "CI" for the skeleton/full CI strategy. push: branches: - "*" diff --git a/CLAUDE.md b/CLAUDE.md index b06fdfb3..9b6f7c10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,12 @@ reimplementation of PRMS process representations (see README.md). - CI uses `--error-for-skips`: a test that skips conditionally must be either `--ignore`d in the broad CI steps or run in a dedicated step whose `--control_pattern` avoids the skip. +- Every domain test job in `ci.yaml` carries an `if:` gate (the + skeleton/full split). A new domain job must copy the gate from an + existing domain job, with an appropriate `ci-` — an ungated job + runs on every push to every branch. Watch for this especially when + merging develop into branches that predate the gates. See DEVELOPER.md + under "CI" for the strategy and token semantics. ## Conventions From 90149c02e1451db254e573b167e8adc796cbb5e0 Mon Sep 17 00:00:00 2001 From: James McCreight Date: Tue, 18 Aug 2026 15:44:09 -0600 Subject: [PATCH 6/6] Add /ci-triage skill; correct CI whats-new entry to PR 408 Captures the CI triage procedure: stale-run vs real-failure verdict via timestamps, public-API annotations when logs are admin-locked, known failure signatures, and local reproduction parity steps. Co-Authored-By: Claude Fable 5 --- .claude/skills/ci-triage/SKILL.md | 94 +++++++++++++++++++++++++++++++ CLAUDE.md | 3 + doc/whats-new.rst | 2 +- 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/ci-triage/SKILL.md diff --git a/.claude/skills/ci-triage/SKILL.md b/.claude/skills/ci-triage/SKILL.md new file mode 100644 index 00000000..a99163ef --- /dev/null +++ b/.claude/skills/ci-triage/SKILL.md @@ -0,0 +1,94 @@ +--- +name: ci-triage +description: Triage failing GitHub Actions runs on pywatershed PRs or branches using the public API - identify failing jobs, read annotations when logs are locked, distinguish stale runs from real failures, and set up local reproduction. Use when the user reports CI failures or invokes /ci-triage, optionally with a PR number or run URL. +--- + +# Triaging pywatershed CI failures + +You diagnose; the human clicks. You run read-only API queries (curl, +no auth) and local reproductions; the human re-runs workflows, pushes +fixes, and merges. Unauthenticated API calls are limited to 60/hour +per IP — batch queries and cache responses to /tmp files. + +## 1. Identify the target + +- PR number → head sha: `curl -s "https://api.github.com/repos/DOI-USGS/pywatershed/pulls?state=open"` + (confirm which PR the user means — numbers are easy to confuse). +- All checks for a sha: `curl -s ".../commits//check-runs?per_page=100"`. + Group by `conclusion`; a full run is ~30+ checks across four workflows + (CI, CI example notebooks, Documentation Build, Safety Check — the + notebook check names are bare OS names like `ubuntu-latest py3.13`). + +## 2. First question: is the failure current? + +Before reading any error text, compare timestamps. Check `started_at` +on the check runs and `created_at`/`run_attempt` on +`.../actions/runs/`. Passing and failing checks on the same sha +with different start times mean someone re-ran one workflow but not the +others — **re-runs are per workflow**; re-running "CI" does not refresh +docs/notebooks/safety. + +Correlate failure times with external events before debugging code: +dependency releases on PyPI +(`curl -s https://pypi.org/pypi//json` → `releases[v][0]['upload_time']`), +mf6 nightly builds, flopy releases. A failure that predates the fix for +its own cause needs a re-run, not a diagnosis. The human has re-run +permission on run pages ("Re-run all jobs") even where the +workflow-dispatch button is missing upstream. + +## 3. Getting failure details + +Job logs (`.../actions/jobs//logs`) return 403 "Must have admin +rights" without auth. Use the public annotations instead: +`curl -s ".../check-runs//annotations"` (check-run id == +job id). Annotations usually contain the terminal error; step-level +detail beyond that requires the human to open the job page. + +## 4. Known failure signatures + +- **`micromamba ... exit code 1` + ENOENT `micromamba-shell`**: env + creation failed, almost always the pip section of the env file being + unresolvable (a version floor not yet published, a new release of a + transitive dep). Note which env file the workflow uses: CI and docs + use `environment.yml`; notebooks and safety use + `environment_w_jupyter.yml`. +- **flopy rejects valid-looking mf6 input, or mf6 rejects data CI used + to accept** (e.g. "Error converting to an integer"): mf6 develop + changed a definition and flopy's bundled dfns lag. Both + `generate_classes` calls in `ci.yaml` need `--ref develop`, and + `autotest/ci_local.sh` must stay in sync with `ci.yaml`. +- **A test fails only in CI via `--error-for-skips`**: a conditional + skip; either `--ignore` it in the broad steps or give it a dedicated + step whose `--control_pattern` avoids the skip. +- **`check_version.yaml` exit codes**: version files must match the + release branch name; exit 7 = missing entry in `doc/index.rst` + (major releases). +- **Domain jobs absent from a branch-push run**: not a failure — the + skeleton/full split gates them (DEVELOPER.md "CI"); tokens or a draft + PR bring them back. + +## 5. Local reproduction + +Reproduce with the job's exact pytest line (visible in `ci.yaml`), +from `autotest/`, in the project conda env. Environment parity +checklist, in order: + +1. mf6 binary: delete the stale one so the nightly build is + reinstalled to match CI. +2. `python -m flopy.mf6.utils.generate_classes --ref develop` + (run from a modflow6 clone's autotest dir, as CI does). +3. Test data: regenerate if stale (`python generate_test_data.py + -n=auto` in `autotest/`; a version guard fails tests when data + predates the current version). +4. Run the job's pytest command with its `--domain` and + `--control_pattern`. Domainless quirk: the conftest exemption is an + exact string match on `-m domainless` — pass exactly that markexpr, + no `--domain` needed. + +## 6. Report format + +Lead with the verdict (real failure vs stale run vs infra). When +staleness is involved, show a short timeline table (run times vs the +external event). List the re-run links per failed workflow — the human +clicks them. If code changes are needed, hand over a diagnosis and the +failing test invocation before drafting fixes. diff --git a/CLAUDE.md b/CLAUDE.md index 9b6f7c10..8b4716a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,6 +63,9 @@ skills: - `/release` — assists a pywatershed release step by step, following `.github/RELEASE.md`: drafts the commands for the human to run, runs the checks, and verifies each stage (PyPI, tag, release assets). +- `/ci-triage` — triages failing GitHub Actions runs on a PR or branch + via the public API: stale-run vs real-failure verdict, annotations + when logs are locked, known failure signatures, local repro steps. Claude: in your first reply of a session, briefly show the user this bullet list of available skills (many users don't know skills exist). diff --git a/doc/whats-new.rst b/doc/whats-new.rst index 61fd6ef1..db1d0746 100644 --- a/doc/whats-new.rst +++ b/doc/whats-new.rst @@ -36,7 +36,7 @@ Internal changes ``ci-`` (e.g. ``ci-fgr``, ``ci-all``) in the branch name or head commit message — see DEVELOPER.md. ``concurrency`` groups cancel in-flight runs superseded by a newer push on the same non-mainline ref. - (:pull:`407`) By `James McCreight `_. + (:pull:`408`) By `James McCreight `_. - Require pyPRMS >=0.10.0 and remove the temporary ``packaging <26.3`` pin it supersedes (pyPRMS 0.9.10 crashed on import of metadata with packaging >=26.3). Also remove calls to pyPRMS methods deprecated in 0.10.0: