Skip to main content

ossctl_core/release/adapters/
python.rs

1//! Python ecosystem adapter: `gh-action-pypi-publish` and `twine`.
2//!
3//! Publishes wheels/sdists to `PyPI`. `twine` uploads directly from this host;
4//! `gh-action-pypi-publish` is the CI trusted-publisher flow, so its publish
5//! body is a clearly-marked skeleton (the real upload happens in the workflow).
6//! `verify` reconciles against `PyPI` via the default [`RegistryQuery`](crate::ports::RegistryQuery) path.
7
8use std::time::Duration;
9
10use crate::contract::schema::Adapter;
11use crate::protocol::release::{BuildArtifacts, DryRunReport, PlannedCommand, PublishReceipt};
12
13use super::{make_receipt, run_all, AdapterError, AdapterTarget, EffectCtx, ReleaseAdapter};
14
15/// The python release adapter, operating as `gh-action-pypi-publish` or `twine`.
16pub struct PythonAdapter {
17    adapter: Adapter,
18}
19
20impl PythonAdapter {
21    /// Construct for a resolved python adapter identity.
22    #[must_use]
23    pub fn new(adapter: Adapter) -> Self {
24        debug_assert!(matches!(
25            adapter,
26            Adapter::GhActionPypiPublish | Adapter::Twine
27        ));
28        Self { adapter }
29    }
30}
31
32impl ReleaseAdapter for PythonAdapter {
33    fn adapter(&self) -> Adapter {
34        self.adapter
35    }
36
37    fn is_ci_delegated(&self) -> bool {
38        // `gh-action-pypi-publish` is the CI trusted-publisher flow — the real
39        // upload is the workflow, not this host. `twine` uploads directly and is
40        // not delegated. Consistent with `publish` returning `Unsupported` for
41        // `gh-action-pypi-publish` only.
42        matches!(self.adapter, Adapter::GhActionPypiPublish)
43    }
44
45    fn dry_run(
46        &self,
47        _ctx: &EffectCtx<'_>,
48        _t: &AdapterTarget,
49    ) -> Result<DryRunReport, AdapterError> {
50        let notes = match self.adapter {
51            Adapter::GhActionPypiPublish => {
52                vec![
53                    "upload runs in CI via the PyPI trusted publisher, not from this host"
54                        .to_string(),
55                ]
56            }
57            _ => vec![],
58        };
59        // `twine check` validates the built distributions; it is side-effect-free.
60        Ok(DryRunReport {
61            adapter: self.adapter,
62            planned_commands: vec![PlannedCommand::new("twine", &["check", "dist/*"])],
63            notes,
64        })
65    }
66
67    fn build(
68        &self,
69        ctx: &EffectCtx<'_>,
70        t: &AdapterTarget,
71    ) -> Result<BuildArtifacts, AdapterError> {
72        run_all(ctx, &[PlannedCommand::new("python", &["-m", "build"])])?;
73        // SKELETON: a production build enumerates the wheels/sdists under dist/.
74        Ok(BuildArtifacts {
75            adapter: self.adapter,
76            artifacts: vec![
77                format!("{}-{}-py3-none-any.whl", t.package, t.version),
78                format!("{}-{}.tar.gz", t.package, t.version),
79            ],
80            notes: vec![],
81        })
82    }
83
84    fn publish(
85        &self,
86        ctx: &EffectCtx<'_>,
87        t: &AdapterTarget,
88    ) -> Result<PublishReceipt, AdapterError> {
89        // PER-TARGET IRREVERSIBLE.
90        if matches!(self.adapter, Adapter::GhActionPypiPublish) {
91            // SKELETON: the real upload is the CI trusted-publisher job; there is
92            // no honest host publish to perform, so we say so rather than
93            // fabricate a receipt.
94            return Err(AdapterError::Unsupported {
95                adapter: self.adapter,
96                operation: "publish",
97            });
98        }
99        run_all(ctx, &[PlannedCommand::new("twine", &["upload", "dist/*"])])?;
100        let remote_url = Some(format!(
101            "https://pypi.org/project/{}/{}/",
102            t.package, t.version
103        ));
104        Ok(make_receipt(ctx, t, None, remote_url))
105    }
106
107    fn timeout(&self) -> Duration {
108        Duration::from_secs(600)
109    }
110}