ADR 0014: Release and contract versioning
Status: accepted, 2026-10-07
Context
Keep Shipping has three contracts that people other than us bind to. The first is the ship.ks file format: a workflow someone wrote last month has to load in a binary released this week. The second is the StepKind trait plus the port traits a third-party step or adapter is written against (ADR 0002, ADR 0100) — a step compiled against one harness must refuse to run against a harness whose ports moved. The third is the TypeScript script-host protocol: an escape hatch step written in TypeScript talks to the host over a JSON contract (ADR 0013). None of the three was versioned as a public surface, so nothing told a step author which one they had written against and nothing stopped us changing one silently.
Cratefield — the harness Keep Shipping is modelled on (ADR 0200) — versions its module contract as a constant and generates a compatibility table from it. That is the model here.
STEP_API was not the gap: it already exists at crates/core/src/step.rs, and HarnessBuilder::build already refuses a step whose step_api() differs. What was missing is the other two constants, a written record of which port trait version is which, and any enforcement that a change to a port trait comes with a STEP_API bump. The pull-request template and CONTRIBUTING.md have both asked for "port contract changes have a changelog entry" since early on, with nothing behind the ask: the changelog had nowhere to live and no test would fail if it were skipped.
The release side has the same shape. There is no tag-triggered build, so "install Keep Shipping" means "build it from a commit", and there is no artifact whose origin a user can check.
Decision
ks-coreexports three constants fromcrates/core/src/version.rs:FORMAT_VERSION(theship.ksformat),STEP_API(theStepKindand port contract) andSCRIPT_PROTOCOL(the TypeScript script-host protocol).STEP_APImoves out ofcrates/core/src/step.rsbut is still re-exported there, soks_core::step::STEP_APIkeeps working.A step runs only under a harness speaking the same
STEP_API;HarnessBuilder::buildalready refuses a mismatch. Additive changes bump the minor, breaking changes bump the major.The port contract is fingerprinted from the
pub traitdeclarations incrates/core/src/ports/, and the fingerprint is pinned next to theSTEP_APIit belongs to incrates/testing/tests/compatibility.rs. Changing a port trait without bumpingSTEP_APIfails CI. Reformatting and comment edits are normalised away before hashing, so only signature changes trip it.docs/COMPATIBILITY.mdis generated from the workspace and drift-checked. It lists the three contracts and the port-contract digest, and names the command that regenerates it.docs/PORTS.mdcarries the hand-written port changelog: one## STEP_API <n>heading per contract version.A tag matching
v*triggers a release:keepshippingbuilt for macOS arm64 and x86_64 and Linux x86_64 and arm64, one archive per target plus a single SHA-256 checksum file, the checksums signed with cosign keyless, and a GitHub Release whose body GitHub's own release-notes generator writes from the conventional commits. Windows comes later; its SBOM and signing story is #116.Crates are not published to crates.io.
publish = falseis already set workspace-wide in the rootCargo.tomland stays set until the licensing decision lands (ADR-0006 — not written yet; the number is reserved, so cite it as prose, not as a link). Releases are binaries. The issue left this open; this is the answer.release-plz is adopted for the changelog, and deliberately not wired to
cargo publish. Its action is pinned atrelease-plz/action@v0.5.139; the action publishesv0.5.xtags only, with nov0floating tag, so there is nov1-style major tag to pin the wayactions/checkout@v4is. Whilepublish = falsestands, every package is excluded from release andrelease-plz releasefinds nothing to release: the step is present and pinned, and currently inert.
Consequences
A step author pins
ks-coreand checks the table indocs/COMPATIBILITY.md. Upgradingks-coreacross aSTEP_APIbump is a deliberate act: the harness refuses the step otherwise, rather than failing later in a way that is harder to read.Breaking a port trait now costs a
STEP_APIbump, a## STEP_API <n>entry indocs/PORTS.md, a regenerateddocs/COMPATIBILITY.mdand an updated pin in the compatibility test — four edits in one PR, which is the point.The fingerprint has honest limits. It is a source digest of the
pub traitdeclarations incrates/core/src/ports/, normalised to one block per trait, so it catches more than a changed signature: a changed default-method body is a changed line inside the block, and so is a renamed trait, a removed trait, a trait's visibility, or a deleted file (its blocks stop entering the hash). What it cannot see is a behaviour change that touches neither a signature nor a port module at all — a step's own logic, a non-port helper. And a new port wired without touching an existing trait is caught only because its file moved the digest, not because the fingerprint understands what it is looking at. ThePortenum anddefine_ports!order check covers that case; the digest is the backstop, not the argument. Zero extracted blocks is an error rather than the hash of the empty string, so a broken extractor cannot pin a plausible-looking constant.The release pipeline is the one workflow with
contents: write; every other gate iscontents: read. The signing job additionally needsid-token: write, which is what actually letscosign sign-blobmint the OIDC identity a keyless signature is built on; the permission is granted on that job alone. Without it the release fails at the sign step and nothing is published.A user verifies a release by checking
SHA256SUMSagainst the cosign signature, not by trusting the download host.Windows is still missing from the matrix; #116 tracks it.