# Contributing to cronstable

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](SECURITY.md) has the private reporting route and
what to include.

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

```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 `<version>` and `:latest`
  (the Debian base owns the bare tags; variants get a `-<distro>` 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.
