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.yml — schedule 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.
Telling these apart is most of what can go wrong when cutting a release.
pyproject.toml's ownversiontakes 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 ofmainreports 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 valuebtclib.__version__reads back from installed metadata; and2026.8.4.1, a fourth number added only if2026.8.4shipped 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 — andversion-checkrefuses 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 acceptsv2026.8.4, the tag, carries no version of its own: it picks the index, PyPI rather than TestPyPI, andversion-checkexists to confirm it says whatpyproject.tomlsays2026.8.4.dev701is a rehearsal, and nobody types it either half at a time:.dev<run*100+attempt>is the templateversion-checkcomputes and thedev-versionaction patches intopyproject.tomlin test.yml'sdistjob whenworkflow_dispatchstarts the workflow,github.run_numbercounted forrelease.ymlalone andgithub.run_attemptcounted for one dispatch of it, so the seventh such run's first attempt, rehearsing2026.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 lockruns straight after, so the lockfile the sdist ships agrees with the version it is named for2026.8.4rc1, and av2026.8.4rc1tag, have no place in this scheme: there is no pre-release here, only a version not yet tagged.version-checkrefuses anything that is not digits and dots, which is what stops2026.8.4rc1atpyproject.tomlbefore a tag is even pushed — and what av2026.8.4rc1tag would otherwise pass, the comparison against it burning a version--preinstalls 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.
Already done for btclib-org/btclib; kept here for the record.
-
On PyPI, add a trusted publisher: owner
btclib-org, repositorybtclib, workflowrelease.yml, environmentpypi. -
On TestPyPI, add the same trusted publisher, with environment
testpypi. -
In the GitHub repository settings, create the
pypiandtestpypienvironments. Both require a review fromfametrano, so neither index is uploaded to without a human approving that run; every job holdingid-token: writeeither 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.pypiis additionally restricted tov*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 anif: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.
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.
-
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. -
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 onlygithub.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. -
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,
--withhaving 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__)"
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.
-
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 thedistjob 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+…@mainwhile 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 beforepublish-pypidoes — 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. -
Set the release version (calendar versioning,
YYYY.M.D) in pyproject.toml, the only place it is declared, and re-lock (theuv-lockpre-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 anrc, a.devor a.postreaching it would burn a real version. Trial runs go to TestPyPI through workflow_dispatch, above, and are never tagged. -
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.pykeeps 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 | Yis 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_HEIGHTmoving intoCONSENSUS_PARAMSprints as a changed attribute while the constant itself is unchanged. And one systemic change repeats once per site:check_validitygoing 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.ymlruns this too, as itspublic-apijob, 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 waydeps-latestabove is read per job. Nothing downstream waits on it:publish-testpypi,publish-pypiandpypi-installeach open theirif:withalways()and name the dependencies they do require, so a redpublic-apicosts the release nothing.That wiring is younger than the job and was wrong twice. On v2026.8.26's rehearsal
publish-testpypilistedpublic-apiinneeds:with noalways(), so a redpublic-apimeant the job never started and thetestpypienvironment review never fired at all (issue #1461); on the v2026.8.27 tag itselfpypi-installwas 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. -
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-checkrefuses 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
maincarries 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-checkis 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.Mof the version the section just retitled now carries, so retitling tov2026.9.3opens## 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 setspyproject.tomlfrom 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 ofmaindeclare a version sorting above every release that ships before that month arrives,2026.10above2026.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.
-
Run
uv run pre-commit run --all-filesanduv run pytest --cov, follow docs/README.rst to check that the documentation builds, and get the above ontomainthrough the usual pull request. The local gates are the evidence until that pull request exists:test.ymlandlint.ymltrigger onpull_requestand on a push tomainalone, 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:
latestis the tip ofmain, so nothing names a version to ask about. The tag's own build is asked about, by thedocumentedjob 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_messageis 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 --onelineregardless of how current it looks, rather than trust that every line landed when it should have. Griffe's result anddeps-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:
maintakes 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> --squashalone 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 clearsREVIEW_REQUIRED, so gh's client-side mergeable check declines before it asks the server at all, and--autoonly waits longer for the same review that will not arrive.--adminis the flag that clears it — the pair REPOSITORY.md's "Branch protection" names,enforce_adminsfalsetogether with holdingadmin— and it is the one to reach for first: measured directly here across #1111, #1113, #1114 and #1133, each landing fromBLOCKEDandREVIEW_REQUIREDwith a verified signature, one of them (#1113)BEHINDas 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 tosquash_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=squashis the fallback for when--adminis unavailable, and needscommit_titleandcommit_messagepassed 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
mainends up at rather than on the branch head they ran against.Then read
lintandteston the commitmainends 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 ownpushtrigger — a run of its own, not thepull_requestrun already green a moment earlier, and the paragraph above on the local gates is why there is no third one to fall back on. -
Rehearse on TestPyPI (see above) from
main. -
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 apyproject.tomlcarrying noversionline — 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 tagwith 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 onecdaway from tagging the commit before the version bump. That is how v2026.8.7 was first tagged;version-checkrefused it, comparing2026.8against the tag's2026.8.7and failing the run with nothing uploaded, which is the guard doing its job. Thegit showabove is the same check one step earlier, where it costs nothing.The commit has to be one
maincontains, andversion-checkrefuses the tag otherwise:git merge-base --is-ancestoragainst 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. -
The workflow builds the full matrix and the distribution files, then pauses at the
pypienvironment 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, andgh run rerun --failedre-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.jsonand<tag>.attestation.jsonlbeside them, and the RELEASE_NOTES.md section as its body. Read those notes once it lands: a run that logsRELEASE_NOTES.md has no v<version> sectiongenerated them from the merged pull requests instead — the fallbackversion-checkexists to make unreachable, not a second way to write release notes — and they are worth replacing by hand if it ever fires. -
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-installnever having run (issue #1470, the paragraph under the griffe step above).conclusiontells the two apart, and a skipped job'sstarted_atandcompleted_atare 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
ghthe 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-testpypiisskippedon a tag andpublish-pypion a rehearsal — each is the other's trigger —public-apiis red on any cycle with breaking changes, anddocumentedskipped on its own account, its guard being the push. Everything else should besuccess. -
Read the
documentedjob 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 forhttps://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. -
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 btclibruns__init__.pyalone, 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 missingwordlist.txtwould 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())"
-
Check the PEP 740 attestations SECURITY.md says every release carries. The JSON API is not where: its
provenancefield answersnulleven on a release that has them. The project page shows them, and machine-readably they are under/integrity/<project>/<version>/<filename>/provenance, whoseattestation_bundles[].publishershould name this repository andrelease.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>
-
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 isRequires-Distas published, so agit+https://still in it is a release that should not have got this far, and each component carries aversiononly where the requirement is a==pin, a floor being a range and not a version. Attested with the distribution files, sogh attestation verifybelow covers it too.Neither sibling repository carries one, on purpose. bitcoin-core-rpc declares
dependencies = [], andpypi-install.ymlalready asserts that against the installed package on every run; a bill of materials there would be an emptycomponentslist restating a fact CI checks more directly. btclib-secp256k1's interesting dependency is the vendored libsecp256k1 C library at the commit itssecp256k1submodule 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, andRequires-Distsays 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 fromRequires-Distalone would namecffiand 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.pyno longer has the limitation that evaluation rested on: it reads a commit-pinned submodule from.gitmodulesand the tree's own gitlink, and reports it as apkg:github/<owner>/<repo>@<sha>component (issue #1280). The premisebtclib-org/.github#24named as what would reopen the question has therefore changed; whether btclib-secp256k1 adopts this and what its ownRELEASING.mdthen says is that repository's decision, tracked at issue #1159. -
Read the release run's
pypi-installjob, 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 untilwait-for-indexhas 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. -
Open the next cycle's version: set
pyproject.toml'sversionto 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-checkreadsuv version --shortat 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.3it is2026.9, which is what those two headings say, and it is not2026.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.9sorts below2026.9.3under 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 — andversion-checkrefuses a tag on the placeholder shape (Which version string is which above). What a reader can trip on is thatpip install --upgrade btclibin 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.
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] requiresnames 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.
-
The workflow failed before the
publish-pypijob: 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 -sthat follows answersfatal: tag 'v2026.8.4' already exists— from a checkout that looks uninvolved. Delete locally wherever it is, then re-create. -
publish-pypiitself 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
pypienvironment 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 reachedpublish-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.4→2026.8.4.1). -
Only the
github-releasejob failed: the PyPI upload is already done.gh run rerun <run id> --failedreaches it when the run marks it failed — a dependent of a failed job is reached too, which is what usually getsgithub-releasefromattestorpublish-pypifailing 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 ongithub-release'sifinrelease.ymlhas the measurement). A skipped job needs the recovery below, same as a failed one--faileddid not reach.Either way, the recovery is a release built from the run's own artifacts by hand — three of them,
dist,sbomandattestation, not the onedistalone: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 downloadbelow, 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>