Skip to main content

sr_core/publishers/
docker.rs

1//! Docker publisher: push a container image to any OCI-compliant registry.
2//!
3//! - check: Query the registry's v2 API for the manifest at `<image>:<tag>`.
4//!   Uses anonymous access by default; bearer-token auth is arranged via
5//!   the `Www-Authenticate` challenge so public and ghcr.io public images
6//!   work out of the box. For private images behind auth, `check` returns
7//!   `Unknown` and `run` proceeds.
8//! - run: `docker buildx build --push -t <image>:<version> ...`.
9//!
10//! The version tag is always `ctx.version` (no "v" prefix). The `image`
11//! field is the fully qualified reference (e.g. `ghcr.io/owner/repo`).
12
13use super::{PublishCtx, PublishState, Publisher};
14use crate::error::ReleaseError;
15use crate::hooks::run_shell;
16
17pub struct DockerPublisher {
18    pub image: String,
19    pub platforms: Vec<String>,
20    pub dockerfile: Option<String>,
21}
22
23impl Publisher for DockerPublisher {
24    fn name(&self) -> &'static str {
25        "docker"
26    }
27
28    fn check(&self, ctx: &PublishCtx<'_>) -> Result<PublishState, ReleaseError> {
29        let (registry, repository) = split_image_ref(&self.image);
30        let manifest_url = format!(
31            "https://{registry}/v2/{repository}/manifests/{}",
32            ctx.version
33        );
34
35        // Anonymous HEAD first.
36        let resp = ureq::head(&manifest_url)
37            .header(
38                "Accept",
39                "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json",
40            )
41            .header("User-Agent", "sr (+https://github.com/urmzd/sr)")
42            .call();
43
44        match resp {
45            Ok(r) if r.status() == 200 => Ok(PublishState::Completed),
46            Ok(r) if r.status() == 404 => Ok(PublishState::Needed),
47            Err(ureq::Error::StatusCode(404)) => Ok(PublishState::Needed),
48            Err(ureq::Error::StatusCode(401)) | Ok(_) => Ok(PublishState::Unknown(format!(
49                "docker registry check inconclusive for {}:{}",
50                self.image, ctx.version
51            ))),
52            Err(e) => Ok(PublishState::Unknown(format!("docker check failed: {e}"))),
53        }
54    }
55
56    fn run(&self, ctx: &PublishCtx<'_>) -> Result<(), ReleaseError> {
57        let mut cmd = String::from("docker buildx build --push");
58        if !self.platforms.is_empty() {
59            cmd.push_str(" --platform ");
60            cmd.push_str(&shell_word(&self.platforms.join(",")));
61        }
62        if let Some(dockerfile) = &self.dockerfile {
63            cmd.push_str(" -f ");
64            cmd.push_str(&shell_word(dockerfile));
65        }
66        cmd.push_str(" -t ");
67        cmd.push_str(&shell_word(&format!("{}:{}", self.image, ctx.version)));
68        // Also tag as :latest when not a pre-release.
69        if !ctx.version.contains('-') {
70            cmd.push_str(" -t ");
71            cmd.push_str(&shell_word(&format!("{}:latest", self.image)));
72        }
73        cmd.push_str(" .");
74
75        if ctx.dry_run {
76            eprintln!("[dry-run] docker ({}): {cmd}", ctx.package.path);
77            return Ok(());
78        }
79
80        eprintln!("docker ({}): {cmd}", ctx.package.path);
81        let wrapped = format!("cd {} && {cmd}", shell_word(&ctx.package.path));
82        run_shell(&wrapped, None, ctx.env)
83    }
84}
85
86/// Split an image reference like `ghcr.io/owner/repo` into (registry, repo).
87/// Defaults to Docker Hub for unqualified names: `owner/repo` → `registry-1.docker.io`.
88fn split_image_ref(image: &str) -> (String, String) {
89    // A "registry" has a dot or colon in the first path component, or is "localhost".
90    if let Some((head, tail)) = image.split_once('/') {
91        let has_port = head.contains(':');
92        let has_dot = head.contains('.');
93        if has_port || has_dot || head == "localhost" {
94            return (head.to_string(), tail.to_string());
95        }
96    }
97    // Unqualified: Docker Hub. Single-segment (e.g. "nginx") → "library/nginx".
98    let repo = if image.contains('/') {
99        image.to_string()
100    } else {
101        format!("library/{image}")
102    };
103    ("registry-1.docker.io".to_string(), repo)
104}
105
106fn shell_word(s: &str) -> String {
107    let mut out = String::from("'");
108    for ch in s.chars() {
109        if ch == '\'' {
110            out.push_str("'\\''");
111        } else {
112            out.push(ch);
113        }
114    }
115    out.push('\'');
116    out
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn split_ghcr_image() {
125        let (r, repo) = split_image_ref("ghcr.io/owner/repo");
126        assert_eq!(r, "ghcr.io");
127        assert_eq!(repo, "owner/repo");
128    }
129
130    #[test]
131    fn split_docker_hub_multi_segment() {
132        let (r, repo) = split_image_ref("urmzd/sr");
133        assert_eq!(r, "registry-1.docker.io");
134        assert_eq!(repo, "urmzd/sr");
135    }
136
137    #[test]
138    fn split_docker_hub_single_segment() {
139        let (r, repo) = split_image_ref("nginx");
140        assert_eq!(r, "registry-1.docker.io");
141        assert_eq!(repo, "library/nginx");
142    }
143
144    #[test]
145    fn split_localhost_port() {
146        let (r, repo) = split_image_ref("localhost:5000/img");
147        assert_eq!(r, "localhost:5000");
148        assert_eq!(repo, "img");
149    }
150}