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 super::secondary_rate_limit::RetryAfterCapture;
use super::{build_octocrab_client, is_octocrab_404, retry_octocrab_call};
use crate::resolve_release_repo;
const LIST_RELEASES_PAGE_SIZE: usize = 100;
pub(crate) async fn find_draft_by_name(
octo: &Arc<octocrab::Octocrab>,
owner: &str,
repo: &str,
name: &str,
policy: &anodizer_core::retry::RetryPolicy,
retry_after: Option<&RetryAfterCapture>,
) -> Result<Option<octocrab::models::repos::Release>> {
let mut page: u32 = 1;
loop {
let route = format!(
"/repos/{}/{}/releases?per_page={}&page={}",
owner, repo, LIST_RELEASES_PAGE_SIZE, page
);
let releases: Vec<octocrab::models::repos::Release> =
retry_octocrab_call(policy, "list releases", retry_after, || {
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() < LIST_RELEASES_PAGE_SIZE {
break;
}
page += 1;
}
Ok(None)
}
pub(super) async fn find_release_by_tag(
octo: &Arc<octocrab::Octocrab>,
owner: &str,
repo: &str,
tag: &str,
policy: &anodizer_core::retry::RetryPolicy,
retry_after: Option<&RetryAfterCapture>,
label: &'static str,
) -> Result<Option<octocrab::models::repos::Release>> {
let owner = owner.to_string();
let repo = repo.to_string();
let tag = tag.to_string();
let result: Result<octocrab::models::repos::Release, octocrab::Error> =
retry_octocrab_call(policy, label, retry_after, || {
let octo = octo.clone();
let owner = owner.clone();
let repo = repo.clone();
let tag = tag.clone();
async move { octo.repos(&owner, &repo).releases().get_by_tag(&tag).await }
})
.await;
match result {
Ok(release) => Ok(Some(release)),
Err(err) if is_octocrab_404(&err) => Ok(None),
Err(err) => Err(anyhow::Error::new(err)),
}
}
pub async fn fetch_published_asset_names(
ctx: &Context,
release_cfg: &ReleaseConfig,
crate_cfg: &CrateConfig,
) -> Result<Option<Vec<String>>> {
let Some(repo) = resolve_release_repo(release_cfg, ctx.token_type, ctx)? else {
return Ok(None);
};
let tag = crate::release_body::resolve_release_tag(
ctx,
&crate_cfg.tag_template,
release_cfg.tag.as_deref(),
&crate_cfg.name,
)?;
let token = ctx.options.token.clone().ok_or_else(|| {
anyhow::anyhow!(
"verify-release: no GitHub token available to fetch the published \
release's assets (set GITHUB_TOKEN or ANODIZER_GITHUB_TOKEN, or pass --token)"
)
})?;
let github_urls = ctx.config.github_urls.clone();
let policy = ctx.retry_policy();
let (octo_raw, retry_after) = build_octocrab_client(&token, &github_urls)?;
let octo = Arc::new(octo_raw);
let release = find_release_by_tag(
&octo,
&repo.owner,
&repo.name,
&tag,
&policy,
Some(&retry_after),
"verify-release fetch published assets",
)
.await?;
match release {
Some(rel) => Ok(Some(rel.assets.into_iter().map(|a| a.name).collect())),
None => anyhow::bail!(
"verify-release: no GitHub release found for tag '{}' on {}/{} — \
the publish should have created it; this is a post-publish defect",
tag,
repo.owner,
repo.name
),
}
}
const READINESS_GUARD_ATTEMPTS: u32 = 8;
const READINESS_GUARD_BASE_DELAY: std::time::Duration = std::time::Duration::from_millis(100);
const READINESS_GUARD_MAX_DELAY: std::time::Duration = std::time::Duration::from_millis(1500);
pub(super) async fn wait_for_release_readable(
octo: &Arc<octocrab::Octocrab>,
owner: &str,
repo: &str,
release_id: u64,
log: &StageLogger,
) -> Result<bool> {
let mut delay = READINESS_GUARD_BASE_DELAY;
for attempt in 1..=READINESS_GUARD_ATTEMPTS {
let route = format!("/repos/{owner}/{repo}/releases/{release_id}");
let result = octo
.get::<octocrab::models::repos::Release, _, _>(route, None::<&()>)
.await;
match result {
Ok(_) => {
if attempt > 1 {
log.verbose(&format!(
"release {release_id} became readable after {attempt} probe(s) \
(GitHub post-create propagation lag)"
));
}
return Ok(true);
}
Err(err) if is_octocrab_404(&err) => {
if attempt < READINESS_GUARD_ATTEMPTS {
tokio::time::sleep(jitter_duration(delay)).await;
delay = std::cmp::min(delay * 2, READINESS_GUARD_MAX_DELAY);
}
}
Err(err) => return Err(anyhow::Error::new(err)),
}
}
Ok(false)
}
pub(super) async fn list_releases_by_name(
octo: &Arc<octocrab::Octocrab>,
owner: &str,
repo: &str,
name: &str,
policy: &anodizer_core::retry::RetryPolicy,
retry_after: Option<&RetryAfterCapture>,
) -> Result<Vec<(u64, String)>> {
let mut out = Vec::new();
let mut page: u32 = 1;
loop {
let route = format!(
"/repos/{}/{}/releases?per_page={}&page={}",
owner, repo, LIST_RELEASES_PAGE_SIZE, page
);
let releases: Vec<octocrab::models::repos::Release> =
retry_octocrab_call(policy, "list releases (retention)", retry_after, || {
let route = route.clone();
let octo = octo.clone();
async move { octo.get(route, None::<&()>).await }
})
.await
.with_context(|| {
format!(
"release: list releases on {}/{} for retention (page {})",
owner, repo, page
)
})?;
let page_len = releases.len();
for r in releases {
if r.name.as_deref() == Some(name) {
out.push((r.id.into_inner(), r.tag_name));
}
}
if page_len < LIST_RELEASES_PAGE_SIZE {
break;
}
page += 1;
}
Ok(out)
}
#[cfg(test)]
mod find_draft_by_name_tests {
use super::*;
use crate::test_support::{build_test_octocrab, test_retry_policy};
use anodizer_core::test_helpers::responder::spawn_oneshot_http_responder;
use std::sync::atomic::Ordering;
fn build_release_list_body(
count: usize,
match_idx: Option<usize>,
target_name: &str,
) -> String {
let mut entries = Vec::with_capacity(count);
for i in 0..count {
let (draft, name) = match match_idx {
Some(idx) if idx == i => (true, target_name.to_string()),
_ => (false, format!("other-release-{i}")),
};
entries.push(serde_json::json!({
"id": 1000 + i as u64,
"node_id": format!("RL_{i}"),
"tag_name": format!("v0.0.{i}"),
"target_commitish": "main",
"name": name,
"draft": draft,
"prerelease": false,
"created_at": "2026-01-01T00:00:00Z",
"published_at": null,
"author": null,
"assets": [],
"tarball_url": null,
"zipball_url": null,
"body": null,
"url": format!("https://api.github.com/repos/o/r/releases/{}", 1000 + i),
"html_url": format!("https://github.com/o/r/releases/{}", 1000 + i),
"assets_url": format!("https://api.github.com/repos/o/r/releases/{}/assets", 1000 + i),
"upload_url": format!("https://uploads.github.com/repos/o/r/releases/{}/assets{{?name,label}}", 1000 + i),
}));
}
serde_json::Value::Array(entries).to_string()
}
fn build_release_list_response(body: String) -> &'static str {
let raw = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body,
);
Box::leak(raw.into_boxed_str())
}
#[tokio::test]
async fn single_page_with_matching_draft_returns_some() {
let body = build_release_list_body(3, Some(1), "v1.2.3");
let (addr, calls) = spawn_oneshot_http_responder(vec![build_release_list_response(body)]);
let octo = build_test_octocrab(addr);
let policy = test_retry_policy();
let found = find_draft_by_name(&octo, "o", "r", "v1.2.3", &policy, None)
.await
.expect("draft search must succeed");
let release = found.expect("draft with matching name must be found");
assert_eq!(release.name.as_deref(), Some("v1.2.3"));
assert!(release.draft, "matched release must be a draft");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"single-page search must issue exactly one list-releases call",
);
}
#[tokio::test]
async fn single_page_no_match_returns_none() {
let body = build_release_list_body(3, None, "v9.9.9");
let (addr, _calls) = spawn_oneshot_http_responder(vec![build_release_list_response(body)]);
let octo = build_test_octocrab(addr);
let policy = test_retry_policy();
let found = find_draft_by_name(&octo, "o", "r", "v9.9.9", &policy, None)
.await
.expect("draft search must succeed");
assert!(
found.is_none(),
"no draft matches the target name => Ok(None)",
);
}
#[tokio::test]
async fn name_matches_but_not_draft_returns_none() {
let body = build_release_list_body(2, None, "ignored");
let mut entries: Vec<serde_json::Value> = serde_json::from_str(&body).expect("array");
entries[0]["name"] = serde_json::Value::String("v1.2.3".to_string());
entries[0]["draft"] = serde_json::Value::Bool(false);
let body = serde_json::Value::Array(entries).to_string();
let (addr, _calls) = spawn_oneshot_http_responder(vec![build_release_list_response(body)]);
let octo = build_test_octocrab(addr);
let policy = test_retry_policy();
let found = find_draft_by_name(&octo, "o", "r", "v1.2.3", &policy, None)
.await
.expect("draft search must succeed");
assert!(
found.is_none(),
"published release with matching name must NOT count as a draft hit",
);
}
#[tokio::test]
async fn paginates_across_pages_until_match_found() {
let page_1 = build_release_list_body(100, None, "v1.2.3");
let page_2 = build_release_list_body(5, Some(0), "v1.2.3");
let (addr, calls) = spawn_oneshot_http_responder(vec![
build_release_list_response(page_1),
build_release_list_response(page_2),
]);
let octo = build_test_octocrab(addr);
let policy = test_retry_policy();
let found = find_draft_by_name(&octo, "o", "r", "v1.2.3", &policy, None)
.await
.expect("paginated draft search must succeed");
let release = found.expect("draft on page 2 must be found");
assert_eq!(release.name.as_deref(), Some("v1.2.3"));
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"pagination must consume exactly 2 list-releases calls (full first page + second page)",
);
}
#[tokio::test]
async fn paginates_to_exhaustion_returns_none() {
let page_1 = build_release_list_body(100, None, "missing");
let page_2 = build_release_list_body(50, None, "missing");
let (addr, calls) = spawn_oneshot_http_responder(vec![
build_release_list_response(page_1),
build_release_list_response(page_2),
]);
let octo = build_test_octocrab(addr);
let policy = test_retry_policy();
let found = find_draft_by_name(&octo, "o", "r", "missing", &policy, None)
.await
.expect("draft search must succeed even when no match");
assert!(
found.is_none(),
"exhausted listing with no match => Ok(None)",
);
assert_eq!(
calls.load(Ordering::SeqCst),
2,
"must fetch both pages before terminating on the partial page",
);
}
}
#[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", None, || 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", None, || 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", None, || 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")
}
}