Files
funkpower 33441113ce ask every binding the same question and insist on the same answer
There is one engine and nine doors into it, and nothing until now checked
that the doors agree. CI tested each binding against itself: the Python
wheel imported, the C demo compiled, the npm package type-checked. None
of that would have caught a field that crosses one boundary wrong, which
is the failure this library is most exposed to, because a wrong answer
about a fast day looks exactly like a right one.

So: twelve days, chosen where the bindings could plausibly differ, and
all twenty-two fields compared against what the command line says. The
days are Pascha itself, the Nativity on both calendars, a Wednesday of
the Great Fast under Byzantine Catholic law and again at the Typikon, a
weekday of the Nativity Fast where Greek and Slavic usage part, the one
Orthodox church on the Gregorian Paschalion, and a day with no feast.

A release now runs what a push runs. It had been gated on cargo test
alone, so the C ABI, the Lua binding, the Python parity check and the
README examples were checked on the branch that led to a release and not
on the release. Neither registry will reuse a version, so the gate
belongs before the upload.

Writing it found three things. The npm exports map did not expose the
wasm by package path, so a bundler asked for typikon/web/typikon_bg.wasm
was told no such path exists and had to reach into node_modules; the
documented init() call was never affected. The suite could hang instead
of fail, because the test web server was backgrounded inside a subshell
and killed by job spec, leaving bash in do_wait forever. And pointing
XDG_CONFIG_HOME at a path that does not exist isolates typikon and
breaks every other program that keeps state under it, a browser included.

A surface whose toolchain is missing is named as skipped, and fewer than
three surfaces running is a failure rather than a pass, so a container
with nothing installed cannot come out green.
2026-09-18 16:08:41 -04:00

7.8 KiB

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.
$ # 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:

[actions]
ENABLED = true
$ 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:

$ 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.

4. An npm token

Also under funkpower, also scoped to the one package, added as the repository secret NPM_TOKEN. Make it a granular access token with write on typikon alone; a classic automation token is account-wide.

It does not need "bypass 2FA", and should not have it. npm is withdrawing direct publish from every token that bypasses 2FA: management operations went in July 2026, publishing goes in January 2027, after which such a token may only read private packages and stage a publish. Staging is what this workflow does, so an ordinary granular token is both sufficient and the one that survives.

npm has trusted publishing too, and like PyPI's it is OIDC against GitHub Actions and GitLab, which this forge is neither. So the forge holds a long-lived credential and the scoping is the whole of the mitigation.

Without the secret the release still succeeds and the npm step warns and skips, so the first release after the token lapses is a PyPI release and nothing else. Check the run.

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.

And on npm, one package with two WebAssembly builds in it: node/ for Node, which loads the module itself, and web/ for browsers and bundlers, which cannot compile WebAssembly synchronously on the main thread and so has to be awaited. The exports map picks between them. There are no per-platform artefacts, because WebAssembly has no platforms.

The npm half needs a hand

The tag does not finish the npm release. The runner stages the tarball; it is not on the registry and npm install typikon will not see it until someone approves it with 2FA:

$ npm stage list
$ npm stage view <stage-id>      # what is in it
$ npm stage approve <stage-id>   # asks for the code

Or the Staged Packages tab on npmjs.com. Either way a code is asked for, which is the point: the forge holds a credential that can propose a release and cannot make one.

npm stage wants npm 11.15 or newer on Node 22.14 or newer, on the approving machine as well as the runner.

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

$ rm -rf dist          # a wheel left in dist/ gets packed into the sdist
$ maturin sdist --out dist && maturin build --release --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.

Every binding agrees

tests/parity.sh asks every binding for the same twelve days and compares all twenty-two fields against what the command line says. It runs in CI on every push and again in the release job before anything is uploaded.

$ tests/parity.sh              # against this checkout
$ tests/parity.sh --published  # against what is on PyPI and npm

Run the second one after a release. It is the only check that exercises the artefacts people will actually install rather than the ones the build produced.

A surface whose toolchain is missing is skipped by name, and the script refuses to report a pass if fewer than three ran, so a container with nothing installed cannot look like a green run. Everything it wants: python3 with venv, node and npm, cc, luajit, chromium, and optionally deno and bun.

For the npm package:

$ npm/build.sh && node npm/test.mjs
$ npm pack --dry-run npm/dist

npm/test.mjs resolves typikon through a staged node_modules, so it tests the exports map and the built files rather than a lucky relative import. It compares a year of days field by field against the command line, runs the web build the way a browser would, and type-checks the published .d.ts under tsc --strict. It needs wasm-bindgen on PATH at the version Cargo.lock pins, and tsc for the last of those.