Thanks for working on cronstable! This document covers local development and, importantly, how releases are cut.
One routing note before anything else: security problems do not go in the issue tracker. SECURITY.md has the private reporting route and what to include.
cronstable uses the Developer Certificate of Origin (DCO): a lightweight, sign-off-based alternative to a CLA. By signing off, you certify that you wrote the patch, or otherwise have the right to submit it under the project's license (the full text is in the DCO file).
Add a sign-off to each commit with -s:
git commit -s -m "Fix the thing"That appends a trailer with the name and email from your git config:
Signed-off-by: Your Name <you@example.com>
CI (the dco job) checks that every commit in a pull request carries this
trailer. Forgot it? Sign off the whole branch and force-push:
git rebase --signoff origin/develop
git push --force-with-leasecronstable targets Python 3.10+ (3.10, 3.11, 3.12, 3.13 and 3.14 are tested) and runs on Linux, macOS and Windows (the test suite runs on all three in CI, including Windows ARM64).
cronstable uses uv for a fast dev loop (tox also
runs through uv via tox-uv, and uv can fetch the 3.10–3.14 interpreters the
test matrix needs). With uv installed:
git clone https://github.com/ptweezy/cronstable
cd cronstable
uv venv # create .venv (uv picks a suitable Python)
uv pip install -e ".[dev]" # editable install with the dev extraPrefer stock tooling? The classic path still works unchanged:
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]" # or: pip install -r requirements_dev.txtNote: all OS-specific behavior lives in
cronstable/platform.py(default shell, default config location, unix-socket support, and shutdown-signal wiring). The POSIX-onlyuser/groupfeature importsgrp/pwdlazily and is rejected on Windows. mypy is pinned to thelinuxplatform (it type-checks the POSIX API surface; the Windows branches are runtime-guarded), so type-checking is identical on every OS.
Everything CI runs is driven by tox:
tox # all envs: py310, py311, py312, py313, py314, lint, mypy, bandit
tox -e lint # ruff check + ruff format --check
tox -e mypy # mypy
tox -e bandit # bandit security lint (medium+ severity)
tox -e py # pytest on the current interpretertox.ini declares requires = tox-uv, so tox provisions its environments and
installs dependencies with uv automatically (much faster; behavior-identical).
Force the legacy virtualenv+pip path with tox --runner virtualenv if you ever
need to.
CI benchmarks every commit against the latest release (startup time, schedule math at 100k-job scale, config parsing, state I/O, memory footprint, and more) and, on a release, fails the pipeline if a metric regresses past its declared limit. The release notes then carry a per-metric diff chart. Check your own changes locally with:
python benchmarks/bench.py --quick --json before.json
# make the change
python benchmarks/bench.py --quick --json after.json
python benchmarks/compare.py --baseline before.json --current after.json --md diff.mdTo ship an intentional, measured regression, start a pushed commit's subject
with [perf:accept] (subjects only, like the [release] marker). The full
harness reference, including how to add a benchmark, is in
benchmarks/README.md.
Releases are automated by the single CI
GitHub Actions pipeline (one workflow builds and tests everything on every
commit and, on a release, publishes it). Version numbers come from git tags via
setuptools_scm; you never edit a version by hand.
A release happens when any commit in a push to main has a release marker
at the start of its subject line (the first line of the commit message):
[release:minor] Add retry backoff to the HTTP reporter
It does not need to be the latest commit in the push, but only subject lines are scanned, and only a marker that begins the subject counts. Prose that mentions a marker in a commit body (or anywhere else in a subject) never triggers or escalates a release.
Valid markers (the bump level is optional; case is ignored):
| Marker | Bump | 1.0.5 → |
|---|---|---|
[release] |
minor | 1.1.0 |
[release:major] |
major | 2.0.0 |
[release:minor] |
minor | 1.1.0 |
[release:patch] |
patch | 1.0.6 |
If more than one commit in the push carries a marker, the latest such commit wins. (File contents like this document are never scanned; only commit subjects are.)
You can also release manually without a marker: Actions → release → Run workflow, then pick the bump level from the dropdown.
The same pipeline runs on every commit and PR; only the publish steps are
gated behind the release check (the lone exception is the wiki job, which
publishes documentation on every push to develop — see Editing the
wiki). On a release it, in order:
- decides whether to release and at what level (the strict marker check,
which only fires on a push to
mainor a manual dispatch); - computes the next version from the latest
X.Y.Ztag (refusing if that tag already exists); - builds and tests everything in parallel —
tox(py310–py314, lint, mypy), the wheel + sdist, the self-contained PyInstaller binaries for Linux (amd64,arm64,i686,armv7,armv6,ppc64le,s390xandriscv64, glibc and musl), macOS (arm64+amd64) and Windows (amd64+arm64), each smoke-tested with--version, plus a build-only pass over every Docker image — all at the computed version. This whole matrix is the gate: a red anywhere (a failed test, a broken binary, or a brokenDockerfile) means no release; - only once the entire gate is green, publishes the wheel + sdist to PyPI via Trusted Publishing (OIDC): there is no API token to manage or leak;
- after a successful publish, creates and pushes the
X.Y.Ztag and a GitHub Release with the wheel, sdist, and all the binaries (cronstable-linux-{amd64,arm64,i686,armv7,ppc64le,s390x,riscv64}, their-muslvariants pluscronstable-linux-armv6-musl,cronstable-macos-{arm64,amd64}, andcronstable-windows-{amd64,arm64}.exe) plus a singleSHA256SUMSattached, then pushes the multi-arch container images and updates the Homebrew tap.
Because no file is committed back to this repo, a release never re-triggers
the workflow. (Two jobs do push elsewhere — the Homebrew tap on a release, and
the wiki on a develop commit — but both targets are separate repositories and
a push to either raises no event here.) Because the tag is
created after publishing, a failed publish leaves no orphan tag and a re-run
cleanly retries the same version.
The official image is built and published by the single
CI pipeline, from the top-level
Dockerfile (and the per-distro docker/Dockerfile.*):
- On every commit and PR it builds every image without pushing (the
dockergate job), across their full published arch sets, so a brokenDockerfilefails CI before a release. - On a release, once the whole gate is green, the
docker-pushjob builds and pushes each distro's multi-arch image, tagged<version>and:latest(the Debian base owns the bare tags; variants get a-<distro>suffix), to bothghcr.io/ptweezy/cronstableanddocker.io/ptweezy/cronstable. GHCR authenticates with the built-inGITHUB_TOKEN; Docker Hub uses theDOCKERHUB_USERNAMEandDOCKERHUB_TOKENrepository secrets (skipped if unset).
Build it locally the same way CI does (the version is read from git, or pass
--build-arg VERSION=X.Y.Z):
docker build -t cronstable .
docker run --rm -v "$PWD/example/docker/cronstable.yaml:/etc/cronstable.d/cronstable.yaml:ro" cronstableEdit wiki/ in this repo — not the wiki in the browser. The
GitHub wiki is a published copy:
every push to develop runs the pipeline's wiki job, which mirrors
wiki/*.md onto it (one file per page, named as the page's URL:
Web-Dashboard.md → /wiki/Web-Dashboard).
The mirror is authoritative, so it deletes: a page created or edited from
the wiki's web UI is reverted on the next push to develop. The job prints
every add/modify/delete to the run log.
Pages link to each other with bare wiki links — [Installation](Installation) —
which only resolve once published. Those links are expected to be dead when
browsing wiki/*.md here; that is not a bug.