use std::path::PathBuf;
use anyhow::Result;
use clap::Args;
use tonin_plugin::lock::{EnvLock, GitLockStore, LockStore, ServiceLock};
#[derive(Args)]
pub struct ReleaseArgs {
#[arg(long)]
pub service: String,
#[arg(long)]
pub version: String,
#[arg(long, required_unless_present = "digest")]
pub image: Option<String>,
#[arg(long)]
pub git_sha: Option<String>,
#[arg(long, required_unless_present = "image")]
pub digest: Option<String>,
#[arg(long, default_value = "staging")]
pub env: String,
#[arg(long)]
pub workspace: Option<PathBuf>,
}
pub fn run(args: ReleaseArgs) -> Result<()> {
let digest_val = args.digest.as_deref().unwrap_or("");
let full_digest = if digest_val.is_empty() {
"".to_string()
} else if digest_val.starts_with("sha256:") {
digest_val.to_string()
} else {
format!("sha256:{}", digest_val)
};
let image_ref = match &args.image {
Some(img) => {
anyhow::ensure!(
img.contains("@sha256:"),
"image must be pinned by digest (contain '@sha256:'), got: {}",
img
);
img.clone()
}
None => {
anyhow::ensure!(
!full_digest.is_empty(),
"either --image or --digest must be provided"
);
format!("ghcr.io/agnitiv/{}@{}", args.service, full_digest)
}
};
let git_sha_ref = args.git_sha.clone().unwrap_or_else(|| "HEAD".to_string());
let root = args
.workspace
.unwrap_or_else(|| std::env::current_dir().unwrap());
let store = GitLockStore::new(&root);
let mut lock = store.read(&args.env).unwrap_or_else(|_| EnvLock {
schema: "v1".into(),
env: args.env.clone(),
services: Default::default(),
});
let prev = lock.services.get(&args.service).map(|s| s.version.clone());
lock.services.insert(
args.service.clone(),
ServiceLock {
version: args.version.clone(),
image: image_ref,
git_sha: git_sha_ref,
},
);
store.write(&lock)?;
match prev {
Some(v) => println!(
"✓ environments/{}/lock.toml: {} {} → {}",
args.env, args.service, v, args.version
),
None => println!(
"✓ environments/{}/lock.toml: {} (new) → {}",
args.env, args.service, args.version
),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_creates_lock_entry_with_digest() {
let tmp = tempfile::tempdir().unwrap();
let store = GitLockStore::new(tmp.path());
run(ReleaseArgs {
service: "agnitiv-api".into(),
version: "0.4.0".into(),
image: None,
git_sha: None,
digest: Some("abc123456789".into()),
env: "staging".into(),
workspace: Some(tmp.path().to_path_buf()),
})
.unwrap();
let lock = store.read("staging").unwrap();
assert_eq!(lock.services["agnitiv-api"].version, "0.4.0");
assert_eq!(
lock.services["agnitiv-api"].image,
"ghcr.io/agnitiv/agnitiv-api@sha256:abc123456789"
);
assert_eq!(lock.services["agnitiv-api"].git_sha, "HEAD");
}
#[test]
fn release_rejects_mutable_tag() {
let tmp = tempfile::tempdir().unwrap();
let result = run(ReleaseArgs {
service: "agnitiv-api".into(),
version: "0.4.0".into(),
image: Some("ghcr.io/agnitiv/agnitiv-api:0.4.0".into()),
git_sha: Some("deadbeef".into()),
digest: None,
env: "staging".into(),
workspace: Some(tmp.path().to_path_buf()),
});
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("digest"));
}
}