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
27 changes: 26 additions & 1 deletion .agents/skills/check-changes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,30 @@ of three buckets it falls into:

## What a good entry says

**Twenty-five words at most per entry.** Count them, and count wrapped lines
too -- a bullet spilling over three lines is over the limit however it looks in
the file. An entry that needs more is either two entries or an explanation that
belongs elsewhere.

Say what changed for the reader, not what was edited. "Rejects a request path
containing `..`" tells an operator something; "hardened `join_upstream`" does
not. Where a behaviour changed rather than appeared, say what it was before.
not.

Add as few entries as the change honestly needs. A changelog is scanned, not
read: every line that could have been left out costs the reader attention on the
lines that could not.

Where to put what the fifteen words cannot hold:

| The reasoning, the measurements, the rejected alternative | the commit message |
| How the thing works and how to configure it | `docs/` |
| Why the code is shaped that way | a comment next to it |

None of that belongs in `CHANGES.md`. A reader wanting it has `git log` and the
documentation; a reader wanting to know whether to care has one line.

Where a behaviour changed rather than appeared, one clause on what it was before
is worth the words -- inside the fifteen, not in addition to them.

Keep the existing Added / Changed / Fixed / Notes grouping. Entries are written
as part of the change that caused them; if you are adding several at once
Expand All @@ -47,3 +68,7 @@ diff when the subject is not enough.

Do not treat a green checklist as the goal. If the Development section is
accurate and short because little user-visible changed, say so.

Do not restore length that was cut. An entry trimmed to fifteen words has not
lost anything a reader of a changelog wanted: check that what was cut is
recorded in the commit message or the documentation, and leave the entry short.
13 changes: 9 additions & 4 deletions .agents/skills/pre-release-check/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,23 @@ A gate, not a fixer. Anything it finds gets fixed in its own commit before the
release proceeds; do not fold repairs into the release commit, which should
stay a mechanical, reviewable change.

## Run these four first
## Run these five first

In this order, because a later one is pointless if an earlier one fails:

1. `run-tests-and-linters` -- the workspace is green, with captured output.
2. `check-licenses` -- every direct dependency is compliant and justified.
3. `check-changes` -- the Development section reflects what landed.
4. `check-docs` -- the reference, CLI surface, error codes and rule table match
3. `regenerate-config-schema` -- `doppel-config.schema.json` matches the types
and every field still describes itself. It is a release asset and is what
editors fetch, so a stale one ships.
4. `check-changes` -- the Development section reflects what landed.
5. `check-docs` -- the reference, CLI surface, error codes and rule table match
the code.

`bump-version` is deliberately not in this list. This skill checks; that one
changes things.
changes things. `regenerate-config-schema` is the one exception, and only
because the thing it writes is generated: if it produces a diff, that diff is a
commit of its own before the release, not part of the release commit.

## Then check the version

Expand Down
95 changes: 95 additions & 0 deletions .agents/skills/regenerate-config-schema/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
name: regenerate-config-schema
description: Use after adding, removing or renaming any field in crates/doppel-core/src/config/, and before cutting a release. Regenerates doppel-config.schema.json and checks that every field still describes itself.
---

# Regenerate the configuration schema

`doppel-config.schema.json` at the repository root is generated, checked in,
attached to every release, and fetched by editors through a
`yaml-language-server` modeline. A stale copy is worse than none: it reports
mistakes that are not mistakes and accepts fields that no longer exist.

## Regenerate

```bash
uv run scripts/config_schema.py
uv run scripts/config_schema.py --check
```

The script runs `doppel config schema` and writes its output. Python tooling
here is driven by `uv`, never `pip`.

Two things already fail when the checked-in copy falls behind, so this skill is
about the cases they cannot see:

- `cargo test -p doppel-core --lib config::schema` compares the file to what the
code produces, for whoever runs the suite locally;
- a CI step runs `--check`, so a forgotten regeneration cannot merge.

## Where the schema comes from

The same `utoipa::ToSchema` derives the admin API's OpenAPI document uses. There
is no second description of the types to keep in step, and that is the point --
so **do not** add `schemars`, a hand-written schema, or a second derive set.

A new type reachable from `Config` needs `utoipa::ToSchema` on it or the build
fails. Two standard-library types have no `utoipa` schema and are annotated
where they appear: `IpAddr` and `PathBuf` both carry
`#[schema(value_type = String)]`.

## Every field must describe itself

The schema is read in an editor. A field with no description is a tooltip that
says nothing, which is the whole reason it is generated from doc comments.

```bash
cargo test -p doppel-core --lib every_field_carries_a_description
```

That test enumerates rather than samples, so a field added without a `///` fails
it. Write the doc comment for the person editing YAML, not for the person
reading Rust: what the field is for, its unit, its default, and what happens
when it is left out.

`utoipa` puts a doc comment where you would not expect for an `Option<T>` whose
`T` has its own schema: the description lands *inside* the `oneOf` branch beside
the `$ref` rather than on the property. `config::schema::hoist_descriptions`
lifts it back out. If a field's description goes missing from the generated
file, that hoist is the first place to look -- not the doc comment.

## When the URL changes

`config::schema::URL` is the `$id` and is also the URL in
`main.example.yaml`'s modeline. They are compared by
`the_example_configs_modeline_names_this_url`, so changing one without the other
fails. It points at the raw file on `main` deliberately: a reader copies that
line once and keeps it, and a version-pinned URL would leave them validating
next year's configuration against an old schema. The per-release asset is there
for anyone who wants the pin.

## Check what it actually rejects

The generated file being current says nothing about it being useful. Validate a
document against it, and include a mistake:

```bash
uv run --with jsonschema --with pyyaml python - <<'EOF'
import json, yaml, jsonschema
schema = json.load(open("doppel-config.schema.json"))
jsonschema.Draft202012Validator.check_schema(schema)
doc = yaml.safe_load(open("main.example.yaml"))
print("example errors:", len(list(jsonschema.Draft202012Validator(schema).iter_errors(doc))))
doc["proxies"][0]["loss"]["percentage"] = 45 # a fraction was meant
print("with a bad percentage:", len(list(jsonschema.Draft202012Validator(schema).iter_errors(doc))))
EOF
```

The first count has to be zero and the second has to not be. A schema that
accepts everything passes every other check in this file.

## Report

Say whether the file changed, and name the fields whose descriptions you added
or reworded. "Regenerated, no diff" is a useful result; "the schema is fine" is
not.
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,25 @@ jobs:
- name: Test workspace
run: cargo test --workspace

# Pinned to an exact release: astral-sh publishes `v9.0.0` but stopped
# publishing the sliding major tag after `v7`, so `@v9` resolves to
# nothing and the job fails before it runs.
- name: Install uv
uses: astral-sh/setup-uv@v9.0.0

# After the tests, so the workspace is already compiled and this only has
# to run the binary. A stale schema is worse than none -- editors report
# mistakes that are not mistakes and accept fields that no longer exist --
# and it is generated, so nothing but a check catches a forgotten
# regeneration.
#
# `cargo test` covers the same ground through a drift test in
# `config::schema`, deliberately: that one fails for whoever runs the
# suite locally, this one names the fix in a step whose title says what
# broke.
- name: Configuration schema is up to date
run: uv run scripts/config_schema.py --check

docs:
name: Documentation builds
runs-on: ubuntu-latest
Expand Down
14 changes: 13 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -326,13 +326,25 @@ jobs:
# flatten them into one directory.
merge-multiple: true

# Attached so a configuration can be validated against the exact release
# it will run under, rather than against whatever `main` says today. The
# checked-in file is taken as-is: CI has already refused a stale one
# (`scripts/config_schema.py --check`), so regenerating here would only
# add a Rust build to a job that otherwise needs none.
- name: Stage the configuration schema
run: cp doppel-config.schema.json dist/

- name: Write checksums
run: |
set -euo pipefail
cd dist
# Relative names, so `shasum -c` works from whatever directory the
# verifier downloaded into.
sha256sum -- *.tar.gz > checksums.txt
#
# The schema is signed alongside the archives: it is a release asset
# people will fetch over the network, and one that could be swapped
# without anyone noticing is worth as little as an unsigned binary.
sha256sum -- *.tar.gz doppel-config.schema.json > checksums.txt
cat checksums.txt

- name: Import the release key
Expand Down
76 changes: 34 additions & 42 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,58 +8,50 @@ release promotes it to a version heading; the `bump-version` skill does that.

## Development

## 0.4.0 -- 2026-08-11

### Added

- A JSON Schema for the configuration, checked in and attached to each release.
- `doppel config schema` prints it; every field carries a description.

### Changed

- An empty or absent `proxies` list is accepted; requests get `503
NO_PROXIES_CONFIGURED`.
- `type: tcp` is now refused while parsing rather than by a validation rule.
- `admin.groups` bounds which names `access` may reference; rule V36 checks it.
- Names may no longer contain `.`, and are capped at 64 characters, 32 for a
proxy.

## 0.3.0 -- 2026-08-10

### Added

- `X-Forwarded-Host` and `X-Forwarded-Proto` are now sent upstream.
- `proxies[].rewrite_redirects`, default `true`.

### Changed

- The documentation site is versioned with `mike`: one built copy per release on
the `gh-pages` branch, a switcher in the header, and the site root redirecting
to the newest release. It was a single unversioned site, so a reader on 0.1.0
had no way to reach the documentation for the release they were running, and
publishing 0.2.0 silently replaced it. Pre-release tags publish nothing; a push
to `main` publishes `dev`.
- A redirect into the proxied space now points back at Doppel, not the upstream.
- The documentation site is versioned with `mike`, one built copy per release.

## 0.2.0 -- 2026-08-03

### Changed

- A matching mock is now decided before `loss` and `latency`, not after, so
`replace` is the share of matching requests a mock answers rather than the
share of those that survived a loss roll. Previously `loss: 0.5` halved every
`replace` in the proxy, and no configuration could ask a mock to answer half
of its matching requests while any loss was set. The proxy's `loss` and
`latency` no longer apply to a request a mock answered: they describe the real
backend, and a mock replaces it.
- A run of slashes at the start of a request path is collapsed to one before
mocks are matched, so `//api/v1/index/` matches a mock declared
`^/api/v1/index/$`. Clients produce the doubled form by joining a base URL
ending in `/` to a path beginning with `/`; it is legal HTTP, nothing rejected
it, and the only symptom was an anchored mock silently not firing. Empty
segments elsewhere in the path are left alone.
- An injected `latency` is now a target for the whole response rather than an
addition to it: the time the upstream really took is subtracted, and only the
remainder is waited out. A 500ms latency in front of a backend answering in
120ms delays by 380ms, where before it delayed by 500 and produced 620ms
total -- so the number written in the configuration was unreachable by
construction, and moved with whatever the upstream happened to be doing. An
upstream slower than the target leaves no remainder and is passed straight
through; the setting is a floor, never a ceiling. `latency_injected_ms` in the
log line is the wait actually taken and reads `0` in that case, while
`doppel_latency_injected_total` still counts the request.
- A matching mock is decided before `loss` and `latency`, so `replace` no longer
shrinks with loss.
- The proxy's `loss` no longer applies to a request a mock answered; its
`latency` still does.
- Leading slashes in a request path are collapsed before mocks are matched.
- An injected `latency` is a target for the whole response: the upstream's real
time is subtracted.

### Fixed

- A mock's `proxy.loss` and `proxy.latency` are applied. They were parsed,
validated and compiled into the runtime, and then never read, so a mock
declaring either was silently answering every request it matched. They now
apply to the requests the mock answers, after it has won its `replace` roll,
and go through the same `decide` as the proxy's -- so loss short-circuits
latency there too.
- What a mock inherits from its proxy is now settled per setting rather than by
accident: `replace` and `latency` fall back to the proxy's, `loss` does not.
`latency` describes how slow the proxy is to answer, which holds whatever
answers, so a mocked response is delayed like any other and a mock's own value
overrides rather than adds to it. `loss` is excluded because a mock inheriting
it would be dropped by the proxy's loss, which is the coupling between `loss`
and `replace` the ordering above exists to remove.
- A mock's own `proxy.loss` and `proxy.latency` are applied; they were parsed and
then ignored.

## 0.1.0 -- 2026-08-02

Expand Down
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ members = ["crates/*"]
resolver = "3"

[workspace.package]
version = "0.2.0"
version = "0.4.0"
edition = "2024"
rust-version = "1.94"
license = "Apache-2.0"
Expand Down
Loading