Development¶
The single entry point for working on draft: toolchain setup, how to run
every CI gate locally, how the tests are laid out, and how a release is cut.
If a gate is green here it is green in CI — every job below is reproducible with one command, and where CI pins a version this file names the same pin.
Contents¶
- Toolchain
- Runtime dependencies
- Everyday commands
- Reproducing every CI gate
- Test layout
- Where the time goes
- Release model
Toolchain¶
| Tool | Version | Why this version |
|---|---|---|
| Go | 1.25 minimum, latest stable to develop | 1.25 is the declared floor in go.mod and is built and tested by the msrv CI job. See the minimum-Go policy. |
golangci-lint |
v2.13.2 | Pinned together with Go 1.26 in ci.yml. The linter embeds go/types from the toolchain it was built against, so a pinned linter against a floating toolchain breaks on every Go release. Bump the pair, never one alone. |
Node (via npx) |
any current | Only for markdownlint-cli2; fetched on demand. |
| Python 3 | 3.9+ | codespell and scripts/check-links.py. |
No build step needs cgo: CGO_ENABLED=0 is set for releases, which is what
makes the Linux binary static and portable.
Runtime dependencies¶
draft shells out rather than linking parsers, which keeps the binary small
and portable at the cost of two documented dependencies:
| Tool | Needed for | Install |
|---|---|---|
pdftotext (Poppler) |
PDF sources | brew install poppler · apt install poppler-utils |
textutil |
DOCX sources | macOS only, preinstalled |
Plus one backend: any supported agent CLI (online) or a running Ollama server
(offline). draft --doctor reports exactly what is present and what is not.
Everyday commands¶
make build # ./bin/draft
make generated # manpage + shell completions into bin/gen
make test # go test ./...
make race # go test -race ./...
make cover # coverage + the same 98% gate CI applies
make bench # benchmarks
make lint # golangci-lint
make docs-lint # markdown, spelling, intra-repo links
make check # fmt, vet, test, docs-lint
make help # every target
# The Unix install contract (GNU make reads GNUmakefile first, and it
# includes Makefile, so every target above still works):
make -f GNUmakefile install PREFIX=/usr/local DESTDIR=/tmp/stage
make -f GNUmakefile uninstall PREFIX=/usr/local DESTDIR=/tmp/stage
The manpage and the shell completions are generated from the CLI
definitions (draft --man, draft --completion <shell>) and never
committed. A checked-in .1 is a copy of the CLI that nothing keeps honest:
flags get added, the manpage does not, and the packaged documentation quietly
describes a different program.
Reproducing every CI gate¶
Each row is a job in .github/workflows/. Run the command; if it passes, that
job passes.
| CI job | Local command |
|---|---|
build → formatting |
test -z "$(gofmt -s -l .)" |
build → vet |
go vet ./... |
build → race |
go test -race ./... |
build → coverage |
make cover |
test (ubuntu/macos/windows) |
go test ./... |
lint |
golangci-lint run |
msrv (go 1.25) |
go build ./... && go test ./... on Go 1.25 |
govulncheck |
make vuln |
reuse |
reuse lint |
docs-lint |
make docs-lint |
regression (benchmarks) |
see Benchmark regressions |
deep-quality → fuzz |
make fuzz (FUZZTIME=2m make fuzz matches CI) |
deep-quality → mutation |
make mutation |
Everything at once, in CI order:
gofmt -s -l . && go vet ./... && go test -race ./... && make cover \
&& golangci-lint run && make docs-lint && make vuln && reuse lint
Benchmark regressions¶
benchmark.yml fails a pull request when any metric regresses more than
25% against the merge base. Reproduce it:
go test -run=NONE -bench=. -benchmem -count=8 ./... > head.txt
git stash && go test -run=NONE -bench=. -benchmem -count=8 ./... > base.txt && git stash pop
go run golang.org/x/perf/cmd/benchstat@latest base.txt head.txt
Use -count=8; fewer runs make benchstat report noise as significance.
Test layout¶
Go convention: tests live beside the code they cover, not in a separate tree.
| Pattern | What it holds |
|---|---|
*_test.go |
Unit tests, in the same package |
example_test.go |
Runnable examples, executed by go test and rendered on pkg.go.dev |
bench_test.go |
Benchmarks |
fuzz_test.go |
Fuzz targets, with their seed corpus in testdata/fuzz/ |
hardening_test.go |
Regression tests for defects found by fuzzing or audit |
examples/ |
Standalone programs demonstrating the library; excluded from the coverage gate |
The coverage gate is 98%, deliberately¶
Not 100%, and not a number chosen to be comfortable. The uncovered remainder
is dominated by main() and by error branches that need a failing syscall to
reach. Chasing the last two points would mean injecting seams into code that
does not otherwise need them, which trades real design for a number.
98% is high enough that a new untested branch is visible in the diff, and the
gate excludes examples/ because those are demonstrations, not library code.
The grounding corpus¶
claims/testdata/corpus/ holds twelve sections with realistic model output.
Each case names the rule it guards, and the test pins how many candidate claims
survive the gate and how many are refused.
make corpus # against the recorded extractions; runs in CI
make corpus-live # against a real backend; needs one, costs a call per case
The two halves catch different regressions, and neither substitutes for the other:
make corpusreplays recorded extractions, so it is deterministic and runs on every push. It catches a change to verification — loosening the number check, for instance, makes the invented "34% improvement" in03-number-absent-from-quotesurvive.make corpus-liveasks a real backend to extract from the same sources and compares againstbaseline-live.json. It catches the other half: a rewordedprompt.Claimthat quietly makes the model return fewer usable claims. Nothing about that is visible in a diff, and the cost only shows up as thinner articles weeks later.
Run make corpus-live before merging any change to prompt.Claim. It is
not in CI because GitHub's runners have neither an agent CLI nor an Ollama
server, and a workflow step that always skips is theatre rather than a gate.
The live comparison is against baseline-live.json, not the verified
counts in corpus.json. Those describe deliberately bad extractions recorded
to pin the gate; a real model produces good output and verifies more, so
comparing the two measures nothing. Baselines are specific to the engine and
model that recorded them and drift as models change, which is why the check
fails only on a collapse rather than on any difference.
The grounding gate is mutation-tested¶
claims is the package that decides whether a claim is trustworthy, so line
coverage is not sufficient evidence — a test that runs a line without asserting
its behaviour still counts. make mutation requires 100% efficacy on that
package: every mutant must be killed. Treat a surviving mutant as a missing
assertion, not a flaky tool.
Where the time goes¶
Worth knowing before optimising anything. On a 14-section paper:
| Component | Share |
|---|---|
| Model latency | ~99% |
| Provider process startup | 0.1–20% depending on the CLI |
pdftotext |
~0.1% |
All draft Go code |
<0.1% |
Micro-optimising the Go code has no product effect. The wins are in call
count, caching and provider choice. See docs/AUDIT-2026-09.md.
The documentation site¶
The manual is published to https://sebastienrousseau.com/draft/ by the
docs workflow on every push to main. Its pages are assembled from this
repository's Markdown by scripts/build-manual.py; nothing is authored in the
site, so a chapter cannot disagree with the file it came from.
make manual # build into .gen/site
make manual-serve # live reload while editing
The site is served behind a Content-Security-Policy¶
The custom domain is fronted by Cloudflare, which adds a domain-wide CSP with an allowlist of image origins. GitHub Pages cannot set response headers, so this rule lives in the Cloudflare configuration and not in this repository.
The practical consequence: an external image added to the documentation will
render on GitHub and be silently blocked on the site. The README's shields.io
badges hit exactly this on the day the manual first published — the markup was
correct and the images returned 200 image/svg+xml, but the browser refused to
load them because img.shields.io was not in img-src. It has since been
added, so the badges render; the constraint itself has not gone away.
The failure gives you nothing to go on: no server-side error, no CI failure,
just an empty space where the image should be. If you add an image from a new
origin, add that origin to the CSP's img-src too. To check what the site
currently allows:
curl -sSI https://sebastienrousseau.com/draft/ | grep -i content-security-policy
And to confirm a page's external resources are actually permitted, compare the origins it references against that header — a blocked resource produces no server-side error, only an empty space and a console message.
Release model¶
Versions are 0.0.x until 0.0.999; the CLI surface and output layout are
covered by the stability policy.
- Land the change through a pull request with CI green.
- Update
CHANGELOG.md— Keep a Changelog form, one heading per release. - Tag
vX.Y.Zand push it. release.ymlbuilds six binaries, generates SBOMs, signs the checksum file with keyless Sigstore, notarizes the macOS binaries, and attests build provenance. It is gated on the protectedreleaseenvironment.
Before the first use of new release machinery, run the workflow's dry-run mode
(workflow_dispatch with dry_run: true), which exercises everything except
publishing.
Recovering a failed release¶
If release.yml fails partway, some artifacts may already be published while
later steps — provenance attestation, the macOS installer, the verification
job — never ran. A re-run has to start from a commit whose workflow contains
the fix, and the tag decides which workflow file runs, so fixing main is
not enough on its own:
# 1. Land the fix on main and confirm CI is green.
# 2. Remove the incomplete release, keeping nothing stale behind.
gh release delete vX.Y.Z --yes # the tag survives this
# 3. Move the tag onto the fixed commit and re-push, which re-triggers the
# workflow. Verify the signature before pushing, since a re-tag re-signs.
git tag -d vX.Y.Z && git push origin :refs/tags/vX.Y.Z
git tag -a vX.Y.Z -m "..." && git tag -v vX.Y.Z
git push origin vX.Y.Z
Check the download counts first (gh release view vX.Y.Z --json assets). This
is only safe while nobody holds the old artifacts: rebuilt binaries are not
guaranteed byte-identical, so anyone who already downloaded would see a
checksum mismatch. If there are downloads, ship the fix as the next version
instead.
Verifying a release is documented in docs/RELEASE_SECURITY.md.