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