From b66b47351206ff84f8499ea0f23d2bc2e74cc72d Mon Sep 17 00:00:00 2001 From: Pouri Date: Thu, 10 Sep 2026 18:18:16 +0330 Subject: [PATCH 1/6] ci: build every branch this project uses, and check what the Makefile checks Two gaps, both the same shape: CI was configured once and then the project moved without it. The push trigger listed `main`, `feat/**` and `design/**`. Every branch this repo has actually opened since is a `fix/**`, and none of them was built on push: `fix/dialog-cursor-access-hash` sat on the remote for a day with no run against it at all, because the `pull_request` trigger is the only thing that was covering that prefix and no pull request had been opened yet. The list now carries `fix/**`, `chore/**` and `docs/**` as well. The mypy step named seven paths. The Makefile's strict set names thirty-two, and the two lists had drifted so far apart that `tlgr/transport`, the whole `tlgr/daemon` strict subset, `tlgr/core/config.py`, `tlgr/core/peers.py`, `tlgr/parity.py` and `tlgr/version.py` were type-checked on developer machines by `make typecheck` and not in CI. All three steps now run the Makefile target instead of a second copy of the command, so the strict set has one definition and CI cannot silently check less than a contributor does. The set passes as it stands; nothing had regressed, it simply was not being watched. --- .github/workflows/ci.yml | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3a923d..938ef02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,11 @@ name: ci on: push: - branches: ["main", "feat/**", "design/**"] + # Every branch this project actually uses. A branch left off this list is + # not "untested until the PR opens", it is untested until somebody + # notices: `fix/dialog-cursor-access-hash` sat on the remote for a day + # with no run against it at all. + branches: ["main", "feat/**", "fix/**", "chore/**", "docs/**", "design/**"] pull_request: concurrency: @@ -36,16 +40,14 @@ jobs: uv venv --python ${{ matrix.python }} uv pip install -e ".[dev]" - - name: ruff check - run: uv run ruff check . - - - name: ruff format --check - run: uv run ruff format --check . + # `make` rather than the commands spelled out again: the Makefile owns + # the strict set, and CI was checking seven of its thirty-two entries + # because the two lists drifted apart. One definition, both places. + - name: ruff check and ruff format --check + run: make lint PY="uv run python" - name: mypy (strict set) - run: | - uv run mypy tlgr/models tlgr/ops tlgr/registry.py tlgr/schema.py \ - tlgr/core/errors.py tlgr/core/timefmt.py tlgr/core/pagination.py + run: make typecheck PY="uv run python" - name: pytest - run: uv run pytest -q --cov=tlgr --cov-report=term-missing + run: make test PY="uv run python" From 141f8000abfdc11e26af6eeef8bfdc41a07ba1ec Mon Sep 17 00:00:00 2001 From: Pouri Date: Thu, 10 Sep 2026 18:18:16 +0330 Subject: [PATCH 2/6] release: a tag builds, checks and publishes itself There was no CD. `ci.yml` was the only workflow, 2.0.0 had been written into `tlgr/__init__.py` and CHANGELOG.md and merged to main five days ago, and the newest thing on the Releases page was v1.0.0 from March, with no artefacts attached to it. A release was an unwritten manual procedure that nobody had run. It is a tag now. `git push origin vX.Y.Z` triggers `release.yml`, which in order: checks that the tag, `tlgr.__version__` and the `## [x.y.z]` heading in CHANGELOG.md all say the same version; re-runs the ARCHITECTURE 12.3 acceptance subset against the tagged tree, so a tag placed on the wrong commit fails here instead of on a user's machine; builds the sdist and the wheel; installs that wheel into an empty environment and asks the installed `tlgr` for its version; and only then creates the GitHub release, with the changelog section as its notes and both artefacts attached. `tools/release_notes.py` is the first of those steps and is runnable on its own, which is the point of it being a file rather than four lines of shell: the version agreement can be checked before the tag is pushed. It exits 1 with the disagreement named, so the release job stops before anything is published and a bad tag costs a `git push --delete` and nothing else. Nothing here publishes to PyPI. tlgr is not on PyPI and this does not put it there; the wheel on the release is the artefact. --- .github/workflows/release.yml | 78 +++++++++++++++++++++++++++++ tools/release_notes.py | 93 +++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100755 tools/release_notes.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7dc6cb7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,78 @@ +name: release + +# A tag is the whole trigger. `git tag -a vX.Y.Z && git push origin vX.Y.Z` +# is the release procedure; everything below is what that tag is checked +# against before anything reaches the Releases page. +on: + push: + tags: ["v*"] + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: build and publish ${{ github.ref_name }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # The changelog check reads history no deeper than the working + # tree, but `gh release create` wants the tag object itself. + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.12 + + - name: Install project + run: | + uv venv --python 3.12 + uv pip install -e ".[dev]" + + # Before the build, not after it: the three places a version is written + # down have to agree, and the changelog entry has to exist. + - name: Tag, package version and changelog agree + run: uv run python tools/release_notes.py "${{ github.ref_name }}" --output notes.md + + # The full matrix already ran on the branch this tag points into. This + # is the §12.3 acceptance subset, re-run against the tagged tree so a + # tag placed on the wrong commit fails here rather than on someone's + # machine. + - name: Acceptance suite + run: make acceptance PY="uv run python" + + - name: Build sdist and wheel + run: uv build + + # An install from the artefact, not from the checkout: this is the only + # step that proves the wheel a user gets is importable and reports the + # version its tag claims. + - name: Smoke-test the wheel + run: | + uv venv --python 3.12 /tmp/smoke + VIRTUAL_ENV=/tmp/smoke uv pip install dist/*.whl + reported="$(/tmp/smoke/bin/tlgr --version)" + echo "$reported" + case "$reported" in + *"${GITHUB_REF_NAME#v}") ;; + *) echo "wheel reports '$reported', tag is ${GITHUB_REF_NAME}" >&2; exit 1 ;; + esac + + - name: Create the GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${GITHUB_REF_NAME}" \ + --title "${GITHUB_REF_NAME}" \ + --notes-file notes.md \ + --verify-tag \ + dist/* diff --git a/tools/release_notes.py b/tools/release_notes.py new file mode 100755 index 0000000..c336cd4 --- /dev/null +++ b/tools/release_notes.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Check that a tag, the package version and the changelog agree, and print +the changelog section that belongs to it. + +A release has three statements of its own version: the git tag, +`tlgr.__version__` (which `pyproject.toml` reads through `dynamic`), and the +`## [x.y.z]` heading in `CHANGELOG.md`. Nothing makes them agree on its own, +and a wheel that says 2.0.0 under a v2.0.1 tag is the kind of mistake nobody +finds until an install is wrong. This is the check, and it runs before the +build rather than after it. + + python tools/release_notes.py v2.0.0 [--output notes.md] + +On agreement the changelog body for that version is written to `--output` +(default: stdout) and the exit status is 0. On any disagreement the reason +goes to stderr and the exit status is 1, which fails the release job before +anything is published. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CHANGELOG = ROOT / "CHANGELOG.md" + +#: `## [2.0.0] — 2026-09-04`. The separator between the version and the date +#: is whatever the entry chose; only the bracketed version is matched. +HEADING = re.compile(r"^## \[(?P[^\]]+)\]") + + +def package_version() -> str: + """The version the built artefacts will carry.""" + sys.path.insert(0, str(ROOT)) + from tlgr import __version__ + + return __version__ + + +def changelog_section(version: str) -> str: + """The body under `## [version]`, up to the next `## ` heading.""" + lines = CHANGELOG.read_text(encoding="utf-8").splitlines() + start: int | None = None + for index, line in enumerate(lines): + match = HEADING.match(line) + if match is None: + continue + if match.group("version") == version: + start = index + 1 + continue + if start is not None: + return "\n".join(lines[start:index]).strip() + "\n" + if start is None: + raise SystemExit( + f"CHANGELOG.md has no '## [{version}]' heading. Write the entry before tagging." + ) + return "\n".join(lines[start:]).strip() + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("tag", help="the git tag being released, e.g. v2.0.0") + parser.add_argument( + "--output", + type=Path, + default=None, + help="write the notes here instead of stdout", + ) + args = parser.parse_args() + + tag_version = args.tag[1:] if args.tag.startswith("v") else args.tag + installed = package_version() + if tag_version != installed: + print( + f"tag {args.tag} does not match tlgr.__version__ ({installed}). " + f"Bump one of them; a wheel must not disagree with its tag.", + file=sys.stderr, + ) + return 1 + + notes = changelog_section(tag_version) + if args.output is None: + sys.stdout.write(notes) + else: + args.output.write_text(notes, encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 501cd6e5b3a7c30dea89b5ef2ff849f9546a5e57 Mon Sep 17 00:00:00 2001 From: Pouri Date: Thu, 10 Sep 2026 18:18:16 +0330 Subject: [PATCH 3/6] docs: tlgr is not on PyPI, so stop telling people to install it from there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pip install tlgr` is the second line of the README and it has never worked: there is no `tlgr` project on PyPI, and https://pypi.org/simple/tlgr/ is a 404 today. The v1.0.0 release did not publish one either. Anyone following the README either got an error or, if the name is ever claimed by somebody else, something worse than an error. UPGRADING.md inherited the same assumption in section 2 — `pipx upgrade tlgr`, `pip install --force tlgr`, `pip install -U tlgr` — none of which has a source to upgrade from. The editable-install path below it was already correct, because that one describes what actually happens. Both now name the two things that do work: an install from this repository, and the wheel attached to a release. The `pipx` form is first because tlgr runs a long-lived daemon and wants an environment of its own. Claiming the name on PyPI is a separate decision and this does not make it. --- README.md | 9 ++++++++- docs/UPGRADING.md | 11 +++++++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e73dc42..6bd16a2 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,16 @@ Full Telegram account control from the terminal. Agent-friendly, daemon-based, with webhook event push. ``` -pip install tlgr +pipx install git+https://github.com/tlgrcli/tlgr.git ``` +tlgr is not published on PyPI, so `pip install tlgr` does not reach this +project. Install from the repository, or from the wheel attached to a +[release](https://github.com/tlgrcli/tlgr/releases). `pipx` is the +recommendation because tlgr runs a long-lived daemon and wants its own +environment; `pip install git+https://github.com/tlgrcli/tlgr.git` into a +virtualenv works the same way. + > **For agents:** logging in is a sequence of ordinary commands — `tlgr auth send-code` then `tlgr auth verify-code` — so only *reading the code* needs a person. Secrets come from `--x-env`/`--x-stdin`/`--x-file`, never argv. See [AGENT.md](AGENT.md) for the full agent reference. > **Coming from tlgr 1.x with a running daemon?** Stop it before you upgrade — two processes on one session file is how an authorization gets revoked. [docs/UPGRADING.md](docs/UPGRADING.md) is the ten-minute cutover, including the six output shapes an agent has to adapt to. diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index cffd168..abf32b2 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -78,11 +78,18 @@ removed on the next start — but an *open file handle* is not. ## 2. Upgrade the install ```bash -pipx upgrade tlgr # or: pipx install --force tlgr +pipx install --force git+https://github.com/tlgrcli/tlgr.git tlgr --version # expect 2.0.0 ``` -`pip install -U tlgr` works the same way if that is how it was installed. +tlgr is not on PyPI, so `pipx upgrade tlgr` and `pip install -U tlgr` have +nothing to upgrade from: the install came from this repository and so does +the upgrade. `--force` because pipx will not reinstall over an existing +install otherwise. Into a virtualenv it is +`pip install -U 'tlgr @ git+https://github.com/tlgrcli/tlgr.git'`, and the +wheel attached to the [2.0.0 +release](https://github.com/tlgrcli/tlgr/releases/tag/v2.0.0) installs the +same build without git. ### A pipx editable install — the checkout *is* the install From f82033fa8b3e426ef337b1e696f0321928f0cb61 Mon Sep 17 00:00:00 2001 From: Pouri Date: Thu, 10 Sep 2026 18:18:16 +0330 Subject: [PATCH 4/6] docs(contributing): the gates are `make check`, and a release is a tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Testing section predates the Makefile. It asked for `python -m py_compile tlgr/**/*.py` and a manual try against a test account, which is neither what CI runs nor enough to pass it — a contributor following it would open a PR that fails on ruff, on mypy, or on a stale generated reference they were never told to regenerate. It now points at `make check` and names the two shorter forms for the inner loop, and says that `docs/reference` and the parity index are artefacts with tests that fail when they go stale. The new Releases section writes down the procedure `release.yml` implements: which three files state the version, that they have to agree, and that the tag is the trigger for everything else. Also a line on branch prefixes, since which ones CI builds on push is not guessable from the outside. --- CONTRIBUTING.md | 52 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3a4318..cc3bfaa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,14 +37,24 @@ Thank you for your interest in contributing! This document provides guidelines f ### Testing -Before submitting a PR: +The Makefile owns the gates, and CI runs the same targets. Before submitting +a PR, run what CI will run: -1. Ensure the code compiles without errors: - ```bash - python -m py_compile tlgr/**/*.py - ``` +```bash +make check +``` + +That is `lint`, `typecheck`, `test`, `docs` and `parity` in order. Two +shorter forms exist for the inner loop: `make test-fast` (no coverage, stops +at the first failure) and `make acceptance` (the subset that proves +ARCHITECTURE 12.3). -2. Test your changes manually with a test Telegram account +The generated reference and the parity index are artefacts, not hand-written +files. `make docs` and `make parity` regenerate them; `tests/test_docs_fresh.py` +and `tests/test_parity.py` fail if you commit code without them. + +Manual verification against a test Telegram account is still worth doing for +anything that touches the wire, but it is not a substitute for the suite. ### Pull Requests @@ -64,6 +74,36 @@ Before submitting a PR: 5. Fill out the PR template with details about your changes +Branch names matter to CI: `feat/**`, `fix/**`, `chore/**`, `docs/**` and +`design/**` are built on push. A branch outside those prefixes is only built +once its pull request is open. + +## Releases + +A release is a tag, and the tag is the whole procedure. Three files state the +version and all three have to agree before anything is published: +`tlgr/__init__.py` (`__version__`, which `pyproject.toml` reads), the +`## [x.y.z]` heading in `CHANGELOG.md`, and the tag itself. + +1. Bump `__version__` and write the `CHANGELOG.md` entry, in a PR like any + other change. +2. Once it is on `main` and CI is green there: + ```bash + git tag -a v2.0.0 -m "v2.0.0" + git push origin v2.0.0 + ``` + +The `release` workflow takes it from there. It checks the three versions +against each other, re-runs the acceptance suite against the tagged tree, +builds the sdist and the wheel, installs the wheel into a clean environment +and asks it for its version, and only then creates the GitHub release with +the changelog section as its notes and both artefacts attached. Any step +failing means no release is created, so a bad tag costs a `git push --delete` +and nothing else. + +tlgr is not on PyPI. Installation is from the repository (see `README.md`), +and the attached wheel is the artefact to install from a release. + ## Reporting Issues When reporting bugs, please include: From 5b086d8126c785fdaf1eff6199c5e97c6c334dc3 Mon Sep 17 00:00:00 2001 From: Pouri Date: Thu, 10 Sep 2026 18:49:06 +0330 Subject: [PATCH 5/6] release: publish the release's own wheel to PyPI, over trusted publishing The `pip install tlgr` in the README should become true rather than be deleted, so the release workflow gains a second job that puts the sdist and the wheel on PyPI. It publishes the artefacts the first job built, downloaded rather than rebuilt: the wheel that was smoke-tested in a clean environment and the wheel a user installs have to be the same bytes, and two `uv build` runs are not a guarantee of that. It runs after the GitHub release exists, so a PyPI failure leaves the release standing and can be re-run on its own. Trusted publishing rather than an API token: PyPI verifies the workflow's OIDC token against a publisher configured for this repository, so there is no publishing secret here to leak or rotate, and `permissions` drops to `read` at the top of the file with each job asking for exactly what it needs (`contents: write` to create the release, `id-token: write` to mint the token). The `pypi` environment is the second gate and is where a required reviewer goes if this should ever stop being automatic. The publisher has to exist before the first release that uses it, as a *pending* publisher since the project is not on PyPI yet; CONTRIBUTING names the five fields. --- .github/workflows/release.yml | 38 +++++++++++++++++++++++++++++++++-- CONTRIBUTING.md | 24 ++++++++++++++++++++-- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7dc6cb7..0e45140 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,8 +7,10 @@ on: push: tags: ["v*"] +# Read by default. Each job asks for what it needs: `release` writes the +# Releases page, `publish` mints an OIDC token and writes nothing here. permissions: - contents: write + contents: read concurrency: group: release-${{ github.ref }} @@ -16,8 +18,10 @@ concurrency: jobs: release: - name: build and publish ${{ github.ref_name }} + name: build and release ${{ github.ref_name }} runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v4 with: @@ -76,3 +80,33 @@ jobs: --notes-file notes.md \ --verify-tag \ dist/* + + # The same two files the release now carries, handed to the publish job + # rather than rebuilt there: a wheel that was smoke-tested and a wheel + # that reaches PyPI have to be the same bytes. + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + if-no-files-found: error + + publish: + name: publish ${{ github.ref_name }} to PyPI + needs: release + runs-on: ubuntu-latest + # Trusted publishing: PyPI verifies this workflow's OIDC token against a + # publisher configured for tlgrcli/tlgr, so there is no API token in the + # repository to leak. The environment is the second gate — it is where a + # required reviewer goes if this should ever stop being automatic. + environment: + name: pypi + url: https://pypi.org/p/tlgr + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc3bfaa..b5bd3ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,8 +101,28 @@ the changelog section as its notes and both artefacts attached. Any step failing means no release is created, so a bad tag costs a `git push --delete` and nothing else. -tlgr is not on PyPI. Installation is from the repository (see `README.md`), -and the attached wheel is the artefact to install from a release. +A second job publishes those same two files to PyPI. It downloads the +artefacts the first job built rather than rebuilding them, because the wheel +that was smoke-tested and the wheel that reaches PyPI have to be the same +bytes. It runs after the GitHub release exists, so a PyPI failure leaves the +release standing and is re-runnable on its own. + +### The PyPI publisher + +Publishing uses [trusted +publishing](https://docs.pypi.org/trusted-publishers/): PyPI verifies the +workflow's OIDC token instead of an API token, so there is no publishing +secret in this repository and nothing to leak or rotate. It has to be +configured once, on pypi.org, before the first release that uses it: + +- owner `tlgrcli`, repository `tlgr`, workflow `release.yml`, environment + `pypi`; +- for the first release, add it as a *pending* publisher, since the project + does not exist on PyPI until something is published to it. + +Until that publisher exists the `publish` job fails and the GitHub release +still succeeds, which is the intended order: the release is the artefact of +record, PyPI is a distribution channel on top of it. ## Reporting Issues From 478ece213eb641e4969d567fb5cc55ca24c0a73a Mon Sep 17 00:00:00 2001 From: Pouri Date: Thu, 10 Sep 2026 18:57:08 +0330 Subject: [PATCH 6/6] ci: a push does not rebuild what its pull request is already building MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding `fix/**` and `chore/**` to the push trigger made a problem visible that `feat/**` already had: a branch with an open pull request is built twice, the same commit through the same ten-job matrix, once for the push and once for the pull request. Half of those jobs are macOS, which is the scarcest runner on the account, so the duplicate is not free — it is the queue in front of the run that actually gates the merge. Four branches pushed in one afternoon sat queued for thirty-five minutes without a single job starting. A `guard` job on ubuntu decides. A `pull_request` event always builds; `main` always builds; a push to any other branch builds only when that branch has no open pull request. So a branch pushed before its pull request exists is still covered — which is the gap this file had, and the reason it sat unbuilt for a day — and the moment a pull request opens, the push run steps aside instead of racing it. The matrix itself is untouched. This removes duplicated work, not coverage. --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 938ef02..e69c1af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,12 +9,48 @@ on: branches: ["main", "feat/**", "fix/**", "chore/**", "docs/**", "design/**"] pull_request: +# `gh pr list` in the guard job needs to read pull requests; nothing here +# writes anything. +permissions: + contents: read + pull-requests: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true jobs: + # A branch with an open pull request is built by the `pull_request` event. + # Building it again on push is the same commit through the same matrix for + # a second time, and at ten jobs a run — half of them macOS, which is the + # scarcest runner there is — the duplicate is what puts a queue in front of + # the run that actually gates the merge. So: push builds a branch only + # while nothing else is building it. + guard: + name: should this push build + runs-on: ubuntu-latest + outputs: + run: ${{ steps.check.outputs.run }} + steps: + - id: check + env: + GH_TOKEN: ${{ github.token }} + run: | + if [ "${{ github.event_name }}" != "push" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" # the pull_request run itself + elif [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "run=true" >> "$GITHUB_OUTPUT" # main is never skipped + elif [ "$(gh pr list --repo "$GITHUB_REPOSITORY" \ + --head "$GITHUB_REF_NAME" --state open \ + --json number --jq 'length')" != "0" ]; then + echo "run=false" >> "$GITHUB_OUTPUT" # its pull request covers it + else + echo "run=true" >> "$GITHUB_OUTPUT" # no pull request yet + fi + test: + needs: guard + if: needs.guard.outputs.run == 'true' name: ${{ matrix.os }} · py${{ matrix.python }} runs-on: ${{ matrix.os }} # 3.14 is allowed to fail: it is a forward-looking signal, not a gate.