Skip to content

ci/cd: build every branch, check what the Makefile checks, and make a tag cut a release - #18

Merged
erfnzdeh merged 6 commits into
mainfrom
chore/ci-release-pipeline
Sep 10, 2026
Merged

ci/cd: build every branch, check what the Makefile checks, and make a tag cut a release#18
erfnzdeh merged 6 commits into
mainfrom
chore/ci-release-pipeline

Conversation

@erfnzdeh

Copy link
Copy Markdown
Collaborator

Four findings from an audit of the repository's CI, releases and install
instructions, and the fix for each.

1. CI did not build fix/** branches

The push trigger listed main, feat/** and design/**. Every branch this
repo has opened since is a fix/**, and none was built on push;
fix/dialog-cursor-access-hash sat on the remote for a day with no run
against it. fix/**, chore/** and docs/** are on the list now.

2. CI type-checked seven of the thirty-two strict paths

ci.yml spelled out its own mypy invocation and the Makefile's STRICT set
had grown past it. tlgr/transport, the strict tlgr/daemon subset,
tlgr/core/config.py, tlgr/core/peers.py, tlgr/parity.py and
tlgr/version.py were checked by make typecheck locally and not in CI.
Lint, typecheck and test now all run the Makefile target, so there is one
definition of each gate. The full set passes as it stands: nothing had
regressed, it was simply unwatched.

3. There was no CD, and no 2.0.0 release

2.0.0 has been on main since 4 September and the newest release was v1.0.0
from March, with no artefacts on it. release.yml makes a tag the whole
procedure. On v* it checks that the tag, tlgr.__version__ and the
## [x.y.z] changelog heading agree; re-runs the ARCHITECTURE 12.3
acceptance subset against the tagged tree; builds the sdist and wheel;
installs the wheel into an empty environment and asks it for its version; and
only then creates the release with the changelog section as notes and both
artefacts attached. Any step failing means no release, so a bad tag costs a
git push --delete.

tools/release_notes.py is the version-agreement check, runnable on its own
before a tag is pushed.

Nothing here publishes to PyPI. That is a separate decision.

4. pip install tlgr has never worked

There is no tlgr project on PyPI (https://pypi.org/simple/tlgr/ is a 404),
and it is the second line of the README. UPGRADING.md section 2 inherited the
same assumption. Both now name what works: an install from this repository,
and the wheel attached to a release.

CONTRIBUTING.md gets the release procedure written down, and its Testing
section replaced: it predated the Makefile and asked for python -m py_compile, which is neither what CI runs nor enough to pass it.

Verification

Run locally against this branch on 3.12: make lint, make typecheck (the
full strict set, 139 files), make acceptance (10586 passed), uv build,
and an install of the resulting wheel into a clean venv reporting
tlgr, version 2.0.0.

… 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.
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.
…there

`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.
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.
…hing

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.
@erfnzdeh

Copy link
Copy Markdown
Collaborator Author

Added a fifth piece after review: the release publishes to PyPI.

release.yml gains a publish job that puts the sdist and wheel on PyPI over
trusted publishing — PyPI verifies
the workflow's OIDC token, so there is no API token in this repository to leak
or rotate. Top-level permissions drops to read, with contents: write on
the release job and id-token: write on the publish job.

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. It runs after the GitHub release exists, so
a PyPI failure leaves the release standing and is re-runnable alone.

One thing has to happen on pypi.org before the first tag, since the project
does not exist there yet: add a pending trusted publisher with owner
tlgrcli, repository tlgr, workflow release.yml, environment pypi.
CONTRIBUTING.md documents it. Until it exists the publish job fails and the
GitHub release still succeeds, which is the intended order.

The README and UPGRADING wording in this PR still says tlgr is not on PyPI,
which is true as of this merge. That flips in a follow-up once the first
publish has actually landed, rather than promising it in advance.

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.
@erfnzdeh
erfnzdeh merged commit fdd7ca0 into main Sep 10, 2026
12 of 13 checks passed
@erfnzdeh
erfnzdeh deleted the chore/ci-release-pipeline branch September 10, 2026 17:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant