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
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ effect. State the numbers, not "benchmarked".
- [ ] **Behaviour changed?** If a wrong change here could pass silently, pin it with
a test whose name is the claim and whose docstring opens `INVARIANT:` and says
what breaks it. Do **not** write prose about mechanism — there is no page for
it. See [`planning/README.md`](../planning/README.md#where-a-fact-goes).
it. See the "Where a fact goes" section of [`CLAUDE.md`](../CLAUDE.md).
- [ ] **Adding a fact anywhere?** Run the admission check: derivable from
`modern_di/` → don't write it; enforceable → a test; a user needs it →
`docs/`; otherwise it does not get written.
Expand Down
39 changes: 6 additions & 33 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,48 +25,22 @@ jobs:
- uses: extractions/setup-just@v4
- uses: astral-sh/setup-uv@v7

# Curated release notes are MANDATORY for a stable tag. This runs BEFORE
# `just publish` (which is irreversible) so a missing notes file aborts
# the release before anything reaches PyPI — rather than silently shipping
# with GitHub's auto-generated notes. Pre-release tags (a letter in the
# name, e.g. 2.0.0rc1) are exempt and keep the auto-generated fallback.
- name: Require curated release notes (stable tags)
run: |
set -euo pipefail
if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then
echo "Pre-release ${GITHUB_REF_NAME}: curated notes not required."
exit 0
fi
notes="planning/releases/${GITHUB_REF_NAME}.md"
if [ ! -f "$notes" ]; then
echo "::error::Stable tag ${GITHUB_REF_NAME} has no curated release notes at ${notes}. Write the notes, commit to main, and re-tag." >&2
exit 1
fi
echo "Found curated release notes: ${notes}"

# PyPI is irreversible, so it runs FIRST: if it fails the job stops and no
# GitHub Release is created advertising a version that never reached PyPI.
# `just publish` derives the version from $GITHUB_REF_NAME (the tag name).
# Auth via PyPI Trusted Publishing (OIDC); no PYPI_TOKEN. Needs a Trusted
# Publisher on the modern-di PyPI project (env: pypi, workflow: release.yml).
- run: just publish

# Description source: planning/releases/<tag>.md if present (verbatim, no
# auto-changelog appended); otherwise GitHub's generated notes. The guard
# above makes the file mandatory for stable tags, so the generated-notes
# fallback only ever fires for pre-releases. A tag with a letter (2.0.0rc1)
# is a pre-release -> flagged so GitHub won't mark it "Latest".
# The Release body is GitHub's generated notes, rendered from the squashed
# PR titles since the previous tag — so a conventional-commit title is what
# a reader gets. A release wanting prose is edited after the fact with
# `gh release edit <tag> --notes-file`. A tag with a letter (2.0.0rc1) is a
# pre-release -> flagged so GitHub won't mark it "Latest".
- name: Resolve release metadata
id: meta
run: |
set -euo pipefail
notes="planning/releases/${GITHUB_REF_NAME}.md"
if [ -f "$notes" ]; then
echo "body_path=$notes" >> "$GITHUB_OUTPUT"
echo "generate_notes=false" >> "$GITHUB_OUTPUT"
else
echo "generate_notes=true" >> "$GITHUB_OUTPUT"
fi
if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
Expand All @@ -76,7 +50,6 @@ jobs:
- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
body_path: ${{ steps.meta.outputs.body_path }}
generate_release_notes: ${{ steps.meta.outputs.generate_notes }}
generate_release_notes: true
prerelease: ${{ steps.meta.outputs.prerelease }}
draft: false
85 changes: 62 additions & 23 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ or read it for every recipe and its intent. The non-obvious essentials:
- `just test [args]` — pytest, **no coverage**; targeted runs won't trip the
gate. Passes args through: `just test tests/providers/test_factory.py -k <substring>`.
- `just test-ci` — the **gated** full run (100% line coverage); this is what CI runs.
- `just lint` (autofix) / `just lint-ci` (no autofix; also runs the repo-wide link check).
- `just check-links` validates every relative Markdown link and heading anchor in the repo, including the trees `mkdocs --strict` never sees.
- `just lint` (autofix) / `just lint-ci` (no autofix).
- `just docs-build` builds the site with `mkdocs --strict`, which fails on a broken link or nav
entry within `docs/`. Nothing validates links in root Markdown, `.github/`, or `docs/agents/`.

## Architecture

Expand All @@ -25,7 +26,7 @@ or read it for every recipe and its intent. The non-obvious essentials:

There is no separate capability-page home for behavior detail — it lives in the code and its
`INVARIANT:`-marked tests. Before writing prose about a capability, run the admission check in
[`planning/README.md`](planning/README.md#where-a-fact-goes).
**Where a fact goes** below.

### Key files

Expand Down Expand Up @@ -75,26 +76,64 @@ scheduled** becomes a GitHub issue (see
[`docs/agents/issue-tracker.md`](docs/agents/issue-tracker.md)). There is no
third state. There is no separate truth-home directory either — the living truth
about behaviour is the code and its `INVARIANT:`-marked tests, and a behaviour
change is reviewed with the diff, not promoted to a page. See
[`planning/README.md`](planning/README.md) for the admission check that decides
where a given fact belongs.

- **Cutting a release (maintainers)** is tag-driven via
[`.github/workflows/release.yml`](.github/workflows/release.yml): write the
notes at `planning/releases/<version>.md` from
[`planning/_templates/release.md`](planning/_templates/release.md) (used
verbatim as the GitHub Release body; `docs/changelog.md` links to the
directory rather than republishing it), then push a bare-semver-**named** tag
off green `main` —
`git tag -m "modern-di 2.19.2" 2.19.2 && git push origin 2.19.2`. Only the tag
*name* must be bare semver (that is what the workflow matches); the tag object
itself may be annotated or signed, and `-m` is required whenever
`tag.gpgsign`/`tag.forceSignAnnotated` is set — without it `git tag` aborts
with `fatal: no tag message?`. The workflow runs `just publish`
(the tag sets the version via `uv version`; no `pyproject.toml` bump) to PyPI,
then creates the GitHub Release — PyPI first, so a failed publish creates no
Release. Pre-releases use the PEP 440 form (`2.0.0rc1`, not `2.0.0-alpha.5`).
PyPI is irreversible; there is no CI gate (a tag is the commitment point).
change is reviewed with the diff, not promoted to a page. **Where a fact goes**
below is the admission check that decides where a given fact belongs.

### Where a fact goes

Four homes, one owner each:

| Home | Holds |
|---|---|
| `modern_di/` | anything readable from the module — the default |
| a named test | an **invariant**: must stay true, and a change could silently break it |
| `docs/adr/` | a rejected alternative, with the reasoning that would otherwise be re-litigated |
| `docs/` | anything a user needs |

Before writing a line anywhere:

> Can an agent get this by reading `modern_di/`? → **don't write it.**
> Would a wrong change here fail a test? → it belongs **in the test**, not in prose.
> Does a user need it? → **`docs/`**.
> Otherwise it does not get written.

**Prose about mechanism has no home. There is no file to add a paragraph to.**

Both ADRs and `INVARIANT:` docstrings ratchet in the other direction: nothing
prunes a record once its call is settled, or a docstring once its claim stops
mattering. Keeping either lean is a standing habit, not a one-time fix.

An invariant is written as a test whose name is the claim, with a docstring opening
`INVARIANT:` and a second paragraph naming **what breaks it**. That second paragraph
is where an anti-refactor warning lives — design rationale, not a report of what this
one test happens to catch. It does not have to describe a regression that *this*
test alone would fail on; a sibling test may be the one that actually trips. The unit
of truth is the invariant plus the whole suite, not the docstring plus its single
test — the accepted cost is that a reader cannot tell, from one docstring alone,
whether that test or a sibling one catches a given regression.
`tests/test_invariant_census.py` enforces that shape.

### Cutting a release (maintainers)

Tag-driven via [`.github/workflows/release.yml`](.github/workflows/release.yml):
push a bare-semver-**named** tag off green `main` —
`git tag -m "modern-di 3.4.0" 3.4.0 && git push origin 3.4.0`. Only the tag
*name* must be bare semver (that is what the workflow matches); the tag object
itself may be annotated or signed, and `-m` is required whenever
`tag.gpgsign`/`tag.forceSignAnnotated` is set — without it `git tag` aborts
with `fatal: no tag message?`. The workflow runs `just publish`
(the tag sets the version via `uv version`; no `pyproject.toml` bump) to PyPI,
then creates the GitHub Release — PyPI first, so a failed publish creates no
Release. Pre-releases use the PEP 440 form (`2.0.0rc1`, not `2.0.0-alpha.5`).
PyPI is irreversible; there is no CI gate (a tag is the commitment point).

The Release body is GitHub's generated notes, built from the squashed PR titles
since the previous tag. A conventional-commit PR title is therefore the changelog
entry a reader gets, and that is where the care goes. A release wanting prose gets
it after the fact with `gh release edit <tag> --notes-file <file>`. There is no
committed notes file and no template. Releases 2.15.0 through 3.4.0 have curated
bodies, which live on the
[Releases page](https://github.com/modern-python/modern-di/releases) and nowhere else.

## Code Style

Expand Down
9 changes: 0 additions & 9 deletions docs/changelog.md

This file was deleted.

13 changes: 3 additions & 10 deletions docs/integrations/writing-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,13 +433,8 @@ Each official integration is its own repository and PyPI package, mirroring the
[Lifecycle](../providers/lifecycle.md), [Scopes](../providers/scopes.md), and
the most relevant recipe, and the `## API` table last. Integrations do not
ship their own docs site.
- **Release.** Tag-driven, mirroring `modern-di`: write release notes and push a
bare semver tag off green `main`.

!!! tip "Planning convention"
For the planning/change-management setup, following the
[planning-convention](https://github.com/lesnik512/planning-convention) is
recommended — the same two-axis convention the `modern-di` repo uses.
- **Release.** Tag-driven, mirroring `modern-di`: push a bare semver tag off
green `main` and let the workflow publish.

## Checklist

Expand Down Expand Up @@ -470,6 +465,4 @@ Each official integration is its own repository and PyPI package, mirroring the
- [ ] `examples/app.py` (+ smoke test asserting real injected output, 100%
coverage, no `omit`) and a README `Usage example: [examples/](./examples)`
line.
- [ ] `CLAUDE.md` and `Justfile` mirrored; invariants pinned by named tests;
[planning-convention](https://github.com/lesnik512/planning-convention)
followed.
- [ ] `CLAUDE.md` and `Justfile` mirrored; invariants pinned by named tests.
8 changes: 1 addition & 7 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,12 @@ lint:
uv run ruff check --fix
uv run ty check

# CI lint (no autofix) — same checks as `lint` plus the repo-wide link check.
# CI lint (no autofix) — the same checks as `lint`.
lint-ci:
uv run eof-fixer . --check
uv run ruff format --check
uv run ruff check --no-fix
uv run ty check
uv run python planning/links.py

# Check every relative Markdown link and heading anchor. `mkdocs --strict` only sees
# docs/; planning/ lives outside docs_dir and is read on GitHub.
check-links:
uv run python planning/links.py

# Run pytest with NO coverage (targeted runs won't trip the gate). Passes args through.
test *args:
Expand Down
1 change: 0 additions & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ nav:
- To 3.x: migration/to-3.x.md
- From that-depends: migration/from-that-depends.md
- From dependency-injector: migration/from-dependency-injector.md
- Changelog: changelog.md
- Development:
- Contributing: dev/contributing.md

Expand Down
84 changes: 0 additions & 84 deletions planning/README.md

This file was deleted.

39 changes: 0 additions & 39 deletions planning/_templates/release.md

This file was deleted.

Loading
Loading