Metadata-Version: 2.4
Name: shushrun
Version: 1.1.0
Summary: Run a command quietly unless it fails.
Project-URL: Homepage, https://gitlab.com/paulto/shushrun
Project-URL: Repository, https://gitlab.com/paulto/shushrun
Project-URL: Issues, https://gitlab.com/paulto/shushrun/-/issues
Author-email: Paul Tobias <gitlab@tobias.pt>
License-Expression: AGPL-3.0-or-later
License-File: LICENSE
Keywords: cron,journald,logging,stderr,systemd,wrapper
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: System :: Logging
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: Utilities
Requires-Python: >=3.9
Provides-Extra: cysystemd
Requires-Dist: cysystemd; extra == 'cysystemd'
Provides-Extra: systemd
Requires-Dist: systemd-python; extra == 'systemd'
Description-Content-Type: text/markdown

# shushrun

Run a command quietly unless it fails — then surface its full output, with stdout and stderr put back on their own file descriptors and in (best-effort) original order.

## Why?

No single tool does both halves of this: **log every run** of a command (to journald or a file) *and* **stay silent unless it fails**, replaying the captured output only on a non-zero exit. `chronic` gives the silence but keeps no log; `systemd-cat` and `tee` log but always print. shushrun is the combination.

## The problem

`cron` mails a job's output whenever it writes to stdout/stderr or exits non-zero. That's useful, but a job that prints harmless warnings to stderr on every run produces mail on every run. Capturing the warnings to silence them means that on a *failure* cron reports only that the job failed — the output then needed has already been discarded.

The naive fix — `output="$(cmd 2>&1)"; [ $? -ne 0 ] && echo "$output"` — loses the distinction between stdout and stderr, because merging the two streams is the one thing that destroys it.

`shushrun` captures both streams, always logs them, and on a non-zero exit replays stdout to stdout and stderr to stderr (in capture order) before exiting with the command's own code — so cron stays silent on success and mails the full, faithful picture on failure.

## Behaviour

- **Always:** on a systemd host, re-run itself inside a transient unit (`systemd-run`) so that shushrun and the command share one cgroup, and report that cgroup's CPU time and peak memory alongside the exit status. Nothing to turn on, nothing to configure; where systemd-run cannot be used the command simply runs as before.
- **Always:** record the command's output — to **journald** by default (tagged with the command's basename; stdout at info, stderr at warning, plus an exit-status entry at err on failure / info on success), or to a file with `--log FILE` (plain lines; `--log-emoji` prefixes `🗒` stdout / `⚠` stderr, or set custom prefixes with `--log-stdout-prefix` / `--log-stderr-prefix`).
- **Success (exit 0):** emit nothing — cron stays quiet even if the command printed warnings.
- **Failure (exit ≠ 0):** replay stdout on stdout and stderr on stderr, in order, then exit with the command's exit code. The replay is capped at 256 KiB (`--replay-max-bytes`) — past that it keeps the first and last half and elides the middle, marking what it dropped and where the whole run is, because mail too big to deliver can be discarded silently.

## Installation

shushrun is a single-file, dependency-free tool on PyPI. Run it without installing via [uv](https://docs.astral.sh/uv/):

```sh
uvx shushrun -- mycmd --flags
```

or install the `shushrun` command with `pipx install shushrun`, `uv tool install shushrun`, or `pip install shushrun`. It needs only Python ≥ 3.9 and, for the default journald sink, a systemd host — no compiler, no C-extension wheel, nothing to build.

## Compatibility

**The 1.x series is not stable.** Any release may change options, defaults, output, or the environment a command runs in, and nothing is kept working for backwards compatibility's sake.

## Usage

```sh
shushrun [--reader poll|threads] [--log FILE] -- mycmd --flags
```

journald is the default sink, so dropping it in front of an existing cronjob needs no flags:

```cron
MAILTO=admin@example.com
17 3 * * *  shushrun -- /usr/local/bin/myjob.sh
```

Every run is logged to journald (`journalctl --identifier myjob.sh`); cron mails only when `myjob.sh` exits non-zero. Pass `--log FILE` (or set `SHUSHRUN_LOG`) to write a file instead — there is no default file. The default journald sink needs no dependency (shushrun writes journald's native socket directly); it prefers the `python3-systemd` or `cysystemd` binding when one is installed, and `shushrun --help` shows which sink is in use. See [Design decisions](#design-decisions).

## Environment variables

Every option has an environment-variable equivalent, named `SHUSHRUN_<OPTION>` — set a default once (in a cron file, a systemd unit's `Environment=`, or a shell profile) instead of repeating flags on every invocation. An explicit command-line flag takes precedence over its variable.

| Variable | Option | Example value |
|---|---|---|
| `SHUSHRUN_READER` | `--reader` | `poll` or `threads` |
| `SHUSHRUN_LOG` | `--log` | `/var/log/myjob.log` |
| `SHUSHRUN_LOG_EMOJI` | `--log-emoji` | `1` (also `true` / `yes` / `on`) |
| `SHUSHRUN_LOG_STDOUT_PREFIX` | `--log-stdout-prefix` | `O> ` |
| `SHUSHRUN_LOG_STDERR_PREFIX` | `--log-stderr-prefix` | `E> ` |
| `SHUSHRUN_REPLAY_MAX_BYTES` | `--replay-max-bytes` | `262144` (`0` = exit status only, `-1` = no cap) |
| `SHUSHRUN_STATS` | `--stats` | `1` |

## Example

`demo` — a job that prints progress to stdout, a warning to stderr, and exits with the code it is given:

```sh
#!/bin/sh
echo "syncing 42 records"
echo "warning: connection retried once" >&2
echo "sync complete"
exit "${1:-0}"
```

Success is silent — a stderr warning alone produces no output, so cron stays quiet:

```console
$ shushrun --log demo.log --log-emoji -- ./demo 0
$ echo $?
0
```

Failure replays both streams and re-exits with the command's code:

```console
$ shushrun --log demo.log --log-emoji -- ./demo 1
syncing 42 records
sync complete
warning: connection retried once
./demo 1: exited with status 1
$ echo $?
1
```

(The streams come out grouped rather than interleaved: this three-line job finishes faster than the reader can interleave them — the best-effort ordering discussed below. Within each stream the order is always exact.)

Both runs are recorded — to journald by default, or to a file with `--log`. With `--log-emoji` (opt-in, used here) stdout lines are marked `🗒` and stderr `⚠`:

```console
$ cat demo.log
# 2026-06-16T10:27:15+07:00 exit 0 cmd: ./demo 0
🗒 syncing 42 records
🗒 sync complete
⚠ warning: connection retried once
# 2026-06-16T10:27:15+07:00 exit 1 cmd: ./demo 1
🗒 syncing 42 records
🗒 sync complete
⚠ warning: connection retried once
```

## stdbuf matters

A C/libc program block-buffers stdout (4 KB) when it's a pipe but leaves stderr unbuffered, so stderr races ahead and the captured order is wrong before `shushrun` ever sees it. Prepend `stdbuf --output=L --error=L` to line-buffer the source. It's best-effort: no effect on Go, static binaries, or programs that set their own buffering.

## The readers, and a concurrency note

`--reader poll` (default) is a single thread running `select.poll()` over both pipes. `--reader threads` runs one thread per stream. They produce equivalent results for any normal (non-CPU-bound) producer.

There is **no meaningful GIL vs free-threaded difference here.** A blocking read releases the GIL in CPython, so two reader threads already run their reads concurrently on a stock build; free-threading only parallelizes the nanosecond-scale Python work (append under a lock), which is never the bottleneck. This is an I/O-bound problem, and the GIL already steps out of the way for I/O. The single `poll` loop is simpler, has no lock, and is the one to use.

`--reader threads --stats` makes this observable: it prints the live `gil` state and the captured line count; between a stock `python3` and a free-threaded `python3.14t` the `gil` flag flips `True`/`False` while the captured order barely moves.

## The fundamental limit: order vs distinction

Exact cross-stream order and the stdout/stderr distinction cannot coexist. Once the command's two `write()` calls land in two separate kernel pipe buffers, nothing records which happened first — a reader cannot recover it, no matter how many CPUs it has. The only way to preserve exact order is to merge into one stream (`2>&1`), which is exactly what discards the distinction.

So `shushrun` keeps the distinction and accepts **best-effort** cross-stream order (exact within each stream, always). For a normal logger that's effectively perfect; it only clumps under sustained, simultaneous high-rate output on both streams.

## Similar tools

`shushrun` sits in a well-trodden space; if it isn't the right fit, one of these likely is. Install lines are Debian/Ubuntu.

### chronic — `apt install moreutils`

[moreutils](https://joeyh.name/code/moreutils/) · [chronic(1)](https://manpages.debian.org/chronic)

The closest match: run a command, show its output only if it fails, keeping stdout/stderr on their own fds. It does not interleave (it buffers each stream and prints stdout then stderr) and keeps no log. For quiet-unless-failed alone, with no log needed, this is the first choice — it's tiny and packaged everywhere.

```console
$ chronic ./demo 0      # exit 0 (even with a stderr warning) -> silent
$ chronic ./demo 1      # failure -> dump, exit 1
syncing 42 records
sync complete
warning: connection retried once
```

### cronic — `apt install cronic`

[cronic](https://habilis.net/cronic/) — a classic shell script for taming cron spam. It treats a non-zero exit **or** any stderr as failure and prints a sectioned report. Policy difference worth noting: a stderr warning on an otherwise-successful run *does* trigger output, where `shushrun` and `chronic` stay silent.

```console
$ cronic ./demo 0       # exit 0 but stderr present -> reported anyway
Cronic detected failure or error output for the command:
./demo 0

RESULT CODE: 0

ERROR OUTPUT:
warning: connection retried once

STANDARD OUTPUT:
syncing 42 records
sync complete
```

### annotate-output — `apt install devscripts`

[annotate-output(1)](https://manpages.debian.org/devscripts/annotate-output.1.html) — not quiet-unless-failed; it *always* prints, prefixing every line with a timestamp and `O:`/`E:` so the streams stay distinguishable in one merged stream, in arrival order. Complementary to `shushrun`, and subject to the same best-effort ordering (here it happened to read stderr first):

```console
$ annotate-output ./demo 0
10:27:15 I: Started ./demo 0
10:27:15 E: warning: connection retried once
10:27:15 O: syncing 42 records
10:27:15 O: sync complete
10:27:15 I: Finished with exitcode 0
```

`ts` (also `apt install moreutils`) timestamps arbitrary lines and pairs well with these. In the systemd world, a timer unit with `OnFailure=` plus `journalctl` is the non-wrapper alternative: journald records each line's stream and order, with alerting driven from the `OnFailure` handler.

## Tests

`tests/test_shushrun.py` (pytest) is two tiers:

- **Hard invariants (gate the build):** silence on success, split replay on failure, the replay cap eliding only the middle, exit-code propagation, missing-command → 127, and the deterministic ordering guarantee — within-stream order is exact and no line is lost — each run against both readers.
- **Measurements (do not gate):** cross-stream `max_run` / mean displacement. These are scheduling-dependent with no fixed correct value, so they are `xfail(strict=False)` and recorded via `record_property` — they run and report their real numbers, but neither outcome fails the suite. (`xfail` is isolated to the flaky metric so it can't mask a real regression in the hard tier.)

```sh
pytest                                 # green; flaky measurements never break it
pytest -rxX -s -k interleave           # see the recorded numbers and XFAIL/XPASS
uv run --with pytest --python 3.13 pytest    # GIL build
uv run --with pytest --python 3.14t pytest   # free-threaded; gil flips, suite stays green
SHUSHRUN_BIN=/usr/local/bin/shushrun pytest   # point at an installed binary
```

## Visualizing the reordering

`tests/visualize_ordering.py` runs the producer (whose sent order is known to be strict `o,e,o,e,…`) through `shushrun` and prints the sent (`TX`) and received (`RX`) sequences as aligned, terminal-width-wrapped line pairs. Out-of-place characters are shown in UPPERCASE (lowercase = correct place), so the marking survives copy-paste; with colour on they also get a red background.

```sh
python3 tests/visualize_ordering.py --reader threads --delay-us 1000   # best-effort: scattered swaps
python3 tests/visualize_ordering.py --reader threads --delay-us 0      # bursty: order collapses
python3 tests/visualize_ordering.py --reader poll    --delay-us 1000   # single reader: exact here
```

## Design decisions

Why things are built this way — so a future reader can tell a deliberate choice from an accident, and knows what would justify changing it. Each entry has a **Revisit if**: when that becomes true, change the decision rather than carrying it forward unexamined.

### journald sinks: native socket by default, bindings optional

`record_to_journald` logs to journald through the best of three sinks, tried in order, using the first available. `shushrun --help` prints the list, marks the one chosen, and gives the reason any are not available:

1. **`systemd.journal`** — the canonical binding: the `python3-systemd` distro package (`apt install python3-systemd`) or the `systemd-python` PyPI sdist (source-only — needs `libsystemd-dev` + a C compiler). The `shushrun[systemd]` extra pulls the latter.
2. **`cysystemd`** — a separate PyPI implementation with the same `journal.send(MESSAGE=…, PRIORITY=…, SYSLOG_IDENTIFIER=…)` API that ships prebuilt wheels, so `pip`/`uv` install it with no toolchain. The `shushrun[cysystemd]` extra pulls it.
3. **The native socket** (stdlib) — with no binding installed, shushrun writes journald's submission protocol directly to `/run/systemd/journal/socket`: framed `KEY=VALUE` datagrams, length-prefixed for any value containing a newline. This is the default and needs nothing — no dependency, no wheel, no compiler — so `uvx shushrun` / `pip install shushrun` works on any interpreter. That is why the bindings are *optional* extras, not hard requirements. `AF_UNIX` datagram sends are reliable (they block rather than drop when journald is busy).

Bindings rank first because they are maintained and handle one case the socket writer does not — a single message larger than the socket buffer, which a binding sends via a sealed memfd. The socket is the zero-dependency floor that makes the tool installable anywhere journald runs.

**Revisit if:** a single log line can exceed the datagram limit in practice — add the memfd path to the socket writer, or document installing a binding. If `systemd-python` starts shipping wheels, fold it and `cysystemd` into one recommended extra.

### journald is the default destination (file is opt-in)

With no flags, `shushrun -- cmd` logs to journald; `--log FILE` (or `SHUSHRUN_LOG`) writes a file instead. There is intentionally no "log nowhere" default. The point is to drop in front of an existing command with zero ceremony, and journald is the right default on a systemd host: structured, queryable (`journalctl --identifier <cmd>`), and it carries the stream in the entry priority for free.

**Revisit if:** it's often run on non-systemd hosts and the missing-binding warning becomes noise — then auto-detect journald and fall back to silence, or make the default configurable.

### No default log file path

`--log` has no built-in default; only an explicit `--log` or `SHUSHRUN_LOG` produces a file. An earlier version defaulted to `~/shushrun.log`, which scatters stray, never-rotated files wherever the tool runs, under whatever user invoked it. Opt-in only.

**Revisit if:** never, probably — a surprise file per host has no upside.

### File prefixes: plain by default, emoji via `--log-emoji`, or custom

`--log` files are plain by default; with `--log-emoji`, stdout lines are prefixed `🗒 ` and stderr `⚠ ` (journald distinguishes streams by PRIORITY regardless). Off by default because per-line emoji are a matter of taste — opinionated output shouldn't be forced on everyone. The emoji are only defaults — `--log-stdout-prefix STR` / `--log-stderr-prefix STR` override either prefix (used verbatim; pass `''` to suppress one), so anyone who prefers ASCII (`O: ` / `E: `) or other glyphs sets them without touching the code. As for the prefix glyph itself: a `[stderr] ` text prefix is verbose and marks only one stream. A hidden / zero-width per-line prefix was considered and rejected: invisible marking defeats the prefix's only purpose (a human can't see the stream), and zero-width bytes silently break `grep`, `wc`, copy-paste, and editors that strip "weird" characters. The prefixes are bare codepoints (U+1F5D2, U+26A0); append U+FE0F to force `⚠` to emoji width so the two columns align.

**Revisit if:** machine-parseable file logs are needed (use journald, or emit JSON lines). Emoji rendering badly is now a runtime fix, not a code change — pass ASCII via `--log-stdout-prefix` / `--log-stderr-prefix`.

### Keep the stdout/stderr distinction; accept best-effort cross-stream order

`shushrun` preserves within-stream order exactly and cross-stream order best-effort. Exact order and the distinction can't coexist: once a process's two `write()`s land in separate kernel pipe buffers, nothing records which came first, so no reader can recover it at any CPU count; merging (`2>&1`) restores exact order but destroys the distinction.

**Revisit if:** a consumer needs exact order more than the split — merge the streams with `2>&1` (losing the distinction) rather than changing this.

### Default reader is the single `poll` loop; `threads` is a teaching aid

`--reader poll` (one thread, `select.poll`) is the default; `--reader threads` exists but is documented as a lesson, not a speedup. This is I/O-bound, and in CPython a blocking read releases the GIL, so two reader threads already read concurrently on a stock build — and a free-threaded build only parallelizes the nanosecond-scale append. Measured: GIL vs free-threaded changes nothing here. poll is also simpler (no lock, no shared state) and deterministic.

**Revisit if:** the reader ever becomes CPU-bound (it won't for line-tagging).

### `stdbuf` is the caller's job, not baked in

The tool doesn't auto-prepend `stdbuf --output=L --error=L`; it's documented for the caller to add when cross-stream order matters. `stdbuf` only affects libc stdio buffering — no effect on Go, static binaries, or programs that set their own — and it silently changes the child's behavior, so baking in a best-effort, sometimes-no-op change would be surprising.

**Revisit if:** only libc programs are ever wrapped and automatic line-buffering would be preferred.

### The tool runs the command; it is not a pipe filter

Usage is `shushrun -- cmd`, not `cmd | shushrun`. A pipe gives the downstream tool only stdout (stderr bypasses it), and a shell pipeline's exit status is the *last* command's — so stderr would never reach journald or the mail, and "mail only on non-zero exit" could never fire. Running the command is the only way to capture both streams and propagate the real exit code.

**Revisit if:** never — this is structural, not a preference.

### Capture fully, then record/replay after the command exits

Output is buffered per line in memory; journald/file writes and the failure replay happen after the command exits. This decouples logging from the job — a journald restart, a full disk, or a slow sink can't break, block, or SIGPIPE the wrapped command (a real risk with `cmd | tee | systemd-cat`). The cost: a run's journald entries appear at completion, not live.

**Revisit if:** a long job needs live progress — then stream through `systemd-cat`, accepting the coupling and SIGPIPE caveat.

### shushrun puts *itself* inside the transient unit, and there is no option for it

On a systemd host shushrun re-executes itself under `systemd-run` and runs the command from in there, so both processes land in one cgroup. Wrapping only the command would have been simpler, and wrong: shushrun holds the entire captured run in memory, so it is the half most worth accounting for and limiting, and leaving it outside would measure the wrong process. That is also why no library is used — [pystemd](https://pypi.org/project/pystemd/) can create a transient unit, but nothing can move an already-running process into one, so re-executing is the mechanism either way and `systemd-run` needs no dependency, no wheel and no compiler.

The figures then come from the cgroup itself — `cpu.stat` and `memory.peak` under `/sys/fs/cgroup`, read at the end of the run — not from `systemctl show`. systemd turns accounting on by default, so nothing has to be requested, nothing has to be kept alive with `RemainAfterExit` to be read afterwards, and there is nothing to clean up.

Whether shushrun is already in a unit is decided by `INVOCATION_ID`, which systemd sets in the environment of every unit it starts. One check answers both questions: do not wrap what is already wrapped, and only report cgroup figures when they describe this run alone. It costs no invented variable, and it means a shushrun started from a timer or a service is left alone and still gets its figures.

Every option on the `systemd-run` command line is load-bearing: `--pipe` keeps stdin, stdout and stderr on their own file descriptors (`--pty` would merge the two output streams, the one thing this tool exists to avoid) and propagates the exit status; `--quiet` keeps systemd-run's own `Running as unit …` line out of the captured stderr; `--collect` removes the unit even when the run fails, which it otherwise leaves in `systemctl --failed` for good; and `--working-directory` plus one `--setenv` per variable are there because systemd-run otherwise hands the command the service manager's environment and working directory, silently changing what a job sees.

It is skipped, silently, when systemd is not running, `systemd-run` is not installed, the interpreter cannot say where it lives, or there is no manager to take the request — root talks to the system manager, everyone else to their own, because system scope wants interactive polkit authentication from a non-root caller and a cron job has nobody to answer it. `--user` needs the user bus, which exists only while that user has a session or `loginctl enable-linger` is set for them. None of this is worth a line of output on a host that can never satisfy it; `--stats` reports `systemd-run=yes|no` for anyone who wants to know.

**Revisit if:** a job needs resource *limits* rather than accounting — the properties (`MemoryMax=`, `CPUQuota=`) would need a way through, which is the first real argument for an option here.

### The failure replay is capped by default, at one merged budget

`replay_on_failure` writes at most `--replay-max-bytes` (default 262144): the first and last half of the output, with the middle elided and replaced by a line naming how many lines and bytes were dropped and where the whole run is — the `--log` file, or the `journalctl --identifier <basename>` command for the journald default. `0` is a real budget of zero, mailing nothing but that marker and the exit status; `-1` (any negative) removes the cap.

The cap exists because the replay is what cron mails, and mail that is too big can be *silently* discarded — no bounce, nothing in the sender's logs, so a failure alert becomes no alert at all. Measured against Slack's email-to-channel integration on 2026-08-01: its inbound endpoint answers `250 OK` and then drops the message when the HTML file Slack renders from it exceeds 1 MiB (1048576 bytes). Plain text expands ≈2.05× when rendered (measured at both 80-byte and 990-byte line lengths), and the boundary is exact and reproducible — 6312 lines of 80 bytes post, 6313 do not. 256 KiB leaves the rendered mail at about half that ceiling.

On by default, because silent loss is the failure it prevents and an opt-in cap only protects those who already knew to ask for one. One merged budget over both streams rather than one each, because what it has to bound is the size of a single mail and a mail carries both. Nothing is lost: only the replay is capped, and the full run is always recorded to journald or `--log`.

Two consequences worth knowing:

- **Lines are kept whole.** The budget never falls in the middle of a line — a line that does not fit ends that side of the excerpt instead of being cut, so a single line longer than the whole budget is elided entirely and its side can come out empty. It is still counted in the marker, so an output that is one enormous line mails the marker alone rather than a fragment. The head is offered half the budget and the tail gets what the head left, then the head gets back what the tail could not spend either, so the excerpt is as long as the line lengths allow wherever the long lines happen to sit.
- **The budget counts the replayed output only.** The elision marker and the `exited with status N` line are written on top of it, so the mail is up to about 200 bytes larger than `--replay-max-bytes`.

**Revisit if:** the receiver's ceiling changes, or a consumer needs the whole replay in the mail — raise it or pass `--replay-max-bytes -1`.

### Tests: invariants gate the build; ordering measurements don't

The pytest suite hard-asserts the deterministic guarantees (silence on success, split replay, exit-code propagation, within-stream order, no lines lost). Cross-stream ordering numbers are `xfail(strict=False)` and recorded, never gating — they're scheduling-dependent with no fixed correct value, so asserting a specific number would be flaky and would dress a best-effort property up as a guarantee. The `xfail` is isolated to the measurement so it can't mask a regression in the invariants.

**Revisit if:** an ordering property becomes deterministic (it won't, short of merging the streams).

## TODO

- **Don't break silence-on-success when journald is unreachable.** With no binding installed *and* no journald socket (a non-systemd host), the native-socket write fails and shushrun warns to stderr on every run — which a successful cron job would then mail. Detect an absent `/run/systemd/journal/socket` and fall back to a file, or to silence, instead of warning.
- **Bound memory on huge output.** The whole run is buffered in memory before it is recorded and replayed — fine for normal logs, but a job that emits gigabytes uses that much RAM. Optionally spill to a temp file past a size threshold. Related: a single buffered line larger than the journald socket's datagram limit is dropped by the socket sink — the sealed-memfd path a binding provides would carry it.

## Releasing

shushrun publishes to PyPI from GitLab CI via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC): no API token is stored anywhere — PyPI mints a one-shot upload token from a short-lived GitLab id_token. To cut a release, bump `version` in `pyproject.toml`, commit, and push a matching tag:

```sh
git tag v1.0.1 && git push origin v1.0.1
```

The tag pipeline (`.gitlab-ci.yml`) builds the sdist + wheel and uploads them. The trusted publisher is registered on PyPI for namespace `paulto`, project `shushrun`, pipeline `.gitlab-ci.yml`, environment `pypi`.

## License

[GNU Affero General Public License v3.0 or later](LICENSE). Copyright (C) 2026 Paul Tobias. The Affero clause is the point: anyone who runs a modified shushrun as a network service must offer its source to those users — copyleft the SaaS loophole can't route around.
