Skip to main content

ephemeris_release/adapters/npm/
publisher.rs

1//! npm package publication with npm.
2
3use std::ffi::OsStr;
4use std::io::Write as _;
5use std::path::Path;
6use std::process::Command;
7
8use ephemeris_token::Token;
9use reqwest::Url;
10use tempfile::NamedTempFile;
11
12use super::NpmError;
13use crate::PackageRegistry;
14use crate::error::PackageFailure;
15use crate::state::ReleaseTarget;
16use crate::unit::Unit;
17
18/// npm publication to one registry.
19#[derive(Debug)]
20pub(crate) struct NpmPublisher<N> {
21    registry: Option<N>,
22    token: Option<Token>,
23    program: &'static str,
24}
25
26/// A temporary npm configuration that authenticates against one registry.
27struct Npmrc(NamedTempFile);
28
29impl<N> NpmPublisher<N> {
30    /// Bind the registry to publish to, when one is configured.
31    #[must_use]
32    pub(crate) const fn new(registry: Option<N>) -> Self {
33        Self {
34            registry,
35            token: None,
36            program: "npm",
37        }
38    }
39
40    /// Publish with `token`.
41    #[must_use]
42    pub(crate) fn authenticated(mut self, token: Token) -> Self {
43        self.token = Some(token);
44        self
45    }
46}
47
48impl<N: PackageRegistry> NpmPublisher<N> {
49    pub(crate) async fn is_published(&self, unit: &Unit) -> Result<bool, NpmError> {
50        if !publishes(unit) {
51            return Ok(true);
52        }
53        let registry = self.registry(unit)?;
54        let name = unit
55            .package_name
56            .as_deref()
57            .ok_or_else(|| NpmError::MissingPackage(unit.name.clone()))?;
58        registry
59            .package_exists(name, &unit.version.to_string())
60            .await
61            .map_err(|error| NpmError::Registry(PackageFailure::new(error)))
62    }
63
64    fn registry(&self, unit: &Unit) -> Result<&N, NpmError> {
65        self.registry
66            .as_ref()
67            .ok_or_else(|| NpmError::MissingRegistry {
68                unit: unit.name.clone(),
69            })
70    }
71
72    pub(crate) async fn publish(&self, unit: &Unit, file: &Path) -> Result<(), NpmError> {
73        if !publishes(unit) {
74            return Ok(());
75        }
76        let token = self.token.as_ref().ok_or(NpmError::MissingToken)?;
77        self.publish_with(token, unit, file).await
78    }
79
80    async fn publish_with(&self, token: &Token, unit: &Unit, file: &Path) -> Result<(), NpmError> {
81        if !publishes(unit) {
82            return Ok(());
83        }
84        let registry_url =
85            self.registry(unit)?
86                .base_url()
87                .ok_or_else(|| NpmError::MissingRegistry {
88                    unit: unit.name.clone(),
89                })?;
90        let npmrc = Npmrc::new(registry_url, token)?;
91        let Err(error) = self.run(file, registry_url.as_str(), &npmrc) else {
92            return Ok(());
93        };
94        // A publish that reached the registry but reported failure, for
95        // example on a timeout, counts as done once the version is visible.
96        if self
97            .is_published(unit)
98            .await
99            .is_ok_and(|published| published)
100        {
101            Ok(())
102        } else {
103            Err(error)
104        }
105    }
106
107    fn run(&self, file: &Path, registry_url: &str, npmrc: &Npmrc) -> Result<(), NpmError> {
108        let file = file.to_string_lossy();
109        let arguments = ["publish", file.as_ref(), "--registry", registry_url];
110        let owned = || arguments.iter().map(ToString::to_string).collect();
111        let output = Command::new(self.program)
112            .args(arguments)
113            .env("NPM_CONFIG_USERCONFIG", npmrc.path())
114            .output()
115            .map_err(|source| NpmError::Spawn {
116                arguments: owned(),
117                source,
118            })?;
119        if output.status.success() {
120            return Ok(());
121        }
122        Err(NpmError::Failed {
123            arguments: owned(),
124            stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
125        })
126    }
127}
128
129impl Npmrc {
130    fn new(registry_url: &Url, token: &Token) -> Result<Self, NpmError> {
131        let line = Self::line(registry_url, token)?;
132        let mut file = NamedTempFile::new().map_err(NpmError::Npmrc)?;
133        writeln!(file, "{line}").map_err(NpmError::Npmrc)?;
134        file.flush().map_err(NpmError::Npmrc)?;
135        Ok(Self(file))
136    }
137
138    /// The line that authenticates against `url`, which names the registry
139    /// without its scheme.
140    fn line(url: &Url, token: &Token) -> Result<String, NpmError> {
141        let host = url
142            .host_str()
143            .ok_or_else(|| NpmError::RegistryWithoutHost {
144                url: url.to_string(),
145            })?;
146        let port = url
147            .port()
148            .map(|port| format!(":{port}"))
149            .unwrap_or_default();
150        let slash = if url.path().ends_with('/') { "" } else { "/" };
151        Ok(format!(
152            "//{host}{port}{}{slash}:_authToken={}",
153            url.path(),
154            token.expose()
155        ))
156    }
157
158    fn path(&self) -> &OsStr {
159        self.0.path().as_os_str()
160    }
161}
162
163/// Whether the unit publishes to npm.
164const fn publishes(unit: &Unit) -> bool {
165    matches!(
166        &unit.target,
167        ReleaseTarget::Npm {
168            manifest: _,
169            publish: true,
170        }
171    )
172}
173
174#[cfg(test)]
175mod tests {
176    use std::cell::Cell;
177    use std::collections::BTreeMap;
178    use std::convert::Infallible;
179    use std::fs;
180    use std::path::Path;
181
182    use ephemeris_token::Token;
183    use reqwest::Url;
184
185    use super::{NpmError, NpmPublisher, Npmrc};
186    use crate::PackageRegistry;
187    use crate::state::ReleaseTarget;
188    use crate::unit::Unit;
189
190    struct VisibleFrom {
191        lookups: Cell<usize>,
192        first_visible: usize,
193        base: Option<Url>,
194    }
195
196    impl PackageRegistry for VisibleFrom {
197        type Error = Infallible;
198
199        async fn package_exists(&self, _: &str, _: &str) -> Result<bool, Self::Error> {
200            let lookup = self.lookups.get();
201            self.lookups.set(lookup.saturating_add(1));
202            Ok(lookup >= self.first_visible)
203        }
204
205        fn base_url(&self) -> Option<&Url> {
206            self.base.as_ref()
207        }
208    }
209
210    fn unit(publish: bool) -> Unit {
211        Unit {
212            name: "web".into(),
213            package_name: Some("@acme/web".into()),
214            version: "1.0.0".parse().unwrap(),
215            target: ReleaseTarget::Npm {
216                manifest: "web/package.json".parse().unwrap(),
217                publish,
218            },
219            source: vec!["web".parse().unwrap()].try_into().unwrap(),
220            changelog: Some("web/CHANGELOG.md".parse().unwrap()),
221            tag_prefix: "web-v".into(),
222            dependencies: Vec::new(),
223            pins: BTreeMap::new(),
224            revision: None,
225        }
226    }
227
228    fn publisher(first_visible: usize) -> NpmPublisher<VisibleFrom> {
229        let mut publisher = NpmPublisher::new(Some(VisibleFrom {
230            lookups: Cell::new(0),
231            first_visible,
232            base: Some(
233                "https://forge.example/api/packages/acme/npm/"
234                    .parse()
235                    .unwrap(),
236            ),
237        }));
238        publisher.program = "false";
239        publisher
240    }
241
242    #[test]
243    fn the_npmrc_line_names_the_registry_without_its_scheme() {
244        let token = Token::new("secret");
245        let line = |url: &str| Npmrc::line(&url.parse().unwrap(), &token);
246        assert_eq!(
247            line("https://forge.example/api/packages/acme/npm").unwrap(),
248            "//forge.example/api/packages/acme/npm/:_authToken=secret"
249        );
250        assert_eq!(
251            line("http://127.0.0.1:3000/").unwrap(),
252            "//127.0.0.1:3000/:_authToken=secret"
253        );
254        assert!(matches!(
255            line("mailto:ops@example.org"),
256            Err(NpmError::RegistryWithoutHost { url: _ })
257        ));
258    }
259
260    #[test]
261    fn the_npmrc_file_holds_the_line() {
262        let npmrc = Npmrc::new(
263            &"https://forge.example/npm/".parse().unwrap(),
264            &Token::new("secret"),
265        )
266        .unwrap();
267        assert_eq!(
268            fs::read_to_string(npmrc.path()).unwrap(),
269            "//forge.example/npm/:_authToken=secret\n"
270        );
271    }
272
273    #[tokio::test]
274    async fn a_unit_that_does_not_publish_counts_as_published() {
275        let publisher = publisher(usize::MAX);
276        assert!(publisher.is_published(&unit(false)).await.unwrap());
277        assert_eq!(publisher.registry.as_ref().unwrap().lookups.get(), 0);
278        assert!(!publisher.is_published(&unit(true)).await.unwrap());
279    }
280
281    #[tokio::test]
282    async fn failed_publish_of_a_version_now_visible_counts_as_published() {
283        let publisher = publisher(0);
284        publisher
285            .publish_with(&Token::new("secret"), &unit(true), Path::new("web.tgz"))
286            .await
287            .unwrap();
288        assert_eq!(publisher.registry.as_ref().unwrap().lookups.get(), 1);
289    }
290
291    #[tokio::test]
292    async fn failed_publish_of_an_absent_version_is_an_error() {
293        assert!(matches!(
294            publisher(usize::MAX)
295                .publish_with(&Token::new("secret"), &unit(true), Path::new("web.tgz"))
296                .await,
297            Err(NpmError::Failed {
298                arguments: _,
299                stderr: _
300            })
301        ));
302    }
303
304    #[tokio::test]
305    async fn a_publish_without_a_configured_registry_is_an_error() {
306        let publisher = NpmPublisher::new(Some(VisibleFrom {
307            lookups: Cell::new(0),
308            first_visible: 0,
309            base: None,
310        }));
311        assert!(matches!(
312            publisher
313                .publish_with(&Token::new("secret"), &unit(true), Path::new("web.tgz"))
314                .await,
315            Err(NpmError::MissingRegistry { unit: _ })
316        ));
317    }
318
319    #[tokio::test]
320    async fn an_unconfigured_registry_fails_before_any_lookup() {
321        let publisher = NpmPublisher::new(None::<VisibleFrom>);
322        assert!(matches!(
323            publisher.is_published(&unit(true)).await,
324            Err(NpmError::MissingRegistry { unit: _ })
325        ));
326    }
327}