This page describes how main is protected, what each GitHub Actions workflow does, how dependency updates pass CI, and how a release is made. It reflects the files in .github/workflows/ and the repository settings as of September 2026.
mainmain is protected by a repository ruleset named Protect Main (Settings → Rules → Rulesets, or gh api repos/JonasHeinickeBio/pyEuropePMC/rulesets). It enforces:
| Rule | Effect |
|---|---|
deletion |
main cannot be deleted. |
non_fast_forward |
History on main cannot be rewritten. |
pull_request |
Every change goes through a pull request; direct pushes are rejected. No approvals are required, and merge, squash and rebase merges are all allowed. |
required_status_checks |
A pull request can merge only when these checks pass and the branch is up to date with main (strict_required_status_checks_policy: true): Lint, types & security, Tests & coverage, Unit tests (core deps only), Compatibility Summary and zizmor. |
Nobody can bypass the ruleset: bypass_actors is empty. That has two consequences:
GitHub’s merge queue is available only to repositories owned by an organization. pyEuropePMC belongs to a personal account (JonasHeinickeBio), and the ruleset API rejects a merge_queue rule with Invalid rule 'merge_queue' (HTTP 422), even when it is the only rule in the request.
The ruleset uses the closest substitute: pull requests only, required checks against an up-to-date branch, and auto-merge. What a queue would add, re-testing against a base that changed after the branch was last updated, matters little at this repository’s pull request volume.
If the repository moves to an organization, adding a merge_queue rule to the same ruleset is enough; the workflows already handle the merge_group event.
| Workflow | File | Triggers | What it does |
|---|---|---|---|
| CI | cdci.yml |
push to main, pull request, merge group, manual |
Lint, types & security runs ruff, the format check, mypy, the requirements.txt sync check, poetry check --lock and bandit. Tests & coverage runs the default test suite with all extras, enforces the 75% coverage threshold and uploads reports to Codecov and Coveralls. CodeScene delta (advisory) runs cs delta when the CS_ACCESS_TOKEN secret is available and never fails the workflow. |
| CI — Light core install | unit-tests.yml |
push to main, pull request, merge group, manual |
Installs the locked core dependencies and the dev group without any extra, checks that representative optional packages (pandas, matplotlib, flask, langchain, openai, semanticscholar) are absent, and runs pyeuropepmc --help and the default test suite. |
| Python Version Compatibility Matrix | python-compatibility.yml |
push to main, pull request, merge group, Sundays at 06:00 UTC, manual |
Compiles and imports the package on Python 3.10 to 3.13, runs the default test suite on Python 3.10 and 3.12 across operating systems, and reports both stages in Compatibility Summary. |
| Workflow security audit | zizmor.yml |
push to main that changes .github/workflows/, .github/actions/ or .github/zizmor.yml; pull request; merge group; Wednesdays at 05:00 UTC; manual |
Audits the workflow and action files with zizmor 1.30.1, uploads the results to code scanning and fails on findings of medium severity or higher. |
| CI — Functional Tests | integration-tests.yml |
push to main, nightly at 03:00 UTC, manual |
Runs pytest -m functional --run-integration with all extras against the live services. On failure it opens a single issue labelled functional-test-failure, or comments on the open one, and the job fails. |
| Deploy Documentation to GitHub Pages | deploy-docs.yml |
push to main or pull request that changes docs/ or the workflow, manual |
Builds docs/ with Jekyll and deploys it to GitHub Pages, except on pull requests. |
| Pull Request Labeler | labeler.yml |
pull request opened or updated | Applies labels according to .github/labeler.yml. |
| Greetings | greetings.yml |
issue or pull request opened | Welcomes first-time contributors; skips the repository owner and bots. |
| Enhance new issues | enhanceIssue.yml |
issue opened by a person | Rewrites the issue’s title and body with actions/ai-inference. |
| Summarize new issues | summary.yml |
issue opened by a person | Comments with a summary generated by actions/ai-inference. |
| Workflow | File | Triggers | What it does |
|---|---|---|---|
| Weekly Benchmarks | benchmark.yml |
Mondays at 02:00 UTC, manual | Runs the modular benchmark against the live API and opens a pull request that updates the benchmark section of README.md and .github/benchmark-history.json. A manual run with commit_results set to false opens no pull request. |
| Feature Suggester | analyze_repo.yml |
Mondays at 03:00 UTC, manual | Analyses the repository and files improvement suggestions as issues; see Feature Suggester workflow. |
| Publish to PyPI | release.yml |
tag v*, manual |
Publishes a release; see Release process. |
| Update Changelog | changelog.yml |
tag v*.*.*, manual |
When CHANGELOG.md has no section for the version, generates one from the commit subjects since the previous tag and opens a pull request with it. |
| Manage Stale Issues and PRs | stale.yml |
daily at 02:30 UTC, manual | Marks issues stale after 60 days without activity and closes them 7 days later; marks pull requests stale after 30 days and closes them 14 days later. Assigned items and items with exempt labels are left alone. |
Two further checks have no workflow file. CodeQL runs through GitHub’s default code scanning setup for Python and GitHub Actions. Dependabot opens weekly update pull requests for the Python dependencies and for the actions in .github/workflows/ and .github/actions/setup-python-env/ (.github/dependabot.yml).
Jobs that need the project install it through the composite action .github/actions/setup-python-env. It installs Poetry 2.3.2 and poetry-plugin-export 1.10.0 with pip, exports the locked versions for the requested extras and dependency groups, and installs them into a fresh .venv with uv. poetry lock never runs in CI.
The config job in python-compatibility.yml computes the operating-system matrix up front:
main, the weekly schedule, manual runs and merge_group) runs on Ubuntu, Windows and macOS.The gap is real: a Windows-only failure between asyncio and pytest-socket after the MCP server rewrite (#190) passed the pull request checks, broke only on the push to main, and needed a follow-up fix (#202). The merge_group trigger would close the gap by running the full matrix before merging, but without a merge queue nothing sends that event.
A workflow suppressed by a paths: filter never runs, so it never reports its checks, and a required check that never reports leaves a pull request waiting indefinitely for a status. A job skipped by an if: condition reports success instead, as GitHub’s troubleshooting guide for required status checks explains.
Both path-sensitive workflows therefore trigger on every pull request and decide inside a job whether there is work to do:
python-compatibility.yml, the config job sets relevant to true for every event except a pull request. For a pull request it is true only when the pull request changes src/, tests/, pyproject.toml, poetry.lock, the workflow itself or .github/actions/setup-python-env/action.yml. syntax-check and core-tests run only when relevant is true. compatibility-summary, the Compatibility Summary check, runs with if: always() and fails only when one of those jobs failed, so a skipped matrix still produces a passing check. Require Compatibility Summary, never the matrix jobs.zizmor.yml, the changes job does the same for .github/workflows/, .github/actions/ and .github/zizmor.yml. When nothing relevant changed, the zizmor job is skipped, which counts as passing.Before making a job a required check, confirm two things: its workflow triggers on every pull_request without a paths: filter, and the job reports a result even when the work it depends on is skipped.
merge_group triggerscdci.yml, unit-tests.yml, python-compatibility.yml and zizmor.yml listen for the merge_group event, so their required checks would run on queued pull requests if the repository gets a merge queue. Until then the event never fires.
pyproject.toml and poetry.lock are the source of truth. requirements.txt lists the core dependencies exported from the lock, and the Lint, types & security job fails when the committed file differs from a fresh export. The output depends on the poetry-plugin-export version (1.8.0 and 1.10.0 write some environment markers differently), so regenerate the file with the versions CI pins:
uvx --from poetry==2.3.2 --with poetry-plugin-export==1.10.0 poetry export --without-hashes -f requirements.txt -o requirements.txt
The poetry-export-requirements pre-commit hook runs the same export when pyproject.toml, poetry.lock or requirements.txt is committed.
Dependabot changes only pyproject.toml and poetry.lock, so its Python pull requests fail the sync check. Check out the pull request, replacing NUMBER with its number:
gh pr checkout NUMBER
Update the branch with main if it is behind, regenerate requirements.txt with the export command above, commit the result and push it to the same branch.
release.yml runs when a v* tag is pushed, or when it is dispatched manually (see Dry run on TestPyPI). Its jobs run in this order:
verify first refuses a manual run that asks for pypi from anything but a tag, then runs poetry check --lock, ruff, the format check, mypy and the default test suite on the tagged commit, with all extras on Python 3.12. On a tag it also checks that the tag is v followed by the version in pyproject.toml, and warns when CHANGELOG.md has no section for that version.build runs poetry build, installs the wheel and the sdist into separate fresh virtual environments, checks that each reports the pyproject.toml version as pyeuropepmc.__version__ and runs pyeuropepmc --help, runs twine check --strict over both (PyPI rejects a long description it cannot render, and README.md is that description), and extracts the version’s changelog section as release notes.attest creates Sigstore build provenance attestations for the distributions.publish confirms the target index one last time - pypi.org only from a tag - then uploads the distributions with trusted publishing (OIDC, no API token) and skip-existing: true.github-release runs for tags only. It creates the GitHub Release with the distributions attached, using the changelog section as the description, with generated notes added from the commit log.publish-mcp-registry runs for tags only. It sets both version fields in server.json to the tag’s version and publishes the file to the MCP Registry as io.github.JonasHeinickeBio/pyeuropepmc, authenticating with GitHub OIDC. It has continue-on-error: true, so a registry failure does not fail a release that is already on PyPI.changelog.yml also runs on the tag. It does nothing when CHANGELOG.md already has a section for the version.
Before you tag, the commit on main must have:
version under [project] in pyproject.toml; verify fails when the tag differs;__version__ in src/pyeuropepmc/__init__.py; build fails when the installed package reports a version other than the one in pyproject.toml;version and packages[0].version in server.json; the registry job rewrites both from the tag, but keep the committed file in step.## [X.Y.Z] - YYYY-MM-DD in CHANGELOG.md, usually the ## [Unreleased] section renamed. Without it the GitHub Release has only generated notes, and changelog.yml opens a pull request with an entry generated from commit subjects.mcp-name marker in README.md: the line <!-- mcp-name: io.github.JonasHeinickeBio/pyeuropepmc -->. The MCP Registry confirms that the PyPI package belongs to the server by finding mcp-name: io.github.JonasHeinickeBio/pyeuropepmc in the package description, which is built from README.md. Without it the registry rejects the publish with HTTP 400, as happened with 2.2.0. PyPI does not let a released version’s description change, so a missing marker needs a new version.chore/release-X.Y.Z, with the version and changelog edits listed above.release.yml, server.json, the mcp-name marker or packaging metadata, run the dry run on TestPyPI from that branch.chore(release): bump to X.Y.Z and merge it when the required checks pass.Tag the merge commit on main and push the tag, replacing X.Y.Z with the version:
git switch main
git pull --ff-only
git tag vX.Y.Z
git push origin vX.Y.Z
Publish to PyPI run in the Actions tab, then check the version on PyPI, the GitHub Release and the MCP Registry listing.Try any change to release.yml, server.json or anything else on the release path before a real tag exists. Dispatch the workflow against your branch; TestPyPI is the default target:
gh workflow run release.yml --ref <branch>
On a branch, github.ref_type is branch, so everything gated on a tag is skipped: the version and changelog checks in verify, github-release and publish-mcp-registry. verify, build, attest and publish run, and publish uploads to TestPyPI. This catches problems such as a wrongly cased MCP Registry namespace, or a mypy setting that fails verify, while the version bump and changelog entry are still easy to change.
A manual run cannot reach pypi.org from a branch. The environment input defaults to testpypi, and -f environment=pypi fails in the first step of verify unless the run is on a v* tag; publish checks the same thing again immediately before the upload. Dispatching a tag with -f environment=pypi is the supported way to retry a publish for a version whose upload failed.
Note: the
pypiGitHub environment has no required reviewers and no branch restriction, so those two checks inrelease.ymlare what stands between a dispatch and a permanent version number on pypi.org. Addingmainas a deployment branch rule and a required reviewer to the environment (Settings → Environments → pypi) would put that gate in GitHub itself.
Because publish uses skip-existing: true, a dry run for a version that is already on TestPyPI uploads nothing and still succeeds.
Auto-merge is enabled for the repository. gh pr merge --auto --squash, or Enable auto-merge on the pull request page, merges a pull request as soon as the required checks pass and the branch is up to date.