ref:fedf3c89c3ab18bab5ec8bed1a95723bd60b5ba7

feat(ci): publish versioned releases with CalVer tags

- Config reads ANVIL_SERVER_URL and ANVIL_TOKEN from env (for CI) - release list gets --format json for script consumption - ci/release.sh computes next CalVer version (YYYY.MM.BUILD) - build-runner step publishes release on main with versioned assets Asset naming: anvil_runner_linux_{amd64,arm64}_YYYY.MM.BUILD CI artifacts keep unversioned names (per-run already). Requires ANVIL_TOKEN CI secret: anvil ci set-secret ANVIL_TOKEN <pat> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SHA: fedf3c89c3ab18bab5ec8bed1a95723bd60b5ba7
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-04-10 05:37
Parents: 98c330d
4 files changed +98 -15
Type
.anvil.yml +32 −2
@@ -61,7 +61,7 @@
timeout_seconds: 1800
run: |
set -e
apt-get update && apt-get install -y gcc-x86-64-linux-gnu 2>&1
apt-get update && apt-get install -y gcc-x86-64-linux-gnu jq 2>&1
# Build arm64 natively (CI runner is aarch64)
echo "==> Building arm64 binary (native)..."
@@ -73,10 +73,40 @@
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc \
cargo build --release --target x86_64-unknown-linux-gnu 2>&1
# Stage for CI artifact upload (unversioned names)
# Stage for artifact upload
mkdir -p runner-dist
cp target/release/anvil runner-dist/anvil_runner_linux_arm64
cp target/x86_64-unknown-linux-gnu/release/anvil runner-dist/anvil_runner_linux_amd64
# On main branch: compute CalVer version, publish release, upload assets.
# ANVIL_SERVER_URL is injected by CI. ANVIL_TOKEN must be set as a CI secret.
if [ "$ANVIL_BRANCH" = "main" ] || [ "$ANVIL_BRANCH" = "refs/heads/main" ]; then
if [ -z "${ANVIL_TOKEN:-}" ]; then
echo "ERROR: ANVIL_TOKEN secret not set. Run: anvil ci set-secret ANVIL_TOKEN <pat>"
exit 1
fi
export ANVIL_CLI="$PWD/target/release/anvil"
VERSION=$(bash ci/release.sh)
echo "==> Publishing release $VERSION"
cp target/release/anvil "runner-dist/anvil_runner_linux_arm64_${VERSION}"
cp target/x86_64-unknown-linux-gnu/release/anvil "runner-dist/anvil_runner_linux_amd64_${VERSION}"
"$ANVIL_CLI" release create \
--tag "$VERSION" \
--title "anvil-cli $VERSION" \
--body "Runner binaries for linux/amd64 and linux/arm64." \
--repo fangorn/anvil-cli
"$ANVIL_CLI" release upload "$VERSION" \
"runner-dist/anvil_runner_linux_arm64_${VERSION}" \
--repo fangorn/anvil-cli
"$ANVIL_CLI" release upload "$VERSION" \
"runner-dist/anvil_runner_linux_amd64_${VERSION}" \
--repo fangorn/anvil-cli
echo "==> Published release $VERSION"
fi
depends_on: [test, clippy, fmt]
cache:
key: Cargo.lock
ci/release.sh +29 −0
@@ -1,0 +1,29 @@
#!/usr/bin/env bash
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.
# Defaults to YYYY.MM.1 if no prior release exists.
ANVIL="${ANVIL_CLI:-anvil}"
REPO="${ANVIL_REPO:-fangorn/anvil-cli}"
YEAR_MONTH=$(date +"%Y.%m")
LATEST=$($ANVIL release list --format json "$REPO" 2>/dev/null \
| jq -r '[.[] | select(.tag_name | test("^[0-9]{4}\\.[0-9]{2}\\.[0-9]+$"))] | sort_by(.tag_name | split(".") | map(tonumber)) | .[-1].tag_name // empty' \
|| true)
if [ -z "$LATEST" ]; then
echo "${YEAR_MONTH}.1"
exit 0
fi
LATEST_YM=$(echo "$LATEST" | cut -d. -f1-2)
LATEST_BUILD=$(echo "$LATEST" | cut -d. -f3)
if [ "$LATEST_YM" = "$YEAR_MONTH" ]; then
echo "${YEAR_MONTH}.$((LATEST_BUILD + 1))"
else
echo "${YEAR_MONTH}.1"
fi
src/commands/release.rs +17 −9
@@ -17,6 +17,9 @@
List {
/// Repository (org/repo)
repo: Option<String>,
/// Output format: "table" (default) or "json"
#[arg(long, default_value = "table")]
format: String,
},
/// View a release
View {
@@ -181,7 +184,7 @@
pub async fn run(args: ReleaseArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
ReleaseCommand::List { repo, format } => list(repo.as_deref(), &format).await,
ReleaseCommand::List { repo } => list(repo.as_deref()).await,
ReleaseCommand::View { tag, repo } => view(repo.as_deref(), &tag).await,
ReleaseCommand::Create {
repo,
@@ -245,18 +248,23 @@
}
}
async fn list(repo: Option<&str>, format: &str) -> Result<(), Box<dyn std::error::Error>> {
async fn list(repo: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/releases")).await?;
let releases: Vec<Release> = if let Some(arr) = resp.get("releases") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
};
let releases_val = resp
.get("releases")
.or_else(|| resp.get("data"))
.cloned()
.unwrap_or(resp);
if format == "json" {
println!("{}", serde_json::to_string(&releases_val)?);
return Ok(());
}
let releases: Vec<Release> = serde_json::from_value(releases_val).unwrap_or_default();
output::header(&format!("Releases ({org}/{name})"));
src/config.rs +20 −4
@@ -30,11 +30,27 @@
pub fn load() -> Result<Self, ConfigError> {
let path = Self::path();
if !path.exists() {
return Ok(Self::default());
let mut cfg: Self = if path.exists() {
let contents = std::fs::read_to_string(&path)?;
serde_json::from_str(&contents)?
} else {
Self::default()
};
// Env vars take precedence over the config file so CI can inject
// credentials without writing to disk.
if let Ok(url) = std::env::var("ANVIL_SERVER_URL") {
if !url.is_empty() {
cfg.server_url = Some(url);
}
}
let contents = std::fs::read_to_string(&path)?;
Ok(serde_json::from_str(&contents)?)
if let Ok(token) = std::env::var("ANVIL_TOKEN") {
if !token.is_empty() {
cfg.token = Some(token);
}
}
Ok(cfg)
}
pub fn save(&self) -> Result<(), ConfigError> {