Versioning & release procedure
This document is the versioning policy and the release runbook. The README carries a summary; this file wins on detail.
Versioning policy
Scheme: SemVer 2.0.0. Pre-1.0 semantics, applied strictly:
- MINOR (0.X.0) — any breaking change, or a coherent feature series/milestone.
- PATCH (0.x.Y) — fixes and purely additive changes that break nothing below.
What counts as breaking (these are the public contracts, per the ADRs):
| Surface | Breaking examples | Non-breaking examples |
|---|---|---|
| CLI + exit codes (ADR-0009) | removing/renaming a flag; changing an exit-code meaning | new flag; new subcommand |
| Pack schema (ADR-0004) | removing a key; changing key semantics | new optional key |
| Event wire schema (ADR-0008) | removing/renaming a field or variant; changing schema semantics | new variant; new field with a default (additive-only rule) |
| Canonical artifact format (ADR-0010) | any change to emitted bytes (snapshot-locked) | — (changes are inherently breaking; bump minor) |
| Engine seam (ADR-0002) | changing EngineFactory/EngineSession/StepBatch/ScenarioCtx shapes | new defaulted trait method |
| Config file | removing/renaming a proef.toml key | new optional key |
1.0.0 is declared when the pack schema, CLI grammar, event schema, and exit codes are stable enough to promise MAJOR-only breakage. Until then, downstream consumers should pin minor versions.
Single source of truth: [workspace.package] version in the root Cargo.toml.
Every crate inherits it (version.workspace = true); the workspace releases as one
set, always. Never version a crate individually.
Orthogonal versions, not to confuse with the crate version:
- The event schema version is the
schemafield inrun_started(EVENT_SCHEMA_VERSION). It only moves on a semantic break of the stream — additive variants/fields do not bump it. - The hurl pins (
=8.0.1) never move as a side effect of a release. Upgrades go exclusively through the canary + runbook (IMPLEMENTATION-PLAN §7, ADR-0003). - Toolchain policy: the pin tracks latest stable Rust but adopts a new
minor only at its
x.y.1point release, ~3-4 weeks afterx.y.0(tools and third-party crates track latest immediately; exact pins like hurl outrank everything). A reviewer reading “latest stable” as “bump on release day” prompted writing this down (R18-2). - MSRV is the toolchain pinned in
rust-toolchain.toml; it may rise in any MINOR release pre-1.0 and is not a separate contract yet.
Tags: annotated vX.Y.Z on main, linear history. Cadence: release when a
milestone or a coherent series lands — not on a calendar.
CHANGELOG rules
## [Unreleased]always exists at the top; every landed change adds a line there in the same commit series that lands it.- On release,
Unreleasedcontent moves under## [X.Y.Z] - YYYY-MM-DD(with a short parenthetical theme) and a fresh emptyUnreleasedis left behind.
Release runbook
From a clean, green main (all gates local + CI).
mainis protected — the release commit goes through a pull request, and the tag is pushed only after it merges. Do notgit push origin main, and do not tag before the merge.git push --follow-tagsis not atomic: git pushes refs independently, so a protected-branch rejection stops the branch while the tag still lands — and a tag is exactly whatrelease.ymltriggers on. That combination starts a release build from a commit that is not onmain. It happened cutting 0.10.0; the run was cancelled and the tag deleted before anything published, but the recovery is avoidable and this ordering avoids it.
# 1. On a release branch, cut the changelog: move [Unreleased] → [X.Y.Z] - date
# with a short parenthetical theme, and leave a fresh empty [Unreleased].
# (There is no link-reference section at the bottom of CHANGELOG.md — nothing
# to update there.)
git switch -c release/vX.Y.Z
# 2. Bump the version in the root Cargo.toml — BOTH places:
# [workspace.package] version = "X.Y.Z" (the crates' own version)
# [workspace.dependencies] proef-core / proef-engine-hurl / proef-lsp
# version = "X.Y.Z"
# (the inter-crate pins — belt-and-suspenders for independent crates.io
# publish; a stale pin no longer satisfies the bumped version and fails
# resolution, so these move in lockstep with the line above).
cargo build --workspace # refreshes Cargo.lock versions
# fuzz/ is a separate workspace with its own committed lock, and the gates job
# checks it with --locked: refresh it too or that gate goes red on the release
# commit.
cargo check --manifest-path fuzz/Cargo.toml --all-targets
# 3. Full gates — the same set CI runs, so a green local pass predicts a green PR:
cargo nextest run && cargo test --doc
cargo clippy --all-targets --all-features -- -D warnings && cargo fmt --all --check
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features --workspace
cargo deny check && cargo audit && cargo machete
cargo run -p xtask -- docs-check # the gate a docs-touching release commit trips
zizmor .github/workflows/
# 4. Commit and open the release PR (no tag yet):
git commit -am "release: vX.Y.Z"
git push -u origin release/vX.Y.Z
gh pr create --base main --title "release: vX.Y.Z"
# 5. After CI is green and the PR is MERGED, tag the *merged* commit and push
# only the tag. The squash merge creates a new commit, so tagging the branch
# would leave the tag off `main`'s history.
git switch main && git pull --ff-only
git describe --tags --exact-match HEAD 2>/dev/null && echo "already tagged — stop"
git tag -a vX.Y.Z -m "proef X.Y.Z"
git push origin vX.Y.Z # this, and only this, starts release.yml
If the release commit was made on main locally before branching, git pull --ff-only refuses afterwards: the squash merge superseded it. Confirm the merged
commit carries the version bump, check git diff --quiet HEAD origin/main, then
git reset --hard origin/main.
The tag push triggers .github/workflows/release.yml, which:
- builds release binaries for five targets (macOS arm64/x86_64, Linux
arm64/x86_64-gnu, Windows x86_64-msvc — the Windows zip bundles the vcpkg
DLLs; macOS links the SDK’s system libxml2 and vendors OpenSSL, so shipped
binaries need no Homebrew), via
cargo auditable(binaries stay scannable) with no cache restore (cache poisoning must not reach published artifacts), attesting SLSA build provenance per artifact (the repo is public, so this runs unconditionally); - publishes the GitHub Release with the version’s CHANGELOG section and all
five archives (asset names must stay in sync with the
binstallmetadata in theproefpackage manifest); - regenerates
Formula/proef.rbin theemrecdr/homebrew-proeftap (deploy-key auth via theHOMEBREW_TAP_DEPLOY_KEYrepo secret) — only when the tag is newer than the version the tap already carries. That step is gated on nothing but “a tag was pushed” and rewrites the formula whole, so a tag pushed late or out of order would downgrade everybrew upgrade; it now skips green instead, leaving the tap alone while the release still publishes. The formula installs the binary, its man page and the bash/zsh/fish completions; from 0.16.0 until 0.18.0 it installed only the binary, so Homebrew users silently got neither the man page nor completion while every other channel did. The render step now checks the archive for each file the formula claims to install, because nothing else connects the two.
A tag runs the workflow as it existed at the tagged commit, not as it exists
on main — the ordinary push-event rule, and the one that decides what
backfilling a missing tag actually does. Patching release.yml therefore
protects future tags only: a tag cut on a commit older than a fix runs the
pipeline without it. This is not theoretical here. Backfilling v0.15.0
(commit dated 2026-08-25) would have run that commit’s unguarded tap job and
walked the published formula from 0.17.0 back to 0.15.0, defeating the
forward-only guard added in 0.18 — which lives on later commits and could not
apply. The backfill was done with gh workflow disable release.yml around the
push for exactly that reason, then the Release created by hand. Disable the
workflow before pushing any tag whose commit predates a release-pipeline fix.
workflow_dispatch runs build+attest only — a full matrix smoke without
publishing. crates.io publication remains a deliberate manual cargo publish
per crate in dependency order (core → engine-hurl → lsp → proef — proef-lsp
before proef, which depends on it non-optionally) and is not automated.
Manual because it is the one step nothing undoes: a published version can be yanked, never replaced or re-uploaded. So publish from the tag, not from a working tree that merely resembles it:
git describe --tags --exact-match HEAD # must print vX.Y.Z
git status --short # must be empty
cargo publish -p proef-core --dry-run --locked
cargo publish -p proef-core --locked
cargo publish -p proef-engine-hurl --locked
cargo publish -p proef-lsp --locked
cargo publish -p proef --locked
--locked throughout, so what ships is what the committed lockfile resolves.
Only these four go: [workspace.package] publish = false is the default and each
publishable crate overrides it, so proef-fixture, proef-harness and xtask
are excluded by construction rather than by remembering to skip them. Each
command waits for the registry before returning, which is what makes the next
one resolvable.
The registry does not carry every tag. 0.15.0, 0.16.0 and 0.17.0 were tagged and
released on GitHub but never published, so crates.io goes 0.14.0 → 0.18.0
(published 2026-09-09, from the tag, all four crates). Cargo resolves version
requirements rather than sequences, so the gap costs a consumer nothing — it is
recorded here so that a reader comparing git tag against the registry does not
read it as a failed upload.
History
v0.1.0— initial release (fresh history baseline, 2026-07-29)v0.2.0— deep-review correctness blockers (duplicate-request, body corruption, delay budget), panic containment, the output contract, and the author guides — breaking:--output jsonstream split, empty selections exit 2, event schema grew additivelyv0.2.1— review P0 (header grammar, pipe, filters, name dedup, secrets perms) + failure UX (hurl expected/actual, true error-line anchoring)v0.3.0— data-safety blockers (asset copy, run rotation, zero-entry false green), Then-step visibility with exact attribution, UserInput taxonomy (user mistakes exit 2), option caps + repeat budget, atomic locked stores — breaking: proef-core API pruned,when:skips on literal false, zero-entry packs fail validationv0.3.1— secret hardening:secret rm,PROEF_KEYCI override, the saveAs-vs-secret promotion guard, doctor store/key health, corrupt-store recovery, warned-step reasons on the consolev0.4.0— external config & environments (proef.toml[url]/[vars]/[env.<name>],${url:}/${vars:},--env/PROEF_ENV, ADR-0012), default suite path, and the competitive-review breadth pass — breaking: the pack root keytemplates:becamemacros:with no alias (ADR-0004 amendment)v0.5.0— theproef-lsplanguage server: diagnostics, completion, go-to-definition and references over the sans-IO core (ADR-0017)v0.5.1— LSP correctness: process-leak, malformed-request crash, broken-pack degradation, root-at-suite, overlay keying;use:/match:go-to-definitionv0.5.2— CLI correctness: diff step-collision, truncated-run gate, setup double-run, the first EPIPE guard, overflow hardening, bare-filename path resolution, exit-130 documentationv0.5.3— closed-pipe safety: every remaining raweprintln!inproef-clirouted through the EPIPE-safe guard (with a source-scanning drift test), andproef-lsp’s panic-recovery notice no longer kills the server it just rescuedv0.6.0— first-run UX & run-record correctness:proef init, a did-you-mean for unset config variables, a next-command nudge; onerun_started/run_finishedpair per record with suite-only totals, truncated-record banners inreport/explain, a real worker slot index — breaking: a scenario with no steps is now an errorv0.7.0— record & artifact integrity:run_finishedis the record’s last line again (a watchdog-abandoned scenario no longer appends past it),${fake:…}values no longer repeat across a scenario’s steps,.map.jsonstops listing captures that were never made and stops dropping real ones, and a whitespace-onlyexpect:is rejected instead of emitting an inverted span — breaking:proef_core::resolve::resolvetakes a caller-owned occurrence counter andResolution::fakesis gonev0.8.0— CLI output & exit integrity: an unreadablePROEF_KEY/PROEF_ENV/PROEF_SECRET_<NAME>is a loud user error instead of reading as unset, therun.logtee no longer duplicates bytes on a short write,proef fmtkeeps a file’s own line endings,report -owrites artifact links that resolve, anddiffstops inventing flakiness for a step with no baseline — breaking: a failed stdout write now exits 3 where it exited 0, and a malformed environment variable exits 2 where it was silently ignoredv0.9.0— tool-surface integrity & authoring guidance: values interpolated into LSP snippets, GitHub annotations and job-summary tables are escaped,proef fmtrefuses a file that is not a pack and stops trimming the YAML skeleton,--sarifcarriesstartLineso annotations land,--watchretriggers onproef.toml,--dry-run’s nudge echoes the run that was actually validated, a templatedretry:stops under-counting the batch budget,--output jsonreports the real exit, a truncated record counts its warned scenarios, a failing run says when the scaffold’s routes are still placeholders,macrosprints the sentence an author needs,proef lspadopts the client’s workspace root, and AUTHORING documents docstring placeholders and the validation-catalogue pattern — breaking:proef secret set --valuewas removed in favour of--stdin(a secret in argv is visible tops), andproef macros --output json’spatternfield changed from a boolean tostring|nullv0.10.0— named hurl fragments (ADR-0018): a step mayref:one# @proef <name>entry of a real.hurlfile, values supplied bybind:at pack/macro/step scope, so the same bytes run under stockhurland under proef;[run] fragmentsnames the scanned root, aref:step records the fragment it ran asfile.hurl#nameeverywhere a failure is reported, and the editor completesbind:keys and jumps fromref:to the annotation — breaking:pack::loadtakes a&FragmentCorpus,PackSet::fragmentsis anArc,LoweredScenario::secretsis a map, andLoweredStep/StepOutcome/Event::StepFinishedcarryfragmentv0.11.0— the adoption response: ADR-0007’s value caps reach fragment text (byte-identical[Options]exited 2 inline and 0 behind aref:, then ran),proef fragmentslists the corpus and names both ways a fragment dies with a--checkgate, abind:key nothing reads is refused with did-you-mean,doctorreports the corpus,initscaffolds both body forms,--confignames theproef.tomlto read, and[run] exclusive-tagsruns a scenario with the pool to itself — breaking:FragmentScannerreturnsScannedFile,AnalyzeCtxtakes the corpus rather than building one per call,StepKindSpeccarries anoptionsrecogniser, andScenarioSpeccarriesexclusivev0.11.1— the gaps 0.11.0 shipped with:--configreachesproef lspand--watch(it was honoured by the runner alone, so the editor reported everyref:as unknown in exactly the layout the flag exists for),proef fragmentscounts[run] setup/teardownusage instead of calling a phase-only fragment unreachable and failing--check, one predicate answers “is this a fragment file?” where three disagreed, and--junit/--sarif/report -ocreate the directories their paths name — asartifacts -oand the run directory already did, and as pytest, jest-junit, cargo-nextest and the embedded hurl all dov0.12.0— one path rule, and a watcher that stops lying: a path written inproef.tomlresolves against the config, a path typed on the command line against the working directory, with no exceptions — which finally inventoried.proef-state.json,.proef-secrets.jsonand the run records, all three cwd-anchored and unlisted.--watchrereads the config it retriggers on; stops feeding itself whenruns-dirchanges mid-loop (one edit produced 39 runs in 12 seconds against a live API); and matches a relatively-typed or symlinked--config, which also restoredproef lsp --configgo-to-definition across the fragment corpus.doctorfails aproef.tomlthat will not parse instead of reporting on invented defaults,--configis honoured or refused by every subcommand, and[run] exclusive-tagsvalidates itself in both paths — breaking: the secret store, the World and the run records move with the config rather than the shell, which reaches anyone who ran proef from a subdirectoryv0.13.0— a record that travels, and a secret that stays one: nothing proef records names the machine that produced it (one naming boundary, the dual of the path rule — breaking: artifact bytes change for path-less runs), and a secret reflected base64/hex/percent/JSON-escape-encoded is redacted like its raw form (live leak reproduced, then closed; ADR-0005 amended). The fragment corpus read is bounded (601 MB → 15 MB on the measured pathological input), fuzzing actually reaches the fragment rules (probe-verified),[run] keep-runsmakes retention expressible,difftakes a record path for the CI-baseline flow, the bundled libcurl gets a CVE floor no advisory scanner would catch, and a hung test is a five-minute failure instead of a five-day zombiev0.14.0— proef at CI scale:--max-fail Nstops a run honestly (the never-run tail records as skipped, the record is a cancelled rundiffrefuses to certify),--reruncontinues a cancelled run instead of a false green,proef flakyfolds the retained history into verdicts (flapping by transition-count, passes-only-on-retry, broken-not-flaky) completing the detect→quarantine→resolve loop the@quarantinetag already anchored, and--shard I/Npartitions a matrix by a frozen identity hash so adding a scenario never re-buckets the others — plus the reverse docs gate: every subcommand must be documented, enforced rather than noticedv0.15.0— validation rounds 17–18 + the Robot Framework capability audit:@skip/@skip:reasonand@quarantinevisible in every sink (ADR-0019), tag globs + per-tag report verdicts +[tag-links], explicit run metadata (--meta/[meta], ADR-0020), the rerun overlay (one JUnit and one report covering the whole suite),--console dotted|quiet,--shuffleseeded by the run id,reproduce_hintinto the record — breaking: quarantined failures reach JUnit as skipped-with-message,--shardre-deals (the hash gained fmix64), tag atoms glob, JUnit identity isclassname+name. Its tag was missing for two weeks (found 2026-09-09): the release commit landed 2026-08-25 butv0.15.0was never pushed, andrelease.ymlstarts on the tag alone — so the pipeline never ran and 0.15.0 had no GitHub Release, binaries or attestations. Backfilled 2026-09-09 with the workflow disabled for the push: the tag now points at the release commit and the Release carries the changelog section, marked not-latest, with no archives — the only release without them. Not repaired by simply pushing the tag; the runbook above says which workflow a tag actually runsv0.16.0— the surfaces tell the truth: an eight-wave improvement programme (#112–#142) plus the round that found what it missed (#143–#150). CI-sink conformance (JUnit detail into element content, an XML-1.0 control-character boundary, real limits on the GitHub summary and annotations), a triageable and linkable HTML report, console colour, shell completions and a man page in every archive, a project-awaredoctor,explain/diff/doctor --format json,--console failed,flaky --by,proef schema config, and the LSP wave — document symbols, hover, quick-fix code actions off a structuredDiag::fix, one analysis per edit rather than per keystroke, and a panic guard on both message-loop entry points. Pack validation became linear in the macro count (65× at 3200 macros) and the last unfuzzed parser gained a target — breaking:--outputsplit by meaning into--format(which format) and-o/--output(which path),World::set_globalreturns a#[must_use] bool,ConsoleReporter::newtakes acolorflagv0.17.0— the environment a suite runs in, and the guards that keep its claims true:[http]gained the keys that describe an environment rather than a request (TLSinsecure, proxy, mTLS cert/key,max-redirs,user-agent,cookie-store = false),--ctrfrenders the run off the same fold as JUnit,--shard-weightsbalances a matrix by measured duration from one sharedtimings.json, and the HTML report answers “what is slowest”. The hurl-coverage audit closed the two defects aref:fragment could not work around (#164–#166): afile,…;body resolves beside the file that wrote the reference, and two features’ same-named assets stop overwriting each other. A--run-idrecord is findable again (ADR-0021), a disk filling mid-run reaches the exit code, and the 23 diagnostic codes that had no test got one — breaking:emit::file_referencesbecameArtifact::assetscarrying each reference with the source that wrote it,emit::asset_rootis new,HttpDefaultsgained eight fields and lostCopy, and the canonical artifact format moved (an artifact that reads a file now names its--file-rootin the replay line)v0.18.0— the CI-consumer surfaces, run to exhaustion (#168–#179): output proef could not deliver never looks like success. A run-record write failure latches into exit 3 through one fold (escalate_environment_failures, beside the JUnit/CTRF and GitHub-summary failures), SIGTERM/SIGHUP take the graceful cancel so a CI job timeout leaves a complete record and its reports, and a custom--run-idno longer collapses the JUnit identity onto the nil uuid. Asset staging resolves beside the file the parser read wherever youcdfrom, with--sariflines from the carried source and the symlink and case-insensitive staging edges closed. The ADR-0007 budget family is closed over its inputs and bounded as a product (a four-hour batch ceiling,[http] timeout-ms = 0refused), everyRunSummarysink routes identities through the masker, andproef flakygained the 2026 statistical guards — a sample floor, hysteresis, an environment-outage guard, and an input-fingerprint equivalence class — breaking:proef_lsp::RootResolverreturns aResolvedRoot,timings::rendertakes a&Redactions, andflaky’snewverdict is renamedinsufficient-datawith its default sample floor rising from 2 to 10v0.19.0— the checks that could not see what they claimed to cover (#186–#191). The Homebrew formula installs the man page and the shell completions again — broken since 0.16.0 because the formula is a heredoc inrelease.ymland the archive is staged in another job, so nothing tied the two together; the render step now fails if the archive lacks a file the formula installs, and this tag is the first to carry it.--dry-runrefuses afile,…;asset that is not there, through staging’s own checker rather than a second walk, so the gate CI runs before standing an environment up is no longer blind to a defect that is entirely static. The asset scan moved behind the engine seam: it was a scan for the literal"file,"inproef-corethat the ADR-0002 guard structurally could not classify — so it was never sanctioned and never reported missing, and that ADR’s “thirteen literals” measurement is corrected to fourteen — while reading hurl’s own AST also stopsfile,inside a JSON body counting as an asset. A fragment’s assets stage from where its file was read rather than from where its recorded name points, the fragment-side twin of whatLoadedFeature::read_fromalready does.--rerunreads its base record once instead of twice, and the one doc check that only reads files moved into the half of the gate that only reads files — breaking:proef_core::emit::emittakes the registered step kinds andStepKindSpecgains anassetshook, replacingemit::file_refs_in