Skip to content

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

  1. 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."
  2. Rules: Numbered, mandatory constraints.
  3. 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:

  1. Explanation or rationale (put in ADRs, specs, research docs, or DEVELOPMENT_STORY.md)
  2. History or changelog-style notes
  3. 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:

  1. Propose: Open a PR with an RFC in sdd/rfcs/ (see rfc-template.md). No code yet.
  2. Review: Maintainers and community review for design fit and completeness.
  3. Accept: The RFC graduates to a spec in sdd/specs/. It now defines the contract.
  4. 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.

  1. Write a spec in sdd/specs/ or as an addendum in sdd/specs/backends/<name>.md
  2. Implement Backend ABC in src/remote_store/backends/_<name>.py
  3. Register a fixture in tests/backends/fixtures/ (declare it in backends.toml / fixtures.toml and add a per-fixture factory module)
  4. 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_conformance and are validated by a Dafny-compiled oracle — see sdd/formal/README.md § Compiled Oracle
  5. Add user-facing guide in docs-src/guides/backends/<name>.md and add to docs-src/guides/_nav.yml
  6. Update every backend enumeration — see Backend order below for how to find them all and what order they go in
  7. Add backend config example to examples/configuration/configuration.py
  8. If the backend needs an extra, add it to pyproject.toml [project.optional-dependencies], and add the same floor to run_constraints in packaging/conda-forge/recipe.yaml, then hatch run gen-conda-feedstock (the shipped copy beside it is generated, and lint fails 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:

  1. Write an RFC in sdd/rfcs/, get it accepted as a spec in sdd/specs/
  2. Implement in src/remote_store/ext/<name>.py — define __all__
  3. Use only the public Store / Backend API (never _backend). Use unwrap() for native access
  4. Do not own Store lifecycle — never call store.close() or use with store:
  5. Let CapabilityNotSupported propagate — do not catch and suppress it
  6. Add tests in tests/ext/test_<name>.py with @pytest.mark.spec("ID")
  7. Write a user guide in docs-src/guides/<name>.md
  8. Add the page to docs-src/guides/_nav.yml (under the Extensions section)
  9. Add a runnable example in examples/
  10. The example docs page is auto-generated at tutorial/examples/<slug>.md from the module docstring via gen_pages.py — no manual wrapper file needed
  11. If the extension needs an extra, add it to pyproject.toml [project.optional-dependencies], and add the same floor to run_constraints in packaging/conda-forge/recipe.yaml, then hatch run gen-conda-feedstock (the shipped copy beside it is generated, and lint fails 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
  12. Update CHANGELOG.md and sdd/BACKLOG.md (or sdd/BACKLOG-DONE.md) in the same commit

Export patterns

Pure Python (no extra dependencies) — export unconditionally from remote_store.__init__:

# In remote_store/__init__.py:
from remote_store.ext.<name> import Foo, bar

Optional dependency — two guards are needed:

  1. 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
  1. 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 / Backend API
  • 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 lx v1.2.2 (affected lx ≤ v1.2.1). Earlier releases could silently drop the class header of decorated dataclasses from the -u -Y skeleton. On src/remote_store/_models.py, lx v1.2.1 dropped 4 of the 5 public data-model classes (FileInfo, WriteResult, FolderEntry, FolderInfo), orphaning their fields under ContentDigest and losing some fields entirely; the output still looked like valid Python, so the loss was easy to miss. lx v1.2.2 fixes it: re-running lx --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); on lx ≤ 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 -Y skeleton shows the public API only: lx intentionally 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).

rm -rf .venv                       # Linux / macOS
Remove-Item -Recurse -Force .venv  # PowerShell

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:

eval $(ssh-agent -s)
ssh-add ~/.ssh/<your-key>

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 spec to verify all spec-derived tests pass
  • Run pytest --cov=remote_store for 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 :: Python classifiers in pyproject.toml, declared in the comment above them. requires-python and ci.yml's ALL_PYTHONS are narrower or derived, not co-equal. scripts/python_support.py holds each version's release date, which is the one hand-kept input.
  • Rule 9's two-year test is the declared floors in pyproject.toml against PyPI's upload dates, and pyproject.toml governs: a floor is what a user resolves against, and PyPI supplies only the date. hatch run check-support-windows derives 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 all for a code change, or the lighter hatch run lint + hatch run docs-gate for a docs/specs-only change — then verify CHANGELOG and BACKLOG are updated and check the ripple-check table in sdd/CLAUDE-REFERENCE.md. Running hatch run all is always a safe superset if you would rather not classify the diff.

Release

Phase 0: Pre-flight

  • [ ] Master is clean: git status shows no uncommitted changes
  • [ ] CI is green on master: ci.yml (lint, typecheck, the tiered test jobs, examples, docs, package) and the ci-full.yml full live-backend matrix on every supported interpreter — the full live-backend guarantee runs in ci-full.yml, not ci.yml
  • [ ] hatch run all passes locally (constituent scripts in pyproject.toml; the pre-commit gate variant deliberately does not enforce the 95% floor — CI does)
  • [ ] ## Release Blockers in sdd/BACKLOG.md has no open item. Any open BL- item, [ ] or [~], stops the release until it moves to BACKLOG-DONE.md; that section's Promise states the rule and the ID prefix table says what earns BL-
  • [ ] No open [~] items shipping in this release in sdd/BACKLOG.md — complete and move to BACKLOG-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-outcomes read, and the open trace-outcome revisit item in sdd/BACKLOG.md closed 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-length read, 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 in infra/drift-locks/PYTHON-SUPPORT.md holds 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-python is 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 ## Unreleased heading, 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, and CHANGELOG.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 (in lint and docs-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 whose audience carries a user. 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 in sdd/BACKLOG.md; and the contributor.process clause in sdd/traces/_schema.yml, where an item with no user. 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 .body lists every merged PR since vPREV — 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.Z section. The PR making the break owes that section, writes it in the same PR, and links it from the CHANGELOG entry — the link is what scripts/check_breaking_migration_link.py (in lint and docs-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, since check_no_tracker_refs.py bars 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 under sdd/BACKLOG-DONE.md § Unreleased carries the mechanism and the figures, or, when it links a dossier under sdd/backlog/, that dossier does; docs-src/reference/migration.md carries 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 order check_changelog_unreleased.py stands 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 run hatch run lint or all by hand while the section is half-condensed
  • [ ] FEATURES.md updated for this release: backends, extensions, capabilities, extras — this is the only time FEATURES.md is edited (do NOT update the version header; bump-my-version handles 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 spec as proxy)
  • [ ] Examples: hatch run examples passes; 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 ## Unreleased to ## vX.Y.Z, add a fresh empty ## Unreleased (*(none)*) above
  • [ ] Update date-released in CITATION.cff to today (bump-my-version only updates version:, 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 in sdd/research/ are the one exemption
  • [ ] Keywords consistent: pyproject.toml = CITATION.cff
  • [ ] Conda recipe: update context.version in packaging/conda-forge/recipe.yaml to X.Y.Z, then hatch run gen-conda-feedstock — the recipe is not in [[tool.bumpversion.files]], so this edit is by hand, and the generated packaging/conda-forge/feedstock/recipe.yaml beside it does not regenerate itself. Phase 3's hatch run all gates 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]] in pyproject.toml — does NOT commit or tag; --allow-dirty is required because the Phase 1/2 edits above are still uncommitted)
  • [ ] hatch run gen-graph (stamps source_version + snapshot in docs-src/_data/graph/graph.json from the bumped version)
  • [ ] hatch run gen-features (regenerates mechanical sections of FEATURES.md from updated graph.json)
  • [ ] hatch run gen-graph-viz (regenerates docs-src/explanation/graph_viz.html from graph.json; hatch run all gates on its freshness, so skipping it fails Phase 3)
  • [ ] Review and commit: git diff to verify nothing unexpected, then git add -A and commit as Release vX.Y.Z

Phase 3: Validate

  • [ ] hatch run all passes (constituent scripts in pyproject.toml)
  • [ ] hatch run test-cov-strict passes locally with Azurite running (enforces the 95% floor that hatch run all deliberately skips)
  • [ ] mkdocs build --strict passes
  • [ ] hatch build && hatch run twine check dist/* — package builds cleanly (not python -m build: build is 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.yaml matches release version, and packaging/conda-forge/feedstock/recipe.yaml carries it too (hatch run all fails 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-gate job (the badge reuses this run; publish.yml no 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.Z in 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; stable is 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 update source.sha256 in packaging/conda-forge/recipe.yaml
  • [ ] Commit the packaging/conda-forge/recipe.yaml sha256 update in this repo via a branch and PR. hatch run lint regenerates nothing for you — run hatch run gen-conda-feedstock and commit packaging/conda-forge/feedstock/recipe.yaml in the same PR, or the gate fails
  • [ ] conda-forge: copy packaging/conda-forge/feedstock/recipe.yaml — the generated file, whole — onto conda-forge/remote-store-feedstock, following sdd/CONDA-FORGE.md, which owns the routes, the rules and the walkthrough. Nothing is stripped or reassembled by hand any more; build.number is the only edit on the far side. Done when conda search -c conda-forge remote-store lists 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.py reports match. It reads the feedstock's main, 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)