ShippedSecurity · CI/CD
Workflow SAST — zizmor
boost's four workflows embed github-script JavaScript and
shell and drive a Trusted-Publisher release — a rich attack surface.
zizmor statically flags template injection, unpinned
actions, over-broad GITHUB_TOKEN permissions and dangerous
triggers. The one tool that audits the automation that ships every
other fix.
Complexity S
Impact High
Wow ★★★★★
pipx-run
ShippedQuality · CI/CD
Workflow linting — actionlint
Catches GitHub Actions YAML bugs before they fail a live release:
malformed ${{ }} expressions, deprecated syntax, and
shellcheck run over every run: block. Cheap insurance for a
repo whose release is fully automated — a broken workflow is a broken
publish.
Complexity S
Impact Med
Wow ★★★
shellcheck built in
ShippedSecurity · Supply chain
Build provenance — SLSA attestations
actions/attest-build-provenance emits a cryptographically
signed, verifiable record of which workflow built which wheel from
which commit, layered on top of the existing PyPI Trusted
Publishing. Consumers can gh attestation verify the
artifact they install — provenance without a paid signing service.
Complexity S
Impact Med
Wow ★★★★
SLSA build L2
ShippedSecurity · Supply chain
SBOM on every release — CycloneDX / Syft
Generate a CycloneDX SBOM with the free anchore/sbom-action
and attach it to each GitHub Release, so downstreams can inventory and
scan exactly what boost is built from. Modest today (boost is close to
stdlib-only) but it grows in value as the optional [rag]
extras pull in real dependencies.
Complexity S
Impact Med
Wow ★★★
feeds osv-scanner
ShippedSecurity · Vuln
SBOM-aware scanning — osv-scanner
Google's osv-scanner cross-checks a lockfile or the SBOM
above against the OSV database, with broader ecosystem coverage than a
single-language audit. The SBOM-driven complement to pip-audit:
one scans the resolved env, the other scans the published manifest.
Complexity S
Impact Med
Wow ★★
OSV-backed
ShippedTesting · Type
Second type checker — pyright
Run Microsoft's pyright alongside mypy. Its independent
inference catches None-flow and narrowing bugs the current
mypy config lets slide, and it's the same engine most editors use — so
CI enforces what contributors already see. Two type checkers rarely
agree on nothing.
Complexity M
Impact Med
Wow ★★★
editor-parity
ShippedQuality · Smell
Widen the ruff rule surface — B·SIM·C4·PERF·RUF
Beyond the S security family (round 1), enable
flake8-bugbear (B), simplify (SIM),
comprehensions (C4), perflint (PERF) and
Ruff-native (RUF) rules — bug, readability and performance
smells caught at zero new-tool cost since ruff already runs in the lint
gate.
Complexity S
Impact Med
Wow ★★★
0 new deps
ShippedTesting · Gap
Patch-coverage gate — diff-cover
Enforce coverage on the changed lines of each PR — no external
service needed, it reads the same coverage.xml the suite
already emits. Pairs with the 80% project gate so new code can't quietly
ride in under-tested behind an already-high overall number.
Complexity S
Impact Med
Wow ★★
self-hosted
ShippedCI/CD
publish.yml ignores the pip-audit / metadata gates
publish.yml triggers on workflow_run: [ci] and gates only on
github.event.workflow_run.conclusion == 'success' — it never checks the
independently-triggered pip-audit.yml or package-metadata.yml workflows
that run on the same push. A merge that fails the live-CVE gate or the twine/metadata check still
auto-publishes to PyPI as long as ci itself is green.
Shipped as the second option: scripts/release_preflight.py runs as the release job's
first step — before release-drafter creates the tag, so a blocked release leaves no
published Release behind — and waits for each required workflow to conclude for
git rev-parse HEAD, the commit actually checked out and about to be built.
Folding them into ci.yml was rejected: pip-audit also runs weekly on a
cron, which inside ci.yml would drag the whole 9-cell test matrix along with it.
The gate fails closed — red, cancelled, skipped, never started, still
running at the deadline and an unreadable API reply are all refusals. Silence is the dangerous
case: a gate that never ran leaves nothing red to see, so it must never read as consent.
Complexity S
Impact High
Wow ★★★
gates PyPI
ShippedCI/CD
No timeout-minutes on any CI job
A hung step — a stalled tap clone, a wedged smoke-test subprocess — runs until GitHub's default
job timeout instead of failing fast, costliest across the 3 OS × 3 Python tests
matrix. (The card said 8 workflow files; there are 24 now, of which only fuzz.yml
and sonarcloud.yml had a timeout.) Shipped: 24 jobs across 22 files, with the
numbers taken from measured durations over recent runs rather than guessed —
mutation's slowest observed run was 24.8m so it gets 60, the tests
matrix peaked at 8.3m so it gets 30, publish's release job gets 45 because the
new release preflight can legitimately wait on a sibling gate, and everything else ran under
2m so it gets 15. A timeout that trips on a normal run is worse than no timeout.
osv-scanner.yml's scan-pr is the one job left without one: it
delegates to a reusable workflow via a job-level uses:, and GitHub rejects
timeout-minutes there outright — the bound has to live in the called workflow.
A unit test holds the line so a job added next month cannot quietly arrive without one, and
it refuses to pass vacuously if its own parser stops finding jobs.
Complexity S
Impact Low
Wow ★
ShippedSupply chain
Dependabot's pip entry misses pyproject.toml's extras
.github/dependabot.yml declares pip for
directory: /requirements, so the hash-pinned dev/CI toolchain does get weekly
bump PRs. What it does not cover is pyproject.toml at the repo root:
Dependabot only scans manifests under the declared directory, so the optional
extras — [rag] (sqlite-vec), [eval] (ranx, ragas and the pinned
langchain 0.3 stack), [bdd] (behave), [perf] (pytest-benchmark) —
never get proactive version-bump PRs, only reactive pip-audit CVE flags.
Low impact by design: [project].dependencies is empty, so none of this reaches
anyone who installs boost-skill-cli — it is contributor-facing only. The fix is
a second pip entry with directory: /. Weigh it against the noise:
the [eval] stack is deliberately held at langchain 0.3 because ragas
0.2.x breaks against langchain ≥1.0, so that one will raise PRs that must be closed
unmerged until ragas catches up.
Complexity S
Impact Low
Wow ★
extras only — /requirements is covered
ShippedSupply chain
License-compliance scanning of the dependency closure
Nothing in CI checks SPDX license compatibility of resolved dependencies — pip-audit
gates known CVEs, not license terms, and the [eval]/[rag] extras pull in a
nontrivial transitive closure (the langchain stack, ragas, sqlite-vec) whose licenses are never
verified against boost's own. Add a pip-licenses --fail-on (or reuse lint)
step to package-metadata.yml to catch an incompatible transitive dependency before an
extra ships it.
Complexity S
Impact Med
Wow ★★
PlannedSupply chain
The pytest tmpdir CVE has no fix at our Python floor
CVE-2025-71176 — pytest through 9.0.2 relies on the predictable
/tmp/pytest-of-{user} path, letting a local user cause a denial of
service or possibly gain privileges. Dependabot alert #1 (medium), raised against
requirements/test-tools.txt.
It cannot be closed by a version bump. The lock carries two markered pins, and only
one of them is exposed: pytest==9.1.1 ; python_full_version ≥ '3.10' is
already past the 9.0.3 patch, while
pytest==8.4.2 ; python_full_version < '3.10' is not — and cannot be, because
pytest 9.x declares requires-python ≥3.10 while this project declares
requires-python = ">=3.9". The advisory marks every 8.x release
vulnerable, so there is no backport to move to either. The fix is gated on dropping the 3.9
floor, which is a deliberate compatibility promise, not an oversight.
Residual exposure is contributor-facing only: test-tools.txt is the dev/CI
toolchain and [project].dependencies is empty, so nobody installing
boost-skill-cli ever receives pytest, and CI runners are single-tenant
ephemeral VMs where the local-attacker precondition does not hold. Dismissed as
tolerable_risk rather than left open to rot the alert list.
Revisit when either the 3.9 floor is dropped or a patched 8.x lands. If the floor must stay
and the alert must clear, the real mitigation is pointing pytest at a private base
temp dir (--basetemp / PYTEST_DEBUG_TEMPROOT) so the predictable
path is never used — that fixes the behaviour without satisfying Dependabot's version check.
Complexity M
Impact Low
Wow ★★
dismissed as tolerable risk, 2026-07-25
ShippedSupply chain
OpenSSF Scorecard's findings, triaged into three piles
Scorecard files into the same code-scanning inbox as CodeQL, so its findings read like
code defects and get ignored alongside the false positives. They are not defects — they
are posture metrics, and they must not be dismissed as "false positive" the way the
serve.py traversal alerts legitimately were, because that misrepresents the
repo's security posture. Triaged 2026-07-25, re-triaged 2026-07-29 against the 7 still open.
Fixed — TokenPermissions #26/#27/#28: ci,
codeql and adapter-conformance had no top-level
permissions: block at all, scoring 0. Added contents: read as the
least-privilege default.
Intentional, leave — TokenPermissions #39/#37/#25/#24:
publish.yml needs contents: write to create the release and tag,
osv-scanner needs security-events: write to upload SARIF, and
ci.yml's tests job needs contents: write for the
coverage-badge push. "Fixing" these breaks the release pipeline. They should be
dismissed as used-in-tests/acceptable-risk with a reason, not left open to rot the list.
Correction — PinnedDependencies was NOT "not code-fixable". The first
pass filed #40/#41/#48 under "npx and pip invocations inside steps, not actions, so
SHA-pinning does not apply". That reads the check as being about action SHAs, and it
is not: for npm the remediation is a committed lockfile, and all three hits were
npm install steps. tests/visual/package.json pinned its two direct
deps exactly — and .gitignore then excluded the lockfile, so 79
transitive packages resolved fresh on every run. theme-lint was worse:
npm install stylelint@16 eslint@9 re-resolved the whole linter every run, so
the linter CI executed was never the linter anyone reviewed. Both trees are now hash-pinned
(181 + 81 packages, integrity on every one) and installed with npm ci;
dependabot.yml gains both npm directories, because a pin with no update path is
only half the job.
What the pin immediately exposed. Committing the lock made
osv-scanner able to see the transitive tree for the first time, and it failed
the branch at once on CVE-2026-14257 (brace-expansion, CVSS 7.5,
unbounded expansion → OOM). Pre-existing, not introduced: the old floating
npm install eslint@9 resolved the same package, invisibly. The advisory's range
is all versions below 5.0.8, so the 1.x maintenance line has no fix — and the obvious
patch, an overrides entry forcing brace-expansion@^5, breaks
eslint: v5 exports { expand } where minimatch@3 requires a
callable default, so eslint "style/**/*.{js,mjs}" dies with
"expand is not a function" while plain eslint style/ still passes — a
green-looking linter with a broken glob path. The real fix is upstream:
eslint@10 depends on minimatch@^10.2.5, which requires the patched
brace-expansion@^5.0.8. Bumped 9.39.5 → 10.8.0; all 262 packages across both
locks now return zero OSV advisories.
Open by decision, correctly — BranchProtectionID #33 (4/10) and
CodeReviewID #34 (0/26 approved changesets) are two readings of one deliberate
choice recorded in its own card: require status
checks, do not require reviews, because the working model is parallel
loop/* branches that self-merge and there is no second reviewer. Every warning
Scorecard prints under #33 — stale-review dismissal, required approvers, CODEOWNERS review,
last-push approval — follows from that one choice. They stay open because the score is
accurate: this repo really does merge without human review. Dismissing them would be
the lie.
Clears itself — MaintainedID #35 is "repository was created within the
last 90 days". Created 2026-07-17, so it resolves on its own around 2026-10-15. Nothing
to do but not dismiss it.
Needs a human, not a commit — CIIBestPracticesID #36 is the one
remaining actionable finding, and it cannot be fixed from a branch: it asks whether the
project is registered for an OpenSSF Best Practices badge, which means creating a project
entry at bestpractices.dev under a real account and answering its criteria. The
prerequisites are already in place (LICENSE, SECURITY.md, CONTRIBUTING.md,
CODE_OF_CONDUCT.md, a published CI gate); only the registration is missing.
Complexity M
Impact Med
Wow ★★
7 open · 3 now hash-pinned, 3 decided, 1 clears itself
ShippedRelease safety
main has no branch protection, so the release rules are honour-system
GET /branches/main/protection returns "Branch not protected". Scorecard
flags it (BranchProtectionID, score 0), but the sharper way to see it is this:
CLAUDE.md says "never merge onto a red release" and "every merge to main cuts a
PyPI release" — and nothing enforces either. A merge with a red gate succeeds
silently, and the release workflow then ships that commit to PyPI. The rule exists only in
the head of whoever is merging.
This is a decision, not a bug, which is why it is filed rather than fixed. Requiring
status checks would have stopped the exact situation that occurred on
2026-07-25, when three PRs sat red on a lock_toolchain --check failure that
belonged to none of them. But protection cuts both ways here: the repo's whole working model
is parallel loop/* branches opening and merging their own PRs, so
"require pull request reviews" would deadlock every loop (there is no second
reviewer), and that same absence is what keeps CodeReviewID pinned at 0/30.
The shape that fits: require status checks to pass (at minimum lint
and the tests matrix) and require branches to be up to date, but do not
require reviews. That enforces the release rule that actually matters while leaving
self-merge intact. Decide before adding more concurrent loops, not after.
Update — the blocker is gone. This prescription was not implementable as written: lint named three different jobs (ci, markdownlint, theme-lint), and GitHub matches required checks by name alone. Those collisions are now renamed, the required list is checked-in at .github/required-checks.txt and gated against drift, and python3 scripts/check_required_checks.py --print-api emits the exact payload — with required_pull_request_reviews: null, so self-merging loops keep working. Still a decision, but now a one-command one.
Shipped. The decision was taken as prescribed — status checks yes, reviews no.
GET /branches/main/protection now returns strict: true with
required_pull_request_reviews absent, so a red gate blocks the merge that would
have cut the release, and loops still self-merge. One correction to the prescription above:
requiring "at minimum lint and the tests matrix" is right, but the
list must contain only checks that run on every PR — the first version also
required four path-filtered docs checks, which report on some PRs and not others and would
have hung any PR that touched no matching file. See
required-checks-can-declare-a-check-that-deadlocks-prs.
Complexity S
Impact High
Wow ★★★
decided, applied, and now gated against deadlock
ShippedBug
adapter-conformance's LangGraph leg never passed — a quoted matrix value
The install step ran
pip -q install "${{ matrix.pip }}". Quoting is correct for the two legs
whose matrix value is a single token (crewai[anthropic],
openai-agents[litellm]) — the brackets would otherwise be shell globs.
But the LangGraph leg's value is a requirement list,
langgraph langchain-anthropic, so the quotes handed pip one bogus
requirement and it refused before installing anything:
ERROR: Invalid requirement: 'langgraph langchain-anthropic'.
The leg landed 2026-07-22 and the workflow was schedule/workflow_dispatch
only, so nothing exercised it on its own pull request — the first Monday cron was
its first execution ever, five days later, and it has a 0% pass rate. It read as
upstream framework drift; it was never that. Reproduced locally, and with the
step fixed the whole leg passes end to end against live PyPI: langgraph 1.2.9 +
langchain-anthropic 1.5.2 resolve clean, boost adapt --to langgraph
renders, and the factory builds a real
langgraph.graph.state.CompiledStateGraph.
Fix: pass the spec through env: (rather than inlining a
${{ }} expansion into run:, the shape zizmor flags) and
let it word-split under set -f, which keeps the [extra]
brackets literal — without it, crewai[anthropic] glob-expands against
any matching filename. Also added a narrow pull_request trigger on
adapters.py and this workflow, so an edit to the thing under test is
tested on the PR that makes it. That trigger is path-filtered, so it must stay out
of the required-check list: a required context that does not report on a PR leaves
it pending forever.
Complexity S
Impact Med
Wow ★★★
shipped broken, first run exposed it
ShippedRelease safety
The required-check gate could not see paths:, so it green-lit a list that deadlocks every PR
.github/required-checks.txt declared 21 contexts and
scripts/check_required_checks.py reported
"21 required, 22 PR check names, no ambiguity" and exited 0. Four of those 21 —
validate, markdown-lint, theme-lint and
vale — are produced by path-filtered workflows.
When a pull request touches none of a workflow's paths:, GitHub does not
create a pending check run that later resolves; it creates no check run at all.
Branch protection then waits forever for a status that is never coming, and the pull
request can never merge. Applying that list would have bricked the repository — and
--print-api would have emitted the payload to do it.
The cause is one regex. The gate decided "runs on pull_request" with
re.search(r"^\s{2}pull_request:", header), which is true for any workflow
that mentions the trigger, whether or not paths:,
paths-ignore: or types: [labeled] narrows it — precisely the
distinction that decides whether a required check deadlocks.
Demonstrated live rather than argued: the PR that fixed the
adapter-conformance install touches no style/** file, and its 30
check runs contain validate, markdown-lint, vale,
check and sweep — but no theme-lint. Requiring
theme-lint would have hung that PR permanently.
Fix: classify the trigger as none/always/filtered and refuse any required name that
resolves only to a filtered workflow, with an error that names the culprit file. The four
docs checks move to a commented block explaining why they are useful but not requireable.
The list is 17 contexts, all verified to report on every PR. Ten new tests cover the rule
and all ten fail against the previous script.
Complexity S
Impact High
Wow ★★★★
the gate green-lit a list that bricks every PR
ShippedBug
ci.yml's job summary could exit 1 on its own, under the always() it was given
The job summary step is marked if: always() so a run still reports
when something upstream died. Its last line was
[ -n "$SMOKE" ] && echo "- **Smoke suite:** ${SMOKE}", which is the final command
of the { ... } >> "$GITHUB_STEP_SUMMARY" group, and that group is the final
command of the step. GitHub runs shell: bash as
bash --noprofile --norc -eo pipefail, so with $SMOKE empty the
AND-list's status of 1 becomes the group's status and then the step's exit code.
$SMOKE is empty precisely when smoke.sh never printed its
== results: line — an earlier step died first, or smoke never ran. That is the
exact scenario always() exists for, so the reporting step failed in the one case
it was written to survive. Reproduced directly: exit 1 with no match, exit 0 with one.
It never masked a real failure (the job is already red when this happens), but it planted a
second misleading red step, and would have silently reddened the job if that step were ever
moved into a green context. Replaced with an if block.
Complexity S
Impact Low
Wow ★★★
an always() step that exits 1 on its own
ShippedBug
post-deploy.yml's always() destroyed the second signal it existed to preserve
The console-health step carries if: always() with the comment
"an HTTP failure above must not hide a console failure here — they are different breakages
and both belong in one report". But the two steps it depends on —
actions/setup-node and the npm install that fetches puppeteer — had
no if: at all. A failure in the HTTP smoke skips both, so the console
check then runs with no node_modules and produces an environment error rather
than a console verdict.
The run history separates the two cleanly: an environment error exits 2, a genuine
console failure exits 1. Run 30214061945 has
HTTP smoke = failure, setup-node = skipped, npm = skipped, console = failure (exit 2),
while the four runs where the npm install actually happened all exit 1. So in exactly the
case the always() was written for, the second signal was destroyed instead of
preserved — and the report said "console check failed" when it meant "the console check
never ran".
Fixed by putting always() on the setup steps too, so the dependency chain the
final step needs survives the failure it is meant to outlive.
Complexity S
Impact Med
Wow ★★★★
the always() destroyed the signal it was written to preserve
ShippedBug
markdownlint linted the fuzzer's corpus, so shipping a crash reproducer would redden a prose gate
.markdownlint-cli2.jsonc globs **/*.md and its ignores
list carved out .claude/** and docs/roadmap/items/** but not
tests/fuzz/corpus/**. So 12 of the 21 files being prose-linted were
deliberately-malformed YAML-frontmatter seeds for the atheris fuzzer —
07-unterminated.md, 09-unbalanced-quote.md, 12-bom.md
and friends. Malformed is their entire purpose, and they passed the gate only by luck.
That luck was going to run out by design. fuzz.yml exists to mine new
malformed inputs and ship them as reproducers
(-artifact_prefix=artifacts/, uploaded as fuzz-crash-<target>),
and one such crash artifact already exists from a real run. The moment anyone committed it into
tests/fuzz/corpus/frontmatter/ — exactly what the workflow is for — an unrelated
prose-style gate would have gone red for a reason with nothing to do with prose, on a file
whose whole point is to be invalid.
Fixed by adding tests/fuzz/corpus/** to ignores. Verified with the
pinned markdownlint-cli2@0.18.1: 21 → 9 files linted, 0 errors, and a planted
crash reproducer no longer reddens the gate.
Complexity S
Impact Med
Wow ★★★
12 of 21 linted files were malformed on purpose
ShippedRelease safety
Enabling a merge queue would deadlock every required check except ci.yml's
Only ci.yml declares merge_group among its triggers. The other
workflows producing required contexts — codeql.yml,
pip-audit.yml, package-metadata.yml, osv-scanner.yml —
run on pull_request only.
GitHub's merge queue evaluates the required checks against the temporary
merge_group ref, not against the pull request. So the moment a merge queue is
turned on, codeql-analyze, pip-audit, metadata and
scan-pr / osv-scan would never report on the queued ref and every enqueued PR
would sit forever — the same never-reports deadlock as
required-checks-can-declare-a-check-that-deadlocks-prs, arriving through a
different door.
Nothing is broken today; no merge queue is configured. Filed because the trap is invisible
until the switch is flipped, and because the pressure to flip it is real: with
strict: true and several concurrent loops, every merge forces every other open
PR to rebase and re-run its full matrix, which is exactly the problem merge queues solve.
Fix before enabling, not after: add merge_group: to each workflow that produces
a required context, and extend scripts/check_required_checks.py to fail when a
required name comes from a workflow lacking that trigger — the same gate that now catches
paths: filters.
Complexity S
Impact Med
Wow ★★★
a latent trap, only if someone enables the queue
ShippedCI speed
The mutation gate was CI — 26 minutes, three times the next-longest job
Measured across twelve consecutive runs, the mutation job took
20.6–39.2 minutes (median ~27) while the next-longest job in the same run,
tests (windows-latest, 3.12), took 8.6. One job was the critical path,
and it was charged twice per change: once blocking the pull request, then again on
main, because publish.yml fires on workflow_run after
CI completes — so the PyPI release waited on it too. PR #278 merged at 20:52 and the
release landed at ~21:20, essentially all of it this gate.
The cost is ~10,502 mutants over 45 files in boost_cli/core, run 4-way parallel
(max_children defaults to os.cpu_count(); ubuntu-latest
is 4 vCPU). Generating them takes 5.3 seconds — the entire cost is executing the unit
suite ten thousand times. A measured local baseline: 1600s wall, 6.70 mutations/second,
8850/10502 killed (84.3%).
Two independent wins, because they apply to different pull requests.
Skip what cannot have changed. Sampling the last 40 merged PRs, 38% touch neither
boost_cli/ nor tests/ nor the mutation configuration — docs,
roadmap cards, other workflows. Their score is identical to the base commit's by construction,
and they were each paying 26 minutes to prove it. The relevance rule is deliberately wider than
boost_cli/core/: setup.cfg's also_copy ships the whole
package into mutants/ and the tests import it, so a change in
commands/ can flip a core mutant. It also fails safe — no resolvable
base commit means run the full gate.
Shard the rest. mutmut run accepts fnmatch patterns over mutant names and
each source file owns a disjoint mutants/<path>.meta, so splitting by file
yields results that merge without any per-mutant reconciliation. Verified live:
mutmut run 'boost_cli.core.nethttp.*' ran 16/16 of that file's mutants and left
store.py at 0/1931. Six shards reach 5.44x — and no further, because
store.py alone is 18% of all mutants and cannot be split. That ceiling is printed
by plan --explain rather than left to be rediscovered.
The dead end, recorded so nobody repeats it. Caching mutants/ between runs
is the obvious first idea and it does not work in mutmut 3.6 — it actively destroys the
data it would need. copy_src_dir skips targets that already exist, so on a warm
tree create_mutants_for_file takes its source_mtime < mutant_mtime
branch and resets every exit code to None. Demonstrated rather than
inferred: a file with 235/235 recorded results came back with 185 wiped and the run
re-testing from zero. It would have looked like it was working while saving 5.3 seconds.
What adversarial review caught, which local testing did not. The first working
version sharded by glob("*.py") — non-recursive — while mutmut walks
source_paths with os.walk. A subpackage
(core/rag/bm25.py) would therefore be assigned to no shard; and because
export-cicd-stats skips a path with no .meta rather than
counting it unkilled, those mutants would drop out of total entirely and the
required check would report PASS over a subset. Fail-open — precisely inverting
the property the file's own docstring claimed. Two further finds: no fnmatch pattern can
address __init__.py, because mutmut rewrites .__init__. out of mutant
names (boost_cli.core.__init__.* matches nothing, and
boost_cli.core.* would swallow the whole package); and
git diff --name-only reports only a rename's destination, so moving
core/x.py to docs/x.py looked like a docs-only change and skipped the
gate. All three are now covered by tests that fail against the pre-fix code, and merge asserts
that every mutatable file was actually accounted for — so future layout drift reddens the
gate instead of quietly narrowing it.
The trap this had to avoid. mutation is a required status check
(.github/required-checks.txt). Path-filtering the job — the natural way to
implement "skip" — means GitHub creates no check run at all, and branch protection waits
for a status that never comes. This repository has already deadlocked that way twice. So the
required job always runs (if: always()) and inspects what the upstream jobs
actually did; always() on its own would have reported a failed shard as a green
required check. Merging likewise fails closed: mutmut counts an unrun mutant as "not checked"
inside total, and the gate divides by it, so a partial merge would quietly depress
the score instead of erroring.
What actually landed, measured on main. The eleven CI runs before this
merged took 20.7–39.4 minutes (median ~28.4); the merge commit's own run took
12.1. This pull request touches tests/, so it ran the full six-shard
gate — the worst case, not the docs-only path.
It also missed its own estimate, in an instructive way. The prediction was ~6 minutes for a code
change; the slowest shard took 9.9. Packing by mutant count is near-perfect — the six
shards carry 1703–1931 mutants each, within 1.10x of ideal — but measured
throughput ran 3.39 to 14.19 mutants/second, a 4.2x spread. Mutant count is simply a
poor proxy for time: a store.py mutant re-runs a far larger covering test set than an
ed25519.py one, and survivors run their tests to completion where kills exit early.
Re-weighting the planner by seconds instead of count would even out the billed minutes but
not the wall clock, because shard 0 is store.py by itself and its 9.5 minutes
is the floor. Going below it means splitting a single file — feasible, since mutant names are
addressable per function (boost_cli.core.store.install_*), which would bring the floor
toward the 5.1-minute even split of the 30.8 minutes of total work.
Complexity M
Impact High
Wow ★★★★
28 min of CI became 12; the new floor is one file, store.py
PlannedCI speed
The mutation gate's floor is a single file — shard 0 is store.py
Sharding took the mutation gate from ~28 minutes to ~12 (see
mutation-gate-was-the-whole-critical-path), but it cannot go lower without
splitting one file. Measured across four consecutive six-shard runs:
638bffeb ran 1.9, 3.9, 5.7, 6.7, 7.2, 11.7 minutes;
02c18bd6 ran 2.7, 5.0, 5.2, 5.5, 5.9, 11.7; fc427839 ran 3.1,
4.8, 5.3, 6.4, 7.3, 9.7; and f42e617b ran 2.9, 4.9, 5.5, 6.6, 7.3,
9.3.
mutation-shard (0) was the slowest leg in all four. Total work is
36–37 job-minutes, so a perfectly even split would be 6.1; the actual critical
path is 9.3–11.7, or 1.5–1.9× that. The gap is not bad packing —
the planner balances mutant count to within 1.10× of ideal. It is that count is
a poor proxy for time: measured throughput spans 3.39 to 14.19 mutants/second, because a
store.py mutant re-runs a far larger covering test set than an
ed25519.py one, and survivors run their tests to completion where kills exit early.
Re-weighting the planner by seconds would even out the billed minutes but not the wall
clock, because store.py is 1931 mutants (18.4% of the repo) in one file and
longest-processing-time packing cannot place a single file across two bins. The floor is that
file.
The way under it: mutant names are addressable per function, so
boost_cli.core.store.install_* can be its own shard. That would bring the critical
path toward the 6.1-minute even split. Two things must survive the change —
pattern_for() still has to emit patterns that match nothing else, and
cmd_merge's completeness assertion has to keep failing closed, since mutmut counts
an unrun mutant inside total and a partial merge would silently depress the score
rather than error.
Complexity M
Impact Med
Wow ★★★
shard 0 is store.py alone — 9.3-11.7 min against a 6.1 even split
PlannedCI speed
A third of CI job time is spent waiting for a runner, not running
With the mutation gate no longer dominating, the largest remaining term in CI wall clock is
queueing. Measured over six consecutive successful ci runs: 104
job-minutes queued against 236 job-minutes executing — 31% of all job
time spent waiting for a runner.
The obvious metric lies. A run's run_started_at minus
created_at is 0.0 for every run, which reads as "no queueing anywhere".
Queueing happens per job, not per run, so it is only visible as
job.started_at - job.created_at. Anything measuring this at run level will
conclude, wrongly, that there is nothing to fix.
The per-run spread shows what drives it — median job queue by run:
c36c53a7 0.1 min, c15ed9da 0.1, 903b704f 0.2,
638bffeb 0.7, ad71ba99 2.5, 9e190537 2.9.
That is a 29× swing driven not by the change under test but by how many sibling
loop/* branches and Dependabot PRs happen to be live at the same moment. It is why
one merge took 41 minutes wall clock with a max shard of only 11.4.
Sharding the mutation gate made this worse on purpose: it traded one runner slot for six. That
is the right trade for latency in isolation, and the wrong one to keep making blindly while a
dozen branches are in flight, because every concurrent PR now multiplies its footprint. Options,
roughly in increasing order of intrusiveness: a concurrency group that cancels
superseded PR runs; capping the shard matrix with max-parallel; or making the shard
count adaptive. Worth measuring before choosing — this item is the measurement, not the
fix.
Complexity M
Impact High
Wow ★★★★
104 job-min queued vs 236 executing — 31% of CI is waiting for a runner
ShippedSecurity · CI/CD
The required lint job pins zizmor==1.27.0 — a yanked release
ci.yml runs the workflow SAST step as
pipx run zizmor==1.27.0 .github/workflows. PyPI marks 1.27.0 as yanked, with
yanked_reason: GHSA-f42p-wjw5-97qh. It is the only yanked release out of 66,
and 1.28.0 is the successor.
Exact-pinning the security scanner is correct — an unpinned linter is how
ruff-defaults-broke-boost-lint happened. Pinning to a version its maintainer has
withdrawn for a security advisory is the failure mode on the other side, and it is
quieter: a yank does not stop an exact pin from resolving. pip installs
1.27.0 and emits a warning nobody reads in CI logs, so the required lint
gate goes on passing while running a withdrawn build of the tool that audits the workflows
driving a Trusted-Publisher release.
Two things worth doing together. Bump to 1.28.0 and confirm the four documented
dangerous-triggers exemptions in .github/zizmor.yml still suppress
cleanly — the line-anchored ignore entries (publish.yml:19,
sbom.yml:29, …) are exactly the kind of thing a version bump can invalidate.
And sweep the other exact pins for yanks: nothing currently checks for this. pip-audit
catches CVEs in the resolved dependency closure, but a yanked pinned tool is neither a
CVE in the closure nor a manifest diff, so osv-scanner does not see it either. This
was found by accident, while installing zizmor locally to verify an unrelated change.
Already shipped when this card was written — the card was filed stale, and that is the
more useful finding. Commit a4450f76, "ci(security): zizmor 1.27.0 is yanked
— take 1.28.0", landed at 08:07Z; this card was filed at 20:18Z, twelve hours
later, against a base that already carried 1.28.0. The yank was observed correctly
against a tree extracted earlier in the day and then never re-checked against the branch point.
ci.yml now runs pipx run zizmor==1.28.0.
Two things worth keeping from it. The predicted risk did not materialise: zizmor
1.28.0 runs clean over all 25 workflows (exit 0, "no findings to report"), and the
line-anchored ignores in .github/zizmor.yml — including
sbom.yml:29 — all still resolve, so the bump needed no config change. And the
gap the card names remains real and unclosed: nothing checks whether an exact pin has been
yanked. pip-audit scans the resolved dependency closure and
osv-scanner diffs the manifest; a yanked pinned tool is neither, so this was
caught by a human noticing a pip warning, twice, by luck.
Complexity S
Impact Med
Wow ★★★★
already fixed by a4450f76 twelve hours before this card was filed
ShippedRelease safety
The release verifies one commit and ships another
publish.yml fires on workflow_run when ci completes, gates
on that run's conclusion == 'success' (plus event == 'push' and
head_repository == this repo), and then checks out ref: main. Those are
not the same commit. Observed 2026-07-28: ci for ecfe38bc
completed at 20:19:05Z; the release run was created at 20:19:07Z; it tagged and shipped
c1727bac as v1.0.271 — while c1727bac's own
ci, started 20:18:40Z, was still in_progress. One commit's green
verdict authorised a different commit's release.
Most of this is already mitigated, and the card should not be read as "untested code ships".
A strict "require branches to be up to date" policy means the shipped commit was green as a pull
request before it could merge, so its tree was gated. And
release_preflight.py is already pointed at the right commit —
--sha "$(git rev-parse HEAD)", with an explicit comment that this is "not the trigger
sha". It polls each required workflow for that exact sha and fails closed on red, cancelled,
skipped, never-ran or still-running-at-deadline. The machinery is correct.
Its coverage is the gap. .github/required-checks.txt lists 18 required
contexts. publish.yml passes preflight exactly two of them —
--require pip-audit.yml and --require package-metadata.yml. The other
16 are never re-checked against the commit being shipped, including all 14 that come from
ci.yml and codeql-analyze. The clearest evidence this is an oversight
rather than a decision is in-tree: release_preflight.py's own docstring says it waits
for every required workflow for the exact commit being released, and the caller hands it
two.
A required context that can never report on main at all — and must not
be made to. osv-scanner.yml declares only pull_request and
merge_group, so scan-pr / osv-scan produces nothing for any commit on
main. This card originally proposed giving it a push trigger. That was
wrong. It is PR-scoped deliberately: it calls the
osv-scanner-reusable-pr.yml diff workflow so it reports only what a pull
request adds, and its own header records why a full-repo scan is unwanted — it
would trip the accepted, documented old-langchain pin in the [eval] extra that ragas
requires. The correct conclusion is the opposite one: scan-pr / osv-scan is
inherently a pull-request gate and belongs nowhere near release preflight.
The fix is not to change the checkout ref. Pinning to
github.event.workflow_run.head_sha would build the older commit while release-drafter
still tags the default branch's head — artifact and tag would disagree. The lower-risk
change is to widen preflight's --require set to the contexts that actually run on
main, and optionally fail the job when git rev-parse HEAD differs from
the triggering run's head_sha. This is the third defect found in the same few lines
— see publish-trigger-was-reachable-from-a-fork (who can fire it) and
publish-gate-ignores-pip-audit-and-metadata (which gates are consulted); this one is
which commit they were consulted about.
Shipped. publish.yml now passes preflight every required context's workflow
that actually runs on a push to main — ci.yml,
codeql.yml, pip-audit.yml, package-metadata.yml —
taking coverage of the shipped sha from 2 of 18 required contexts to 17 of 18. The
remaining one is scan-pr / osv-scan, excluded on purpose for the reason above. The
checkout ref is untouched: pinning it to the trigger's head_sha would build the older
commit while release-drafter still tags the branch head, leaving artifact and tag disagreeing.
--timeout-seconds is raised to 3600 because ci must now finish for the shipped sha and
ci's wall clock is dominated by runner queueing; failing closed on a slow-but-green ci would be
worse than waiting, and a timeout is still fail-closed either way.
Verified by running the real script against main before merging: the four shipped
gates return exit 0 ("all 4 release gates green"), and adding
--require osv-scanner.yml returns exit 1 — "no run recorded for this
commit ... a release gate that never reported cannot be assumed green" — which is the
concrete proof that the original recommendation would have blocked every release.
Complexity S
Impact High
Wow ★★★★
preflight now waits on 17 of 18 for the shipped sha, up from 2
PlannedCI reporting
demo.yml still fails on every push — and the fix is not in the workflow
demo-gif-workflow-has-never-succeeded (shipped, PR 294) fixed the recorder:
charmbracelet/vhs-action's broken ffmpeg installer was replaced with apt plus
ttyd/vhs pinned by release-asset id. That worked. The recording step now succeeds on both event
paths. The workflow still fails on every push to main — observed
2026-07-28 at 20:56, 20:04 and 09:27, and it is the only failing workflow in the last 60 runs.
The failure moved one step later, to open a PR if the recording changed. The run
annotation gives it verbatim:
GitHub Actions is not permitted to create or approve pull requests.
Nothing in demo.yml is wrong, so nothing in demo.yml will fix it.
The job already declares contents: write and pull-requests: write,
which is exactly what peter-evans/create-pull-request documents as required. The
workflow uses no secrets. persist-credentials: false on the checkout is correct and
is the repo-wide convention across all 31 checkout steps — the action supplies its own git
auth and explicitly unsets any persisted credential. Every one of those is a plausible-looking
false lead. The actual blocker is a repository setting: Actions → General
→ Workflow permissions → "Allow GitHub Actions to create and
approve pull requests", which the API reports as
can_approve_pull_request_reviews: false. This is the same shape as
sbom.yml: a workflow that is correct in the file and inert in reality.
Deciding it is a real decision, not a formality. Turning that toggle on grants every
workflow in the repo the ability to open pull requests, which is a genuine widening of what a
compromised action can do. The alternative is to accept that this step cannot work and change
what the push path does — upload the GIF as an artifact on both paths, as the pull-request
path already does, and drop the PR-opening step.
A second, independent defect in the same file. Its concurrency group is
group: demo, not ref-scoped. Every other per-ref workflow scopes it —
sonarcloud-${{ github.ref }}, fuzz-${{ github.ref }},
eval-stats-${{ github.ref }}, eval-explain-${{ github.ref }}; only the
deliberately-singleton release and post-deploy are global. With
cancel-in-progress: true, a pull-request run and a main run therefore
cancel each other, which is where demo's cancelled runs come from. Fix is
group: demo-${{ github.ref }} regardless of what is decided about the toggle.
Worth reconciling while here: the tree disagrees with itself on the history.
demo-gif-workflow-has-never-succeeded says "3 runs, 3 failures", while
demo.yml and ci-failure-issue.yml both say six of six.
Complexity S
Impact Med
Wow ★★★★
a repo setting blocks it — no edit to demo.yml can fix this one
PlannedHygiene · DX
68 of the repo's 77 branches are merged loop/* branches nobody deletes
The repository carries 77 branches, 70 of them loop/*. Classifying each by the
state of its pull request: 68 have a merged PR and are safe to delete, 1 has an open
PR (loop/dependabot-findings), and 1 has no PR on record. The default branch list is
almost entirely finished work.
The obvious measurement is wrong here, which is worth recording. Testing "is it merged?"
with compare(main...branch).ahead_by == 0 reports 0 of 70 as merged —
because this repo squash-merges, and a squash leaves the branch's original commits outside
main's history forever. Every merged branch looks permanently ahead. The state has to
come from the pull request, not from commit reachability.
The cause is a single repository setting: delete_branch_on_merge is false, so
nothing removes a head branch when its PR merges. No workflow prunes them either — none of
the 25 workflow files triggers on create/delete or touches
loop/*, and no scripts/ entry or Makefile target does branch cleanup.
The one branch-deletion automation in the tree is demo.yml's
delete-branch: true, which is create-pull-request tidying its own
bot/demo-gif branch and has nothing to do with loop/*. No documentation
asks anyone to clean up either: CONTRIBUTING.md, CLAUDE.md and
.claude/commands/roadmap-loop.md all describe branching and squash-merging, and none
of the three mentions deleting the branch afterwards.
Not a claim that branches are never deleted. The item files name 144 distinct
owner: loop/<topic> values against 70 surviving branches, so roughly 98 have
gone at some point — by hand, or by topic reuse. The accurate statement is narrower:
nothing in the repo causes cleanup, so merged branches accumulate until someone does it
manually.
Low impact and near-zero risk to fix — flip delete_branch_on_merge to true and
the backlog can be pruned by listing merged PR head refs. The reason it is worth doing at all is
that CLAUDE.md tells every agent to run git worktree list and inspect branch state
before touching a tree, and 68 stale entries make that check noisier for every concurrent loop.
Complexity S
Impact Low
Wow ★★
68 of 70 loop/* branches have a merged PR; one setting fixes it
ShippedRelease safety
One commit can cut two releases, and the naive guard against it breaks retries
publish.yml checks out ref: main rather than the commit whose
ci fired it. release-verifies-the-wrong-commit fixed the
verification half of that — preflight now waits on the shipped sha's own gates
— but the same decoupling has a second consequence it does not address: two triggers can
both resolve to the same tip and release it twice.
Observed on 5061e756: two release runs created 27 seconds apart
(22:24:32Z and 22:24:59Z), both succeeding, cutting v1.0.277 and v1.0.278 from one commit.
It recurs — v1.0.246/v1.0.247 both point at c1d67c1c,
and v1.0.248/v1.0.249 both at c750651. Each duplicate burns
a PyPI version on a byte-identical build and, until the companion fix in this change, left one of
the two GitHub Releases with no SBOM.
Preflight does not stop it and was never going to: both runs check out the same green tip, so both
legitimately pass every gate. The duplication is upstream of verification.
The obvious fix is a trap, which is why this is filed rather than patched. Guarding with
"skip if git tag --points-at HEAD already has a v* tag" would stop the
duplicate — and would also stop any legitimate retry. release-drafter creates the tag
before the build, attestation, wheel smoke-test and PyPI upload run, so a release that
failed at the upload step leaves a tag behind with nothing on PyPI. Under that guard, re-running it
would skip instead of finishing the job, converting a recoverable failure into a version that can
never be published. The concurrency: group: release block does not help either: it
serialises the two runs rather than collapsing them, which is exactly what happened here.
A correct fix has to distinguish "already released" from "tagged but not published" — check
whether the version exists on PyPI, or whether the GitHub Release has its expected assets, rather
than whether a tag exists. Alternatives worth weighing: collapse the trigger so only the newest
queued run proceeds (cancel-in-progress on a group keyed by nothing, losing the
serialisation the release job wants), or skip when git rev-parse HEAD already differs
from workflow_run.head_sha — that is self-healing, since the newer commit's own
ci will trigger its own release, but it means some merges never get their own version.
Already mitigated: sbom.yml now builds and attaches an SBOM for every
tag on the commit rather than only the highest, so a duplicate release no longer produces a release
with no SBOM. Each tag is built separately so its SBOM carries its own version.
Correction: the claim above originally read "because setuptools-scm reads the version from
the tag", and that is false — it reads the version from the commit, so separate
checkouts of two tags on one commit produce the same version. That was a second, quieter
consequence of the duplicate release, fixed separately in
sbom-declares-the-wrong-version.
Shipped — the PyPI option above, because it is the only one that answers what was
actually published rather than what was merely attempted. A new guard job runs
scripts/release_guard.py before release and resolves three cases: tagged
and the version is on PyPI → skip; tagged and it is not on PyPI → release, because
that is the failed-upload retry this card warned a tag-only guard would break; untagged →
release. It fails closed — tagged but PyPI unreadable also skips, since a missed
release is one workflow_dispatch away while a duplicate burns a version on identical
code. A separate job rather than an if: on all eight release steps, so one forgotten
condition cannot half-execute a release. ref: main stays: shipping the tip is
deliberate, and idempotence was the piece missing from it.
Complexity M
Impact Med
Wow ★★★
guarded on PyPI, not on the tag — so retries still work