diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3a923d..e69c1af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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. @@ -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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0e45140 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3a4318..b5bd3ce 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,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: 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 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())