From b33652ab4c461d255045357e85345b3054a8e7f3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 21 Aug 2026 20:11:08 +0100 Subject: [PATCH 1/4] ci: run the checks on the self-hosted runner fleet The org's GitHub Actions billing block stops GitHub-hosted jobs from starting, so on ubuntu-latest these checks never ran at all -- every PR on this repo has sat with checks that never leave the queue. gha-ci-runners (the ARC fleet on the OVH cluster) is where the org's actually-running CI already lives. gitleaks drops its sudo install (the runner container has no sudo) and runs the extracted binary from the workspace by path instead; nothing else changes shape. --- .github/workflows/ci.yml | 6 +++--- .github/workflows/gitleaks.yml | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab92c80..be5e580 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ permissions: jobs: typecheck: - runs-on: ubuntu-latest + runs-on: gha-ci-runners steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -32,7 +32,7 @@ jobs: pip-audit --skip-editable test: - runs-on: ubuntu-latest + runs-on: gha-ci-runners env: # pywr's lpsolve extension needs headers this runner doesn't have; glpk (also needed) is installed below via apt. PYWR_BUILD_LPSOLVE: "false" @@ -64,7 +64,7 @@ jobs: test-minimal-install: # Regression guard for the model_runner lazy-import bug: a plain `pip install .`, with neither pandas nor pywr present, must still let create-synthetic-model run — only the `run` extra needs either. - runs-on: ubuntu-latest + runs-on: gha-ci-runners steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml index 1f98e69..fa8d067 100644 --- a/.github/workflows/gitleaks.yml +++ b/.github/workflows/gitleaks.yml @@ -11,7 +11,8 @@ permissions: jobs: gitleaks: - runs-on: ubuntu-latest + # Self-hosted fleet: the org's billing block stops GitHub-hosted jobs, and the runner container has no sudo -- the binary runs from the workspace by path. + runs-on: gha-ci-runners steps: - uses: actions/checkout@v4 with: @@ -21,8 +22,7 @@ jobs: run: | VER=8.21.2 curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${VER}/gitleaks_${VER}_linux_x64.tar.gz" | tar -xz gitleaks - sudo install gitleaks /usr/local/bin/gitleaks - gitleaks version + ./gitleaks version - name: Scan new commits for secrets run: | @@ -35,5 +35,5 @@ jobs: RANGE="${{ github.event.before }}..${{ github.sha }}" fi echo "Scanning commit range: $RANGE" - gitleaks detect --source . --log-opts="$RANGE" \ + ./gitleaks detect --source . --log-opts="$RANGE" \ --config .gitleaks.toml --redact --verbose From 4ef8edf10cb435e3c8a71c0b6af60c931fb16655 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 27 Aug 2026 08:55:09 +0100 Subject: [PATCH 2/4] ci: drop sudo apt-get install of libglpk-dev in the test job The self-hosted runner container has no sudo, so this step never ran successfully there. pywr-moea's and pywr-borg's own CI already confirm the Nexsys-Analytics/ci-runners image prebakes libglpk-dev among its other native build dependencies, so the install step is unnecessary rather than needing a no-sudo replacement. --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be5e580..a444e93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: test: runs-on: gha-ci-runners env: - # pywr's lpsolve extension needs headers this runner doesn't have; glpk (also needed) is installed below via apt. + # pywr's lpsolve extension needs headers this runner doesn't have; glpk (also needed) is prebaked into the ci-runners image. PYWR_BUILD_LPSOLVE: "false" steps: - uses: actions/checkout@v4 @@ -43,7 +43,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: sudo apt-get update && sudo apt-get install -y libglpk-dev - run: pip install -e ".[run,dev]" - run: pytest -v - name: write coverage summary From 25eb06732626f9a0de25731cf92b41a7f2380723 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 27 Aug 2026 08:55:40 +0100 Subject: [PATCH 3/4] refactor: extract CI step logic into composite actions Moves each job's step logic out of ci.yml/gitleaks.yml into .github/actions/{typecheck,test,test-minimal-install,gitleaks}, parameterised by a working-directory input defaulting to the repo root. This lets mono's synced-in copy of this repo call the identical steps locally against its subtree-prefixed path, matching the pattern already shipped for infrastructure -- no behaviour change to this repo's own CI, which still resolves working-directory to ".". Also applies the org's CI hardening convention established on cluster-api-v2 #91: every job gets timeout-minutes (15 for the typecheck-shaped job, 30 for test, 10 for the minimal-install and gitleaks jobs, since self-hosted runner pods have no GitHub-imposed job limit), and the gitleaks download is checksum-verified and time-bounded (--max-time 60, sha256sum -c against the pinned v8.21.2 release digest) rather than piped straight from curl into tar. --- .github/actions/gitleaks/action.yml | 39 +++++++++++++ .../actions/test-minimal-install/action.yml | 29 ++++++++++ .github/actions/test/action.yml | 41 ++++++++++++++ .github/actions/typecheck/action.yml | 40 +++++++++++++ .github/workflows/ci.yml | 56 ++----------------- .github/workflows/gitleaks.yml | 22 +------- 6 files changed, 157 insertions(+), 70 deletions(-) create mode 100644 .github/actions/gitleaks/action.yml create mode 100644 .github/actions/test-minimal-install/action.yml create mode 100644 .github/actions/test/action.yml create mode 100644 .github/actions/typecheck/action.yml diff --git a/.github/actions/gitleaks/action.yml b/.github/actions/gitleaks/action.yml new file mode 100644 index 0000000..aaef2c8 --- /dev/null +++ b/.github/actions/gitleaks/action.yml @@ -0,0 +1,39 @@ +name: Gitleaks scan +description: Downloads a checksum-verified gitleaks release and scans the commits introduced by the current push or pull request for secrets. Extracted so this repo's own CI and monorepo's synced copy of this repo can call the identical steps locally -- see the parent engagement README's "Sharing CI with the monorepo" pattern, established for infrastructure and applied here. + +inputs: + working-directory: + description: Directory whose git history and .gitleaks.toml config to scan + required: false + default: "." + +runs: + using: composite + steps: + - name: Install gitleaks + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + set -euo pipefail + VER=8.21.2 + curl -sSfL --max-time 60 -o gitleaks.tar.gz "https://github.com/gitleaks/gitleaks/releases/download/v${VER}/gitleaks_${VER}_linux_x64.tar.gz" + echo "5bc41815076e6ed6ef8fbecc9d9b75bcae31f39029ceb55da08086315316e3ba gitleaks.tar.gz" | sha256sum -c - + tar -xzf gitleaks.tar.gz gitleaks + rm gitleaks.tar.gz + ./gitleaks version + + - name: Scan new commits for secrets + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "pull_request" ]; then + RANGE="${{ github.event.pull_request.base.sha }}..${{ github.sha }}" + elif [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then + RANGE="-1 ${{ github.sha }}" # first push of a branch: scan tip commit + else + RANGE="${{ github.event.before }}..${{ github.sha }}" + fi + echo "Scanning commit range: $RANGE" + ./gitleaks detect --source . --log-opts="$RANGE" \ + --config .gitleaks.toml --redact --verbose diff --git a/.github/actions/test-minimal-install/action.yml b/.github/actions/test-minimal-install/action.yml new file mode 100644 index 0000000..74219af --- /dev/null +++ b/.github/actions/test-minimal-install/action.yml @@ -0,0 +1,29 @@ +name: Test minimal install +description: Regression guard for the model_runner lazy-import bug -- a plain `pip install .`, with neither pandas nor pywr present, must still let create-synthetic-model run. Extracted so this repo's own CI and monorepo's synced copy of this repo can call the identical steps locally -- see the parent engagement README's "Sharing CI with the monorepo" pattern, established for infrastructure and applied here. + +inputs: + working-directory: + description: Directory containing the pywr_utils package to test + required: false + default: "." + +runs: + using: composite + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - shell: bash + working-directory: ${{ inputs.working-directory }} + run: pip install . + - shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + pywr-utils create-synthetic-model --zones 2 --transfers 1 --output /tmp/synthetic.json --show-summary + test -f /tmp/synthetic.json + - name: confirm pandas and pywr are genuinely absent + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + ! python -c "import pandas" 2>/dev/null + ! python -c "import pywr" 2>/dev/null diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml new file mode 100644 index 0000000..4f32981 --- /dev/null +++ b/.github/actions/test/action.yml @@ -0,0 +1,41 @@ +name: Test +description: Installs the package with its run/dev extras, runs the pytest suite, and uploads a coverage report. Extracted so this repo's own CI and monorepo's synced copy of this repo can call the identical steps locally -- see the parent engagement README's "Sharing CI with the monorepo" pattern, established for infrastructure and applied here. + +inputs: + working-directory: + description: Directory containing the pywr_utils package to test + required: false + default: "." + +runs: + using: composite + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - shell: bash + working-directory: ${{ inputs.working-directory }} + # pywr's lpsolve extension needs headers this runner doesn't have; glpk (also needed) is prebaked into the ci-runners image. + env: + PYWR_BUILD_LPSOLVE: "false" + run: pip install -e ".[run,dev]" + - shell: bash + working-directory: ${{ inputs.working-directory }} + run: pytest -v + - name: write coverage summary + # Fully native, no admin-gated feature or third-party action needed: coverage.py's own markdown formatter piped into GitHub Actions' own step-summary mechanism. Shows up on every run's summary page (and one click away from the PR checks list) regardless of whether Code Quality is ever enabled. + if: always() + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + echo '## Coverage' >> "$GITHUB_STEP_SUMMARY" + python -m coverage report --format=markdown >> "$GITHUB_STEP_SUMMARY" + - name: upload coverage report + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: actions/upload-code-coverage@v1 + with: + file: ${{ inputs.working-directory }}/coverage.xml + language: Python + label: code-coverage/pytest + # TEMPORARY: Code Quality isn't enabled for this repo yet (Settings > Security > Code quality) — that's an org/repo admin action, and neither CI nor a push-only collaborator can do it. Without this, the upload 403s and fails the whole job. Remove fail-on-error once an admin enables Code Quality and this step succeeds for real. + fail-on-error: false diff --git a/.github/actions/typecheck/action.yml b/.github/actions/typecheck/action.yml new file mode 100644 index 0000000..86f4dd9 --- /dev/null +++ b/.github/actions/typecheck/action.yml @@ -0,0 +1,40 @@ +name: Typecheck and lint +description: Runs mypy, ruff, vulture, bandit, and pip-audit against the Python package. Extracted so this repo's own CI and monorepo's synced copy of this repo can call the identical steps locally -- see the parent engagement README's "Sharing CI with the monorepo" pattern, established for infrastructure and applied here. + +inputs: + working-directory: + description: Directory containing the pywr_utils package to check + required: false + default: "." + +runs: + using: composite + steps: + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - shell: bash + working-directory: ${{ inputs.working-directory }} + run: pip install -e ".[dev]" + - shell: bash + working-directory: ${{ inputs.working-directory }} + run: mypy pywr_utils/ + - name: lint (ruff) + shell: bash + working-directory: ${{ inputs.working-directory }} + run: ruff check . + - name: dead-code scan (vulture) + shell: bash + working-directory: ${{ inputs.working-directory }} + run: vulture pywr_utils vulture_whitelist.py --min-confidence 80 + - name: security scan (bandit) + shell: bash + working-directory: ${{ inputs.working-directory }} + run: bandit -r pywr_utils + - name: dependency vulnerability scan (pip-audit) + shell: bash + working-directory: ${{ inputs.working-directory }} + # pip itself is not a declared dependency, just whatever setup-python provisions; keep it current so a stale bundled pip doesn't produce a false CVE finding here. + run: | + pip install -U pip + pip-audit --skip-editable diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a444e93..a91265d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,68 +12,24 @@ permissions: jobs: typecheck: runs-on: gha-ci-runners + timeout-minutes: 15 steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - run: pip install -e ".[dev]" - - run: mypy pywr_utils/ - - name: lint (ruff) - run: ruff check . - - name: dead-code scan (vulture) - run: vulture pywr_utils vulture_whitelist.py --min-confidence 80 - - name: security scan (bandit) - run: bandit -r pywr_utils - - name: dependency vulnerability scan (pip-audit) - # pip itself is not a declared dependency, just whatever setup-python provisions; keep it current so a stale bundled pip doesn't produce a false CVE finding here. - run: | - pip install -U pip - pip-audit --skip-editable + - uses: ./.github/actions/typecheck test: runs-on: gha-ci-runners - env: - # pywr's lpsolve extension needs headers this runner doesn't have; glpk (also needed) is prebaked into the ci-runners image. - PYWR_BUILD_LPSOLVE: "false" + timeout-minutes: 30 steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - run: pip install -e ".[run,dev]" - - run: pytest -v - - name: write coverage summary - # Fully native, no admin-gated feature or third-party action needed: coverage.py's own markdown formatter piped into GitHub Actions' own step-summary mechanism. Shows up on every run's summary page (and one click away from the PR checks list) regardless of whether Code Quality is ever enabled. - if: always() - run: | - echo '## Coverage' >> "$GITHUB_STEP_SUMMARY" - python -m coverage report --format=markdown >> "$GITHUB_STEP_SUMMARY" - - name: upload coverage report - if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - uses: actions/upload-code-coverage@v1 - with: - file: coverage.xml - language: Python - label: code-coverage/pytest - # TEMPORARY: Code Quality isn't enabled for this repo yet (Settings > Security > Code quality) — that's an org/repo admin action, and neither CI nor a push-only collaborator can do it. Without this, the upload 403s and fails the whole job. Remove fail-on-error once an admin enables Code Quality and this step succeeds for real. - fail-on-error: false + - uses: ./.github/actions/test test-minimal-install: # Regression guard for the model_runner lazy-import bug: a plain `pip install .`, with neither pandas nor pywr present, must still let create-synthetic-model run — only the `run` extra needs either. runs-on: gha-ci-runners + timeout-minutes: 10 steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - run: pip install . - - run: | - pywr-utils create-synthetic-model --zones 2 --transfers 1 --output /tmp/synthetic.json --show-summary - test -f /tmp/synthetic.json - - name: confirm pandas and pywr are genuinely absent - run: | - ! python -c "import pandas" 2>/dev/null - ! python -c "import pywr" 2>/dev/null + - uses: ./.github/actions/test-minimal-install diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml index fa8d067..c419d06 100644 --- a/.github/workflows/gitleaks.yml +++ b/.github/workflows/gitleaks.yml @@ -13,27 +13,9 @@ jobs: gitleaks: # Self-hosted fleet: the org's billing block stops GitHub-hosted jobs, and the runner container has no sudo -- the binary runs from the workspace by path. runs-on: gha-ci-runners + timeout-minutes: 10 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - - name: Install gitleaks - run: | - VER=8.21.2 - curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${VER}/gitleaks_${VER}_linux_x64.tar.gz" | tar -xz gitleaks - ./gitleaks version - - - name: Scan new commits for secrets - run: | - set -euo pipefail - if [ "${{ github.event_name }}" = "pull_request" ]; then - RANGE="${{ github.event.pull_request.base.sha }}..${{ github.sha }}" - elif [ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then - RANGE="-1 ${{ github.sha }}" # first push of a branch: scan tip commit - else - RANGE="${{ github.event.before }}..${{ github.sha }}" - fi - echo "Scanning commit range: $RANGE" - ./gitleaks detect --source . --log-opts="$RANGE" \ - --config .gitleaks.toml --redact --verbose + - uses: ./.github/actions/gitleaks From 1783259d95d024f6ebde2fbeb5474db338dd0fdc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 16 Sep 2026 18:54:29 +0100 Subject: [PATCH 4/4] ci: enable workflow_dispatch for on-demand re-runs --- .github/workflows/ci.yml | 1 + .github/workflows/gitleaks.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a91265d..e276985 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: {} + workflow_dispatch: {} permissions: contents: read diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml index c419d06..09a9dca 100644 --- a/.github/workflows/gitleaks.yml +++ b/.github/workflows/gitleaks.yml @@ -5,6 +5,7 @@ on: pull_request: push: branches: [main] + workflow_dispatch: {} permissions: contents: read