anodizer_stage_release/lib.rs
1use anodizer_core::config::PrereleaseConfig;
2use anodizer_core::context::Context;
3use anodizer_core::git;
4use anodizer_core::log::{StageLogger, Verbosity};
5use anodizer_core::scm::ScmTokenType;
6use anyhow::{Context as _, Result};
7
8/// Module-level logger for warnings emitted from helpers (and async upload
9/// retry loops) that don't have runtime access to the stage's
10/// `ctx.logger("release")`. Carries the `release` stage context so these
11/// lines render under the release section header (the per-line tag is gone —
12/// format B), keeping them consistent with the rest of the stage's output.
13pub(crate) fn release_log() -> StageLogger {
14 StageLogger::new("release", Verbosity::Normal)
15}
16
17mod gitea;
18mod github;
19pub use github::fetch_published_asset_names;
20mod gitlab;
21pub mod publisher;
22mod release_body;
23mod run;
24pub use run::collect_release_upload_candidates;
25
26#[cfg(test)]
27mod test_support;
28
29#[cfg(test)]
30mod tests;
31
32// ---------------------------------------------------------------------------
33// classify_asset_conflict — shared release-asset overwrite decision
34// ---------------------------------------------------------------------------
35
36/// The decision for a release asset whose name already exists (or may exist)
37/// on the remote, derived from a byte-size probe plus the user's
38/// `replace_existing_artifacts` setting.
39///
40/// This is the single source of truth for the immutable-releases invariant
41/// shared by every SCM backend: a **byte-identical** remote asset is a no-op,
42/// not an overwrite, so it is skipped REGARDLESS of `replace_existing_artifacts`
43/// — the user's flag guards against replacing *different* bytes, never against
44/// re-uploading the same bytes. Each backend maps these variants onto its own
45/// action type (GitHub's post-422 `AlreadyExistsAction`, Gitea's pre-upload
46/// `GiteaUploadAction`).
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub(crate) enum AssetConflict {
49 /// Remote asset is present and byte-identical to the local file: skip the
50 /// upload (idempotent no-op), independent of the replace flag.
51 IdenticalSkip,
52 /// Remote asset is present, differs from the local file, and the user
53 /// opted into overwrites (`replace_existing_artifacts: true`): delete the
54 /// stale asset, then upload.
55 ReplaceDiffering,
56 /// Remote asset is present, differs from the local file, and overwrites are
57 /// forbidden (`replace_existing_artifacts: false`): surface the conflict
58 /// instead of mutating published bytes.
59 ConflictForbidden,
60 /// No conflicting remote asset to reconcile: upload as-is.
61 NoConflict,
62}
63
64/// Classify a release-asset upload against any same-named remote asset.
65///
66/// `remote_present` is whether a remote asset with the target name exists at
67/// all; `remote_size` is its byte size when known (`None` = present but size
68/// unreadable). `local_size` is the local file's byte count.
69///
70/// Pure (no I/O) so the overwrite decision is unit-testable without a live
71/// API client. The same-size idempotent skip fires regardless of
72/// `replace_existing_artifacts`; a differing remote routes to overwrite when
73/// the flag is set and to a forbidden-conflict otherwise. An unknown remote
74/// size on a present asset is treated as a mismatch (better to bail/replace
75/// than silently keep possibly-wrong bytes).
76pub(crate) fn classify_asset_conflict(
77 replace_existing_artifacts: bool,
78 remote_present: bool,
79 remote_size: Option<u64>,
80 local_size: u64,
81) -> AssetConflict {
82 if !remote_present {
83 return AssetConflict::NoConflict;
84 }
85 if remote_size == Some(local_size) {
86 return AssetConflict::IdenticalSkip;
87 }
88 if replace_existing_artifacts {
89 AssetConflict::ReplaceDiffering
90 } else {
91 AssetConflict::ConflictForbidden
92 }
93}
94
95// ---------------------------------------------------------------------------
96// retry_upload — shared exponential-backoff retry for upload operations
97// ---------------------------------------------------------------------------
98
99/// Retry an async upload operation with exponential backoff.
100/// 10 attempts, 50ms initial delay, 30s cap.
101///
102/// # Layering note
103///
104/// As of P1.4, gitlab/gitea publishers themselves call `retry_http_async`
105/// internally with the user's `Config.retry` policy. Wrapping those
106/// already-retrying calls in `retry_upload` (here) produces nested-retry
107/// behavior: the inner helper exhausts its policy first, then this outer
108/// loop retries up to its own 10 attempts. The total worst-case latency
109/// grows accordingly. This is intentional — the per-publisher inner
110/// policy gives the user a configurable surface that didn't exist before,
111/// and the outer loop stays as the safety net.
112///
113/// # Classifier alignment with the inner helpers
114///
115/// The inner `retry_http_async` already classifies via [`is_retriable`]
116/// (5xx / 429 / network-substring → retry, 4xx → fast-fail). The outer
117/// loop here MUST honor the same classification: blindly retrying every
118/// `Err` would amplify a 4xx fast-fail by 10×, defeating the inner's
119/// decision. We re-run [`is_retriable`] on the bubbled-up error and
120/// `Break` on non-retriable failures, matching the inner's policy and
121/// the intended retry envelope.
122pub(crate) async fn retry_upload<F, Fut>(operation_name: &str, mut f: F) -> Result<()>
123where
124 F: FnMut() -> Fut,
125 Fut: std::future::Future<Output = Result<()>>,
126{
127 use anodizer_core::retry::{RetryPolicy, is_retriable, retry_async};
128 use std::ops::ControlFlow;
129 retry_async(&RetryPolicy::UPLOAD, |_attempt| {
130 let fut = f();
131 async move {
132 match fut.await {
133 Ok(()) => Ok(()),
134 Err(e) if is_retriable(e.as_ref()) => Err(ControlFlow::Continue(e)),
135 Err(e) => Err(ControlFlow::Break(e)),
136 }
137 }
138 })
139 .await
140 .with_context(|| format!("{operation_name}: retry exhausted"))
141}
142
143// ---------------------------------------------------------------------------
144// populate_artifact_download_urls
145// ---------------------------------------------------------------------------
146
147/// Set `metadata["url"]` on every artifact for the given crate, constructing
148/// the download URL from the SCM backend's download base, owner/repo, tag, and
149/// artifact name. This lets publishers resolve download URLs without an
150/// explicit `url_template`.
151pub(crate) fn populate_artifact_download_urls(
152 ctx: &mut Context,
153 crate_name: &str,
154 token_type: ScmTokenType,
155 download_base: &str,
156 owner: &str,
157 repo: &str,
158 tag: &str,
159) {
160 let dl_base = download_base.trim_end_matches('/');
161 let url_tag = anodizer_core::url::percent_encode_path_segment(tag);
162 let url_prefix = match token_type {
163 ScmTokenType::GitLab => {
164 if owner.is_empty() {
165 format!("{dl_base}/{repo}/-/releases/{url_tag}/downloads")
166 } else {
167 format!("{dl_base}/{owner}/{repo}/-/releases/{url_tag}/downloads")
168 }
169 }
170 ScmTokenType::GitHub | ScmTokenType::Gitea => {
171 format!("{dl_base}/{owner}/{repo}/releases/download/{url_tag}")
172 }
173 };
174 for artifact in ctx.artifacts.all_mut() {
175 if artifact.crate_name == crate_name && !artifact.name.is_empty() {
176 let encoded_name = anodizer_core::url::percent_encode_path_segment(&artifact.name);
177 artifact
178 .metadata
179 .insert("url".to_string(), format!("{url_prefix}/{encoded_name}"));
180 }
181 }
182}
183
184// ---------------------------------------------------------------------------
185// render_repo_ref
186// ---------------------------------------------------------------------------
187
188/// Pick the `ScmRepoConfig` for the active publish target and template-render
189/// its `owner` and `name` fields.
190///
191/// Resolution order:
192/// 1. Explicit `release.provider:`.
193/// 2. Active SCM token type with provider-side fallback (the historical
194/// behaviour — preserved so existing configs don't change shape).
195///
196/// Returns `Ok(None)` when no matching block is configured.
197pub(crate) fn resolve_release_repo(
198 release_cfg: &anodizer_core::config::ReleaseConfig,
199 token_type: ScmTokenType,
200 ctx: &anodizer_core::context::Context,
201) -> Result<Option<anodizer_core::config::ScmRepoConfig>> {
202 // Explicit `release.provider:` wins over token-type inference. This
203 // is the cross-platform publishing seam: a project hosted on GitLab
204 // (so `GITLAB_TOKEN` is the active token) can declare
205 // `provider: github` to redirect publish output to GitHub.
206 use anodizer_core::config::ForceTokenKind;
207 let raw = match release_cfg.provider {
208 Some(ForceTokenKind::GitHub) => release_cfg.github.as_ref(),
209 Some(ForceTokenKind::GitLab) => release_cfg.gitlab.as_ref(),
210 Some(ForceTokenKind::Gitea) => release_cfg.gitea.as_ref(),
211 None => match token_type {
212 ScmTokenType::GitLab => release_cfg.gitlab.as_ref().or(release_cfg.github.as_ref()),
213 ScmTokenType::Gitea => release_cfg.gitea.as_ref().or(release_cfg.github.as_ref()),
214 ScmTokenType::GitHub => release_cfg.github.as_ref(),
215 },
216 };
217 let Some(repo) = raw else {
218 return Ok(None);
219 };
220 let owner = ctx
221 .render_template(&repo.owner)
222 .with_context(|| format!("release: render repo.owner '{}'", repo.owner))?;
223 let name = ctx
224 .render_template(&repo.name)
225 .with_context(|| format!("release: render repo.name '{}'", repo.name))?;
226 Ok(Some(anodizer_core::config::ScmRepoConfig { owner, name }))
227}
228
229/// Compose the public release HTML URL for the active SCM provider.
230///
231/// GitLab omits the `/{owner}` segment when `owner` is empty (a top-level
232/// project with no namespace), matching the authoritative
233/// [`gitlab::gitlab_release_url`] path. Without this, an empty owner would
234/// emit a double-slash `{base}//{repo}/-/releases/{tag}` that diverges from
235/// the URL the live create returns. GitHub / Gitea always include the owner
236/// segment, mirroring their authoritative composers.
237pub(crate) fn compose_release_url(
238 token_type: ScmTokenType,
239 download_base: &str,
240 owner: &str,
241 repo: &str,
242 tag: &str,
243) -> String {
244 let base = download_base.trim_end_matches('/');
245 match token_type {
246 ScmTokenType::GitHub | ScmTokenType::Gitea => {
247 format!("{}/{}/{}/releases/tag/{}", base, owner, repo, tag)
248 }
249 ScmTokenType::GitLab => {
250 if owner.is_empty() {
251 format!("{}/{}/-/releases/{}", base, repo, tag)
252 } else {
253 format!("{}/{}/{}/-/releases/{}", base, owner, repo, tag)
254 }
255 }
256 }
257}
258
259// ---------------------------------------------------------------------------
260// should_mark_prerelease
261// ---------------------------------------------------------------------------
262
263/// Decide whether the GitHub Release should be marked as a pre-release.
264///
265/// - `Auto` – inspect the tag for common pre-release suffixes.
266/// - `Bool(b)` – use the explicit value regardless of the tag.
267/// - `None` – default to `false`.
268///
269/// # Design note
270///
271/// A prerelease decision could be made once at config-load time by inspecting
272/// the parsed semver's prerelease segment and storing a single flag for the
273/// whole release run, so every release in the run shares that one decision.
274///
275/// Anodizer evaluates per-tag at run time. Each crate in a workspace can
276/// have an independent tag with its own prerelease suffix, so a single
277/// global decision doesn't translate to the workspace model. For example,
278/// a workspace release that bumps `core` to `v1.2.3` and `cli` to
279/// `v0.4.0-rc.1` should mark only the `cli` release as prerelease — which
280/// only works when the decision is per-tag, not per-run.
281pub(crate) fn should_mark_prerelease(config: &Option<PrereleaseConfig>, tag: &str) -> bool {
282 match config {
283 Some(PrereleaseConfig::Auto) => git::parse_semver_tag(tag)
284 .map(|sv| sv.is_prerelease())
285 .unwrap_or(false),
286 Some(PrereleaseConfig::Bool(b)) => *b,
287 None => false,
288 }
289}
290
291// build_release_body, collect_extra_files, resolve_make_latest,
292// resolve_content_source, compose_body_for_mode, build_release_json,
293// resolve_release_tag live in `release_body.rs`. Mode-resolution is on
294// `ReleaseConfig::resolved_mode` (lazy-defaults policy).
295
296// ---------------------------------------------------------------------------
297// populate_checksums_var
298// ---------------------------------------------------------------------------
299
300/// Populate the `{{ .Checksums }}` template variable from the registered
301/// `ArtifactKind::Checksum` artifacts.
302///
303/// # Mode selection
304///
305/// The release-body description emits two shapes:
306///
307/// - 0 artifacts → unset / empty string
308/// - 1 artifact → string with the combined file's contents
309/// - ≥2 artifacts (split-mode sidecars) → `map[ChecksumOf]contents` so a
310/// Tera template can do `{% for k, v in Checksums %}…{% endfor %}`
311///
312/// Anodizer's workspace model adds a third case:
313/// **multiple combined-mode sidecars**, one per crate. The checksum stage
314/// marks those with `metadata["combined"] = "true"` (and leaves
315/// `ChecksumOf` unset). Without aggregation, the ≥2-artifact branch above
316/// would collide every combined file on an empty `ChecksumOf` key, leaking
317/// the build host's filesystem layout into release notes and dropping
318/// every crate's content except the last. Instead, when every checksum
319/// artifact is a combined-mode sidecar, this helper UNIONS all per-crate
320/// content lines into a single SHA256SUMS-style block, deduplicated and
321/// sorted alphabetically by filename (matching the per-crate sort the
322/// checksum stage already applies, and following the convention so a
323/// release body templated with `{{ .Checksums }}` renders the full
324/// workspace inventory).
325///
326/// Mixed mode (some combined + some split sidecars) falls back to a map keyed
327/// by `ChecksumOf` for every artifact, with the
328/// combined files keyed by their artifact `name` since they have no
329/// `ChecksumOf`. Mixed mode is unusual but the map shape stays consistent
330/// for templates that already iterate with `{% for k, v in Checksums %}`.
331pub(crate) fn populate_checksums_var(ctx: &mut Context) {
332 use anodizer_core::artifact::ArtifactKind;
333
334 let checksum_artifacts = ctx.artifacts.by_kind(ArtifactKind::Checksum);
335 if checksum_artifacts.is_empty() {
336 ctx.template_vars_mut().set("Checksums", "");
337 return;
338 }
339
340 let is_combined = |a: &&anodizer_core::artifact::Artifact| {
341 a.metadata.get("combined").map(|s| s.as_str()) == Some("true")
342 };
343 let all_combined = checksum_artifacts.iter().all(is_combined);
344 let any_split = checksum_artifacts
345 .iter()
346 .any(|a| a.metadata.contains_key("ChecksumOf"));
347
348 if all_combined && !any_split {
349 let mut lines: Vec<String> = Vec::new();
350 for artifact in &checksum_artifacts {
351 let content = std::fs::read_to_string(&artifact.path).unwrap_or_default();
352 for line in content.lines() {
353 if !line.is_empty() {
354 lines.push(line.to_string());
355 }
356 }
357 }
358 lines.sort_by(|a, b| {
359 let name_a = a.split_once(" ").map(|(_, n)| n).unwrap_or(a);
360 let name_b = b.split_once(" ").map(|(_, n)| n).unwrap_or(b);
361 name_a.cmp(name_b)
362 });
363 lines.dedup();
364 ctx.template_vars_mut().set("Checksums", &lines.join("\n"));
365 return;
366 }
367
368 let mut map = serde_json::Map::new();
369 for artifact in &checksum_artifacts {
370 let key = artifact
371 .metadata
372 .get("ChecksumOf")
373 .cloned()
374 .unwrap_or_else(|| artifact.name.clone());
375 let content = std::fs::read_to_string(&artifact.path).unwrap_or_default();
376 map.insert(key, serde_json::Value::String(content));
377 }
378 ctx.template_vars_mut()
379 .set_structured("Checksums", serde_json::Value::Object(map));
380}
381
382// ---------------------------------------------------------------------------
383// ReleaseStage
384// ---------------------------------------------------------------------------
385
386pub struct ReleaseStage;
387
388#[cfg(test)]
389mod asset_conflict_tests {
390 //! The shared overwrite classifier consumed by both the GitHub and Gitea
391 //! backends. The byte-identical-skip invariant lives here once; the
392 //! per-backend projection tests (`spec.rs` / `gitea.rs`) pin the mapping
393 //! onto their own action enums.
394 use super::{AssetConflict, classify_asset_conflict};
395
396 #[test]
397 fn absent_remote_is_no_conflict_regardless_of_flag() {
398 assert_eq!(
399 classify_asset_conflict(false, false, None, 100),
400 AssetConflict::NoConflict
401 );
402 assert_eq!(
403 classify_asset_conflict(true, false, None, 100),
404 AssetConflict::NoConflict
405 );
406 }
407
408 #[test]
409 fn identical_bytes_skip_regardless_of_flag() {
410 // The cardinal invariant: same size = idempotent no-op even when
411 // `replace_existing_artifacts: false`.
412 assert_eq!(
413 classify_asset_conflict(false, true, Some(100), 100),
414 AssetConflict::IdenticalSkip
415 );
416 assert_eq!(
417 classify_asset_conflict(true, true, Some(100), 100),
418 AssetConflict::IdenticalSkip
419 );
420 }
421
422 #[test]
423 fn differing_bytes_with_replace_allowed_overwrites() {
424 assert_eq!(
425 classify_asset_conflict(true, true, Some(100), 200),
426 AssetConflict::ReplaceDiffering
427 );
428 // Unknown remote size on a present asset is treated as a mismatch.
429 assert_eq!(
430 classify_asset_conflict(true, true, None, 200),
431 AssetConflict::ReplaceDiffering
432 );
433 }
434
435 #[test]
436 fn differing_bytes_with_replace_forbidden_is_conflict() {
437 assert_eq!(
438 classify_asset_conflict(false, true, Some(100), 200),
439 AssetConflict::ConflictForbidden
440 );
441 // Present-but-unreadable size + no opt-in: bail rather than mutate.
442 assert_eq!(
443 classify_asset_conflict(false, true, None, 200),
444 AssetConflict::ConflictForbidden
445 );
446 }
447}