Bulwark is a Go CLI that unifies code-quality and security scanning — SAST, SCA, linting, and
coverage gates — for Rust, TypeScript, and Go projects. It is the single entry point a developer
runs locally and CI runs identically, so "green locally" and "green in CI" can never drift apart.
It replaces per-repo ad hoc security workflows (CodeQL, standalone cargo-audit jobs, Codecov as a
blocking gate) across wardnet, wardnet-cloud, and inforge with one consistent pipeline.
go build ./... # build the binary
go test -race ./... # run tests
golangci-lint run ./... # lint — must be clean before a PR
go run ./cmd/bulwark # run the CLI locally
# Release build dry-run (produces dist/):
go run github.com/goreleaser/goreleaser/v2@latest release --snapshot --cleancmd/bulwark/ # the bulwark CLI (scan, coverage, version, update)
internal/detect/ # ecosystem + TS-package detection (walks for Cargo.toml/package.json/go.mod)
internal/config/ # .bulwark.yml loading (opt-outs + pipeline shape — see Configuration below)
internal/toolchain/ # ensures the Go/Rust/Node runtime each detected ecosystem needs (see Toolchains below)
internal/rust/ # clippy, cargo-audit, cargo-deny
internal/typescript/ # pinned Biome, the only TS linter (see Linters)
internal/golang/ # gosec, govulncheck (installed into a version-keyed GOBIN dir)
internal/semgrep/ # pinned Semgrep, installed via pipx
internal/coverage/ # per-language coverage percentage (see Coverage below)
internal/gitstate/ # bulwark-state branch read/write (see Coverage below)
internal/executil/ # shared external-command runner every scanner package uses
assets/bulwark-logo.png # logo — used by README and the action's PR comment (see below)
docs/release-notes/ # _header.md + one <tag>.md per release that needs one (see Release notes)
.goreleaser.yml # build/release config (v2 schema)
.golangci.yml # lint config (v2 schema)
.github/workflows/{ci,release}.yml
.github/dependabot.yml
action.yml # composite action: install + scan + coverage + PR comment + report
scripts/install.sh # curl|sh installer shipped with every release
- Module path:
wardnet/bulwark(notgithub.qkg1.top/wardnet/bulwark— a deliberate deviation from the other repos in this org, to be applied there too later; do not "fix" this back). bulwarkships as a single statically-linked binary (CGO_ENABLED=0), built for linux/darwin × amd64/arm64.
All four subcommands (scan, coverage, version, update) are fully implemented — every check
is a real tool invocation (not a stub). Every scanner pins its own tool version and installs it into
a bulwark-managed cache directory rather than trusting whatever's already on the machine (see each
internal/<lang> package's doc comment for why). update follows the same pattern as inforge's
self-update (checksum-verified binary replacement, refuses on dev builds, passive update nudge on
every other command). bulwark coverage has been verified end-to-end against this repo's own real
bulwark-state branch on GitHub, not just a local fixture.
.github/workflows/ci.yml runs three jobs on every push/PR to main: lint (golangci-lint),
build & test (go build/go test -race), and self-scan — bulwark builds itself and runs
bulwark scan --dir . against its own repo. self-scan is dogfooding, not a formality: it's the
only job that exercises the actual scan/report path end-to-end against a real repo, and it already
caught a real bug once (see the git history around the go-version: "1.26.5" pin below).
Pin the exact Go patch version in workflows (currently "1.26.6"), never a bare minor ("1.26").
actions/setup-go's go-version: "1.26" resolves to whatever 1.26.x patch it has
cached/available, which is not necessarily the version this repo's go.mod toolchain directive
pins — and critically, go install-ing an external tool (gosec, govulncheck) does not consult
the current module's go.mod toolchain directive the way building the module itself does. This bit
us for real: self-scan's govulncheck step passed locally (toolchain directive respected) but
failed in CI (setup-go had installed an older, vulnerable patch) until go-version was pinned to the
exact 1.26.5. If go.mod's toolchain line is ever bumped, update every go-version: in
ci.yml/release.yml to match in the same change.
internal/toolchain (see Toolchains below) now also sets GOTOOLCHAIN from the scanned repo's own
go.mod before running any Go tooling, which fixes this class of drift for consumers — they no
longer need to pin go-version on bulwark's account. Keep the pins here regardless: this repo's
lint and build & test jobs invoke go directly rather than through bulwark, so nothing in that
mechanism covers them, and self-scan benefits from the pin holding independently of the feature
it is dogfooding.
.bulwark.yml at the scan root is optional and carries all the config. It does two things, and
tuning severity or suppressing individual findings is neither of them (that's what a fix-up pass +
inline #nosec/nosemgrep annotations in the scanned repo are for):
- Opt out of what bulwark's zero-config default already does (scan everything detected, every
check enabled), plus the numeric gate knobs (
coverage.tolerance,coverage.patch.tolerance). - Describe the repo's pipeline —
coverage.sourceand thecoverage.{go,rust}report paths. These narrow nothing; they state a fact about how the repo is built. That fact is the same for every invocation in that repo, which is exactly why it belongs in a file at the scan root rather than in a flag or action input each caller has to remember to repeat identically.
See internal/config/config.go for the full schema; shape:
rust:
enabled: true # set false to skip Rust entirely even if a Cargo.toml is detected
exclude: [] # extra directory names to skip during ecosystem/package detection
typescript:
enabled: true
exclude: ["legacy-app"]
linter: biome # the only accepted value. The retired "eslint" is rejected with an
# error rather than silently run under Biome (see Linters below).
install: "" # override coverage's install-command auto-detection, e.g.
# "corepack enable && yarn install --immutable" (see Coverage below)
go:
enabled: true
exclude: []
semgrep:
enabled: true
config: auto # override to a custom registry ref/path if needed
toolchain:
enabled: true # set false to keep the diagnostics but never download/install
# (air-gapped runners, or images that preprovision everything)
# go/rust/node: deliberately unset. The versions come from the repo's own
# manifests — see Toolchains below. These keys exist only as a local override.
coverage:
source: run # who produces the coverage data. "run" (default) has bulwark execute each
# ecosystem's test suite itself; "report" has it never execute anything and
# only parse what a prior CI job already wrote. Not "run tests or not" —
# that describes bulwark's behavior; this names which side of the pipeline
# owns coverage production. Anything else is rejected, never silently
# treated as "run" (see the section below).
go:
report: coverage.out # only read under source: report, and usually unnecessary — each discovered
# module's own coverage.out/cover.out/c.out is found without it. A bare
# path applies when exactly one module is discovered; a repo with several
# uses a mapping instead:
# report:
# wctl: coverage/wctl.out
# sdk/wardnet-go: coverage/sdk.out
rust:
report: coverage/llvm-cov.json # the cargo-llvm-cov JSON export — the aggregate percentage
lcov: coverage/lcov.info # the lcov export — patch coverage's per-line data. Two paths
# because the JSON export has no per-line data at all. Same
# bare-or-mapping shape as go.report, keyed by crate dir.
# There is deliberately no `typescript:` here: Istanbul/Vitest
# write coverage/{coverage-summary.json,lcov.info} by fixed
# convention, so there has never been anything to override.
tolerance: 0.1 # pp a language's aggregate coverage may dip below its baseline before
# the gate fails; absorbs sub-tenth measurement noise ("86.1% vs
# baseline 86.1%, regressed 0.0%"). Compared at display precision
# (tenths); 0 = fail any dip the report can show. Must be finite and
# non-negative — Load rejects anything else.
floor: 0 # minimum coverage any single measured unit (Go module, Rust crate/
# workspace root, TS package) must reach. 0 = off, which is the default:
# it is opt-in, so upgrading never starts failing a repo over a gap it
# has always had. This is the signal line-weighting removes — see
# Aggregation below.
patch:
tolerance: 0.1 # the patch gate's own dip allowance — deliberately independent, so
# loosening the aggregate knob never weakens the untested-new-code checkOmitting the file, or omitting a section/key within it, keeps that value at its default — see
internal/config/config_test.go for the exact merge semantics.
Biome backs the TypeScript check. typescript.linter accepts biome and nothing else; the
retired eslint value is rejected by config.validateLinter with an error naming the
removal, rather than accepted and quietly run under Biome. A repo that set that key stated
which rule set it gates on, and switching it silently would change what the scan measures
while every run still reported [PASS] — the same reason an unknown value is rejected rather
than defaulted. LinterESLint stays defined for no purpose but to be recognised and refused.
Biome is not a replacement for eslint-plugin-security — Semgrep is, and it already ran.
Biome's security group is six JSX/eval/secret rules (noBlankTarget,
noDangerouslySetInnerHtml, noDangerouslySetInnerHtmlWithChildren, noGlobalEval,
noScriptUrl, noSecrets), of which only noGlobalEval coincided with anything the plugin
had; bulwark additionally gates on the correctness group, so the TypeScript check is not
"security findings only" the way the others are. Measured against a fixture carrying all four
classes, Semgrep at config: auto covers detect-child-process (with taint — it names the
tainted argument) and detect-non-literal-fs-filename (as a path-traversal finding), partly
covers detect-unsafe-regex (dynamic patterns via detect-non-literal-regexp, but no static
ReDoS analysis of a literal), and does not cover detect-object-injection at all. Several of
those Semgrep rules are ports of the ESLint ones and are stronger than the originals. The real
gap is detect-object-injection — the plugin's most commonly disabled rule — plus
literal-pattern ReDoS. Do not rebuild either as a Biome GritQL plugin: GritQL matches syntax
against the CST with no dataflow or taint, so it could only produce a weaker copy of what
Semgrep already does. See ADR 0008,
and ADR 0005 for the opt-in it supersedes.
Why it went. ESLint's TypeScript support is a compiler dependency, not a linter one:
@typescript-eslint/parser declares typescript as a peer with an
upper bound (>=4.8.4 <6.1.0), so bulwark's own pin manifest had to carry a typescript
inside that window, and the window moves only when upstream ships support for a new compiler.
internal/typescript's package doc had recorded exactly this hazard — and a Dependabot PR
bumped typescript across the ceiling anyway and merged, after which npm ci on the
committed lockfile failed with ERESOLVE and every consumer with TypeScript got an install
error instead of a lint result. CI stayed green throughout, because bulwark's own repo has no
TypeScript to scan: its only package.json files are the pin manifests, which .bulwark.yml
excludes by name, so self-scan cannot reach that code path. Biome parses TypeScript with its
own Rust parser and depends on no compiler package, so biome-pin has no peer range to cross
and the failure class does not exist for it.
Four things about the Biome integration were established against Biome directly rather than
from its docs. The pin is now 2.5.10, and biome.json's $schema URL must be bumped with
it — TestBiomePinMatchesConfigSchema fails the build otherwise, the same mirror guard
internal/golang/pins_test.go provides for the Go constants. Re-verified on 2.5.10 where
noted:
files.includesnegations work from an out-of-tree config (re-verified on 2.5.10: anevalinsidedist/is not reported). Biome resolves config globs "relative to the folder the configuration file is in", and bulwark stagesbiome.jsonin its cache directory — so this looked like it would silently ignore nothing. It doesn't:**-prefixed negations are depth-agnostic and match correctly. But they are doing real work — with them removed, Biome lintsdist/and reports findings inside a minified production bundle.TestBiomeConfigIgnoresMatchesDefaultSkipDirsguards it.--config-pathbeats the scanned project's own rootbiome.json(re-verified on 2.5.10). A project config settinglinter.enabled: falseis ignored, so bulwark's verdict doesn't vary with what the target repo declares — the same stanceinternal/typescript's doc comment takes generally.- A
biome.jsonin a subdirectory does NOT abort bulwark's run, and the earlier claim that it does was a mis-scoped reproduction. Biome errors with "Found a nested root configuration, but there's already a root configuration" only when it resolves configuration from the tree itself. bulwark always passes--config-path, and under that flag a nestedbiome.json— with"root": true, or with norootkey at all — is simply ignored: Biome exits 0, lints normally, and the report is clean. Confirmed on 2.5.8 and 2.5.10 with bulwark's exact invocation, so this was never version drift. The consequence is thatlintDirBiome'sbiomeNestedRootConfigbranch is unreachable as long as--config-pathis passed. It stays, because it stops being unreachable the moment anyone drops that flag — and dropping it is a plausible change, since it is what would let a project's own Biome config be honoured. What must not survive is the claim that it fires today. - A nested config declaring
"root": falseis merged into bulwark's config. Its rules then fire in our run.reportableBiome's category allowlist contains that — mergedlint/style/*is dropped. The same merge is a real limitation in the other direction that cannot be closed from here: a nested config could set"security": "off"and silently narrow what bulwark checks.
A check whose findings never stream must set executil.Result.Detail. executil.Run
streams every tool's stdout/stderr live, so for gosec, clippy, cargo-audit and Semgrep the
findings are on the terminal and in the log action.yml captures before anyone reads the
Result. Biome is the exception: its report goes to a file via --reporter-file so its own
chatter cannot corrupt the JSON, so nothing streams and Output holds no findings.
cmd/bulwark/scan.go's report prints Detail under a failing check — without it a
developer sees [FAIL] biome(.) and has to re-run the pinned toolchain by hand to learn why,
and the PR comment carries nothing at all. Detail lines are indented, and that is
load-bearing rather than cosmetic: action.yml's tool_result() matches
^\[(PASS|FAIL)\] <name>$ anchored at both ends, so an indented line cannot be mistaken for
a status line even when a finding's own message contains one.
recommended: false in the bundled biome.json is not tidiness — without it Biome's default
preset enables style and suspicious too, and bulwark would start failing PRs over opinions it
never agreed to enforce. The formatter and assist are explicitly disabled for the same reason:
bulwark is not a formatter and must never report a formatting diff as a finding.
bulwark pins every tool it runs, which is the whole premise — and for a long time every one of
those pins was a Go string constant, invisible to Dependabot. A pinned security toolchain that
nothing ever ages out is a scanner that quietly goes stale while still reporting [PASS], which
is the worst available failure. Every pin now lives in a real package-manager manifest that
Dependabot watches, colocated with the package that uses it:
| Tool(s) | Manifest | Runtime source |
|---|---|---|
| @biomejs/biome | internal/typescript/biome-pin/package.json + lock |
the manifest itself (npm ci) |
| cargo-audit | internal/rust/cargo-audit-pin/Cargo.toml |
parsed by internal/rust/pins.go |
| cargo-deny | internal/rust/cargo-deny-pin/Cargo.toml |
parsed by internal/rust/pins.go |
| semgrep | internal/semgrep/requirements.txt |
parsed by internal/semgrep/pins.go |
| gosec, govulncheck | internal/golang/go-pin/go.mod |
still Go constants — see below |
The npm toolchains install with npm ci from a committed lockfile into a cache directory keyed
by a hash of that lockfile. The old key was a hand-maintained string of concatenated version
numbers, so adding a dependency meant remembering to extend the key, and forgetting meant reusing
a cache directory that predated it — precisely how "every .ts file is silently skipped" would have
come back when the TypeScript parser was added. A lockfile hash cannot be forgotten.
Go is the one exception, deliberately. gosecPkg/govulncheckPkg are const expressions that
concatenate the version at compile time, and go:embed cannot read files inside a nested module,
so the versions can't be read from go-pin/go.mod at runtime. The constants stay and
internal/golang/pins_test.go fails CI if a Dependabot bump to go-pin/go.mod isn't mirrored into
them. go-pin is a separate module rather than tool directives in bulwark's own go.mod:
declaring them in the main module works, but drags gosec's entire dependency graph (grpc, protobuf,
google.golang.org/api, …) into code bulwark never links — measured at go.mod 17→69 lines and
go.sum 17→114 — which would then generate a stream of irrelevant Dependabot PRs.
Each cargo tool gets its own manifest, and that is not tidiness. cargo-audit and cargo-deny
cannot resolve in a shared dependency graph — cargo-deny's krates pins petgraph =0.8.1 while
cargo-audit's cargo-lock pulls 0.8.2 — and a [package] manifest with no src/lib.rs doesn't
parse at all. Either way cargo errors, Dependabot's updater fails in a job log nobody reads, and the
pin silently stops being bumped: the exact failure this whole arrangement exists to prevent. cargo install never shares a graph between two tools, so the conflict is invisible in normal use.
internal/rust's TestPinManifestsAreSeparate guards against a well-meaning consolidation.
Adding a new pin means three edits, not one: the manifest, a .github/dependabot.yml entry,
and an exclude in bulwark's own .bulwark.yml. The manifests are real package.json/Cargo.toml/
go.mod files, so CI's self-scan would otherwise treat each as a package to lint, a crate to audit
or a module to scan. internal/config's TestPinDirectoriesAreExcluded covers only the exclude
half — it never reads dependabot.yml, and it discovers pins by a *-pin directory-name suffix, so
internal/semgrep/requirements.txt falls outside it entirely. Nothing enforces the Dependabot
entry. See ADR 0006.
bulwark provisions every tool it runs — gosec/govulncheck via go install into a version-keyed
cache, cargo-audit/cargo-deny via cargo install, Biome via npm, Semgrep via pipx — under the
"pin the exact toolchain, don't reuse ambient installs" principle in internal/golang. The one
thing it long did not provision was the language toolchain it does that provisioning with:
go, cargo and node were simply assumed to be on PATH. internal/toolchain closes that.
Nothing was visibly broken before, and that matters for judging changes here: on a GitHub-hosted
runner Go is preinstalled and Go 1.21+ fetches whatever go.mod asks for, so the assumption held.
It was a robustness and determinism gap, not an outage — a self-hosted or container runner without
Go fails at go install, the version used is whatever the image ships rather than what the repo
declares, and with no shared module cache every run re-downloads.
Versions come from the repo's own manifests, never from .bulwark.yml.
| Ecosystem | Version source |
|---|---|
| Go | the go and toolchain directives in every discovered go.mod, highest wins |
| Rust | channel in rust-toolchain.toml, else the legacy bare rust-toolchain, per discovered crate |
| TypeScript | engines.node in each detected package's package.json, else .nvmrc (package, then scan root) |
Those files are already authoritative and already enforced by the language's own tooling, so a
second copy in bulwark's config could only agree redundantly or drift silently — and a stale
duplicate is worse than none, because it reads as authoritative. toolchain.{go,rust,node} in
.bulwark.yml exist purely as a deliberate local override, which is a different thing from a
parallel source of truth. toolchain.enabled: false keeps the diagnostics and skips the
downloads.
An ambient toolchain that already satisfies the declared version is used as-is. Downloading
something already present and correct is pure cost, and on the runners bulwark actually runs on
that is the common case — so the common path does no network I/O at all. internal/toolchain's
satisfied is the single predicate for that decision; an unpinned requirement (a stable rust
channel, an lts/* .nvmrc, no declaration at all) is satisfied by anything present, since the repo
named no floor to be below. A toolchain that won't identify itself is treated as too old, because
it cannot be shown to satisfy a pin.
Each ecosystem provisions differently, and only one of the three downloads anything:
- Go delegates to
GOTOOLCHAIN. Any Go 1.21+ can fetch another toolchain itself, through the module proxy and verified against Go's checksum database — better provenance than bulwark could hand-roll — so bulwark just names the version. Only with no Go at all, or one older than 1.21, does it download a tarball fromgo.dev, taking the SHA-256 from the release index. Go is pinned even when the ambient toolchain already satisfies the declaration — the one ecosystem where "satisfied" is not the same as "nothing to do".GOTOOLCHAIN's default (auto) does not only upgrade:go install <tool>@<version>run outside a module, whichinternal/golangdoes to fetch gosec and govulncheck, makes the go command consult that tool's owngo.modand switch to whatever minimum it declares.golang.org/x/vuln@v1.6.0declaresgo >= 1.25.0, so anautorunner with Go 1.26 installed builds govulncheck with go1.25 — and a govulncheck built by an older Go rejects newer source outright. That is the exact failure recorded under CI above, and it reproduces on a runner whose ambient toolchain is perfectly correct.pinAmbientGotherefore setsGOTOOLCHAIN=localin that case.local, not the declared version: the declaration is a minimum, so ago 1.26directive resolves togo1.26.0and pinning it would downgrade a runner already on 1.26.6 — backwards, when the newer patch is the one carrying the security fix. bulwark's ownci.ymlsetsGOTOOLCHAIN: localby hand for exactly this reason; consumers now get it automatically. The probe reads the ambient version withGOTOOLCHAIN=localset for the same reason:go versioninside a module honours that module'stoolchaindirective and reports the version it would switch to, which is not the onelocalwould then select — measure and pin have to come from the same place, or bulwark concludes "ambient is fine" and lands on an older toolchain than it just measured. - Rust delegates to rustup, which already reads the same
rust-toolchain.tomlbulwark does. This extends rather than contradictsinternal/rust's existing stance that the toolchain version "is the target repo's responsibility via its own rust-toolchain.toml". What it adds is materialising the channel up front with theclippyandrustfmtcomponents the checks need — rustup would otherwise install it lazily in the middle ofcargo clippy, where a missing component reads as a check failure rather than a setup step. With no rustup at all, bulwark says so and continues rather than installing rustup behind the user's back. Installing is not selecting, and the two come apart in exactly one case. rustup picks a toolchain by readingrust-toolchain.tomlfrom the directory cargo runs in, which covers the normal case for free —internal/rustandinternal/coverageboth run cargo inside the crate directory, so the file bulwark read is the file rustup reads. A version supplied bytoolchain.rustin.bulwark.ymlhas no such file, so rustup cannot see it and would install the requested channel and then go on running the old default.Requirement.Overriddenmarks that case andprovisionRustsetsRUSTUP_TOOLCHAINonly then: applying it whenever a channel came from a manifest would override rustup's own per-crate selection — the thinginternal/rustsays not to second-guess — and would break a monorepo whose crates pin different channels. - Node is the only one bulwark downloads and unpacks itself, from
nodejs.org, with the digest read from that release'sSHASUMS256.txt. There is no assumable equivalent of GOTOOLCHAIN or rustup — nvm/fnm/volta are all optional and mutually exclusive. The.tar.gzis taken over the.tar.xzpurely because Go's standard library decompresses gzip and not xz.
Provisioning failures warn; they do not fail the scan. This step is preparation, not a gate,
and falling through to "whatever is on PATH" is exactly today's behavior — turning a working scan
into a hard failure over a network blip would be a regression, and if the toolchain really is
absent the next step fails loudly and specifically. What must not happen is failing silently, so
every reuse, substitution, skip and failure is named on stderr. Stderr, not stdout, deliberately:
action.yml's PR-comment builder scrapes bracketed tags from coverage stdout with a pattern that
is not line-anchored, so unrelated chatter on stdout would end up in the PR comment.
Doing this in bulwark rather than in each caller's CI is the point. gt briefly grew a
setup-go step in its bulwark stage (19e4b77, reverted in a0ed107) and it was wrong twice
over: it looked for go.mod at exactly one path, so wardnet — whose modules live under wctl/ and
sdk/wardnet-go/, not the scan root — would silently have got nothing, and it put knowledge of Go
toolchains into gt, which would then have needed the same for Rust and TypeScript forever. bulwark
already knows which ecosystems it detected and where, so it reads every manifest under the scan
root; and it is the only place that helps wardnet at all, since wardnet calls wardnet/bulwark@v1
directly rather than through gt. See ADR 0004.
Downloads land in the same version-keyed ~/.cache/bulwark layout every other bulwark-managed
install already uses, so a consumer caching that one path (as wardnet's workflow already does)
covers language toolchains too with no key change. Installs stage into a sibling temp directory and
are renamed into place, so an interrupted download can never leave a half-populated toolchain that
the next run mistakes for a complete one.
bulwark coverage diffs the current branch's per-language coverage against a lazily-computed,
per-main-commit-SHA baseline cached on a dedicated bulwark-state branch (never main — bot-owned
generated cache data, not source, needs no PR/review and never pollutes main's history):
- A run on main records its own coverage as that commit's baseline. When
HeadSHA == BaseSHA(bulwark is running on main, not ahead of it) there is nothing to gate against — the current commit is the baseline — socmd/bulwark/coverage.gowrites what it just measured tobulwark-stateand stops. This is the primary way baselines get created, and the only one that works for a repo whose coverage is produced by a multi-job CI pipeline rather than by bulwark running the tests itself (precisely whatcoverage.source: reportexists to serve): such a repo can never recompute a historical baseline, becausecomputeBaselineAt's throwaway worktree is a bare checkout with none of the toolchain (cargo-llvm-cov, yarn/Node) or staged reports the pipeline provides — it measures nothing. wardnet ran that way for months: the numbers it kept failing to reconstruct in a worktree were numbers it had already measured, and thrown away, when this same command ran on main. Recording them costs nothing — no test re-run, no extra tooling, they are already in hand. Consumers must therefore runbulwark coverageon pushes to main, not only on PRs. - Baseline writes merge; a partial run never shrinks the baseline. A consumer's CI may
path-filter its coverage jobs (wardnet skips frontend coverage on a Rust-only change), and the
cache-miss worktree routinely lacks tooling, so either write path can legitimately measure only
some of the detected languages. Recording only what was measured would silently drop the
unmeasured language's entry — every later PR would see it as
[NEW], compared against nothing, permanently, and the "never cache an empty baseline" guard doesn't catch it because the report isn't empty, just partial. So both writers (record-on-main andcomputeBaselineAt's cache-miss path) runcmd/bulwark/coverage.go'scarryForwardBaselinefirst: it copies the entry for every detected-but-unmeasured language from the nearest prior baseline viagitstate.PriorBaselines, which starts at the recorded SHA itself (so a re-run or a concurrent per-language job never clobbers a fresher same-commit entry with an ancestor's stale value — and a shallow depth-1 checkout can still see it) before walking first-parent ancestors, best-effort, skipping poisoned{}entries. A language that's genuinely gone — source deleted, orenabled: falsein.bulwark.yml(enabledEcosystemsstrips disabled languages from the detected set) — is no longer detected, so its entry still dies with it; only "the code is there but this run didn't measure it" carries forward. Therecorded coverage baselineline names anything carried, and an unmeasured language no prior had is named in a stderr warning (shallow history is the usual culprit) instead of vanishing silently. This applies even when a main run measures nothing (a docs-only merge: every producer path-filtered away, no reports for a report-sourced repo to read) — the whole baseline is carried rather than skipping the record, because a main commit with no baseline forces every PR against it into the recompute-nothing → all-[NEW]→ gate-on-nothing hole (wardnet/wardnet#899). "No coverage measured" is only printed when there was truly nothing to record: nothing measured and no priors to carry. internal/gitstate.BaseSHAresolvesgit merge-base HEAD origin/main.internal/gitstate.ReadBaselinefetchesbulwark-stateand readsv2/<sha>.json(seegitstate.StatePath, and Aggregation below for why it is versioned) viagit show(no checkout) — a missing branch or missing file is a cache miss, not an error.- On a cache miss,
cmd/bulwark/coverage.go'scomputeBaselineAtchecks outorigin/mainat that SHA into a throwawaygit worktree(never disturbing the caller's own working tree/branch), computes coverage there, andinternal/gitstate.WriteBaselinepushes it tobulwark-state(via another throwaway worktree — creating the branch as an orphan the first time).bulwark-stateis shared and busy — every CI run on the repo may push to it — soWriteBaselinefetches the fresh remote ref immediately before staging each attempt (the local tracking ref is as stale as the job's checkout, minutes old by the time a scan finishes) and retries a rejected push from the re-fetched ref, treating "the fetched branch already has this exact content" as success. A push that never lands is returned as an error: the PR-side cache-miss caller downgrades it to a warning (it already holds the computed baseline), but the record-on-main path must never print "recorded" for a baseline that was lost — that exact silent loss (stale ref → non-fast-forward rejection → swallowed) is how wardnet's main runs kept recording nothing while every PR re-hit a cache miss. internal/coverage.Computegets the actual number per detected ecosystem: covered-over-total statements read from the profile for Go,cargo llvm-cov --json'sdata[0].totals.lines.{count,covered}for Rust, and — for TypeScript, best-effort only — a package's owntest:coveragescript plus Vitest/Istanbul'scoverage-summary.json, since unlike a linter there's no single canonical coverage-invocation convention to standardize on across arbitrary TS packages. A language whose coverage can't be measured is silently omitted from the report, not failed.- Rust never assumes
--diritself is the crate/workspace root —internal/detect.RustCrateDirsdiscovers every independent Cargo crate/workspace root under--dir(deduping a workspace member's ownCargo.tomlunder its ancestor workspace root), and bothinternal/rust.Checkandinternal/coverage.rustCoverageiterate every discovered root, summing line counts across them the same way TypeScript sums across packages (see Aggregation below). Rust's report overrides are therefore keyed by crate directory (relative to--dir) rather than a single path —coverage.rust.reportandcoverage.rust.lcovaccept a mapping of crate dir to path, and the--rust-report/--rust-lcov-reportflags are repeatable with the same<crateDir>=<path>syntax. A bare value (a scalar in the file, or no=on the flag) is only honored when discovery finds exactly one crate, preserving the original single-crate invocation unchanged. - Go never assumes
--diritself is a module root either, for the same reason and by the same shape (see ADR 0002) —internal/detect.GoModuleDirsdiscovers every module under--dirandinternal/coverage.goCoveragemeasures each in turn, summing across them.coverage.go.reportand--go-reportare likewise keyed by module dir (--go-report <moduleDir>=<path>). This one bit for real:go test,go list -mandgo tool cover -funcare all module-scoped, so running them at a monorepo root measured nothing — and said so only in a warning, leaving Go absent from wardnet's gate, aggregate and patch both, while CI stayed green. Two guards keep that from recurring quietly: the aggregate is computed from the profile itself rather than by shelling out togo tool cover -func(same number, but it works from any directory), andmoduleNamerejectsgo list -m'scommand-line-argumentsanswer instead of treating it as a module path that strips nothing. - Generated Go files are excluded from both the aggregate and the patch gate, matched on Go's
// Code generated ... DO NOT EDIT.convention (https://golang.org/s/generatedcode) rather than a filename pattern — the same signal golangci-lint and Codecov use. Without it the gate measures code generation rather than testing: wardnet's regenerated REST client was 983 of one PR's 1007 changed Go lines and pinned its SDK module's aggregate at 2%. Notego.excludein.bulwark.ymlcannot do this job — it narrows module discovery, not what is inside a module. - An empty baseline is never cached, and an empty cached baseline is a miss.
Computesilently omits any language whose tooling it can't run (deliberate — a repo with no coverage tooling shouldn't hard fail). But baseline computation runs in a bare worktree: nonode_modules, no CI-staged report, only whatever tooling the runner happens to have.internal/coverage.rustCoverageunderSourceRunrequirescargo-llvm-covonPATHand bulwark does not install it (unlike cargo-audit/cargo-deny/gosec/semgrep, which it pins and installs itself) — so on a runner without it, the baseline computes to{}. Cached, that{}is indistinguishable from a real entry: every later PR gets a cache hit, every language reports[NEW], and the gate enforces nothing, silently and permanently, with no way to self-heal. wardnet ran this way — all nine baselines on itsbulwark-statebranch were{}, and its coverage gate had never once compared anything.cmd/bulwark/coverage.gotherefore refuses to cache an empty report,gitstate.ReadBaselinetreats a cached{}as a miss (which heals already-poisoned branches without a manual purge), andwarnUnmeasurednames every detected-but-unmeasured ecosystem on stderr rather than dropping it in silence. If a language is missing from the gate, bulwark now says so. - A language with no prior baseline entry (new) is reported but doesn't fail the check on its own;
a language whose current coverage dipped below its baseline by more than
coverage.tolerance(default 0.1pp, compared at the report's tenth-of-a-point display precision) does. To keep tolerated dips from compounding — each merge lowering the baseline the next PR gates against — the baseline writers restore any within-tolerance dip to the prior (high-water) value when recording; only a beyond-tolerance drop, which was FAIL-visible on the PR that introduced it, resets the baseline. A language the baseline has but the current run doesn't splits on detection: still detected in the tree means its coverage step just didn't run this time (path-filtered CI job, missing tooling) and it's reported as[UNMEASURED]; no longer detected means the source actually left the tree and it's reported as[DROPPED](wardnet/wardnet#892 showed a Rust-only PR as "typescript: no longer measured" when the TS code was untouched — only the frontend coverage job had been skipped). Neither fails on its own.
A language's figure is the ratio of its units' summed line counts — Σ covered / Σ total
across every discovered Go module, Rust crate/workspace root and TypeScript package — never the
mean of the units' percentages. Each per-unit measurement returns an internal/coverage.LineCount
(Go counts statements, since that is what a Go profile records; the ratio is the same quantity),
and Compute returns the Unit list alongside the per-language percentages.
The mean is not a mild approximation, which is why the counts are carried all the way rather than
reduced early. On a nine-package pnpm monorepo, one commit that added a 39-line untested file to
the smallest package (230 lines) while adding ~1,250 well-tested lines elsewhere read as −2.2
under the mean and +0.44 by line count: the gate failed a change that improved coverage, by
five times the true magnitude, in the opposite direction. A 4,494-line library and a 230-line app
had equal votes. Widening coverage.tolerance to absorb that is explicitly not the fix — it exists
for sub-tenth instrumentation noise, and a tolerance wide enough to hide a 2.2-point artefact hides
a genuine two-point regression too.
A unit with no measurable lines is unmeasured, never 0%. An empty Go profile, a crate llvm-cov
reports zero lines for, an Istanbul summary with total.lines.total == 0 — each is returned as a
Unit with a zero LineCount, which Unit.Measured reports false for. aggregate skips it, so no
0/0 reaches a language's figure, and the floor below never fails it as a unit at 0%.
It is returned rather than dropped because dropping it is invisible. A language reports a percentage
as soon as one of its units measures, so warnUnmeasured — which works per language — stays
silent while the floor gate covers fewer units than the repo holds: a repo whose CI path-filtered
eight of nine TypeScript packages has a fully measured language and a gate that saw one package.
floorReport therefore names each one as [UNMEASURED] with a stderr warning and prints its pass
line as N of M unit(s), the same "a gate that didn't run must be visibly distinct from a gate that
passed" rule Patch coverage below states.
A TypeScript package that declares no test:coverage script is the exception: under SourceRun it
has opted out of being measured, which is not the same as a report bulwark expected and did not
find, so it is not a discovered unit at all. One [UNMEASURED] line and one warning per types-only
package on every run trains readers to ignore the tag that exists to be noticed.
floorReport filters to enabled ecosystems, and it is the only gate that does. Compute
measures every language detect.Ecosystems finds without consulting rust/typescript/go.enabled,
so its units — and current itself — can carry a language the repo opted out of. For the aggregate
that has always been one surprising [FAIL] line; for a per-unit gate it would be one build failure
per crate. Closing it at the source (having Compute skip disabled languages, which is what this
document's Coverage section already claims happens) is the better fix and is not this change.
coverage.floor is what line-weighting removes, put back deliberately. Weighting by lines is
blind to a small unit nobody tests: a package with 0 of 8 lines covered is 0.1% of an 8,305-line
repo, so the headline barely moves. The two metrics answer different questions — "did this change
leave code untested?" is the aggregate, "is there a unit nobody tests at all?" is the floor — and
neither bounds the other. cmd/bulwark/coverage.go's floorReport gates every measured unit
against it and prints one [FAIL] line per unit below, in the same bracketed vocabulary
diffReport/patchReport use. An unmeasured unit is [UNMEASURED], never a failure and never
folded into the passing count.
It is also the one gate that runs on main. The aggregate and patch gates compare against a
baseline, and on a push to main the current commit is the baseline, so they have nothing to
compare and the record-on-main path returns before them. A floor has no baseline: a unit is above
the bar or below it, and that reads the same on main as on a pull request. Skipping it there would
leave main ungated on a unit that arrived through a path no pull request measured. The baseline is
recorded first and unconditionally — it is worth keeping whether or not a unit is below the floor,
and losing it would push every later pull request into a recompute-nothing cache miss over an
unrelated failure. It defaults to 0 (off), because upgrading bulwark must not start
failing a repo over a gap it has always had, and it has no baseline and no
compare-against-last-time: a floor is an absolute standard, and ratcheting it against a prior
value would make a unit that has never had tests permanently acceptable.
Cached baselines from before this live at a different path and are simply missed. Entries
recorded under the mean are a different quantity, so gitstate.StatePath writes and reads
v2/<key>.json on bulwark-state and every consumer takes one clean cache miss and re-records.
The version is the metric's, not the file format's — the entries stay a plain
language → percentage object, readable by hand on the branch — and the old entries stay in place
rather than being overwritten. PriorBaselines' git ls-tree needs -r for the same reason, or
it lists the directory instead of the entries in it. Consumers still see a step change on upgrade
(+9 points on the repo above); say so in the release notes. See
ADR 0007.
Unlike Codecov or Sonar — which never execute your tests, only ingest a coverage report your build
already produced — bulwark coverage's default (coverage.source: run) actually runs each
ecosystem's test suite itself (go test -coverprofile, cargo llvm-cov, a package's
test:coverage script). That's the right default for local dev (one command, no separate step to
remember), but it's wrong for CI if a test job already runs with coverage instrumentation on —
running tests again would duplicate work that may already be expensive (wardnet/wardnet-cloud's
existing pipelines already run tests twice: once plain for pass/fail, once instrumented for
coverage; bulwark coverage piling on a third run would make it worse, not better).
coverage.source: report fixes this: bulwark never executes anything, only looks for a report file
a prior job already produced — internal/coverage.findReportForUnit checks an explicit override
first (keyed by the discovered module/crate directory it applies to, from coverage.go.report /
coverage.rust.report / coverage.rust.lcov, or the matching --go-report/--rust-report/
--rust-lcov-report flag), then a built-in candidate list resolved relative to that directory
(coverage.out/cover.out/c.out for Go;
coverage/llvm-cov.json/llvm-cov.json/target/llvm-cov/llvm-cov.json for Rust — TypeScript has
no override, since coverage/coverage-summary.json is already Istanbul's own fixed convention, not
something projects vary). In CI, the intended shape is: the existing test job already produces
coverage as a side effect of running tests once (e.g. cargo llvm-cov nextest as the test runner,
not a second pass after a plain cargo test), and bulwark coverage runs afterward as a pure
report-consumer.
The axis is named for who owns production, not for what bulwark skips. The setting used to be
--tests=run|skip (and a tests-mode action input), which described bulwark's own behavior and
left the reader to infer the pipeline shape behind it. run/report names the decision the repo
is actually making. The never-execute promise is unchanged and still guarded by
internal/coverage.TestGoCoverageSourceReportDoesNotRunTests.
Where it lives is the point (see ADR 0003).
coverage.source is a property of how a repo's pipeline is built
— one answer, true for every workflow in that repo — so it belongs in .bulwark.yml at the scan
root, not restated at each call site. The composite action therefore has no input for it:
action.yml passes only --dir, and --dir is the one thing that can't move into the file, since
the file lives at the scan root and bulwark must know the root before it can read its own config.
The CLI flags (--source, and --go-report/--rust-report/--rust-lcov-report) remain as a
local-dev/one-off override that outranks the file; --tests survives as a deprecated alias mapping
skip to report. cmd/bulwark/coverage.go's resolveSource and resolveReports own that
precedence. Both source flags default to "", not to "run" — with a "run" default the flag
would always be populated, always outrank the file, and coverage.source would never once be
consulted, a silent failure indistinguishable from the config key not working.
One exception, and it is a trap. Computing a baseline at a historical main SHA (a cache
miss) always uses coverage.SourceRun internally — cmd/bulwark/coverage.go's computeBaselineAt
hardcodes it and passes an empty ReportPaths{} — regardless of what coverage.source says. A
historical commit's throwaway checkout has no CI-produced report sitting in it, so there is nothing
to consume. This is why internal/coverage.Compute takes the source as a parameter and never
reads cfg.Coverage.Source itself, even though it is already handed the config: a Compute that
consulted the config directly would hand every report-sourced repo an empty baseline forever —
exactly the {} poisoning the caller then refuses to cache, leaving the gate comparing against
nothing, silently and permanently. Keeping the axis in the signature forces the one caller that
must override it to say so out loud.
cmd/bulwark/TestComputeBaselineAtRunsTestsEvenWhenSourceIsReport guards this directly, with a
fixture whose only possible source of a coverage number is a test that actually ran. The real cost
is one test run per main commit (cached afterward on bulwark-state), not once per PR, so it
doesn't reintroduce the duplication coverage.source: report exists to avoid.
TypeScript's SourceRun path also runs a one-time dependency install per workspace root before
executing each package's test:coverage script — a fresh checkout (baseline computation's throwaway
worktree, but also any other SourceRun invocation) has no node_modules a prior step could have
already installed. internal/coverage.resolvePackageManager auto-detects npm/yarn/pnpm from the
root's lockfile (package-lock.json/yarn.lock/pnpm-lock.yaml); a root with more than one
recognized lockfile is treated as ambiguous and install is skipped there rather than guessing a
priority order. typescript.install in .bulwark.yml overrides auto-detection entirely with an
explicit shell command (e.g. corepack enable && yarn install --immutable), for Corepack-pinned or
otherwise nonstandard install flows auto-detection can't infer, or to resolve an ambiguous root.
internal/coverage.tsWorkspaceRoots dedupes so a shared root serving multiple nested packages is
only installed once, not once per package.
Aggregate coverage and patch coverage catch disjoint regression classes: aggregate catches
coverage lost in code the current PR never touches (e.g. a deleted test file — none of those lines
are in the diff, so aggregate is the only gate that notices); patch coverage catches untested new
code even when the codebase is big enough that it doesn't move the aggregate percentage. Neither
bounds the other, so bulwark coverage computes and gates on both, not either/or — patch coverage
is a second, independent check alongside diffReport's existing aggregate gate, not a replacement.
Patch coverage has no baseline or threshold of its own — it always gates against that same
language's aggregate baseline already read from bulwark-state (patch% >= baseline% - coverage.patch.tolerance — its own knob, deliberately independent of coverage.tolerance, so
loosening the noisy aggregate gate never silently weakens this one). A language
with no aggregate baseline yet is reported informationally ([NEW]), not failed, mirroring
aggregate's own handling of a first-time-seen language. It's opt-out, not opt-in, per language, via
.bulwark.yml:
coverage:
patch:
go:
enabled: false # defaults to trueChanged lines come from a hand-rolled unified-diff hunk parser (internal/coverage.ChangedLines,
git diff --relative --unified=0 <merge-base>..HEAD) — deliberately not a diff library, since the
format needed is a small, stable subset (hunk headers + + lines). --relative matters: the
command runs in --dir, and every consumer of the changed-line map works in --dir-relative
paths (crate/package prefixes, lcov normalization). Without it, git emits repo-root-relative paths,
so with --dir pointing at a subdirectory (wardnet's --dir source) every changed file failed the
prefix match and the patch gate silently measured nothing. mergeBase is the exact same SHA
gitstate.BaseSHA already resolved for the aggregate baseline lookup, reused as-is rather than
recomputed. The parser does no language-aware filtering of comments/blank lines/imports — that
happens later, when changed lines are intersected with a coverage report's line-hit data
(internal/coverage.PatchPercent counts only lines the report actually mentions).
A Go coverage profile is the exception, and it bit us. lcov (Rust, TypeScript) lists only executable lines, so "absent from the report" safely means "not executable, don't count it". A Go profile records blocks, not statements — every line between a block's braces is in the report, comments and blank lines included.
The dividing line is the format, not the tooling: lcov simply has no slot for a non-executable
line, whereas a Go profile has no notion of a line at all, only a brace-to-brace span that bulwark
itself expands. Don't infer it from how the tool measures — Vitest's default v8 provider is
range-based exactly like Go's profile, and llvm-cov's own text report does print counts beside
comment lines inside a function. Both still emit clean lcov (v8 maps ranges back onto statements via
v8-to-istanbul; llvm-cov --lcov only emits DA: for lines carrying a coverage segment) —
verified directly against both producers with a comment and a blank line inside an uncovered
function, neither of which appeared in the resulting lcov. So Go needs the filtering below and the
other two genuinely don't. So a comment added inside an uncovered function counted as an
uncovered new line, and a comment-only PR scored 0% patch coverage and failed the gate
(wardnet/inforge#216, whose entire diff was nosemgrep annotations and workflow YAML).
internal/coverage.ParseGoProfile therefore reads each profiled source file and drops blank and
//-comment lines before they ever reach LineHits. It deliberately does not try to track
/* */ comments (that needs a lexer — /* inside a string literal opens nothing) or treat a
leading * as a comment continuation (*p = x is a pointer assignment): over-counting a rare block
comment merely understates patch coverage, while wrongly dropping a statement would let genuinely
untested code through the gate.
Per-ecosystem line-hit sources, all converging on the same LineHits (map[file]map[line]hits)
shape:
- Go:
internal/coverage.ParseGoProfilereads the samecoverage.outprofile the aggregate percentage is computed from — no separate format, no secondgo testrun.Compute's returnedPatchSources.GoProfilesis amap[string]GoModuleProfilekeyed by module dir (like Rust'sRustLCOV, not a single path), each entry carrying the profile path, the module path, and the module's directory relative to--dir. Both of the latter are needed to turn a profile's package-qualified names into--dir-relative paths, and neither generalises across modules — wardnet's aregithub.qkg1.top/wardnet/wardnet/source/wctlunderwctl/andwardnet.network/goundersdk/wardnet-go/.goPatchPercentmerges every module's hits; no per-module prefix scoping is needed (unlike Rust's), because the keys are already--dir-relative and cannot collide. - Rust:
cargo llvm-covdoesn't emit per-line data in its--jsonexport, so patch coverage additionally produces an--lcovreport, per discovered crate/workspace root (see the Coverage section above). UnderSourceRun, this doesn't cost a second test execution:cargo llvm-cov --no-reportruns each crate's suite once and keeps raw profile data on disk, then both--no-run --json(aggregate, unchanged) and--no-run --lcov(patch, new) regenerate their reports from that same profile. UnderSourceReport, the lcov file is anotherfindReportForCratelookup per crate — an explicitcoverage.rust.lcoventry (or--rust-lcov-report <crateDir>=<path>), else a candidate list (coverage/lcov.info,lcov.info,target/llvm-cov/lcov.info) resolved relative to that crate's own directory, mirroringcoverage.rust.reportexactly.Compute's returnedPatchSources.RustLCOVis amap[string]stringkeyed by crate dir (like TypeScript'sTSLCOV, not a single path) —cmd/bulwark/coverage.go'srustPatchPercentresolves each crate's contribution independently, mirroringtsPatchPercent's longest-prefix matching so two crates can't clobber each other's hit data for a same-named file. A crate with no resolvable lcov file is omitted from patch coverage, not a failure — but not silently when it matters (see the[UNMEASURED]paragraph below). - TypeScript: reads
<pkgDir>/coverage/lcov.info(Istanbul/Vitest's native lcov output) — fixed convention, no override flag, matching the existing no-override precedent for TS aggregate coverage. This only works if the consumer's own test config already has anlcovreporter enabled; otherwise it's omitted, the same best-effort caveat AGENTS.md already documents for TS aggregate coverage.
cmd/bulwark/coverage.go's patchReport prints one bracketed status line per language using the
same [PASS]/[FAIL]/[NEW] vocabulary the aggregate gate already uses (e.g.
[FAIL] go patch: 0.0% (0/9 new lines; baseline 55.68%)) — this needs no changes to
action.yml's PR-comment builder, since its cov_detail regex is generic and already picks up any
matching bracketed line.
A skipped patch gate must say so. When a detected language's patch gate is enabled and the
diff touches that language's files, but no per-line source was resolved (no lcov for the crate, no
lcov reporter in a TS package), patchReport prints an [UNMEASURED] <lang> patch: ... line and
a stderr warning naming the missing wiring — it never fails the gate on its own, mirroring
diffReport's [UNMEASURED] handling. The old behavior was a bare continue, and it read as
"patch coverage passed" in the PR comment: wardnet/wardnet#957 shipped a green bulwark summary
(aggregate flat, patch never ran for want of an lcov path) while Codecov — fed the very same lcov
export the pipeline had already produced — failed that diff's patch coverage. The two reports can
only stay aligned if a gate that didn't run is visibly distinct from a gate that passed. The
report stays scoped to detected ecosystems, since the patch gates default to enabled for all three
languages regardless of what the repo contains — a stray changed .rs file in a pure Go repo must
not produce a rust line.
internal/semgrep.Check picks its subcommand from whether SEMGREP_APP_TOKEN is set: semgrep ci
(diff-aware, applies the org's platform policy, uploads to the AppSec Platform) when it is, plain
semgrep scan --config <ruleset> --error when it isn't. Those two modes disagree about scope,
and that disagreement was a standing CI defect: GitHub deliberately withholds repo secrets from
dependabot[bot] events, so every Dependabot PR arrived with an empty token, silently fell back to
a whole-repo scan, and blocked on the consuming repo's pre-existing findings — findings no
token-bearing run had ever reported, in code the PR never touched. Whether a PR was green depended
on who opened it.
bulwark scan --diff-base <ref> closes that gap: in scan mode it passes Semgrep
--baseline-commit, so the fallback blocks on the same thing semgrep ci would — what the change
introduces — and nothing else. --diff-base auto resolves the merge-base with origin/main via the
same internal/gitstate.BaseSHA the coverage gate already uses, so a PR's scan and its coverage
agree on what "this change" means. action.yml passes auto on every pull_request event.
Two deliberate choices in cmd/bulwark/scan.go's resolveDiffBase:
- A token short-circuits it entirely —
semgrep cialready scopes itself to the diff, so resolving a merge-base would cost agit fetchnothing reads, and would newly demand a full-history checkout from token-bearing consumers that don't need one today. - An unresolvable
autois an error, not a silent full scan. Falling back would reintroduce the exact surprise the flag exists to remove: a scan that quietly widens its own scope. A shallow checkout is a fixable misconfiguration (fetch-depth: 0), so bulwark says so and fails.
Default (--diff-base empty) is still a full-repo scan — that's what a local bulwark scan wants,
and it's what a push to main gets.
Restoring semgrep ci on Dependabot PRs (for the platform dashboard's sake) is a consumer-side
option, not a bulwark one: the token has to be added to the repo's separate Dependabot secrets
store (gh secret set SEMGREP_APP_TOKEN --app dependabot), since Actions secrets are not visible to
Dependabot events. It is not required for CI to be green — the diff-aware fallback above is — and it
does hand an upload token to a workflow that executes the bumped dependency's code, so it's a
per-repo judgment call.
Unlike inforge's action (install-only — its invocations vary too much per call site to bake in),
bulwark's usage is uniform enough (.bulwark.yml already carries all the config) that the action
owns the whole install → run → report flow: install bulwark, run scan/coverage (each toggleable
independently via run-scan/run-coverage), post one sticky PR comment summarizing both (upsert,
not a fresh comment every run — via marocchino/sticky-pull-request-comment), and optionally
upload to Codecov (non-blocking, purely for its dashboard/history) and/or switch bulwark's own
Semgrep check into semgrep ci mode (diff-aware + uploads to the Semgrep AppSec Platform) when a
SEMGREP_APP_TOKEN-equivalent input is supplied. The Codecov upload is two codecov/codecov-action
invocations sharing the same codecov-token gate — one report_type: coverage, one
report_type: test_results — both relying entirely on that action's own recursive workspace
auto-discovery rather than bulwark passing explicit files:/directory: paths itself. This is
why a consumer's CI only needs to hand bulwark a token: bulwark owns the whole Codecov
relationship (coverage and JUnit test-results), so the calling workflow never has to install a
Codecov CLI or push to Codecov directly itself.
The PR comment's header embeds assets/bulwark-logo.png by absolute raw URL
(raw.githubusercontent.com/wardnet/bulwark/main/...), never a repo-relative path — the comment is
posted into the consuming repo's PR, where a relative assets/... would resolve against that repo
and 404. It's pinned to bulwark's default branch, not a release tag, so the image keeps resolving for
consumers pinned to an older bulwark version. Renaming or moving that file therefore breaks the logo
in every consumer's PR comment retroactively — treat its path as a public API.
Never interpolate ${{ inputs.* }} or ${{ steps.*.outputs.* }} directly into a run: script
body — pass it via that step's env: block instead, and reference the env var name ("$DIR",
not "${{ inputs.dir }}") inside the script. Semgrep's own yaml.github-actions.security.run-shell-injection
rule caught this exact mistake once already (see git history) — expression interpolation directly
into a shell script is a real script-injection vector if the interpolated value could ever contain
shell metacharacters, regardless of how trusted the input value looks today. if: conditions and
with: blocks on a uses: step are fine to interpolate directly — only run: script bodies are
the risk, since that's the only place text gets spliced into something a shell then executes.
release.yml assembles the GitHub release body's header from
docs/release-notes/_header.md (the invariant install block — it lives there
rather than in .goreleaser.yml's header: so the workflow can extend it) plus
docs/release-notes/<tag>.md when that file exists, passes it with
--release-header, and goreleaser appends its commit-derived changelog beneath.
A missing per-version file is deliberately not an error. Most patch releases are
fully described by their commit subjects, and failing a release over a file it
never needed would make every release depend on remembering a step — the same
failure the major-alias move in that workflow was automated away to prevent.
Write one when the release carries what a commit subject cannot: a change in
what an existing number or verdict means, an upgrade step consumers must take,
or a new major. docs/release-notes/v2.0.0.md is the worked example, and the
file has to be on main before the tag is pushed — the workflow reads it out of
the tagged tree.
- Version injection:
cmd/bulwarkexposesvar version = "dev", overridden at release via-ldflags "-X main.version=<tag>". Keep that variable name and package stable. - goreleaser & golangci-lint both use the v2 config schema. In golangci-lint v2,
gosimpleis part ofstaticcheck— don't add it as a separate linter (it will error). - Lint must pass with zero issues;
errcheckis on, so check returned errors.
- Always: run
go build ./...,go test -race ./..., andgolangci-lint run ./...before proposing a PR. - Ask first: changing the Go version, renaming the binary/
cmddir, altering the release archive layout, or editing CI. - Never: introduce cgo, commit
dist/or secrets, or skip the lint/test gates.
This repo uses a bare-repo + typed-worktree layout managed by the gt CLI — one session, one
gt wt add <type/name> worktree; never use raw git worktree or edit inside .bare/.