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
12 changes: 5 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
name: CI

# No push trigger for `dev`: everything on dev travels through a pull
# request anyway, and a push run would twin the PR run on the same commit —
# under *required* checks, a deduplicating cancellation poisons the gate.
# `main` keeps its push trigger because a rebase-merge mints a new SHA that
# no PR run ever checked.
# No push trigger at all: everything travels through a pull request, and
# main only ever receives a SQUASH of a green PR the squashed tree is
# byte-identical to the PR head the checks already ran on, so a main
# re-run would test the same content twice. Merging runs release.yml
# alone; workflow_dispatch stays as the manual escape hatch.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

Expand Down
20 changes: 11 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,23 +95,25 @@ jobs:
if: needs.decide.outputs.release == 'true' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
# Trusted publishing: PyPI verifies THIS workflow's OIDC identity —
# no PYPI_TOKEN to store, leak or rotate. The `dynamic-config-py-web`
# project carries a Trusted Publisher entry on PyPI naming this
# repository and this file; OUTSTANDING.md spells the console entry.
id-token: write
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: distributions
path: dist
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- run: pip install twine
# `--skip-existing`: a version on PyPI is permanent, so the only way
# `skip-existing`: a version on PyPI is permanent, so the only way
# forward after a partial failure is a rerun that publishes the
# remainder. `release-web.sh --check` is what keeps that from being
# the normal case.
- run: twine upload --skip-existing dist/*
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
packages-dir: dist
skip-existing: true

tag-and-release:
name: tag and GitHub release
Expand Down
22 changes: 19 additions & 3 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ name: Security
# No push trigger for `dev` — same reasoning as ci.yml: dev travels through
# pull requests, and a push twin under required checks poisons the gate.
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "17 6 * * 1"
Expand All @@ -28,6 +26,24 @@ permissions:
contents: read

jobs:
policy:
name: the security policy names the current line
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
# The stale-table drift this org has already lived through once:
# SECURITY.md said 0.0.x while the workspace shipped 0.7. The table's
# top row must name the line that is actually published.
- run: |
version=$(grep -m1 '__version__' src/dynamic_config_web/__init__.py | cut -d'"' -f2)
line="${version%.*}.x"
if ! grep -qF "| ${line}" SECURITY.md; then
echo "::error file=SECURITY.md::the supported-versions table does not name ${line} (workspace is ${version})"
exit 1
fi
echo "SECURITY.md names ${line} — current"

osv:
name: advisories, every ecosystem
runs-on: ubuntu-latest
Expand Down Expand Up @@ -126,7 +142,7 @@ jobs:
# — which is the whole point of running it.
security-ok:
name: Security is green
needs: [osv, supply-chain, published]
needs: [policy, osv, supply-chain, published]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
Expand Down
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ for an adapter and an adapter fix should not drag the wheels behind it.

## [Unreleased]

## 0.2.1 — 2026-08-20

### Changed

- **`dynamic-config-py>=0.3.1` is the floor**: the base wheel's
failure events arrive natively there, and `stream_events` rides
them without a poll. The `failure_poll` parameter is accepted and
warns, exactly as the base wheel treats it.

### Changed

- **`stream_events` no longer polls.** A refused reload wakes the
stream natively (dynamic-config-py 0.3.1's failure hook), so
`ReloadFailed` events arrive when the refusal happens and nothing
runs on a timer. Requires `dynamic-config-py>=0.3.1`.

### Deprecated

- **`stream_events(failure_poll=...)`** is accepted, ignored, and
warns: the interval refusals were polled at, now that they wake the
stream themselves. Remove the argument.

## 0.2.0 — 2026-08-18

### Changed
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ add no cache, no copy and no second source of truth — `db.current()` is
still the read path, and `current()` here is that read taken once per
request.

What you may build on and find unchanged tomorrow is written down: the [Compatibility Contract](https://dynamic-config-rs.github.io/compatibility.html).

## License

MIT.
7 changes: 6 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@ workflow by hand.

## What an operator has to have ready

`PYPI_TOKEN` as a repository secret, with upload rights to this project.
**No token.** The publish job authenticates through PyPI's Trusted
Publishing (OIDC): the one-time console entry — PyPI →
`dynamic-config-py-web` → Settings → Publishing → *Add a trusted
publisher* — names owner `dynamic-config-rs`, repository
`dynamic-config-python-web`, workflow `release.yml`. A leftover
`PYPI_TOKEN` secret is inert and should be revoked.
Nothing else.

## Afterwards
Expand Down
20 changes: 12 additions & 8 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ the ones it explicitly does not.
`Debug`; reload diffs, `check()` reports, unknown-key suggestions and *error
messages* all report paths and types, never values, so nothing routes around the
redaction. A report of a value appearing in a log or an error message is a
vulnerability. `dynamic-config/tests/security.rs` asserts each of these, and CI
vulnerability. the engine repository's [`tests/security.rs`](https://github.com/dynamic-config-rs/dynamic-config) asserts each of these, and its CI
runs it as a job of its own.

**The parsing surfaces are fuzzed.** The unit parsers, `Value` path lookup and
the rule that decides whether a path touches a secret run under libFuzzer
(`fuzz/`, built on every CI run and run on a weekly schedule). A crash or a
(the engine repository's [`fuzz/`](https://github.com/dynamic-config-rs/dynamic-config), built on every CI run and run on a weekly schedule). A crash or a
redaction rule that answers differently for the same path is a finding: report
it through the process above, with the reproducing input attached.

Expand Down Expand Up @@ -68,12 +68,16 @@ with your privileges.

| Version | Supported |
|---|---|
| 0.0.x | ✅ the latest patch |
| < 0.0.1 | — nothing older exists |
| 0.2.x (0.2.1+) | ✅ the latest patch |
| 0.1 | — end of life |

Before 1.0, fixes land on the latest published version and nothing is
backported: there is no version old enough to be worth pinning to. After 1.0,
the current and previous minor versions.
Security fixes land on the **latest patch of the line above** and
nothing is backported before 1.0: when a release ships, every prior
patch of its line is end-of-life the same day. Older toolchains resolve
older published versions through cargo's MSRV-aware resolver and are
explicitly unsupported. After 1.0, the current and previous minor
lines. The full promise lives in the engine book's
[Compatibility Contract](https://dynamic-config-rs.github.io/compatibility.html).

## Threat model, stated plainly

Expand All @@ -100,7 +104,7 @@ runs on every change:

| Claim | Enforced by |
|---|---|
| Secrets stay out of diagnostics | `dynamic-config/tests/security.rs`, run as its own CI job |
| Secrets stay out of diagnostics | the engine repository's `tests/security.rs`, run as its own CI job there |
| Files are created private, symlinks refused | `write::permissions` tests, same job |
| A store cannot panic the process | `checked_add` on every server-supplied duration, plus a hostile-document test |
| No unsafe code | `#![forbid(unsafe_code)]` in every crate, *and* a CI job that checks the attribute is still there |
Expand Down
1 change: 1 addition & 0 deletions book/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
- [Readiness a Load Balancer Can Use](use-cases/readiness.md)
- [Diagnostics Behind a Token](use-cases/diagnostics.md)
- [Configuration in a Test](use-cases/testing-a-service.md)
- [A Production FastAPI Service](use-cases/production-fastapi.md)

# Advanced

Expand Down
8 changes: 8 additions & 0 deletions book/src/health.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ Three routes, three questions, and the reason they are three: an operator
who cannot tell them apart either restarts a process that was serving fine
or keeps routing to one that has been stale since Tuesday.

The definitions — why serving last-known-good is *ready*, why degraded
is a detail and not a state, the JSON shape — are the engine book's
[Readiness & Liveness](https://dynamic-config-rs.github.io/readiness.html)
contract; the routes below are its ASGI rendering. The metric names are
likewise the
[Metrics Contract](https://dynamic-config-rs.github.io/metrics-contract.html)'s,
not this package's to invent.

## `/healthz` — liveness

Always 200. Configuration has no say in it.
Expand Down
19 changes: 10 additions & 9 deletions book/src/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,16 @@ of what this package is for, and pinning per message is a decision only the
application can make. `latest(config)` is the honest read there, and
`stream_events(config)` is how a connection follows reloads.

## A refused reload cannot wake anything

The engine bumps a generation when a document *installs*. A load that
installed nothing does not, so nothing can be notified of it: a `/readyz`
scrape notices a refusal because it reads `status()`, and an event stream
notices it only if it was given `failure_poll`. There is no push.

That is the engine's shape rather than this package's, and it is documented
there too.
## A refused reload wakes `stream_events` — and nothing else

Since engine 0.7.1 a refusal pushes: `stream_events` delivers
`reload_failed` the moment it happens, no polling (`failure_poll` is
accepted, ignored, and warns). What a refusal still does not do is move
a *model*: `/readyz` reports it because it reads `status()`, and a
dependency keeps answering last-known-good — the split the
[engine book's Change Notification
page](https://dynamic-config-rs.github.io/change-notification.html)
holds for all three languages.

## No `/config/snapshot`

Expand Down
117 changes: 117 additions & 0 deletions book/src/use-cases/production-fastapi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# A Production FastAPI Service, End to End

Every piece here is documented alone elsewhere; a production service
needs all of them at once, in the right order. This is the
order — a service with two configurations, its own lifespan, a
dedicated executor, guarded diagnostics and the three operational
routes, written once so it can be copied whole.

```python
import os
from contextlib import asynccontextmanager
from dataclasses import dataclass

import dynamic_config
from dynamic_config import ConfigGroup, DynamicConfig
from dynamic_config_web import token_guard
from dynamic_config_web.fastapi import config_dependency, setup
from fastapi import Depends, FastAPI


# ── The declarations — plain classes, importable from anywhere ─────────
@dataclass
class Database:
host: str = "localhost"
pool_size: int = 8


@dataclass
class Features:
cache: bool = False


database = DynamicConfig(Database, key="db").file("config.toml").env("APP_")
features = DynamicConfig(Features, key="features").file("config.toml")


# ── Configuration's own thread pool ────────────────────────────────────
#
# By default the blocking half of `init_async` shares the loop's
# executor with everything else in the process. Two named threads mean a
# reload never queues behind an unrelated batch job — and a thread dump
# that says `dynamic-config-blocking-0` answers a question
# `ThreadPoolExecutor-3_0` does not.
dynamic_config.configure_executor(2)


# ── The application's OWN lifespan — setup() wraps it, never replaces it
@asynccontextmanager
async def lifespan(app: FastAPI):
pool = await open_pool(database.current())
app.state.pool = pool

# Reconfigure the pool when the values move — on the loop, because
# the pool is the loop's. (`on_change_async` is a decorator factory;
# applied inline it answers the guard that unregisters.)
guard = database.on_change_async("pool_size")(resize(pool))

yield

guard.close()
await pool.close()


app = FastAPI(lifespan=lifespan)

# ── The wiring: loads, watches, routes, scope — one call ───────────────
#
# `setup` composes with the lifespan above: yours runs inside its
# load-then-watch bracket, so `database.current()` is already answering
# by the time `open_pool` reads it. The group makes the two
# configurations one lifecycle; `/readyz` reports per-key.
setup(
app,
ConfigGroup(database, features),
debounce=0.25,
guard=token_guard(os.environ["CONFIG_TOKEN"]),
)

db = config_dependency(database)
flags = config_dependency(features)


# ── Handlers: a `def` dependency is a dictionary lookup, no round trip ─
@app.get("/")
def index(current: Database = Depends(db), toggles: Features = Depends(flags)):
return {"pool": current.pool_size, "cache": toggles.cache}
```

What `setup` put on the app without another line:

| Route | Answers |
|---|---|
| `/healthz` | 200, always — liveness has no configuration opinion |
| `/readyz` | ready / degraded per key, LKG-serving counts as ready |
| `/metrics` | the [Metrics Contract](https://dynamic-config-rs.github.io/metrics-contract.html)'s names, Prometheus text |
| `/_config/explain` | per-path provenance, redacted, behind the token guard |

## The deployment two-liner

uvicorn with workers forks; each worker runs the lifespan and `setup`'s
bracket itself, so each owns its watcher —
[four workers are four engines](https://dynamic-config-rs.github.io/python/pre-forking.html):

```console
$ CONFIG_TOKEN=... uvicorn app:app --workers 4
```

gunicorn with the uvicorn worker class is the same story with the
[pre-forking chapter](deployment.md)'s hook.

## Absent on purpose

No `/reload` endpoint (the watcher owns reloads; an endpoint is an
unaudited write path), no configuration values in any route this page
added (paths, kinds, counts — the values stay in the handlers that use
them), and no restart-on-failure (a failing reload leaves last-known-good
serving; see [Readiness a Load Balancer Can Use](readiness.md)).
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ classifiers = [
# One, and it is the whole point: this package is adapters over the engine.
# No framework is required — each is an extra, and importing this package
# imports none of them.
dependencies = ["dynamic-config-py>=0.2"]
dependencies = ["dynamic-config-py>=0.3.1"]
dynamic = ["version"]

[project.entry-points.pytest11]
Expand Down
2 changes: 1 addition & 1 deletion src/dynamic_config_web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def health(db: Database = Depends(config_dependency(config))):

#: The distribution's version, and the single place it is written: the
#: build backend reads this file rather than the other way round.
__version__ = "0.2.0"
__version__ = "0.2.1"

from ._diagnostics import (
Guard,
Expand Down
Loading
Loading