Root Cause Analysis¶
The pre-push hook and the task lint shortcut were a strict subset of what GitHub Actions executed. Local checks ran ruff check and bandit, but CI additionally enforced ruff format --check, mypy src/pyrxd/security/, the full pytest suite, a 100% coverage floor on pyrxd.security, and an 85% coverage floor on the package overall. Because none of those four gates were reachable through a single local command, contributors could push a branch that passed every check they knew to run and still watch CI fail — exactly what happened on PR #14. The drift was silent: nothing in the repo flagged that the local and remote check sets had diverged, so the gap only surfaced when CI rejected work that “looked clean” locally.
A second, subtler cause compounded the first. CONTRIBUTING.md instructed new contributors to bootstrap with pip install -e ".[dev]", which installs only the dev dependency group. The test group — containing pytest-cov, pytest-mock, hypothesis, and pytest-github-actions-annotate-failures — was omitted, so even a contributor who tried to run the full CI command set locally would hit cryptic ModuleNotFoundError failures rather than a real signal. The fix had to address both halves: a single local command that mirrors CI exactly, and a setup path that actually installs the dependencies that command needs.
Solution¶
PR #15 closed the gap with three coordinated changes: a task ci aggregate that mirrors the CI workflows one-for-one, a versioned pre-push hook that runs it, and a corrected setup instruction so the hook’s dependencies are actually present.
1. New tasks in pyproject.toml¶
The missing checks are added as named tasks, then composed into a single task ci that runs the same set GitHub Actions runs.
[tool.taskipy.tasks]
# ... existing tasks ...
format-check = "ruff format --check src tests examples"
typecheck = "mypy src/pyrxd/security/"
coverage-security = 'pytest tests/security/ -o "addopts=" --cov=pyrxd.security --cov-fail-under=100'
coverage-overall = 'pytest tests/ -o "addopts=" --cov=pyrxd --cov-fail-under=85'
# `task ci` runs the full set of checks GitHub Actions runs (.github/workflows/{lint,ci}.yml).
ci = "task lint && task format-check && task test && task coverage-security && task coverage-overall && task typecheck"
The -o "addopts=" override on the coverage tasks clears any default addopts so the explicit --cov flags are not double-applied or shadowed by repo-wide pytest configuration.
2. Versioned pre-push hook at scripts/git-hooks/pre-push¶
The hook lives in the repo (so it’s reviewable and updatable) and delegates entirely to task ci, keeping a single source of truth for what “ready to push” means.
#!/usr/bin/env bash
set -euo pipefail
if ! command -v task >/dev/null 2>&1; then
echo "task not found on PATH — activate the project venv (source .venv/bin/activate) and re-run, or install dev deps with: pip install -e .[dev]"
exit 1
fi
if task ci; then
echo "pre-push: all CI checks passed locally"
else
echo "pre-push: local CI failed — fix before pushing (or --no-verify for WIP)"
exit 1
fi
3. Installer at scripts/install-git-hooks.sh¶
Git won’t track .git/hooks/ directly, so an installer symlinks the versioned hooks into place. Symlinking (rather than copying) means future hook updates take effect without re-running the installer.
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HOOK_SRC_DIR="${REPO_ROOT}/scripts/git-hooks"
HOOK_DST_DIR="${REPO_ROOT}/.git/hooks"
for src in "${HOOK_SRC_DIR}"/*; do
name="$(basename "${src}")"
dst="${HOOK_DST_DIR}/${name}"
# Symlink so future updates take effect without re-running installer
if ln -sf "${src}" "${dst}" 2>/dev/null; then
chmod +x "${dst}"
else
cp "${src}" "${dst}"
chmod +x "${dst}"
fi
done
4. CONTRIBUTING.md setup fix¶
The bootstrap instruction is changed so the dependencies task ci needs are actually installed.
Before:
pip install -e ".[dev]"
After:
poetry install --sync # installs all groups (dev + test) — matches CI exactly
poetry install --sync installs both the dev and test groups, matching the environment CI builds. Without this, task ci would fail on the coverage tasks with ModuleNotFoundError: No module named 'pytest_cov' and similar.
Contributor workflow¶
After cloning:
poetry install --sync
./scripts/install-git-hooks.sh # one-time hook install
Before any push (automatic once the hook is installed):
poetry run task ci
Validation¶
End-to-end run on the PR #15 branch:
task cicompleted in 1m34s2178 tests passed, 3 skipped
86.26% overall coverage (>=85% required)
100%
pyrxd.securitycoverage (required)mypy reported “no issues found in 5 source files”
The pre-push hook fired on the actual push, found nothing new because task ci had already been run, and PR #15 went green in CI on the first attempt.
Prevention Strategies¶
Run
task cibefore every push. This is the canonical local mirror of.github/workflows/{lint,ci}.yml. If it passes locally, CI will pass; if it fails, fix it before pushing. Treattask cias the source of truth for “ready to push.”Install the pre-push hook once per clone. Run
./scripts/install-git-hooks.shafter cloning and after the hook version bumps. The hook runstask ciautomatically so you cannot accidentally push a broken commit during routine work.Use
poetry install --syncfor dev setup. Do not usepip install -e .[dev]— it silently skips thetestPoetry group, so your environment will be missing test-only dependencies and you will get green local runs that diverge from CI.--syncalso prunes stale packages so your env matchespoetry.lockexactly.When changing a constant referenced in tests, run the full suite. Constants like the BIP-44 coin type (
m/44'/512'/...), key prefixes, network IDs, and fee schedules are typically referenced as literal strings across many test files. Runpoetry run pytest(no path argument) — notpytest tests/test_hd_wallet.py— whenever you touch a shared constant.Keep
pyproject.tomltasks in lockstep with.github/workflows/*.yml. When you add or modify a CI step (new linter, new check, new pytest flag), update the corresponding task in the same PR. Treat the workflow file and the task definition as a paired edit.Run both halves of ruff. Local checks must include
ruff check(lint) ANDruff format --check(formatter).task cialready does both — don’t substitute partial commands.
Detection Methods¶
The pre-push hook is the primary early-detection mechanism. It catches local-CI parity issues before they ever reach GitHub Actions. Hook output should match CI output line-for-line.
CI parity audit script. Add a small CI job (or pre-commit check) that asserts
task ciinvokes the same commands as the workflow files. A simple diff of the command list catches drift like “we addedmypyto CI but forgot to add it totask ci.”Watch the workflow vs. task last-modified gap. If
.github/workflows/ci.ymlwas edited more recently thanpyproject.toml’s[tool.taskipy.tasks]block (or vice-versa), that’s a signal of drift worth investigating.Run
poetry run pytest --collect-only | wc -lbefore and after refactors. If the number of collected tests changes unexpectedly, you may be running a narrower set than CI.Search for literal constant values, not just symbol names. Before merging a constant change,
rg "44'/236'"(or whatever the old value was) across the entire repo — includingtests/,docs/, and example files — to find every hardcoded usage.
Anti-patterns to Avoid¶
Don’t claim “tests passed” after a scoped run.
pytest tests/test_hd_wallet.py tests/test_hd.py tests/test_keys.pyis a partial signal, not a green light. Only a fulltask ci(or unscopedpytest) justifies pushing.Don’t rely on
ruff checkalone. It does not catch formatting issues thatruff format --checkwould catch. They are separate tools with separate rule sets.Don’t bypass the pre-push hook with
--no-verifyexcept for genuine WIP branches you are sharing for review-only purposes. Never bypass when pushing to a PR branch you intend to merge.Don’t add a CI workflow step without updating the matching task. A CI-only check creates a permanent local-CI drift trap for the next contributor.
Don’t use
pip install -e .[dev]in this repo. It misses thetestgroup and produces an environment that lies to you about test status.Don’t trust “works on my machine.” The only equivalent of “what CI will say” is
task ci. Anything less is speculation.Don’t change a coin-type, prefix, or wire-format constant in isolation — these values are baked into test fixtures and example outputs across the repo. Treat constant changes as cross-cutting refactors that demand a full-suite verification.