Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# The image is built from pyproject.toml, README.md, LICENSE, src/ and
# alembic.ini. Everything else stays out of the build context, and .env above
# all: configuration reaches a container through its environment, never a file
# copied into a layer.
.git
.github
.claude
.context
.env
.env.*
.venv
venv
.mypy_cache
.pytest_cache
.ruff_cache
**/__pycache__
*.pyc
build
dist
site
docs
tests
*.db
50 changes: 50 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# One image for every Hymical Forms process. The command decides which one runs:
#
# (default) the API, on port 8000
# python -m hymical_forms.worker the delivery worker
# alembic upgrade head apply migrations, then exit
# python -m hymical_forms.cli ... operator commands (keys, retention cleanup)
#
# Configuration comes from FORMS_* environment variables only. Nothing secret is
# baked into the image. See docs/operations/deployment.md.

FROM python:3.13-slim AS build

WORKDIR /src
# Only what the wheel is built from, so a change to the docs or tests does not
# invalidate this layer.
COPY pyproject.toml README.md LICENSE ./
COPY src ./src
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /wheels .


FROM python:3.13-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1

# A fixed numeric UID, so a Kubernetes runAsNonRoot check can verify it without
# resolving a name.
RUN groupadd --system --gid 10001 forms \
&& useradd --system --uid 10001 --gid forms --no-create-home \
--home-dir /app --shell /usr/sbin/nologin forms

WORKDIR /app

COPY --from=build /wheels /wheels
RUN pip install /wheels/*.whl && rm -rf /wheels

# Alembic looks for its ini file in the working directory. It names the
# migrations by package, so the ones shipped inside the installed wheel are used.
COPY alembic.ini ./

USER 10001:10001

EXPOSE 8000

# --no-proxy-headers: uvicorn would otherwise rewrite the client address from
# X-Forwarded-For when the peer is 127.0.0.1, and FORMS_TRUSTED_PROXY_HOPS is
# meant to be the only thing that decides whether that header is believed.
CMD ["uvicorn", "hymical_forms.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-proxy-headers"]
90 changes: 90 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# An example deployment: PostgreSQL, a one-shot migration, the API and one
# worker. A starting point to adapt, not a hardened production stack. See
# docs/operations/deployment.md.
#
# POSTGRES_PASSWORD=... docker compose up -d --build
#
# The password ends up inside FORMS_DATABASE_URL, so if it contains characters
# that are special in a URL (@ : / % ? #), percent-encode them or pick one that
# does not.

x-forms-environment: &forms-environment
FORMS_DATABASE_URL: postgresql+psycopg://forms:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/forms

services:
postgres:
image: postgres:17
environment:
POSTGRES_USER: forms
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}
POSTGRES_DB: forms
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "forms", "-d", "forms"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
# Deliberately no published port: only the services below need to reach it.

# All three application services share one image, built from this checkout.
# Each declares the build so that none of them tries to pull the tag instead.

# Applies migrations and exits. The API and the worker wait for it to finish
# successfully, and would refuse to start against an unmigrated schema anyway.
migrate:
build: .
image: hymical-forms:local
command: ["alembic", "upgrade", "head"]
environment:
<<: *forms-environment
depends_on:
postgres:
condition: service_healthy
restart: "no"

api:
build: .
image: hymical-forms:local
environment:
<<: *forms-environment
# Uncomment and set to the real number of proxies once one stands in front
# of the API. Never higher. See docs/operations/reverse-proxy.md.
# FORMS_TRUSTED_PROXY_HOPS: "1"
ports:
# Bound to loopback, so the API is reached through a reverse proxy on this
# host rather than exposed directly.
- "127.0.0.1:8000:8000"
depends_on:
migrate:
condition: service_completed_successfully
healthcheck:
# Liveness only: /health does not touch the database.
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)"
interval: 10s
timeout: 5s
retries: 3
restart: unless-stopped

worker:
build: .
image: hymical-forms:local
command: ["python", "-m", "hymical_forms.worker"]
environment:
<<: *forms-environment
depends_on:
migrate:
condition: service_completed_successfully
# SIGTERM lets the worker finish the batch it holds, which takes up to one
# attempt deadline (connect plus read timeout, 15 seconds by default). Docker's
# own default of 10 seconds would kill it part way through.
stop_grace_period: 30s
restart: unless-stopped

volumes:
postgres-data:
4 changes: 3 additions & 1 deletion docs/api/submissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,9 @@ Reports that the API process is running.

This is a liveness signal only. It does not check the database, so it stays
answerable while the database is down, which is what makes it useful for deciding
whether to restart the process. It is not a readiness check.
whether to restart the process. It is not a readiness check, and there is no
separate readiness route. See
[Deployment](../operations/deployment.md#health-checks).

| Status | Cause |
| --- | --- |
Expand Down
5 changes: 3 additions & 2 deletions docs/architecture/delivery-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,9 @@ It does not remove the window, and set too low it **creates** duplicates:
another worker can claim a delivery that is still in flight.

The lease must comfortably outlast `FORMS_WEBHOOK_CONNECT_TIMEOUT_SECONDS` and
`FORMS_WEBHOOK_READ_TIMEOUT_SECONDS` combined. The defaults leave a wide margin,
60 seconds against 15. See [Worker](../operations/worker.md).
`FORMS_WEBHOOK_READ_TIMEOUT_SECONDS` combined, which is the overall deadline every
attempt is held to. The defaults leave a wide margin, 60 seconds against 15. See
[Worker](../operations/worker.md).

## Two attempt counters

Expand Down
1 change: 1 addition & 0 deletions docs/development/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,4 +135,5 @@ Guidelines that keep the site coherent:
## Related

- [Testing](testing.md)
- [Releasing](releasing.md)
- [Architecture overview](../architecture/overview.md)
171 changes: 171 additions & 0 deletions docs/development/releasing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Releasing

A release is a deliberate act by a maintainer, not something CI or an agent does
on its own. This page is the checklist that act follows, so that the version a
build reports, the tag it was cut from, and the release notes that describe it
all agree.

## Where the version lives

There is exactly one place the version is written down:

```python
# src/hymical_forms/__init__.py
__version__ = "0.2.0"
```

Everything else reads it. `pyproject.toml` declares the version as dynamic and
Hatch takes it from that file, and the running service reports it in three
places:

- the `GET /health` response (`version`)
- the OpenAPI document (`info.version`)
- the `User-Agent` of every outbound webhook (`Hymical-Forms/<version>`)

A tag that points at a commit whose `__version__` disagrees with it produces a
build that misreports itself in all three. That is what the checklist below is
for.

## Checklist

Work through it in order, on a release branch off an up-to-date `main`.

### 1. Decide the version

Pick the next version from what changed since the last tag:

```bash
git log --oneline "$(git describe --tags --abbrev=0)"..main
```

A fix-only release bumps the patch number. Anything that changes the webhook
payload or the `Hymical-Signature` header is a breaking change to every receiver
and must not be slipped into a patch release.

### 2. Bump the version

Edit `src/hymical_forms/__init__.py`, then update every place the documentation
quotes the version literally:

```bash
grep -rn "<old version>" docs README.md src
```

`docs/api/submissions.md` shows a sample `/health` body with the version in it.
Old release notes keep the version they describe; do not rewrite history there.

### 3. Write the release notes

Add `docs/releases/v<version>.md`, following the shape of the previous one:
what the release is, what changed, what was verified, and the limitations worth
knowing before deploying it. Then add it to the `Release Notes` section of the
`nav:` in `mkdocs.yml`, newest first, or a strict docs build will fail on an
omitted page.

If the release contains a migration, say so, name the revision, and say whether
it is safe to apply while the previous build is still running. The default answer
is no: [deploy order](../operations/deployment.md#deploy-order) is stop, migrate,
start.

### 4. Verify

Every one of these must pass on the release branch:

```bash
pytest
ruff check .
ruff format --check .
mypy
```

The PostgreSQL integration suite, against a real disposable database. A release
is never cut on the fast suite alone: it is the only suite that exercises row
locking, `SKIP LOCKED` claiming, constraint races and the migrations themselves.

```bash
export HYMICAL_TEST_POSTGRES_URL=postgresql+psycopg://forms:forms@localhost:5432/forms_test
alembic -x database_url="$HYMICAL_TEST_POSTGRES_URL" upgrade head
pytest tests/integration -m postgres
```

The documentation:

```bash
pip install -e ".[docs]"
mkdocs build --strict
```

The packages, from a clean tree, and the container image:

```bash
pip install build
python -m build
docker build -t hymical-forms:<version> .
```

Install the wheel into a fresh virtual environment and confirm it reports the
version you are about to tag:

```bash
python -c "import hymical_forms; print(hymical_forms.__version__)"
```

### 5. Merge

Open a pull request for the release branch and merge it once CI is green. CI runs
the fast suite, the PostgreSQL suite, the linters and the strict docs build, so a
green pull request repeats step 4 on a machine other than yours.

### 6. Tag the merge commit

Tag the commit that landed on `main`, not a commit on the release branch, and
check the version at that exact commit before pushing the tag:

```bash
git switch main
git pull
git show HEAD:src/hymical_forms/__init__.py | grep __version__
git tag -a v<version> -m "v<version>"
git push origin v<version>
```

The tag name is `v` followed by exactly the string in `__version__`. Use an
annotated tag (`-a`), so the tag records who cut it and when.

A pushed tag is never moved or deleted. If a tag turns out to be wrong, the fix is
a new release with a new version, not a retag: someone may already have built
from the old one.

### 7. Publish

Publishing a GitHub release, a package or an image is a separate decision from
tagging, and is made by a maintainer. Nothing in CI publishes on a tag.

## Known discrepancy: v0.2.1

The checklist above exists because the `v0.2.1` tag did not follow it.

| | `v0.2.0` | `v0.2.1` |
| --- | --- | --- |
| Commit | `8e99004` (release prep merge) | `235f8af` (delivery claim fencing merge) |
| `__version__` at that commit | `0.2.0` | `0.2.0` |
| Release notes | [v0.2.0](../releases/v0.2.0.md) | none |
| Tag type | lightweight | lightweight |

So a build from the `v0.2.1` tag reports itself as `0.2.0` in `/health`, in
OpenAPI and in its webhook `User-Agent`, and there is no page describing what
`v0.2.1` changed. What it does contain over `v0.2.0` is one change: delivery claim
fencing, which stops a worker whose lease has expired from overwriting the state
recorded by the worker that reclaimed the delivery (see
[Delivery semantics](../architecture/delivery-semantics.md#who-owns-a-claim)).

The tag has already been pushed, so it is not moved or rewritten. The next
release resolves the discrepancy by taking a version above `0.2.1`, bumping
`__version__` to it, and having its release notes cover everything since
`v0.2.0`, the fencing change included.

## Related

- [Contributing](contributing.md)
- [Testing](testing.md)
- [Deployment](../operations/deployment.md)
4 changes: 3 additions & 1 deletion docs/guides/idempotency.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ winner, not a check in application code. See
[Concurrency](../architecture/concurrency.md).

A replay reports the same `delivery.queued` as the original and does **not**
queue a second delivery. One submission owes at most one delivery, enforced by a
queue a second delivery. It describes the stored submission, so adding or
removing the endpoint's webhook after the original request does not change what a
replay reports. One submission owes at most one delivery, enforced by a
unique constraint on the submission.

## Conflict semantics
Expand Down
11 changes: 9 additions & 2 deletions docs/guides/webhooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,15 @@ Do not enable it in production.
| `FORMS_WEBHOOK_CONNECT_TIMEOUT_SECONDS` | `5` | Wait for the destination to accept a connection |
| `FORMS_WEBHOOK_READ_TIMEOUT_SECONDS` | `10` | Wait for the destination to respond |

Both must stay comfortably below `FORMS_WORKER_LEASE_SECONDS`, or another worker
could claim a delivery that is still in flight. See [Worker](../operations/worker.md).
Each of these bounds a single wait, so on top of them every attempt has an overall
deadline of the two combined (15 seconds by default). A destination that answers
one byte at a time cannot hold a request open past it; such an attempt is
recorded as a `timeout` and retried. Only the response status is used: the
response body is never read.

The deadline must stay comfortably below `FORMS_WORKER_LEASE_SECONDS`, or another
worker could claim a delivery that is still in flight. The worker logs a warning
at startup if it does not. See [Worker](../operations/worker.md).

## Related

Expand Down
Loading
Loading