Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 48 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,55 @@ 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:

# `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.
Expand All @@ -36,16 +76,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"
112 changes: 112 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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*"]

# 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: read

concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false

jobs:
release:
name: build and release ${{ github.ref_name }}
runs-on: ubuntu-latest
permissions:
contents: write
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/*

# 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
72 changes: 66 additions & 6 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -64,6 +74,56 @@ 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.

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

When reporting bugs, please include:
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions docs/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading