diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8026503..cc2af1b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,7 +74,9 @@ One command, run from a clean `main`: cargo release "$(git cliff --unreleased --bumped-version | sed 's/^v//')" --execute ``` -`git cliff` reads the conventional commits since the last tag and prints the version they imply; `cargo release` writes it into `Cargo.toml` and `Cargo.lock`, makes a `chore(release):` commit, and creates an annotated tag. It asks for confirmation first and shows the version it computed — read that line before answering. Nothing is pushed and nothing is published; those are separate, deliberate acts. The rules live in `cliff.toml` and `release.toml`, both of which explain themselves. +`git cliff` reads the conventional commits since the last tag and prints the version they imply; `cargo release` writes it into `Cargo.toml` and `Cargo.lock`, rewrites the "current release" line under the README's lockup, makes a `chore(release):` commit, and creates an annotated tag. It asks for confirmation first and shows the version it computed — read that line before answering. Nothing is pushed and nothing is published; those are separate, deliberate acts. The rules live in `cliff.toml` and `release.toml`, both of which explain themselves. + +The README line is generated, so do not hand-edit it: it lives between `` markers and is replaced wholesale on each bump. Losing a marker fails the bump rather than skipping the line, and `tests/release_metadata.rs` fails the commit if the line and `Cargo.toml` ever disagree. While the project is pre-1.0: `fix` and `docs` bump the patch, `feat` bumps the minor, and a breaking `!` **also** bumps the minor rather than jumping to 1.0.0. That last one overrides a git-cliff default that would have gone straight to 1.0.0, and it is set explicitly in `cliff.toml` — 1.0.0 is a compatibility promise this project has not made, and it should be a version somebody types on purpose. diff --git a/README.md b/README.md index 978ed70..8c20221 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ ![atgc](brand/png/lockup-512.png) +*Current release: [v0.15.0](https://tangled.org/permadeath.com/atgc/tags/v0.15.0)* + **AT**proto **G**it **C**lient. ## Features diff --git a/TODO.md b/TODO.md index 9c0d9c7..159a012 100644 --- a/TODO.md +++ b/TODO.md @@ -760,6 +760,10 @@ warn where the stack command is better. ## release (artifacts on tags) - [ ] `release create` / `list` / `view` — sh.tangled.repo.artifact - [ ] `release upload` / `download` / `delete` +- [ ] Point the README's current-release line at the binary once there is + one. The line already exists and is rewritten on every bump; it says + only the tag today because a tag is all there is to say. Widening it + is a one-line edit to `pre-release-replacements` in release.toml ## key - [x] `key add` / `list` / `delete` — sh.tangled.publicKey records, which are what a knot authorizes a push against. `add` defaults to the one @@ -955,6 +959,15 @@ warn where the stack command is better. the minor while pre-1.0, overriding git-cliff's jump to 1.0.0. No longer unexercised: v0.2.0, v0.2.1 and v0.3.0 were all cut this way, through scripts/release.sh +- [x] The bump also writes which release is current into the README, on an + italic line under the lockup linking the tag on Tangled. A + `pre-release-replacements` rule in release.toml rewrites the block + between two HTML comment markers, so it lands in the `chore(release):` + commit rather than trailing it; `tests/release_metadata.rs` fails if + that line and Cargo.toml ever disagree. The markers, rather than a + match on the prose, because the line is meant to grow — see the + release-artifacts entry above, which is what it should point at once + there is a built binary to point at - [x] Push the `v0.1.0` epoch tag. Annotated, on 6d16818 — the last commit before Conventional Commits start — so the first bump counts every conforming commit. It is on origin, and the bumps since have counted diff --git a/release.toml b/release.toml index 915c76e..5851a49 100644 --- a/release.toml +++ b/release.toml @@ -43,3 +43,37 @@ tag-message = "atgc {{tag_name}}" # a single-crate repo, so off is also simply the truthful setting. pre-release-commit-message = "chore(release): atgc v{{version}}" consolidate-commits = false + +# The README says which release is current, and this is what writes it. The +# line is rewritten before the version commit is made, so it lands in that +# commit alongside Cargo.toml and Cargo.lock rather than trailing a release +# by however long it takes someone to notice. +# +# The line is found by its opening prose and not by hidden `` markers +# around it. Markers would let the wording drift freely, but they only stay +# invisible if the renderer drops comments, and this README is read through +# Tangled's sanitizer as well as any other — a renderer that escaped them +# instead would print the scaffolding at the top of the front page. The +# tradeoff bought by not finding out: "*Current release: " is now load-bearing +# text. Reword it here and in tests/release_metadata.rs together. +# +# `(?m)` so `^` and `$` bind to the line rather than the file. The match stops +# at the newline, which is why the replacement is a single line — it is one +# line by design, and widening it later (a built binary alongside the tag) is +# an edit to this string, not to the shape of the rule. +# +# `exactly = 1` is the check that matters. Without it a reword that got out of +# step would leave the README pinned at whatever version it last said, and a +# release that quietly does not update the thing it exists to update is worse +# than one that fails; with it, cargo-release stops with "N replacements +# expected, found M" before anything is committed. +# +# The repository URL is spelled out rather than taken from {{repository}}. +# Only {{version}} is documented for this step, and an unrecognized +# placeholder is not an error — it is copied through as literal text, which +# would put a dead link in the README and tag it as current. The test in +# tests/release_metadata.rs is the backstop for that whole class of failure: +# it reads this line back and fails if it disagrees with Cargo.toml. +pre-release-replacements = [ + { file = "README.md", exactly = 1, search = "(?m)^\\*Current release: .*$", replace = "*Current release: [v{{version}}](https://tangled.org/permadeath.com/atgc/tags/v{{version}})*" }, +] diff --git a/tests/release_metadata.rs b/tests/release_metadata.rs new file mode 100644 index 0000000..5a833eb --- /dev/null +++ b/tests/release_metadata.rs @@ -0,0 +1,95 @@ +//! The README's "current release" line agrees with the version it claims. +//! +//! Nobody types that line. `cargo release` rewrites it as part of a bump, +//! from the `pre-release-replacements` rule in release.toml, and commits it +//! alongside Cargo.toml. So the two move together or the release is wrong, +//! and this is where "or the release is wrong" gets noticed. +//! +//! It runs on every commit rather than only at release time, because the +//! failure it guards against is silent: a README pinned at an old version +//! reads exactly like a correct one. The check is cheap and the release step +//! is rare, so paying for it continuously is the right trade. + +/// The prefix release.toml's regex anchors on. Spelled here as a plain +/// literal so that a reword which updates only one of the two files fails +/// this test rather than passing quietly and skipping the line at the next +/// bump. Keep the two in step. +const PREFIX: &str = "*Current release: "; + +/// The line as published. +/// +/// Panics rather than returning an error: a README with no release line is +/// the same failure as a README with a stale one, and the test wants both to +/// read as a failure of that file, not of the parsing. +fn release_line() -> &'static str { + let readme = include_str!("../README.md"); + let mut hits = readme.lines().filter(|l| l.starts_with(PREFIX)); + let line = hits + .next() + .expect("README has no line starting with the release prefix release.toml rewrites"); + assert!( + hits.next().is_none(), + "README has more than one release line; release.toml's `exactly = 1` would refuse the bump" + ); + line +} + +/// The version in the README is the version in Cargo.toml. This is the whole +/// point of the file: a bump that updated one and not the other is a release +/// that lies about which release it is. +#[test] +fn readme_names_the_crate_version() { + let line = release_line(); + let tag = format!("v{}", env!("CARGO_PKG_VERSION")); + assert!( + line.contains(&tag), + "README's release line says {line:?}, which does not mention {tag}" + ); +} + +/// A placeholder cargo-release does not recognize is copied through as +/// literal text instead of failing, so `{{repository}}` in a replacement +/// would publish a link to a host called `{{repository}}`. Nothing else on +/// the line should contain braces, which makes this a cheap total check. +#[test] +fn readme_release_line_has_no_unrendered_placeholders() { + let line = release_line(); + assert!( + !line.contains("{{"), + "README's release line has an unrendered template placeholder: {line:?}" + ); +} + +/// Italics, and only italics. The line is a caption on the lockup above it, +/// not the first sentence of the README; the opening `*` comes with the +/// prefix, so what is worth asserting is that the replacement still closes +/// the emphasis and has not been promoted to bold. +#[test] +fn readme_release_line_is_italic() { + let line = release_line(); + assert!( + line.ends_with('*') && !line.starts_with("**"), + "README's release line is not italic: {line:?}" + ); +} + +/// Underneath the lockup, which is the whole reason it reads as a caption. +/// The image is the first line of the README and the release line is the +/// next non-blank one; anything in between has pushed the version further +/// from the mark it belongs to. +#[test] +fn readme_release_line_sits_under_the_lockup() { + let readme = include_str!("../README.md"); + let mut lines = readme.lines().filter(|l| !l.trim().is_empty()); + let first = lines.next().unwrap_or_default(); + assert!( + first.starts_with("![atgc](brand/"), + "README no longer opens with the lockup, it opens with {first:?}" + ); + let second = lines.next().unwrap_or_default(); + assert_eq!( + second, + release_line(), + "the release line is no longer the first thing under the lockup" + ); +}