use std::path::Path;
use anyhow::Result;
use super::super::{GenerateCI, Platform, ResolvedCIConfig, ResolvedTarget};
use super::resolve::resolve_config;
use super::yaml::Yaml;
use crate::BridgeModel;
fn uniform_field<F: Fn(&ResolvedTarget) -> Option<&str>>(
targets: &[ResolvedTarget],
accessor: F,
) -> Option<Option<String>> {
if targets.is_empty() {
return Some(None);
}
let first = accessor(&targets[0]);
for target in &targets[1..] {
let value = accessor(target);
if value != first {
return None;
}
}
Some(first.map(ToOwned::to_owned))
}
type FieldAccessor = fn(&ResolvedTarget) -> Option<&str>;
const MATURIN_ACTION_FIELDS: &[(&str, &str, FieldAccessor)] = &[
("manylinux", "manylinux", |target| {
target.manylinux.as_deref()
}),
("container", "container", |target| {
target.container.as_deref()
}),
("docker-options", "docker_options", |target| {
target.docker_options.as_deref()
}),
("rust-toolchain", "rust_toolchain", |target| {
target.rust_toolchain.as_deref()
}),
("rustup-components", "rustup_components", |target| {
target.rustup_components.as_deref()
}),
("before-script-linux", "before_script_linux", |target| {
target.before_script_linux.as_deref()
}),
];
const MATRIX_TARGET: &str = "matrix.platform.target";
const GITHUB_TOKEN: &str = "github.token";
const GITHUB_WORKSPACE: &str = "github.workspace";
pub(crate) fn generate_github_from_cli(
cli: &GenerateCI,
project_name: &str,
bridge_model: &BridgeModel,
sdist: bool,
) -> Result<String> {
let resolved = resolve_config(cli, None, bridge_model)?;
generate_github(cli, &resolved, project_name, bridge_model, sdist, None)
}
struct RenderContext<'a> {
cli: &'a GenerateCI,
resolved: &'a ResolvedCIConfig,
project_name: &'a str,
is_abi3: bool,
is_bin: bool,
setup_python: bool,
min_python_minor: Option<u8>,
}
pub(crate) fn generate_github(
cli: &GenerateCI,
resolved: &ResolvedCIConfig,
project_name: &str,
bridge_model: &BridgeModel,
sdist: bool,
min_python_minor: Option<u8>,
) -> Result<String> {
let is_abi3 = bridge_model.is_abi3();
let is_bin = bridge_model.is_bin();
let setup_python = resolved.pytest
|| matches!(
bridge_model,
BridgeModel::Bin(Some(_))
| BridgeModel::PyO3 { .. }
| BridgeModel::Cffi
| BridgeModel::UniFfi
);
let context = RenderContext {
cli,
resolved,
project_name,
is_abi3,
is_bin,
setup_python,
min_python_minor,
};
let mut conf = workflow_header();
let mut needs = Vec::new();
for (&platform, targets) in &resolved.platform_targets {
needs.push(platform.to_string());
emit_platform_job(&mut conf, platform, targets, &context);
conf.push('\n');
}
if sdist {
needs.push("sdist".to_string());
emit_sdist_job(&mut conf, cli);
conf.push('\n');
}
emit_release_job(&mut conf, resolved, &needs);
Ok(conf)
}
fn workflow_header() -> String {
let mut gen_cmd = std::env::args()
.enumerate()
.map(|(index, arg)| {
if index == 0 {
env!("CARGO_PKG_NAME").to_string()
} else {
arg
}
})
.collect::<Vec<String>>()
.join(" ");
if gen_cmd.starts_with("maturin new") || gen_cmd.starts_with("maturin init") {
gen_cmd = format!("{} generate-ci github", env!("CARGO_PKG_NAME"));
}
format!(
"# This file is autogenerated by maturin v{version}
# To update, run
#
# {gen_cmd}
#
name: CI
on:
push:
branches:
- main
- master
tags:
- '*'
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:\n",
version = env!("CARGO_PKG_VERSION"),
)
}
fn emit_platform_job(
conf: &mut String,
platform: Platform,
targets: &[ResolvedTarget],
context: &RenderContext<'_>,
) {
let platform_name = platform.to_string();
let mut y = Yaml::new(conf, 1);
y.line(format!("{platform_name}:"))
.indent()
.line(format!("runs-on: {}", gha_expr("matrix.platform.runner")));
emit_target_matrix(&mut y, targets);
y.line("steps:")
.indent()
.line("- uses: actions/checkout@v6");
emit_platform_setup(
&mut y,
platform,
context.setup_python,
context.min_python_minor,
);
let maturin_args = build_maturin_args(
context.cli,
context.resolved,
platform,
context.is_abi3,
context.is_bin,
context.setup_python,
);
let extra_args_suffix = build_extra_args_suffix(targets);
emit_build_step(
&mut y,
"Build wheels",
&maturin_args,
&extra_args_suffix,
"",
targets,
);
if context.is_abi3 {
emit_free_threaded_setup(&mut y, platform, context.min_python_minor);
emit_build_step(
&mut y,
"Build free-threaded wheels",
&maturin_args,
&extra_args_suffix,
" -i python3.14t",
targets,
);
}
emit_upload_wheels_step(&mut y, platform);
if context.resolved.pytest {
let chdir = pytest_chdir_prefix(context.cli);
emit_pytest_steps(
&mut y,
platform,
context.project_name,
&chdir,
context.min_python_minor,
);
}
}
fn emit_target_matrix(y: &mut Yaml, targets: &[ResolvedTarget]) {
if targets.is_empty() {
return;
}
y.line("strategy:")
.indent()
.line("matrix:")
.indent()
.line("platform:")
.indent();
for target in targets {
y.line(format!("- runner: {}", target.runner)).indent();
y.line(format!("target: {}", target.target));
if let Some(python_arch) = &target.python_arch {
y.line(format!("python_arch: {python_arch}"));
}
emit_varying_matrix_fields(y, targets, target);
y.dedent();
}
y.dedent_by(3);
}
fn emit_platform_setup(
y: &mut Yaml,
platform: Platform,
setup_python: bool,
min_python_minor: Option<u8>,
) {
match platform {
Platform::Emscripten => emit_emscripten_setup(y),
Platform::Android => {}
_ if setup_python => emit_python_setup(y, platform, min_python_minor),
_ => {}
}
}
fn emit_emscripten_setup(y: &mut Yaml) {
y.line("- run: pip install pyodide-build");
y.line("- name: Get Emscripten and Python version info")
.indent()
.line("shell: bash")
.line("run: |")
.indent();
y.line("echo EMSCRIPTEN_VERSION=$(pyodide config get emscripten_version) >> $GITHUB_ENV");
y.line("echo PYTHON_VERSION=$(pyodide config get python_version | cut -d '.' -f 1-2) >> $GITHUB_ENV");
y.line("pip uninstall -y pyodide-build");
y.dedent_by(2)
.line("- uses: mymindstorm/setup-emsdk@v14")
.indent()
.line("with:")
.indent()
.line(format!("version: {}", gha_expr("env.EMSCRIPTEN_VERSION")))
.line("actions-cache-folder: emsdk-cache")
.dedent_by(2)
.line("- uses: actions/setup-python@v6")
.indent()
.line("with:")
.indent()
.line(format!(
"python-version: {}",
gha_expr("env.PYTHON_VERSION")
))
.dedent_by(2)
.line("- run: pip install pyodide-build");
}
fn emit_python_setup(y: &mut Yaml, platform: Platform, min_python_minor: Option<u8>) {
let python_version = match (platform, min_python_minor) {
(Platform::Windows, Some(minor)) if minor >= 13 => format!("3.{minor}"),
(Platform::Windows, _) => "3.13".to_string(),
(_, Some(minor)) => format!("3.{minor}"),
_ => "3.x".to_string(),
};
y.line("- uses: actions/setup-python@v6")
.indent()
.line("with:")
.indent()
.line(format!("python-version: {python_version}"));
if matches!(platform, Platform::Windows) {
y.line(format!(
"architecture: {}",
gha_expr("matrix.platform.python_arch")
));
}
y.dedent_by(2);
}
fn emit_free_threaded_setup(y: &mut Yaml, platform: Platform, min_python_minor: Option<u8>) {
if !matches!(platform, Platform::Windows | Platform::Macos) {
return;
}
let python_version = if matches!(min_python_minor, Some(minor) if minor > 14) {
format!("3.{}t", min_python_minor.unwrap())
} else {
"3.14t".to_string()
};
y.line("- uses: actions/setup-python@v6")
.indent()
.line("with:")
.indent()
.line(format!("python-version: {python_version}"));
if matches!(platform, Platform::Windows) {
y.line(format!(
"architecture: {}",
gha_expr("matrix.platform.python_arch")
));
}
y.dedent_by(2);
}
fn build_maturin_args(
cli: &GenerateCI,
resolved: &ResolvedCIConfig,
platform: Platform,
is_abi3: bool,
is_bin: bool,
setup_python: bool,
) -> String {
let mut maturin_args = if is_abi3 || (is_bin && !setup_python) {
Vec::new()
} else if matches!(platform, Platform::Emscripten) {
vec!["-i".to_string(), "${{ env.PYTHON_VERSION }}".to_string()]
} else if matches!(platform, Platform::Android) {
Vec::new()
} else {
vec!["--find-interpreter".to_string()]
};
if let Some(manifest_arg) = manifest_arg(cli) {
maturin_args.push("--manifest-path".to_string());
maturin_args.push(manifest_arg);
}
if resolved.zig && matches!(platform, Platform::ManyLinux) {
maturin_args.push("--zig".to_string());
}
if maturin_args.is_empty() {
String::new()
} else {
format!(" {}", maturin_args.join(" "))
}
}
fn build_extra_args_suffix(targets: &[ResolvedTarget]) -> String {
if let Some(uniform_value) = uniform_field(targets, |target| target.extra_args.as_deref()) {
uniform_value
.map(|value| format!(" {value}"))
.unwrap_or_default()
} else {
" ${{ matrix.platform.extra_args }}".to_string()
}
}
fn manifest_arg(cli: &GenerateCI) -> Option<String> {
cli.manifest_path.as_ref().and_then(|manifest_path| {
(manifest_path != Path::new("Cargo.toml")).then(|| manifest_path.display().to_string())
})
}
fn pytest_chdir_prefix(cli: &GenerateCI) -> String {
cli.manifest_path
.as_ref()
.and_then(|manifest_path| {
(manifest_path != Path::new("Cargo.toml"))
.then(|| manifest_path.parent().unwrap().display().to_string())
})
.map(|parent| format!("cd {parent} && "))
.unwrap_or_default()
}
fn emit_upload_wheels_step(y: &mut Yaml, platform: Platform) {
let artifact_name = match platform {
Platform::Emscripten => "wasm-wheels".to_string(),
_ => format!("wheels-{platform}-{}", gha_expr("matrix.platform.target")),
};
y.line("- name: Upload wheels")
.indent()
.line("uses: actions/upload-artifact@v6")
.line("with:")
.indent()
.line(format!("name: {artifact_name}"))
.line("path: dist")
.dedent_by(2);
}
fn emit_sdist_job(conf: &mut String, cli: &GenerateCI) {
let manifest_args = manifest_arg(cli)
.map(|path| format!(" --manifest-path {path}"))
.unwrap_or_default();
let mut y = Yaml::new(conf, 1);
y.line("sdist:")
.indent()
.line("runs-on: ubuntu-latest")
.line("steps:")
.indent()
.line("- uses: actions/checkout@v6")
.line("- name: Build sdist")
.indent()
.line("uses: PyO3/maturin-action@v1")
.line("with:")
.indent()
.line("command: sdist")
.line(format!("args: --out dist{manifest_args}"))
.dedent_by(2)
.line("- name: Upload sdist")
.indent()
.line("uses: actions/upload-artifact@v6")
.line("with:")
.indent()
.line("name: wheels-sdist")
.line("path: dist");
}
fn emit_release_job(conf: &mut String, resolved: &ResolvedCIConfig, needs: &[String]) {
let mut y = Yaml::new(conf, 1);
y.line("release:");
y.indent();
y.line("name: Release");
y.line("runs-on: ubuntu-latest");
y.line(format!(
"if: {}",
gha_expr(
"startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'"
)
));
y.line(format!("needs: [{}]", needs.join(", ")));
emit_release_permissions(&mut y, resolved);
emit_release_steps(&mut y, resolved);
}
fn emit_release_permissions(y: &mut Yaml, resolved: &ResolvedCIConfig) {
y.line("permissions:");
y.indent();
y.line("# Use to sign the release artifacts");
y.line("id-token: write");
y.line("# Used to upload release artifacts");
y.line("contents: write");
if !resolved.skip_attestation {
y.line("# Used to generate artifact attestation");
y.line("attestations: write");
}
y.dedent();
}
fn emit_release_steps(y: &mut Yaml, resolved: &ResolvedCIConfig) {
y.line("steps:");
y.indent();
y.line("- uses: actions/download-artifact@v7");
if !resolved.skip_attestation {
emit_release_attestation_step(y);
}
emit_release_publish_steps(y);
if resolved
.platform_targets
.contains_key(&Platform::Emscripten)
{
emit_release_github_upload_step(y);
}
}
fn emit_release_attestation_step(y: &mut Yaml) {
y.line("- name: Generate artifact attestation");
y.indent();
y.line("uses: actions/attest-build-provenance@v3");
y.line("with:");
y.indent();
y.line("subject-path: 'wheels-*/*'");
y.dedent_by(2);
}
fn emit_release_publish_steps(y: &mut Yaml) {
y.line("- name: Install uv");
y.indent();
y.line(format!(
"if: {}",
gha_expr("startsWith(github.ref, 'refs/tags/')")
));
y.line("uses: astral-sh/setup-uv@v7");
y.dedent();
y.line("- name: Publish to PyPI");
y.indent();
y.line(format!(
"if: {}",
gha_expr("startsWith(github.ref, 'refs/tags/')")
));
y.line("run: uv publish 'wheels-*/*'");
y.line("env:");
y.indent();
y.line(format!(
"UV_PUBLISH_TOKEN: {}",
gha_expr("secrets.PYPI_API_TOKEN")
));
y.dedent_by(2);
}
fn emit_release_github_upload_step(y: &mut Yaml) {
y.line("- name: Upload to GitHub Release");
y.indent();
y.line("uses: softprops/action-gh-release@v1");
y.line("with:");
y.indent();
y.line("files: |");
y.indent();
y.line("wasm-wheels/*.whl");
y.dedent();
y.line(format!(
"prerelease: {}",
gha_expr("contains(github.ref, 'alpha') || contains(github.ref, 'beta')")
));
}
fn gha_expr(expr: &str) -> String {
format!("${{{{ {expr} }}}}")
}
fn emit_pytest_steps(
y: &mut Yaml,
platform: Platform,
project_name: &str,
chdir: &str,
min_python_minor: Option<u8>,
) {
match platform {
Platform::All | Platform::Android => {}
Platform::ManyLinux => emit_manylinux_pytest(y, project_name, chdir, min_python_minor),
Platform::Musllinux => emit_musllinux_pytest(y, project_name, chdir),
Platform::Windows => {
emit_uv_pytest(
y,
project_name,
chdir,
".venv/Scripts/activate",
None,
Some("bash"),
);
}
Platform::Macos => {
emit_uv_pytest(y, project_name, chdir, ".venv/bin/activate", None, None);
}
Platform::Emscripten => emit_emscripten_pytest(y, project_name, chdir),
}
}
const UBUNTU_2404_PYTHON_MINOR: u8 = 12;
fn emit_manylinux_pytest(
y: &mut Yaml,
project_name: &str,
chdir: &str,
min_python_minor: Option<u8>,
) {
let if_x86_64 = gha_expr(&format!("startsWith({MATRIX_TARGET}, 'x86_64')"));
y.line("- uses: astral-sh/setup-uv@v7")
.indent()
.line(format!("if: {if_x86_64}"))
.dedent();
emit_uv_pytest(
y,
project_name,
chdir,
".venv/bin/activate",
Some(&if_x86_64),
Some("bash"),
);
let needs_deadsnakes =
matches!(min_python_minor, Some(minor) if minor > UBUNTU_2404_PYTHON_MINOR);
y.line("- name: pytest")
.indent()
.line(format!(
"if: {}",
gha_expr(&format!(
"!startsWith({MATRIX_TARGET}, 'x86') && {MATRIX_TARGET} != 'ppc64'"
))
))
.line("uses: uraimo/run-on-arch-action@v2")
.line("with:")
.indent();
y.line(format!("arch: {}", gha_expr(MATRIX_TARGET)));
y.line("distro: ubuntu24.04");
y.line(format!("githubToken: {}", gha_expr(GITHUB_TOKEN)));
y.line("install: |");
y.indent();
y.line("apt-get update");
if needs_deadsnakes {
let minor = min_python_minor.unwrap();
y.line("apt-get install -y --no-install-recommends software-properties-common");
y.line("add-apt-repository -y ppa:deadsnakes/ppa");
y.line("apt-get update");
y.line(format!(
"apt-get install -y --no-install-recommends python3.{minor} python3.{minor}-venv"
));
} else {
y.line("apt-get install -y --no-install-recommends python3 python3-venv");
}
y.dedent();
y.line("run: |");
y.indent();
y.line("set -e");
if needs_deadsnakes {
let minor = min_python_minor.unwrap();
y.line(format!("python3.{minor} -m venv .venv"));
} else {
y.line("python3 -m venv .venv");
}
y.line("source .venv/bin/activate");
y.line("pip install pytest");
y.line(format!(
"pip install {project_name} --no-index --no-deps --find-links dist --force-reinstall"
));
y.line(format!("pip install {project_name}"));
y.line(format!("{chdir}pytest"));
y.dedent_by(3);
}
fn emit_musllinux_pytest(y: &mut Yaml, project_name: &str, chdir: &str) {
let if_x86_64 = gha_expr(&format!("startsWith({MATRIX_TARGET}, 'x86_64')"));
let if_not_x86 = gha_expr(&format!("!startsWith({MATRIX_TARGET}, 'x86')"));
y.line("- name: pytest")
.indent()
.line(format!("if: {if_x86_64}"))
.line("run: |")
.indent();
y.line("set -e");
y.line(format!(
"docker run --rm -v {}:/io -w /io alpine:latest sh -c '",
gha_expr(GITHUB_WORKSPACE)
));
y.line(" apk add py3-pip py3-virtualenv");
y.line(" python3 -m virtualenv .venv");
y.line(" source .venv/bin/activate");
y.line(format!(
" pip install {project_name} --no-index --no-deps --find-links dist --force-reinstall"
));
y.line(format!(" pip install {project_name} pytest"));
y.line(format!(" {chdir}pytest"));
y.line("'")
.dedent_by(2)
.line("- name: pytest")
.indent()
.line(format!("if: {if_not_x86}"))
.line("uses: uraimo/run-on-arch-action@v2")
.line("with:")
.indent();
y.line(format!("arch: {}", gha_expr(MATRIX_TARGET)));
y.line("distro: alpine_latest");
y.line(format!("githubToken: {}", gha_expr(GITHUB_TOKEN)));
y.line("install: |");
y.indent();
y.line("apk add py3-virtualenv");
y.dedent();
y.line("run: |");
y.indent();
y.line("set -e");
y.line("python3 -m virtualenv .venv");
y.line("source .venv/bin/activate");
y.line(format!(
"pip install {project_name} --no-index --no-deps --find-links dist --force-reinstall"
));
y.line(format!("pip install {project_name} pytest"));
y.line(format!("{chdir}pytest"));
y.dedent_by(3);
}
fn emit_uv_pytest(
y: &mut Yaml,
project_name: &str,
chdir: &str,
activate_path: &str,
if_expr: Option<&str>,
shell: Option<&str>,
) {
if if_expr.is_none() {
y.line("- uses: astral-sh/setup-uv@v7");
}
y.line("- name: pytest").indent();
if let Some(expr) = if_expr {
y.line(format!("if: {expr}"));
}
if let Some(shell) = shell {
y.line(format!("shell: {shell}"));
}
y.line("run: |").indent();
y.line("set -e");
y.line("uv venv .venv");
y.line(format!("source {activate_path}"));
y.line(format!(
"uv pip install {project_name} --no-index --no-deps --find-links dist --reinstall"
));
y.line(format!("uv pip install {project_name} pytest"));
y.line(format!("{chdir}pytest"));
y.dedent_by(2);
}
fn emit_emscripten_pytest(y: &mut Yaml, project_name: &str, chdir: &str) {
y.line("- uses: actions/setup-node@v3")
.indent()
.line("with:")
.indent()
.line("node-version: '22'")
.dedent_by(2)
.line("- name: pytest")
.indent()
.line("run: |")
.indent();
y.line("set -e");
y.line("pyodide venv .venv");
y.line("source .venv/bin/activate");
y.line(format!(
"pip install {project_name} --no-index --no-deps --find-links dist --force-reinstall"
));
y.line(format!("pip install {project_name} pytest"));
y.line(format!("{chdir}python -m pytest"));
y.dedent_by(2);
}
fn emit_build_step(
y: &mut Yaml,
name: &str,
maturin_args: &str,
extra_args_suffix: &str,
extra_suffix: &str,
targets: &[ResolvedTarget],
) {
y.line(format!("- name: {name}"))
.indent()
.line("uses: PyO3/maturin-action@v1")
.line("with:")
.indent();
y.line(format!("target: {}", gha_expr(MATRIX_TARGET)));
y.line(format!(
"args: --release --out dist{maturin_args}{extra_args_suffix}{extra_suffix}"
));
y.line(format!(
"sccache: {}",
gha_expr("!startsWith(github.ref, 'refs/tags/')")
));
for &(field_name, matrix_key, accessor) in MATURIN_ACTION_FIELDS {
emit_maturin_action_field(y, field_name, matrix_key, targets, accessor);
}
y.dedent_by(2);
}
fn emit_maturin_action_field(
y: &mut Yaml,
field_name: &str,
matrix_key: &str,
targets: &[ResolvedTarget],
accessor: FieldAccessor,
) {
if let Some(uniform_value) = uniform_field(targets, accessor) {
if let Some(value) = uniform_value {
y.line(format!("{field_name}: {value}"));
}
} else {
y.line(format!(
"{field_name}: ${{{{ matrix.platform.{matrix_key} }}}}"
));
}
}
fn emit_varying_matrix_fields(
y: &mut Yaml,
all_targets: &[ResolvedTarget],
target: &ResolvedTarget,
) {
for &(_field_name, matrix_key, accessor) in MATURIN_ACTION_FIELDS {
if uniform_field(all_targets, accessor).is_none()
&& let Some(value) = accessor(target)
{
y.line(format!("{matrix_key}: {value}"));
}
}
if uniform_field(all_targets, |target| target.extra_args.as_deref()).is_none()
&& let Some(value) = &target.extra_args
{
y.line(format!("extra_args: {value}"));
}
}