This repo holds reusable GitHub Actions workflows for PyPSA and other Python projects.
A caller repo uses a workflow with a few lines of YAML.
The workflows use composite actions in .github/actions/.
| Workflow | Purpose |
|---|---|
test.yml |
Test matrix in a pixi env (library mode or project mode). |
release.yml |
Tag-to-release chain: prepare, test, build, TestPyPI, GitHub release, PyPI. |
benchmark.yml |
CodSpeed benchmarks, or a pytest-benchmark comparison of base and head. |
pypsa-eur.yml |
Run PyPSA-Eur with the caller package at the current commit. |
link-check.yml |
Check links with lychee. |
Each release has a tag v1.x.y. The v1 tag moves to the latest v1.x.y release on the default branch.
Pre-release tags do not move v1.
Pin a workflow in one of two ways:
- Major tag:
uses: PyPSA/reusable-github-workflows/.github/workflows/test.yml@v1. - Full commit SHA:
uses: PyPSA/reusable-github-workflows/.github/workflows/test.yml@<sha> # v1.2.3. Use this form if your repo requires SHA pins (for example with zizmor). Use this form forrelease.yml. The release run can publish to PyPI, and a SHA cannot move.
The workflows load their composite actions with the self-repository syntax uses: $/.github/actions/<name>.
GitHub resolves $/ to this repo at the same commit as the called workflow.
Thus the actions always match the workflow version that you pin.
Self-hosted runners must use runner version 2.336.0 or newer.
Dependabot updates both forms. Add this to .github/dependabot.yml:
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule: {interval: monthly}
groups:
github-actions: {patterns: ["*"]}Runs the tests in a pixi env on a matrix of OS and Python versions (library mode) or pixi environments (project mode).
Library mode builds a wheel with hynek/build-and-inspect-python-package and installs it with uv into a throwaway pixi env.
Project mode starts when you set pixi-task. It uses the pixi manifest of the repo.
| Input | Type | Default | Description |
|---|---|---|---|
extras |
string | dev |
Comma-separated extras to install with the wheel. |
pixi-add |
string | Space-separated conda-forge specs to add to the env, e.g. glpk coin-or-cbc or openjdk>=22. |
|
uv-locked |
boolean | false |
Install dependencies from uv.lock before the wheel. |
cooldown |
string | 7d |
Ignore conda and PyPI releases newer than this age (library mode). Empty disables. uv-locked installs ignore it. |
matrix |
string | auto |
single, lean, full or auto (lean on push and PR, full on schedule, dispatch and tags). |
python-versions |
string | JSON list that overrides the Python versions from the trove classifiers. | |
os-full |
string | ["ubuntu-latest","macos-latest","windows-latest"] |
JSON list of runners for the full matrix. |
pytest-args |
string | Extra pytest arguments. | |
test-command |
string | Command that replaces the default pytest command. | |
pixi-task |
string | Pixi task to run. Setting it switches to project mode. | |
pixi-environments |
string | ["default"] |
Project mode: JSON list of pixi environments, or a regex such as ^py3. |
manifest-path |
string | Project mode: path of the pixi manifest. | |
pixi-version |
string | v0.81.0 |
Pixi version. |
cache-path |
string | Extra path to cache, e.g. cutouts. | |
cache-key |
string | Key of the extra cache. | |
coverage |
boolean | false |
Upload coverage to Codecov from the ubuntu leg with the last version. A separate coverage job uploads with OIDC. |
coverage-file |
string | coverage.xml |
Coverage report file. |
artifact-suffix |
string | Suffix of the Packages artifact name. Set it if test.yml runs more than once in a workflow run. |
|
vars-env |
string | Space-separated repository or organization variable (vars) names to export to the job env. |
|
working-directory |
string | . |
Directory of the package, relative to the repo root. |
name: Test
on:
push: {branches: [main]}
pull_request:
schedule: [{cron: "0 5 * * 1"}]
workflow_dispatch:
permissions: {}
jobs:
test:
uses: PyPSA/reusable-github-workflows/.github/workflows/test.yml@v1
with:
pixi-add: glpk coin-or-cbc
coverage: true
permissions: {contents: read, id-token: write}The caller job must give id-token: write, also without coverage: true. Only the coverage job uses it. The test jobs run the package code and do not get it.
Runs the release chain for a tag push. The jobs are:
prepare(only ifprepare-commandis set anddry-runis false). If the tag does not point at the prepare commit, the job runs the command, commits the changes with the PyPSA bot and moves the tag to the new commit. The new tag starts a new run. The first run then stops.test: callstest.ymlwith the values intest-inputs.build: builds the sdist and wheel (artifactPackages-release). The job fails if the wheel version is not the tag without the leadingv. Use PEP 440 tags, for examplev1.2.0rc1.testpypiandsmoke(only iftestpypi: true): upload to TestPyPI, then install the package from TestPyPI in a fresh env and runsmoke-command. The install retries for up to 5 minutes.github-release: creates the GitHub release. A tag with ana,borrcsuffix makes a pre-release.publish(only ifpypi: true): uploads to PyPI with trusted publishing.
A failure in a job stops all later jobs. dry-run: true builds and verifies, but does not upload or release.
The output ready is true when all checks passed and the run is not a dry run.
| Input | Type | Default | Description |
|---|---|---|---|
prepare-command |
string | Bash command that updates files before the release. Pixi is on PATH. Empty skips the prepare stage. | |
release-branch |
string | Branch that gets the prepare commit. Its tip must be the tag commit. Empty means the default branch. | |
prepare-commit-message |
string | Commit message of the prepare commit. Empty means [github-actions.ci] prepare release <tag>. |
|
test |
boolean | true |
Run test.yml before the build. |
test-inputs |
string | {} |
JSON object with test.yml inputs (string values for string inputs, true/false for boolean inputs). |
build |
boolean | true |
Build and verify the sdist and wheel (artifact Packages-release). Set false for repos without a Python package. |
pypi |
boolean | true |
Publish to PyPI. Needs build. |
testpypi |
boolean | false |
Publish to TestPyPI and run a smoke check before the GitHub release and PyPI. |
smoke-command |
string | Command for the TestPyPI smoke check. Empty means python -c "import <top-level module>". |
|
tag |
string | Release tag. Empty means the ref name of the trigger. | |
release-notes-file |
string | File with the release body. Empty means generated release notes. | |
release-notes-url |
string | URL put in a line above the release body. | |
github-release |
boolean | true |
Create the GitHub release. |
dry-run |
boolean | false |
Build and verify only. Skip the TestPyPI upload, the GitHub release and PyPI. |
working-directory |
string | . |
Directory of the package, relative to the repo root. |
test-inputs takes the test.yml inputs as a JSON object. Use JSON strings for string inputs and JSON booleans for boolean inputs.
Use the same values as your test caller, so that you keep the test settings in one place.
An empty string falls back to the test.yml default. Thus "extras": "" installs the dev extra.
The nested test.yml uses the artifact suffix -release-test.
name: Release
on:
push: {tags: ["v*.*.*"]}
permissions: {}
jobs:
release:
uses: PyPSA/reusable-github-workflows/.github/workflows/release.yml@<sha> # v1.x.y
with:
prepare-command: pixi run update-data
testpypi: true
test-inputs: '{"extras": "dev", "pixi-add": "openjdk>=22"}'
permissions: {contents: write, id-token: write}The caller job must give contents: write and id-token: write, also for a dry run.
GitHub checks the permissions of all called jobs before the run starts.
Runs benchmarks in a library-mode pixi env. Set mode to one of:
codspeed: runspytest <benchmark-path> --codspeedwithCodSpeedHQ/action, once per mode incodspeed-modes. A walltime run on thecodspeed-macrorunner starts on non-PR events, and on PRs with the labelwalltime-label. The jobs use OIDC (id-token: write) and never block a merge (continue-on-error). Thebenchmarksextra must containpytest-codspeed.compare: on a PR, runs pytest-benchmark at the base commit and at the head commit on the same runner. The job writespytest-benchmark compareto the step summary and to a sticky PR comment (same-repo PRs only). If you setfail-threshold, the job fails when a benchmark mean is slower by more than that percent. On other events, the job runs the head commit only and writes the summary. Thebenchmarksextra must containpytest-benchmark.
| Input | Type | Default | Description |
|---|---|---|---|
mode |
string | codspeed |
codspeed (CodSpeed upload) or compare (pytest-benchmark, base vs head). |
benchmark-path |
string | benchmarks |
Benchmark directory, relative to working-directory. |
extras |
string | dev,benchmarks |
Comma-separated extras to install with the wheel. |
python-version |
string | 3.13 |
Python version of the benchmark env. |
pixi-add |
string | Space-separated conda-forge specs to add to the env. | |
cooldown |
string | 7d |
Ignore conda and PyPI releases newer than this age. Empty disables. |
pytest-args |
string | Extra pytest arguments. | |
codspeed-modes |
string | ["simulation","memory"] |
JSON list of CodSpeed modes for ubuntu-latest (codspeed mode only). |
walltime-label |
string | trigger:benchmark |
PR label that starts the walltime run on codspeed-macro. Non-PR events always run it (codspeed mode only). |
fail-threshold |
string | Fail if a benchmark mean regresses by more than this percent. Empty never fails (compare mode only). | |
vars-env |
string | Space-separated repository or organization variable (vars) names to export to the job env. |
|
working-directory |
string | . |
Directory of the package, relative to the repo root. |
name: Benchmark
on:
push: {branches: [main]}
pull_request:
types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
permissions: {}
jobs:
benchmark:
uses: PyPSA/reusable-github-workflows/.github/workflows/benchmark.yml@v1
with:
mode: codspeed
pixi-add: glpk coin-or-cbc
permissions: {contents: read, pull-requests: write, id-token: write}The caller job must give all three permissions for both modes.
Runs a PyPSA-Eur pixi task with the caller package at the current commit (PR head SHA or github.sha).
The pypsa-eur-swap action removes the package from the PyPSA-Eur pixi env and adds it from the git commit.
The PyPSA-Eur exclude-newer cooldown stays. The swapped package and the also-upgrade packages skip it.
The job caches data/ weekly and cutouts/ monthly. It uploads logs/, .snakemake/log/ and results/ for 3 days.
If paths is set and no changed file matches, the job skips the run and reports success.
| Input | Type | Default | Description |
|---|---|---|---|
package |
string | Package name in the PyPSA-Eur pixi manifest. Empty means the caller repo name. | |
pypsa-eur-ref |
string | master |
Branch, tag or SHA of PyPSA/pypsa-eur. |
task |
string | integration-tests-streamlined |
Pixi task to run in PyPSA-Eur. |
also-upgrade |
string | Space-separated packages to upgrade after the swap, e.g. linopy. | |
paths |
string | Space- or newline-separated path globs. The run starts only if a change matches. Empty means always run. Schedule and dispatch events always run. | |
vars-env |
string | Space-separated repository or organization variable (vars) names to export to the job env. |
name: PyPSA-Eur
on:
push: {branches: [master]}
pull_request:
schedule: [{cron: "0 5 * * *"}]
workflow_dispatch:
permissions: {}
jobs:
pypsa-eur:
uses: PyPSA/reusable-github-workflows/.github/workflows/pypsa-eur.yml@v1
with:
also-upgrade: linopy
paths: |
pypsa/**
pyproject.toml
permissions: {contents: read, pull-requests: read}Checks links with lychee.
If the config file does not exist, the workflow uses a default: http and https links only, all status codes except 404 and 410 pass, timeouts pass.
| Input | Type | Default | Description |
|---|---|---|---|
globs |
string | './**/*.md' './**/*.py' './**/*.yaml' './**/*.yml' './**/*.toml' |
Space-separated, quoted file globs that lychee checks. |
config |
string | .lychee.toml |
Lychee config file, relative to working-directory. If the file is missing, a built-in default is used. |
fail |
boolean | true |
Fail the job when lychee finds broken links. |
working-directory |
string | . |
Directory that holds the files to check. |
name: Link check
on:
schedule: [{cron: "0 6 * * 1"}]
workflow_dispatch:
permissions: {}
jobs:
link-check:
uses: PyPSA/reusable-github-workflows/.github/workflows/link-check.yml@v1
permissions: {contents: read}PyPI trusted publishing does not officially support reusable workflows.
PyPI compares the trusted publisher with the workflow file in the caller repo.
The OIDC token names the reusable workflow in this repo.
Thus publish and testpypi in release.yml can fail with an invalid-publisher error.
If the upload fails, use the fallback:
- Set
pypi: false(andtestpypi: false) in therelease.ymlcall. - Add a
publishjob to the caller release file. The job uses thereadyoutput and thePackages-releaseartifact.
publish:
needs: release
if: needs.release.outputs.ready == 'true'
runs-on: ubuntu-latest
environment: pypi
permissions: {id-token: write}
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with: {name: Packages-release, path: dist}
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2With pypi: false, release.yml still builds and verifies the artifact. A repo without a Python package sets build: false and pypi: false.
Do not use a PyPI API token. It is a long-lived secret in each repo.
-
PyPI: on pypi.org, add a trusted publisher for the caller repo, the caller release workflow file (for example
release.yml) and the environmentpypi. -
TestPyPI: for
testpypi: true, add the same trusted publisher on test.pypi.org with the environmenttestpypi. -
Environments: create the GitHub environments
pypiandtestpypi. Add protection rules if you want a manual approval. -
PyPSA bot (only for
prepare-command): create the GitHub environmentrelease. Limit its deployment tags tov*. Set the environment secretPYPSA_BOT_PRIVATE_KEYand the variablePYPSA_BOT_ID(app ID). Only thepreparejob can then read the key. Install the app on the repo. The app must be able to push to the release branch and to move tags (check branch and tag rulesets). -
CodSpeed: install the CodSpeed GitHub app on the repo and enable the repo on codspeed.io.
-
Codecov: enable the repo on codecov.io. The upload uses OIDC, so the repo needs no
CODECOV_TOKEN. -
Secrets: pass each secret by name. Do not use
secrets: inherit. The workflows accept only these secrets:CDSAPI_KEY,EARTHDATAHUB_API_KEY,MOSEKLM_LICENSE_FILEandGUROBI_LIC. Each secret becomes an env var with the same name, but only in the steps that run the tests, benchmarks or PyPSA-Eur task. The names are the env var names that the code reads. If your repo secret has another name, map it in the call, for exampleCDSAPI_KEY: ${{ secrets.CDSAPI_TOKEN }}. A secret that the caller does not pass is an empty env var. Other steps and third-party actions do not get the secrets, and a job gets only the secrets that it names. To add a secret to the list, open a PR in this repo. -
Variables: list repository or organization variables (
vars) invars-env. The workflows export them to the job env.secrets: CDSAPI_KEY: ${{ secrets.CDSAPI_TOKEN }} with: vars-env: CDSAPI_URL
Each job starts with step-security/harden-runner in audit mode. It logs the outbound network connections of the job, with a link to a report in the job summary.
The plan is to switch to block mode: the job can then reach only the listed hosts, so a compromised dependency cannot send secrets away.
Harden-runner sends the network events to StepSecurity. The free tier covers public repos. On macOS and Windows runners it supports only audit mode. The CodSpeed codspeed-macro runner skips it.