diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73c45e1..64c8d46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,3 +84,32 @@ jobs: # screen has to stay readable at 72 columns regular and 53 on the PicoCalc, and # every PicoCalc case is a hard failure rather than a warning. - run: python -m pytest -q + + # Builds the distributions and checks them the way PyPI would, publishing nothing. + # Worth its minute: packaging breaks silently and is otherwise only discovered on + # the one day you least want to discover it. The wheel is installed into a clean + # environment and the command run from outside the source tree, because "it built" + # and "it works when installed" are different claims. + build: + name: build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: pip install build twine + + - run: python -m build + + - run: twine check dist/* + + - name: Install the wheel clean and run the command + run: | + python -m venv /tmp/fresh + /tmp/fresh/bin/pip install --quiet dist/*.whl + cd /tmp && /tmp/fresh/bin/meshterm --help > /dev/null + echo "the wheel installs and the console script runs" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0cd786a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,99 @@ +# Publishing to PyPI. Deliberately hard to fire. +# +# There is no `push: tags` trigger here, and that absence is the point. A release +# workflow that publishes on a `v*` tag would mean tagging v0.9.0 on launch day +# silently claims a distribution name on a public index, permanently, as a side +# effect of an unrelated action. PyPI has no rename and no true delete, so that +# side effect is not one you can take back. +# +# So: a human runs this, from the Actions tab, on purpose, and types the version +# to prove they meant this version. Three gates before anything leaves the runner: +# +# 1. no automatic trigger at all +# 2. the typed version must equal the version actually built +# 3. the `pypi` environment, which can require a reviewer in repo settings +# +# It publishes whatever version is in the tree. There is no version argument, so +# this cannot publish something other than what is committed. +name: Release + +on: + workflow_dispatch: + inputs: + confirm_version: + description: "Version to publish, exactly (e.g. 0.9.0). A mismatch aborts." + required: true + type: string + +permissions: + contents: read + +jobs: + build: + name: build and check + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: pip install build twine + + - run: python -m build + + # Renders the README the way PyPI will, and fails on metadata PyPI would reject. + - run: twine check dist/* + + # The version the build actually produced, read off the artifact rather than + # trusted from anywhere else. + - id: built + shell: bash + run: | + version=$(ls dist/*.tar.gz | sed -E 's/.*-([0-9][^-]*)\.tar\.gz/\1/') + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "built version: $version" + + - name: Confirm the typed version matches what was built + shell: bash + env: + TYPED: ${{ inputs.confirm_version }} + BUILT: ${{ steps.built.outputs.version }} + run: | + if [ "$TYPED" != "$BUILT" ]; then + echo "::error::You typed '$TYPED' but the tree builds '$BUILT'. Nothing published." + exit 1 + fi + echo "confirmed: $BUILT" + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + name: publish to PyPI + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + # Configure this environment in repo settings with a required reviewer, and it + # becomes a fourth gate: the job waits for an approval before it runs. The name + # must also match the environment set on the PyPI trusted publisher, or the OIDC + # exchange fails with an unhelpful error. If the publisher has no environment + # configured, delete this line. + environment: + name: pypi + url: https://pypi.org/p/mesh-term + permissions: + # Trusted Publishing mints a short-lived OIDC token instead of storing an API + # token in the repository. This is the only thing that needs write here. + 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/CHANGELOG.md b/CHANGELOG.md index d9f5596..ec0f9c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ allowed to change behaviour, not just add to it. ## [Unreleased] +### Changed + +- The distribution is named `mesh-term`. PyPI prohibits `meshterm` (pypi/support#12162), + and the name you install is the only thing that changes: the import package and the + command are both still `meshterm`. +- `pyproject.toml` carries the metadata a stranger sees on a package page — project URLs, + trove classifiers, and the licence as a PEP 639 expression. + +### Added + +- Continuous integration. Lint once, tests across seven OS and Python combinations, and a + packaging job that installs the built wheel into a clean environment and runs the command + from outside the source tree. +- A release workflow with no automatic trigger, which publishes nothing until a human runs + it on purpose and types the version. + ## [0.2.0] — 2026-09-07 The first version to carry a changelog. Everything before it is in the commit log and is diff --git a/pyproject.toml b/pyproject.toml index 13ba21e..36afa08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,14 +3,34 @@ requires = ["hatchling"] build-backend = "hatchling.build" [project] -name = "meshterm" +# The distribution name only. The import package and the console script both stay +# `meshterm` — PyPI prohibits that name (see pypi/support#12162), and nothing about the +# app changes because of it. `[tool.hatch.build.targets.wheel]` below is what keeps the +# wheel shipping the `meshterm/` package under this different name. +name = "mesh-term" dynamic = ["version"] description = "MeshTerm is a full-featured TUI-based MeshCore client for your terminal. Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Linux." readme = "README.md" requires-python = ">=3.10" authors = [{ name = "Jean-Pierre Martineau" }] -license = { text = "Apache-2.0" } +license = "Apache-2.0" +license-files = ["LICENSE", "NOTICE"] keywords = ["meshcore", "lora", "mesh", "radio", "cli"] +# No `License ::` entry: PEP 639 forbids one alongside the SPDX expression above. +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: End Users/Desktop", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Communications :: Ham Radio", + "Topic :: System :: Networking", +] dependencies = [ "meshcore>=2.3", # companion-device protocol (serial/BLE/TCP) @@ -34,6 +54,12 @@ dev = [ "ruff>=0.4", ] +[project.urls] +Homepage = "https://github.com/jpmartineau/MeshTerm" +Repository = "https://github.com/jpmartineau/MeshTerm" +Issues = "https://github.com/jpmartineau/MeshTerm/issues" +Changelog = "https://github.com/jpmartineau/MeshTerm/blob/main/CHANGELOG.md" + [project.scripts] meshterm = "meshterm.cli:main"