Skip to main content

ossctl_core/release/adapters/
node.rs

1//! Node ecosystem adapter: `release-please`, `changesets`, and `npm-publish`.
2//!
3//! Publishes packages to the npm registry. `npm-publish` publishes directly and
4//! `changesets` runs `changeset publish` (both REAL host publishes).
5//! `release-please` publishes on merge via a CI job keyed off the GitHub release,
6//! never from this host, so its `publish` honestly returns
7//! [`AdapterError::Unsupported`] rather than fabricating an npm receipt (matching
8//! `cargo-dist` and `gh-action-pypi-publish`). `verify` reconciles against npm via
9//! the default [`RegistryQuery`](crate::ports::RegistryQuery) path.
10
11use std::time::Duration;
12
13use serde::Deserialize;
14
15use crate::contract::schema::Adapter;
16use crate::protocol::release::{BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt};
17
18use super::{make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, ReleaseAdapter};
19
20/// The node release adapter, operating as `release-please`, `changesets`, or
21/// `npm-publish`.
22pub struct NodeAdapter {
23    adapter: Adapter,
24}
25
26impl NodeAdapter {
27    /// Construct for a resolved node adapter identity.
28    #[must_use]
29    pub fn new(adapter: Adapter) -> Self {
30        debug_assert!(matches!(
31            adapter,
32            Adapter::ReleasePlease | Adapter::Changesets | Adapter::NpmPublish
33        ));
34        Self { adapter }
35    }
36}
37
38impl ReleaseAdapter for NodeAdapter {
39    fn adapter(&self) -> Adapter {
40        self.adapter
41    }
42
43    fn dry_run(
44        &self,
45        _ctx: &EffectCtx<'_>,
46        _t: &AdapterTarget,
47    ) -> Result<DryRunReport, AdapterError> {
48        let (planned_commands, notes) = match self.adapter {
49            Adapter::Changesets => (
50                vec![PlannedCommand::new("changeset", &["status", "--verbose"])],
51                vec![],
52            ),
53            Adapter::ReleasePlease => (
54                vec![PlannedCommand::new(
55                    "release-please",
56                    &["release-pr", "--dry-run"],
57                )],
58                vec!["release-please publishes on merge via CI, not from this host".to_string()],
59            ),
60            _ => (
61                vec![PlannedCommand::new("npm", &["publish", "--dry-run"])],
62                vec![],
63            ),
64        };
65        Ok(DryRunReport {
66            adapter: self.adapter,
67            planned_commands,
68            notes,
69        })
70    }
71
72    fn build(
73        &self,
74        ctx: &EffectCtx<'_>,
75        _t: &AdapterTarget,
76    ) -> Result<BuildArtifacts, AdapterError> {
77        // `npm pack --json` reports the packed tarball's exact filename, which is
78        // not always `{package}-{version}.tgz`: a scoped package `@scope/pkg` packs
79        // to `scope-pkg-{version}.tgz`. Read the name npm actually produced so the
80        // asset the coordinator threads into the binary / GitHub-Release upload set
81        // is correct. (`--json` machine output requires npm ≥ 7.20.3.)
82        let cmd = PlannedCommand::new("npm", &["pack", "--json"]);
83        let outputs = run_all(ctx, std::slice::from_ref(&cmd))?;
84        let stdout = outputs[0].stdout.trim();
85        // A build that cannot identify its own artifact must fail hard, never guess:
86        // reconstructing `{package}-{version}.tgz` is wrong for scoped packages (the
87        // exact plausible-but-wrong artifact this engine must not fabricate), and a
88        // guessed path only fails later, opaquely, at upload time. This mirrors how
89        // the cargo adapter treats an unparseable `cargo metadata`.
90        let Some(artifacts) = parse_pack_filenames(stdout).filter(|names| !names.is_empty()) else {
91            return Err(AdapterError::Command {
92                command: cmd.rendered(),
93                code: None,
94                stderr: format!(
95                    "`npm pack --json` produced no parseable tarball filename \
96                     (npm ≥ 7.20.3 is required for --json); output was: {stdout}"
97                ),
98            });
99        };
100        Ok(BuildArtifacts {
101            adapter: self.adapter,
102            artifacts,
103            notes: vec![],
104        })
105    }
106
107    fn publish(
108        &self,
109        ctx: &EffectCtx<'_>,
110        t: &AdapterTarget,
111    ) -> Result<PublishReceipt, AdapterError> {
112        // release-please publishes on merge via a CI job keyed off the GitHub
113        // release; there is no faithful host publish. Report that honestly rather
114        // than running a representative command and returning a receipt for a
115        // publish that did not happen (matching cargo-dist / gh-action-pypi-publish).
116        if matches!(self.adapter, Adapter::ReleasePlease) {
117            return Err(AdapterError::Unsupported {
118                adapter: self.adapter,
119                operation: "publish",
120            });
121        }
122        // PER-TARGET IRREVERSIBLE.
123        let cmds = match self.adapter {
124            // `changeset publish` is workspace-wide — it publishes every unpublished
125            // package in one shot — so the coordinator must drive a Changesets target
126            // at most once per workspace; a truthful per-target receipt for a batch
127            // publish is a broader change tracked as a spin-off (see the terminal
128            // report). The command itself is the genuine publish, not a placeholder.
129            Adapter::Changesets => vec![PlannedCommand::new("changeset", &["publish"])],
130            _ => vec![PlannedCommand::new("npm", &["publish"])],
131        };
132        run_all(ctx, &cmds)?;
133        let remote_url = Some(format!(
134            "https://www.npmjs.com/package/{}/v/{}",
135            t.package, t.version
136        ));
137        Ok(make_receipt(ctx, t, None, remote_url))
138    }
139
140    fn timeout(&self) -> Duration {
141        Duration::from_secs(600)
142    }
143}
144
145/// One entry of `npm pack --json` output — only the packed tarball `filename` is
146/// consumed (the exact name npm produced, which the coordinator threads into the
147/// binary upload set).
148#[derive(Deserialize)]
149struct NpmPackEntry {
150    filename: String,
151}
152
153/// Parse the packed tarball filename(s) from `npm pack --json` output. `None` when
154/// the payload is empty or not the expected array shape; the caller then fails the
155/// build rather than guessing a name (see [`NodeAdapter::build`]).
156fn parse_pack_filenames(stdout: &str) -> Option<Vec<String>> {
157    if stdout.is_empty() {
158        return None;
159    }
160    let entries: Vec<NpmPackEntry> = serde_json::from_str(stdout).ok()?;
161    Some(entries.into_iter().map(|e| e.filename).collect())
162}