Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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):

SurfaceBreaking examplesNon-breaking examples
CLI + exit codes (ADR-0009)removing/renaming a flag; changing an exit-code meaningnew flag; new subcommand
Pack schema (ADR-0004)removing a key; changing key semanticsnew optional key
Event wire schema (ADR-0008)removing/renaming a field or variant; changing schema semanticsnew 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 shapesnew defaulted trait method
Config fileremoving/renaming a proef.toml keynew 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 schema field in run_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.1 point release, ~3-4 weeks after x.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

Keep a Changelog 1.1.0:

  • ## [Unreleased] always exists at the top; every landed change adds a line there in the same commit series that lands it.
  • On release, Unreleased content moves under ## [X.Y.Z] - YYYY-MM-DD (with a short parenthetical theme) and a fresh empty Unreleased is left behind.

Release runbook

From a clean, green main (all gates local + CI).

main is protected — the release commit goes through a pull request, and the tag is pushed only after it merges. Do not git push origin main, and do not tag before the merge. git push --follow-tags is not atomic: git pushes refs independently, so a protected-branch rejection stops the branch while the tag still lands — and a tag is exactly what release.yml triggers on. That combination starts a release build from a commit that is not on main. 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:

  1. 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);
  2. publishes the GitHub Release with the version’s CHANGELOG section and all five archives (asset names must stay in sync with the binstall metadata in the proef package manifest);
  3. regenerates Formula/proef.rb in the emrecdr/homebrew-proef tap (deploy-key auth via the HOMEBREW_TAP_DEPLOY_KEY repo 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 every brew 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 json stream split, empty selections exit 2, event schema grew additively
  • v0.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 validation
  • v0.3.1 — secret hardening: secret rm, PROEF_KEY CI override, the saveAs-vs-secret promotion guard, doctor store/key health, corrupt-store recovery, warned-step reasons on the console
  • v0.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 key templates: became macros: with no alias (ADR-0004 amendment)
  • v0.5.0 — the proef-lsp language 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-definition
  • v0.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 documentation
  • v0.5.3 — closed-pipe safety: every remaining raw eprintln! in proef-cli routed through the EPIPE-safe guard (with a source-scanning drift test), and proef-lsp’s panic-recovery notice no longer kills the server it just rescued
  • v0.6.0 — first-run UX & run-record correctness: proef init, a did-you-mean for unset config variables, a next-command nudge; one run_started/run_finished pair per record with suite-only totals, truncated-record banners in report/explain, a real worker slot index — breaking: a scenario with no steps is now an error
  • v0.7.0 — record & artifact integrity: run_finished is 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.json stops listing captures that were never made and stops dropping real ones, and a whitespace-only expect: is rejected instead of emitting an inverted span — breaking: proef_core::resolve::resolve takes a caller-owned occurrence counter and Resolution::fakes is gone
  • v0.8.0 — CLI output & exit integrity: an unreadable PROEF_KEY/PROEF_ENV/ PROEF_SECRET_<NAME> is a loud user error instead of reading as unset, the run.log tee no longer duplicates bytes on a short write, proef fmt keeps a file’s own line endings, report -o writes artifact links that resolve, and diff stops 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 ignored
  • v0.9.0 — tool-surface integrity & authoring guidance: values interpolated into LSP snippets, GitHub annotations and job-summary tables are escaped, proef fmt refuses a file that is not a pack and stops trimming the YAML skeleton, --sarif carries startLine so annotations land, --watch retriggers on proef.toml, --dry-run’s nudge echoes the run that was actually validated, a templated retry: stops under-counting the batch budget, --output json reports the real exit, a truncated record counts its warned scenarios, a failing run says when the scaffold’s routes are still placeholders, macros prints the sentence an author needs, proef lsp adopts the client’s workspace root, and AUTHORING documents docstring placeholders and the validation-catalogue pattern — breaking: proef secret set --value was removed in favour of --stdin (a secret in argv is visible to ps), and proef macros --output json’s pattern field changed from a boolean to string|null
  • v0.10.0 — named hurl fragments (ADR-0018): a step may ref: one # @proef <name> entry of a real .hurl file, values supplied by bind: at pack/macro/step scope, so the same bytes run under stock hurl and under proef; [run] fragments names the scanned root, a ref: step records the fragment it ran as file.hurl#name everywhere a failure is reported, and the editor completes bind: keys and jumps from ref: to the annotation — breaking: pack::load takes a &FragmentCorpus, PackSet::fragments is an Arc, LoweredScenario::secrets is a map, and LoweredStep/StepOutcome/ Event::StepFinished carry fragment
  • v0.11.0 — the adoption response: ADR-0007’s value caps reach fragment text (byte-identical [Options] exited 2 inline and 0 behind a ref:, then ran), proef fragments lists the corpus and names both ways a fragment dies with a --check gate, a bind: key nothing reads is refused with did-you-mean, doctor reports the corpus, init scaffolds both body forms, --config names the proef.toml to read, and [run] exclusive-tags runs a scenario with the pool to itself — breaking: FragmentScanner returns ScannedFile, AnalyzeCtx takes the corpus rather than building one per call, StepKindSpec carries an options recogniser, and ScenarioSpec carries exclusive
  • v0.11.1 — the gaps 0.11.0 shipped with: --config reaches proef lsp and --watch (it was honoured by the runner alone, so the editor reported every ref: as unknown in exactly the layout the flag exists for), proef fragments counts [run] setup/teardown usage 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 -o create the directories their paths name — as artifacts -o and the run directory already did, and as pytest, jest-junit, cargo-nextest and the embedded hurl all do
  • v0.12.0 — one path rule, and a watcher that stops lying: a path written in proef.toml resolves 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.json and the run records, all three cwd-anchored and unlisted. --watch rereads the config it retriggers on; stops feeding itself when runs-dir changes mid-loop (one edit produced 39 runs in 12 seconds against a live API); and matches a relatively-typed or symlinked --config, which also restored proef lsp --config go-to-definition across the fragment corpus. doctor fails a proef.toml that will not parse instead of reporting on invented defaults, --config is honoured or refused by every subcommand, and [run] exclusive-tags validates 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 subdirectory
  • v0.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-runs makes retention expressible, diff takes 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 zombie
  • v0.14.0 — proef at CI scale: --max-fail N stops a run honestly (the never-run tail records as skipped, the record is a cancelled run diff refuses to certify), --rerun continues a cancelled run instead of a false green, proef flaky folds the retained history into verdicts (flapping by transition-count, passes-only-on-retry, broken-not-flaky) completing the detect→quarantine→resolve loop the @quarantine tag already anchored, and --shard I/N partitions 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 noticed
  • v0.15.0 — validation rounds 17–18 + the Robot Framework capability audit: @skip/@skip:reason and @quarantine visible 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, --shuffle seeded by the run id, reproduce_hint into the record — breaking: quarantined failures reach JUnit as skipped-with-message, --shard re-deals (the hash gained fmix64), tag atoms glob, JUnit identity is classname+name. Its tag was missing for two weeks (found 2026-09-09): the release commit landed 2026-08-25 but v0.15.0 was never pushed, and release.yml starts 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 runs
  • v0.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-aware doctor, 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 structured Diag::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: --output split by meaning into --format (which format) and -o/--output (which path), World::set_global returns a #[must_use] bool, ConsoleReporter::new takes a color flag
  • v0.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 (TLS insecure, proxy, mTLS cert/key, max-redirs, user-agent, cookie-store = false), --ctrf renders the run off the same fold as JUnit, --shard-weights balances a matrix by measured duration from one shared timings.json, and the HTML report answers “what is slowest”. The hurl-coverage audit closed the two defects a ref: fragment could not work around (#164–#166): a file,…; body resolves beside the file that wrote the reference, and two features’ same-named assets stop overwriting each other. A --run-id record 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_references became Artifact::assets carrying each reference with the source that wrote it, emit::asset_root is new, HttpDefaults gained eight fields and lost Copy, and the canonical artifact format moved (an artifact that reads a file now names its --file-root in 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-id no longer collapses the JUnit identity onto the nil uuid. Asset staging resolves beside the file the parser read wherever you cd from, with --sarif lines 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 = 0 refused), every RunSummary sink routes identities through the masker, and proef flaky gained the 2026 statistical guards — a sample floor, hysteresis, an environment-outage guard, and an input-fingerprint equivalence class — breaking: proef_lsp::RootResolver returns a ResolvedRoot, timings::render takes a &Redactions, and flaky’s new verdict is renamed insufficient-data with its default sample floor rising from 2 to 10
  • v0.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 in release.yml and 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-run refuses a file,…; 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," in proef-core that 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 stops file, 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 what LoadedFeature::read_from already does. --rerun reads 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::emit takes the registered step kinds and StepKindSpec gains an assets hook, replacing emit::file_refs_in