Skip to content

v0.7.3

Latest

Choose a tag to compare

@github-actions github-actions released this 11 Oct 02:21
· 8 commits to main since this release
cb72dc5

The release that makes lethe-delete's PyPI page correct. git diff v0.7.2..v0.7.3 -- lethe/ is empty — the library's code is unchanged — so what
ships here is the long description and the docs around it. That is the point
rather than a caveat: the description is the artifact that was broken. Every
relative link on the project page 404'd, and because a long description is
frozen into the uploaded artifact and the page renders only the latest release,
no amount of fixing main could repair it. Only publishing a new version can,
which is this one.

Added

  • The root README's links will work on the next PyPI release, and cannot
    regress after that.
    Not now: a long description is baked into the uploaded
    artifact and a project page renders only the latest release, so
    pypi.org/project/lethe-delete/ keeps serving 0.7.2's broken copy until
    0.7.3 goes out — verified by reading the live info.description, which still
    carries all seven. This entry first claimed the present tense, which is the
    class docs/reviewing.md had just been given ("the fix is not retroactive"),
    broken in the change immediately after writing it. The sdist built from this
    commit carries zero relative targets in its PKG-INFO, so the fix is real —
    it is just not live yet.

    All seven relative links are absolute: six docs/*.md occurrences
    (threat-model.md twice) and ](LICENSE). That file is lethe-delete's
    long description, and readme_renderer emits a relative target verbatim, so
    each one resolved against pypi.org/project/lethe-delete/ and 404'd. The
    display text is unchanged; only the targets moved, and the link count is the
    same 20 before and after. Every rewritten target was confirmed to exist on
    main before the rewrite, so none of these trade a 404 for a different 404.

    How long they were broken is worth stating precisely, because the first
    telling of it was wrong. lethe-delete has eight v* tags but only
    three versions on PyPI — 0.1.0, 0.2.0, 0.7.2 — and a project page renders
    only the latest, so tags and GitHub Releases are both the wrong unit. The
    live page serves 0.7.2, whose README carries all seven. Before that it served
    0.2.0, whose README carried two of them (docs/m2m.md and LICENSE, the
    only two present at every tag). Read from PyPI per version: 0.1.0 and 0.2.0
    each published two relative targets, 0.7.2 publishes six distinct ones across
    seven occurrences. So the page has been broken since the very first upload on
    2026-07-06, and has shown all seven since 0.7.2 was uploaded on
    2026-09-11 — not the 2026-09-08 this entry first gave, which was the tag
    date; the page changes when the artifact is uploaded, not when a tag is cut.
    Per-link tag coverage ranges from 1/8 (docs/releasing.md, added just before
    the last release) to 8/8 — never the uniform "seven" first claimed.

    The guard that replaces remembering is one assertion with no allowlist,
    because fixing all seven leaves the inventory empty and an empty rule beats a
    rule with exceptions. It reads the file from each package's project.readme
    rather than a hardcoded path, so renaming the readme moves the check with it
    instead of leaving it pointed at a file nobody publishes — the same lesson as
    PaymentConfig validating from_env and nothing else.

    It covers the whole link surface, not the part in use today: inline links,
    images, reference-style definitions and raw HTML href/src. Images
    matter because the scan that found the original seven matched href= only,
    and an image is src= — so it never looked at the one image in the file. It
    happened to be an absolute CI badge, but the check had a hole where a
    relative badge would have sat. Fenced and inline code are stripped first, so
    a README may still show a relative link as an example without tripping it.

    Nine file-level mutations: each of the seven link forms reverted or added
    back fails it (including in notary/README.md, and including a renamed
    readme =), while a relative link inside a code fence, a bare #anchor, and
    a mailto: all correctly do not.

    Those mutations were not enough, which is why there is also a table of 19
    link forms asserting which must be caught and which must not. Probing the
    patterns that way found one that renders, is relative, and was not
    flagged: an unquoted HTML attribute, <a href=docs/x.md>. Valid HTML5, kept
    by the sanitizer — measured, not assumed: readme_renderer turns
    <a href=docs/x.md> into href="docs/x.md" rel="nofollow", so the relative
    target really does reach the page — and missed because the pattern demanded
    quotes. Reading the regex had not found it; a table did. A second test keeps
    both halves of that table populated, so it cannot drift to all-catch or
    all-ignore and still pass.

  • The notary has its own changelog, notary/CHANGELOG.md,
    and the entries that belonged to it have moved there. This file's
    [Unreleased] had collected eight of them — mainnet auth, the catalog
    identity, the honest banner, the construction-time validation — and all eight
    had already shipped, in lethe-notary 0.2.0 and 0.2.1.

    That is not untidiness, it is a release that lies. release.yml extracts
    ## [X.Y.Z] from this file verbatim as the GitHub Release notes, so renaming
    [Unreleased] for the next v* tag would have published, as lethe-delete
    X.Y.Z's
    notes, "the notary can settle on mainnet" — describing a different
    package, shipped weeks earlier, in a release whose artifact contains none of
    it. The extraction guard exists precisely so the release and the changelog
    cannot tell different stories; it cannot tell that the story is about the
    wrong product.

    Reaching that file from PyPI turned out to need two links rather than one,
    and the difference is only visible by reading the published record instead of
    the tree — pypi.org/pypi/lethe-notary/{0.2.0,0.2.1}/json carries no
    Changelog URL, metadata is frozen at upload, and the sdist ships no
    CHANGELOG.md. So a Changelog project URL covers releases from the next
    one on, and a line in notary/README.md covers the two already out, because
    their frozen Documentation URL points at that README on main and resolves
    live. Both links are asserted; deleting the README one is the only failure
    here that publishing again cannot repair.

    Those links must be absolute, which is measured rather than assumed:
    readme_renderer 46.0 — the library PyPI renders long descriptions with —
    passes ](CHANGELOG.md) through as href="CHANGELOG.md", which on
    pypi.org/project/<name>/ resolves to a 404. Checking that is what caught
    this change adding a relative link to the root README, one file away from
    the guard it had just written for the notary's. It also turned up seven
    pre-existing relative links in that README, which is lethe-delete's long
    description. Fixed in the entry below, which also corrects two things this
    entry originally got wrong about them: they are not all docs/*.md (six are,
    with threat-model.md linked twice; the seventh is ](LICENSE)), and "for
    seven releases" was a count of GitHub Releases, which is not what renders.

    Worth recording how it got there: release-notary.yml was written with a
    comment saying this changelog "is versioned to lethe-delete — there is no
    ## [0.2.0] section that means the notary. Inventing one here would be the
    exact drift that guard exists to prevent." The trap was named correctly, in
    writing, and then walked into anyway, because the note explained why the
    workflow
    should not invent a section and said nothing about where the
    notary's entries were piling up meanwhile. A hazard you have described is not
    a hazard you have handled.

    So the split is enforced rather than remembered. A notary-v* tag whose
    version has no section in notary/CHANGELOG.md does not publish; and because
    that check only fires once the tag is pushed, notary/tests/test_packaging.py
    makes the same assertion on every commit, along with one that the notary's
    changelog is not a copy of this one. Eight ways for the split to come apart
    quietly were tried against those tests — a missing section, a version bumped
    without one, a heading with nothing under it, a section whose number is only
    a prefix of the version, this file copied over that one, and three ways to
    cut the notary's changelog off from its readers — and each fails the test
    meant to catch it.

  • docs/reviewing.md, written from the defects a single hard run actually
    produced rather than from general principle. The finding worth the document:
    almost none of them were in the payment logic — they were in the machinery
    meant to catch problems. A stub facilitator that stripped the port and hid a
    real bug for a commit; a CI guard that sent its own diagnostic to
    /dev/null; a noqa on the dead import it described; a comment claiming to
    match an SDK, written from memory, wrong twice; validation attached to
    from_env and therefore to nobody who constructs the object directly.

    It also records a stopping rule, because three audit passes over one change
    each found something. The signal is not that a pass found something, it is
    what class it found: pass three repeating pass two's class meant pass two's
    enumeration was incomplete, not that a fourth pass was owed. Repeat class →
    make the class exhaustive, do not look harder.

    tests/test_review_invariants.py enforces the parts that can be: an
    inventory of every signal suppressor in the tree — noqa, importorskip,
    skip marks — each with the reason it was allowed, failing on a new one, on a
    growing count, and on a stale entry; plus a check that no CI step sends a
    guard's output to /dev/null unless it is a --help smoke test where the
    exit code is the whole signal. Mutation-tested against six ways to slip one
    past, including the inventory itself going vacuous.

  • docs/fleet-reset.md — a design note on a second target shape: restoring
    a fleet of agents to an attested baseline, rather than deleting one data
    subject's records. Nothing is built. It exists because
    arXiv:2608.10218 states the requirement
    Lethe's spine would satisfy — contamination must be cleared from every agent
    at once, or the ones still infected re-infect the ones just cleaned — and
    because that question ("did we actually clean all of them, and can we show
    it?") outlives the particular attack that raised it. The note is equally
    clear about what not to do: the mitigation for that attack is free, so nobody
    should sell protection from it, and a detector keyed on the paper's "viral
    persona" would be semantic and should not be built.

Fixed

  • pytest notary/tests tests failed to collect. Both directories go on
    sys.path and both had a module named conftest, so
    from conftest import make_cert in the notary tests resolved to the root
    one, which has no such name — five files failed to import. CI never saw it,
    because it runs the two suites as separate steps for unrelated reasons
    (different dependencies); the person who hit it was whoever ran a bare
    pytest over both directories, which is the obvious first thing to try. The
    helper moved to a uniquely-named module, which cannot collide, and CI now
    collects both suites in one invocation so this cannot come back quietly.

  • A failed PyPI publish no longer costs a version number. The Release
    workflow takes a workflow_dispatch with a tag input that runs only the
    publish job, checked out at that tag, leaving the existing GitHub Release
    alone. Before this, the sole retry was a fresh tag, because
    gh release create refuses an existing release — so v0.7.2's failed upload
    would have had to become v0.7.3. The hand-run path keeps its guards: the tag
    must look like a release tag, the checkout must be sitting on that tag's
    commit, and the built artifacts must carry that tag's version — three checks
    replacing the tag-vs-version.py gate that lives in the skipped Release job.
    The publish job is also gated on !cancelled() rather than always():
    always() runs through a cancelled workflow, and cancelling is the only stop
    button there is on a step that cannot be undone.

  • The publish step prints what PyPI actually said. Its 422 body names the
    claim that did not match; the first version swallowed it and guessed, so
    v0.7.2 reported a guess. (The guess was wrong twice over: the message blamed
    a missing publisher, and a later revision blamed a mismatched one — the page
    showed no publishers configured at all, which a 422 also covers and the
    hint now says.) The trap worth naming in advance — entering the workflow's
    display name Release instead of the filename release.yml — is in the
    error and in docs/releasing.md.