ref:45992034a3b55690328f40d455f072e0357dcbf7

feat(ci): bake CalVer version into released binaries (#4)

## Summary - Add `build.rs` that runs `git describe --tags --always` and sets `ANVIL_VERSION` at compile time - Clap reads `ANVIL_VERSION` for `--version` output instead of `CARGO_PKG_VERSION` - CI bootstrap-builds once (to get the anvil binary for release.sh), computes the CalVer version, creates a local git tag, then rebuilds so the version is baked into the released binary - Dev builds report git SHA (`anvil 9ff9d8e`), release builds report CalVer (`anvil 2026.04.2`) ## How it works The local tag only exists inside the CI container — it never gets pushed. The server-side tag is created by `anvil release create --tag $VERSION`. No commits to main, no branch protection issues, no CI loops. Refs #1 ## Test plan - [x] Local build without tags → reports git SHA - [x] Local build with tag → reports tag - [x] fmt, clippy, tests pass - [ ] CI publishes release with version-stamped binary
SHA: 45992034a3b55690328f40d455f072e0357dcbf7
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-04-12 17:27
Parents: 9ff9d8e
5 files changed +56 -18
Type
.anvil.yml +1 −1
@@ -62,7 +62,7 @@
# Delegated to a bash script because the runner invokes /bin/sh (dash)
# and we need bash features (pipefail, trap ERR) for publish rollback.
run: |
apt-get update && apt-get install -y bash gcc-x86-64-linux-gnu jq 2>&1
apt-get update && apt-get install -y bash curl gcc-x86-64-linux-gnu jq 2>&1
exec bash ci/build-runner.sh
depends_on: [test, clippy, fmt]
cache:
build.rs +26 −0
@@ -1,0 +1,26 @@
use std::process::Command;
fn main() {
// Watch the ref that HEAD points to (detects new commits on current branch),
// HEAD itself (detects branch switches / detached checkout),
// and the tags directory (detects new tags created before a rebuild).
if let Ok(head) = std::fs::read_to_string(".git/HEAD") {
if let Some(ref_path) = head.trim().strip_prefix("ref: ") {
println!("cargo:rerun-if-changed=.git/{ref_path}");
}
}
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs/tags");
let version = Command::new("git")
.args(["describe", "--tags", "--always"])
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string());
let out = std::env::var("OUT_DIR").unwrap();
std::fs::write(format!("{out}/version.txt"), &version).unwrap();
}
ci/build-runner.sh +14 −6
@@ -18,6 +18,19 @@
PUBLISH=1
fi
# Compute the CalVer version and tag HEAD locally so that build.rs can
# read it via 'git describe --tags'. The tag is local-only — it never
# gets pushed. The server-side tag is created by 'anvil release create'.
if [ "$PUBLISH" = "1" ]; then
VERSION=$(bash ci/release.sh)
if [ -z "$VERSION" ]; then
echo "ERROR: ci/release.sh returned empty version" >&2
exit 1
fi
echo "==> Tagging HEAD as $VERSION (local only)"
git tag -f "$VERSION"
fi
# Build arm64 natively (CI runner is aarch64)
echo "==> Building arm64 binary (native)..."
cargo build --release 2>&1
@@ -37,12 +50,7 @@
exit 0
fi
export ANVIL_CLI="$PWD/target/release/anvil"
VERSION=$(bash ci/release.sh)
if [ -z "$VERSION" ]; then
echo "ERROR: ci/release.sh returned empty version" >&2
exit 1
fi
ANVIL_CLI="$PWD/target/release/anvil"
echo "==> Publishing release $VERSION"
cp target/release/anvil "runner-dist/anvil_runner_linux_arm64_${VERSION}"
ci/release.sh +14 −10
@@ -2,29 +2,33 @@
set -euo pipefail
# Compute next CalVer version: YYYY.MM.BUILD
# Reads latest Anvil release tag, increments build number.
# Resets build to 1 on new month.
# Fails loudly on command errors — only defaults to YYYY.MM.1 when the
# release list is genuinely empty.
# Queries the Anvil release API via curl (no anvil binary needed).
# Increments build number within the current month, resets on new month.
command -v jq >/dev/null 2>&1 || { echo "error: jq is required" >&2; exit 1; }
command -v curl >/dev/null 2>&1 || { echo "error: curl is required" >&2; exit 1; }
ANVIL_SERVER="${ANVIL_SERVER_URL:-https://anvil.fangorn.io}"
ANVIL="${ANVIL_CLI:-anvil}"
REPO="${ANVIL_REPO:-fangorn/anvil-cli}"
YEAR_MONTH=$(date +"%Y.%m")
# Capture list output and exit code separately so a real failure is not
# silently swallowed.
if [ -z "${ANVIL_TOKEN:-}" ]; then
echo "error: ANVIL_TOKEN is required" >&2
exit 1
fi
TMP=$(mktemp)
trap 'rm -f "$TMP"' EXIT
if ! "$ANVIL" release list --format json "$REPO" > "$TMP" 2>/dev/null; then
echo "error: 'anvil release list' failed" >&2
if ! curl -sf -H "Authorization: Bearer $ANVIL_TOKEN" \
"${ANVIL_SERVER}/api/v1/${REPO}/releases" > "$TMP"; then
echo "error: release API request failed" >&2
exit 1
fi
LATEST=$(jq -r '
[.[] | select(.tag_name | test("^[0-9]{4}\\.[0-9]{2}\\.[0-9]+$"))]
.releases
| [.[] | select(.tag_name | test("^[0-9]{4}\\.[0-9]{2}\\.[0-9]+$"))]
| sort_by(.tag_name | split(".") | map(tonumber))
| .[-1].tag_name // empty
' < "$TMP")
src/commands/mod.rs +1 −1
@@ -21,7 +21,7 @@
#[command(
name = "anvil",
about = "CLI for Anvil — a self-hosted code forge",
version
version = include_str!(concat!(env!("OUT_DIR"), "/version.txt"))
)]
pub struct Cli {
#[command(subcommand)]