use std::path::PathBuf;
use anyhow::{Result, bail};
use crate::cargo::Package;
use crate::cli::{Platform, Runtime};
use crate::project::GeneratedFile;
use crate::user_config::{ResolvedCiRunners, ResolvedRunner};
pub const OMNIX_REF_DEFAULT: &str = "github:juspay/omnix/v1.3.2";
pub const GENERATED_WORKFLOW_MARKER: &str =
"# Generated by simit. Manual edits will be reported as ci=drift.";
const CARGO_NEXTEST_VERSION: &str = "0.9.100";
const CARGO_DENY_VERSION: &str = "0.18.3";
const CARGO_DENY_POLICY_CHECKS: &str = "bans licenses sources";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum OmCiMode {
#[default]
Off,
Replace,
Augment,
}
#[derive(Debug, Clone)]
pub struct CiOptions {
pub with_nextest: bool,
pub with_msrv: bool,
pub with_audit: bool,
pub with_deny: bool,
pub with_docs: bool,
pub with_artifacts: bool,
pub om_ci: OmCiMode,
pub omnix_ref: String,
pub release_smoke_command: Option<String>,
pub extra_setup: Vec<String>,
pub extra_env: Vec<(String, String)>,
pub required_secrets: Vec<String>,
pub package_scoped: bool,
pub homebrew: Option<HomebrewOptions>,
pub chocolatey: Option<ChocolateyOptions>,
pub scoop: Option<ScoopOptions>,
}
impl Default for CiOptions {
fn default() -> Self {
Self {
with_nextest: false,
with_msrv: false,
with_audit: false,
with_deny: false,
with_docs: false,
with_artifacts: false,
om_ci: OmCiMode::default(),
omnix_ref: OMNIX_REF_DEFAULT.to_owned(),
release_smoke_command: None,
extra_setup: Vec::new(),
extra_env: Vec::new(),
required_secrets: Vec::new(),
package_scoped: false,
homebrew: None,
chocolatey: None,
scoop: None,
}
}
}
#[derive(Debug, Clone)]
pub struct HomebrewOptions {
/// Formula name, for example `modde`. UpperCamelCased for the Ruby class.
pub name: String,
/// Binaries to install. Defaults to `name` if empty.
pub binaries: Vec<String>,
/// Tap repo URL, for example `<https://codeberg.org/caniko/homebrew-modde.git>`.
pub tap_url: String,
/// Actions secret sourced into `HOMEBREW_TAP_TOKEN`.
pub tap_token_secret: String,
/// Project description for the formula (<= 80 chars).
pub description: String,
/// Homepage URL.
pub homepage: String,
/// SPDX license identifier.
pub license: String,
/// Release-artefact filename pattern. Supports {version}, {arch}, {os} placeholders.
pub archive_pattern: String,
/// Codeberg user/repo for download URLs.
pub download_repo: String,
/// Per-platform enable/disable. Default: all four.
pub platforms: HomebrewPlatformSet,
}
#[derive(Debug, Clone)]
pub struct HomebrewPlatformSet {
pub darwin_arm: bool,
pub darwin_intel: bool,
pub linux_arm: bool,
pub linux_intel: bool,
}
#[derive(Debug, Clone)]
pub struct ChocolateyOptions {
pub name: String,
pub id: String,
pub title: String,
pub authors: Option<String>,
pub description: String,
pub project_url: String,
pub license_url: Option<String>,
pub tags: Option<String>,
pub release_notes_url: Option<String>,
pub download_repo: String,
pub archive_pattern: String,
pub push_source: String,
}
#[derive(Debug, Clone)]
pub struct ScoopOptions {
pub name: String,
pub bucket_url: String,
pub bucket_token_secret: String,
pub description: String,
pub homepage: String,
pub license: String,
pub download_repo: String,
pub archive_pattern: String,
pub binaries: Vec<String>,
pub x64: bool,
pub arm64: bool,
}
#[derive(Debug, Clone, Copy)]
pub struct SelfCheckOptions<'a> {
pub enabled: bool,
pub runner_override: Option<&'a str>,
pub windows_runner_override: Option<&'a str>,
pub packages: &'a [String],
pub workspace: bool,
}
impl Default for HomebrewPlatformSet {
fn default() -> Self {
Self {
darwin_arm: true,
darwin_intel: true,
linux_arm: true,
linux_intel: true,
}
}
}
pub fn files(
platform: Platform,
runtime: Runtime,
package: &Package,
file_suffix: Option<&str>,
self_check: SelfCheckOptions<'_>,
runners: &ResolvedCiRunners,
options: CiOptions,
) -> Result<Vec<GeneratedFile>> {
if options.with_msrv && package.rust_version.is_none() {
bail!("--with-msrv requires package.rust-version in Cargo.toml");
}
let dir = PathBuf::from(platform.workflow_dir());
let ci_name = workflow_file_name("ci", file_suffix);
let publish_name = workflow_file_name("publish-crate", file_suffix);
let artifacts_name = workflow_file_name("release-artifacts", file_suffix);
let mut files = vec![GeneratedFile {
relative_path: dir.join(ci_name),
content: ci_workflow(
platform,
runtime,
package,
self_check,
runners,
options.clone(),
),
}];
if package.is_publishable() {
files.push(GeneratedFile {
relative_path: dir.join(publish_name),
content: publish_workflow(
platform,
runtime,
package,
&runners.release,
options.clone(),
),
});
}
if options.with_artifacts {
files.push(GeneratedFile {
relative_path: dir.join(artifacts_name),
content: artifacts_workflow(platform, runtime, package, runners, &options),
});
}
Ok(files)
}
fn workflow_file_name(stem: &str, suffix: Option<&str>) -> String {
match suffix {
Some(suffix) => format!("{stem}-{suffix}.yaml"),
None => format!("{stem}.yaml"),
}
}
fn ci_workflow(
platform: Platform,
runtime: Runtime,
package: &Package,
self_check: SelfCheckOptions<'_>,
runners: &ResolvedCiRunners,
options: CiOptions,
) -> String {
let mut workflow = String::new();
push_generated_workflow_header(&mut workflow);
push_required_secrets_header(&mut workflow, &options.required_secrets);
workflow.push_str("name: CI\n\n");
workflow.push_str("on:\n");
workflow.push_str(" push:\n");
workflow.push_str(" branches: [\"**\"]\n");
if !(platform == Platform::Forgejo && runtime == Runtime::Nix) {
workflow.push_str(" pull_request:\n");
}
workflow.push('\n');
push_concurrency(&mut workflow);
workflow.push_str("jobs:\n");
workflow.push_str(" test:\n");
workflow.push_str(" runs-on: ");
workflow.push_str(&runs_on(&runners.ci));
workflow.push('\n');
push_container(&mut workflow, platform, runtime, package);
push_job_env(&mut workflow, runtime, &options.extra_env);
workflow.push_str(" steps:\n");
push_checkout_step(&mut workflow, platform);
match runtime {
Runtime::Nix => {
push_install_nix_step(&mut workflow, platform);
push_nix_cargo_bin_path_step(&mut workflow);
push_extra_setup_steps(&mut workflow, &options.extra_setup);
match options.om_ci {
OmCiMode::Off => {
push_nix_ci_legacy_steps(
&mut workflow,
platform,
package,
self_check,
&options,
);
}
OmCiMode::Replace => {
push_om_ci_step(&mut workflow, &options);
push_quality_tool_install_steps(&mut workflow, runtime, &options);
push_optional_ci_steps(&mut workflow, runtime, package, &options);
if self_check.enabled {
push_self_check_steps(
&mut workflow,
platform,
runtime,
self_check,
&options,
);
}
}
OmCiMode::Augment => {
push_om_ci_step(&mut workflow, &options);
push_nix_ci_legacy_steps(
&mut workflow,
platform,
package,
self_check,
&options,
);
}
}
}
Runtime::Cargo => {
push_rust_setup_step(&mut workflow, platform);
push_rust_cache_steps(&mut workflow, platform);
push_extra_setup_steps(&mut workflow, &options.extra_setup);
push_test_steps(&mut workflow, package, &options);
push_quality_tool_install_steps(&mut workflow, runtime, &options);
push_optional_ci_steps(&mut workflow, runtime, package, &options);
if self_check.enabled {
push_self_check_steps(&mut workflow, platform, runtime, self_check, &options);
}
push_clippy_steps(&mut workflow, package, &options);
push_package_crate_step(&mut workflow, package, &options);
}
}
trim_trailing_blank_lines(&mut workflow);
workflow
}
fn publish_workflow(
platform: Platform,
runtime: Runtime,
package: &Package,
runner: &ResolvedRunner,
options: CiOptions,
) -> String {
let mut workflow = String::new();
push_generated_workflow_header(&mut workflow);
push_required_secrets_header(&mut workflow, &options.required_secrets);
workflow.push_str(
"# Before creating and pushing a release tag, run `simit changelog release <version>` locally.\n",
);
push_release_security_header(&mut workflow, false);
workflow.push_str("name: Publish Crate\n\n");
workflow.push_str("on:\n");
workflow.push_str(" push:\n");
workflow.push_str(" tags:\n");
workflow.push_str(" - \"*.*.*\"\n");
workflow.push_str(" workflow_dispatch:\n");
workflow.push_str(" inputs:\n");
workflow.push_str(" force_publish:\n");
workflow.push_str(
" description: \"Bypass release smoke checks and continue publishing.\"\n",
);
workflow.push_str(" required: false\n");
workflow.push_str(" default: false\n");
workflow.push_str(" type: boolean\n\n");
push_concurrency(&mut workflow);
workflow.push_str("jobs:\n");
workflow.push_str(" publish:\n");
push_release_permissions(&mut workflow, platform);
workflow.push_str(" runs-on: ");
workflow.push_str(&runs_on(runner));
workflow.push('\n');
push_container(&mut workflow, platform, runtime, package);
push_job_env(&mut workflow, runtime, &options.extra_env);
workflow.push_str(" steps:\n");
push_checkout_step(&mut workflow, platform);
match runtime {
Runtime::Nix => {
push_install_nix_step(&mut workflow, platform);
push_nix_cargo_bin_path_step(&mut workflow);
push_extra_setup_steps(&mut workflow, &options.extra_setup);
workflow.push_str(&validate_tag_step(command_prefix(runtime), &package.name));
match options.om_ci {
OmCiMode::Off => {
push_nix_publish_legacy_steps(&mut workflow, package, &options);
}
OmCiMode::Replace => {
push_om_ci_step(&mut workflow, &options);
push_quality_tool_install_steps(&mut workflow, runtime, &options);
push_optional_publish_steps(&mut workflow, runtime, &options);
workflow.push_str(" - name: Dry-run publish\n");
workflow.push_str(" run: nix develop -c cargo publish");
push_package_selector(&mut workflow, package, &options);
workflow.push_str(" --dry-run\n\n");
workflow.push_str(&publish_step(
package,
"nix develop -c cargo publish",
options.package_scoped,
));
}
OmCiMode::Augment => {
push_om_ci_step(&mut workflow, &options);
push_nix_publish_legacy_steps(&mut workflow, package, &options);
}
}
}
Runtime::Cargo => {
push_rust_setup_step(&mut workflow, platform);
push_rust_cache_steps(&mut workflow, platform);
push_extra_setup_steps(&mut workflow, &options.extra_setup);
workflow.push_str(&validate_tag_step(command_prefix(runtime), &package.name));
push_test_steps(&mut workflow, package, &options);
push_quality_tool_install_steps(&mut workflow, runtime, &options);
push_optional_publish_steps(&mut workflow, runtime, &options);
push_clippy_steps(&mut workflow, package, &options);
workflow.push_str(" - name: Dry-run publish\n");
workflow.push_str(" run: cargo publish");
push_package_selector(&mut workflow, package, &options);
workflow.push_str(" --dry-run\n\n");
workflow.push_str(&publish_step(
package,
"cargo publish",
options.package_scoped,
));
}
}
workflow
}
fn artifacts_workflow(
platform: Platform,
runtime: Runtime,
package: &Package,
runners: &ResolvedCiRunners,
options: &CiOptions,
) -> String {
let mut workflow = String::new();
push_generated_workflow_header(&mut workflow);
push_required_secrets_header(&mut workflow, &options.required_secrets);
workflow.push_str("name: Release Artifacts\n\n");
push_release_security_header(&mut workflow, true);
workflow.push_str("on:\n");
workflow.push_str(" push:\n");
workflow.push_str(" tags:\n");
workflow.push_str(" - \"*.*.*\"\n\n");
push_concurrency(&mut workflow);
workflow.push_str("jobs:\n");
let has_windows_packagers = options.chocolatey.is_some() || options.scoop.is_some();
let linux_job_name = if has_windows_packagers {
"build-linux"
} else {
"build"
};
workflow.push_str(" ");
workflow.push_str(linux_job_name);
workflow.push_str(":\n");
push_release_permissions(&mut workflow, platform);
workflow.push_str(" runs-on: ");
workflow.push_str(&runs_on(&runners.release));
workflow.push('\n');
push_container(&mut workflow, platform, runtime, package);
push_job_env(&mut workflow, runtime, &options.extra_env);
workflow.push_str(" steps:\n");
push_checkout_step(&mut workflow, platform);
workflow.push_str(&validate_release_tag_step(None, None));
match runtime {
Runtime::Nix => {
push_install_nix_step(&mut workflow, platform);
push_nix_cargo_bin_path_step(&mut workflow);
push_extra_setup_steps(&mut workflow, &options.extra_setup);
workflow.push_str(" - name: Build package\n");
workflow.push_str(" run: nix build\n\n");
}
Runtime::Cargo => {
push_rust_setup_step(&mut workflow, platform);
push_rust_cache_steps(&mut workflow, platform);
push_extra_setup_steps(&mut workflow, &options.extra_setup);
workflow.push_str(" - name: Build release binary\n");
workflow.push_str(" run: cargo build --release --locked");
push_package_selector(&mut workflow, package, options);
workflow.push_str("\n\n");
workflow.push_str(" - name: Install Nix release tools\n");
workflow.push_str(" uses: https://github.com/cachix/install-nix-action@v31\n\n");
}
}
push_release_integrity_steps(&mut workflow, platform);
if let Some(command) = options.release_smoke_command.as_deref() {
push_release_smoke_step(&mut workflow, command);
}
if let Some(homebrew) = &options.homebrew {
push_homebrew_publish_step(&mut workflow, homebrew);
}
if has_windows_packagers {
let windows_runner = runners
.windows
.as_ref()
.expect("windows runner resolved for windows packagers");
push_windows_build_job(&mut workflow, platform, package, windows_runner, options);
push_windows_publish_job(&mut workflow, platform, windows_runner, options);
}
workflow
}
fn push_windows_build_job(
workflow: &mut String,
platform: Platform,
package: &Package,
windows_runner: &ResolvedRunner,
options: &CiOptions,
) {
let matrix = windows_matrix(options);
workflow.push_str("\n build-windows:\n");
workflow.push_str(" runs-on: ");
workflow.push_str(&runs_on(windows_runner));
workflow.push('\n');
workflow.push_str(" strategy:\n");
workflow.push_str(" fail-fast: false\n");
workflow.push_str(" matrix:\n");
workflow.push_str(" include:\n");
for row in &matrix {
workflow.push_str(" - arch: ");
workflow.push_str(row.arch);
workflow.push('\n');
workflow.push_str(" target: ");
workflow.push_str(row.target);
workflow.push('\n');
}
workflow.push_str(" steps:\n");
push_checkout_step(workflow, platform);
push_windows_rust_setup_step(workflow, platform);
workflow.push_str(" - name: Install Windows target\n");
workflow.push_str(" run: rustup target add ${{ matrix.target }}\n\n");
workflow.push_str(" - name: Build release binary\n");
workflow.push_str(" run: cargo build --release --locked");
push_package_selector(workflow, package, options);
workflow.push_str(" --target ${{ matrix.target }}\n\n");
push_windows_archive_step(workflow, package, options);
workflow.push_str(" - name: Upload Windows archives\n");
push_action_uses(workflow, platform, "upload-artifact", "v4");
workflow.push_str(" with:\n");
workflow.push_str(" name: windows-${{ matrix.arch }}\n");
workflow.push_str(" path: release/*.zip\n");
}
fn push_windows_publish_job(
workflow: &mut String,
platform: Platform,
windows_runner: &ResolvedRunner,
options: &CiOptions,
) {
workflow.push_str("\n publish-windows-packages:\n");
workflow.push_str(" needs: build-windows\n");
workflow.push_str(" runs-on: ");
workflow.push_str(&runs_on(windows_runner));
workflow.push('\n');
workflow.push_str(" steps:\n");
push_checkout_step(workflow, platform);
workflow.push_str(" - name: Download Windows archives\n");
push_action_uses(workflow, platform, "download-artifact", "v4");
workflow.push_str(" with:\n");
workflow.push_str(" pattern: windows-*\n");
workflow.push_str(" path: release\n");
workflow.push_str(" merge-multiple: true\n\n");
push_windows_rust_setup_step(workflow, platform);
workflow.push_str(" - name: Install simit\n");
workflow.push_str(" run: cargo install --locked simit\n\n");
if let Some(chocolatey) = &options.chocolatey {
push_chocolatey_publish_step(workflow, chocolatey);
}
if let Some(scoop) = &options.scoop {
push_scoop_publish_step(workflow, scoop);
}
}
#[derive(Clone, Copy)]
struct WindowsMatrixRow {
arch: &'static str,
target: &'static str,
}
fn windows_matrix(options: &CiOptions) -> Vec<WindowsMatrixRow> {
let mut rows = Vec::new();
if options.chocolatey.is_some() || options.scoop.as_ref().is_some_and(|scoop| scoop.x64) {
rows.push(WindowsMatrixRow {
arch: "x64",
target: "x86_64-pc-windows-msvc",
});
}
if options.scoop.as_ref().is_some_and(|scoop| scoop.arm64) {
rows.push(WindowsMatrixRow {
arch: "arm64",
target: "aarch64-pc-windows-msvc",
});
}
rows
}
fn push_windows_rust_setup_step(workflow: &mut String, platform: Platform) {
match platform {
Platform::Github => {
workflow.push_str(" - name: Install Rust\n");
workflow.push_str(" uses: dtolnay/rust-toolchain@stable\n");
workflow.push_str(" with:\n");
workflow.push_str(" toolchain: stable\n\n");
}
Platform::Forgejo => {
workflow.push_str(" - name: Install Rust\n");
workflow.push_str(" run: |\n");
workflow.push_str(" rustup toolchain install stable --profile minimal\n");
workflow.push_str(" rustup default stable\n\n");
}
}
}
fn push_windows_archive_step(workflow: &mut String, package: &Package, options: &CiOptions) {
let archives = windows_archive_specs(options);
workflow.push_str(" - name: Package Windows archive\n");
workflow.push_str(" shell: pwsh\n");
workflow.push_str(" run: |\n");
workflow.push_str(" $version = $env:GITHUB_REF_NAME\n");
workflow.push_str(" if (-not $version) { $version = $env:FORGE_REF_NAME }\n");
workflow.push_str(" if (-not $version -and $env:GITHUB_REF) { $version = $env:GITHUB_REF -replace '^refs/tags/', '' }\n");
workflow.push_str(" if (-not $version -and $env:FORGE_REF) { $version = $env:FORGE_REF -replace '^refs/tags/', '' }\n");
workflow.push_str(" if (-not $version) { throw 'Release tag name is unavailable' }\n");
workflow.push_str(" New-Item -ItemType Directory -Force release | Out-Null\n");
workflow.push_str(" $binary = \"target/${{ matrix.target }}/release/");
workflow.push_str(&package.name);
workflow.push_str(".exe\"\n");
workflow.push_str(
" if (-not (Test-Path $binary)) { throw \"missing Windows binary: $binary\" }\n",
);
for archive in archives {
workflow.push_str(" if (\"${{ matrix.arch }}\" -eq \"");
workflow.push_str(archive.arch);
workflow.push_str("\") {\n");
workflow.push_str(" $archive = Join-Path release ");
workflow.push_str(&ps_expanding_double_quote(&archive.file_name));
workflow.push('\n');
workflow.push_str(
" Compress-Archive -Path $binary -DestinationPath $archive -Force\n",
);
workflow.push_str(" }\n");
}
workflow.push('\n');
}
#[derive(Clone, Eq, PartialEq)]
struct WindowsArchiveSpec {
arch: &'static str,
key: &'static str,
file_name: String,
}
fn windows_archive_specs(options: &CiOptions) -> Vec<WindowsArchiveSpec> {
let mut specs = Vec::new();
if let Some(chocolatey) = &options.chocolatey {
specs.push(WindowsArchiveSpec {
arch: "x64",
key: "x64",
file_name: resolve_windows_archive(
&chocolatey.archive_pattern,
&chocolatey.name,
"x86_64",
),
});
}
if let Some(scoop) = &options.scoop {
if scoop.x64 {
specs.push(WindowsArchiveSpec {
arch: "x64",
key: "x64",
file_name: resolve_windows_archive(&scoop.archive_pattern, &scoop.name, "x86_64"),
});
}
if scoop.arm64 {
specs.push(WindowsArchiveSpec {
arch: "arm64",
key: "arm64",
file_name: resolve_windows_archive(&scoop.archive_pattern, &scoop.name, "aarch64"),
});
}
}
specs.sort_by(|left, right| left.file_name.cmp(&right.file_name));
specs.dedup_by(|left, right| left.file_name == right.file_name && left.arch == right.arch);
specs
}
fn resolve_windows_archive(pattern: &str, name: &str, arch: &str) -> String {
pattern
.replace("{name}", name)
.replace("{version}", "$version")
.replace("{arch}", arch)
}
fn push_chocolatey_publish_step(workflow: &mut String, opts: &ChocolateyOptions) {
let archive = resolve_windows_archive(&opts.archive_pattern, &opts.name, "x86_64");
workflow.push_str(" - name: Install Chocolatey\n");
workflow.push_str(" shell: pwsh\n");
workflow.push_str(" run: |\n");
workflow.push_str(" if (Get-Command choco -ErrorAction SilentlyContinue) { choco --version; exit 0 }\n");
workflow.push_str(" Set-ExecutionPolicy Bypass -Scope Process -Force\n");
workflow.push_str(" [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072\n");
workflow.push_str(
" iwr https://community.chocolatey.org/install.ps1 -UseBasicParsing | iex\n\n",
);
workflow.push_str(" - name: Publish Chocolatey package\n");
workflow.push_str(" env:\n");
workflow.push_str(" CHOCOLATEY_API_KEY: ${{ secrets.chocolatey_api_key }}\n");
workflow.push_str(" CHOCO_PUSH_SOURCE: ");
workflow.push_str(&opts.push_source);
workflow.push('\n');
workflow.push_str(" shell: pwsh\n");
workflow.push_str(" run: |\n");
workflow.push_str(" if (-not $env:CHOCOLATEY_API_KEY) { Write-Host 'CHOCOLATEY_API_KEY not configured; skipping Chocolatey package update.'; exit 0 }\n");
push_windows_version_lines(workflow);
workflow.push_str(" simit dist chocolatey bump `\n");
workflow.push_str(" --version $version `\n");
workflow.push_str(" --package-dir chocolatey-package `\n");
workflow.push_str(" --archive ");
workflow.push_str(&ps_expanding_double_quote(&format!(
"x64=release/{archive}"
)));
workflow.push_str(" `\n");
push_chocolatey_cli_flags(workflow, opts);
workflow.push_str(" --push `\n");
workflow.push_str(" --push-source \"$env:CHOCO_PUSH_SOURCE\" `\n");
workflow.push_str(" --api-key-env CHOCOLATEY_API_KEY\n\n");
}
fn push_scoop_publish_step(workflow: &mut String, opts: &ScoopOptions) {
workflow.push_str(" - name: Publish Scoop bucket\n");
workflow.push_str(" env:\n");
workflow.push_str(" SCOOP_BUCKET_TOKEN: ${{ secrets.");
workflow.push_str(&opts.bucket_token_secret);
workflow.push_str(" }}\n");
workflow.push_str(" SCOOP_BUCKET_URL: ");
workflow.push_str(&opts.bucket_url);
workflow.push('\n');
workflow.push_str(" shell: pwsh\n");
workflow.push_str(" run: |\n");
workflow.push_str(" if (-not $env:SCOOP_BUCKET_TOKEN) { Write-Host 'SCOOP_BUCKET_TOKEN not configured; skipping Scoop bucket update.'; exit 0 }\n");
push_windows_version_lines(workflow);
workflow.push_str(" $credentialHelper = '!f() { echo username=caniko; echo \"password=$SCOOP_BUCKET_TOKEN\"; }; f'\n");
workflow.push_str(" if (Test-Path bucket) { Remove-Item -Recurse -Force bucket }\n");
workflow.push_str(" git -c credential.helper=\"$credentialHelper\" clone \"$env:SCOOP_BUCKET_URL\" bucket\n");
workflow.push_str(" Push-Location bucket\n");
workflow.push_str(" git config credential.helper \"$credentialHelper\"\n");
workflow.push_str(" git config user.email 'ci@simit.rs'\n");
workflow.push_str(" git config user.name 'simit release bot'\n");
workflow.push_str(" git remote set-head origin -a\n");
workflow.push_str(" $defaultBranch = (git symbolic-ref --short refs/remotes/origin/HEAD) -replace '^origin/', ''\n");
workflow.push_str(" git checkout $defaultBranch\n");
workflow.push_str(" Pop-Location\n");
workflow.push_str(" simit dist scoop bump `\n");
workflow.push_str(" --version $version `\n");
workflow.push_str(" --bucket bucket `\n");
for spec in scoop_archive_specs(opts) {
workflow.push_str(" --archive ");
workflow.push_str(&ps_expanding_double_quote(&format!(
"{}=release/{}",
spec.key, spec.file_name
)));
workflow.push_str(" `\n");
}
push_scoop_cli_flags(workflow, opts);
workflow.push_str(" --push\n\n");
}
fn push_windows_version_lines(workflow: &mut String) {
workflow.push_str(" $version = $env:GITHUB_REF_NAME\n");
workflow.push_str(" if (-not $version) { $version = $env:FORGE_REF_NAME }\n");
workflow.push_str(" if (-not $version -and $env:GITHUB_REF) { $version = $env:GITHUB_REF -replace '^refs/tags/', '' }\n");
workflow.push_str(" if (-not $version -and $env:FORGE_REF) { $version = $env:FORGE_REF -replace '^refs/tags/', '' }\n");
workflow.push_str(" if (-not $version) { throw 'Release tag name is unavailable' }\n");
}
fn push_chocolatey_cli_flags(workflow: &mut String, opts: &ChocolateyOptions) {
push_ps_arg(workflow, "--choco-name", &opts.name);
push_ps_arg(workflow, "--choco-id", &opts.id);
push_ps_arg(workflow, "--choco-title", &opts.title);
if let Some(authors) = &opts.authors {
push_ps_arg(workflow, "--choco-authors", authors);
}
push_ps_arg(workflow, "--choco-description", &opts.description);
push_ps_arg(workflow, "--choco-project-url", &opts.project_url);
if let Some(license_url) = &opts.license_url {
push_ps_arg(workflow, "--choco-license-url", license_url);
}
if let Some(tags) = &opts.tags {
push_ps_arg(workflow, "--choco-tags", tags);
}
if let Some(release_notes_url) = &opts.release_notes_url {
push_ps_arg(workflow, "--choco-release-notes-url", release_notes_url);
}
push_ps_arg(workflow, "--choco-download-repo", &opts.download_repo);
push_ps_arg(workflow, "--choco-archive-pattern", &opts.archive_pattern);
}
fn push_scoop_cli_flags(workflow: &mut String, opts: &ScoopOptions) {
push_ps_arg(workflow, "--scoop-name", &opts.name);
push_ps_arg(workflow, "--scoop-bucket", &opts.bucket_url);
push_ps_arg(workflow, "--scoop-description", &opts.description);
push_ps_arg(workflow, "--scoop-homepage", &opts.homepage);
push_ps_arg(workflow, "--scoop-license", &opts.license);
push_ps_arg(workflow, "--scoop-download-repo", &opts.download_repo);
push_ps_arg(workflow, "--scoop-archive-pattern", &opts.archive_pattern);
let binaries = if opts.binaries.is_empty() {
vec![opts.name.as_str()]
} else {
opts.binaries.iter().map(String::as_str).collect::<Vec<_>>()
};
for binary in binaries {
push_ps_arg(workflow, "--scoop-binary", binary);
}
if !opts.x64 {
push_ps_arg(workflow, "--scoop-no-arch", "x64");
}
if !opts.arm64 {
push_ps_arg(workflow, "--scoop-no-arch", "arm64");
}
}
fn scoop_archive_specs(opts: &ScoopOptions) -> Vec<WindowsArchiveSpec> {
let mut specs = Vec::new();
if opts.x64 {
specs.push(WindowsArchiveSpec {
arch: "x64",
key: "x64",
file_name: resolve_windows_archive(&opts.archive_pattern, &opts.name, "x86_64"),
});
}
if opts.arm64 {
specs.push(WindowsArchiveSpec {
arch: "arm64",
key: "arm64",
file_name: resolve_windows_archive(&opts.archive_pattern, &opts.name, "aarch64"),
});
}
specs
}
fn push_ps_arg(workflow: &mut String, flag: &str, value: &str) {
workflow.push_str(" ");
workflow.push_str(flag);
workflow.push(' ');
workflow.push_str(&ps_double_quote(value));
workflow.push_str(" `\n");
}
fn push_homebrew_publish_step(workflow: &mut String, opts: &HomebrewOptions) {
let tap_repo = homebrew_tap_repo(&opts.tap_url);
let archives = homebrew_archives(opts);
let binaries = if opts.binaries.is_empty() {
vec![opts.name.as_str()]
} else {
opts.binaries.iter().map(String::as_str).collect()
};
workflow.push_str(" - name: Publish Homebrew tap\n");
workflow.push_str(" env:\n");
workflow.push_str(" HOMEBREW_TAP_TOKEN: ${{ secrets.");
workflow.push_str(&opts.tap_token_secret);
workflow.push_str(" }}\n");
workflow.push_str(" HOMEBREW_TAP_REPO: ");
workflow.push_str(&tap_repo);
workflow.push('\n');
workflow.push_str(" HOMEBREW_TAP_URL: ");
workflow.push_str(&opts.tap_url);
workflow.push('\n');
workflow.push_str(" run: |\n");
workflow.push_str(" set -euo pipefail\n\n");
workflow.push_str(" if [ -z \"${HOMEBREW_TAP_TOKEN:-}\" ]; then\n");
workflow.push_str(
" echo \"HOMEBREW_TAP_TOKEN not configured; skipping Homebrew tap update.\"\n",
);
workflow.push_str(" exit 0\n");
workflow.push_str(" fi\n\n");
workflow.push_str(" VERSION=\"$CODEBERG_REF_NAME\"\n");
workflow.push_str(" for artifact in \\\n");
for (index, archive) in archives.iter().enumerate() {
workflow.push_str(" \"release/");
workflow.push_str(&archive.file_name);
workflow.push('"');
if index + 1 == archives.len() {
workflow.push_str("; do\n");
} else {
workflow.push_str(" \\\n");
}
}
workflow.push_str(" test -s \"$artifact\"\n");
workflow.push_str(" done\n\n");
workflow.push_str(
" credential_helper='!f() { echo username=caniko; echo \"password=$HOMEBREW_TAP_TOKEN\"; }; f'\n",
);
workflow.push_str(" rm -rf tap\n");
workflow.push_str(" git -c credential.helper=\"$credential_helper\" clone \"$HOMEBREW_TAP_URL\" tap\n");
workflow.push_str(" cd tap\n");
workflow.push_str(" git config credential.helper \"$credential_helper\"\n");
workflow.push_str(" git config user.email 'ci@modde.tartanoglu.com'\n");
workflow.push_str(" git config user.name 'modde release bot'\n");
workflow.push_str(" git remote set-head origin -a\n");
workflow.push_str(
" DEFAULT_BRANCH=\"$(git symbolic-ref --short refs/remotes/origin/HEAD | sed 's|^origin/||')\"\n",
);
workflow.push_str(" git checkout \"$DEFAULT_BRANCH\"\n");
workflow.push_str(" cd ..\n\n");
workflow.push_str(" nix run '.#rs-harbor' -- brew bump \\\n");
workflow.push_str(" --name ");
workflow.push_str(&shell_word(&opts.name));
workflow.push_str(" \\\n");
workflow.push_str(" --version \"$VERSION\" \\\n");
workflow.push_str(" --description ");
workflow.push_str(&shell_quote(&opts.description));
workflow.push_str(" \\\n");
workflow.push_str(" --homepage ");
workflow.push_str(&shell_quote(&opts.homepage));
workflow.push_str(" \\\n");
workflow.push_str(" --license ");
workflow.push_str(&shell_word(&opts.license));
workflow.push_str(" \\\n");
for archive in &archives {
workflow.push_str(" --archive \"");
workflow.push_str(archive.key);
workflow.push('=');
workflow.push_str("https://codeberg.org/");
workflow.push_str(&opts.download_repo);
workflow.push_str("/releases/download/${VERSION}/");
workflow.push_str(&archive.file_name);
workflow.push_str(",release/");
workflow.push_str(&archive.file_name);
workflow.push_str("\" \\\n");
}
for binary in binaries {
workflow.push_str(" --binary ");
workflow.push_str(&shell_word(binary));
workflow.push_str(" \\\n");
}
workflow.push_str(" --tap \"$PWD/tap\"\n\n");
workflow.push_str(" cd tap\n");
workflow.push_str(" if [ -z \"$(git status --porcelain -- Formula/");
workflow.push_str(&opts.name);
workflow.push_str(".rb)\" ]; then\n");
workflow.push_str(" echo \"tap already contains ");
workflow.push_str(&opts.name);
workflow.push_str(" ${VERSION}; nothing to push\"\n");
workflow.push_str(" exit 0\n");
workflow.push_str(" fi\n\n");
workflow.push_str(" git add Formula/");
workflow.push_str(&opts.name);
workflow.push_str(".rb\n");
workflow.push_str(" git commit -m \"");
workflow.push_str(&opts.name);
workflow.push_str(" ${VERSION}\"\n");
workflow.push_str(" git push origin \"HEAD:${DEFAULT_BRANCH}\"\n");
}
struct HomebrewArchive {
key: &'static str,
file_name: String,
}
fn homebrew_archives(opts: &HomebrewOptions) -> Vec<HomebrewArchive> {
[
("darwin_arm", "aarch64", "darwin", opts.platforms.darwin_arm),
(
"darwin_intel",
"x86_64",
"darwin",
opts.platforms.darwin_intel,
),
("linux_arm", "aarch64", "linux", opts.platforms.linux_arm),
("linux_intel", "x86_64", "linux", opts.platforms.linux_intel),
]
.into_iter()
.filter(|(_, _, _, enabled)| *enabled)
.map(|(key, arch, os, _)| HomebrewArchive {
key,
file_name: resolve_archive(opts, arch, os),
})
.collect()
}
fn resolve_archive(opts: &HomebrewOptions, arch: &str, os: &str) -> String {
opts.archive_pattern
.replace("{name}", &opts.name)
.replace("{version}", "${VERSION}")
.replace("{arch}", arch)
.replace("{os}", os)
}
fn homebrew_tap_repo(tap_url: &str) -> String {
tap_url
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_end_matches(".git")
.split_once('/')
.map(|(_, path)| path.to_owned())
.unwrap_or_else(|| tap_url.to_owned())
}
fn push_release_security_header(workflow: &mut String, include_artifact_signing: bool) {
workflow.push_str("# Required release trust roots and secrets:\n");
workflow.push_str(
"# - keys/maintainers.gpg: pinned maintainer OpenPGP public keys for git verify-tag.\n",
);
if !include_artifact_signing {
return;
}
workflow.push_str(
"# - keys/minisign.pub: pinned minisign public key for SHA256SUMS.txt.minisig.\n",
);
workflow.push_str("# - MINISIGN_SECRET_KEY: password-protected minisign secret key for signing SHA256SUMS.txt.\n");
workflow.push_str("# - MINISIGN_PASSWORD: password for MINISIGN_SECRET_KEY.\n");
workflow.push_str("# - COSIGN_PRIVATE_KEY and COSIGN_PASSWORD: optional fallback when keyless Sigstore OIDC is unavailable.\n");
}
fn push_release_permissions(workflow: &mut String, platform: Platform) {
match platform {
Platform::Github => {
workflow.push_str(" permissions:\n");
workflow.push_str(" contents: read\n");
workflow.push_str(" id-token: write\n");
}
Platform::Forgejo => {
workflow.push_str(" enable-openid-connect: true\n");
}
}
}
fn push_release_integrity_steps(workflow: &mut String, platform: Platform) {
workflow.push_str(" - name: Generate signed checksums and provenance\n");
workflow.push_str(" env:\n");
workflow.push_str(" MINISIGN_SECRET_KEY: ${{ secrets.MINISIGN_SECRET_KEY }}\n");
workflow.push_str(" MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}\n");
workflow.push_str(" COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}\n");
workflow.push_str(" COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}\n");
workflow.push_str(" run: |\n");
workflow.push_str(" set -euo pipefail\n");
workflow.push_str(" test -d release\n");
workflow.push_str(" test -s keys/minisign.pub\n");
workflow.push_str(" test -n \"${MINISIGN_SECRET_KEY:-}\"\n");
workflow.push_str(" test -n \"${MINISIGN_PASSWORD:-}\"\n\n");
workflow.push_str(" find release -maxdepth 1 -type f ! -name 'SHA256SUMS.txt*' ! -name '*.cosign.bundle' ! -name '*.intoto.*' -print0 | sort -z | xargs -0 sha256sum > release/SHA256SUMS.txt\n");
workflow.push_str(" test -s release/SHA256SUMS.txt\n\n");
workflow.push_str(" umask 077\n");
workflow.push_str(" minisign_key=\"$(mktemp)\"\n");
workflow.push_str(" oidc_token=\"$(mktemp)\"\n");
workflow.push_str(" cosign_key=\"$(mktemp)\"\n");
workflow.push_str(" export oidc_token cosign_key\n");
workflow.push_str(
" cleanup() { rm -f \"$minisign_key\" \"$oidc_token\" \"$cosign_key\"; }\n",
);
workflow.push_str(" trap cleanup EXIT\n");
workflow.push_str(" printf '%s' \"$MINISIGN_SECRET_KEY\" > \"$minisign_key\"\n");
workflow.push_str(" printf '%s\\n' \"$MINISIGN_PASSWORD\" |\n");
workflow.push_str(" nix shell nixpkgs#minisign -c minisign -S \\\n");
workflow.push_str(" -s \"$minisign_key\" \\\n");
workflow.push_str(" -m release/SHA256SUMS.txt \\\n");
workflow.push_str(" -x release/SHA256SUMS.txt.minisig \\\n");
workflow.push_str(" -c \"untrusted comment: ${GITHUB_REF_NAME:-${FORGE_REF_NAME:-${CODEBERG_REF_NAME:-release}}} SHA256SUMS signature\"\n");
workflow.push_str(" nix shell nixpkgs#minisign -c minisign -V \\\n");
workflow.push_str(" -m release/SHA256SUMS.txt \\\n");
workflow.push_str(" -x release/SHA256SUMS.txt.minisig \\\n");
workflow.push_str(" -p keys/minisign.pub\n\n");
workflow.push_str(
" nix shell nixpkgs#cosign nixpkgs#curl nixpkgs#jq -c bash <<'SCRIPT'\n",
);
workflow.push_str(" set -euo pipefail\n");
workflow.push_str(
" tag=\"${GITHUB_REF_NAME:-${FORGE_REF_NAME:-${CODEBERG_REF_NAME:-}}}\"\n",
);
workflow.push_str(" if [ -z \"$tag\" ]; then ref=\"${GITHUB_REF:-${FORGE_REF:-${CODEBERG_REF:-}}}\"; tag=\"${ref#refs/tags/}\"; fi\n");
workflow.push_str(" repo_url=\"${GITHUB_SERVER_URL:-${FORGE_SERVER_URL:-https://codeberg.org}}/${GITHUB_REPOSITORY:-${FORGE_REPOSITORY:-${CODEBERG_REPOSITORY:-unknown/unknown}}}\"\n");
workflow.push_str(" git_sha=\"$(git rev-parse HEAD)\"\n");
workflow.push_str(" workflow_sha=\"$(sha256sum ");
workflow.push_str(platform.workflow_dir());
workflow.push_str("/release-artifacts.yaml | awk '{print $1}')\"\n");
workflow.push_str(" flake_lock_sha=\"missing\"\n");
workflow.push_str(" if [ -f flake.lock ]; then flake_lock_sha=\"$(sha256sum flake.lock | awk '{print $1}')\"; fi\n");
workflow.push_str(" builder_id=\"${repo_url}/src/tag/${tag}/");
workflow.push_str(platform.workflow_dir());
workflow.push_str("/release-artifacts.yaml\"\n\n");
workflow.push_str(" sign_blob_keyless() {\n");
workflow.push_str(" file=\"$1\"\n");
workflow.push_str(" if [ -n \"${ACTIONS_ID_TOKEN_REQUEST_URL:-}\" ] && [ -n \"${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}\" ]; then\n");
workflow.push_str(" curl -fsSL -H \"Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}\" \"${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=sigstore\" > \"$oidc_token\"\n");
workflow.push_str(" cosign sign-blob --yes --identity-token \"$oidc_token\" --bundle \"${file}.cosign.bundle\" \"$file\"\n");
workflow.push_str(" else\n");
workflow.push_str(" return 1\n");
workflow.push_str(" fi\n");
workflow.push_str(" }\n\n");
workflow.push_str(" attest_blob_keyless() {\n");
workflow.push_str(" file=\"$1\" predicate=\"$2\"\n");
workflow.push_str(" if [ -s \"$oidc_token\" ]; then\n");
workflow.push_str(" cosign attest-blob --yes --identity-token \"$oidc_token\" --predicate \"$predicate\" --type slsaprovenance1 --output-attestation \"${file}.intoto.jsonl\" --bundle \"${file}.intoto.bundle\" \"$file\"\n");
workflow.push_str(" else\n");
workflow.push_str(" return 1\n");
workflow.push_str(" fi\n");
workflow.push_str(" }\n\n");
workflow.push_str(" sign_blob_with_key() {\n");
workflow.push_str(" file=\"$1\"\n");
workflow.push_str(" test -n \"${COSIGN_PRIVATE_KEY:-}\"\n");
workflow.push_str(" printf '%s' \"$COSIGN_PRIVATE_KEY\" > \"$cosign_key\"\n");
workflow.push_str(" cosign sign-blob --yes --key \"$cosign_key\" --bundle \"${file}.cosign.bundle\" \"$file\"\n");
workflow.push_str(" }\n\n");
workflow.push_str(" attest_blob_with_key() {\n");
workflow.push_str(" file=\"$1\" predicate=\"$2\"\n");
workflow.push_str(" cosign attest-blob --yes --key \"$cosign_key\" --predicate \"$predicate\" --type slsaprovenance1 --output-attestation \"${file}.intoto.jsonl\" --bundle \"${file}.intoto.bundle\" \"$file\"\n");
workflow.push_str(" }\n\n");
workflow.push_str(" find release -maxdepth 1 -type f \\( -name '*.tar.gz' -o -name '*.zip' -o -name '*.AppImage' -o -name '*.src.rpm' \\) -print0 | sort -z | while IFS= read -r -d '' file; do\n");
workflow.push_str(" artifact_sha=\"$(sha256sum \"$file\" | awk '{print $1}')\"\n");
workflow.push_str(" predicate=\"$(mktemp)\"\n");
workflow.push_str(" jq -n --arg builder_id \"$builder_id\" --arg git_sha \"$git_sha\" --arg workflow_sha \"$workflow_sha\" --arg flake_lock_sha \"$flake_lock_sha\" --arg artifact \"$(basename \"$file\")\" --arg artifact_sha \"$artifact_sha\" '{buildDefinition:{buildType:\"https://simit.rs/release-artifacts\",externalParameters:{artifact:$artifact,artifactDigest:{sha256:$artifact_sha}},internalParameters:{},resolvedDependencies:[{uri:\"git\",digest:{gitCommit:$git_sha}},{uri:\"release-artifacts workflow\",digest:{sha256:$workflow_sha}},{uri:\"flake.lock\",digest:{sha256:$flake_lock_sha}}]},runDetails:{builder:{id:$builder_id}}}' > \"$predicate\"\n");
workflow.push_str(" if sign_blob_keyless \"$file\" && attest_blob_keyless \"$file\" \"$predicate\"; then\n");
workflow.push_str(" echo \"signed and attested $file with keyless Sigstore\"\n");
workflow.push_str(" elif [ -n \"${COSIGN_PRIVATE_KEY:-}\" ]; then\n");
workflow.push_str(" echo \"keyless Sigstore failed for $file; using COSIGN_PRIVATE_KEY fallback\"\n");
workflow.push_str(" sign_blob_with_key \"$file\"\n");
workflow.push_str(" attest_blob_with_key \"$file\" \"$predicate\"\n");
workflow.push_str(" else\n");
workflow.push_str(" echo \"keyless Sigstore failed for $file, and COSIGN_PRIVATE_KEY is not configured\" >&2\n");
workflow.push_str(" exit 1\n");
workflow.push_str(" fi\n");
workflow.push_str(" rm -f \"$predicate\"\n");
workflow.push_str(" done\n");
workflow.push_str(" SCRIPT\n\n");
}
fn push_release_smoke_step(workflow: &mut String, command: &str) {
workflow.push_str(" - name: Run release smoke checks\n");
workflow.push_str(" env:\n");
workflow.push_str(" FORCE_PUBLISH: ${{ inputs.force_publish }}\n");
workflow.push_str(" run: |\n");
workflow.push_str(" set -euo pipefail\n");
workflow.push_str(" mkdir -p release\n\n");
workflow.push_str(" if [ \"${FORCE_PUBLISH:-false}\" = \"true\" ]; then\n");
workflow.push_str(" {\n");
workflow.push_str(" echo \"release smoke report\"\n");
workflow.push_str(" echo \"force_publish: true\"\n");
workflow.push_str(" echo \"smoke checks bypassed by workflow_dispatch input; external publish continued by explicit operator override\"\n");
workflow.push_str(" } | tee release/smoke-report.txt\n");
workflow.push_str(" exit 0\n");
workflow.push_str(" fi\n\n");
workflow.push_str(
" VERSION=\"${GITHUB_REF_NAME:-${FORGE_REF_NAME:-${CODEBERG_REF_NAME:-}}}\"\n",
);
workflow.push_str(" if [ -z \"$VERSION\" ]; then ref=\"${GITHUB_REF:-${FORGE_REF:-${CODEBERG_REF:-}}}\"; VERSION=\"${ref#refs/tags/}\"; fi\n");
workflow.push_str(" test -n \"$VERSION\"\n");
workflow.push_str(" ");
workflow.push_str(command);
workflow.push_str(" \"$VERSION\" release\n\n");
}
fn shell_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\\''"))
}
fn shell_word(value: &str) -> String {
if value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/'))
{
value.to_owned()
} else {
shell_quote(value)
}
}
fn ps_double_quote(value: &str) -> String {
format!(
"\"{}\"",
value
.replace('`', "``")
.replace('"', "`\"")
.replace('$', "`$")
)
}
fn ps_expanding_double_quote(value: &str) -> String {
format!("\"{}\"", value.replace('`', "``").replace('"', "`\""))
}
pub fn deny_toml() -> String {
r#"[advisories]
version = 2
[licenses]
version = 2
allow = [
"Apache-2.0",
"BSD-2-Clause",
"BSL-1.0",
"CC0-1.0",
"ISC",
"LicenseRef-UFL-1.0",
"MIT",
"OFL-1.1",
"Unicode-3.0",
"Unlicense",
"Zlib",
]
[bans]
multiple-versions = "warn"
wildcards = "allow"
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
"#
.to_owned()
}
fn push_concurrency(workflow: &mut String) {
workflow.push_str("concurrency:\n");
workflow.push_str(" group: ${{ github.workflow }}-${{ github.ref }}\n");
workflow.push_str(" cancel-in-progress: true\n\n");
}
fn push_generated_workflow_header(workflow: &mut String) {
workflow.push_str(GENERATED_WORKFLOW_MARKER);
workflow.push('\n');
}
fn push_required_secrets_header(workflow: &mut String, required_secrets: &[String]) {
if required_secrets.is_empty() {
return;
}
workflow.push_str("# Project-required secrets:\n");
for secret in required_secrets {
workflow.push_str("# - ");
workflow.push_str(secret);
workflow.push('\n');
}
}
fn push_container(workflow: &mut String, platform: Platform, runtime: Runtime, package: &Package) {
if platform == Platform::Forgejo && runtime == Runtime::Cargo {
workflow.push_str(" container: ");
workflow.push_str(&rust_container(package));
workflow.push('\n');
}
}
fn push_job_env(workflow: &mut String, runtime: Runtime, extra_env: &[(String, String)]) {
let needs_nix_config =
runtime == Runtime::Nix && !extra_env.iter().any(|(key, _)| key == "NIX_CONFIG");
let needs_xdg_cache_home =
runtime == Runtime::Nix && !extra_env.iter().any(|(key, _)| key == "XDG_CACHE_HOME");
let needs_cargo_home =
runtime == Runtime::Nix && !extra_env.iter().any(|(key, _)| key == "CARGO_HOME");
if !needs_nix_config && !needs_xdg_cache_home && !needs_cargo_home && extra_env.is_empty() {
return;
}
workflow.push_str(" env:\n");
if needs_nix_config {
workflow.push_str(" NIX_CONFIG: \"experimental-features = nix-command flakes\"\n");
}
if needs_xdg_cache_home {
workflow.push_str(" XDG_CACHE_HOME: \"/tmp/.cache\"\n");
}
if needs_cargo_home {
workflow.push_str(" CARGO_HOME: \"/tmp/.cargo\"\n");
}
for (key, value) in extra_env {
workflow.push_str(" ");
workflow.push_str(key);
workflow.push_str(": \"");
workflow.push_str(&yaml_double_quote(value));
workflow.push_str("\"\n");
}
}
fn yaml_double_quote(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
fn push_checkout_step(workflow: &mut String, platform: Platform) {
workflow.push_str(" - name: Checkout\n");
push_action_uses(workflow, platform, "checkout", "v4");
workflow.push('\n');
}
fn push_extra_setup_steps(workflow: &mut String, extra_setup: &[String]) {
for (index, command) in extra_setup.iter().enumerate() {
workflow.push_str(" - name: Project setup");
if extra_setup.len() > 1 {
workflow.push(' ');
workflow.push_str(&(index + 1).to_string());
}
workflow.push('\n');
if command.contains('\n') {
workflow.push_str(" run: |\n");
for line in command.lines() {
workflow.push_str(" ");
workflow.push_str(line);
workflow.push('\n');
}
workflow.push('\n');
} else {
workflow.push_str(" run: ");
workflow.push_str(command);
workflow.push_str("\n\n");
}
}
}
fn push_action_uses(workflow: &mut String, platform: Platform, action: &str, version: &str) {
if platform == Platform::Forgejo {
workflow.push_str(" uses: https://code.forgejo.org/actions/");
workflow.push_str(action);
workflow.push('@');
workflow.push_str(version);
workflow.push('\n');
} else {
workflow.push_str(" uses: actions/");
workflow.push_str(action);
workflow.push('@');
workflow.push_str(version);
workflow.push('\n');
}
}
fn push_install_nix_step(workflow: &mut String, platform: Platform) {
if platform == Platform::Forgejo {
return;
}
workflow.push_str(" - name: Install Nix\n");
workflow.push_str(" uses: https://github.com/cachix/install-nix-action@v31\n\n");
}
fn push_nix_cargo_bin_path_step(workflow: &mut String) {
workflow.push_str(" - name: Add cargo bin to PATH\n");
workflow.push_str(" run: echo \"$CARGO_HOME/bin\" >> \"$GITHUB_PATH\"\n\n");
}
fn push_om_ci_step(workflow: &mut String, options: &CiOptions) {
workflow.push_str(" - name: Run om ci\n");
workflow.push_str(" env:\n");
workflow.push_str(" OMNIX_REF: ");
workflow.push_str(&shell_word(&options.omnix_ref));
workflow.push('\n');
workflow.push_str(" run: nix run \"$OMNIX_REF\" -- ci run\n\n");
}
fn push_nix_ci_legacy_steps(
workflow: &mut String,
platform: Platform,
package: &Package,
self_check: SelfCheckOptions<'_>,
options: &CiOptions,
) {
workflow.push_str(" - name: Check flake\n");
workflow.push_str(" run: nix flake check\n\n");
workflow.push_str(" - name: Test\n");
workflow.push_str(" run: nix develop -c cargo test");
push_package_selector(workflow, package, options);
workflow.push_str("\n\n");
push_quality_tool_install_steps(workflow, Runtime::Nix, options);
push_optional_ci_steps(workflow, Runtime::Nix, package, options);
if self_check.enabled {
push_self_check_steps(workflow, platform, Runtime::Nix, self_check, options);
}
workflow.push_str(" - name: Clippy\n");
workflow.push_str(" run: nix develop -c cargo clippy");
push_package_selector(workflow, package, options);
workflow.push_str(" --all-targets -- --deny warnings\n\n");
push_nix_package_crate_step(workflow, package, options);
}
fn push_nix_package_crate_step(workflow: &mut String, package: &Package, options: &CiOptions) {
if !package.is_publishable() {
return;
}
workflow.push_str(" - name: Package crate\n");
workflow.push_str(" run: nix develop -c cargo package");
push_package_selector(workflow, package, options);
push_package_flags(workflow, package, options);
}
fn push_nix_publish_legacy_steps(workflow: &mut String, package: &Package, options: &CiOptions) {
workflow.push_str(" - name: Check flake\n");
workflow.push_str(" run: nix flake check\n\n");
workflow.push_str(" - name: Test\n");
workflow.push_str(" run: nix develop -c cargo test");
push_package_selector(workflow, package, options);
workflow.push_str("\n\n");
push_quality_tool_install_steps(workflow, Runtime::Nix, options);
push_optional_publish_steps(workflow, Runtime::Nix, options);
workflow.push_str(" - name: Clippy\n");
workflow.push_str(" run: nix develop -c cargo clippy");
push_package_selector(workflow, package, options);
workflow.push_str(" --all-targets -- --deny warnings\n\n");
workflow.push_str(" - name: Dry-run publish\n");
workflow.push_str(" run: nix develop -c cargo publish");
push_package_selector(workflow, package, options);
workflow.push_str(" --dry-run\n\n");
workflow.push_str(&publish_step(
package,
"nix develop -c cargo publish",
options.package_scoped,
));
}
fn rust_container(package: &Package) -> String {
let Some(rust_version) = package.rust_version.as_deref() else {
return "rust:bookworm".to_owned();
};
let distro = if rust_version_supports_trixie(rust_version) {
"trixie"
} else {
"bookworm"
};
format!("rust:{rust_version}-{distro}")
}
fn rust_version_supports_trixie(rust_version: &str) -> bool {
let mut parts = rust_version.split('.');
let major = parts.next().and_then(|part| part.parse::<u64>().ok());
let minor = parts.next().and_then(|part| part.parse::<u64>().ok());
matches!(
(major, minor),
(Some(major), _) if major > 1
) || matches!((major, minor), (Some(1), Some(minor)) if minor >= 93)
}
fn push_rust_setup_step(workflow: &mut String, platform: Platform) {
match platform {
Platform::Forgejo => {
workflow.push_str(" - name: Install Rust components\n");
workflow.push_str(" run: rustup component add clippy rustfmt\n\n");
}
Platform::Github => {
workflow.push_str(" - name: Install Rust\n");
workflow.push_str(" uses: dtolnay/rust-toolchain@stable\n");
workflow.push_str(" with:\n");
workflow.push_str(" toolchain: stable\n");
workflow.push_str(" components: rustfmt, clippy\n\n");
}
}
}
fn push_rust_cache_steps(workflow: &mut String, platform: Platform) {
workflow.push_str(" - name: Cache cargo bin (tools)\n");
push_action_uses(workflow, platform, "cache", "v4");
workflow.push_str(" with:\n");
workflow.push_str(" path: ~/.cargo/bin\n");
let workflow_glob = match platform {
Platform::Forgejo => ".forgejo/workflows/*.yaml",
Platform::Github => ".github/workflows/*.yaml",
};
workflow.push_str(&format!(
" key: cargo-bin-${{{{ runner.os }}}}-${{{{ hashFiles('{workflow_glob}') }}}}\n\n",
));
if platform == Platform::Forgejo {
return;
}
workflow.push_str(" - name: Cache cargo registry + target\n");
workflow.push_str(" uses: https://github.com/Swatinem/rust-cache@v2\n");
workflow.push_str(" with:\n");
workflow.push_str(" cache-all-crates: \"true\"\n");
workflow.push_str(" cache-on-failure: \"true\"\n");
workflow.push_str(" save-if: ${{ github.ref == 'refs/heads/trunk' }}\n\n");
}
fn push_test_steps(workflow: &mut String, package: &Package, options: &CiOptions) {
if options.with_nextest {
workflow.push_str(" - name: Install nextest\n");
workflow.push_str(" run: command -v cargo-nextest >/dev/null 2>&1 || cargo install cargo-nextest --locked --version ");
workflow.push_str(CARGO_NEXTEST_VERSION);
workflow.push_str("\n\n");
workflow.push_str(" - name: Test all features\n");
workflow.push_str(" run: cargo nextest run");
push_package_selector(workflow, package, options);
workflow.push_str(" --all-features\n\n");
} else {
workflow.push_str(" - name: Test all features\n");
workflow.push_str(" run: cargo test");
push_package_selector(workflow, package, options);
workflow.push_str(" --all-features\n\n");
}
if has_features(package) {
workflow.push_str(" - name: Test no default features\n");
if options.with_nextest {
workflow.push_str(" run: cargo nextest run");
push_package_selector(workflow, package, options);
workflow.push_str(" --no-default-features\n\n");
} else {
workflow.push_str(" run: cargo test");
push_package_selector(workflow, package, options);
workflow.push_str(" --no-default-features\n\n");
}
}
}
fn push_clippy_steps(workflow: &mut String, package: &Package, options: &CiOptions) {
workflow.push_str(" - name: Clippy all features\n");
workflow.push_str(" run: cargo clippy");
push_package_selector(workflow, package, options);
workflow.push_str(" --all-targets --all-features -- --deny warnings\n\n");
if has_features(package) {
workflow.push_str(" - name: Clippy no default features\n");
workflow.push_str(" run: cargo clippy");
push_package_selector(workflow, package, options);
workflow.push_str(" --all-targets --no-default-features -- --deny warnings\n\n");
}
}
fn push_package_crate_step(workflow: &mut String, package: &Package, options: &CiOptions) {
if !package.is_publishable() {
return;
}
workflow.push_str(" - name: Package crate\n");
workflow.push_str(" run: cargo package");
push_package_selector(workflow, package, options);
push_package_flags(workflow, package, options);
}
fn push_quality_tool_install_steps(workflow: &mut String, runtime: Runtime, options: &CiOptions) {
let prefix = command_prefix(runtime);
if options.with_audit {
workflow.push_str(" - name: Install cargo-audit\n");
workflow.push_str(" run: ");
if runtime == Runtime::Cargo {
workflow.push_str(
"command -v cargo-audit >/dev/null 2>&1 || cargo install cargo-audit --locked\n\n",
);
} else {
workflow.push_str(prefix);
workflow.push_str("cargo install cargo-audit --locked\n\n");
}
}
if options.with_deny {
workflow.push_str(" - name: Install cargo-deny\n");
workflow.push_str(" run: ");
if runtime == Runtime::Cargo {
workflow.push_str("command -v cargo-deny >/dev/null 2>&1 || cargo install cargo-deny --locked --version ");
workflow.push_str(CARGO_DENY_VERSION);
workflow.push_str("\n\n");
} else {
workflow.push_str(prefix);
workflow.push_str("cargo install cargo-deny --locked --version ");
workflow.push_str(CARGO_DENY_VERSION);
workflow.push_str("\n\n");
}
}
}
fn push_optional_ci_steps(
workflow: &mut String,
runtime: Runtime,
package: &Package,
options: &CiOptions,
) {
let prefix = command_prefix(runtime);
if options.with_msrv {
workflow.push_str(" - name: Check MSRV\n");
workflow.push_str(" run: ");
workflow.push_str(prefix);
workflow.push_str("cargo");
if runtime == Runtime::Cargo {
workflow.push_str(" +");
workflow.push_str(package.rust_version.as_deref().expect("validated MSRV"));
}
workflow.push_str(" check");
push_package_selector(workflow, package, options);
workflow.push_str(" --all-targets\n\n");
}
if options.with_audit {
push_audit_database_step(workflow);
push_audit_step(workflow, runtime);
}
if options.with_deny {
workflow.push_str(" - name: Deny dependency policy\n");
workflow.push_str(" run: ");
workflow.push_str(prefix);
workflow.push_str("cargo deny check ");
workflow.push_str(CARGO_DENY_POLICY_CHECKS);
workflow.push_str("\n\n");
}
if options.with_docs {
workflow.push_str(" - name: Build docs\n");
workflow.push_str(" run: ");
workflow.push_str(prefix);
workflow.push_str("cargo doc");
push_package_selector(workflow, package, options);
workflow.push_str(" --no-deps --all-features\n\n");
}
}
fn push_optional_publish_steps(workflow: &mut String, runtime: Runtime, options: &CiOptions) {
let prefix = command_prefix(runtime);
if options.with_audit {
push_audit_database_step(workflow);
push_audit_step(workflow, runtime);
}
if options.with_deny {
workflow.push_str(" - name: Deny dependency policy\n");
workflow.push_str(" run: ");
workflow.push_str(prefix);
workflow.push_str("cargo deny check ");
workflow.push_str(CARGO_DENY_POLICY_CHECKS);
workflow.push_str("\n\n");
}
if options.with_docs {
workflow.push_str(" - name: Build docs\n");
workflow.push_str(" run: ");
workflow.push_str(prefix);
workflow.push_str("cargo doc --no-deps --all-features\n\n");
}
}
fn push_audit_database_step(workflow: &mut String) {
workflow.push_str(" - name: Fetch RustSec advisory database\n");
workflow.push_str(" run: |\n");
workflow.push_str(" db=\"${CARGO_HOME:-$HOME/.cargo}/advisory-db\"\n");
workflow.push_str(" rm -rf \"$db\"\n");
workflow.push_str(" mkdir -p \"$(dirname \"$db\")\"\n");
workflow.push_str(" git -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=30 clone --depth 1 https://github.com/RustSec/advisory-db.git \"$db\"\n\n");
}
fn push_audit_step(workflow: &mut String, runtime: Runtime) {
workflow.push_str(" - name: Audit dependencies\n");
workflow.push_str(" run: |\n");
workflow.push_str(" db=\"${CARGO_HOME:-$HOME/.cargo}/advisory-db\"\n");
workflow.push_str(" ");
workflow.push_str(command_prefix(runtime));
workflow.push_str("cargo audit --db \"$db\" --no-fetch --stale\n\n");
}
fn push_package_selector(workflow: &mut String, package: &Package, options: &CiOptions) {
if options.package_scoped {
workflow.push_str(" -p ");
workflow.push_str(&shell_word(&package.name));
}
}
fn push_package_flags(workflow: &mut String, package: &Package, options: &CiOptions) {
workflow.push_str(" --allow-dirty");
if options.package_scoped && has_local_path_dependencies(package) {
workflow.push_str(" --list");
}
workflow.push('\n');
}
fn has_local_path_dependencies(package: &Package) -> bool {
package
.dependencies
.iter()
.any(|dependency| dependency.source.is_none() && dependency.path.is_some())
}
fn command_prefix(runtime: Runtime) -> &'static str {
match runtime {
Runtime::Cargo => "",
Runtime::Nix => "nix develop -c ",
}
}
fn trim_trailing_blank_lines(workflow: &mut String) {
while workflow.ends_with("\n\n") {
workflow.pop();
}
}
fn has_features(package: &Package) -> bool {
!package.features.is_empty()
}
fn push_self_check_steps(
workflow: &mut String,
platform: Platform,
runtime: Runtime,
self_check: SelfCheckOptions<'_>,
options: &CiOptions,
) {
workflow.push_str(" - name: Check generated CI\n");
workflow.push_str(" run: ");
workflow.push_str(command_prefix(runtime));
workflow.push_str("cargo run -- init ci --platform ");
workflow.push_str(platform.as_str());
push_self_check_suffix(workflow, runtime, self_check, options);
workflow.push_str(" - name: Check generated flake and hooks\n");
workflow.push_str(" run: ");
workflow.push_str(command_prefix(runtime));
workflow.push_str("cargo run -- init flake --check\n\n");
}
fn push_self_check_suffix(
workflow: &mut String,
runtime: Runtime,
self_check: SelfCheckOptions<'_>,
options: &CiOptions,
) {
if runtime == Runtime::Nix {
workflow.push_str(" --runtime nix");
}
if let Some(runner) = self_check.runner_override {
workflow.push_str(" --runner ");
workflow.push_str(runner);
}
if let Some(runner) = self_check.windows_runner_override {
workflow.push_str(" --windows-runner ");
workflow.push_str(runner);
}
if self_check.workspace {
workflow.push_str(" --workspace");
}
for package in self_check.packages {
workflow.push_str(" --package ");
workflow.push_str(&shell_word(package));
}
if options.with_nextest {
workflow.push_str(" --with-nextest");
}
if options.with_msrv {
workflow.push_str(" --with-msrv");
}
if options.with_audit {
workflow.push_str(" --with-audit");
}
if options.with_deny {
workflow.push_str(" --with-deny");
}
if options.with_docs {
workflow.push_str(" --with-docs");
}
if options.with_artifacts {
workflow.push_str(" --with-artifacts");
}
match options.om_ci {
OmCiMode::Off => {}
OmCiMode::Replace => {
workflow.push_str(" --with-om-ci");
}
OmCiMode::Augment => {
workflow.push_str(" --om-ci-augment");
}
}
if options.om_ci != OmCiMode::Off && options.omnix_ref != OMNIX_REF_DEFAULT {
workflow.push_str(" --omnix-ref ");
workflow.push_str(&shell_word(&options.omnix_ref));
}
if options.homebrew.is_some() {
workflow.push_str(" --with-homebrew");
}
if options.chocolatey.is_some() {
workflow.push_str(" --with-chocolatey");
}
if options.scoop.is_some() {
workflow.push_str(" --with-scoop");
}
workflow.push_str(" --check\n\n");
}
fn runs_on(runner: &ResolvedRunner) -> String {
if runner.labels.len() == 1 {
return runner.labels[0].clone();
}
let labels = runner
.labels
.iter()
.map(|label| format!("\"{}\"", label.replace('"', "\\\"")))
.collect::<Vec<_>>()
.join(", ");
format!("[{labels}]")
}
fn validate_release_tag_step(
cargo_command_prefix: Option<&str>,
package_name: Option<&str>,
) -> String {
let version_check = if let (Some(cargo_command_prefix), Some(package_name)) =
(cargo_command_prefix, package_name)
{
let package_name = shell_word(package_name);
format!(
r#"
# cargo pkgid scopes the version lookup to the crate this workflow publishes.
version="$({cargo_command_prefix}cargo pkgid -p {package_name} | awk -F'[#@]' '{{print $NF}}')"
if [ -z "$version" ]; then
echo "Could not read package version from cargo pkgid" >&2
exit 1
fi
if [ "$tag" != "$version" ]; then
echo "Tag $tag does not match Cargo.toml package version $version" >&2
exit 1
fi
"#
)
} else {
String::new()
};
format!(
r#" - name: Validate signed release tag
run: |
set -euo pipefail
tag="${{GITHUB_REF_NAME:-${{FORGE_REF_NAME:-${{CODEBERG_REF_NAME:-}}}}}}"
if [ -z "$tag" ]; then
ref="${{GITHUB_REF:-${{FORGE_REF:-${{CODEBERG_REF:-}}}}}}"
tag="${{ref#refs/tags/}}"
fi
if ! printf '%s\n' "$tag" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Tag must be an exact semver version like 0.1.1, got '$tag'" >&2
exit 1
fi
{version_check}
test -s keys/maintainers.gpg
if ! command -v gpg >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update
apt-get install -y --no-install-recommends gnupg
else
echo "gpg is required to verify signed release tags" >&2
exit 1
fi
fi
GNUPGHOME="$(mktemp -d)"
export GNUPGHOME
trap 'rm -rf "$GNUPGHOME"' EXIT
chmod 700 "$GNUPGHOME"
gpg --batch --import keys/maintainers.gpg
git fetch --force --tags origin "refs/tags/${{tag}}:refs/tags/${{tag}}"
git verify-tag "$tag"
"#
)
}
fn validate_tag_step(cargo_command_prefix: &str, package_name: &str) -> String {
validate_release_tag_step(Some(cargo_command_prefix), Some(package_name))
}
fn publish_step(package: &Package, command: &str, package_scoped: bool) -> String {
let package_name = shell_quote(&package.name);
let package_arg = if package_scoped {
format!(" -p {}", shell_word(&package.name))
} else {
String::new()
};
let command = format!("{command}{package_arg}");
format!(
r#" - name: Publish
env:
CRATES_IO_API_TOKEN: ${{{{ secrets.CRATES_IO_API_TOKEN }}}}
run: |
set -euo pipefail
crate_name={package_name}
version="${{GITHUB_REF_NAME:-${{FORGE_REF_NAME:-${{CODEBERG_REF_NAME:-}}}}}}"
if [ -z "$version" ]; then
ref="${{GITHUB_REF:-${{FORGE_REF:-${{CODEBERG_REF:-}}}}}}"
version="${{ref#refs/tags/}}"
fi
if [ -z "$version" ]; then
echo "Could not determine release version from tag ref" >&2
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
if command -v apt-get >/dev/null 2>&1; then
apt-get update
apt-get install -y --no-install-recommends curl ca-certificates
else
echo "curl is required to check crates.io for existing versions" >&2
exit 1
fi
fi
if ! status="$(curl --retry 3 -sS -o /dev/null -w '%{{http_code}}' -A 'simit init-ci publish check' "https://crates.io/api/v1/crates/${{crate_name}}/${{version}}")"; then
status=000
fi
case "$status" in
200)
echo "${{crate_name}} ${{version}} is already published on crates.io; skipping publish"
exit 0
;;
404)
;;
*)
echo "Could not check crates.io for ${{crate_name}} ${{version}} before publishing (HTTP $status)" >&2
exit 1
;;
esac
if [ -z "${{CRATES_IO_API_TOKEN:-}}" ] && [ -z "${{CARGO_REGISTRY_TOKEN:-}}" ]; then
echo "CRATES_IO_API_TOKEN is required to publish to crates.io" >&2
exit 1
fi
export CARGO_REGISTRY_TOKEN="${{CARGO_REGISTRY_TOKEN:-$CRATES_IO_API_TOKEN}}"
{command}
"#
)
}