# Contributing to cronstable Thanks for working on cronstable! This document covers local development and, importantly, how releases are cut. ## Signing off your commits (DCO) cronstable uses the [Developer Certificate of Origin](DCO) (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](DCO) file). Add a sign-off to each commit with `-s`: ```sh git commit -s -m "Fix the thing" ``` That appends a trailer with the name and email from your git config: ```text Signed-off-by: Your Name ``` 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: ```sh git rebase --signoff origin/develop git push --force-with-lease ``` ## Development setup cronstable 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](https://docs.astral.sh/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: ```sh 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 extra ``` Prefer stock tooling? The classic path still works unchanged: ```sh python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate pip install -e ".[dev]" # or: pip install -r requirements_dev.txt ``` > **Note:** all OS-specific behavior lives in > [`cronstable/platform.py`](cronstable/platform.py) (default shell, default config > location, unix-socket support, and shutdown-signal wiring). The POSIX-only > `user`/`group` feature imports `grp`/`pwd` lazily and is rejected on Windows. > mypy is pinned to the `linux` platform (it type-checks the POSIX API surface; > the Windows branches are runtime-guarded), so type-checking is identical on > every OS. ## Running the checks Everything CI runs is driven by `tox`: ```sh 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 interpreter ``` `tox.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. ## Performance benchmarks 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: ```sh 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.md ``` To 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](benchmarks/README.md). ## Releasing Releases are **automated** by the single [`CI`](.github/workflows/release.yml) 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. ### Cutting a release 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): ```text [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. ### What the pipeline does 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](#editing-the-wiki)). On a release it, in order: 1. **decides** whether to release and at what level (the strict marker check, which only fires on a push to `main` or a manual dispatch); 2. **computes** the next version from the latest `X.Y.Z` tag (refusing if that tag already exists); 3. **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`, `s390x` and `riscv64`, 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 broken `Dockerfile`) means no release; 4. **only once the entire gate is green**, publishes the wheel + sdist to PyPI via [Trusted Publishing (OIDC)](https://docs.pypi.org/trusted-publishers/): there is no API token to manage or leak; 5. **after a successful publish**, creates and pushes the `X.Y.Z` tag and a GitHub Release with the wheel, sdist, and all the binaries (`cronstable-linux-{amd64,arm64,i686,armv7,ppc64le,s390x,riscv64}`, their `-musl` variants plus `cronstable-linux-armv6-musl`, `cronstable-macos-{arm64,amd64}`, and `cronstable-windows-{amd64,arm64}.exe`) plus a single `SHA256SUMS` attached, 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. ## Container image The official image is built and published by the single [`CI`](.github/workflows/release.yml) pipeline, from the top-level [`Dockerfile`](Dockerfile) (and the per-distro `docker/Dockerfile.*`): - **On every commit and PR** it builds every image *without* pushing (the `docker` gate job), across their full published arch sets, so a broken `Dockerfile` fails CI before a release. - **On a release**, once the whole gate is green, the `docker-push` job builds and pushes each distro's multi-arch image, tagged `` and `:latest` (the Debian base owns the bare tags; variants get a `-` suffix), to both `ghcr.io/ptweezy/cronstable` and `docker.io/ptweezy/cronstable`. GHCR authenticates with the built-in `GITHUB_TOKEN`; Docker Hub uses the `DOCKERHUB_USERNAME` and `DOCKERHUB_TOKEN` repository 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`): ```sh docker build -t cronstable . docker run --rm -v "$PWD/example/docker/cronstable.yaml:/etc/cronstable.d/cronstable.yaml:ro" cronstable ``` ## Editing the wiki Edit [`wiki/`](wiki) in this repo — not the wiki in the browser. The [GitHub wiki](https://github.com/ptweezy/cronstable/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.