Skip to content

release: prepare content-addressable 0.1.0 - #74

Merged
hartsock merged 9 commits into
mainfrom
release/0.1.0
Aug 5, 2026
Merged

release: prepare content-addressable 0.1.0#74
hartsock merged 9 commits into
mainfrom
release/0.1.0

Conversation

@hartsock

@hartsock hartsock commented Aug 5, 2026

Copy link
Copy Markdown
Owner

release: prepare content-addressable 0.1.0

First final release — freezes the core content-addressing contract for the
0.1.x line and publishes the Rust core to crates.io + the Python distribution
to PyPI via the hardened, gated release workflow.

No package, tag, or GitHub release has been published by this PR. It only
prepares main. v0.1.0 is cut after this merges and a human authorizes it.

Provenance

  • Base (origin/main): 7678df4db8d66b93290fe020d3dcaa91169a1ab7
  • Head (release/0.1.0): 195eb7d3d0edc410dc0036b67788974f24b3325d
  • 0.1.0 is absent from crates.io (only 0.1.0-alpha.1) and PyPI (only 0.1.0a1). No v0.1.0 tag exists.

Why this PR is large: it also lands the release hardening

The hardening machinery this release depends on had not yet landed on main, so
it is folded in here (per the release runbook). Two concerns, clearly separated
in the commits:

  1. Release hardening (e45a216a6e8a6e, 195eb7d): canonical version
    verifier, common pre-publish gate + provenance, SHA-pinned actions +
    least-privilege permissions + Dependabot, experimental-feature rename, aligned
    RELEASING, and a fix so cargo doc --workspace (a gate step) actually passes.
  2. 0.1.0 bump + de-alpha docs (17b9cfc, a4846c4): the three version
    strings, classifier, README/STABILITY/lib docs, CHANGELOG.

Stable contract (frozen for 0.1.x)

CIDv1 · dag-cbor (0x71) · BLAKE3 multihash (0x1e) · 32-byte digest ·
base32-lower bafyr4i… string · frozen binary envelope + bare-digest accessors ·
frozen Rust↔Python parity (golden tests/vectors.json, byte-identical to the
baseline — no frozen-vector drift
) · fail-closed CID-profile validation at
every ingress
(Rust FromStr/TryFrom<Cid>/from_bytes/serde; Python
parse/from_bytes).

⚠️ Experimental-feature decision — alpha→final MIGRATION

The optional features were renamed to make their instability unmistakable
(no compatibility aliases):

0.1.0-alpha.1 0.1.0
features = ["merkle"] features = ["unstable-merkle"]
features = ["store"] features = ["unstable-store"]

Module paths (content_addressable::merkle, ::store) and type names are
unchanged — only the feature flags moved. Default (frozen-core) users are
unaffected. Their bytes/API remain not frozen in 0.1.0.

Version changes

Cargo.toml, content-addressable-py/Cargo.toml0.1.0; pyproject.toml
0.1.0; classifier 3 - Alpha4 - Beta. Cargo.lock refreshed via cargo.
just verify-release v0.1.0 → OK; a wrong tag fails closed.

Test evidence (local, Linux x86_64 / py3.12)

cargo fmt --check, clippy --workspace --all-targets --all-features -D warnings,
cargo test --workspace [--all-features] --locked,
cargo doc --workspace --all-features --no-deps -D warnings,
cargo publish -p content-addressable --locked --dry-run, actionlint,
shellcheck, MSRV 1.85 build+test — all green.

Packaging evidence

maturin wheel (cp39-abi3 manylinux_2_34_x86_64) + sdist built; twine check
PASSED both. Clean-venv install of wheel and sdist: import resolves to
site-packages, task smoke passes, and the golden + profile-validation + README
suites pass 45/45 against each installed artifact. Contents scanned — no
secrets/paths/junk; LICENSE present; wheel ships a CycloneDX SBOM.

Local artifact SHA-256 (informational — the published checksums come from the
CI-built wheels):

  • content_addressable-0.1.0-cp39-abi3-manylinux_2_34_x86_64.whl19dfe5ce2ea2c752614b18203487b20092a3557b5a302814f4523fcfd2900bfe
  • content_addressable-0.1.0.tar.gzeda8774cdd45799ee4fd8bfdd95c768618124c7d86bdd8e2b526a6aef4057096

The full py3.9/3.13 × macOS/Windows/aarch64 matrix + sdist is exercised by the
release workflow's install-smoke/sdist-smoke jobs (CI-only).

Release workflow evidence

.github/workflows/release.yml — actionlint clean. provenance asserts tag ==
v<version>, tag == HEAD, and HEAD contained in main. Both publish-pypi
(OIDC, env pypi) and publish-crate (env crates-io) need: [release-gate],
which needs the whole DAG. A workflow_dispatch dry-run against this branch is
being run (no publish).

Registry prerequisites (human-only — CI cannot assert)

  • PyPI Trusted Publisher for hartsock/content-addressable, workflow release.yml, env pypi.
  • CARGO_REGISTRY_TOKEN scoped to content-addressable.
  • GitHub environments pypi and crates-io with required reviewers; main branch protection; v* tag protection.

Do NOT

Do not merge or cut v0.1.0 without explicit authorization. Draft/hold status is
governed by the release runbook.

Refs #14

hartsock and others added 9 commits August 4, 2026 19:52
Replace the fragmented, grep/sed-based version check (which read only two of the
three manifests and whose `${ver/-/}` PEP 440 mapping was wrong — it produced
`0.1.0alpha.1`, not `0.1.0a1`) with one repository-owned tool that both local dev
(`just verify-release`) and CI call.

`scripts/verify_release.py` (real TOML parser, fail-closed) reads all THREE
version declarations — root `Cargo.toml`, `content-addressable-py/Cargo.toml`, and
the root `pyproject.toml` (the actual path; `content-addressable-py/pyproject.toml`
does not exist) — and proves: both Rust manifests agree; `pyproject` is the
canonical PEP 440 of the Rust version (0.1.0->0.1.0, 0.1.0-alpha.1->0.1.0a1,
-beta.2->b2, -rc.3->rc3); and an optional tag is exactly `v<rust-version>`.
Missing files, duplicate-key/unparsable TOML, non-string versions, disagreement,
unsupported/ambiguous prereleases, and build metadata all exit non-zero with a
file-and-value diagnostic.

`tests/test_verify_release.py` (27 tests, run by CI's python job) covers valid
final/alpha/beta/rc, malformed, mismatched, missing, duplicate-key, and
unsupported cases. `just verify-release [tag]` is the single call site; the CI
release gate will call the same recipe (no second mapping in YAML/shell).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fact tests

Rebuild release.yml around ONE deterministic gate that both registries depend
on. Neither `cargo publish` nor PyPI publication can begin until `release-gate`
passes, and `release-gate` requires every releasable condition:

- provenance: `just verify-release <tag>` (the canonical three-manifest tool) +
  the tag points at HEAD + HEAD is contained in the protected branch
  `origin/main` (a tag on an unmerged/detached commit is refused). Full-history
  checkout; does not trust the ref name alone.
- rust-gate: `just check` + release-only supersets (`cargo clippy --workspace
  --all-targets --all-features -D warnings`, `cargo test --workspace --locked`,
  `--all-features --locked`, `cargo doc --workspace --all-features` with
  `-D warnings`) + `cargo publish -p content-addressable --locked --dry-run`.
- msrv: build + test on the pinned Rust 1.85.
- build-wheels / build-sdist: gated behind provenance + rust-gate.
- install-smoke: downloads each platform's BUILT wheel, installs it into a clean
  venv on {Linux x86_64, Linux aarch64, macOS universal2, Windows x64} x
  {py3.9, 3.12, 3.13}, asserts the import resolves to site-packages (NOT the
  source tree), and runs the golden-vector + fail-closed-parsing + README tests
  against the INSTALLED package. `twine check` is kept but is no longer treated
  as an install test.
- sdist-smoke: installs the sdist (build-from-source) and runs the same tests.

Both publish jobs `needs: [release-gate]` and consume the EXACT validated
artifacts (download-artifact; no rebuild). Previously `publish-crate` depended
only on the old grep-based `verify`, so a broken Python build or a failing test
could not stop a half-completed dual-registry release.

Security: top-level `permissions: {}` with explicit least-privilege per job
(`contents: read`; PyPI `id-token: write` only; no write scope unused). Every
third-party action is pinned to a full commit SHA with the tag in a trailing
comment. Both publish jobs run in protected environments (`pypi`, `crates-io`).
actionlint + shellcheck clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Supply-chain + least-privilege hardening for the CI workflow, matching the
posture already applied to release.yml.

Action pinning
  Every third-party action in ci.yml was pinned from a moving ref (@v4,
  @stable, @1.85, @v2, @v5) to a full commit SHA with a human-readable version
  in a trailing comment. A moving tag can be silently repointed at malicious
  code; a SHA cannot. The SHAs match release.yml exactly so the two workflows
  stay in lockstep:
    actions/checkout            @11bd719 # v4.2.2
    dtolnay/rust-toolchain      @2c7215f  (toolchain: stable / "1.85")
    Swatinem/rust-cache         @98c8021 # v2.8.0
    actions/setup-python        @a26af69 # v5.6.0
  dtolnay/rust-toolchain now takes an explicit `toolchain:` input (the ref name
  is no longer the toolchain selector once pinned to a SHA).

Least privilege
  Top-level `permissions: {}` drops the default GITHUB_TOKEN to nothing; each
  job re-grants only `contents: read`. No CI job writes to the repo, publishes,
  or needs an OIDC token, so nothing else is granted.

Dependabot
  New .github/dependabot.yml opens weekly reviewable PRs for the github-actions
  ecosystem (grouped) plus cargo and pip, with rebase disabled so it never
  force-pushes over a branch under review. SHA pins are safe but frozen — this
  is how they pick up upstream security fixes without landing silently.

Validation: actionlint clean on ci.yml and release.yml; dependabot.yml parses
as a valid v2 config; no moving-ref action pins remain in either workflow.

Co-authored-by: Claude <hartsock@users.noreply.github.com>
Rename the two experimental, default-off cargo features so their names carry
the stability signal at the call site:

    merkle -> unstable-merkle    (src/merkle.rs, MerkleNode<T>)
    store  -> unstable-store     (src/store.rs, NodeStore seam)

A developer copy-pasting `features = ["merkle"]` from an example had no signal
that the serialized node bytes / trait API are NOT frozen for 0.1.x. The
`unstable-` prefix is the Cargo convention (cf. `unstable-*` in tokio, serde)
for "this may change under you"; the frozen 0.1.x contract is exactly the
default feature set, and now nothing outside it can be enabled without typing
`unstable-`.

No compatibility aliases: there is no released 0.1.0 yet, so a clean rename is
correct — leaving `merkle`/`store` as aliases would re-introduce the very
footgun this removes. Pre-1.0 alpha, this is not a breaking change to any
published surface.

Scope of the rename (feature FLAG only):
  - Cargo.toml [features] keys + their doc comments
  - every #[cfg(feature = ...)] / #![cfg(feature = ...)] in src/ and tests/
  - README install snippets, the compatibility table, and prose that names the
    feature to enable
  - src/lib.md, docs/STABILITY.md feature sections
  - justfile + .github/workflows/ci.yml explanatory comments

Deliberately UNCHANGED (not the flag): the Rust module paths
`content_addressable::merkle` / `::store`, the type names (`MerkleNode`,
`NodeStore`, ...), the rustdoc intra-doc links `[`merkle`]` / `[`store`]` (they
resolve to the still-named modules), and the crates.io "merkle" domain keyword.
The feature that ENABLES a module and the module's NAME are intentionally
distinct (the common Cargo pattern), which also makes the enable-line read
clearly: `unstable-store` turns on the `store` module.

Validation: `just check` green (fmt, clippy -D warnings --all-features, test,
rustdoc -D warnings --all-features, leaf guard). `cargo test` (default) proves
both features stay OFF; `cargo test --all-features` compiles + runs both under
the new gate names (34 core + 29 store + 6 merkle/store integration tests pass).

Co-authored-by: Claude <hartsock@users.noreply.github.com>
RELEASING.md described a two-file, single-`verify`-job release that no longer
matches the hardened workflow, and its prerelease example used a spelling the
release gate now REJECTS. Rewrite it to the actual, gated reality.

Corrections
  - "two version strings" -> THREE files (root Cargo.toml, the PyO3 crate's
    Cargo.toml, and pyproject.toml). The table already listed three; the prose
    contradicted it.
  - Prerelease spelling: the old example `0.1.0-rc1` / tag `v0.1.0-rc1` is
    rejected by scripts/verify_release.py (SemVer prerelease numbers require a
    dot). Corrected to `0.1.0-rc.1` -> `0.1.0rc1`, tag `v0.1.0-rc.1`. Every
    SemVer->PEP 440 pair in the doc was checked against the real tool.

Added
  - The full pre-publish gate DAG and a job-by-job table: nothing publishes
    unless provenance + rust-gate + msrv + wheels + sdist + install-smoke +
    sdist-smoke all pass through the single `release-gate` aggregator.
  - Provenance/branch-containment: the tag must be `v<Cargo.toml version>`,
    `HEAD` must be the tag commit, and that commit must be contained in `main`.
  - The canonical SemVer->PEP 440 table, pointing at verify_release.py as the
    single source of the mapping (no second copy in YAML/shell).
  - Local dry-run (`just verify-release [v<version>]`) and the
    `workflow_dispatch` no-publish dry-run.
  - Partial-outage recovery: re-run only the failed publish; both consume the
    already-validated artifacts, so a re-run cannot emit different bytes. Never
    re-tag or hand-publish to patch a partial publish.
  - The second deployment environment `crates-io` (publish-crate uses it; the
    old doc only mentioned `pypi`), branch/tag protection, and a human-only
    configuration checklist for the settings CI cannot assert.
  - Classifier policy: keep `Development Status :: 3 - Alpha` until there is
    evidence for Beta/Stable; do not claim Production/Stable to look finished.

No version strings were changed — the version bump is deliberately a separate
commit/PR (documented here as such), keeping this release-hardening work off the
0.1.0 bump.

Co-authored-by: Claude <hartsock@users.noreply.github.com>
CLAUDE.md hook-parity rule: a pipeline edit triggers a hook audit. The feature
rename (merkle -> unstable-merkle) updated the CI `test` job's step names; the
pre-push hook that mirrors that job still echoed the old `merkle` feature name
in three comment/echo lines. The command (`cargo test --all-features`) is
unchanged, so this is text-only parity — but the hook must read as the local
equivalent of the pipeline. shellcheck clean.

Co-authored-by: Claude <hartsock@users.noreply.github.com>
First final release of the frozen ContentId contract. Bump all three
authoritative version declarations from the alpha to 0.1.0:

    Cargo.toml                        0.1.0-alpha.1 -> 0.1.0
    content-addressable-py/Cargo.toml 0.1.0-alpha.1 -> 0.1.0
    pyproject.toml                    0.1.0a1       -> 0.1.0

Cargo.lock refreshed via `cargo update -p ... --precise 0.1.0` (Cargo tooling,
not a hand edit). The canonical verifier agrees:

    just verify-release          -> Rust '0.1.0' == PEP 440 '0.1.0'
    just verify-release v0.1.0    -> OK (tag matches)
    just verify-release v0.1.0-rc.1 -> FAILS closed (tag mismatch)

Isolated version-only commit; de-alpha documentation, the classifier bump, and
the changelog follow in a separate commit.

Co-authored-by: Claude <hartsock@users.noreply.github.com>
Make the release-facing documentation and metadata describe a shipped, final
0.1.0 instead of an in-progress alpha, and resolve one doc contradiction.

Metadata
  - pyproject.toml classifier: Development Status 3 - Alpha -> 4 - Beta. Beta,
    not Production/Stable: the core is frozen for 0.1.x but the optional
    unstable-* features are still moving, and there is not yet a stable track
    record to claim Production/Stable.

De-alpha prose (now "0.1.0, the first release that freezes the core contract")
  - README status section + all install snippets (0.1.0-alpha.1 -> 0.1.0).
  - docs/STABILITY.md package-status line.
  - src/lib.md crate-level stability section.
  - src/merkle.rs: the Merkle-byte-freeze target "0.1.0-rc1" -> "a later 0.1.x
    release" (merkle bytes stay unstable in 0.1.0, honestly).
  - Cargo.toml unstable-merkle comment.
  - RELEASING.md classifier/maturity note (now describes 4 - Beta).
  - tests/common/mod.rs: corrected an inaccurate "ContentId serde not frozen
    during 0.1.0-alpha" aside — the ContentId serde repr IS frozen; the shared
    JSON vector format simply carries no native CID type, so it is exercised in
    the per-language suites.

Doc contradiction resolved
  - docs/MERKLE_ROADMAP.md framed "0.1.0 = catalog complete" on a 0.0.x
    micro-release channel that never happened. That framing is now marked
    SUPERSEDED: 0.1.0 shipped as the FROZEN CORE contract with the catalog
    riding 0.1.x behind the experimental unstable-* features. Historical ladder
    (approved 2026-07-21) is preserved with the superseding note.

CHANGELOG.md (new) — 0.1.0 release notes: first stable-contract release; CIDv1 +
dag-cbor + BLAKE3 32-byte profile; canonical dag-cbor; frozen Rust/Python
presentation; golden cross-language vectors; fail-closed profile validation at
every ingress; checked/unchecked byte constructors; verification behavior; MSRV
1.85; Python 3.9+ abi3; unstable-merkle/unstable-store status; the
alpha-to-final feature-rename migration (merkle->unstable-merkle,
store->unstable-store, no aliases); the incompatible SHA3-256/pickle sibling
warning; package-name vs import-name table; known limitations.

`just check` green at 0.1.0 (fmt, clippy -D warnings --all-features, test,
rustdoc -D warnings, leaf). No frozen vectors changed.

Co-authored-by: Claude <hartsock@users.noreply.github.com>
The rust-gate's `cargo doc --workspace --all-features --no-deps` step failed
(exit 101): the binding crate's lib is deliberately named `content_addressable`
(so the installed Python import name is correct), which collides with the core
crate's lib of the same name — rustdoc refuses to write two crates to the same
output path. The gate would have gone red on its first real run; only
`just check` (non-workspace doc, core crate only) had been exercised before.

Fix: `[lib] doc = false` on content-addressable-py. Its API is documented on the
Python side, not via rustdoc, and the collision made it undocumentable via
`--workspace` regardless. `cargo doc --workspace` now documents only the core
crate (exit 0). No effect on maturin, clippy, or `cargo test --workspace`
(the binding is still built and tested).

Co-authored-by: Claude <hartsock@users.noreply.github.com>
@hartsock
hartsock merged commit 534d96d into main Aug 5, 2026
31 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant