# Contributing Thank you for considering contributing to **volumen** — a small, file-based Markdown blog engine written in Python, with a built-in admin and a public JSON API. ## Development Setup ### Requirements - **Python ≥ 3.14** with the `venv` module (the venv itself is managed by `uv`). - Fedora: `sudo dnf install python3.14 python3-virtualenv` - Debian/Ubuntu: `sudo apt install python3.14 python3-venv python3-pip` - **[`uv`](https://docs.astral.sh/uv/)** — the package manager. The project pins every dependency (including transitive ones) in `uv.lock`; install with `curl -LsSf https://astral.sh/uv/install.sh | sh` (or via your distro). - [`just`](https://github.com/casey/just) — the `justfile` is the source of truth for install / run / build / test / uninstall. - Linux, macOS, or FreeBSD. ### Quick Start ```bash git clone https://sourcedock.dev/petrbalvin/volumen.git cd volumen just install # uv sync just test # uv run pytest just build # ruff check + ruff format --check (zero warnings) just dev # run against ./posts and ./config.toml on :9090 ``` `just dev` serves the bundled sample posts and a local `config.toml`. The dev admin password is `admin` (the hash lives in `./config.toml`). Open . For a fresh `serve` (without `--config`), the first run writes a commented template to `/etc/volumen/config.toml`; see [`docs/deployment.md`](docs/deployment.md) for the full production bootstrap (`uv tool install volumen && sudo volumen init`). ### Running Tests ```bash just test ``` The pytest suite (`tests/`) covers: - `frontmatter` — `+++` TOML block parse/serialise round-trips. - `post` / `store` — the Post model, reading/writing posts and media. - `markdown` — GFM-ish rendering (tables, fenced code, `
`). - `config` — defaults, overrides, template generation. - `password` / `users` — scrypt hashing, roles, last-admin safeguards. - `server` / `admin` — the JSON API and the admin (auth, CSRF, CRUD, uploads, roles) via `httpx.AsyncClient` against the FastAPI app. ### Linting Ruff is the source of truth for style (settings in `pyproject.toml`, `[tool.ruff]`, `[tool.ruff.lint]`). The project enforces **zero warnings**: ```bash just build # ruff check + ruff format --check just fmt # ruff format + ruff check --fix (autocorrect) ``` `just build` is what CI runs; both steps must exit 0 with no diff in `--check` mode. ## Code Style - Idiomatic Python 3.14+; let Ruff guide you. CI fails on any warning. - **4-space** indentation, **double quotes**, lines `≤ 100` characters (set in `[tool.ruff]`). - Type hints on every public function signature; `from __future__ import annotations` at the top of every module. - Prefer the standard library. New runtime dependencies belong in `pyproject.toml`'s `dependencies` array and should be discussed in an issue first. - Prefer `httpx` for HTTP tests; never start a real network server in tests. - Admin content is authored by trusted users; see [`docs/security.md`](docs/security.md) for the trust model before touching rendering or uploads. ### Testing - Put tests in `tests/test_*.py`; use `pytest` fixtures (see `tests/conftest.py`) for the temp content directory and the FastAPI app. - Exercise the HTTP layer with `httpx.AsyncClient` driving the FastAPI app via `app=` in the `AsyncClient` constructor. - Name tests `test__`; cover both the happy and the error path. ## Commit Conventions We follow [Conventional Commits](https://www.conventionalcommits.org/). | Type | Use | |------------|----------------------------------------------| | `feat` | New feature | | `fix` | Bug fix | | `refactor` | Restructuring without behaviour change | | `docs` | Documentation | | `test` | Adding or updating tests | | `chore` | Maintenance, CI, tooling | | `perf` | Performance improvement | | `style` | Formatting / lint-only changes | | `security` | Hardening without a user-visible fix | ``` feat: add author role with last-admin safeguards fix: rename create_app to factory to avoid a FastAPI clash refactor: extract frontmatter parsing into its own module docs: document the dual-mode editor test: cover GFM table and fenced code rendering chore: pin Python dependencies to latest ``` - English, lowercase subject, imperative mood (`add`, not `added`). - No trailing period, max 72 characters. - No version numbers in commit messages — versions belong to tags. ## Pull Request Flow 1. Create a feature branch from `development`. 2. Make your changes with Conventional-Commits messages. 3. Record your changes under the `## [development]` section at the top of `CHANGELOG.md`, using the Keep a Changelog categories (`Added`, `Changed`, `Fixed`, `Removed`, `Security`). 4. Ensure `just build` reports zero warnings and `just test` passes. 5. Add or update tests for your change. 6. Update documentation if the public API, configuration, or behaviour changes: - `README.md` — high-level overview. - `docs/configuration.md` — config keys. - `docs/api-reference.md` — JSON API behaviour. - `docs/architecture.md` — structural changes. - `docs/security.md` — anything touching auth, uploads, or rendering. 7. Open a pull request against `development`. Releases are cut by merging `development` into `main` and pushing a `vX.Y.Z` tag — see [Cutting a release](#cutting-a-release). ## Project Structure ``` volumen/ ├── exe/ │ └── volumen # thin shim: from volumen.cli import main ├── src/ │ └── volumen/ │ ├── __init__.py # VERSION + login-attempt limiter │ ├── cli.py # argparse entry point: serve, hash-password, version │ ├── config.py # tomllib loader + deep-merge defaults │ ├── frontmatter.py # parse/serialise the +++ TOML frontmatter block │ ├── markdown.py # python-markdown + pymdown-extensions │ ├── post.py # Post model (metadata + body + rendered HTML) │ ├── store.py # read/write posts and media on disk (mtime-cached) │ ├── password.py # scrypt hashing/verification (hashlib + secrets) │ ├── users.py # users.toml + admin/author roles │ ├── feed_helpers.py # RSS / JSON Feed / sitemap generation │ ├── api_routes.py # /api/volumen/* (FastAPI router) │ ├── admin_routes.py # /admin/* + /media/* (FastAPI router) │ ├── server.py # FastAPI app factory, middleware, dependencies │ └── web/ │ ├── templates/ # Jinja2 templates (layout, login, list, form, settings) │ └── static/ # static assets (volumen-logo.svg, volumen-icon.svg) ├── tests/ # pytest suite ├── docs/ # architecture, configuration, api-reference, deployment, security - `.gitea/workflows/` # test.yml, release.yml, migration-guard.yml (Gitea Actions) ├── justfile # install, run, dev, lint, fmt, build, test, uninstall ├── pyproject.toml # PEP 621 metadata + dependencies + tool config ├── uv.lock # locked dependency graph ├── config.toml.example # bootstrap config template ├── CHANGELOG.md └── LICENSE ``` ## Architecture Overview ```mermaid graph TD Browser[Browser / Vue SPA] Nginx[nginx reverse proxy] App[FastAPI app -- Uvicorn] ApiR[api_routes.py] AdminR[admin_routes.py + CSRF + CSP] Store[Store] Markdown[Markdown renderer -- python-markdown] Users[Users] Posts[(content_dir/*.md)] UsersFile[(users.toml)] Browser -->|GET /api/volumen/*| Nginx Browser -->|/admin| Nginx Nginx --> App App --> ApiR App --> AdminR ApiR --> Store ApiR --> Markdown AdminR --> Store AdminR --> Users Store --> Posts Users --> UsersFile ``` ### Key Design Decisions - **File-based, no database** — every post is a `.md` file with TOML frontmatter; users live in a `users.toml`. Everything is plain text, safe to commit and back up. - **Headless for the front-end** — volumen serves a read-only JSON API and its own server-rendered admin; the public site is a separate consumer. - **Standard library first** — TOML parsing uses `tomllib` (3.11+), scrypt uses `hashlib.scrypt`, constant-time compares use `secrets.compare_digest`. No runtime TOML or crypto dependency is required. - **Trusted authors** — python-markdown passes raw HTML through on purpose; content is only as safe as who can log in (see `docs/security.md`). - **Roles, server-enforced** — `admin` manages users, `author` manages posts and their own account; `require_admin` (a FastAPI dependency) gates user management on the server, with last-admin safeguards. - **IPv6-first** — the default bind address is `::`; bind to `::1` when behind nginx. ## CI volumen uses **Gitea Actions**; the workflows live under `.gitea/workflows/` and run on the `fedora:latest` runner. ### Test — `.gitea/workflows/test.yml` Triggered on push and pull requests targeting `development`. | Step | What it does | |------|--------------| | Set up Python and uv | `astral-sh/setup-uv` with Python 3.14 | | Install dependencies | `uv sync --frozen --group dev` | | Lint | `uv run ruff check src/ tests/` | | Format | `uv run ruff format --check src/ tests/` | | Tests | `uv run pytest` | Run the same locally before opening a PR: ```bash just build just test ``` ### Release — `.gitea/workflows/release.yml` Triggered by pushing a `v*` tag. It verifies the tag matches the version in `pyproject.toml`, runs the quality gate, builds the wheel and sdist with `python -m build`, extracts the matching `CHANGELOG.md` section, creates a Gitea release via the REST API, uploads both artefacts, and publishes them to PyPI via `uv publish`. The auto-generated `${{ secrets.GITEA_TOKEN }}` is used to create the release (via the REST API). Only one secret is required: a `PYPI_TOKEN` (project-scoped upload token from ). ### Migration guard — `.gitea/workflows/migration-guard.yml` A grep guard that fails the build if any of the Ruby-only strings (`bundle exec`, `RuboCop`, `Sinatra`, `Puma`, `kramdown`, `gem build`, `Volumen::`, `Rack::Session::Cookie`, `OpenSSL::KDF`, `lib/volumen/`) appear in tracked files other than the explicitly allow-listed `CHANGELOG.md` and the guard workflow itself. ### Cutting a release 1. Bump `version` under `[project]` in `pyproject.toml`. 2. Rename `## [development]` to `## [X.Y.Z] — YYYY-MM-DD` at the top of `CHANGELOG.md`, and add a fresh empty `## [development]` section above it. 3. Ensure CI is green on `development`. 4. Merge `development` into `main` (the only time `main` changes outside a tag). 5. Tag and push: ```bash git tag -a vX.Y.Z -m "volumen vX.Y.Z" git push origin main git push origin vX.Y.Z ``` 6. The release workflow builds the wheel + sdist, publishes them to sourcedock.dev Releases **and** to PyPI (`uv publish --token "$PYPI_TOKEN"`). #### PyPI publishing token A PyPI API token must be configured in `Settings → Secrets and Variables → Actions` of the sourcedock.dev repository under the name `PYPI_TOKEN`. Create a **project-scoped token** at (scoped to `volumen`) rather than a global account token. Rotate the token at least yearly. The token must have the `upload` scope for the project. For local dry-runs and ad-hoc uploads, use the same token via the `UV_PUBLISH_TOKEN` env var or `uv publish --token "$PYPI_TOKEN"`. Never commit it. ## Report a Bug Open an issue at - volumen version (`uv run volumen version`). - Operating system and architecture. - Exact steps (request or admin action) that reproduce it. - Expected vs actual behaviour, and the relevant slice of `journalctl -u volumen`. For **security issues**, please email **opensource@petrbalvin.org** rather than opening a public issue.