ref:924321a3015ee4cf7d2491354ee3c054cd499409

fix(ci): address review issues on CalVer release publishing

- Check ANVIL_TOKEN secret upfront before the 10-min build, not after - Use bash trap to roll back the release if any asset upload fails, preventing orphaned empty/partial releases - release.sh now fails loudly when 'anvil release list' errors instead of silently defaulting to YYYY.MM.1 and colliding with existing tags - release.sh checks for jq and reorders setup so failures surface cleanly - Generate release body from git log since the previous CalVer tag, falling back to last 20 commits on first release - Promote --format from a free-form String to clap ValueEnum so typos like '--format JSON' get rejected at parse time instead of silently printing a table Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SHA: 924321a3015ee4cf7d2491354ee3c054cd499409
Author: Cole Christensen <cole.christensen@macmillan.com>
Date: 2026-04-10 15:25
Parents: fedf3c8
3 files changed +74 -21
Type
.anvil.yml +42 −10
@@ -60,7 +60,20 @@
- name: build-runner
timeout_seconds: 1800
run: |
set -e
set -euo pipefail
# Fail fast if we're about to publish a release but the secret is missing —
# don't waste 10+ minutes on a build that will error at the end.
PUBLISH=0
if [ "${ANVIL_BRANCH:-}" = "main" ] || [ "${ANVIL_BRANCH:-}" = "refs/heads/main" ]; then
if [ -z "${ANVIL_TOKEN:-}" ]; then
echo "ERROR: ANVIL_TOKEN secret not set. Run:" >&2
echo " anvil ci set-secret --name ANVIL_TOKEN --value <pat> --repo fangorn/anvil-cli" >&2
exit 1
fi
PUBLISH=1
fi
apt-get update && apt-get install -y gcc-x86-64-linux-gnu jq 2>&1
# Build arm64 natively (CI runner is aarch64)
@@ -78,26 +91,44 @@
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.
if [ "$PUBLISH" = "1" ]; then
# 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)
if [ -z "$VERSION" ]; then
echo "ERROR: ci/release.sh returned empty version" >&2
exit 1
fi
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}"
# Generate changelog body from commits since the previous CalVer tag.
PREV_TAG=$("$ANVIL_CLI" release list --format json fangorn/anvil-cli \
| 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')
if [ -n "$PREV_TAG" ] && git rev-parse --verify "$PREV_TAG" >/dev/null 2>&1; then
CHANGELOG=$(git log --oneline "${PREV_TAG}..HEAD" || echo "(no commits since $PREV_TAG)")
else
CHANGELOG=$(git log --oneline -n 20)
fi
BODY=$(printf 'Runner binaries for linux/amd64 and linux/arm64.\n\n## Changes\n\n%s\n' "$CHANGELOG")
"$ANVIL_CLI" release create \
--tag "$VERSION" \
--title "anvil-cli $VERSION" \
--body "Runner binaries for linux/amd64 and linux/arm64." \
--body "$BODY" \
--repo fangorn/anvil-cli
# Roll back the release if any subsequent step fails — don't leave
# orphaned empty/partial releases lying around.
cleanup_release() {
echo "==> Publish failed — rolling back release $VERSION" >&2
"$ANVIL_CLI" release delete "$VERSION" --repo fangorn/anvil-cli >&2 || true
}
trap cleanup_release ERR
"$ANVIL_CLI" release upload "$VERSION" \
"runner-dist/anvil_runner_linux_arm64_${VERSION}" \
--repo fangorn/anvil-cli
@@ -105,6 +136,7 @@
"runner-dist/anvil_runner_linux_amd64_${VERSION}" \
--repo fangorn/anvil-cli
trap - ERR
echo "==> Published release $VERSION"
fi
depends_on: [test, clippy, fmt]
ci/release.sh +19 −4
@@ -4,15 +4,30 @@
# 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.
# Fails loudly on command errors — only defaults to YYYY.MM.1 when the
# release list is genuinely empty.
command -v jq >/dev/null 2>&1 || { echo "error: jq is required" >&2; exit 1; }
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.
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
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)
exit 1
fi
LATEST=$(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
' < "$TMP")
if [ -z "$LATEST" ]; then
echo "${YEAR_MONTH}.1"
src/commands/release.rs +13 −7
@@ -1,10 +1,16 @@
use crate::client::Client;
use crate::config;
use crate::output;
use clap::{Args, Subcommand};
use clap::{Args, Subcommand, ValueEnum};
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Copy, Clone, Debug, ValueEnum)]
pub enum OutputFormat {
Table,
Json,
}
#[derive(Args)]
pub struct ReleaseArgs {
#[command(subcommand)]
@@ -17,9 +23,9 @@
List {
/// Repository (org/repo)
repo: Option<String>,
/// Output format: "table" (default) or "json"
#[arg(long, default_value = "table")]
/// Output format
#[arg(long, value_enum, default_value_t = OutputFormat::Table)]
format: OutputFormat,
format: String,
},
/// View a release
View {
@@ -184,7 +190,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, format } => list(repo.as_deref(), format).await,
ReleaseCommand::View { tag, repo } => view(repo.as_deref(), &tag).await,
ReleaseCommand::Create {
repo,
@@ -248,6 +254,6 @@
}
}
async fn list(repo: Option<&str>, format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
async fn list(repo: Option<&str>, format: &str) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -260,7 +266,7 @@
.cloned()
.unwrap_or(resp);
if format == "json" {
if matches!(format, OutputFormat::Json) {
println!("{}", serde_json::to_string(&releases_val)?);
return Ok(());
}