mirror of
https://git.pumpin.online/funkpower/typikon.git
synced 2026-10-22 18:57:13 +00:00
build it on tags, not on trust
Tag-driven releases. main is always releasable, a vX.Y.Z tag on it builds the sdist and the manylinux wheels and publishes them; Cargo.toml carries the version and the first job refuses to go on if the tag disagrees with it, because PyPI will not let a version be reused even after it is yanked. CI fires on push and on nothing else. This forge is public and takes pull requests from strangers, and a runner executes whatever the workflow checks out, so a pull_request trigger would run unread code from a stranger on the runner host. Push-only means the runner only ever runs what someone with push rights put there. A pull request therefore gets no automatic check: read it, push the branch, let it run then. No cargo fmt gate. The tables here are aligned by hand and rustfmt wants one entry per line, which loses the shape that makes MONTHS and JURISDICTIONS readable — a gate that fights the house style is a gate that gets ignored. Clippy stays, and passes. The Python job builds the bindings against the CLI it just built and runs them against each other, because they are one engine and the only way that stays true is if something checks. docs/releasing.md covers what does not exist yet: Actions is off on this forge on purpose, there is no runner, and PyPI's trusted publishing does not speak to Forgejo — so a long-lived token has to live here, project-scoped rather than account-wide so a compromised forge costs this package rather than all of them.
This commit is contained in:
@@ -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
|
||||
@@ -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/*
|
||||
@@ -271,6 +271,12 @@ widgets for Hyprland (Quickshell), waybar and eww, all reading the JSON above.
|
||||
|
||||

|
||||
|
||||
## 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;
|
||||
|
||||
@@ -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 <from Settings → Actions → Runners> \
|
||||
--name <host> --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.
|
||||
Reference in New Issue
Block a user