anodizer_core/config/pypi.rs
1use std::collections::BTreeMap;
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6use super::{Amd64Variant, StringOrBool, deserialize_string_or_bool_opt};
7
8/// PyPI publisher configuration.
9///
10/// Publishes the project's prebuilt binaries as native Python wheels — one
11/// `py3-none-<platform>` wheel per built target, with the platform tag
12/// derived by inspecting each binary (glibc floor for `manylinux`, Mach-O
13/// deployment target for `macosx`) — and uploads them via PyPI's legacy
14/// (twine-protocol) upload API. Optionally also builds and uploads a source
15/// distribution via `maturin sdist`. Each `pypis[]` entry produces one
16/// publish.
17///
18/// ```yaml
19/// pypis:
20/// - name: my-tool
21/// requires_python: ">=3.7"
22/// sdist: true
23/// sdist_manifest: "pypi/"
24/// ```
25#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
26#[serde(default, deny_unknown_fields)]
27pub struct PypiConfig {
28 /// Unique identifier for selecting this entry from the CLI (`--id=...`).
29 pub id: Option<String>,
30
31 /// Build IDs filter: only include binaries whose crate is in this list.
32 pub ids: Option<Vec<String>>,
33
34 /// Target-triple allowlist: restrict the wheels to a subset of the built
35 /// targets. When unset (the default), every built target becomes a wheel.
36 /// When set, only binaries whose target triple appears in this list are
37 /// built into wheels; the rest are silently skipped. Orthogonal to `ids:`:
38 /// both filters apply (a binary must pass the `ids` filter AND, when this
39 /// is set, be listed here). A listed triple that no selected build
40 /// produces is a config error, and an explicit empty list (`targets: []`)
41 /// is rejected — omit the field to publish every built target. A common use
42 /// is excluding `x86_64-pc-windows-gnu` so it does not collide with the
43 /// `x86_64-pc-windows-msvc` wheel on the shared `win_amd64` platform tag.
44 /// Example: `targets: [x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc]`.
45 pub targets: Option<Vec<String>>,
46
47 /// PyPI project name. May use any PEP 508 name form (`My.Tool`,
48 /// `my_tool`); PyPI normalizes it per PEP 503 for index lookups and the
49 /// wheel filename escapes it per PEP 427. Falls back to the crate name
50 /// when unset.
51 pub name: Option<String>,
52
53 /// Also build and upload a source distribution via `maturin sdist`.
54 /// Default `false`. Requires `sdist_manifest` to point at the directory
55 /// containing the project's `pyproject.toml`, and `maturin` on `PATH`.
56 ///
57 /// ```yaml
58 /// pypis:
59 /// - sdist: true
60 /// sdist_manifest: "pypi/"
61 /// ```
62 pub sdist: bool,
63
64 /// Templated directory containing the `pyproject.toml` that `maturin
65 /// sdist` builds from, relative to the project root (e.g. `"pypi/"`).
66 /// Required when `sdist: true`; unused otherwise.
67 pub sdist_manifest: Option<String>,
68
69 /// Templated twine upload endpoint URL. Default
70 /// `https://upload.pypi.org/legacy/` (the production PyPI upload API).
71 /// Point it at TestPyPI to rehearse a release:
72 ///
73 /// ```yaml
74 /// pypis:
75 /// - index_url: "https://test.pypi.org/legacy/"
76 /// ```
77 ///
78 /// This is the twine *upload* target, not a `{owner, name}` source
79 /// repository — the name `index_url` keeps it distinct from the reserved
80 /// `repository` meaning every git-based publisher uses. The legacy
81 /// `repository:` spelling is still accepted via serde alias.
82 #[serde(alias = "repository")]
83 pub index_url: Option<String>,
84
85 /// Tolerate the index rejecting a file that already exists (the
86 /// twine `--skip-existing` semantics). Default `true` so a re-run of an
87 /// already-published tag skips previously-uploaded files instead of
88 /// failing the release. Set to `false` to make a duplicate upload a hard
89 /// error.
90 pub skip_existing: bool,
91
92 /// `Requires-Python` version specifier written into each wheel's
93 /// METADATA (e.g. `">=3.7"`). Purely declarative for a binary wheel —
94 /// the shipped executable does not import Python — but pip honors it
95 /// during resolution. Omitted when unset.
96 pub requires_python: Option<String>,
97
98 /// Templated one-line `Summary` for the package METADATA. Falls back to
99 /// the project-level `metadata.description` (and then the crate's
100 /// `Cargo.toml [package].description`) when unset.
101 pub summary: Option<String>,
102
103 /// Templated long description written as the METADATA body (rendered on
104 /// the PyPI project page). Falls back to the summary when unset.
105 pub description: Option<String>,
106
107 /// `Description-Content-Type` for the long-description body — how PyPI
108 /// renders it (`text/markdown`, `text/x-rst`, `text/plain`). When a
109 /// `description` is present and this is unset, defaults to
110 /// `text/markdown` (the modern norm); without the header PyPI renders the
111 /// body as raw plaintext. Omitted entirely when there is no description.
112 pub description_content_type: Option<String>,
113
114 /// Package author, emitted as the METADATA `Author` header.
115 pub author: Option<String>,
116
117 /// Package author email, emitted as the METADATA `Author-email` header.
118 pub author_email: Option<String>,
119
120 /// Arbitrary `Project-URL` label → URL map, one
121 /// `Project-URL: <label>, <url>` METADATA header each (the PyPI sidebar
122 /// links). Emitted in addition to the `Homepage` link derived from
123 /// `homepage`; use this for `Repository`, `Documentation`, `Changelog`,
124 /// `Funding`, etc. Rendered in sorted label order for a byte-stable wheel.
125 ///
126 /// ```yaml
127 /// pypis:
128 /// - project_urls:
129 /// Repository: "https://github.com/me/my-tool"
130 /// Documentation: "https://docs.example.com"
131 /// ```
132 pub project_urls: Option<BTreeMap<String, String>>,
133
134 /// Templated homepage URL, emitted as `Project-URL: Homepage`. Falls
135 /// back to `metadata.homepage` (then `Cargo.toml [package].homepage`)
136 /// when unset.
137 pub homepage: Option<String>,
138
139 /// Templated license expression (e.g. `MIT`, `Apache-2.0`), emitted as
140 /// the METADATA `License` field. Falls back to `metadata.license` (then
141 /// `Cargo.toml [package].license`) when unset.
142 pub license: Option<String>,
143
144 /// Keywords list, emitted comma-separated in METADATA.
145 pub keywords: Option<Vec<String>>,
146
147 /// Trove classifier lines (e.g.
148 /// `"Programming Language :: Rust"`), one `Classifier:` METADATA header
149 /// each.
150 pub classifiers: Option<Vec<String>>,
151
152 /// Per-target-triple wheel platform-tag overrides: `<target triple>` →
153 /// explicit wheel platform tag. When a built target has an entry, its tag
154 /// is used *verbatim* — binary inspection (the glibc floor for
155 /// `manylinux`, the Mach-O deployment target for `macosx`) is skipped for
156 /// that target. Every target without an entry keeps the auto-detected tag.
157 ///
158 /// The escape hatch for toolchains whose emitted glibc floor is stricter
159 /// than the compatibility a project wants to advertise — e.g. pinning
160 /// `aarch64-unknown-linux-gnu` to `manylinux_2_28` to match a
161 /// `maturin`/PyO3 build environment rather than shipping the higher floor
162 /// the binary's symbols imply.
163 ///
164 /// ```yaml
165 /// pypis:
166 /// - platform_tag_overrides:
167 /// aarch64-unknown-linux-gnu: manylinux_2_28_aarch64
168 /// ```
169 pub platform_tag_overrides: Option<BTreeMap<String, String>>,
170
171 /// `x86_64` micro-architecture variant selector — `v1` (baseline), `v2`,
172 /// `v3` (AVX2), or `v4`. When set, an amd64 binary carrying
173 /// `amd64_variant` metadata becomes the `win_amd64`/`manylinux…x86_64`
174 /// wheel only when its variant matches; a binary with no variant metadata
175 /// still matches (the baseline build). Default: `v1`. Typed as
176 /// [`Amd64Variant`], so any value outside `v1`..`v4` is rejected at parse
177 /// time.
178 pub amd64_variant: Option<Amd64Variant>,
179
180 /// ARM version selector (e.g. `"6"`, `"7"`). When set, a 32-bit ARM binary
181 /// carrying `arm_variant` metadata becomes the wheel only when its variant
182 /// matches; a binary with no variant metadata still matches. Does not
183 /// affect `aarch64`/`arm64` (64-bit ARM has no sub-variant).
184 pub arm_variant: Option<String>,
185
186 /// Whether the upload authenticates with a long-lived API token or with
187 /// GitHub Actions OIDC (PyPI Trusted Publishing). Default [`Auto`]:
188 /// a token when one is available, otherwise a Trusted-Publishing exchange
189 /// when an OIDC context is present.
190 ///
191 /// [`Auto`]: PypiAuthMode::Auto
192 pub auth: PypiAuthMode,
193
194 /// API token for the upload (templated). Falls back to the `PYPI_TOKEN`
195 /// env var, then `MATURIN_PYPI_TOKEN`, when unset. Sent as HTTP Basic
196 /// auth with the literal username `__token__` and NEVER logged. Unused
197 /// when `auth: oidc` (Trusted Publishing mints its own short-lived token).
198 pub token: Option<String>,
199
200 /// Skip this publisher. Accepts bool or template string.
201 /// Accepts the legacy `disable:` spelling via serde alias for back-compat.
202 #[serde(
203 default,
204 alias = "disable",
205 deserialize_with = "deserialize_string_or_bool_opt"
206 )]
207 pub skip: Option<StringOrBool>,
208
209 /// Override whether this publisher failing should fail the overall release.
210 ///
211 /// Default: `true` — PyPI is a Manager-group publisher whose uploads are
212 /// one-way (a published filename can never be re-uploaded, even after
213 /// deletion), so a failed publish aborts by default to avoid surprising
214 /// the operator with a half-released version. Set to `false` to log
215 /// failures but continue.
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub required: Option<bool>,
218
219 /// Template-conditional gate: when the rendered result is falsy
220 /// (`"false"` / `"0"` / `"no"` / empty), the PyPI publisher entry is
221 /// skipped. Render failure hard-errors.
222 #[serde(rename = "if")]
223 pub if_condition: Option<String>,
224
225 /// When `true`, a triggered rollback leaves this publisher's work in
226 /// place rather than attempting to undo it. Default `false`. (PyPI has
227 /// no programmatic delete path anyway — rollback is warn-only — but the
228 /// flag suppresses even that warning.)
229 pub retain_on_rollback: Option<bool>,
230}
231
232impl Default for PypiConfig {
233 fn default() -> Self {
234 Self {
235 id: None,
236 ids: None,
237 targets: None,
238 name: None,
239 sdist: false,
240 sdist_manifest: None,
241 index_url: None,
242 skip_existing: true,
243 requires_python: None,
244 summary: None,
245 description: None,
246 description_content_type: None,
247 author: None,
248 author_email: None,
249 project_urls: None,
250 homepage: None,
251 license: None,
252 keywords: None,
253 classifiers: None,
254 platform_tag_overrides: None,
255 amd64_variant: None,
256 arm_variant: None,
257 auth: PypiAuthMode::default(),
258 token: None,
259 skip: None,
260 required: None,
261 if_condition: None,
262 retain_on_rollback: None,
263 }
264 }
265}
266
267/// How a `pypis[]` entry authenticates its upload: a long-lived API token, or
268/// GitHub Actions OIDC (PyPI Trusted Publishing, which mints a short-lived
269/// upload token per run — no stored secret).
270///
271/// Unlike npm, PyPI is uploaded directly over HTTP rather than through a CLI,
272/// so the Trusted-Publishing exchange (Actions id-token → PyPI mint-token) is
273/// performed by anodizer itself. Trusted Publishing also creates brand-new
274/// projects when a *pending* publisher is configured on PyPI, so there is no
275/// per-package "must already exist" caveat as there is for npm.
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
277#[serde(rename_all = "kebab-case")]
278pub enum PypiAuthMode {
279 /// Use a token when one is available (`cfg.token` / `PYPI_TOKEN` /
280 /// `MATURIN_PYPI_TOKEN`); otherwise, when an OIDC context is present, mint
281 /// a Trusted-Publishing token. Errors only when neither is available.
282 #[default]
283 Auto,
284 /// Always authenticate with the token; never attempt OIDC. Errors if no
285 /// token is available. This is anodizer's historical behaviour.
286 Token,
287 /// Always authenticate with OIDC (Trusted Publishing); never fall back to
288 /// the token. Errors if the GitHub Actions OIDC request env
289 /// (`ACTIONS_ID_TOKEN_REQUEST_URL` / `_TOKEN`) is absent, so a misconfigured
290 /// Trusted Publisher fails the release loudly instead of silently falling
291 /// back to a token.
292 Oidc,
293}