use std::sync::Arc;
use anodizer_core::config::{CrateConfig, ReleaseConfig};
use anodizer_core::context::Context;
use anodizer_core::log::StageLogger;
use anodizer_core::retry::jitter_duration;
use anyhow::{Context as _, Result};
use octocrab::repos::releases::MakeLatest;
use crate::release_body::{
GITHUB_RELEASE_BODY_MAX_CHARS, build_publish_patch_body, build_release_json,
compose_body_for_mode,
};
use crate::{release_log, resolve_release_repo};
mod assets;
mod client;
mod rate_limit;
mod retry_call;
mod retry_classify;
mod secondary_rate_limit;
pub(crate) use assets::{delete_release_asset_by_name, find_release_asset_size};
pub(crate) use client::build_octocrab_client;
pub(crate) use rate_limit::check_github_rate_limit;
pub(crate) use retry_call::{format_retry_warn, is_octocrab_404, retry_octocrab_call};
use secondary_rate_limit::{is_secondary_rate_limit, secondary_rl_delay};
pub(crate) fn upload_retry_locals(
policy: &anodizer_core::retry::RetryPolicy,
) -> (u32, std::time::Duration, std::time::Duration) {
(policy.max_attempts, policy.base_delay, policy.max_delay)
}
pub(crate) struct BackendEnv<'a> {
pub rt: &'a tokio::runtime::Runtime,
pub ctx: &'a Context,
pub log: &'a StageLogger,
pub token: &'a Option<String>,
}
#[derive(Clone, Copy)]
pub(crate) struct GithubReleaseSpec<'a> {
pub tag: &'a str,
pub name: &'a str,
pub body: &'a str,
pub mode: &'a str,
pub draft: bool,
pub prerelease: bool,
pub make_latest: &'a Option<MakeLatest>,
pub target_commitish: &'a Option<String>,
pub discussion_category: &'a Option<String>,
}
#[derive(Clone, Copy)]
pub(crate) struct UploadOpts {
pub skip_upload: bool,
pub replace_existing_draft: bool,
pub replace_existing_artifacts: bool,
pub use_existing_draft: bool,
pub resume_release: bool,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum AlreadyExistsAction {
SkipIdempotent,
BailReplaceForbidden,
DeleteAndRetry,
}
pub(crate) fn check_existing_assets_block_upload(
skip_upload: bool,
resume_release: bool,
replace_existing_artifacts: bool,
existing_asset_names: &[&str],
) -> Option<Vec<String>> {
if skip_upload
|| resume_release
|| replace_existing_artifacts
|| existing_asset_names.is_empty()
{
return None;
}
Some(existing_asset_names.iter().map(|s| s.to_string()).collect())
}
pub(crate) fn classify_already_exists(
replace_existing_artifacts: bool,
remote_size: Option<u64>,
local_size: u64,
) -> AlreadyExistsAction {
if remote_size == Some(local_size) {
return AlreadyExistsAction::SkipIdempotent;
}
if !replace_existing_artifacts {
return AlreadyExistsAction::BailReplaceForbidden;
}
AlreadyExistsAction::DeleteAndRetry
}
pub(crate) fn run_github_backend(
env: &BackendEnv<'_>,
crate_cfg: &CrateConfig,
release_cfg: &ReleaseConfig,
spec: &GithubReleaseSpec<'_>,
upload_opts: &UploadOpts,
artifact_entries: &[(std::path::PathBuf, Option<String>)],
) -> Result<Option<(String, String, String, String)>> {
let BackendEnv {
rt,
ctx,
log,
token,
} = *env;
let GithubReleaseSpec {
tag,
name: release_name,
body: release_body,
mode: release_mode,
draft,
prerelease,
make_latest,
target_commitish,
discussion_category: discussion_category_name,
} = *spec;
let UploadOpts {
skip_upload,
replace_existing_draft,
replace_existing_artifacts,
use_existing_draft,
resume_release,
} = *upload_opts;
let github = match resolve_release_repo(release_cfg, ctx.token_type, ctx)? {
Some(r) => r,
None => {
log.warn(&format!(
"no github config for crate '{}', skipping",
crate_cfg.name
));
return Ok(None);
}
};
let token_str = match token {
Some(t) => t.clone(),
None => {
anyhow::bail!(
"release: no GitHub token available (set GITHUB_TOKEN or ANODIZER_GITHUB_TOKEN, or pass --token)"
);
}
};
let github_urls = ctx.config.github_urls.clone();
let gh_download_base = github_urls
.as_ref()
.and_then(|u| u.download.clone())
.unwrap_or_else(|| "https://github.com".to_string());
let policy = ctx.retry_policy();
let url = rt.block_on(async {
let octo = Arc::new(build_octocrab_client(&token_str, &github_urls)?);
let rate_limit_client = reqwest::Client::new();
async fn find_draft_by_name(
octo: &Arc<octocrab::Octocrab>,
owner: &str,
repo: &str,
name: &str,
policy: &anodizer_core::retry::RetryPolicy,
) -> Result<Option<octocrab::models::repos::Release>> {
let mut page: u32 = 1;
loop {
let route = format!(
"/repos/{}/{}/releases?per_page=100&page={}",
owner, repo, page
);
let releases: Vec<octocrab::models::repos::Release> =
retry_octocrab_call(policy, "list releases", || {
let route = route.clone();
let octo = octo.clone();
async move { octo.get(route, None::<&()>).await }
})
.await
.with_context(|| {
format!(
"release: list releases on {}/{} (page {})",
owner, repo, page
)
})?;
if let Some(found) = releases
.iter()
.find(|r| r.draft && r.name.as_deref() == Some(name))
{
return Ok(Some(found.clone()));
}
if releases.len() < 100 {
break;
}
page += 1;
}
Ok(None)
}
check_github_rate_limit(&rate_limit_client, &token_str, 10).await;
if replace_existing_draft
&& draft
&& let Some(existing) =
find_draft_by_name(&octo, &github.owner, &github.name, release_name, &policy)
.await?
{
log.status(&format!(
"replacing existing draft release '{}' (id={})",
release_name, existing.id
));
let existing_id = existing.id.into_inner();
let owner = github.owner.clone();
let repo = github.name.clone();
retry_octocrab_call(&policy, "delete release", || {
let octo = octo.clone();
let owner = owner.clone();
let repo = repo.clone();
async move {
octo.repos(&owner, &repo)
.releases()
.delete(existing_id)
.await
}
})
.await
.with_context(|| {
format!(
"release: delete existing draft release '{}' on {}/{}",
release_name, github.owner, github.name
)
})?;
}
let existing_draft = if use_existing_draft {
match find_draft_by_name(&octo, &github.owner, &github.name, release_name, &policy)
.await?
{
Some(existing) => {
log.status(&format!(
"reusing existing draft release '{}' (id={})",
release_name, existing.id
));
Some(existing)
}
None => None,
}
} else {
None
};
let (final_body, existing_by_tag) = if let Some(ref existing) = existing_draft {
let existing_body = existing.body.as_deref();
(
compose_body_for_mode(release_mode, existing_body, release_body),
None,
)
} else {
if release_mode != "replace" {
check_github_rate_limit(&rate_limit_client, &token_str, 10).await;
let owner = github.owner.clone();
let repo = github.name.clone();
let tag_owned = tag.to_string();
let lookup: Result<octocrab::models::repos::Release, octocrab::Error> =
retry_octocrab_call(&policy, "get release by tag", || {
let octo = octo.clone();
let owner = owner.clone();
let repo = repo.clone();
let tag_owned = tag_owned.clone();
async move {
octo.repos(&owner, &repo)
.releases()
.get_by_tag(&tag_owned)
.await
}
})
.await;
match lookup {
Ok(existing) => {
let existing_body = existing.body.as_deref();
let body =
compose_body_for_mode(release_mode, existing_body, release_body);
(body, Some(existing))
}
Err(err) if is_octocrab_404(&err) => {
(release_body.to_string(), None)
}
Err(err) => {
return Err(anyhow::Error::new(err)).with_context(|| {
format!(
"release: look up existing release by tag '{}' on {}/{}",
tag, github.owner, github.name
)
});
}
}
} else {
(release_body.to_string(), None)
}
};
if let Some(ref existing) = existing_by_tag {
let asset_names: Vec<&str> =
existing.assets.iter().map(|a| a.name.as_str()).collect();
if let Some(conflicting) = check_existing_assets_block_upload(
skip_upload,
resume_release,
replace_existing_artifacts,
&asset_names,
) {
anyhow::bail!(
"release: GitHub release for tag '{}' already exists with {} asset(s) ({}) \
left by a prior failed attempt. To recover, pass one of:\n\
\x20 • --resume-release (continue into the existing release; assumes its \
assets are correct), or\n\
\x20 • --replace-existing (overwrite the assets with the current build), or\n\
\x20 • set release.replace_existing_artifacts: true in config, or\n\
\x20 • delete the existing release manually and retry.",
tag,
conflicting.len(),
conflicting.join(", ")
);
}
}
let user_wants_draft = draft;
if final_body.len() > GITHUB_RELEASE_BODY_MAX_CHARS {
log.warn(&format!(
"release body ({} chars) exceeds GitHub limit ({}); truncating",
final_body.len(),
GITHUB_RELEASE_BODY_MAX_CHARS,
));
}
let json_body = build_release_json(&crate::release_body::ReleaseJsonSpec {
tag,
name: release_name,
body: &final_body,
draft: true, prerelease_flag: prerelease,
make_latest: &None, target_commitish,
discussion_category: &None, });
check_github_rate_limit(&rate_limit_client, &token_str, 10).await;
let release = if let Some(ref existing) = existing_draft {
let route = format!(
"/repos/{}/{}/releases/{}",
github.owner, github.name, existing.id
);
retry_octocrab_call(&policy, "update draft release", || {
let route = route.clone();
let body = json_body.clone();
let octo = octo.clone();
async move {
octo.patch::<octocrab::models::repos::Release, _, _>(route, Some(&body))
.await
}
})
.await
.with_context(|| {
format!(
"release: update existing draft release '{}' on {}/{}",
tag, github.owner, github.name
)
})?
} else if let Some(ref existing) = existing_by_tag {
log.status(&format!(
"updating existing release '{}' (id={}, mode={})",
release_name, existing.id, release_mode
));
let route = format!(
"/repos/{}/{}/releases/{}",
github.owner, github.name, existing.id
);
let mut patch_body = json_body.clone();
if let Some(obj) = patch_body.as_object_mut() {
obj.insert(
"draft".to_string(),
serde_json::Value::Bool(existing.draft),
);
}
retry_octocrab_call(&policy, "update existing release", || {
let route = route.clone();
let body = patch_body.clone();
let octo = octo.clone();
async move {
octo.patch::<octocrab::models::repos::Release, _, _>(route, Some(&body))
.await
}
})
.await
.with_context(|| {
format!(
"release: update existing release '{}' on {}/{}",
tag, github.owner, github.name
)
})?
} else {
let route = format!("/repos/{}/{}/releases", github.owner, github.name);
retry_octocrab_call(&policy, "create release", || {
let route = route.clone();
let body = json_body.clone();
let octo = octo.clone();
async move {
octo.post::<_, octocrab::models::repos::Release>(route, Some(&body))
.await
}
})
.await
.with_context(|| {
format!(
"release: create GitHub release '{}' on {}/{}",
tag, github.owner, github.name
)
})?
};
log.status(&format!(
"created GitHub Release '{}' (id={}) on {}/{}",
release_name, release.id, github.owner, github.name
));
let html_url = format!(
"{}/{}/{}/releases/tag/{}",
gh_download_base.trim_end_matches('/'),
github.owner,
github.name,
tag,
);
let release_id_raw = release.id.into_inner();
if skip_upload {
log.status("skip_upload is set, skipping artifact uploads");
} else {
let upload_concurrency: usize = std::env::var("ANODIZER_GITHUB_UPLOAD_CONCURRENCY")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
.filter(|&n| n > 0)
.or_else(|| {
release_cfg
.upload_concurrency
.filter(|&n| n > 0)
})
.unwrap_or(4) as usize;
let semaphore = Arc::new(tokio::sync::Semaphore::new(upload_concurrency));
let gh_owner = github.owner.clone();
let gh_name = github.name.clone();
let tag_for_upload = tag.to_string();
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 mut join_set = tokio::task::JoinSet::new();
for (path, file_name) in prepared_entries {
let sem = semaphore.clone();
let octo = octo.clone();
let gh_owner = gh_owner.clone();
let gh_name = gh_name.clone();
let tag_c = tag_for_upload.clone();
let token_for_rate_limit = token_str.clone();
join_set.spawn(async move {
let _permit = sem
.acquire()
.await
.map_err(|e| anyhow::anyhow!("semaphore closed: {}", e))?;
if replace_existing_artifacts {
delete_release_asset_by_name(
&octo,
&gh_owner,
&gh_name,
release_id_raw,
&file_name,
)
.await
.with_context(|| {
format!(
"release: delete existing artifact '{}' from release '{}'",
file_name, tag_c
)
})?;
}
let (max_upload_attempts, initial_retry_delay, max_retry_delay) =
upload_retry_locals(&policy);
let mut last_err: Option<anyhow::Error> = None;
let mut overwrite_attempted = false;
for attempt in 1..=max_upload_attempts {
let data = std::fs::read(&path).with_context(|| {
format!("release: read artifact {}", path.display())
})?;
let local_size = data.len() as u64;
match octo
.repos(&gh_owner, &gh_name)
.releases()
.upload_asset(release_id_raw, &file_name, data.into())
.send()
.await
{
Ok(_) => {
last_err = None;
break;
}
Err(err) => {
let is_server_error = matches!(
&err,
octocrab::Error::GitHub { source, .. }
if source.status_code.is_server_error()
);
let is_already_exists = matches!(
&err,
octocrab::Error::GitHub { source, .. }
if source.status_code.as_u16() == 422
&& source.errors.as_ref().is_some_and(|errs| {
errs.iter().any(|e| {
e.get("code")
.and_then(|v| v.as_str())
== Some("already_exists")
})
})
);
if is_already_exists {
if overwrite_attempted {
release_log().warn(&format!(
"existing asset '{file_name}' on release '{tag_c}' \
reappeared after delete+retry; \
skipping, stale asset kept"
));
last_err = None;
break;
}
let remote_size = find_release_asset_size(
&octo,
&gh_owner,
&gh_name,
release_id_raw,
&file_name,
)
.await
.with_context(|| {
format!(
"release: look up existing asset '{}' on release '{}'",
file_name, tag_c
)
})?;
match classify_already_exists(
replace_existing_artifacts,
remote_size,
local_size,
) {
AlreadyExistsAction::SkipIdempotent => {
last_err = None;
break;
}
AlreadyExistsAction::BailReplaceForbidden => {
return Err(anyhow::anyhow!(err)).with_context(|| {
format!(
"release: artifact '{}' already exists on release '{}' \
with different bytes and `replace_existing_artifacts: false` \
forbids overwriting (set \
`release.replace_existing_artifacts: true` \
to permit overwrites)",
file_name, tag_c
)
});
}
AlreadyExistsAction::DeleteAndRetry => {
}
}
match delete_release_asset_by_name(
&octo,
&gh_owner,
&gh_name,
release_id_raw,
&file_name,
)
.await
{
Ok(_) => {
overwrite_attempted = true;
last_err = Some(anyhow::anyhow!(err));
if attempt < max_upload_attempts {
let base = std::cmp::min(
initial_retry_delay * 2u32.pow(attempt - 1),
max_retry_delay,
);
tokio::time::sleep(jitter_duration(base)).await;
}
continue;
}
Err(del_err) => {
release_log().warn(&format!(
"could not overwrite existing asset '{file_name}' on release '{tag_c}' \
(size mismatch and delete failed: {del_err}); skipping, stale asset kept"
));
last_err = None;
break;
}
}
}
if is_secondary_rate_limit(&err) {
let delay = jitter_duration(secondary_rl_delay());
release_log().warn(&format!(
"release: upload of '{file_name}' hit GitHub secondary \
rate limit; sleeping {:.1}s before retry \
(attempt {attempt}/{})",
delay.as_secs_f64(),
max_upload_attempts,
));
if attempt < max_upload_attempts {
tokio::time::sleep(delay).await;
}
last_err = Some(anyhow::anyhow!(err));
continue;
}
let is_rate_limited = matches!(
&err,
octocrab::Error::GitHub { source, .. }
if source.status_code.as_u16() == 403
|| source.status_code.as_u16() == 429
);
if is_rate_limited {
release_log().status(&format!(
"rate limited on upload of '{file_name}', checking rate limits..."
));
check_github_rate_limit(
&reqwest::Client::new(),
&token_for_rate_limit,
100,
)
.await;
last_err = Some(anyhow::anyhow!(err));
continue;
} else if is_server_error
|| matches!(&err, octocrab::Error::Hyper { .. })
|| matches!(&err, octocrab::Error::Http { .. })
|| matches!(&err, octocrab::Error::Service { .. })
|| matches!(&err, octocrab::Error::Other { .. })
|| matches!(&err, octocrab::Error::Serde { .. })
|| matches!(&err, octocrab::Error::Json { .. })
{
let status = match &err {
octocrab::Error::GitHub { source, .. } => {
source.status_code.as_u16()
}
_ => 0,
};
let label = format!("upload of '{file_name}'");
release_log().warn(&format_retry_warn(
&label,
attempt,
max_upload_attempts,
status,
));
last_err = Some(anyhow::anyhow!(err));
if attempt < max_upload_attempts {
let base = std::cmp::min(
initial_retry_delay * 2u32.pow(attempt - 1),
max_retry_delay,
);
tokio::time::sleep(jitter_duration(base)).await;
}
continue;
} else {
return Err(anyhow::anyhow!(err)).with_context(|| {
format!(
"release: upload artifact '{}' to release '{}'",
file_name, tag_c
)
});
}
}
}
}
if let Some(err) = last_err {
return Err(err).with_context(|| {
format!(
"release: upload artifact '{}' to release '{}' failed after {} attempts",
file_name, tag_c, max_upload_attempts
)
});
}
Ok::<String, anyhow::Error>(file_name)
});
}
while let Some(result) = join_set.join_next().await {
match result {
Ok(Ok(file_name)) => {
log.verbose(&format!("uploaded artifact: {}", file_name));
}
Ok(Err(e)) => return Err(e),
Err(join_err) => {
return Err(anyhow::anyhow!(
"release: upload task panicked: {}",
join_err
));
}
}
}
}
if !user_wants_draft {
check_github_rate_limit(&rate_limit_client, &token_str, 10).await;
let publish_route = format!(
"/repos/{}/{}/releases/{}",
github.owner, github.name, release_id_raw
);
let publish_body = build_publish_patch_body(
release_name,
prerelease,
make_latest,
discussion_category_name,
);
let _published: octocrab::models::repos::Release =
retry_octocrab_call(&policy, "publish PATCH", || {
let publish_route = publish_route.clone();
let publish_body = publish_body.clone();
let octo = octo.clone();
async move {
octo.patch::<octocrab::models::repos::Release, _, _>(
publish_route,
Some(&publish_body),
)
.await
}
})
.await
.with_context(|| {
format!(
"release: publish (un-draft) release '{}' on {}/{}",
tag, github.owner, github.name
)
})?;
log.status(&format!(
"published release '{}' (draft -> live)",
release_name
));
}
Ok::<String, anyhow::Error>(html_url)
})?;
Ok(Some((
url,
gh_download_base,
github.owner.clone(),
github.name.clone(),
)))
}
#[cfg(test)]
mod already_exists_tests {
use super::*;
#[test]
fn idempotent_when_remote_matches_local_regardless_of_flag() {
assert_eq!(
classify_already_exists(false, Some(100), 100),
AlreadyExistsAction::SkipIdempotent,
);
assert_eq!(
classify_already_exists(true, Some(100), 100),
AlreadyExistsAction::SkipIdempotent,
);
}
#[test]
fn bails_when_replace_forbidden_and_sizes_differ() {
assert_eq!(
classify_already_exists(false, Some(100), 200),
AlreadyExistsAction::BailReplaceForbidden,
);
assert_eq!(
classify_already_exists(false, None, 200),
AlreadyExistsAction::BailReplaceForbidden,
);
}
#[test]
fn deletes_and_retries_when_replace_allowed_and_sizes_differ() {
assert_eq!(
classify_already_exists(true, Some(100), 200),
AlreadyExistsAction::DeleteAndRetry,
);
assert_eq!(
classify_already_exists(true, None, 200),
AlreadyExistsAction::DeleteAndRetry,
);
}
}
#[cfg(test)]
mod get_by_tag_lookup_tests {
use super::*;
use anodizer_core::retry::RetryPolicy;
use anodizer_core::test_helpers::responder::spawn_oneshot_http_responder;
use std::net::SocketAddr;
use std::sync::atomic::Ordering;
use std::time::Duration;
#[tokio::test]
async fn is_octocrab_404_matches_only_404_github_variant() {
let github_err_404 = synth_github_error(404).await;
assert!(
is_octocrab_404(&github_err_404),
"404 status_code on GitHub variant must classify as 404"
);
let github_err_503 = synth_github_error(503).await;
assert!(
!is_octocrab_404(&github_err_503),
"503 must NOT classify as 404 (would let the caller fall \
through to create-release and surface a downstream 422)"
);
let github_err_422 = synth_github_error(422).await;
assert!(
!is_octocrab_404(&github_err_422),
"422 must NOT classify as 404"
);
let github_err_500 = synth_github_error(500).await;
assert!(
!is_octocrab_404(&github_err_500),
"500 must NOT classify as 404"
);
}
#[tokio::test]
async fn get_by_tag_404_fast_fails_through_helper_to_predicate() {
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nContent-Length: 23\r\n\r\n{\"message\":\"Not Found\"}",
]);
let octo = build_test_octocrab(addr);
let policy = RetryPolicy {
max_attempts: 5,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result: Result<Vec<serde_json::Value>, octocrab::Error> =
retry_octocrab_call(&policy, "get release by tag", || async {
octo.get("/repos/owner/repo/releases/tags/v1.0.0", None::<&()>)
.await
})
.await;
assert!(result.is_err(), "404 must surface as Err from the helper");
let err = result.expect_err("err is Some by the assert above");
assert!(
is_octocrab_404(&err),
"404 must classify so the caller maps to None: got {err:?}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"404 must NOT retry (fast-fail honors classifier)"
);
}
#[tokio::test]
async fn get_by_tag_5xx_retries_then_succeeds_under_helper() {
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 502 Bad Gateway\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{}",
]);
let octo = build_test_octocrab(addr);
let policy = RetryPolicy {
max_attempts: 5,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result: Result<serde_json::Value, octocrab::Error> =
retry_octocrab_call(&policy, "get release by tag", || async {
octo.get("/repos/owner/repo/releases/tags/v1.0.0", None::<&()>)
.await
})
.await;
assert!(
result.is_ok(),
"5xx must retry to success under the get_by_tag label: {:?}",
result.err()
);
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"expected 2 retries past 5xx + 1 success"
);
}
#[tokio::test]
async fn get_by_tag_500_forever_surfaces_real_error_not_404_fallthrough() {
let (addr, calls) = spawn_oneshot_http_responder(vec![
"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n",
"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n",
"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n",
]);
let octo = build_test_octocrab(addr);
let policy = RetryPolicy {
max_attempts: 3,
base_delay: Duration::from_millis(1),
max_delay: Duration::from_millis(2),
};
let result: Result<serde_json::Value, octocrab::Error> =
retry_octocrab_call(&policy, "get release by tag", || async {
octo.get("/repos/owner/repo/releases/tags/v1.0.0", None::<&()>)
.await
})
.await;
assert!(
result.is_err(),
"500-forever must surface as Err, NOT swallow into None"
);
let err = result.expect_err("err is Some by the assert above");
assert!(
!is_octocrab_404(&err),
"500-forever must NOT classify as 404; the backend's only \
non-error fall-through is 404, so misclassifying here would \
trigger the original bug: get_by_tag 5xx -> create-release \
POST -> 422. Got: {err:?}"
);
assert_eq!(
calls.load(Ordering::SeqCst),
3,
"max_attempts=3 must produce exactly 3 octocrab calls"
);
}
async fn synth_github_error(status: u16) -> octocrab::Error {
let body = serde_json::json!({
"message": "synthetic",
"documentation_url": "https://example/synthetic"
})
.to_string();
let resp = format!(
"HTTP/1.1 {status} STATUS\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body,
);
let static_resp: &'static str = Box::leak(resp.into_boxed_str());
let (addr, _calls) = spawn_oneshot_http_responder(vec![static_resp]);
let octo = build_test_octocrab(addr);
octo.get::<serde_json::Value, _, _>("/synthetic", None::<&()>)
.await
.expect_err("synth_github_error: octocrab must surface Err for non-2xx status")
}
fn build_test_octocrab(addr: SocketAddr) -> octocrab::Octocrab {
let builder = octocrab::OctocrabBuilder::new()
.base_uri(format!("http://{addr}/"))
.expect("OctocrabBuilder::base_uri accepts loopback URL");
builder
.build()
.expect("OctocrabBuilder::build succeeds on loopback URL")
}
}
#[cfg(test)]
mod existing_assets_precheck_tests {
use super::*;
#[test]
fn no_conflict_when_release_has_no_assets() {
let result = check_existing_assets_block_upload(false, false, false, &[]);
assert!(result.is_none(), "empty asset list must not block");
}
#[test]
fn no_conflict_when_replace_existing_is_true() {
let result = check_existing_assets_block_upload(false, false, true, &["foo.tar.gz"]);
assert!(
result.is_none(),
"replace_existing_artifacts=true permits overwrite"
);
}
#[test]
fn no_conflict_when_skip_upload_is_true() {
let result = check_existing_assets_block_upload(true, false, false, &["foo.tar.gz"]);
assert!(result.is_none(), "skip_upload=true means nothing to upload");
}
#[test]
fn no_conflict_when_resume_release_is_true() {
let result =
check_existing_assets_block_upload(false, true, false, &["foo.tar.gz", "bar.zip"]);
assert!(
result.is_none(),
"--resume-release must bypass the pre-check"
);
}
#[test]
fn no_conflict_when_replace_existing_cli_override_is_true() {
let result =
check_existing_assets_block_upload(false, false, true, &["foo.tar.gz", "bar.zip"]);
assert!(
result.is_none(),
"--replace-existing must bypass the pre-check via replace_existing_artifacts=true"
);
}
#[test]
fn conflicts_when_assets_present_and_replace_forbidden() {
let assets = &["app_linux_amd64.tar.gz", "checksums.txt"];
let result = check_existing_assets_block_upload(false, false, false, assets);
let names = result.expect("should detect conflict");
assert_eq!(names.len(), 2);
assert!(names.contains(&"app_linux_amd64.tar.gz".to_string()));
assert!(names.contains(&"checksums.txt".to_string()));
}
}