Skip to main content

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}