diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..f235498 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,70 @@ +name: ci + +# Deliberately not triggered by pull_request. +# +# The forge this runs on is public and takes pull requests from strangers, and a +# runner executes whatever the workflow checks out. Triggering on pull_request +# would mean a stranger's first contribution runs code on the runner host before +# anyone has read it. Triggering only on push means the runner only ever executes +# code that someone with push rights put there. +# +# The cost is that a pull request gets no automatic check. Run it by pushing the +# branch yourself once you have read the diff. +on: + push: + branches: ['**'] + tags-ignore: ['**'] + +jobs: + test: + runs-on: docker + container: + image: rust:1.98-bookworm + steps: + - uses: actions/checkout@v4 + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} + restore-keys: cargo-${{ runner.os }}- + + # No `cargo fmt --check`. The tables in this crate are aligned by hand — + # MONTHS, JURISDICTIONS — and rustfmt wants one entry per line, which loses + # the shape that makes them readable. A gate that fights the house style is + # a gate that gets ignored. + - name: Lint + run: | + rustup component add clippy + cargo clippy --all-targets -- -D warnings + + - name: Rust tests + run: cargo test + + # The CLI and the bindings are the same engine, so they have to agree. This + # is the check that caught the bindings resolving their own default + # jurisdiction while the CLI read the configured one. + - name: Python bindings + run: | + apt-get update && apt-get install -y --no-install-recommends python3 python3-venv python3-dev + python3 -m venv /tmp/venv + /tmp/venv/bin/pip install --quiet maturin pytest mypy + cargo build --release + /tmp/venv/bin/maturin build --release --out dist + /tmp/venv/bin/pip install --quiet --no-index --find-links dist typikon + /tmp/venv/bin/python -m pytest tests/python -q + + - name: Type stubs + run: | + cat > /tmp/typecheck.py <<'EOF' + import datetime, typikon + d: typikon.Day = typikon.describe(datetime.date(2026, 4, 12)) + day: datetime.date = d.date + feast: str | None = d.feast + n: int = d.days_to_pascha + EOF + /tmp/venv/bin/python -m mypy --strict /tmp/typecheck.py diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..91d6d8a --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,96 @@ +name: release + +# Tag-driven. Cargo.toml carries the version; the tag only points at it, and the +# first job refuses to go on if the two disagree — a release tagged v0.3.0 that +# publishes 0.2.0 is not recoverable, because PyPI will not let a version be +# reused even after it is yanked. +on: + push: + tags: ['v*'] + +jobs: + verify: + runs-on: docker + container: + image: rust:1.98-bookworm + steps: + - uses: actions/checkout@v4 + - name: Tag must match Cargo.toml + run: | + tag="${GITHUB_REF_NAME#v}" + crate=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2) + echo "tag=$tag crate=$crate" + [ "$tag" = "$crate" ] || { echo "::error::tag $tag != Cargo.toml $crate"; exit 1; } + - name: Tests + run: cargo test + + build: + needs: verify + runs-on: docker + container: + image: rust:1.98-bookworm + strategy: + matrix: + target: [x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu] + steps: + - uses: actions/checkout@v4 + - name: Build wheel + run: | + apt-get update && apt-get install -y --no-install-recommends python3 python3-venv + python3 -m venv /tmp/venv + /tmp/venv/bin/pip install --quiet maturin[zig] + # --zig cross-compiles against an older glibc than the build image + # ships, so the wheel is not silently pinned to bookworm's. + /tmp/venv/bin/maturin build --release --zig \ + --target ${{ matrix.target }} --out dist + - uses: actions/upload-artifact@v3 + with: + name: wheels-${{ matrix.target }} + path: dist + + sdist: + needs: verify + runs-on: docker + container: + image: rust:1.98-bookworm + steps: + - uses: actions/checkout@v4 + - name: Build sdist + run: | + apt-get update && apt-get install -y --no-install-recommends python3 python3-venv + python3 -m venv /tmp/venv + /tmp/venv/bin/pip install --quiet maturin + /tmp/venv/bin/maturin sdist --out dist + - uses: actions/upload-artifact@v3 + with: + name: sdist + path: dist + + publish: + needs: [build, sdist] + runs-on: docker + container: + image: python:3.12-bookworm + steps: + - uses: actions/download-artifact@v3 + with: + path: dist + - name: Collect + run: | + mkdir -p upload + find dist -name '*.whl' -o -name '*.tar.gz' | xargs -I{} cp {} upload/ + ls -l upload + - name: Publish + env: + # A PROJECT-SCOPED token, not an account-wide one. PyPI's trusted + # publishing is OIDC and supports GitHub, GitLab, Google Cloud Build and + # ActiveState — not Forgejo — so this forge has to hold a long-lived + # credential. Scoping it to this one project is the whole of the + # mitigation available: if the forge is compromised, the blast radius is + # releases of typikon rather than every package the account owns. + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: | + pip install --quiet twine + twine check upload/* + twine upload --non-interactive upload/* diff --git a/README.md b/README.md index dcffc5c..fad49fb 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,12 @@ widgets for Hyprland (Quickshell), waybar and eww, all reading the JSON above. ![The menologion widget](https://git.pumpin.online/funkpower/menologion/raw/branch/main/docs/nativity-fast.png) +## Releasing + +`main` is always releasable; a `vX.Y.Z` tag on it builds the wheels and publishes +to PyPI. [docs/releasing.md](docs/releasing.md) has the workflow, and what has to +be set up before the first one. + ## Licence GPL-2.0-only. See [LICENSE](LICENSE). The menologion is under it too; diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..b78233c --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,120 @@ +# Releasing + +## The branching workflow + +`main` is always releasable. Everything else is short-lived. + +``` +feat/whatever ──push──▶ ci runs tests + │ + └──merge──▶ main ──tag v0.3.0──▶ release builds and publishes to PyPI +``` + +- **Work on a branch.** Pushing any branch runs `ci.yml`: clippy, the Rust tests, + the Python bindings built and tested against the freshly built CLI, and mypy + against the stubs. +- **Merge to `main`** when it is green. +- **Release by tagging.** `Cargo.toml` carries the version and the tag points at + it. The first job refuses to continue if the two disagree, because PyPI will + not let a version be reused — not even after it is yanked — so publishing + 0.2.0 under a tag that says v0.3.0 cannot be undone. + +```console +$ # on main, working tree clean +$ sed -i 's/^version = .*/version = "0.3.0"/' Cargo.toml +$ cargo build # refresh Cargo.lock +$ git commit -am 'release 0.3.0' +$ git tag v0.3.0 && git push origin main v0.3.0 +``` + +`__version__` in Python comes from `CARGO_PKG_VERSION`, and maturin reads the +same field, so `Cargo.toml` is the only place a version is written. + +## Why pull requests do not trigger CI + +`ci.yml` fires on `push` and nothing else. This forge is public and takes pull +requests from strangers; a runner executes whatever the workflow checks out, so a +`pull_request` trigger would run an unread stranger's code on the runner host. +Push-only means the runner only ever executes code that someone with push rights +put there. + +The cost is real: a pull request gets no automatic check. Read the diff, push the +branch yourself, and let it run then. + +## What has to exist first + +### 1. Actions enabled on the forge + +Off by default here, deliberately — see `homedocs/services/forgejo-public.md`. +In `app.ini` on moria: + +```ini +[actions] +ENABLED = true +``` + +```console +$ ssh moria 'cd ~/docker-services/forgejo-public && docker compose down && docker compose up -d' +``` + +Always restart the whole stack. `network_mode: service:forge-wg` means restarting +the forge alone silently strips its networking. + +### 2. A runner + +The forge has no default route and cannot address the LAN, so it cannot reach a +runner. Runners poll outward, so register one against the **public** URL and let +it come to the forge the same way any other client does: + +```console +$ forgejo-runner register --no-interactive \ + --instance https://git.pumpin.online \ + --token \ + --name --labels docker +``` + +It needs Docker and outbound internet — it fetches crates and wheels. It executes +repository code, so put it somewhere you would be willing to lose, not on a host +that matters. moria runs the MCP servers and the forge itself; that is the wrong +box for it. + +### 3. A PyPI token + +Registered under the **funkpower** account. Make it **project-scoped to +typikon**, not account-wide, and add it as the repository secret `PYPI_TOKEN`. + +PyPI's trusted publishing is OIDC and supports GitHub, GitLab, Google Cloud Build +and ActiveState — not Forgejo. So this forge has to hold a long-lived credential, +and scoping it to the one project is the whole of the mitigation available: if +the forge is compromised, what is lost is the ability to publish typikon, not +every package the account owns. + +Rotate it at `pypi.org/manage/account/token/` if the forge is ever suspect. + +## What gets published + +| | | +|---|---| +| `typikon-X.Y.Z.tar.gz` | sdist — builds from source anywhere with a Rust toolchain | +| `…-cp39-abi3-manylinux…x86_64.whl` | | +| `…-cp39-abi3-manylinux…aarch64.whl` | cross-built with `maturin --zig` | + +abi3, so one wheel per platform covers every Python from 3.9 up rather than one +per interpreter. + +**No macOS or Windows wheels.** Forgejo has no hosted runners, so those need a +Mac and a Windows box registered as runners. Until then both fall back to the +sdist, which needs a Rust toolchain on the installing machine — fine for +developers, a wall for everyone else. + +## Checking a release without publishing + +```console +$ maturin build --release --out dist && maturin sdist --out dist +$ twine check dist/* +$ python -m venv /tmp/v && /tmp/v/bin/pip install --no-binary :all: dist/*.tar.gz +$ /tmp/v/bin/python -c 'import typikon, datetime; print(typikon.today())' +``` + +Installing from the sdist with `--no-binary` is the one that catches a missing +file, because it builds the crate from exactly what PyPI would hand someone.