Contributing to Remote Store¶
Scope: contributor workflow, development setup, release process, and consistency checklists for remote-store.
This project follows Spec-Driven Development (SDD): every feature starts as a specification before any code is written. See sdd/000-process.md for the full methodology.
Project Principles¶
The universal working principles for this repo are listed in
CLAUDE.md § Principles. They apply to any contributor,
not only automated agents.
Documentation framework¶
See CLAUDE.md § Documentation framework for placement, structure, and longevity rules.
Authoritative Document Format¶
Internal process and reference documents follow a fixed structure.
Principle¶
Meaningful minimum. Each document covers one concern, states clear principles, and stops. No detailed instructions for every situation.
Structure¶
- Intent & Scope: What this document governs and who it is for. Max 5–10 lines. A reader must know: "this is THE source for topic X."
- Rules: Numbered, mandatory constraints.
- Guides (optional): Heuristics, examples, lookup tables. Useful but not binding.
Sections 1 and 2 alone must be sufficient to understand the document's purpose and obligations.
Scope¶
Applies to root-level process documents in sdd/ (000-process.md, AUTHORING.md, CI-OPERATIONS.md, CONDA-FORGE.md, DESIGN.md, DOCUMENTATION.md, DRIFT-RULES.md, TESTING.md, CONTENT-RULES.md, CLAUDE-REFERENCE.md). Does not apply to specs, ADRs, RFCs, research, audits, BACKLOG.md, README, CHANGELOG, DEVELOPMENT_STORY, CLAUDE.md, or CONTRIBUTING.md (which follow their own formats).
Cross-check¶
Every sentence and section must pass this test: "this would force different behavior in situation X." If it does not, it is decoration — rewrite as a rule or remove.
Exclusions¶
Authoritative documents must not contain:
- Explanation or rationale (put in ADRs, specs, research docs, or
DEVELOPMENT_STORY.md) - History or changelog-style notes
- Meta-commentary about the document itself
Spec-First Workflow¶
The full SDD pipeline is described in sdd/000-process.md. For external contributions, start here:
- Propose: Open a PR with an RFC in
sdd/rfcs/(seerfc-template.md). No code yet. - Review: Maintainers and community review for design fit and completeness.
- Accept: The RFC graduates to a spec in
sdd/specs/. It now defines the contract. - Implement: Open a follow-up PR with tests (referencing spec IDs) and implementation.
Bug Fixes¶
Bug fixes follow a strict pipeline: BACKLOG → CHANGELOG → failing TEST → FIX →
COMMIT together. See sdd/000-process.md § Rule 6 for
the canonical rule.
Repository Structure¶
sdd/
000-process.md # How specs work in this repo
specs/ # Accepted specifications (source of truth)
adrs/ # Architecture Decision Records
rfcs/ # Proposals under discussion
Browse sdd/specs/ for the full list of specifications. Each spec file is
numbered and named after the feature it describes (e.g. 008-s3-backend.md).
For spec format and ID prefixes, see sdd/000-process.md § Spec format.
Adding a New Backend¶
See Build Your Own Backend for a full
walkthrough of the Backend contract, error mapping, and capabilities.
- Write a spec in
sdd/specs/or as an addendum insdd/specs/backends/<name>.md - Implement
BackendABC insrc/remote_store/backends/_<name>.py - Register a fixture in
tests/backends/fixtures/(declare it inbackends.toml/fixtures.tomland add a per-fixture factory module) - The cross-backend conformance suite under
tests/backends/conformance/(spec-traced per-topic files:test_io.py,test_listing.py,test_atomic.py, …) runs automatically against the new fixture; Dafny-derived cases carry@pytest.mark.extended_conformanceand are validated by a Dafny-compiled oracle — seesdd/formal/README.md§ Compiled Oracle - Add user-facing guide in
docs-src/guides/backends/<name>.mdand add todocs-src/guides/_nav.yml - Update every backend enumeration — see Backend order below for how to find them all and what order they go in
- Add backend config example to
examples/configuration/configuration.py - If the backend needs an extra, add it to
pyproject.toml[project.optional-dependencies], and add the same floor torun_constraintsinpackaging/conda-forge/recipe.yaml, thenhatch run gen-conda-feedstock(the shipped copy beside it is generated, andlintfails on a stale one)
Backend order (step 6). Every enumeration lists backends in one order:
local (Local, Memory) → cloud (S3, S3-PyArrow, Azure, Graph) → SFTP / SSH → special-purpose (HTTP, SQLBlob, SQLQuery)
Insert the new backend into its group; do not append it. Where a table carries footnote markers, they run in first-appearance order, so re-sequence them when a new row lands above an existing marker.
Which side governs, if this page and the gate ever disagree: the gate. The
order above is documented here for a reader, but the authority is the
_BACKENDS constant in scripts/check_backend_order.py, and this page is one
of the surfaces that gate scans. So a new backend is added to _BACKENDS first,
and the paragraph above is updated to match — not the other way round.
Do not go looking for the enumerations — the gate knows where they are.
hatch run check-backend-order reads every enumeration across the README, this
page, the guides, the API reference, context7.json, and the conda recipe, and
fails on any that is out of order. It runs inside hatch run lint and hatch run docs-gate, so
CI enforces it on every PR. Add the backend, run the gate, fix what it names.
This replaced a git grep, and the reason is worth knowing before anyone proposes
another one: a grep for a backend name cannot see an enumeration that abbreviates
(one wrote plain SQL), and a grep scoped to a path cannot see the enumerations
outside it (two live in packaging and repo-root metadata). Both holes were real, and
both hid live defects. If you find yourself about to hand-maintain a list of places
to check, extend scripts/check_backend_order.py instead.
The gate proves order, not membership. Whether a list should name every backend is a judgement it deliberately does not make: the API reference splits its sync and async tables on purpose, and the README's comparison row abridges on purpose. That one is on you and your reviewer.
Two things are deliberately out of scope. FEATURES.md is generated between
BEGIN_GENERATED markers and sorts alphabetically — never hand-edit it. The
tagline is a slogan rather than an enumeration, so the group order does not apply
to it; it is mirrored across packaging, citation, and docs-metadata files, and
git grep "Write file storage code once" is the way to find every copy. Do not
work from a memorised list — the mirrors outnumber the ones anyone remembers, and
the forgotten copies are the ones that go stale.
Adding an Extension¶
Extensions live in src/remote_store/ext/ and follow the contract in the extension architecture ADR. Full checklist:
- Write an RFC in
sdd/rfcs/, get it accepted as a spec insdd/specs/ - Implement in
src/remote_store/ext/<name>.py— define__all__ - Use only the public
Store/BackendAPI (never_backend). Useunwrap()for native access - Do not own Store lifecycle — never call
store.close()or usewith store: - Let
CapabilityNotSupportedpropagate — do not catch and suppress it - Add tests in
tests/ext/test_<name>.pywith@pytest.mark.spec("ID") - Write a user guide in
docs-src/guides/<name>.md - Add the page to
docs-src/guides/_nav.yml(under the Extensions section) - Add a runnable example in
examples/ - The example docs page is auto-generated at
tutorial/examples/<slug>.mdfrom the module docstring viagen_pages.py— no manual wrapper file needed - If the extension needs an extra, add it to
pyproject.toml[project.optional-dependencies], and add the same floor torun_constraintsinpackaging/conda-forge/recipe.yaml, thenhatch run gen-conda-feedstock(the shipped copy beside it is generated, andlintfails on a stale one) — that block covers every extra, backend and extension alike, and a dependency missing from it is silently unconstrained for conda users - Update
CHANGELOG.mdandsdd/BACKLOG.md(orsdd/BACKLOG-DONE.md) in the same commit
Export patterns¶
Pure Python (no extra dependencies) — export unconditionally from remote_store.__init__:
Optional dependency — two guards are needed:
- In the extension module, raise a helpful error if the dependency is missing:
# In ext/<name>.py:
try:
import some_lib
except ModuleNotFoundError as _exc:
raise ModuleNotFoundError(
"some_lib is required for the <name> extension. "
"Install it with: pip install 'remote-store[<name>]'"
) from _exc
- In
remote_store/__init__.py, conditionally re-export with a silent guard:
try:
from remote_store.ext.<name> import Foo, bar
__all__ += ["Foo", "bar"]
except ImportError:
pass
Add the optional dependency as an extra in pyproject.toml [project.optional-dependencies].
Third-Party Extensions¶
External packages should use the naming convention remote-store-<name> and:
- Use only the public
Store/BackendAPI - Use
register_backend()for backend registration (if applicable) - Use
unwrap()for native handle access - For backend extensions: reuse the conformance test suite by importing and parameterizing it
Development Setup¶
The repo pins its priority interpreter in .python-version (the version CI's
coverage lane runs on). It is the single source for the priority Python across
CI: workflows that need it read it (setup-python's python-version-file, or
ci.yml's setup job for the matrix). Two files cannot read it and carry a
literal copy, each held equal by a gate: .readthedocs.yaml
(scripts/check_readthedocs_python.py) and the default hatch env's python
in pyproject.toml (scripts/check_hatch_python.py). Those two gated
copies are not the whole bump: the drift locks resolve on .python-version
too, and some prose names the primary version. No gate checks those; the
ripple-check's Primary Python row in
sdd/CLAUDE-REFERENCE.md
enumerates them.
hatch does not read .python-version, so the pin is what makes your local
hatch run all gate on the same interpreter as CI; without it hatch builds
.venv from the first python on PATH. An existing .venv keeps its
interpreter when the pin changes: hatch run preflight then fails and names
the remedy, hatch env remove default. (The full-matrix ci-full.yml
backstop runs every supported interpreter, not the priority one, from a list
kept equal to ci.yml's ALL_PYTHONS by scripts/check_ci_full_matrix.py.)
The default hatch env is configured with path = ".venv", so hatch run
creates and owns .venv/ at the repo root via uv. A separate
python -m venv or pip install -e ".[dev]" step duplicates the env that
hatch run would build, and a stdlib-built .venv may not match the shape
hatch expects.
# Clone and enter the repo
git clone https://github.com/haalfi/remote-store.git
cd remote-store
# Install hatch (skip if you already have it). Any of these work:
uv tool install hatch # recommended if you use uv
pipx install hatch
pip install --user hatch
# Run any hatch script — .venv/ is auto-built on first invocation:
hatch run all # or run individual steps:
hatch run lint
hatch run typecheck
hatch run test-cov # coverage variants — see pyproject.toml comments for which to use when
hatch run examples
All dev scripts are defined in pyproject.toml under [tool.hatch.envs.default.scripts]. Run hatch run to see available commands.
Bundling a code skeleton for an LLM (optional)¶
To orient a coding agent on a subtree without spending tokens on full bodies,
lx (Go, MIT) emits an AST-level skeleton —
class/method signatures, type fields, and docstrings only. For
src/remote_store/ that is ~80k tokens versus ~265k for the full source, and
there is no concise git archive / cat equivalent. This is the one job lx
does that is awkward to reproduce by hand; for whole-file context just let the
agent read the files. As a contributor's skeleton tool lx is a purely local
convenience: not a project dependency and not installed by any hatch
script. (Separately, the Read the Docs build fetches a pinned lx binary to
generate the published llms-api.txt API skeleton — see .readthedocs.yaml;
that is an automated publish step, not part of local development.)
go install github.com/rasros/lx/cmd/lx@latest # one-time
lx --xml -u -Y src/remote_store/ # skeleton: signatures + types only
git diff --name-only | lx -c # bundle just the changed files to the clipboard
It honours .gitignore and estimates tokens. Point it at a subtree, not the
repo root (a whole-repo walk is ~3.7M tokens, mostly tests/ and sdd/); no
.lxignore is committed, so use -e <glob> for ad-hoc excludes.
Fixed in
lxv1.2.2 (affectedlx≤ v1.2.1). Earlier releases could silently drop theclassheader of decorated dataclasses from the-u -Yskeleton. Onsrc/remote_store/_models.py,lxv1.2.1 dropped 4 of the 5 public data-model classes (FileInfo,WriteResult,FolderEntry,FolderInfo), orphaning their fields underContentDigestand losing some fields entirely; the output still looked like valid Python, so the loss was easy to miss.lxv1.2.2 fixes it: re-runninglx --xml -u -Y src/remote_store/now emits every public class with its fields (0 public classes dropped across the whole subtree). Upgrade to ≥ v1.2.2 (go install github.com/rasros/lx/cmd/lx@latest); onlx≤ v1.2.1 only, work around it by bundling the data models as full source (lx --xml src/remote_store/_models.py, ~1.7k tokens). Full-source and changed-file bundles were never affected. Upstream report: rasros/lx#76.The
-u -Yskeleton shows the public API only:lxintentionally omits leading-underscore (_Foo) classes and functions, so private helpers will not appear — expected, not the bug above.
Migrating an existing checkout¶
If you previously created .venv/ with python -m venv or via IDE
auto-discovery, delete it before the first hatch run. Hatch's behaviour
on a pre-existing non-uv venv is not guaranteed (it may reuse, rebuild, or
fail depending on what metadata it finds), and a reused stdlib venv will
be missing the dev / docs / bench feature installs.
On Windows, close any IDE / language server / running pytest that has
file handles inside .venv\ before deleting — otherwise the delete fails
with WinError 32 (file in use).
Commit Signing¶
All commits should be signed for supply chain transparency. GitHub Vigilant Mode is enabled on the repository — unsigned commits show as "Unverified".
Setup (SSH signing, one-time)¶
# Tell Git to use SSH for signing
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/<your-key>.pub
git config --global commit.gpgSign true
git config --global tag.gpgSign true
Then upload your public key to GitHub as a Signing Key (not Authentication): Settings > SSH and GPG keys > New SSH key > Key type: Signing Key.
SSH agent (avoid passphrase prompts)¶
If your key has a passphrase, start the SSH agent so signing happens silently:
On Windows, you can also enable the OpenSSH Authentication Agent service
(via Set-Service ssh-agent -StartupType Automatic in an elevated PowerShell)
for persistence across sessions.
Verify with echo "test" | git commit-tree HEAD^{tree} -S — a commit hash without passphrase prompt means signing works.
Verify signatures locally (one-time)¶
Producing signatures and verifying them locally are separate steps. Without an
allowed-signers file, git log --show-signature (and %G?) reports N for your
own SSH-signed commits — they are signed, but Git has nothing to verify against.
To make local verification resolve to G:
echo "$(git config user.email) $(cat ~/.ssh/<your-key>.pub)" >> ~/.ssh/allowed_signers
git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
This is purely local; GitHub verifies independently regardless. Note: commits
merged via GitHub's squash button are re-signed with GitHub's own key and show
E locally ("no public key") — that is expected, not a failure.
Code Style¶
See sdd/DESIGN.md for the full code style conventions.
- Formatter/linter: ruff (line-length 120)
- Type checking: mypy strict mode
- Tests: pytest with
@pytest.mark.spec("ID")markers for spec traceability - Coverage: Target >= 95%
Test Requirements¶
See sdd/TESTING-RUNBOOK.md for how to run the test
stages, invoke live-cloud tests, and record or refresh cassettes. See
sdd/TESTING.md for testing quality rules. Spec
traceability and test-per-spec obligations are in
sdd/000-process.md § Rules (Rules 1–2 cover spec-test traceability).
- Run
pytest -m specto verify all spec-derived tests pass - Run
pytest --cov=remote_storefor coverage reports
Tests gated on RS_TEST_LIVE_HNS=1 require a real Azure Data Lake Storage
Gen2 account. See Azure HNS account setup
for the provisioning recipe.
Tests gated on RS_TEST_LIVE_S3=1 require a real AWS S3 account. See the
"Required IAM permissions" section in
tests/backends/fixtures/s3_live.py for the IAM policy needed by the
test user.
Examples and Notebooks¶
The examples/ directory contains runnable Python scripts that are validated in CI. Example scripts must remain self-contained and use tempfile.TemporaryDirectory for cleanup.
Jupyter notebooks in examples/notebooks/ are validated in CI via
hatch run notebooks (code cells executed with exec(), no Jupyter needed).
Visual output is not checked — the runner validates that cells execute without
errors.
Dependency drift guard¶
The drift guard is one of the scheduled/automated CI guards; for the full
inventory of those guards, where each one's finding lands, and the durable-TODO
principle they follow, see sdd/CI-OPERATIONS.md.
Every [<extra>] in pyproject.toml declares a floor, and a ceiling only
where a known-incompatible major looms — see the comment on
[project.optional-dependencies] in pyproject.toml for the authoritative
per-extra ranges. .github/workflows/drift-guard.yml runs weekly (Monday 07:00 UTC):
it re-resolves each remote-store[<extra>] with pip install --upgrade --pre,
diffs against the committed baselines in infra/drift-locks/, runs the
smoke targets in scripts/drift_smoke_map.py on every leg,
and reconciles a single rolling GitHub issue. The workflow never edits
pyproject.toml and never opens a pin-update PR — it is early warning,
not automated remediation.
A second lane watches the other end of each range. It installs every extra at
the floor of every range it declares — uv pip install --resolution
lowest-direct, which takes the floors from pyproject.toml rather than from a
second copy of them — on the oldest interpreter requires-python admits, and
runs that extra's smoke against it. Its legs exit 0 whatever they find: a floor
finding is a decision about a published range, so it belongs on the rolling
issue beside its owner rather than in a red X. infra/drift-locks/KNOWN-FINDINGS.md
records the findings either lane already reports and somebody owns, so a new one
is distinguishable from a known one; delete a row there in the same change that
lands the fix. The issue splits a floor finding three ways, and only one of
them is not about the floor: installs, then breaks means the floor is too low —
raise it, following the bump table in
§ When to bump, which prices a floor raise by how old the
releases it newly excludes are; does not install is a decision owed on a
published range, raise it or record why it stands; plugins cannot coexist is
a gap in the smoke's own tooling and says nothing about the floor. The floor
lane resolves but writes no lock:
pyproject.toml already holds the claim, so there is nothing to commit.
When you deliberately bump a floor (e.g. paramiko>=3.1 after a
known-breaking upstream release), refresh the baseline in the same PR:
hatch run drift-check refresh-baseline <extra> # regenerate the lock
hatch run drift-check render-docs # regenerate the docs page
Then commit infra/drift-locks/<extra>.txt and
docs-src/reference/tested-versions.md. Run on Linux with Python 3.13: a lock
is OS-specific as well as Python-specific, and resolving elsewhere can pull
platform-conditional dependencies the workflow never sees, which the next weekly
run then reports as drift.
A floor bump has no run-derived shortcut — the drift-guard run resolves from
master, so its published resolution predates your change. If you cannot resolve
on Linux 3.13, use a container or ask a maintainer.
infra/drift-locks/README.md § Refreshing after a deliberate floor bump
is authoritative.
hatch run drift-check refresh-baseline all refreshes every extra at once.
Versioning¶
This project follows Semantic Versioning. Pre-1.0, minor bumps may contain breaking changes.
What that promises a user — the stability tiers, the public API surface it
covers, and why dependency ranges are shaped the way they are — is published
at docs-src/explanation/dependency-policy.md.
This section covers the maintainer half: which change earns which bump, and
how to make it.
When to bump¶
| Change type | Bump | Examples |
|---|---|---|
| New public API, feature, or backend | minor (0.X.0) |
Store.to_key(), new backend, new config loader |
| Bug fix, internal refactor | patch (0.0.X) |
Fix round-trip bug, update retry logic |
| Breaking API change (pre-1.0) | minor (0.X.0) |
Remove method, rename parameter |
| Breaking API change (post-1.0) | major (X.0.0) |
— |
| Dependency floor raised, excluding only versions 2+ years past their own release | patch (0.0.X) |
sqlalchemy>=2.0 → >=2.0.31 where 2.0.30 is older than 2 years |
| Dependency floor raised, excluding a version younger than that | breaking → minor (0.X.0) pre-1.0 |
Raising a floor past a release still inside its support window |
| Dropping a Python version | breaking → minor (0.X.0) pre-1.0 |
Raising requires-python, dropping a classifier |
| CI, docs, metadata-only | no bump | Add classifier, update README |
The three dependency and interpreter rows above are the maintainer side of Rules 8 and 9 on the published dependency policy. That page tells users a Python version is supported for as long as CPython ships security fixes for it, and a dependency version for at least 2 years after its own release; these rows are where that promise is honoured or broken.
Dropping an interpreter carries the same obligation as raising a floor, and
for the same reason: a user on the interpreter being dropped, whose install
stops resolving, is in exactly the position of one excluded by a floor raise. So it earns a **Breaking**
CHANGELOG entry and a ## vPREV to vX.Y.Z section in
docs-src/reference/migration.md, both in the
change that drops it. Pre-1.0 the bump itself is minor, which both rows now
state outright rather than delegating: what makes the change breaking is the
obligation, not the version number it lands on. It also moves
every spelling of the supported set at once; the ripple-check's
Supported interpreter set row
enumerates them and says, per spelling, what watches it — and it is worth
reading rather than summarising here, since no check compares the full set
across its groups and README.md's prose is watched by nothing at all.
Which artefact governs each of the two support windows¶
sdd/DRIFT-RULES.md Rule 4 asks for this in writing, beside the contract it
arbitrates:
- The supported interpreter set is the
Programming Language :: Pythonclassifiers inpyproject.toml, declared in the comment above them.requires-pythonandci.yml'sALL_PYTHONSare narrower or derived, not co-equal.scripts/python_support.pyholds each version's release date, which is the one hand-kept input. - Rule 9's two-year test is the declared floors in
pyproject.tomlagainst PyPI's upload dates, andpyproject.tomlgoverns: a floor is what a user resolves against, and PyPI supplies only the date.hatch run check-support-windowsderives the answer — see Phase 0 below.
Stability tiers¶
The tier table moved to
docs-src/explanation/dependency-policy.md § Stability tiers,
which is where a user looks for it. It is authoritative there; this heading
remains so existing references to CONTRIBUTING.md § Stability tiers still
land somewhere useful.
How to bump¶
Version is managed with bump-my-version. It modifies the files listed in [[tool.bumpversion.files]] in pyproject.toml in-place without committing or tagging. The release checklist below handles the commit and tag lifecycle.
Quick reference for the command syntax:
bump-my-version bump patch # 0.4.1 → 0.4.2
bump-my-version bump minor # 0.4.1 → 0.5.0
bump-my-version bump major # 0.4.1 → 1.0.0
Consistency Checklists¶
Documentation, examples, and metadata live in many places. Use these to keep them in sync.
- New backend: see § Adding a New Backend above.
- New extension: see § Adding an Extension above (12-step checklist).
- New Store method / cross-reference validation: see the ripple-check table in
sdd/CLAUDE-REFERENCE.md. - Pre-PR validation: run the PR validation gates —
hatch run allfor a code change, or the lighterhatch run lint+hatch run docs-gatefor a docs/specs-only change — then verify CHANGELOG and BACKLOG are updated and check the ripple-check table insdd/CLAUDE-REFERENCE.md. Runninghatch run allis always a safe superset if you would rather not classify the diff.
Release¶
Phase 0: Pre-flight¶
- [ ] Master is clean:
git statusshows no uncommitted changes - [ ] CI is green on master:
ci.yml(lint, typecheck, the tiered test jobs, examples, docs, package) and theci-full.ymlfull live-backend matrix on every supported interpreter — the full live-backend guarantee runs inci-full.yml, notci.yml - [ ]
hatch run allpasses locally (constituent scripts inpyproject.toml; the pre-commit gate variant deliberately does not enforce the 95% floor — CI does) - [ ]
## Release Blockersinsdd/BACKLOG.mdhas no open item. Any openBL-item,[ ]or[~], stops the release until it moves toBACKLOG-DONE.md; that section's Promise states the rule and the ID prefix table says what earnsBL- - [ ] No open
[~]items shipping in this release insdd/BACKLOG.md— complete and move toBACKLOG-DONE.md, or defer ([ ]). One shape neither remedy covers, and a third disposition for it: an item whose remaining work is a step of this release that can only run after the tag exists — updating a downstream package channel from the published artifact is the standing example — can neither be completed (the step has not happened) nor honestly deferred (the rest of it shipped). Ship that item[~], and record in its body why neither remedy fits and what closes it. Deliberately narrow: an item that could be finished or deferred takes one of the first two, and "it is nearly done" is not this case - [ ]
hatch run report-trace-outcomesread, and the open trace-outcome revisit item insdd/BACKLOG.mdclosed against it — that item names the successor which becomes the next release's ticket, and states what to record: the corpus totals (the report keeps no history, so that line is the baseline the following release differences against), the references selected, and a decision of act, defer, or accept for each. A report, never a gate: nothing here blocks the release, but the decision is written down rather than remembered - [ ]
hatch run report-done-lengthread, and the open backlog item that schedules it closed against it: whether completed entries that link a dossier stayed short under review alone, decided as keep, gate or reverse. Also a report, never a gate - [ ]
[Unreleased]section in CHANGELOG.md is non-empty - [ ] Support windows checked against Rules 8 and 9, which now have mechanisms behind them rather than this line's memory:
- Rule 9, the dependency window: run
hatch run check-support-windows. It compares every user-facing extra's floors against the previous tag's, and for each floor that moved it names the newest release the raise newly excludes and how old that release is. Exit 0 means every raise is patch-eligible; exit 1 means at least one excludes a version younger than 2 years — that entry is marked**Breaking**and carries a migration section — or that a raise could not be dated, which is investigated rather than assumed compliant. Not in any gate, deliberately: it fetches release dates from PyPI, so it cannot run offline or on every pull request. Read the date it prints alongside the verdict; the answer depends on the day it ran - Rule 8, the interpreter window: open the chart on the dependency policy page. Each bar runs from a version's release to the end of its security support and the vertical line is today, both drawn when the page is viewed, so it is current whenever you look — which is why it rather than the issue is this step's surface. A crossing is surfaced on the rolling
[drift-guard]issue, whose Support windows section carries the same rows with days remaining: a version past its window with no row ininfra/drift-locks/PYTHON-SUPPORT.mdholds that issue open, and is a decision this release either takes or registers. So a closed issue is itself the answer that nothing has crossed — the section is only written when the run has a body to write, so do not read a missing one as a missing check, or an old one as today's. Advisory either way: a closed window licenses a drop, it never requires one — nothing is broken on the day one closes requires-pythonis covered by the same obligation as a floor. If this release raises it or drops a classifier, the entry is marked**Breaking**and carries a migration section, per the interpreter row in the bump table above. A user whose install stops resolving is in the same position whichever of the two moved- [ ] Decide bump level (patch / minor / major) per the table above
Phase 1: Content freeze¶
- [ ]
sdd/BACKLOG-DONE.md: all shipping items moved here under the## Unreleasedheading, each marked[x](Phase 2 versions the heading). First in this phase deliberately: every later bullet that reads this section must find it complete —CHANGELOG.md [Unreleased] is complete, whose gate compares against it, andCHANGELOG.md [Unreleased] condensed, which takes each entry's prose from the item bodies in it and the dossiers they link. Sweep late and those run against a section that is not yet complete. Stated as a property rather than a roster on purpose: this phase has been reordered, and any list of which bullets sit where goes stale on the next move while the property does not. - [ ] CHANGELOG.md
[Unreleased]is complete — every completed item has a stub line (see ripple-check row CHANGELOG entry).scripts/check_changelog_unreleased.py(inlintanddocs-gate) holds part of this per PR: one entry per ID, each a single- <ID>: <text>line within its prose budget, and an entry for every completed item whoseaudiencecarries auser.tag. Three things it cannot decide are this line's, and they are why the line stays: whether a stub says the right thing; an entry owed by an item still open insdd/BACKLOG.md; and thecontributor.processclause insdd/traces/_schema.yml, where an item with nouser.tag still owes an entry if it introduced a user-facing framework or spec - [ ] CHANGELOG completeness cross-check:
gh api repos/haalfi/remote-store/releases/generate-notes -f tag_name=vX.Y.Z -f previous_tag_name=vPREV -f target_commitish=master --jq .bodylists every merged PR sincevPREV— confirm each user-facing PR maps to a CHANGELOG[Unreleased]entry (internal/tooling/dependabot PRs need none). Safety net over the per-PR CHANGELOG discipline; the generated text is discarded, not used for the release body (which stays CHANGELOG-derived — Phase 4) - [ ]
docs-src/reference/migration.md: every[Unreleased]entry marked Breaking, and any entry whose behaviour change a caller must act on, has a## vPREV to vX.Y.Zsection. The PR making the break owes that section, writes it in the same PR, and links it from the CHANGELOG entry — the link is whatscripts/check_breaking_migration_link.py(inlintanddocs-gate) enforces, anchor included — the fragment must name a##heading the guide really has, since nothing else in the repo validates it, and it is the only direction available, sincecheck_no_tracker_refs.pybars the published guide from citing the entry it answers. The bump table above fixes the version pair for a pre-1.0 breaking change, so it is knowable at authoring time, long before Phase 2 stamps it; the ripple-check's Breaking change row is where an author meets that obligation. This line is the backstop for the ones that arrive without it, and the place to correct a pair a PR guessed wrong. Above the condensing bullet deliberately: it keys on[Unreleased]entries carrying a**Breaking**marker, and condensing drops those markers along with the stub shape — run it after and it has nothing left to read, while condensing itself needs the guide complete to link from the entries a caller must act on. The softer half — an entry a caller must act on that carries no Breaking marker — is a judgment no marker decides and stays this checklist's to make - [ ] CHANGELOG.md
[Unreleased]condensed — stubs expanded to prose at release time, into the section order below. By hand: no tooling performs this, and "release skill Phase 1" is not a delegation — that skill carries agent notes, not the procedure, which is this line's. Three sources, in the order you reach for them: the stub says what shipped; the item's entry undersdd/BACKLOG-DONE.md§ Unreleased carries the mechanism and the figures, or, when it links a dossier undersdd/backlog/, that dossier does;docs-src/reference/migration.mdcarries what a caller must change. It is a rewrite for a different audience, not an extraction from any of them — a backlog body argues the change to a contributor at whatever length that took, and the released entry tells a user what changed and what to do about it. Shape each entry- **<what changed>** (<ID>): <consequence for the caller>, and link the migration guide from anything a caller must act on. Add the###groupings before condensing any bullet. In that ordercheck_changelog_unreleased.pystands down the stray-line rule, the audience rule and its unknown-ID note — three things, which it names on every run; condense first and it reports every condensed line until the grouping lands. Uniqueness and the prose budget keep running either way, over whatever still parses as a stub. Nothing in this checklist runs that gate before Phase 3, which is after Phase 2's rename — this matters only if you runhatch run lintorallby hand while the section is half-condensed - [ ]
FEATURES.mdupdated for this release: backends, extensions, capabilities, extras — this is the only time FEATURES.md is edited (do NOT update the version header;bump-my-versionhandles it in Phase 2) - [ ] README.md: backends table, installation extras, API table, badges are current
- [ ] Specs vs code: spot-check shipped features match their specs (
pytest -m specas proxy) - [ ] Examples:
hatch run examplespasses; manually review notebooks if API surface changed - [ ] Guides: new/changed backend guides are accurate
- [ ] DEVELOPMENT_STORY.md: add a section for this release (pre-1.0 only)
CHANGELOG section order¶
Added → Changed → Fixed → Removed → Documentation → Internal. Omit
any section with no entries.
This is the one home for that order. Phase 1 condenses into it and Phase 4's
release body follows it; both link here rather than restating it, per
CLAUDE.md § Principles principle 4.
It is decided, not merely described. Tally the pairwise precedence of ###
headings across the released ## [ sections of CHANGELOG.md and most of this
order is what practice already does: Documentation precedes Internal without
exception, and Added leads in all but a couple of early releases. One
adjacent pair came out an exact tie — Changed against Fixed — so that pair
had no convention, and the line above is what it has instead of one. Removed
is too sparse to settle by count at all, and is placed by meaning: after the
changes it completes. Run the tally rather than trusting any of that: it moves
every release, and the characterisations above go stale on exactly the release a
numeral would. They are here to show the order was derived rather than invented —
the order itself is decided and does not need practice to keep vouching for it.
Where this diverges from Keep a Changelog, whose order is Added, Changed,
Deprecated, Removed, Fixed, Security: Removed sits after Fixed here rather
than before it, and Documentation and Internal are additions of ours with no
counterpart there. The tie this order settles, Changed before Fixed, agrees
with it. Deprecated and Security are not in the order above and have never
been used — a security fix has always gone under Fixed, and a deprecation
under Changed. If a release needs either as its own section, place it here
first: the point of one home is that the next person does not have to guess.
Two names are retired rather than reused. Docs (0.16.0, 0.17.0) is
Documentation; Known Limitations (0.1.0) was a one-off. Sections already
published keep the names and order they shipped with — this governs the next
release forward, and nothing checks it mechanically.
Phase 2: Version bump (on a release branch)¶
- [ ] Create release branch:
git checkout -b release-vX.Y.Z - [ ] CHANGELOG.md: rename
[Unreleased]to[X.Y.Z] - YYYY-MM-DD, add fresh empty[Unreleased]above - [ ]
sdd/BACKLOG-DONE.md: rename## Unreleasedto## vX.Y.Z, add a fresh empty## Unreleased(*(none)*) above - [ ] Update
date-releasedinCITATION.cffto today (bump-my-version only updatesversion:, not this field) - [ ] Tagline consistent across every mirror:
git grep "Write file storage code once"— check them all, do not work from a remembered list; the copies nobody lists are the copies that go stale. Historical quotes insdd/research/are the one exemption - [ ] Keywords consistent:
pyproject.toml=CITATION.cff - [ ] Conda recipe: update
context.versioninpackaging/conda-forge/recipe.yamlto X.Y.Z, thenhatch run gen-conda-feedstock— the recipe is not in[[tool.bumpversion.files]], so this edit is by hand, and the generatedpackaging/conda-forge/feedstock/recipe.yamlbeside it does not regenerate itself. Phase 3'shatch run allgates on its freshness, so skipping it fails the next phase - [ ]
bump-my-version bump patch|minor|major --allow-dirty(modifies the files listed in[[tool.bumpversion.files]]inpyproject.toml— does NOT commit or tag;--allow-dirtyis required because the Phase 1/2 edits above are still uncommitted) - [ ]
hatch run gen-graph(stampssource_version+snapshotindocs-src/_data/graph/graph.jsonfrom the bumped version) - [ ]
hatch run gen-features(regenerates mechanical sections ofFEATURES.mdfrom updatedgraph.json) - [ ]
hatch run gen-graph-viz(regeneratesdocs-src/explanation/graph_viz.htmlfromgraph.json;hatch run allgates on its freshness, so skipping it fails Phase 3) - [ ] Review and commit:
git diffto verify nothing unexpected, thengit add -Aand commit asRelease vX.Y.Z
Phase 3: Validate¶
- [ ]
hatch run allpasses (constituent scripts inpyproject.toml) - [ ]
hatch run test-cov-strictpasses locally with Azurite running (enforces the 95% floor thathatch run alldeliberately skips) - [ ]
mkdocs build --strictpasses - [ ]
hatch build && hatch run twine check dist/*— package builds cleanly (notpython -m build:buildis not in the hatch env) - [ ]
pip install dist/*.whl && python -c "import remote_store; print(remote_store.__version__)"— version matches - [ ] Conda recipe: version in
packaging/conda-forge/recipe.yamlmatches release version, andpackaging/conda-forge/feedstock/recipe.yamlcarries it too (hatch run allfails on a stale copy, so a green Phase 3 already proves the second)
Phase 4: Ship¶
Automated by skill agent (/release). User role: review and merge PR only.
- [ ] [Agent] Push release branch to origin
- [ ] [Agent] Create PR with link to checklist
- [ ] [User] Review, approve, and merge PR to master — then notify agent
- [ ] [Agent] Confirm CI green on merge commit — including the Codecov upload from the
coverage-gatejob (the badge reuses this run;publish.ymlno longer recomputes coverage) - [ ] [Agent] Create GitHub Release directly on GitHub using template (no local tags) — this triggers
publish.yml(PyPI) and versioned docs deploy - [ ] [Agent] Watch
publish.yml— confirm it completes successfully (PyPI publish) - [ ] [Agent] Delete the release branch
Release template: title = version, description = "What's Changed" header whose sections are condensed further from the release's own CHANGELOG section, in the CHANGELOG section order, plus two links (CHANGELOG.md + git version diff). See .claude/skills/release/SKILL.md § Phase 4 for full template.
Phase 5: Post-release verification¶
- [ ] PyPI:
pip install remote-store==X.Y.Zin a fresh venv, verify version and README renders on pypi.org - [ ] GitHub Pages: check version switcher shows new version as "latest"
- [ ] ReadTheDocs: check https://docs.remotestore.dev/stable/ shows the new version (RTD automation rule activates tag-based builds;
stableis the default version) - [ ] Conda recipe: fetch sha256 from PyPI (
curl -s https://pypi.org/pypi/remote-store/X.Y.Z/json | python -c "import sys,json; d=json.load(sys.stdin); print([f['digests']['sha256'] for f in d['urls'] if f['filename'].endswith('.tar.gz')][0])") and updatesource.sha256inpackaging/conda-forge/recipe.yaml - [ ] Commit the
packaging/conda-forge/recipe.yamlsha256 update in this repo via a branch and PR.hatch run lintregenerates nothing for you — runhatch run gen-conda-feedstockand commitpackaging/conda-forge/feedstock/recipe.yamlin the same PR, or the gate fails - [ ] conda-forge: copy
packaging/conda-forge/feedstock/recipe.yaml— the generated file, whole — ontoconda-forge/remote-store-feedstock, followingsdd/CONDA-FORGE.md, which owns the routes, the rules and the walkthrough. Nothing is stripped or reassembled by hand any more;build.numberis the only edit on the far side. Done whenconda search -c conda-forge remote-storelists the new version, not when the PR is open: the upload happens after merge and can fail. Whether the bot has fired shows on conda-forge's version-update status; it can take hours - [ ] Once that PR is merged,
python scripts/drift_feedstock.pyreportsmatch. It reads the feedstock'smain, so this is the first point at which it can — and it is the same comparison the weekly drift guard then makes on its own - [ ] Announce if applicable (tracking issues, users)