Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
docs: releasing guide, and the release command in setup, index, READM…
…E, AGENTS

docs/releasing.md: how a release is put together, choosing the version for
an HTTP API, the step by step, the starter marker and a clone's first
release, writing the notes (headings, style, the generated fallback),
changing notes later, troubleshooting, hotfixes. Setup's adapting list gains
the marker flip; the pure-test command lists the new test files; lint
commands include scripts/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
  • Loading branch information
BrahiamSSS and claude committed Oct 12, 2026
commit bfc899b326cb58c23aff3d25da2a0511c5b3365a
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,19 @@ src/basic_api/
schemas/ # user models and schemas, SuccessResponse
services/ # business rules (users)
tests/ # conftest (test database), database-backed and pure tests
docs/ # setup, authentication, api, schemas, known_issues
scripts/release.py # version bump, changelog, tag, push (clones only; see docs/releasing.md)
docs/ # setup, authentication, api, schemas, releasing, known_issues
```

## Common Commands
* **Start dev server**: `poetry run uvicorn basic_api.main:app --reload --app-dir src`
* **Test**: `poetry run pytest` (needs PostgreSQL; `--noconftest` plus the pure
test files runs without one)
* **Format & lint**: `poetry run black src tests && poetry run flake8 src tests`
* **Type check**: `poetry run mypy`
* **Format & lint**: `poetry run black src tests scripts && poetry run flake8 src tests scripts`
* **Type check**: `poetry run mypy` (covers `src` and `scripts`)
* **Release** (in a clone, never in the starter): `poetry run python scripts/release.py prepare minor`;
`--dry-run` first. The version lives only in `pyproject.toml`; Unreleased in
`CHANGELOG.md` becomes the notes. `.github/workflows/release.yml` publishes the tag.
* **Dependency audit**: `poetry run pip-audit` (CI runs it in `lint`)
* **Dependencies**: `poetry add <pkg>` (dev tools: `--group dev`), then `poetry install`.
Ranges in `pyproject.toml` are open to the next major; `poetry.lock` is the pin.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ configuration right. That work is done here, tested, and documented.
| [Authentication](docs/authentication.md) | API key, login flow, token lifetime, what invalidates a token, rate limits, errors |
| [API Reference](docs/api.md) | Every endpoint with status codes, response envelope and error shape |
| [Schemas](docs/schemas.md) | Request and response models with their limits |
| [Releasing](docs/releasing.md) | `scripts/release.py`: version bump, changelog, tag, GitHub release; writing the notes |
| [Known Issues](docs/known_issues.md) | What the starter deliberately does not do yet |
| [Changelog](CHANGELOG.md) | What changed and when |

Expand Down
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ client API key, rate limits, user self-service, tests and CI.
- [API Reference](api.md): every endpoint with status codes, the response
envelope and the error shape.
- [Schemas](schemas.md): request and response models with their limits.
- [Releasing](releasing.md): one command to bump the version, write the
changelog, tag and publish; how to write the notes.
- [Known Issues](known_issues.md): what the starter deliberately leaves for a
real project to decide.
- [Changelog](../CHANGELOG.md).
238 changes: 238 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
# Releasing

How to publish a version of the API, and how to write the notes people read on
the release page. The starter itself is never released (see
[The starter and its clones](#the-starter-and-its-clones)); this page is for a
project built from it.

## How a release is put together

```text
CHANGELOG.md "Unreleased" --> scripts/release.py prepare --> commit + tag vX.Y.Z --> push
|
GitHub release (notes, wheel, sdist) <-- publish <-- build <-- ci <-- verify <----+
```

- **The notes** are the `## [Unreleased]` section of [CHANGELOG.md](../CHANGELOG.md).
The script renames it to the version; the workflow publishes that section as
the release notes, word for word.
- **The script** (`scripts/release.py`) bumps the version, updates the
changelog, commits, tags, and pushes. The version lives in one place,
`[project] version` in `pyproject.toml`; the app reads it from the installed
package metadata (`APP_VERSION`, shown by `GET /` and the OpenAPI document).
- **The workflow** (`.github/workflows/release.yml`) runs on the pushed tag:
1. **verify**: the tag matches `pyproject.toml` and the notes exist.
2. **ci**: the same lint and test jobs as a pull request, PostgreSQL included.
3. **build**: the wheel and the sdist (`poetry build`).
4. **publish**: one GitHub release, created only when everything passed.

## Choosing the version

[Semantic Versioning](https://semver.org/), applied to the HTTP contract: what
a client of this API would notice.

| Release contains | Command | Example |
|---|---|---|
| Only bug fixes, dependency updates, internal changes | `prepare patch` | 0.2.0 to 0.2.1 |
| A new route, a new optional field, a new setting | `prepare minor` | 0.2.1 to 0.3.0 |
| A changed or removed route, field, status code or error shape; a new required setting | `prepare major` (from 1.0.0); below 1.0.0, `prepare minor` with the break under **Breaking changes** | 1.2.0 to 2.0.0 |
| First stable release | `prepare 1.0.0` | 0.9.0 to 1.0.0 |

The commit type cannot tell the script that a `feat` broke a client; you choose
the part. For a test deployment ahead of a release, give an explicit
pre-release version: `prepare 1.0.0-rc.1`. It is published as a **pre-release**,
so GitHub does not show it as "Latest". A later `prepare patch|minor|major`
from `1.0.0-rc.N` finishes it as `1.0.0`.

A pre-release spelled `1.0.0-rc.1` is valid PEP 440, and the installed metadata
normalises it: `GET /` reports `1.0.0rc1`. The tag and the changelog keep the
spelling you typed.

## Step by step

### 1. Write the notes

Make sure the **Unreleased** section of `CHANGELOG.md` describes everything
since the last release (see [Writing the notes](#writing-the-notes)). Each pull
request should already have added its entry, so this is a read-through. Merge
any edits to `main` like any other change.

### 2. Check `main`

```bash
git switch main
git pull
```

The script runs the full test suite before it writes anything, so the machine
you release from needs the test database (see [Setup](setup.md#testing)).

### 3. Preview

```bash
poetry run python scripts/release.py prepare minor --dry-run
```

This prints the new version, the notes exactly as they will be published, and
any local commits that would be pushed with them. It changes nothing.

### 4. Release

```bash
poetry run python scripts/release.py prepare minor
```

The script:

1. Refuses to continue if the project is the starter, you are not on `main`,
have uncommitted changes, are behind `origin/main`, or the tag exists.
2. Runs `pytest`.
3. Writes the version into `pyproject.toml`.
4. Moves the Unreleased notes under `## [X.Y.Z] - <today>`, leaving Unreleased
empty.
5. If `$EDITOR` (or `$VISUAL`) is set, offers to open `CHANGELOG.md` for a last
edit.
6. Asks, then commits `chore(release): vX.Y.Z` and creates an annotated tag
holding the notes.
7. Reinstalls the project (`poetry install --only-root`) so a running dev
server reports the new version; the installed metadata is written at
install time, not read from `pyproject.toml`.
8. Asks, then pushes `main` and the tag in one atomic push, which starts the
workflow.

Answer **no** at the push question, or pass `--no-push`, to stop with
everything still local. Push later with the command it prints, or undo with:

```bash
git tag -d vX.Y.Z && git reset --hard HEAD~1
```

Other options: `--skip-tests` when you have just run them; `--yes` answers every
question, for scripted use; `--allow-branch` releases from another branch.

### 5. Watch the workflow and check the release

Open the repository's **Actions** tab. The release appears under **Releases**
when *publish* finishes. Check that the notes read correctly, the wheel and the
sdist are attached, and a deployment of the tag answers the new version on
`GET /`.

## The starter and its clones

The starter keeps `version = "0.1.0"` and never tags: `prepare` refuses while
`pyproject.toml` has

```toml
[tool.release]
starter = true
```

A project built from the starter sets it to `false` once (it is in the
[Adapting this starter](setup.md#adapting-this-starter) list) and inherits the
script, the workflow, the test and this page unchanged; nothing in them names
the project. The first release works from a repository with no tags: the
Unreleased section is the notes, and if it is empty the script lists every
conventional commit in the history, so write the section. Start wherever the
project is: `prepare minor` for `0.2.0`, or `prepare 1.0.0`.

The script is self-contained, so a clone can take later improvements to it with
a cherry-pick from the starter.

## Writing the notes

### Where it lives and when to write it

Everything under `## [Unreleased]` in `CHANGELOG.md` becomes the release notes.
Add entries **in the same pull request as the change**, while you still
remember why it matters, with the PR number: `(#12)`.

If Unreleased is empty at release time, the script generates the notes from the
commit subjects (see [Generated notes](#generated-notes)). That fallback is
complete but terse; prefer written notes for anything a client developer will
read.

### Structure

`###` headings, in this order, leaving out any that would be empty:

| Heading | For |
|---|---|
| `### Breaking changes` | Anything a client has to change for, plus what to do |
| `### Added` | New routes, fields, settings |
| `### Changed` | Changes to existing behaviour |
| `### Performance` | Faster or lighter, with a number when you measured one |
| `### Fixed` | Bugs fixed, described as the symptom that is gone |
| `### Removed` | Routes, fields or settings taken out |
| `### Security` | Vulnerability fixes and hardening |
| `### Dependencies` | Version moves that matter to a deployment (a new Python or PostgreSQL minimum) |
| `### Documentation` | Docs a client developer would care about |

One or two plain sentences above the first heading may summarise the release.

### Style

- **Write for the people who deploy or call the API**, not for the code: the
route, the header, the setting, the status code.
- **One entry per change, one or two lines**, starting with the thing that
changed. Leave the reasoning to the PR.
- **Breaking changes say what to do**: the new field name, the new minimum, the
migration step.
- **Leave out housekeeping**: refactors with no visible effect, CI, tests,
dependency bumps that change nothing for a deployment.

### Generated notes

With Unreleased empty, `prepare` builds the notes from the commit subjects
since the last tag, read as [Conventional Commits](https://www.conventionalcommits.org/)
(`type(scope): summary`):

| Commit type | Published under |
|---|---|
| `feat` | Added |
| `fix` | Fixed |
| `perf` | Performance |
| `refactor` | Changed |
| `security` | Security |
| `docs` | Documentation |
| `!` after the type (`feat!:`), or `BREAKING CHANGE:` in the body | Breaking changes |
| `chore`, `ci`, `test`, `build`, `style`, merge commits, other subjects | left out |

Each entry is the summary with its scope in bold and the short commit hash.

### Changing notes later

- **Before committing:** accept the script's offer to open `CHANGELOG.md`.
- **After committing, before pushing:** undo (`git tag -d vX.Y.Z && git reset
--hard HEAD~1`), fix Unreleased, run `prepare` again.
- **After publishing:** fix the version's section in `CHANGELOG.md` through a
normal PR, then update the release page:

```bash
python scripts/release.py notes X.Y.Z > notes.md
gh release edit vX.Y.Z --notes-file notes.md
```

The annotated tag keeps the original text; that is expected.

## Troubleshooting

| Problem | What to do |
|---|---|
| `prepare` says this is the starter | You are in the starter, or the clone still has `starter = true`; set it to `false`. |
| `prepare` says `main` is behind `origin/main` | `git pull`, then run it again. |
| `prepare` says "nothing to release" | Unreleased is empty and no `feat`, `fix`, `perf`, `refactor`, `security` or `docs` commits exist since the last tag. Write the notes. |
| Tests fail in `prepare` | Nothing was written. Fix on `main` first. |
| The push was rejected (branch protection on `main`) | Nothing was pushed: the push is atomic. Allow the push for the release commit, or push it through a PR and push the tag after merging (`git push origin vX.Y.Z`; the tag must point at the merged commit). |
| *verify* fails | The tag was not made by the script, or `pyproject.toml` was edited by hand. Delete the tag (`git push --delete origin vX.Y.Z && git tag -d vX.Y.Z`), fix, release again. Nothing was published. |
| *ci* or *build* fails | Nothing is published. A flaky runner: **Re-run failed jobs**. Code at fault: fix on `main`, delete the tag as above, release the next patch version rather than reusing the number. |
| *publish* fails | Re-run the failed job; the archives from *build* are reused. |
| `GET /` still shows the old version on your machine | `poetry install --only-root`. |

Never re-point or reuse a tag that has a published release. Release the next
patch version instead.

## Hotfixes

Merge the fix to `main` with a `### Fixed` entry under Unreleased, then
`prepare patch`. Everything else on `main` since the last release ships with
it; release often enough that `main` is never far from releasable.
12 changes: 8 additions & 4 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ Tests that need no database can run anywhere:

```bash
poetry run pytest --noconftest tests/test_auth_units.py tests/test_user_schemas.py \
tests/test_openapi.py tests/test_config.py tests/test_limiter.py
tests/test_openapi.py tests/test_config.py tests/test_limiter.py \
tests/test_logging_config.py tests/test_request_id.py tests/test_release_script.py
```

Deprecation warnings are not silenced; a new one in the summary is information,
Expand All @@ -118,8 +119,8 @@ not noise.
## Code quality

```bash
poetry run black --check src tests # formatting, line length 88
poetry run flake8 src tests # lint (.flake8 matches Black)
poetry run black --check src tests scripts # formatting, line length 88
poetry run flake8 src tests scripts # lint (.flake8 matches Black)
poetry run mypy # types, [tool.mypy] in pyproject.toml
```

Expand Down Expand Up @@ -149,7 +150,10 @@ Everything a new project renames or changes, in one list:
4. **Names shown to clients**: `title` and `description` in `main.py`, and
`SERVICE_NAME` returned by `/health`.
5. **Configuration**: add a variable in `config.py` and `.env.example` together.
6. **Docs**: this folder and the README.
6. **Releases**: set `starter = false` under `[tool.release]` in
`pyproject.toml`; the release command then works (see [Releasing](releasing.md)).
The starter itself stays at `0.1.0`.
7. **Docs**: this folder and the README.

## Debugging a hung process

Expand Down