Contributing

What this repository holds in common with the others of the organization — the toolchain, the lint gate, the tool tables behind it, the workflow set and the branch rules — is stated once in the organization’s standard, each rule with the alternative it was decided against. It binds this repository, so a change departing from it is a divergence, and one filed as an issue in btclib-org/.github rather than here: a difference between two repositories belongs to neither of them.

This file is the same in every repository of the organization up to its last section. What is true of one tree only — the commands that build its environment, the gates it runs, which of its workflows decide a merge — is under that heading, and the comparison stops there.

How the organization decides, and who holds which role, is GOVERNANCE.md; what it intends to do, and what it deliberately does not, is ROADMAP.md. Both are the organization’s, one copy each beside the standard.

The issue tracker

Where an issue is filed, and what an alignment finding has to name, is the standard’s What this repository is: an issue spanning repositories, or whose subject is the standard, goes to btclib-org/.github, and one about this tree alone stays here.

A finding noticed while doing something else goes where REVIEWING.md’s What is filed, and what is not says, for an author as much as for a reviewer: a pull request answering two questions cannot be accepted for either.

An issue labelled good first issue is one set aside for a first contribution: small and self-contained. One search lists the open ones across the organization, so a newcomer need not know in advance which repository to look in.

Documentation and comments

Section 9 of the standard is the prose style beyond Write plainly below, and it governs the prose this tree ships — comments, docstrings and markdown. It is not restated here: a second wording is the one that goes stale, which is that section’s own One fact in one place.

A commit message is prose this tree ships too, though section 9 does not say so: the only merge method the rule accepts puts it on main as the landing commit’s body, so what is written in one is read there long after the branch is gone.

Working with an LLM

Development here is LLM-based. Most issues, code and reviews are written by agentic sessions that the maintainer directs and reads. Humans are welcome, as long as they are comfortable working that way.

A contribution is yours, whoever wrote it. Read every line before you open the pull request, understand it, and be ready to answer a review without the model. Text or code you have not read, or cannot explain, is closed without review.

For a pull request the agent opened, whoever approves it reads every line and answers for it as their own, the agent being the pull-request agent of GOVERNANCE.md’s Roles. The maintainer, landing it through the bypass without an approval, reads every line and answers for it instead, the landing being their own act; requiring an approval even where the bypass is kept is the alternative set aside. The agent signs off nothing, since a bot cannot certify the Developer Certificate of Origin, so the approval, or that landing, is the certification.

The agent label hands an issue to that agent, in a repository that calls its implement workflow. Section 11’s Labels and issue types says who applies it, section 10 what the agent does with an issue it admits, and the workflow’s header which issues it takes, what answers a decision, and the switch that turns it on.

Write plainly, in the tree and on the forge: the point first, plain words, one fact per sentence. On the forge, the title and the first sentence carry the point; a body is at most two short paragraphs before any code block or template section; a comment is about 250 words, and never more than 300, with no list and no heading; a review’s summary takes the shape REVIEWING.md’s The verdict gives it.

Pull requests

What main accepts, and what it refuses to everyone, is section 11 of the standard. Run the gates locally before opening anything — the last section of this file says which they are — because CI runs exactly them, so a red run there is a local run that was not done.

Every commit of a pull request carries a Signed-off-by: trailer naming its author, which certifies the Developer Certificate of Origin. git commit -s adds it, and git rebase --signoff <base> adds it to commits already made. The Sign-off check is required, so only an administrator can merge a pull request with a commit the check refuses: Landing it says why --admin waits for no required check. The check’s failure prints the command that adds the trailer. The standard’s Signatures says why a signature does not replace it, and which commits the Sign-off job skips.

What a pull request’s title and description have to say about the issues it closes, and why a manual link in the Development panel is a trap neither of them shows, is the standard’s What a pull request says it is. Read it before opening one; it is the rule most often found broken after the fact.

Before it is opened, the branch’s own commit subjects and bodies are read against that same rule. The description does not exist yet to disagree with them, and the standard has the command that scans the branch’s own commit text for a verb in front of a reference.

The two spellings are named here as well as there, against section 9’s One fact in one place, the paragraph above naming the section and not the forms, which are the half a citation is got wrong in: (closes #N) cites an issue the change closes, wherever the citation sits — the title, the commit subject where Merge method makes that the thing that lands — and (issue #N) cites, in those same places, an issue the change advances and does not close. One token holds one meaning whichever file it sits in, so the pair is chosen by what is true of the change rather than by which file is being written, and a tree’s own landed subjects are not what to copy it from: nothing already landed is rewritten, so what a repository wrote before the rule stays where it is.

REVIEWING.md is the standard a review is written against, and is this file’s other half. Read before opening a pull request, it is what the pull request will be answered against.

A pull request adds no entry to CHANGELOG.md or RELEASE_NOTES.md, a release’s own pull request excepted (section 9, section 12).

One subject, opened as soon as it is written

A pull request answers one question. Issues that share a subject are one pull request, closing each of them; issues that do not are one pull request each, however small either of them is.

It is opened the moment it is written and verified — not held for the previous one to be reviewed or to land, and not batched with the next. A batch arrives as one reviewing job with several subjects, which is the shape that costs the most to read; a finished pull request held back is review that could have started and did not. A security advisory’s fix is the exception, held back as section 12’s A security advisory is worked in this order says.

Working this way stacks branches, which is fine and costs one rule: a child whose base was amended is moved with the old base named,

git rebase --onto <new-base> <old-base-sha> <child>

because a plain rebase replays the base’s old commit inside the child, and the forge then shows the base’s old text as additions with nothing red anywhere. Read the child’s diff afterwards rather than trusting the rebase, and retarget each child onto main as its parent lands.

The review

A review is given promptly and on local evidence. It does not wait for CI, does not report a check as a finding, and does not discuss a run at all: whether CI is green is the author’s business, once, at landing time.

The exchange is anchored to a sha rather than to a branch, a branch being free to move under a review:

  • the author hands off by naming the sha pushed and the evidence run against it, then leaves that head alone;

  • the reviewer answers with findings — where, what is wrong, how they know it, and whether each is blocking;

  • the author accepts what is reasonable, declines the rest with a reason in the thread, pushes the answer without waiting for CI, and resolves each thread once answered;

  • the reviewer reopens a thread whose finding is still blocking, and re-reviews the delta rather than the branch.

What ends the loop is the ack of record. A reading that says what it found and delivers no verdict is a review too and ends nothing; the standard’s Review has which is which, and REVIEWING.md has how each is written. A disagreement that survives a second exchange goes to the maintainer instead of into a third round.

The ack of record is not the approval Landing it asks for: it is a bot’s review of type COMMENT (the standard’s Review says whose).

Landing it

CI is read once, and this is where: on the head that lands. main does not ask that head to be up to date with it, and section 11 says why. So a branch is rebased only where it conflicts with main. A rebase that moved nothing but the base leaves the ack standing; one that resolved a conflict does not, that resolution being a change no reviewer has seen.

When main’s CI is red after a merge, its repair takes priority over every other pull request, because until it lands main’s runs cannot tell a new failure from the one already there. A pull request armed with --auto (below) stays armed, and others keep landing meanwhile. Holding every landing for a repair costs more than the break it guards against, so the risk of landing on a red main is accepted (btclib-org/.github#1777).

A pull request whose required check is red only because main was red is closed and reopened once the repair lands (gh pr close <n>, then gh pr reopen <n>). A re-run would test the old merge commit, and the reopen runs the checks on the merge GitHub then holds. GitHub refreshes that merge lazily, so the reopen’s runs can still test the old one. A run’s log is read before the run is relied on: its line HEAD is now at <sha> Merge <head> into <base> names the merge it tested, and where <base> lacks the repair, the pull request is closed and reopened again. No push is made, so its approvals and its ack of record stand. Closing disarms --auto, so it is armed again once the checks are green. The reopen also runs the review bot again, and changes that review requests are answered like any other review’s.

Then squash, the only method the rule accepts.

It lands once somebody other than its author has approved that head, through gh pr merge pinned to it:

gh pr merge <n> --squash --match-head-commit <the head the checks ran on>

With --auto added, GitHub runs the same merge once the approval and the checks are in.

Where the repository has a bypass, the maintainer may land without that approval, through it: GOVERNANCE.md’s How a decision is made says which repositories. --admin turns off gh’s own refusal, and GitHub applies the bypass to the merge. enforce_admins is off, so it waits for no required check either:

gh pr merge <n> --squash --admin \
  --match-head-commit <the head the checks ran on>

Without --admin, gh pr merge refuses a head with no approval, saying it is not mergeable: the base branch policy prohibits the merge.

The pin is not optional, in either command. Reading the ack and merging are two calls, and the head is free to move between them — the push that would move it comes out of the same round the verdict does. Unpinned, the command takes whatever sits at the head when it runs; pinned, it refuses a head that has moved, and a round lost that way is cheaper than a tree nobody has read reaching main. The review above anchors the exchange to a sha and section 11 has an ack name one: the pin is that rule reaching the call that performs the landing.

Verify what landed rather than trusting the answer, the signature the standard asks for being a valid one rather than a particular signer’s:

gh api repos/{owner}/{repo}/commits/main \
  --jq '.commit.verification | {verified, reason}'

What it closed is read again here too, from the landed sha rather than from the pull request: the standard’s What a pull request says it is has the second read, and why the first alone does not reach a squash subject composed after it runs.

The forge deletes the head branch itself, per the setting section 11 names. What is still yours is bringing every checkout sitting on main up to date, that being where the next session starts from and a stale one being where a branch gets built on a base that has moved. REPOSITORY.md carries the settings and why they are what they are.

Worktrees

Run git fetch origin in the primary checkout, then check that git status --porcelain is empty and git branch --show-current is main. Run git pull --ff-only only when both checks pass, then verify git rev-parse HEAD origin/main prints the same sha twice. Create a separate worktree for each task with git worktree add <path> origin/main -b <branch>. After committing, remove it with git worktree remove <path>. If initialized submodules make that fail, check for uncommitted, untracked, and ignored files in the worktree and its initialized submodules. Preserve anything needed before retrying with git worktree remove --force <path>; it can delete those files. Keep the primary checkout clean and on main; do not switch branches or stash there. Hooks can fix files in place, and the local main and refs/stash are shared by all worktrees.

This repository in particular

Everything above is the same file in every repository of the organization; everything below is this one’s, and the comparison stops at this heading.

[![calendar versioning:

yyyy.m.d](https://img.shields.io/badge/cal_ver-yyyy.m.d-1674b1.svg?logo=calver)](https://calver.org/) uv format:ruff lint:ruff docstrings:ruff type check:mypy lint:markdownlint-cli2 pre-commitenabled GitHub repository:btclib-org/btclib-wallet

To get an overview of the project, read the README and ARCHITECTURE.md, which is where the layering this package sits in is written down.

What a primitive does — a curve operation, a signature scheme, a script — is btclib’s, not this package’s: a finding that reproduces with btclib alone is an issue for its tracker rather than this one.

A change in btclib reaches this tree through uv.lock only. A test that fails after uv lock --upgrade-package btclib and not before is a break in btclib’s surface, and its RELEASE_NOTES.md is asked before this code is.

The one constraint

This package is built on btclib and never the reverse. It imports btclib and re-exports nothing of it: a caller wanting one of btclib’s names imports it from btclib. tests/imports_test.py is what reads the import graph. A name of bitcoin_core_rpc that btclib_wallet.fetch publishes is a re-export, and tests/all_test.py’s REEXPORTED records each such name. A name of bip39_slip39 is reached through that package and neither bound nor re-exported by a module here, which tests/all_test.py holds too: a caller reaches the mnemonic schemes by that package’s name.

The public surface

Every module and every package declares __all__, at every depth of the tree. A name is public here because a list says so, not because it happens to lack a leading underscore. An empty list is a legitimate answer, for a module with nothing public of its own; declaring nothing is not. For a package the list is what the __init__ publishes, submodules included; for a module it is what the module itself defines, a name it imported belonging to the module that defines it. btclib_wallet.__all__ is the root of that tree, written out rather than discovered, so that a new module is published by somebody deciding to. tests/all_test.py checks all of this and finds the modules rather than listing them: a public name kept out of a list is recorded in its UNEXPORTED table, and a name published from another module in its REEXPORTED table.

Every public function validates its inputs. Whatever it is handed — a string, octets, or an object somebody built earlier — a name a caller can reach checks it before acting on it, and a malformed argument leaves as a BTClibTypeError or a BTClibValueError, which is what the callers of this package are written to catch. A type the signature does not declare is a BTClibTypeError even where the function answers a bool: the bool is the answer to a well-typed question. The work itself may be deferred to a private twin that does not validate, and that twin is what the package composes internally. btclib_wallet.bip32 is the shape to copy: derive validates and calls _derive, which does not, and key_data_from_bip32_key is the one place a BIP32Key of any spelling becomes a validated BIP32KeyData.

A refusal of ecc’s own leaves as that package’s class. Under a btclib carrying issue btclib-org/btclib#2282 the curve arithmetic and the schemes on it are ecc’s, whose EccTypeError, EccValueError and EccRuntimeError are a TypeError, a ValueError and a RuntimeError and no BTClibException: an argument this package hands on to it unchecked is refused as one of those, which a caller names from ecc.exceptions. A catch that renames a refusal ecc may raise, to say which field it was, names ValueError, so that the field is named whichever library refused; a catch that answers a failed check False names the runtime class of both libraries, which is bip322’s _INVALID. tests/exception_family_test.py holds the classes of both libraries for each kind, and a bare built-in is in none of them.

A refusal of bip39_slip39’s own leaves as that package’s class. A mnemonic this package hands on is checked there, and Bip39Slip39TypeError and Bip39Slip39ValueError are a TypeError and a ValueError and no BTClibException, so except ValueError is what catches a refusal of either library. tests/exception_family_test.py holds them in the same families.

tests/input_validation_test.py drives that rule over every public function whose required parameters are all library input types; tests/bool_contract_test.py, tests/built_object_contract_test.py and tests/curve_parameter_test.py drive it from fixtures where that walk cannot reach.

check_validity=False is not an exemption from this. It says “do not check now”, not “this object is exempt from here on”: these are dataclasses whose validity at construction is not validity at use. Passing it is supported — tests/check_validity_test.py exercises the flag — so a public function that takes an already-built object and asks it nothing is one a caller can reach with an object the package itself would refuse. Every validating dataclass takes check_validity keyword-only.

A bool parameter is a kind or a truth, and only the first is type-checked. A flag that decides what is computed refuses a non-bool; a flag that decides only whether a check runs is read for its truth, and its True is its conservative value. tests/bool_parameter_test.py is the census of which each flag is.

A name’s prefix says what the call answers. assert_* refuses and returns None; is_* and verify* answer a bool and are total over the declared types; check_* answers a bool and refuses what cannot be an answer. A public function answering a bool carries one of the four or is one of the English predicates tests/name_contract_test.py names, such as Psbt.inputs_modifiable. A member that takes nothing but self is a @property.

A private function takes no default argument, the value the call is made with being at the call site, where it is read; tests/private_defaults_test.py is the gate. A trailing underscore is public, and marks the spelling whose input the caller has already prepared; both names of such a pair are in __all__, and prepared is not unchecked — tests/integer_policy_test.py holds the coercion policy.

The environment and the gates

uv is the only tool that must be installed; it fetches interpreters, linters and packaging tools itself. uv sync creates the environment.

uv sync

No test outside tests/integration/ reaches the network or needs a node. tests/README.md is where the suite, its switches and the integration tests are described.

The gate is the suite, the hooks and the documentation build:

uv run pytest
uv run pre-commit run --all-files
uv run --locked --no-default-groups --group docs \
    sphinx-build -n -W -b html docs/source docs/build/html

--cov is in addopts, so the bare pytest above is the coverage gate and fail_under is what it answers against — 100%, and coverage takes that literally: a statement or a branch no test reaches fails it. A selective run is reported and not gated, and tests/conftest.py’s coverage_fail_under is what makes that difference.

The documentation build is the one to remember, because no hook reads reStructuredText: a docstring docutils cannot parse fails it with every hook green — a name ending in an underscore is a reference to a link target, and the fix is double backticks around it. -n turns an unresolved cross-reference into a warning for -W to fail on, and conf.py’s intersphinx_mapping is what resolves a reference into the standard library, btclib, ecc or bitcoin-core-rpc.

Check exit codes, not filtered output. pre-commit run ... | grep -v Passed hides a failure, and grep finding nothing exits 1, which is not the gate’s answer to anything.

The lint gate is not installed as a git hook. pre-commit install writes into the common git directory, which every worktree of this repository shares: git -C <worktree> rev-parse --git-path hooks answers with the primary checkout’s .git/hooks from every one of them. So one session installing it installs it for every other. Run the gate by hand before committing — the uv run pre-commit run --all-files above.

Prefix any --python <version> command with UV_PROJECT_ENVIRONMENT=.venv-<version>, naming the interpreter that command selects — .venv-3.11 for --python 3.11, .venv-pypy3.11 for --python pypy3.11. Without it, uv run --python <version> removes .venv, builds it again on that interpreter and with that command’s own group set, and leaves it there. uv sync restores it.

A Dependabot uv pull request

Dependabot’s uv ecosystem rewrites uv.lock alone. Where a bump moves a package that .clusterfuzzlite/requirements.txt pins, the uv-export hook fails the lint job. An owner, as GOVERNANCE.md names them, then commits the hook’s output to the pull request’s branch before it lands, signed and signed off. On a checkout of that branch:

uv run --locked --only-group lint pre-commit run uv-export --all-files
git commit -S -s -m "Export .clusterfuzzlite/requirements.txt from uv.lock" \
    .clusterfuzzlite/requirements.txt
git push origin HEAD:<branch>

The hook exits 1 when it rewrites the file. The push is a plain one, never a force, so a branch that moved meanwhile refuses it.

The commit is made by hand, not by a bot: the ci: block of .pre-commit-config.yaml says why.

The cost is that Dependabot stops rebasing a pull request once extra commits have been pushed to it, as GitHub’s documentation says. @dependabot recreate in a comment rebuilds the branch without the commit, which is then made again.

The editor

.vscode/settings.json and .vscode/extensions.json are tracked, and they hold no preference: the recommended extensions are the tools .pre-commit-config.yaml already runs, and the settings put the fixing ones on save. Installing them is optional and changes nothing about what a local run enforces.

Anything machine-local — an interpreter path, a telemetry answer, a theme — belongs in the editor’s own user settings instead, those two files being read by every checkout of this repository.

Reproducing what CI runs

Each command below is the one a CI job runs. Keep this section true when a workflow changes.

os-ubuntu.yml, os-macos.yml, os-windows.yml and deps-oldest.yml, the suite job of each — the suite, on one cell of a matrix. --no-cov undoes the --cov addopts carries, the coverage job below being where coverage is measured and gated:

uv run --locked --no-default-groups --group test pytest --no-cov

test.yml, the coverage job:

uv run --locked --no-default-groups --group test pytest

test.yml, the dist job — build the distribution files, check them and install one. release.yml’s test job calls this workflow, which then checks the files reusable-build.yml built and uploaded instead of building its own. release.yml’s publish jobs download that dist artifact, so what the checks below judge is what an index ends up serving. normalize_sdist.py is what puts the commit’s own time into every member of the sdist, and its docstring says why the backend’s archive is not published as it stands; sha256sum after it is the digest a rebuild from the tag is compared against, per RELEASING.md’s Rebuild a release from its tag. generate_sbom.py writes the CycloneDX bill of materials into sbom/, which reusable-build.yml’s attest job signs beside the two files on a release. It is btclib-org/.github’s, served from main, and reads this tree from the working directory, so the commands fetch it first:

export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct)
uv build
uv run --no-project --python 3.15 .github/scripts/normalize_sdist.py dist/
sha256sum dist/*
served=$(mktemp -d)
git clone --depth 1 --filter=blob:none --sparse \
    https://github.com/btclib-org/.github "$served"
git -C "$served" sparse-checkout set .github/scripts
uv run --no-project --python 3.15 \
    "$served"/.github/scripts/generate_sbom.py dist/ sbom/
uv run --locked --only-group check twine check --strict dist/*
uv run --locked --only-group check check-wheel-contents dist/*.whl
uv run --locked --only-group check pyroma --min 10 dist/*.tar.gz

The job then installs the wheel it just built, alone, from an empty directory, and derives an address with it:

tmp=$(mktemp -d)
cd "$tmp"
uv venv
uv pip install "$OLDPWD"/dist/*.whl
.venv/bin/python -c "
from importlib.metadata import requires, version
from btclib_wallet import bip44
from btclib_wallet.mnemonic import bip39

print(version('btclib-wallet'), requires('btclib-wallet'))
xprv = bip39.mxprv_from_mnemonic('abandon ' * 11 + 'about')
address = bip44.address_from_der_path(xprv, 'm/84h/0h/0h/0/0')
assert address == 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu', address
"

Last, it unpacks the sdist outside the checkout and runs the suite there, under the coverage job’s floor; from the checkout’s root:

tmp=$(mktemp -d)
tar -xzf dist/*.tar.gz -C "$tmp" --strip-components=1
cd "$tmp"
uv run --locked --python 3.15 --no-default-groups --group test pytest

lint.yml, the lint job — this file is the lint gate, so there is no second list of tools anywhere:

uv run --locked --only-group lint \
    pre-commit run --all-files --show-diff-on-failure

docs.yml, the docs job — the build, and then a read of the pages it wrote:

uv run --locked --no-default-groups --group docs \
    sphinx-build -n -W -b html docs/source docs/build/html || exit $?
if grep -rn 'href="#\.\.\?/' docs/build/html --include='*.html'; then
    echo "::error::the links above resolve to no page (unresolved relative path)"
    exit 1
fi
found=0
pin=$(sed -n 's/.*reusable-docs\.yml@\([0-9a-f]*\).*/\1/p' .github/workflows/docs.yml)
served=$(mktemp -d) &&
git clone --quiet --filter=blob:none --sparse \
    https://github.com/btclib-org/.github "$served" &&
git -C "$served" sparse-checkout set .github/scripts &&
git -C "$served" checkout --quiet "$pin" &&
uv run --no-project --python 3.15 \
    "$served"/.github/scripts/check_included_links.py docs/build/html || found=$?
rm -rf "$served"
test "$found" -eq 0 || exit "$found"

What myst renders for a destination it cannot resolve is an anchor to an id no page has. -W reports it because docs/source/conf.py resolves the links the included root files carry and suppresses no myst warning; the grep is what still finds one the day a suppression goes back in.

check_included_links.py is btclib-org/.github’s: the commands above check it out at the commit docs.yml pins, as the job does. Its docstring says what it refuses.

integration-bitcoind.yml, the regtest job — a pinned Core release, its published sha256 verified, and the regtest tests with the binary named rather than found on PATH:

BTCLIB_INTEGRATION=1 BTCLIB_BITCOIND=/path/to/bitcoind \
    uv run --locked --no-default-groups --group test \
    pytest tests/integration --junitxml=integration.xml

A step after it reads that report and fails the job if a regtest test skipped, pytest exiting 0 for a module that skipped itself. Which tests skip there by design, and the skip message the step accepts, are that workflow’s header.

integration-electrs.yml — ElectrumFetcher and EsploraFetcher against Blockstream’s electrs on a regtest bitcoind, a reorg included. The bitcoind is the pinned release of the job above. electrs publishes no release binary, so the job builds the commit the workflow pins, and BTCLIB_ELECTRS names the result:

cargo install --locked --bin electrs \
    --git https://github.com/Blockstream/electrs \
    --rev <commit> --root /path/to/electrs-install electrs
BTCLIB_INTEGRATION=1 BTCLIB_BITCOIND=/path/to/bitcoind \
    BTCLIB_ELECTRS=/path/to/electrs-install/bin/electrs \
    uv run --locked --no-default-groups --group test \
    pytest tests/integration/electrs_test.py \
    tests/integration/electrs_reorg_test.py --junitxml=integration.xml

A step after it fails the job if one of these tests skipped. The job is not a required check: it builds a third party’s server. Its pull_request trigger runs it on a pull request touching a path its paths: lists, and gh workflow run integration-electrs.yml --ref <branch> runs it by hand.

integration-floresta.yml — ElectrumFetcher against florestad, on its plain port and its TLS port. Floresta checks blocks with utreexo proofs, which bitcoind does not serve, so utreexod sits between them: the pinned bitcoind mines, utreexod syncs from it and florestad’s only peer is utreexod. utreexod is the release tarball, checked against the sha256 in the release’s manifest. The release publishes no signature for that manifest, so the pin is as good as the release page it was read from. florestad publishes no checksummed binary for the pinned commit, so the job builds it, with libboost-dev and cmake installed. BTCLIB_FLORESTAD and BTCLIB_UTREEXOD name the two programs:

cargo install --locked --bin florestad \
    --git https://github.com/getfloresta/Floresta \
    --rev <commit> --root /path/to/floresta-install florestad
BTCLIB_INTEGRATION=1 BTCLIB_BITCOIND=/path/to/bitcoind \
    BTCLIB_FLORESTAD=/path/to/floresta-install/bin/florestad \
    BTCLIB_UTREEXOD=/path/to/utreexod \
    uv run --locked --no-default-groups --group test \
    pytest tests/integration/floresta_test.py --junitxml=integration.xml

A step after it fails the job if one of these tests skipped. The job is not a required check: it builds and downloads third parties’ programs. Its pull_request trigger runs it on a pull request touching a path its paths: lists, and gh workflow run integration-floresta.yml --ref <branch> runs it by hand.

integration-hwi.yml — the HWI tests against a Trezor emulator and against a Ledger one under Speculos, each beside the same node. The workflow’s own header says what each job installs and pins; the Trezor job runs:

BTCLIB_INTEGRATION=1 BTCLIB_HWI_SIGN=1 \
    BTCLIB_HWI="/path/to/hwi --emulators" \
    BTCLIB_BITCOIND=/path/to/bitcoind \
    uv run --locked --no-default-groups --group test \
    pytest tests/integration/hwi_device_test.py -n0 --junitxml=hwi.xml

It carries no pull_request trigger: a firmware release or an emulator that stopped starting headless is the vendor’s day rather than the branch’s. A branch touching src/btclib_wallet/hwi.py asks for it:

gh workflow run integration-hwi.yml --ref <branch>

qa-assets.yml — the fuzz corpora of psbts and descriptors of Bitcoin Core and rust-bitcoin, replayed through this package’s parsers. The workflow checks out bitcoin-core/qa-assets and rust-bitcoin/qa-assets sparsely, the directories TARGETS in tests/integration/qa_assets_test.py names, and runs:

BTCLIB_QA_ASSETS=/path/to/qa-assets \
BTCLIB_RUST_BITCOIN_QA_ASSETS=/path/to/rust-bitcoin-qa-assets \
    uv run --locked --no-default-groups --group test \
    pytest tests/integration/qa_assets_test.py -n 0 --no-cov

Its psbt-interop job, which runs on the clock and by hand and never on a pull request, compares generated psbts with bitcoin-s and Core; tests/README.md’s Generated PSBT interoperability has its command.

codeql.yml has no line here: its jobs run github/codeql-action and no command of this project’s, so reproducing it locally means the CodeQL CLI and a database rather than a uv run.

sourcery.yml has no line here either: the standard’s The set, and its cadence says why Sourcery is a pull request’s check and not a local one.

What gates a merge, and what only reports

lint.yml, test.yml, docs.yml, integration-bitcoind.yml and sourcery.yml produce the required checks, and REPOSITORY.md reads the rule back from the endpoint rather than restating it. So a diff does not reach a review without having passed them or passing them beside it on the same sha, which is the reliance REVIEWING.md provides for.

workflow

when

what it varies

test

pull request, push

—

lint, docs

pull request, push

—

integration-bitcoind

pull request, push to main, weekly

—

claude-review

pull request, and @claude in a comment

—

api-break

pull request

—

sourcery

pull request

—

codeql

pull request, push, and weekly

the languages

qa-assets

weekly, pull request on its paths

Core’s, rust-bitcoin’s

fuzz

weekly

—

os-ubuntu

weekly, a release

images × interpreters

os-macos

weekly, a release

images × interpreters

os-windows

weekly, a release

images × interpreters

deps-latest

weekly

dependencies upgraded

deps-oldest

weekly

the floor interpreter, dependencies at their floors

integration-hwi

weekly, push to main

two emulators

integration-electrs

pull request on its paths, push to main

—

integration-floresta

pull request on its paths, push to main

—

scorecard

weekly, push to main

—

verified-signatures

push to main

—

links

weekly

—

mutation

weekly

—

vendored-vectors

weekly

the pins in tests/_data/README.md

pypi-install

weekly, a release

what PyPI serves

sdist-rebuild

weekly

the latest release’s sdist, rebuilt

release

a tag, and a rehearsal

the workflows it calls

Which workflows that last row covers is grep -n 'uses: \./\.github/workflows/' .github/workflows/release.yml, not a list here. Which day each of the rest runs is section 10 of the organization’s standard, and not this file’s to restate.

The test, lint, docs and integration-bitcoind rows run one image on one interpreter: ubuntu-latest, and the version .python-version names. claude-review gates nothing: a review that gates a merge would make a model’s judgement a branch rule. api-break gates nothing either; it only labels. So few checks gate a merge because the plan caps the jobs the whole organization may run at once, and REPOSITORY.md’s Plan-gated settings records that ceiling.

verified-signatures reports after each push to main whether every commit pushed is verified; the standard’s Signatures has the rule.

Mutation testing

mutation.yml asks the question coverage cannot: a line the suite executes is not a line the suite checks. It gates nothing and runs weekly, through btclib-org/.github’s reusable-mutation.yml; each file under .github/mutation/ is one session, its header saying what it mutates. One session, by hand:

uv run --locked --no-default-groups --group test --group mutation \
    cosmic-ray baseline .github/mutation/bip32.toml
uv run --locked --no-default-groups --group test --group mutation \
    cosmic-ray init .github/mutation/bip32.toml bip32.sqlite
uv run --locked --no-default-groups --group test --group mutation \
    cr-filter-operators bip32.sqlite .github/mutation/bip32.toml
uv run --locked --no-default-groups --group test --group mutation \
    cosmic-ray exec .github/mutation/bip32.toml bip32.sqlite
uv run --locked --no-default-groups --group test --group mutation \
    cr-report --surviving-only --show-diff bip32.sqlite

The session writes each mutation into the source and restores it afterwards, so nothing else may read the tree while it runs.

The secrets baseline

detect-secrets reads .secrets.baseline to decide which findings have already been reviewed. The test data of a wallet is keys and mnemonics, published upstream as vectors, and adding one means regenerating it:

uvx detect-secrets scan --baseline .secrets.baseline
git add .secrets.baseline
uv run --locked --only-group lint pre-commit run detect-secrets --all-files

The hook fails while the baseline has unstaged changes. The scan and the hook each leave it unstaged.

Read the diff before committing it: a new entry is a finding somebody has to have looked at, which is the whole point of a baseline over an exclusion.

Two branches that each change .secrets.baseline conflict on its generated_at. Taking either side whole drops the other side’s entries, and the hook does not add them back. Take main’s side, run the commands above, put main’s generated_at back, and stage the file. The baseline should then differ from main’s by this branch’s entries alone. The resolution is still a conflict resolved, so REVIEWING.md’s Re-review applies.