use std::path::Path;
use anodizer_core::redact::redact_bearer_tokens;
use anodizer_core::retry::{RetryPolicy, SuccessClass, retry_http_async};
use anodizer_core::url::percent_encode_path_segment as encode_segment;
use anyhow::{Context as _, Result, bail};
use reqwest::Client;
use crate::release_body::compose_body_for_mode;
#[derive(Clone, Copy)]
pub(crate) struct GiteaCtx<'a> {
pub client: &'a Client,
pub api_url: &'a str,
pub owner: &'a str,
pub repo: &'a str,
pub policy: &'a RetryPolicy,
}
#[derive(Clone, Copy)]
pub(crate) struct GiteaReleaseSpec<'a> {
pub tag: &'a str,
pub commit: &'a str,
pub name: &'a str,
pub body: &'a str,
pub draft: bool,
pub prerelease: bool,
pub release_mode: &'a str,
}
#[derive(Clone, Copy)]
pub(crate) struct GiteaAssetSpec<'a> {
pub file_path: &'a Path,
pub file_name: &'a str,
}
pub(crate) fn gitea_release_url(download_url: &str, owner: &str, repo: &str, tag: &str) -> String {
let base = download_url.trim_end_matches('/');
format!(
"{}/{}/{}/releases/tag/{}",
base,
encode_segment(owner),
encode_segment(repo),
encode_segment(tag)
)
}
pub(crate) fn build_gitea_client(token: &str, skip_tls_verify: bool) -> Result<Client> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&format!("token {}", token))
.context("gitea: invalid token value for Authorization header")?,
);
let builder = Client::builder()
.default_headers(headers)
.danger_accept_invalid_certs(skip_tls_verify)
.timeout(std::time::Duration::from_secs(300));
builder.build().context("gitea: build HTTP client")
}
pub(crate) async fn gitea_create_release(
ctx: &GiteaCtx<'_>,
spec: &GiteaReleaseSpec<'_>,
) -> Result<u64> {
let GiteaCtx {
client,
api_url,
owner,
repo,
policy,
} = *ctx;
let GiteaReleaseSpec {
tag,
commit,
name,
body,
draft,
prerelease,
release_mode,
} = *spec;
let api = api_url.trim_end_matches('/');
let enc_owner = encode_segment(owner);
let enc_repo = encode_segment(repo);
if tag.is_empty() {
anyhow::bail!(
"gitea: release for {}/{} is missing required tag_name. Gitea \
POST /repos/{{owner}}/{{repo}}/releases rejects empty `tag_name`. \
Verify the release tag template renders to a non-empty value \
(e.g. `{{{{ Tag }}}}` is unset during `--snapshot`) or set an \
explicit `release.tag:` override.",
owner,
repo
);
}
if commit.is_empty() {
anyhow::bail!(
"gitea: release for {}/{} (tag '{}') is missing required \
target_commitish (commit SHA). Gitea creates the tag at this \
SHA when it doesn't already exist; empty values are rejected. \
This means the git stage did not populate `ctx.git_info.commit` \
— re-run `task release` from inside the git working tree so \
git porcelain can resolve HEAD, or supply the SHA via the \
upstream pipeline.",
owner,
repo,
tag
);
}
let existing = find_release_by_tag(client, api, &enc_owner, &enc_repo, tag, policy).await?;
if let Some((release_id, existing_body)) = existing {
let final_body = compose_body_for_mode(release_mode, existing_body.as_deref(), body);
let update_url = format!(
"{}/api/v1/repos/{}/{}/releases/{}",
api, enc_owner, enc_repo, release_id
);
let payload = serde_json::json!({
"tag_name": tag,
"target_commitish": commit,
"name": name,
"body": final_body,
"draft": draft,
"prerelease": prerelease,
});
retry_http_async(
"gitea: PATCH update release",
policy,
SuccessClass::Strict,
|_| client.patch(&update_url).json(&payload).send(),
|status, body| {
format!(
"gitea: update release failed (HTTP {status}): {}",
redact_bearer_tokens(body)
)
},
)
.await?;
Ok(release_id)
} else {
let create_url = format!("{}/api/v1/repos/{}/{}/releases", api, enc_owner, enc_repo);
let payload = serde_json::json!({
"tag_name": tag,
"target_commitish": commit,
"name": name,
"body": body,
"draft": draft,
"prerelease": prerelease,
});
let resp = retry_http_async(
"gitea: POST create release",
policy,
SuccessClass::Strict,
|_| client.post(&create_url).json(&payload).send(),
|status, body| {
format!(
"gitea: create release failed (HTTP {status}): {}",
redact_bearer_tokens(body)
)
},
)
.await?;
let json: serde_json::Value = resp
.json()
.await
.context("gitea: parse create release response JSON")?;
let release_id = json["id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("gitea: create release response missing 'id' field"))?;
Ok(release_id)
}
}
async fn find_release_by_tag(
client: &Client,
api: &str,
enc_owner: &str,
enc_repo: &str,
tag: &str,
policy: &RetryPolicy,
) -> Result<Option<(u64, Option<String>)>> {
const MAX_PAGES: u32 = 10;
const PAGE_SIZE: u32 = 50;
for page in 1..=MAX_PAGES {
let url = format!(
"{}/api/v1/repos/{}/{}/releases?page={}&limit={}",
api, enc_owner, enc_repo, page, PAGE_SIZE
);
let resp = retry_http_async(
&format!("gitea: GET releases page {page}"),
policy,
SuccessClass::Strict,
|_| client.get(&url).send(),
|status, body| {
format!(
"gitea: list releases failed (HTTP {status}): {}",
redact_bearer_tokens(body)
)
},
)
.await?;
let releases: Vec<serde_json::Value> = resp
.json()
.await
.context("gitea: parse releases list JSON")?;
for release in &releases {
if release["tag_name"].as_str() == Some(tag) {
let id = release["id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("gitea: release missing 'id' field"))?;
let body = release["body"].as_str().map(|s| s.to_string());
return Ok(Some((id, body)));
}
}
if releases.len() < PAGE_SIZE as usize {
break;
}
}
Ok(None)
}
pub(crate) async fn gitea_upload_asset(
ctx: &GiteaCtx<'_>,
release_id: u64,
asset: &GiteaAssetSpec<'_>,
) -> Result<()> {
let GiteaCtx {
client,
api_url,
owner,
repo,
policy,
} = *ctx;
let GiteaAssetSpec {
file_path,
file_name,
} = *asset;
let api = api_url.trim_end_matches('/');
let enc_owner = encode_segment(owner);
let enc_repo = encode_segment(repo);
let enc_filename = encode_segment(file_name);
let upload_url = format!(
"{}/api/v1/repos/{}/{}/releases/{}/assets?name={}",
api, enc_owner, enc_repo, release_id, enc_filename
);
let data = tokio::fs::read(file_path)
.await
.with_context(|| format!("gitea: read file {}", file_path.display()))?;
retry_http_async(
"gitea: POST upload asset",
policy,
SuccessClass::Strict,
|_| {
let file_part = match reqwest::multipart::Part::bytes(data.clone())
.file_name(file_name.to_string())
.mime_str("application/octet-stream")
{
Ok(p) => p,
Err(_) => unreachable!("application/octet-stream is a valid MIME type"),
};
let form = reqwest::multipart::Form::new().part("attachment", file_part);
client.post(&upload_url).multipart(form).send()
},
|status, body| {
format!(
"gitea: upload asset '{}' to release {} failed (HTTP {status}): {}",
file_name,
release_id,
redact_bearer_tokens(body)
)
},
)
.await?;
Ok(())
}
pub(crate) async fn gitea_delete_asset_by_name(
ctx: &GiteaCtx<'_>,
release_id: u64,
file_name: &str,
) -> Result<bool> {
let GiteaCtx {
client,
api_url,
owner,
repo,
policy,
} = *ctx;
let api = api_url.trim_end_matches('/');
let enc_owner = encode_segment(owner);
let enc_repo = encode_segment(repo);
let list_url = format!(
"{}/api/v1/repos/{}/{}/releases/{}/assets",
api, enc_owner, enc_repo, release_id
);
let resp = retry_http_async(
"gitea: GET release assets",
policy,
SuccessClass::Strict,
|_| client.get(&list_url).send(),
|status, body| {
format!(
"gitea: list release assets failed (HTTP {status}): {}",
redact_bearer_tokens(body)
)
},
)
.await?;
let assets: Vec<serde_json::Value> = resp
.json()
.await
.context("gitea: parse release assets JSON")?;
for asset in &assets {
if asset["name"].as_str() == Some(file_name) {
let asset_id = asset["id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("gitea: asset missing 'id' field"))?;
let delete_url = format!(
"{}/api/v1/repos/{}/{}/releases/{}/assets/{}",
api, enc_owner, enc_repo, release_id, asset_id
);
retry_http_async(
"gitea: DELETE asset",
policy,
SuccessClass::Strict,
|_| client.delete(&delete_url).send(),
|status, body| {
format!(
"gitea: delete asset '{}' (id={}) from release {} failed (HTTP {status}): {}",
file_name,
asset_id,
release_id,
redact_bearer_tokens(body)
)
},
)
.await?;
return Ok(true);
}
}
Ok(false)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GiteaUploadAction {
SkipIdempotent,
DeleteThenUpload,
Upload,
}
pub(crate) fn gitea_upload_action(
replace_existing_artifacts: bool,
remote_size: Option<u64>,
local_size: u64,
) -> GiteaUploadAction {
match crate::classify_asset_conflict(
replace_existing_artifacts,
remote_size.is_some(),
remote_size,
local_size,
) {
crate::AssetConflict::IdenticalSkip => GiteaUploadAction::SkipIdempotent,
crate::AssetConflict::ReplaceDiffering => GiteaUploadAction::DeleteThenUpload,
crate::AssetConflict::ConflictForbidden | crate::AssetConflict::NoConflict => {
GiteaUploadAction::Upload
}
}
}
pub(crate) async fn gitea_find_asset_size(
ctx: &GiteaCtx<'_>,
release_id: u64,
file_name: &str,
) -> Result<Option<u64>> {
let GiteaCtx {
client,
api_url,
owner,
repo,
policy,
} = *ctx;
let api = api_url.trim_end_matches('/');
let enc_owner = encode_segment(owner);
let enc_repo = encode_segment(repo);
let list_url = format!(
"{}/api/v1/repos/{}/{}/releases/{}/assets",
api, enc_owner, enc_repo, release_id
);
let resp = retry_http_async(
"gitea: GET release assets (size probe)",
policy,
SuccessClass::Strict,
|_| client.get(&list_url).send(),
|status, body| {
format!(
"gitea: list release assets failed (HTTP {status}): {}",
redact_bearer_tokens(body)
)
},
)
.await?;
let assets: Vec<serde_json::Value> = resp
.json()
.await
.context("gitea: parse release assets JSON")?;
for asset in &assets {
if asset["name"].as_str() == Some(file_name) {
return Ok(asset["size"].as_u64());
}
}
Ok(None)
}
pub(crate) struct GiteaBackendEnv<'a> {
pub rt: &'a tokio::runtime::Runtime,
pub ctx: &'a anodizer_core::context::Context,
pub log: &'a anodizer_core::log::StageLogger,
pub token: &'a Option<String>,
}
#[derive(Clone, Copy)]
pub(crate) struct GiteaBackendSpec<'a> {
pub tag: &'a str,
pub release_name: &'a str,
pub release_body: &'a str,
pub release_mode: &'a str,
pub draft: bool,
pub prerelease: bool,
pub skip_upload: bool,
pub replace_existing_draft: bool,
pub use_existing_draft: bool,
pub replace_existing_artifacts: bool,
}
pub(crate) fn run_gitea_backend(
env: &GiteaBackendEnv<'_>,
crate_cfg: &anodizer_core::config::CrateConfig,
release_cfg: &anodizer_core::config::ReleaseConfig,
spec: &GiteaBackendSpec<'_>,
artifact_entries: &[(std::path::PathBuf, Option<String>)],
) -> Result<Option<(String, String, String, String)>> {
use std::sync::Arc;
let GiteaBackendEnv {
rt,
ctx,
log,
token,
} = env;
let ctx = *ctx;
let log = *log;
let token = *token;
let repo_cfg = match crate::resolve_release_repo(release_cfg, ctx.token_type, ctx)? {
Some(r) => r,
None => {
log.warn(&format!(
"skipped release for crate '{}' — no gitea config",
crate_cfg.name
));
return Ok(None);
}
};
let token_str = match token {
Some(t) => t.clone(),
None => {
bail!("release: no Gitea token available (set GITEA_TOKEN, or pass --token)");
}
};
let gitea_urls = ctx.config.gitea_urls.clone().unwrap_or_default();
let api_url = gitea_urls
.api
.unwrap_or_else(|| "https://gitea.com/api/v1".to_string());
let download_url = gitea_urls
.download
.unwrap_or_else(|| "https://gitea.com".to_string());
let skip_tls = gitea_urls.skip_tls_verify.unwrap_or(false);
let commit_sha = ctx
.git_info
.as_ref()
.map(|g| g.commit.clone())
.unwrap_or_default();
if spec.replace_existing_draft {
log.warn("replace_existing_draft has no effect on Gitea (draft support is limited)");
}
if spec.use_existing_draft {
log.warn("use_existing_draft has no effect on Gitea (draft support is limited)");
}
let policy = ctx.retry_policy();
let tag = spec.tag;
let release_name = spec.release_name;
let release_body = spec.release_body;
let release_mode = spec.release_mode;
let skip_upload = spec.skip_upload;
let replace_existing_artifacts = spec.replace_existing_artifacts;
let draft = spec.draft;
let prerelease = spec.prerelease;
let url = rt.block_on(async {
let client = build_gitea_client(&token_str, skip_tls)?;
let gitea_ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: &repo_cfg.owner,
repo: &repo_cfg.name,
policy: &policy,
};
let release_id = gitea_create_release(
&gitea_ctx,
&GiteaReleaseSpec {
tag,
commit: &commit_sha,
name: release_name,
body: release_body,
draft,
prerelease,
release_mode,
},
)
.await?;
log.status(&format!(
"created Gitea Release '{}' (id={}, tag={}) on {}/{}",
release_name, release_id, tag, repo_cfg.owner, repo_cfg.name
));
if skip_upload {
log.status("skipped artifact uploads — skip_upload is set");
} else {
let upload_parallelism = std::cmp::max(ctx.options.parallelism, 1);
let semaphore = Arc::new(tokio::sync::Semaphore::new(upload_parallelism));
let mut missing_files = Vec::new();
let prepared_entries: Vec<(std::path::PathBuf, String)> = artifact_entries
.iter()
.filter_map(|(path, custom_name)| {
if !path.exists() {
missing_files.push(path.display().to_string());
return None;
}
let file_name = if let Some(name) = custom_name {
name.clone()
} else {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "artifact".to_string())
};
Some((path.clone(), file_name))
})
.collect();
if !missing_files.is_empty() {
anyhow::bail!(
"the following artifact files are missing:\n {}",
missing_files.join("\n ")
);
}
let client = Arc::new(client);
let mut join_set = tokio::task::JoinSet::new();
for (path, file_name) in prepared_entries {
let sem = semaphore.clone();
let client = client.clone();
let api_url = api_url.clone();
let owner = repo_cfg.owner.clone();
let repo = repo_cfg.name.clone();
let tag_owned = tag.to_string();
let policy_inner = policy;
join_set.spawn(async move {
let _permit = sem
.acquire()
.await
.map_err(|e| anyhow::anyhow!("semaphore closed: {}", e))?;
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: &owner,
repo: &repo,
policy: &policy_inner,
};
let local_size = tokio::fs::metadata(&path)
.await
.with_context(|| {
format!(
"gitea: stat local artifact '{}' for size comparison",
file_name
)
})?
.len();
let remote_size = gitea_find_asset_size(&ctx, release_id, &file_name).await?;
match gitea_upload_action(replace_existing_artifacts, remote_size, local_size) {
GiteaUploadAction::SkipIdempotent => {
return Ok::<String, anyhow::Error>(file_name);
}
GiteaUploadAction::DeleteThenUpload => {
gitea_delete_asset_by_name(&ctx, release_id, &file_name)
.await
.with_context(|| {
format!(
"gitea: delete existing asset '{}' from release {}",
file_name, release_id
)
})?;
}
GiteaUploadAction::Upload => {}
}
let op_name = format!("gitea: upload '{}'", file_name);
let asset = GiteaAssetSpec {
file_path: &path,
file_name: &file_name,
};
crate::retry_upload(&op_name, || gitea_upload_asset(&ctx, release_id, &asset))
.await
.with_context(|| {
format!(
"release: upload artifact '{}' to Gitea release '{}'",
file_name, tag_owned
)
})?;
Ok::<String, anyhow::Error>(file_name)
});
}
while let Some(result) = join_set.join_next().await {
let file_name = result
.context("gitea: upload task panicked")?
.context("gitea: upload task failed")?;
log.verbose(&format!("uploaded artifact {}", file_name));
}
}
let html_url = gitea_release_url(&download_url, &repo_cfg.owner, &repo_cfg.name, tag);
Ok::<String, anyhow::Error>(html_url)
})?;
Ok(Some((
url,
download_url,
repo_cfg.owner.clone(),
repo_cfg.name.clone(),
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_url_basic() {
let url = gitea_release_url("https://gitea.example.com", "myorg", "myapp", "v1.0.0");
assert_eq!(
url,
"https://gitea.example.com/myorg/myapp/releases/tag/v1.0.0"
);
}
#[test]
fn release_url_trailing_slash_stripped() {
let url = gitea_release_url("https://gitea.example.com/", "org", "repo", "v2.0.0");
assert_eq!(
url,
"https://gitea.example.com/org/repo/releases/tag/v2.0.0"
);
}
#[test]
fn release_url_special_chars_in_tag() {
let url = gitea_release_url(
"https://gitea.example.com",
"myorg",
"myapp",
"v1.0.0+build.1",
);
assert_eq!(
url,
"https://gitea.example.com/myorg/myapp/releases/tag/v1.0.0%2Bbuild.1"
);
}
#[test]
fn release_url_special_chars_in_owner_and_repo() {
let url = gitea_release_url("https://gitea.example.com", "my org", "my repo", "v1.0.0");
assert!(url.contains("my%20org"), "owner should be percent-encoded");
assert!(url.contains("my%20repo"), "repo should be percent-encoded");
}
#[test]
fn encode_segment_simple() {
assert_eq!(encode_segment("v1.0.0"), "v1.0.0");
}
#[test]
fn encode_segment_with_plus() {
assert_eq!(encode_segment("v1.0.0+build.1"), "v1.0.0%2Bbuild.1");
}
#[test]
fn encode_segment_with_special_chars() {
assert_eq!(encode_segment("v1 beta#2?rc"), "v1%20beta%232%3Frc");
}
#[test]
fn encode_segment_preserves_dots_dashes_underscores() {
assert_eq!(encode_segment("my-project_v2.0"), "my-project_v2.0");
}
#[test]
fn upload_action_same_size_skips_regardless_of_flag() {
assert_eq!(
gitea_upload_action(false, Some(100), 100),
GiteaUploadAction::SkipIdempotent,
);
assert_eq!(
gitea_upload_action(true, Some(100), 100),
GiteaUploadAction::SkipIdempotent,
);
}
#[test]
fn upload_action_diff_size_with_flag_deletes() {
assert_eq!(
gitea_upload_action(true, Some(50), 100),
GiteaUploadAction::DeleteThenUpload,
);
}
#[test]
fn upload_action_diff_size_without_flag_uploads() {
assert_eq!(
gitea_upload_action(false, Some(50), 100),
GiteaUploadAction::Upload,
);
}
#[test]
fn upload_action_absent_remote_uploads_without_delete() {
assert_eq!(
gitea_upload_action(false, None, 100),
GiteaUploadAction::Upload,
);
assert_eq!(
gitea_upload_action(true, None, 100),
GiteaUploadAction::Upload,
);
}
#[test]
fn build_client_normal() {
let client = build_gitea_client("giteatok-xxxx", false);
assert!(client.is_ok());
}
#[test]
fn build_client_skip_tls() {
let client = build_gitea_client("giteatok-xxxx", true);
assert!(client.is_ok());
}
#[test]
fn gitea_auth_header_format() {
assert!(build_gitea_client("my-gitea-token", false).is_ok());
let err = build_gitea_client("bad\ntoken", false).unwrap_err();
assert!(
format!("{err:#}").contains("invalid token value"),
"a control-char token must surface the Authorization header error: {err:#}"
);
}
use anodizer_core::test_helpers::responder::spawn_oneshot_http_responder;
#[tokio::test]
async fn gitea_create_release_retries_5xx_on_list_releases() {
use std::sync::atomic::Ordering;
use std::time::Duration;
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]",
"HTTP/1.1 201 Created\r\nContent-Type: application/json\r\nContent-Length: 9\r\n\r\n{\"id\":42}",
]);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "myorg",
repo: "myrepo",
policy: &policy,
};
let spec = GiteaReleaseSpec {
tag: "v1.0.0",
commit: "abc123",
name: "Release v1.0.0",
body: "release body",
draft: false,
prerelease: false,
release_mode: "replace",
};
let result = gitea_create_release(&ctx, &spec).await;
match result {
Ok(id) => assert_eq!(id, 42, "release id should be parsed from create response"),
Err(e) => panic!("expected success after 5xx retry, got: {e:#}"),
}
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"expected 3 connections (503-retry GET, 200 GET, 201 POST)"
);
}
#[tokio::test]
async fn gitea_create_release_redacts_bearer_in_error_body() {
use std::time::Duration;
let leaky = r#"{"message":"401 Unauthorized: Authorization: Bearer ghp_FAKETOKEN1234567890abcdefg"}"#;
let body_len = leaky.len();
let resp: &'static str = Box::leak(
format!(
"HTTP/1.1 401 Unauthorized\r\nContent-Type: application/json\r\nContent-Length: {body_len}\r\n\r\n{leaky}"
)
.into_boxed_str(),
);
let (addr, _calls) = spawn_oneshot_http_responder(vec![resp]);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "myorg",
repo: "myrepo",
policy: &policy,
};
let spec = GiteaReleaseSpec {
tag: "v1.0.0",
commit: "abc123",
name: "Release v1.0.0",
body: "release body",
draft: false,
prerelease: false,
release_mode: "replace",
};
let err = gitea_create_release(&ctx, &spec)
.await
.expect_err("401 must fast-fail");
let chain = format!("{err:#}");
assert!(
!chain.contains("ghp_FAKETOKEN1234567890abcdefg"),
"bearer token leaked into error chain: {chain}"
);
assert!(
chain.contains("<redacted>"),
"expected `<redacted>` marker in error chain: {chain}"
);
}
#[tokio::test]
async fn gitea_release_tag_empty_bails_with_actionable_error() {
use std::time::Duration;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 1,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let ctx = GiteaCtx {
client: &client,
api_url: "http://unused.invalid",
owner: "myorg",
repo: "myrepo",
policy: &policy,
};
let spec = GiteaReleaseSpec {
tag: "",
commit: "abc123",
name: "Release",
body: "body",
draft: false,
prerelease: false,
release_mode: "replace",
};
let err = gitea_create_release(&ctx, &spec)
.await
.expect_err("empty tag must bail before any HTTP call");
let chain = format!("{err:#}");
assert!(
chain.contains("gitea:"),
"error must carry the gitea: prefix, got: {chain}"
);
assert!(
chain.contains("tag_name"),
"error must name the rejected field, got: {chain}"
);
assert!(
chain.contains("myorg/myrepo"),
"error must name the owner/repo, got: {chain}"
);
assert!(
chain.contains("release.tag:") || chain.contains("snapshot"),
"error must include an actionable hint, got: {chain}"
);
}
#[tokio::test]
async fn gitea_release_commit_empty_bails_with_actionable_error() {
use std::time::Duration;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.expect("client");
let policy = RetryPolicy {
max_attempts: 1,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let ctx = GiteaCtx {
client: &client,
api_url: "http://unused.invalid",
owner: "myorg",
repo: "myrepo",
policy: &policy,
};
let spec = GiteaReleaseSpec {
tag: "v1.0.0",
commit: "",
name: "Release",
body: "body",
draft: false,
prerelease: false,
release_mode: "replace",
};
let err = gitea_create_release(&ctx, &spec)
.await
.expect_err("empty commit must bail before any HTTP call");
let chain = format!("{err:#}");
assert!(
chain.contains("gitea:"),
"error must carry the gitea: prefix, got: {chain}"
);
assert!(
chain.contains("target_commitish"),
"error must name the rejected field, got: {chain}"
);
assert!(
chain.contains("git working tree") || chain.contains("git_info"),
"error must include an actionable hint, got: {chain}"
);
}
use anodizer_core::test_helpers::scripted_responder::{
ScriptedRoute, spawn_scripted_responder, spawn_scripted_responder_on,
};
fn fast_policy(max_attempts: u32) -> RetryPolicy {
RetryPolicy {
max_attempts,
base_delay: std::time::Duration::from_millis(1),
max_delay: std::time::Duration::from_millis(2),
}
}
fn test_client() -> Client {
Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.expect("client")
}
fn http_json(status: &str, body: String) -> &'static str {
let len = body.len();
Box::leak(
format!(
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {len}\r\n\r\n{body}"
)
.into_boxed_str(),
)
}
#[tokio::test]
async fn create_release_posts_when_absent() {
let (addr, log) = spawn_scripted_responder(vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/myorg/myrepo/releases?page=1&limit=50",
response: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]",
times: None,
},
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/myorg/myrepo/releases",
response: http_json("201 Created", serde_json::json!({"id": 99}).to_string()),
times: None,
},
]);
let client = test_client();
let policy = fast_policy(2);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "myorg",
repo: "myrepo",
policy: &policy,
};
let spec = GiteaReleaseSpec {
tag: "v1.0.0",
commit: "deadbeef",
name: "Release v1.0.0",
body: "the body",
draft: true,
prerelease: true,
release_mode: "replace",
};
let id = gitea_create_release(&ctx, &spec)
.await
.expect("create should succeed");
assert_eq!(id, 99, "release id parsed from POST 201 response");
let entries = log.lock().unwrap();
assert_eq!(entries.len(), 2, "one GET list + one POST create");
assert_eq!(entries[0].method, "GET");
assert_eq!(entries[1].method, "POST");
assert_eq!(
entries[1].path, "/api/v1/repos/myorg/myrepo/releases",
"create POSTs to the un-suffixed releases endpoint"
);
let payload: serde_json::Value =
serde_json::from_str(&entries[1].body).expect("POST body is JSON");
assert_eq!(payload["tag_name"], "v1.0.0");
assert_eq!(payload["target_commitish"], "deadbeef");
assert_eq!(payload["name"], "Release v1.0.0");
assert_eq!(payload["body"], "the body");
assert_eq!(payload["draft"], true);
assert_eq!(payload["prerelease"], true);
}
#[tokio::test]
async fn create_release_surfaces_422() {
let (addr, log) = spawn_scripted_responder(vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=1&limit=50",
response: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]",
times: None,
},
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases",
response: http_json(
"422 Unprocessable Entity",
serde_json::json!({"message": "tag already exists"}).to_string(),
),
times: None,
},
]);
let client = test_client();
let policy = fast_policy(1);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let spec = GiteaReleaseSpec {
tag: "v1.0.0",
commit: "abc",
name: "rel",
body: "b",
draft: false,
prerelease: false,
release_mode: "replace",
};
let err = gitea_create_release(&ctx, &spec)
.await
.expect_err("422 must surface as an error");
let chain = format!("{err:#}");
assert!(
chain.contains("create release failed (HTTP 422"),
"error must name the failing create call + status, got: {chain}"
);
let entries = log.lock().unwrap();
assert_eq!(entries.len(), 2, "GET list + single POST (no retry on 4xx)");
}
#[tokio::test]
async fn create_release_retries_5xx_on_post() {
let (addr, log) = spawn_scripted_responder(vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=1&limit=50",
response: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]",
times: None,
},
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases",
response: "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
times: Some(1),
},
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases",
response: http_json("201 Created", serde_json::json!({"id": 7}).to_string()),
times: None,
},
]);
let client = test_client();
let policy = fast_policy(3);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let spec = GiteaReleaseSpec {
tag: "v1.0.0",
commit: "abc",
name: "rel",
body: "b",
draft: false,
prerelease: false,
release_mode: "replace",
};
let id = gitea_create_release(&ctx, &spec)
.await
.expect("create should succeed after 5xx retry");
assert_eq!(id, 7);
let entries = log.lock().unwrap();
let posts = entries.iter().filter(|e| e.method == "POST").count();
assert_eq!(posts, 2, "503 POST retried once, then 201");
}
#[tokio::test]
async fn create_release_missing_id_errors() {
let (addr, _log) = spawn_scripted_responder(vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=1&limit=50",
response: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]",
times: None,
},
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases",
response: http_json(
"201 Created",
serde_json::json!({"name": "rel"}).to_string(),
),
times: None,
},
]);
let client = test_client();
let policy = fast_policy(1);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let spec = GiteaReleaseSpec {
tag: "v1.0.0",
commit: "abc",
name: "rel",
body: "b",
draft: false,
prerelease: false,
release_mode: "replace",
};
let err = gitea_create_release(&ctx, &spec)
.await
.expect_err("missing id must error");
assert!(
format!("{err:#}").contains("missing 'id' field"),
"error must name the missing id field, got: {err:#}"
);
}
#[tokio::test]
async fn update_release_patches_existing_replace_mode() {
let existing = serde_json::json!([
{"id": 5, "tag_name": "v1.0.0", "body": "old body"}
])
.to_string();
let (addr, log) = spawn_scripted_responder(vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=1&limit=50",
response: http_json("200 OK", existing),
times: None,
},
ScriptedRoute {
method: "PATCH",
path_pattern: "/api/v1/repos/o/r/releases/5",
response: http_json("200 OK", serde_json::json!({"id": 5}).to_string()),
times: None,
},
]);
let client = test_client();
let policy = fast_policy(2);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let spec = GiteaReleaseSpec {
tag: "v1.0.0",
commit: "abc",
name: "rel",
body: "new body",
draft: false,
prerelease: false,
release_mode: "replace",
};
let id = gitea_create_release(&ctx, &spec)
.await
.expect("update should succeed");
assert_eq!(id, 5, "returns the existing release id");
let entries = log.lock().unwrap();
assert!(
entries.iter().all(|e| e.method != "POST"),
"existing release must be PATCHed, never POSTed"
);
let patch = entries
.iter()
.find(|e| e.method == "PATCH")
.expect("a PATCH was issued");
assert_eq!(patch.path, "/api/v1/repos/o/r/releases/5");
let payload: serde_json::Value =
serde_json::from_str(&patch.body).expect("PATCH body is JSON");
assert_eq!(
payload["body"], "new body",
"replace mode sends the new body verbatim"
);
}
#[tokio::test]
async fn update_release_append_mode_composes_body() {
let existing = serde_json::json!([
{"id": 8, "tag_name": "v2.0.0", "body": "EXISTING"}
])
.to_string();
let (addr, log) = spawn_scripted_responder(vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=1&limit=50",
response: http_json("200 OK", existing),
times: None,
},
ScriptedRoute {
method: "PATCH",
path_pattern: "/api/v1/repos/o/r/releases/8",
response: http_json("200 OK", serde_json::json!({"id": 8}).to_string()),
times: None,
},
]);
let client = test_client();
let policy = fast_policy(2);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let spec = GiteaReleaseSpec {
tag: "v2.0.0",
commit: "abc",
name: "rel",
body: "ADDED",
draft: false,
prerelease: false,
release_mode: "append",
};
gitea_create_release(&ctx, &spec)
.await
.expect("update should succeed");
let entries = log.lock().unwrap();
let patch = entries
.iter()
.find(|e| e.method == "PATCH")
.expect("a PATCH was issued");
let payload: serde_json::Value =
serde_json::from_str(&patch.body).expect("PATCH body is JSON");
assert_eq!(
payload["body"], "EXISTING\n\nADDED",
"append mode joins existing + new with a blank line"
);
}
#[tokio::test]
async fn update_release_retries_5xx_on_patch() {
let existing = serde_json::json!([
{"id": 3, "tag_name": "v1.0.0", "body": null}
])
.to_string();
let (addr, log) = spawn_scripted_responder(vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=1&limit=50",
response: http_json("200 OK", existing),
times: None,
},
ScriptedRoute {
method: "PATCH",
path_pattern: "/api/v1/repos/o/r/releases/3",
response: "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
times: Some(1),
},
ScriptedRoute {
method: "PATCH",
path_pattern: "/api/v1/repos/o/r/releases/3",
response: http_json("200 OK", serde_json::json!({"id": 3}).to_string()),
times: None,
},
]);
let client = test_client();
let policy = fast_policy(3);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let spec = GiteaReleaseSpec {
tag: "v1.0.0",
commit: "abc",
name: "rel",
body: "b",
draft: false,
prerelease: false,
release_mode: "replace",
};
let id = gitea_create_release(&ctx, &spec)
.await
.expect("update should succeed after 5xx retry");
assert_eq!(id, 3);
let entries = log.lock().unwrap();
let patches = entries.iter().filter(|e| e.method == "PATCH").count();
assert_eq!(patches, 2, "503 PATCH retried once, then 200");
}
#[tokio::test]
async fn find_release_paginates_to_second_page() {
let mut page1: Vec<serde_json::Value> = Vec::new();
for i in 0..50u64 {
page1.push(serde_json::json!({
"id": 1000 + i,
"tag_name": format!("other-{i}"),
"body": null,
}));
}
let page1_body = serde_json::Value::Array(page1).to_string();
let page2_body = serde_json::json!([
{"id": 4242, "tag_name": "v9.9.9", "body": "found me"}
])
.to_string();
let (addr, log) = spawn_scripted_responder(vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=1&limit=50",
response: http_json("200 OK", page1_body),
times: None,
},
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=2&limit=50",
response: http_json("200 OK", page2_body),
times: None,
},
]);
let client = test_client();
let policy = fast_policy(2);
let api_url = format!("http://{addr}");
let found = find_release_by_tag(&client, &api_url, "o", "r", "v9.9.9", &policy)
.await
.expect("listing should succeed");
assert_eq!(
found,
Some((4242, Some("found me".to_string()))),
"tag matched on page 2 returns its id + body"
);
let entries = log.lock().unwrap();
let paths: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
assert_eq!(
paths,
vec![
"/api/v1/repos/o/r/releases?page=1&limit=50",
"/api/v1/repos/o/r/releases?page=2&limit=50",
],
"exactly two pages fetched, in order"
);
}
#[tokio::test]
async fn find_release_short_page_stops_and_returns_none() {
let body = serde_json::json!([
{"id": 1, "tag_name": "v0.1.0", "body": null}
])
.to_string();
let (addr, log) = spawn_scripted_responder(vec![ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=1&limit=50",
response: http_json("200 OK", body),
times: None,
}]);
let client = test_client();
let policy = fast_policy(2);
let api_url = format!("http://{addr}");
let found = find_release_by_tag(&client, &api_url, "o", "r", "v2.0.0", &policy)
.await
.expect("listing should succeed");
assert_eq!(found, None, "tag absent on a short page => None");
let entries = log.lock().unwrap();
assert_eq!(
entries.len(),
1,
"a short first page must not trigger a second GET"
);
}
#[tokio::test]
async fn find_release_missing_id_errors() {
let body = serde_json::json!([
{"tag_name": "v1.0.0", "body": "no id here"}
])
.to_string();
let (addr, _log) = spawn_scripted_responder(vec![ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=1&limit=50",
response: http_json("200 OK", body),
times: None,
}]);
let client = test_client();
let policy = fast_policy(1);
let api_url = format!("http://{addr}");
let err = find_release_by_tag(&client, &api_url, "o", "r", "v1.0.0", &policy)
.await
.expect_err("matched-but-id-less release must error");
assert!(
format!("{err:#}").contains("release missing 'id' field"),
"got: {err:#}"
);
}
#[tokio::test]
async fn upload_asset_posts_multipart_to_assets_endpoint() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("anodizer-x86_64.tar.gz");
tokio::fs::write(&file, b"ARTIFACT-BYTES")
.await
.expect("write fixture");
let (addr, log) = spawn_scripted_responder(vec![ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases/77/assets?name=anodizer-x86_64.tar.gz",
response: http_json("201 Created", serde_json::json!({"id": 1}).to_string()),
times: None,
}]);
let client = test_client();
let policy = fast_policy(2);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let asset = GiteaAssetSpec {
file_path: &file,
file_name: "anodizer-x86_64.tar.gz",
};
gitea_upload_asset(&ctx, 77, &asset)
.await
.expect("upload should succeed");
let entries = log.lock().unwrap();
assert_eq!(entries.len(), 1, "exactly one upload POST");
assert_eq!(entries[0].method, "POST");
assert_eq!(
entries[0].path, "/api/v1/repos/o/r/releases/77/assets?name=anodizer-x86_64.tar.gz",
"name is carried in the query string, release id in the path"
);
assert!(
entries[0].body.contains("name=\"attachment\""),
"multipart body uses the `attachment` form field, got: {}",
entries[0].body
);
assert!(
entries[0].body.contains("ARTIFACT-BYTES"),
"multipart body carries the file contents"
);
}
#[tokio::test]
async fn upload_asset_retries_5xx() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("a.bin");
tokio::fs::write(&file, b"xyz")
.await
.expect("write fixture");
let (addr, log) = spawn_scripted_responder(vec![
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases/1/assets?name=a.bin",
response: "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n",
times: Some(1),
},
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases/1/assets?name=a.bin",
response: http_json("201 Created", serde_json::json!({"id": 2}).to_string()),
times: None,
},
]);
let client = test_client();
let policy = fast_policy(3);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let asset = GiteaAssetSpec {
file_path: &file,
file_name: "a.bin",
};
gitea_upload_asset(&ctx, 1, &asset)
.await
.expect("upload should succeed after 5xx retry");
let entries = log.lock().unwrap();
assert_eq!(entries.len(), 2, "502 upload retried once, then 201");
}
#[tokio::test]
async fn upload_asset_surfaces_4xx() {
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("a.bin");
tokio::fs::write(&file, b"xyz")
.await
.expect("write fixture");
let (addr, _log) = spawn_scripted_responder(vec![ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases/4/assets?name=a.bin",
response: http_json(
"400 Bad Request",
serde_json::json!({"message": "bad asset"}).to_string(),
),
times: None,
}]);
let client = test_client();
let policy = fast_policy(1);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let asset = GiteaAssetSpec {
file_path: &file,
file_name: "a.bin",
};
let err = gitea_upload_asset(&ctx, 4, &asset)
.await
.expect_err("400 must surface");
let chain = format!("{err:#}");
assert!(
chain.contains("upload asset 'a.bin' to release 4 failed (HTTP 400"),
"error must name asset + release + status, got: {chain}"
);
}
#[tokio::test]
async fn delete_asset_by_name_lists_then_deletes() {
let assets = serde_json::json!([
{"id": 11, "name": "other.bin", "size": 1},
{"id": 22, "name": "target.bin", "size": 2}
])
.to_string();
let (addr, log) = spawn_scripted_responder(vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases/9/assets",
response: http_json("200 OK", assets),
times: None,
},
ScriptedRoute {
method: "DELETE",
path_pattern: "/api/v1/repos/o/r/releases/9/assets/22",
response: "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n",
times: None,
},
]);
let client = test_client();
let policy = fast_policy(2);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let deleted = gitea_delete_asset_by_name(&ctx, 9, "target.bin")
.await
.expect("delete should succeed");
assert!(deleted, "matching asset reported as deleted");
let entries = log.lock().unwrap();
assert_eq!(entries.len(), 2, "one list GET + one DELETE");
assert_eq!(entries[0].method, "GET");
assert_eq!(entries[1].method, "DELETE");
assert_eq!(
entries[1].path, "/api/v1/repos/o/r/releases/9/assets/22",
"DELETE targets the matched asset's numeric id, not its name"
);
}
#[tokio::test]
async fn delete_asset_by_name_absent_returns_false() {
let assets = serde_json::json!([
{"id": 11, "name": "other.bin", "size": 1}
])
.to_string();
let (addr, log) = spawn_scripted_responder(vec![ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases/9/assets",
response: http_json("200 OK", assets),
times: None,
}]);
let client = test_client();
let policy = fast_policy(2);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let deleted = gitea_delete_asset_by_name(&ctx, 9, "missing.bin")
.await
.expect("listing should succeed");
assert!(!deleted, "no match => false");
let entries = log.lock().unwrap();
assert_eq!(entries.len(), 1, "only the list GET, no DELETE");
assert!(entries.iter().all(|e| e.method != "DELETE"));
}
#[tokio::test]
async fn delete_asset_by_name_retries_5xx_on_list() {
let assets = serde_json::json!([
{"id": 33, "name": "t.bin", "size": 1}
])
.to_string();
let (addr, log) = spawn_scripted_responder(vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases/2/assets",
response: "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n",
times: Some(1),
},
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases/2/assets",
response: http_json("200 OK", assets),
times: None,
},
ScriptedRoute {
method: "DELETE",
path_pattern: "/api/v1/repos/o/r/releases/2/assets/33",
response: "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n",
times: None,
},
]);
let client = test_client();
let policy = fast_policy(3);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let deleted = gitea_delete_asset_by_name(&ctx, 2, "t.bin")
.await
.expect("delete should succeed after list retry");
assert!(deleted);
let entries = log.lock().unwrap();
let gets = entries.iter().filter(|e| e.method == "GET").count();
assert_eq!(gets, 2, "503 list GET retried once before the DELETE");
assert_eq!(entries.iter().filter(|e| e.method == "DELETE").count(), 1);
}
#[tokio::test]
async fn delete_asset_by_name_surfaces_delete_failure() {
let assets = serde_json::json!([
{"id": 44, "name": "target.bin", "size": 7}
])
.to_string();
let (addr, log) = spawn_scripted_responder(vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases/3/assets",
response: http_json("200 OK", assets),
times: None,
},
ScriptedRoute {
method: "DELETE",
path_pattern: "/api/v1/repos/o/r/releases/3/assets/44",
response: http_json(
"403 Forbidden",
serde_json::json!({"message": "no delete access"}).to_string(),
),
times: None,
},
]);
let client = test_client();
let policy = fast_policy(1);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let err = gitea_delete_asset_by_name(&ctx, 3, "target.bin")
.await
.expect_err("a 403 on the DELETE must surface as an error");
let chain = format!("{err:#}");
assert!(
chain.contains("delete asset 'target.bin' (id=44) from release 3 failed (HTTP 403"),
"error must name asset + id + release + status, got: {chain}"
);
let entries = log.lock().unwrap();
assert_eq!(
entries.iter().filter(|e| e.method == "DELETE").count(),
1,
"a 4xx DELETE fast-fails (no retry)"
);
}
#[tokio::test]
async fn find_asset_size_returns_matched_size() {
let assets = serde_json::json!([
{"id": 1, "name": "a.bin", "size": 10},
{"id": 2, "name": "b.bin", "size": 4096}
])
.to_string();
let (addr, _log) = spawn_scripted_responder(vec![ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases/5/assets",
response: http_json("200 OK", assets),
times: None,
}]);
let client = test_client();
let policy = fast_policy(2);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let size = gitea_find_asset_size(&ctx, 5, "b.bin")
.await
.expect("probe should succeed");
assert_eq!(size, Some(4096), "returns the matched asset's byte size");
}
#[tokio::test]
async fn find_asset_size_absent_returns_none() {
let assets = serde_json::json!([
{"id": 1, "name": "a.bin", "size": 10}
])
.to_string();
let (addr, _log) = spawn_scripted_responder(vec![ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases/5/assets",
response: http_json("200 OK", assets),
times: None,
}]);
let client = test_client();
let policy = fast_policy(2);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let size = gitea_find_asset_size(&ctx, 5, "missing.bin")
.await
.expect("probe should succeed");
assert_eq!(size, None, "no name match => None");
}
#[tokio::test]
async fn find_asset_size_non_numeric_size_is_none() {
let assets = serde_json::json!([
{"id": 1, "name": "a.bin", "size": "not-a-number"}
])
.to_string();
let (addr, _log) = spawn_scripted_responder(vec![ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases/5/assets",
response: http_json("200 OK", assets),
times: None,
}]);
let client = test_client();
let policy = fast_policy(2);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let size = gitea_find_asset_size(&ctx, 5, "a.bin")
.await
.expect("probe should succeed");
assert_eq!(
size, None,
"matched-but-unparseable size falls through to None"
);
}
#[tokio::test]
async fn find_asset_size_list_failure_surfaces_error() {
let (addr, log) = spawn_scripted_responder(vec![ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases/8/assets",
response: http_json(
"401 Unauthorized",
serde_json::json!({"message": "bad token"}).to_string(),
),
times: None,
}]);
let client = test_client();
let policy = fast_policy(1);
let api_url = format!("http://{addr}");
let ctx = GiteaCtx {
client: &client,
api_url: &api_url,
owner: "o",
repo: "r",
policy: &policy,
};
let err = gitea_find_asset_size(&ctx, 8, "a.bin")
.await
.expect_err("a 401 on the size-probe list must surface");
assert!(
format!("{err:#}").contains("list release assets failed (HTTP 401"),
"error must name the failing list call + status, got: {err:#}"
);
assert_eq!(
log.lock().unwrap().len(),
1,
"a 4xx list GET fast-fails (no retry)"
);
}
use anodizer_core::config::{
CrateConfig, GiteaUrlsConfig, ReleaseConfig, RetryConfig, ScmRepoConfig,
};
use anodizer_core::context::Context;
use anodizer_core::log::{StageLogger, Verbosity};
use anodizer_core::scm::ScmTokenType;
use anodizer_core::test_helpers::TestContextBuilder;
fn build_gitea_ctx(api_base: &str) -> Context {
let mut ctx = TestContextBuilder::new()
.project_name("demo")
.tag("v1.0.0")
.commit("deadbeef")
.token(Some("gitea-test".to_string()))
.build();
ctx.token_type = ScmTokenType::Gitea;
ctx.config.gitea_urls = Some(GiteaUrlsConfig {
api: Some(api_base.to_string()),
download: Some(api_base.to_string()),
skip_tls_verify: None,
});
ctx.config.retry = Some(RetryConfig {
attempts: 3,
delay: anodizer_core::config::HumanDuration(std::time::Duration::from_millis(1)),
max_delay: anodizer_core::config::HumanDuration(std::time::Duration::from_millis(2)),
});
ctx
}
fn build_gitea_crate_cfg() -> CrateConfig {
let mut crate_cfg = CrateConfig {
name: "demo".to_string(),
path: ".".to_string(),
tag_template: "v{{ Version }}".to_string(),
..Default::default()
};
crate_cfg.release = Some(ReleaseConfig {
gitea: Some(ScmRepoConfig {
owner: "o".to_string(),
name: "r".to_string(),
}),
mode: Some("replace".to_string()),
..Default::default()
});
crate_cfg
}
fn default_gitea_spec() -> GiteaBackendSpec<'static> {
GiteaBackendSpec {
tag: "v1.0.0",
release_name: "Release v1.0.0",
release_body: "the body",
release_mode: "replace",
draft: false,
prerelease: false,
skip_upload: false,
replace_existing_draft: false,
use_existing_draft: false,
replace_existing_artifacts: false,
}
}
#[test]
fn run_backend_creates_release_and_uploads_one_asset() {
let dir = tempfile::tempdir().expect("tempdir");
let artifact = dir.path().join("demo.tar.gz");
std::fs::write(&artifact, b"PAYLOAD").expect("write artifact");
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
let routes = vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=1&limit=50",
response: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]",
times: None,
},
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases",
response: http_json("201 Created", serde_json::json!({"id": 7}).to_string()),
times: None,
},
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases/7/assets",
response: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]",
times: None,
},
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases/7/assets?name=demo.tar.gz",
response: http_json("201 Created", serde_json::json!({"id": 1}).to_string()),
times: None,
},
];
let (_addr, log) = spawn_scripted_responder_on(listener, |_| routes);
let api_base = format!("http://{addr}");
let ctx = build_gitea_ctx(&api_base);
let crate_cfg = build_gitea_crate_cfg();
let release_cfg = crate_cfg.release.as_ref().expect("release cfg");
let rt = tokio::runtime::Runtime::new().expect("rt");
let log_stage = StageLogger::new("release", Verbosity::Normal);
let token = Some("gitea-test".to_string());
let env = GiteaBackendEnv {
rt: &rt,
ctx: &ctx,
log: &log_stage,
token: &token,
};
let artifacts = vec![(artifact, Some("demo.tar.gz".to_string()))];
let out = run_gitea_backend(
&env,
&crate_cfg,
release_cfg,
&default_gitea_spec(),
&artifacts,
)
.expect("run_gitea_backend should succeed")
.expect("returns Some on success");
let (html_url, download, owner, repo) = out;
assert_eq!(owner, "o");
assert_eq!(repo, "r");
assert_eq!(
download, api_base,
"download base echoes gitea_urls.download"
);
assert_eq!(
html_url,
format!("{api_base}/o/r/releases/tag/v1.0.0"),
"html_url composes from download base + owner/repo/releases/tag/tag"
);
let entries = log.lock().unwrap();
assert!(
entries
.iter()
.any(|e| e.method == "POST" && e.path == "/api/v1/repos/o/r/releases"),
"the create POST hit the loopback"
);
let upload = entries
.iter()
.find(|e| e.method == "POST" && e.path.contains("/assets?name=demo.tar.gz"))
.expect("the upload POST was issued");
assert!(
upload.body.contains("PAYLOAD"),
"the upload POST carried the artifact bytes"
);
}
#[test]
fn run_backend_skip_upload_creates_release_only() {
let dir = tempfile::tempdir().expect("tempdir");
let artifact = dir.path().join("demo.tar.gz");
std::fs::write(&artifact, b"PAYLOAD").expect("write artifact");
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
let routes = vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=1&limit=50",
response: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]",
times: None,
},
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases",
response: http_json("201 Created", serde_json::json!({"id": 7}).to_string()),
times: None,
},
];
let (_addr, log) = spawn_scripted_responder_on(listener, |_| routes);
let api_base = format!("http://{addr}");
let ctx = build_gitea_ctx(&api_base);
let crate_cfg = build_gitea_crate_cfg();
let release_cfg = crate_cfg.release.as_ref().expect("release cfg");
let rt = tokio::runtime::Runtime::new().expect("rt");
let log_stage = StageLogger::new("release", Verbosity::Normal);
let token = Some("gitea-test".to_string());
let env = GiteaBackendEnv {
rt: &rt,
ctx: &ctx,
log: &log_stage,
token: &token,
};
let mut spec = default_gitea_spec();
spec.skip_upload = true;
let artifacts = vec![(artifact, Some("demo.tar.gz".to_string()))];
run_gitea_backend(&env, &crate_cfg, release_cfg, &spec, &artifacts)
.expect("run_gitea_backend should succeed")
.expect("returns Some");
let entries = log.lock().unwrap();
assert!(
entries.iter().all(|e| !e.path.contains("/assets")),
"skip_upload must issue no size probe / upload calls, got: {:?}",
entries.iter().map(|e| &e.path).collect::<Vec<_>>()
);
}
#[test]
fn run_backend_idempotent_skip_when_size_matches() {
let dir = tempfile::tempdir().expect("tempdir");
let artifact = dir.path().join("demo.tar.gz");
std::fs::write(&artifact, b"PAYLOAD").expect("write artifact");
let local_size = std::fs::metadata(&artifact).expect("stat").len();
let existing_assets =
serde_json::json!([{"id": 1, "name": "demo.tar.gz", "size": local_size}]).to_string();
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
let routes = vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=1&limit=50",
response: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]",
times: None,
},
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases",
response: http_json("201 Created", serde_json::json!({"id": 7}).to_string()),
times: None,
},
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases/7/assets",
response: http_json("200 OK", existing_assets),
times: None,
},
];
let (_addr, log) = spawn_scripted_responder_on(listener, |_| routes);
let api_base = format!("http://{addr}");
let ctx = build_gitea_ctx(&api_base);
let crate_cfg = build_gitea_crate_cfg();
let release_cfg = crate_cfg.release.as_ref().expect("release cfg");
let rt = tokio::runtime::Runtime::new().expect("rt");
let log_stage = StageLogger::new("release", Verbosity::Normal);
let token = Some("gitea-test".to_string());
let env = GiteaBackendEnv {
rt: &rt,
ctx: &ctx,
log: &log_stage,
token: &token,
};
let artifacts = vec![(artifact, Some("demo.tar.gz".to_string()))];
run_gitea_backend(
&env,
&crate_cfg,
release_cfg,
&default_gitea_spec(),
&artifacts,
)
.expect("run_gitea_backend should succeed")
.expect("returns Some");
let entries = log.lock().unwrap();
assert!(
entries
.iter()
.all(|e| !(e.method == "POST" && e.path.contains("/assets?name="))),
"a same-size remote asset must skip the upload POST entirely"
);
assert!(
entries.iter().all(|e| e.method != "DELETE"),
"an idempotent skip issues no DELETE"
);
}
#[test]
fn run_backend_replace_existing_deletes_then_uploads() {
let dir = tempfile::tempdir().expect("tempdir");
let artifact = dir.path().join("demo.tar.gz");
std::fs::write(&artifact, b"PAYLOAD-NEW-LONGER").expect("write artifact");
let existing_assets =
serde_json::json!([{"id": 5, "name": "demo.tar.gz", "size": 3}]).to_string();
let list_again =
serde_json::json!([{"id": 5, "name": "demo.tar.gz", "size": 3}]).to_string();
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
let routes = vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=1&limit=50",
response: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]",
times: None,
},
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases",
response: http_json("201 Created", serde_json::json!({"id": 7}).to_string()),
times: None,
},
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases/7/assets",
response: http_json("200 OK", existing_assets),
times: Some(1),
},
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases/7/assets",
response: http_json("200 OK", list_again),
times: None,
},
ScriptedRoute {
method: "DELETE",
path_pattern: "/api/v1/repos/o/r/releases/7/assets/5",
response: "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n",
times: None,
},
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases/7/assets?name=demo.tar.gz",
response: http_json("201 Created", serde_json::json!({"id": 9}).to_string()),
times: None,
},
];
let (_addr, log) = spawn_scripted_responder_on(listener, |_| routes);
let api_base = format!("http://{addr}");
let ctx = build_gitea_ctx(&api_base);
let crate_cfg = build_gitea_crate_cfg();
let release_cfg = crate_cfg.release.as_ref().expect("release cfg");
let rt = tokio::runtime::Runtime::new().expect("rt");
let log_stage = StageLogger::new("release", Verbosity::Normal);
let token = Some("gitea-test".to_string());
let env = GiteaBackendEnv {
rt: &rt,
ctx: &ctx,
log: &log_stage,
token: &token,
};
let mut spec = default_gitea_spec();
spec.replace_existing_artifacts = true;
let artifacts = vec![(artifact, Some("demo.tar.gz".to_string()))];
run_gitea_backend(&env, &crate_cfg, release_cfg, &spec, &artifacts)
.expect("run_gitea_backend should succeed")
.expect("returns Some");
let entries = log.lock().unwrap();
assert_eq!(
entries
.iter()
.filter(
|e| e.method == "DELETE" && e.path == "/api/v1/repos/o/r/releases/7/assets/5"
)
.count(),
1,
"the differing remote asset must be DELETEd before re-upload"
);
assert_eq!(
entries
.iter()
.filter(|e| e.method == "POST" && e.path.contains("/assets?name=demo.tar.gz"))
.count(),
1,
"the asset is re-uploaded after the delete"
);
}
#[test]
fn run_backend_draft_flags_warn_but_create_proceeds() {
let dir = tempfile::tempdir().expect("tempdir");
let artifact = dir.path().join("demo.tar.gz");
std::fs::write(&artifact, b"PAYLOAD").expect("write artifact");
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
let routes = vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=1&limit=50",
response: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]",
times: None,
},
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases",
response: http_json("201 Created", serde_json::json!({"id": 7}).to_string()),
times: None,
},
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases/7/assets",
response: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]",
times: None,
},
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases/7/assets?name=demo.tar.gz",
response: http_json("201 Created", serde_json::json!({"id": 1}).to_string()),
times: None,
},
];
let (_addr, log) = spawn_scripted_responder_on(listener, |_| routes);
let api_base = format!("http://{addr}");
let ctx = build_gitea_ctx(&api_base);
let crate_cfg = build_gitea_crate_cfg();
let release_cfg = crate_cfg.release.as_ref().expect("release cfg");
let rt = tokio::runtime::Runtime::new().expect("rt");
let log_stage = StageLogger::new("release", Verbosity::Normal);
let token = Some("gitea-test".to_string());
let env = GiteaBackendEnv {
rt: &rt,
ctx: &ctx,
log: &log_stage,
token: &token,
};
let mut spec = default_gitea_spec();
spec.replace_existing_draft = true;
spec.use_existing_draft = true;
let artifacts = vec![(artifact, Some("demo.tar.gz".to_string()))];
run_gitea_backend(&env, &crate_cfg, release_cfg, &spec, &artifacts)
.expect("draft flags must not abort the backend")
.expect("returns Some");
let entries = log.lock().unwrap();
assert!(
entries
.iter()
.any(|e| e.method == "POST" && e.path == "/api/v1/repos/o/r/releases"),
"the release is still created despite the no-op draft flags"
);
assert!(
entries
.iter()
.any(|e| e.method == "POST" && e.path.contains("/assets?name=demo.tar.gz")),
"the asset upload still proceeds"
);
}
#[test]
fn run_backend_missing_token_bails() {
let ctx = build_gitea_ctx("http://unused.invalid");
let crate_cfg = build_gitea_crate_cfg();
let release_cfg = crate_cfg.release.as_ref().expect("release cfg");
let rt = tokio::runtime::Runtime::new().expect("rt");
let log_stage = StageLogger::new("release", Verbosity::Normal);
let token: Option<String> = None;
let env = GiteaBackendEnv {
rt: &rt,
ctx: &ctx,
log: &log_stage,
token: &token,
};
let artifacts: Vec<(std::path::PathBuf, Option<String>)> = Vec::new();
let err = run_gitea_backend(
&env,
&crate_cfg,
release_cfg,
&default_gitea_spec(),
&artifacts,
)
.expect_err("a missing token must bail");
assert!(
format!("{err:#}").contains("GITEA_TOKEN"),
"bail must name the missing env var, got: {err:#}"
);
}
#[test]
fn run_backend_no_gitea_config_returns_none() {
let ctx = build_gitea_ctx("http://unused.invalid");
let mut crate_cfg = build_gitea_crate_cfg();
crate_cfg.release = Some(ReleaseConfig {
mode: Some("replace".to_string()),
..Default::default()
});
let release_cfg = crate_cfg.release.as_ref().expect("release cfg");
let rt = tokio::runtime::Runtime::new().expect("rt");
let log_stage = StageLogger::new("release", Verbosity::Normal);
let token = Some("gitea-test".to_string());
let env = GiteaBackendEnv {
rt: &rt,
ctx: &ctx,
log: &log_stage,
token: &token,
};
let artifacts: Vec<(std::path::PathBuf, Option<String>)> = Vec::new();
let out = run_gitea_backend(
&env,
&crate_cfg,
release_cfg,
&default_gitea_spec(),
&artifacts,
)
.expect("no-config is not an error");
assert!(out.is_none(), "absent gitea config => Ok(None)");
}
#[test]
fn run_backend_missing_artifact_file_errors() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
let routes = vec![
ScriptedRoute {
method: "GET",
path_pattern: "/api/v1/repos/o/r/releases?page=1&limit=50",
response: "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]",
times: None,
},
ScriptedRoute {
method: "POST",
path_pattern: "/api/v1/repos/o/r/releases",
response: http_json("201 Created", serde_json::json!({"id": 7}).to_string()),
times: None,
},
];
let (_addr, _log) = spawn_scripted_responder_on(listener, |_| routes);
let api_base = format!("http://{addr}");
let ctx = build_gitea_ctx(&api_base);
let crate_cfg = build_gitea_crate_cfg();
let release_cfg = crate_cfg.release.as_ref().expect("release cfg");
let rt = tokio::runtime::Runtime::new().expect("rt");
let log_stage = StageLogger::new("release", Verbosity::Normal);
let token = Some("gitea-test".to_string());
let env = GiteaBackendEnv {
rt: &rt,
ctx: &ctx,
log: &log_stage,
token: &token,
};
let missing = std::path::PathBuf::from("/nonexistent/anodizer-test/missing.tar.gz");
let artifacts = vec![(missing, Some("missing.tar.gz".to_string()))];
let err = run_gitea_backend(
&env,
&crate_cfg,
release_cfg,
&default_gitea_spec(),
&artifacts,
)
.expect_err("a missing artifact file must abort the upload loop");
assert!(
format!("{err:#}").contains("missing"),
"error must report the missing artifact, got: {err:#}"
);
}
}