Skip to content

Latest commit

 

History

History
851 lines (746 loc) · 45.2 KB

File metadata and controls

851 lines (746 loc) · 45.2 KB

Releasing btclib

Releases are published by GitHub Actions (release.yml), not from a developer machine. Pushing a v<version> tag runs the full test matrix, builds and checks the distribution files, publishes them to PyPI, and creates the GitHub release. There is no PyPI token anywhere: both indices are configured to trust the workflow itself (Trusted Publishing).

The same workflow, started by hand instead of by a tag, is a full rehearsal against TestPyPI. A rehearsal is never tagged.

A workflow GitHub has not registered cannot be dispatched, and it registers one only once its file has reached the default branch. That makes release.yml, deps-latest.yml, pypi-install.yml, os-ubuntu.yml, os-macos.yml and os-windows.ymlschedule and workflow_dispatch only, so nothing else ever triggers them — answer gh: Not Found (HTTP 404) until the release pull request is merged. It bites once, on the first release after any of them is written, and it inverts the order below: the TestPyPI rehearsal and the deps-latest run that this file asks for before the merge can only happen after it, still before the tag. It also means such a workflow reaches main having never run, which is how pypi-install.yml shipped a windows-11-arm cell that failed at setup.

Which version string is which

Telling these apart is most of what can go wrong when cutting a release.

  • pyproject.toml's own version takes three shapes over one cycle, never two at once: 2026.8, month only, between releases — the placeholder "Open the next cycle" sets, so a checkout of main reports itself as work in progress rather than as a release it is not; 2026.8.4, with the day added on release day — calendar versioning, YYYY.M.D — which is what gets published and the only value btclib.__version__ reads back from installed metadata; and 2026.8.4.1, a fourth number added only if 2026.8.4 shipped broken and cannot be reuploaded (see "If something goes wrong"). All three are typed by hand. Three components is always the release day; four is always a patch on it. The day is never dropped in favor of a fourth digit standing in for it, which is what would make the two indistinguishable — and version-check refuses a tag on the placeholder shape for exactly that reason: two components reach the check and nothing past it, whichever one is declared. It does not tell three apart from four, both being a release it accepts
  • v2026.8.4, the tag, carries no version of its own: it picks the index, PyPI rather than TestPyPI, and version-check exists to confirm it says what pyproject.toml says
  • 2026.8.4.dev701 is a rehearsal, and nobody types it either half at a time: .dev<run*100+attempt> is the template version-check computes and the dev-version action patches into pyproject.toml in test.yml's dist job when workflow_dispatch starts the workflow, github.run_number counted for release.yml alone and github.run_attempt counted for one dispatch of it, so the seventh such run's first attempt, rehearsing 2026.8.4, produces exactly that. The multiplier is what makes a re-run a version of its own rather than a collision (issue #1156): a re-run keeps the run's own number and only raises the attempt, so the run number alone was identical across every re-run of one dispatch and PEP 440 could not tell them apart. Placing the attempt below the run number's own place value keeps a run's later attempts sorting after its earlier ones and before the next run's, the attempt therefore capped at two digits and the workflow refusing a hundredth rather than silently wrapping into the next run's range. Nothing commits the result: uv lock runs straight after, so the lockfile the sdist ships agrees with the version it is named for
  • 2026.8.4rc1, and a v2026.8.4rc1 tag, have no place in this scheme: there is no pre-release here, only a version not yet tagged. version-check refuses anything that is not digits and dots, which is what stops 2026.8.4rc1 at pyproject.toml before a tag is even pushed — and what a v2026.8.4rc1 tag would otherwise pass, the comparison against it burning a version --pre installs would then resolve

PEP 440 sorts 2026.8.4.dev701 before 2026.8.4, so a rehearsal never shadows the release it rehearses. git tag on its own does not read the numbers the same way: measured, v2026.10 lists before v2026.7, alphabetically rather than chronologically. git tag --sort=v:refname reads them as PEP 440 does.

One-time setup

Already done for btclib-org/btclib; kept here for the record.

  1. On PyPI, add a trusted publisher: owner btclib-org, repository btclib, workflow release.yml, environment pypi.

  2. On TestPyPI, add the same trusted publisher, with environment testpypi.

  3. In the GitHub repository settings, create the pypi and testpypi environments. Both require a review from fametrano, so neither index is uploaded to without a human approving that run; every job holding id-token: write either runs under one of those two environments directly, or — attest — only after one of them has already succeeded, so nothing exchanges for an OIDC token ahead of the review. pypi is additionally restricted to v* tags, which is the only ref its job runs on anyway — the restriction is what makes that true of the environment and not just of an if: in a file a pull request could change.

    Self-review stays allowed on purpose: the maintainer who pushes the tag is the reviewer, and forbidding it would deadlock a one-maintainer release. The approval is a confirmation step, not a second pair of eyes; it becomes one as soon as there is a second reviewer to add.

Rehearse on TestPyPI

A rehearsal runs the identical pipeline — lint gate, test matrix, the dist job's build, its packaging checks (twine, check-wheel-contents, pyroma) and its two wheel smoke tests, one pinned by uv.lock and one unconstrained — and publishes the very files those checks passed to TestPyPI instead of PyPI.

  1. On GitHub, Actions → release → Run workflow, and pick the branch to rehearse: main, or the release branch while its pull request is still open — the workflow has to be registered on the default branch to be dispatched at all, which is the paragraph at the top of this file, but it runs against whichever branch is picked.

  2. The workflow appends .dev<run*100+attempt> to the version, so every rehearsal is unique on TestPyPI and sorts before the release it rehearses, re-runs included: a re-run raises only github.run_attempt, which the run number is multiplied by 100 to make room for, so re-running a failed or finished rehearsal mints its own version instead of colliding with the one it repeats.

  3. Check the upload on https://test.pypi.org/project/btclib/, and optionally install it (its dependencies come from the real PyPI). The rehearsal version stands in a fence of its own, --with having to precede the command uv runs; the fence below reads it as ${dev:?}, the shell's must-be-set form, so a paste of that fence alone fails naming the variable rather than resolving a spec with a hole in it:

    dev=<version>.dev<run*100+attempt>
    uv run --isolated --no-project \
      --index https://test.pypi.org/simple/ \
      --index-strategy unsafe-best-match \
      --with "btclib==${dev:?}" \
      python -c "import btclib; print(btclib.__version__)"

Release to PyPI

deps-latest is worth dispatching before the tag rather than waiting for its weekly cron, because what it answers is cheaper to know before a version is consumed than after. It gates nothing, so it will not stop you: reading it is the point. Its suite-bindings-latest job is the one worth reading closely: it asks about the newest btclib-secp256k1 release alone, precisely, rather than folding it into the broader upgrade the rest of the workflow makes — a release of the bindings is a release in another repository, which nothing here has to change for the pair to stop working, and this release is the moment to find out before shipping against a pin about to be a version behind.

Read it per job, not as a verdict. A red run means either "one dependency moved and this tree has not caught up" or "the bindings are broken against it", and only the second is a reason to stop — so which job failed is the question, and a run that is red overall while bindings at latest is green on every runner is saying the pin is sound. The second says which it is now (issue #1136): a newest release this tree cannot import fails that job's Assert the bindings are serving step, by name and before the suite, where it used to reach you as a coverage shortfall under a suite that passed — the shape of the first, and the reading that does not stop a release. Red on a test below that step is the other blocking shape, the bindings importing and answering differently. Open the failure rather than inferring it from a sibling: on v2026.8.7 seven jobs were red, six tests and the lint one, and reading a single test log and generalising happened to be right — the lint job was mypy reporting the same four errors — but nothing said so until it was checked.

Usually a red run is future work, and sometimes it blocks. A release ships what uv.lock pins, so drift against a newer version of some dependency does not make the release wrong — it says the next bump is going to be work. That holds while the dependency that moved is a stranger. It does not hold when it is another btclib-org project whose new release this release should depend on: v2026.8.7 was cut the day bitcoin-core-rpc renamed two exported functions with no alias, and shipping against the pinned older one would have published a btclib that could not be installed beside its own sibling. The paragraph above says exactly this about the bindings; it is true of both, word for word, and step 1 asks it of both.

Whether to act on a stranger's drift now, or leave it for Dependabot's own pull requests to catch up, is a decision worth stating rather than defaulting by omission — uv lock --upgrade is gated by nothing here, so silence at the tag reads as "nobody looked" and not as "looked and chose to leave it". State the choice in the release pull request, next to deps-latest's own result.

  1. Make sure the newest release of each btclib-org dependency — btclib-secp256k1 and bitcoin-core-rpc — is the one this release should depend on, and that pyproject.toml's pin says so. Two questions, not one: whether the pin resolves, which the wheel smoke test of the dist job already answers on every pull request by failing when only an unreleased version satisfies it, and whether the floor should move, which nothing automates because only a person knows what the sibling's release was for. Both projects are pinned without a ceiling and both can publish on the morning of a release.

    A direct reference is not a pin this release may carry. Either line can be <name> @ git+…@main while a branch here calls what has just landed there and no release of it carries that yet; PyPI refuses a direct reference in metadata, and nothing before publish-pypi does — the whole matrix builds, the artifacts upload, and the upload fails at the end. So a floor is written back over the reference, >= the release that carries what this tree calls: the sibling has to be released first, and there is no shipping against a commit.

  2. Set the release version (calendar versioning, YYYY.M.D) in pyproject.toml, the only place it is declared, and re-lock (the uv-lock pre-commit hook does it, uv.lock carrying the project version too). btclib.__version__ reads it back from the installed metadata and docs/source/conf.py reads it from the file, so there is nothing else to keep in step; the workflow fails if it disagrees with the tag, or if uv.lock was not re-locked. It also refuses a version that is not final: a tag is the trigger that publishes to PyPI itself, so an rc, a .dev or a .post reaching it would burn a real version. Trial runs go to TestPyPI through workflow_dispatch, above, and are never tagged.

  3. Check the breaking-changes list against the API itself, which nothing automates on a commit:

    uv run --with griffe griffe check btclib -s . -s src -a <previous release tag>

    tests/release_notes_test.py keeps both release-note files count-free: it rejects entry counts and breaking-change totals, because those figures drift and create merge conflicts. It cannot know whether the list is complete, the list being prose about the public API. griffe reads both revisions and answers that: it reports breakage alone — a public object removed, a parameter that changed kind or default or moved, an attribute whose value changed — and says nothing at all about an addition. It compares the expression an attribute is assigned rather than the value the module resolves it to, so a line can print for a change no caller can observe; the paragraph below names the shapes that fall out that way. What the step asks is that nothing it names, once that noise is set aside, is missing from RELEASE_NOTES.md. The converse is not its to answer: an entry describing a break it did not find is a claim about the prose, which review still has to read. It exits 1 on a finding.

    Breakage by griffe's classification is not breakage a user would notice, though, and the noise recurs in recognizable shapes. Attribute value was changed: Union[X, Y] -> X | Y is PEP 604 spelling and breaks nobody, and __version__ and __copyright__ report the same way. A constant moved from a literal into a lookup table reports the same way too: BIP34_HEIGHT moving into CONSENSUS_PARAMS prints as a changed attribute while the constant itself is unchanged. And one systemic change repeats once per site: check_validity going keyword-only is dozens of lines on its own and belongs in RELEASE_NOTES.md once, as a rule, not once per class. Discount those and what is left is short enough to check bullet by bullet — four entries were missing from v2026.8.7's list, and all four were in that remainder.

    Not a gate on every commit, and deliberately so: the comparison is against the previous release tag, so it reports the whole of a development cycle, typically hundreds of differences. No branch stands still at the last release for it to read instead, one branch being one moving tree, so the tag is the only target it has and a cycle's worth of output is the only shape its answer takes.

    release.yml runs this too, as its public-api job, and a red one there is the expected shape of a cycle with breaking changes in it. The job exists so the answer arrives while RELEASE_NOTES.md is being written; it is not a gate, and its own comment says so. Which means a release run is overall red on a cycle like that while every job that matters is green — read the job list and not the run's own badge, the same way deps-latest above is read per job. Nothing downstream waits on it: publish-testpypi, publish-pypi and pypi-install each open their if: with always() and name the dependencies they do require, so a red public-api costs the release nothing.

    That wiring is younger than the job and was wrong twice. On v2026.8.26's rehearsal publish-testpypi listed public-api in needs: with no always(), so a red public-api meant the job never started and the testpypi environment review never fired at all (issue #1461); on the v2026.8.27 tag itself pypi-install was skipped the same way, one hop further down, publish-pypi's own fix not reaching it (issue #1470). Both are fixed. What the pair is worth remembering for is the failure mode rather than the two sites: a job that is skipped and not failed leaves the run green where it is absent, so the thing to check after a release is that every job you expected actually ran, not merely that nothing you can see is red.

  4. Retitle the "work in progress" section of both RELEASE_NOTES.md and CHANGELOG.md as ## v<version>. The workflow lifts the GitHub release notes from RELEASE_NOTES.md's section alone, so that one has to read as the release notes it becomes; CHANGELOG.md is the detail it points at, and the two are retitled together or the link goes nowhere. version-check refuses a tag whose heading still carries anything after the version, in either file, or whose section is empty: the extraction matches ## v<version> followed by a space too, so an unretitled RELEASE_NOTES.md would have published "work in progress, not released yet" as the release notes. A rehearsal is exempt, being what runs before this step.

    In the same pull request, start the next cycle's "work in progress" section in both files, above the one just retitled. What that closes is the window between this pull request landing and Open the next cycle below: without it main carries a CHANGELOG.md whose topmost section is a released version, and any branch in flight files its entries under a release they are not in — silently, the release commit touching only the heading, so a rebase reports no conflict (issue #1458). version-check is unbothered by the section above: its awk matches ## v<version> exactly and never reaches a heading that precedes the tag's, and the tag always carries a day where the placeholder never does, so the two forms cannot collide.

    That new heading names the cycle of the release being cut — the YYYY.M of the version the section just retitled now carries, so retitling to v2026.9.3 opens ## v2026.9 (work in progress, not released yet). It is not a guess at the month the next release will fall in. Open the next cycle's version below sets pyproject.toml from this heading and records that the placeholder sorts below the release just cut, which holds because the month it names has come; a heading naming a month that has not makes a checkout of main declare a version sorting above every release that ships before that month arrives, 2026.10 above 2026.9.10. The asymmetry is the rule: a heading that ends up a month behind the release it names is corrected at the next retitle, where one naming a month ahead stands for the whole cycle and nothing reports it.

    So a cycle running past the end of its month costs nothing and is not a case to plan for. The retitle step of that release renames this heading to the version being cut then, exactly as this one renames the section it is opened above, and nothing is bumped while the cycle runs.

  5. Run uv run pre-commit run --all-files and uv run pytest --cov, follow docs/README.rst to check that the documentation builds, and get the above onto main through the usual pull request. The local gates are the evidence until that pull request exists: test.yml and lint.yml trigger on pull_request and on a push to main alone, deliberately, so that a branch with an open pull request is not tested twice — which means a commit on a branch with no pull request open runs neither.

    Then verify the read the docs build, and that the documentation renders correctly. Read the builds page and not only the rendered one: a site that answers 200 may be serving the last build that succeeded, which for three years was v2023.7.12's — the webhook had been refusing every delivery with a 400 and nobody was told (issue #484). This is the half no check covers: latest is the tip of main, so nothing names a version to ask about. The tag's own build is asked about, by the documented job below.

    Two things about that pull request, both of them before the button rather than after it.

    Give it its title and its body. The title is the version; the body says what the release is — what moved, what did not, and which of the two a user would notice. The squash takes its message from the branch's commits and not from that body — squash_merge_commit_message is REPOSITORY.md's Merge methods to read, not this file's to restate — so the pull request is where the body stays, and where a reader arriving from the release commit ends up. A template left unfilled, or a bot's summary of the diff, is not a substitute — the summary can stay, but what the diff cannot say has to be written, and what a reader should not have to discover at the button belongs there too.

    Write it from the section the retitle above renamed, which the cycle has been filling one landed change at a time, and check that against git log v<previous version>..main --oneline regardless of how current it looks, rather than trust that every line landed when it should have. Griffe's result and deps-latest's run belong here too, each a line rather than a screenshot — both are steps nothing else enforces, and a pull request that never mentions them reads exactly like one that skipped them.

    And land it the way every other pull request here lands: the squash button, pressed by auto-merge once the review and the checks are in. There is no other way in: main takes a pull request and nothing else. That the commit under the tag carries GitHub's web-flow signature rather than the maintainer's costs nothing: the branch rule asks for a valid signature and not for a particular signer.

    gh pr merge <n> --squash alone can still refuse this pull request — the base branch policy prohibits the merge — the way it did on btclib-secp256k1's own v0.8.0.4 (btclib-org/btclib-secp256k1#288): a solo-maintainer repository never clears REVIEW_REQUIRED, so gh's client-side mergeable check declines before it asks the server at all, and --auto only waits longer for the same review that will not arrive. --admin is the flag that clears it — the pair REPOSITORY.md's "Branch protection" names, enforce_admins false together with holding admin — and it is the one to reach for first: measured directly here across #1111, #1113, #1114 and #1133, each landing from BLOCKED and REVIEW_REQUIRED with a verified signature, one of them (#1113) BEHIND as well and cleared the same way. Name the release commit's title and body explicitly when using it — gh pr merge <n> --squash --admin --body-file <path> --subject <title> — rather than leave them to squash_merge_commit_message's repository default, recorded in REPOSITORY.md's Merge methods: this branch carries more than one commit every time (the paragraph below this one), and that default composes the commit under the tag from all of them rather than from what step 3 wrote.

    gh api -X PUT repos/{owner}/{repo}/pulls/<n>/merge -f merge_method=squash is the fallback for when --admin is unavailable, and needs commit_title and commit_message passed the same way for the same reason. It is what landed btclib-secp256k1's 0.8.0.4 clean — but only because that branch carried a single commit, so the repository default's concatenation of every commit and that commit's own message were the same string; a multi-commit release branch without the two parameters would not be so lucky.

    This branch carries more than one commit every time, a version bump and two retitles never being one, so the commit that lands is one GitHub composes at the button and no local object matches it. That is why the checks are read again below, on what main ends up at rather than on the branch head they ran against.

    Then read lint and test on the commit main ends up at before tagging, rather than trust the pull request's own green run:

    gh run list --commit "$(git rev-parse origin/main)"

    the merge pushes to main, and that push fires both workflows again from their own push trigger — a run of its own, not the pull_request run already green a moment earlier, and the paragraph above on the local gates is why there is no third one to fall back on.

  6. Rehearse on TestPyPI (see above) from main.

  7. Tag the release commit and push the tag. Name the commit, and read the tag back before pushing it:

    version=<the version being released>
    sha=<sha of the release commit>

    The placeholders stand in a fence with nothing under them to reach, which is section 9 of the organization standard's rule: a parse error guards only the line it sits on, so an interactive shell discards an unfilled placeholder line and reads the next as a fresh command. The fence below carries the other two guards, which are that section's too: the first stops at the fence, and the fence a split leaves below is live. Every value it consumes from outside itself is written ${name:?}, the shell's own "this must be set" form, and its lines are chained: the first makes an unfilled paste a run-time failure naming the variable, and the second short-circuits the lines under that failure, an interactive shell otherwise answering a failed command by reading the next one. The chain does a second job at release time, putting the read-back in front of the push: a grep that finds nothing — a tag that is not there, or a pyproject.toml carrying no version line — stops the tag from going up. Which version it found is still the releaser's to read, grep '^version' matching that line whatever value it holds.

    git tag -s "v${version:?}" -m "release v${version:?}" "${sha:?}" &&
    git show "v${version:?}:pyproject.toml" | grep '^version' &&
    git push origin "v${version:?}"

    git tag with no commit tags whatever HEAD the shell is in, and every step above ran in a worktree while the primary checkout sits on another branch — so the argumentless form is one cd away from tagging the commit before the version bump. That is how v2026.8.7 was first tagged; version-check refused it, comparing 2026.8 against the tag's 2026.8.7 and failing the run with nothing uploaded, which is the guard doing its job. The git show above is the same check one step earlier, where it costs nothing.

    The commit has to be one main contains, and version-check refuses the tag otherwise: git merge-base --is-ancestor against the default branch is the first thing it asks, before it has read a version at all. What that catches is the other half of the same mistake — the right version on a commit nobody merged, a branch whose pull request is still open, or the pre-squash commit of one that landed, which the squash left on no branch at all.

  8. The workflow builds the full matrix and the distribution files, then pauses at the pypi environment for the review "One-time setup" describes. Approve it. That approval is not the point of no return: the OIDC token exchange happens after it, so a trusted publisher whose claims do not match fails there having uploaded nothing, and the version survives — delete the tag, fix the registration, tag again. The same holds for a rehearsal failing the same way on TestPyPI: its .dev<run*100+attempt> is not consumed either, and gh run rerun --failed re-runs the publish job alone, against the artifacts already built, rather than the whole matrix again. The upload itself is the point of no return, PyPI accepting no file name twice even after deletion; the GitHub release follows it, with the distribution files attached, btclib-<version>.cdx.json and <tag>.attestation.jsonl beside them, and the RELEASE_NOTES.md section as its body. Read those notes once it lands: a run that logs RELEASE_NOTES.md has no v<version> section generated them from the merged pull requests instead — the fallback version-check exists to make unreachable, not a second way to write release notes — and they are worth replacing by hand if it ever fires.

  9. Check that every job you expected actually ran. A failed job is loud and a skipped one is silent, so the run can look finished with a sentinel missing from it — which is how v2026.8.27 published with pypi-install never having run (issue #1470, the paragraph under the griffe step above). conclusion tells the two apart, and a skipped job's started_at and completed_at are the same second with no steps in it:

    run_id=<the release.yml run>

    The placeholder stands in a fence of its own. Quoted, it is text rather than a pair of redirections, so nothing in the line below fails at the parse: pasted before the run id is filled in, it runs and sends gh the literal. What stops it is the ${run_id:?} the fence below carries, the form the tagging step of Release to PyPI describes.

    gh api \
      "repos/btclib-org/btclib/actions/runs/${run_id:?}/jobs?per_page=100" \
      --jq '.jobs[] | [.conclusion, .name] | @tsv'

    publish-testpypi is skipped on a tag and publish-pypi on a rehearsal — each is the other's trigger — public-api is red on any cycle with breaking changes, and documented skipped on its own account, its guard being the push. Everything else should be success.

  10. Read the documented job rather than the site: read the docs activates and builds a new release tag from the automation rule REPOSITORY.md records, and that job waits for https://btclib.readthedocs.io/en/<tag>/ to be served and is red if it never is. Green means the release has a permanent URL of its own, which is the one to link when the version is named anywhere. Red means the build is missing and the builds page says why: nothing about the publication depends on it, so the fix is a build on their side and never a moved tag.

  11. Install what was just published into an environment of its own, then exercise something that touches the shipped data rather than only importing it. import btclib runs __init__.py alone, and the files under _data/ — the BIP39 wordlists among them — are opened by path at the first call that needs one, not imported, so a wheel missing wordlist.txt would install and import cleanly and only fail there:

    uv run --isolated --no-project --with btclib \
      python -c "from btclib.mnemonic.bip39 import seed_from_mnemonic; \
        m = 'abandon abandon abandon abandon abandon abandon abandon ' \
            'abandon abandon abandon abandon about'; \
        print(seed_from_mnemonic(m, 'TREZOR').hex())"
  12. Check the PEP 740 attestations SECURITY.md says every release carries. The JSON API is not where: its provenance field answers null even on a release that has them. The project page shows them, and machine-readably they are under /integrity/<project>/<version>/<filename>/provenance, whose attestation_bundles[].publisher should name this repository and release.yml.

    That endpoint answers whether an attestation is there. Whether it verifies is a second question, and the one worth asking:

    uv run --isolated --no-project --with pypi-attestations \
      pypi-attestations verify pypi \
      --repository https://github.com/btclib-org/btclib <file>
  13. Read the bill of materials attached to the release, btclib-<version>.cdx.json: a CycloneDX 1.6 document naming the distribution, its licence, the two files with their SHA-256, and one component per dependency the wheel's metadata declares. What is worth reading rather than assuming is that list — it is Requires-Dist as published, so a git+https:// still in it is a release that should not have got this far, and each component carries a version only where the requirement is a == pin, a floor being a range and not a version. Attested with the distribution files, so gh attestation verify below covers it too.

    Neither sibling repository carries one, on purpose. bitcoin-core-rpc declares dependencies = [], and pypi-install.yml already asserts that against the installed package on every run; a bill of materials there would be an empty components list restating a fact CI checks more directly. btclib-secp256k1's interesting dependency is the vendored libsecp256k1 C library at the commit its secp256k1 submodule pins. That holds for both wheel kinds it ships and not only the static one: a static build links the library into the extension, a dynamic (ABI-mode) build ships it as a shared object beside the extension instead, and Requires-Dist says nothing about the pin either way. Naming the linkage would invite the opposite conclusion, that the dynamic build escapes the gap — where what is missing is the pinned commit however the object code arrives. A bill of materials built from Requires-Dist alone would name cffi and say nothing about the pin a verifier of that package would most want described, which is worse than omitting the document — issue #1159 has that evaluation.

    generate_sbom.py no longer has the limitation that evaluation rested on: it reads a commit-pinned submodule from .gitmodules and the tree's own gitlink, and reports it as a pkg:github/<owner>/<repo>@<sha> component (issue #1280). The premise btclib-org/.github#24 named as what would reopen the question has therefore changed; whether btclib-secp256k1 adopts this and what its own RELEASING.md then says is that repository's decision, tracked at issue #1159.

  14. Read the release run's pypi-install job, which is this workflow called with the tag rather than a dispatch to remember: the jobs that install have no checkout, so they resolve to what PyPI actually serves rather than to a source tree, and none of them starts until wait-for-index has seen the index serve the version the tag names, so the run cannot pass by testing the release before it. It checks a BIP39 vector against the _data/ files a wheel missing one would still install and import cleanly, and a BIP340 vector besides — both fixed forever, so neither needs an edit after a release the way a version-pinned assertion would. From then on it runs weekly on its own, and a failure means the outside world moved, not this repository — a new interpreter release, PyPI serving a file that does not match its own hash — which is why it is a workflow of its own rather than a job of this one. Actions → pypi-install → Run workflow is for asking between those runs, with no particular version in mind.

  15. Open the next cycle's version: set pyproject.toml's version to the number the two "work in progress" sections already carry, through a pull request like any other. The retitle step above opens those sections in the release's own pull request, so ## v<cycle> (work in progress, not released yet) is standing in CHANGELOG.md and RELEASE_NOTES.md by the time this step runs, and what is set here is that same cycle: one value, pyproject.toml being the file it reaches last. It cannot reach it any earlier — version-check reads uv version --short at tag time, and a pyproject.toml already bumped would offer it the next cycle's number instead of the one being released.

    The placeholder is that cycle, not the month after the release. After 2026.9.3 it is 2026.9, which is what those two headings say, and it is not 2026.10. The two readings name the same month for as long as a cycle holds a single release, which is what makes "the month after" look like the rule; a cycle that ships twice separates them. Taking the month after would also declare a cycle neither notes file has a section for, so the next change to land would file its entry under a heading the declared version disagrees with — issue #1458's shape one file over.

    2026.9 sorts below 2026.9.3 under PEP 440, which follows from the rule rather than arguing against it: a placeholder names the cycle whose notes the tree is accumulating, not a release, and releases are ordered by their tags — and version-check refuses a tag on the placeholder shape (Which version string is which above). What a reader can trip on is that pip install --upgrade btclib in an environment holding such a checkout resolves to the published release, which is the right answer for anybody who did not mean to be running a working tree.

    Those two sections are where the next release's notes accumulate, one landed change at a time, and the merge step above is what reads them back. Nothing else holds them: with one branch there is no pull request standing open for the length of a cycle to be written into as it runs, so a change that lands without its entry leaves nothing behind to reconstruct it from but the diff.

Rebuild a release from its tag

test.yml's dist job exports SOURCE_DATE_EPOCH from the commit date and normalizes the sdist, so a rebuild of a released tag is the same bytes as what was published — that job's own upload is what publish-pypi publishes, unchanged, so "what was published" and "what that job built" are the same files (issue #1166). Anyone can check that, and the check is one command short of the provenance one above: verify the rebuilt file rather than a downloaded one, and it can only pass if the digests agree. A release whose assets carry <tag>.attestation.jsonl has the signed statement on disk too, so --bundle <that file> asks the same question of it without reaching the attestations API — which is the form for whoever mirrors the releases page rather than trusting it live. --signer-workflow is the flag that makes either form say which workflow signed: without it a valid attestation from any workflow in the repository passes.

A worktree and not git checkout, for the reason CLAUDE.md gives: the primary checkout is the maintainer's, and a rebuild wants a tree of its own regardless.

version=<the released version>

The placeholder stands in a fence with nothing under it to reach, and the fence below carries the guard pair the tagging step of Release to PyPI describes: version is what it consumes from outside itself and is written ${version:?}, where repo is assigned inside it, and its lines are chained. The chain does a second job at release time, stopping the rebuild where a build or a verification fails rather than carrying on against a tree that is not the one the tag names.

git worktree add --detach /tmp/btclib-rebuild "v${version:?}" &&
cd /tmp/btclib-rebuild &&
export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) &&
uv build &&
uv run --no-project --python 3.14 \
  .github/scripts/normalize_sdist.py dist/ &&
uv run --no-project --python 3.14 \
  .github/scripts/generate_sbom.py dist/ sbom/ &&
repo=btclib-org/btclib &&
gh attestation verify "dist/btclib-${version:?}-py3-none-any.whl" \
  --repo "$repo" --signer-workflow "$repo/.github/workflows/release.yml" &&
gh attestation verify "dist/btclib-${version:?}.tar.gz" \
  --repo "$repo" --signer-workflow "$repo/.github/workflows/release.yml" &&
gh attestation verify "sbom/btclib-${version:?}.cdx.json" \
  --repo "$repo" --signer-workflow "$repo/.github/workflows/release.yml"

The bill of materials is rebuilt with them and verified like them: its timestamp is SOURCE_DATE_EPOCH and its serial number is derived from the two digests, so it is the same bytes as the released copy — which is the only reason a third gh attestation verify can pass at all.

Two things bound that guarantee, and both are worth knowing before reading a mismatch as tampering:

  • the build backend is bounded, not pinned. [build-system] requires names a range rather than a version, and a build takes whichever version in that range the uv running it carries, so a rebuild months later runs a backend the release never saw. A mismatch dates the rebuild before it accuses anyone; pinning the backend to a version is the fix, and the cost is a bound that ages.
  • the rehearsal is a different version, by construction. A TestPyPI dispatch appends .dev<run*100+attempt> to the version, so its files are not a second build of the release's — they are their own artifact, published where they say they are. The attestation the rehearsal writes covers those, and no digest is shared with the release.

btclib-secp256k1 is not a third bound. It is a runtime dependency, resolved by whoever installs the wheel, and the only trace of it in either distribution file is the Requires-Dist pyproject.toml already spells and the pin uv.lock carries into the sdist — both of them text belonging to the tag. Nothing the build resolves is a runtime dependency: an isolated build installs [build-system] requires and no more, and the rebuild above needs no uv sync to produce the published bytes.

If something goes wrong

  • The workflow failed before the publish-pypi job: nothing was uploaded. Delete the tag, fix, and tag again:

    git tag -d v2026.8.4
    git push origin :refs/tags/v2026.8.4

    Both lines, and the local one is the half that is easy to skip: a tag is per-repository where a branch is per-worktree, so deleting it in one worktree leaves it in every other, and the git tag -s that follows answers fatal: tag 'v2026.8.4' already exists — from a checkout that looks uninvolved. Delete locally wherever it is, then re-create.

  • publish-pypi itself ran and failed at the token exchange (invalid-publisher), after the matrix had already built everything: nothing was uploaded, but retagging would rebuild what was never at fault. A registration that matched once goes stale on its own — a repository rename is enough — and nothing here flags it before the upload tries; sibling repository btclib-secp256k1 hit exactly this on a real tag rather than a rehearsal. Fix the registration and re-run the publish job alone against what is already built:

    gh run rerun --failed <run id>

    a fresh approval of the pypi environment is still required, the protection applying per deployment attempt rather than once per run. This is a different case from the one above: there, the workflow never reached publish-pypi, so there is nothing to re-run and no artifact to re-run it against.

  • The upload succeeded but the release is broken: PyPI never accepts a file name twice, even after deletion. Yank the bad release on PyPI and publish a new patch version (2026.8.42026.8.4.1).

  • Only the github-release job failed: the PyPI upload is already done. gh run rerun <run id> --failed reaches it when the run marks it failed — a dependent of a failed job is reached too, which is what usually gets github-release from attest or publish-pypi failing under it. It does not reach a job the run marks skipped: a skip is neither a failure nor within that flag's blast radius, which is how v2026.8.21 kept a version on PyPI and no release at all through two reruns (issue #1142; the comment on github-release's if in release.yml has the measurement). A skipped job needs the recovery below, same as a failed one --failed did not reach.

    Either way, the recovery is a release built from the run's own artifacts by hand — three of them, dist, sbom and attestation, not the one dist alone:

    run_id=<the release.yml run>
    version=<the released version, e.g. 2026.8.4>

    The placeholders stand in a fence with nothing under them to reach: a paste made before they are filled in would otherwise reach the gh run download below, which takes an empty run id as a run of its own choosing and writes it into the reader's directory. Every fence under it carries the guard pair the tagging step of Release to PyPI describes.

    tag="v${version:?}" &&
    gh run download "${run_id:?}" -n dist -n sbom -n attestation

    Check the distribution files against the digests PyPI already published for them, before attaching anything: it is what makes "the release carries what the index serves" a fact rather than an assumption, and PyPI accepts no second upload to compare them against.

    pypi_json="https://pypi.org/pypi/btclib/${version:?}/json" &&
    for f in dist/*.whl dist/*.tar.gz; do
      sha=$(curl -sf "$pypi_json" |
        jq -r --arg n "$(basename "$f")" \
          '.urls[] | select(.filename==$n) | .digests.sha256')
      echo "$sha  $f" | sha256sum -c -
    done

    Then attach the same three: the bundle renamed as the job renames it, and every file in the order the "Rebuild a release from its tag" section above verifies them in — wheel, sdist, bill of materials:

    mv attestation/attestation.jsonl "${tag:?}.attestation.jsonl" &&
    gh release create "${tag:?}" dist/*.whl dist/*.tar.gz \
      sbom/*.cdx.json "${tag:?}.attestation.jsonl" \
      --title "${tag:?}" --notes-file <the tag's RELEASE_NOTES.md section>