use std::collections::BTreeMap;
use std::path::PathBuf;
use anyhow::{Result, bail};
use crate::cargo::Package;
use crate::cli::{Platform, Runtime};
use crate::config::{ResolvedVscode, VscodePatSource};
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";
pub const STEP_FLAKE_CHECK: &str = "nix-check";
pub const STEP_CARGO_FMT: &str = "cargo-fmt";
pub const STEP_CARGO_TEST: &str = "cargo-test";
pub const STEP_CARGO_DOC: &str = "cargo-doc";
pub const STEP_CARGO_CLIPPY: &str = "cargo-clippy";
pub const STEP_CARGO_PACKAGE: &str = "cargo-package";
pub const STEP_EXTRA_SETUP: &str = "extra-setup";
pub const STEP_SELF_CHECK: &str = "self-check";
pub const STEP_QUALITY_TOOLS: &str = "quality-tools";
#[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 with_pypi_publish: bool,
pub publish_crates: 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 required_env: Vec<String>,
pub package_scoped: bool,
pub homebrew: Option<HomebrewOptions>,
pub chocolatey: Option<ChocolateyOptions>,
pub scoop: Option<ScoopOptions>,
}
#[derive(Debug, Clone)]
pub struct CodebergPagesOptions {
pub repo: String,
pub owner: String,
pub canonical_domain: Option<String>,
pub site_output: String,
pub token_secret: String,
pub source_branch: String,
pub deploy_app: String,
}
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,
with_pypi_publish: false,
publish_crates: 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(),
required_env: 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 summary: Option<String>,
pub project_url: String,
pub license_url: Option<String>,
pub icon_url: Option<String>,
pub package_source_url: Option<String>,
pub docs_url: Option<String>,
pub bug_tracker_url: Option<String>,
pub project_source_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,
}
pub struct FilesRequest<'a> {
pub platform: Platform,
pub runtime: Runtime,
pub package: &'a Package,
pub file_suffix: Option<&'a str>,
pub self_check: SelfCheckOptions<'a>,
pub runners: &'a ResolvedCiRunners,
pub options: CiOptions,
pub step_runners: &'a BTreeMap<String, ResolvedRunner>,
}
impl Default for HomebrewPlatformSet {
fn default() -> Self {
Self {
darwin_arm: true,
darwin_intel: false,
linux_arm: true,
linux_intel: true,
}
}
}
pub fn files(request: FilesRequest<'_>) -> Result<Vec<GeneratedFile>> {
let FilesRequest {
platform,
runtime,
package,
file_suffix,
self_check,
runners,
options,
step_runners,
} = request;
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(),
step_runners,
),
}];
if options.publish_crates && 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)
}
pub fn codeberg_pages_file(
platform: Platform,
runner: &ResolvedRunner,
pages: &CodebergPagesOptions,
) -> Result<GeneratedFile> {
if platform != Platform::Forgejo {
bail!("Codeberg Pages workflow generation is forgejo-only");
}
Ok(GeneratedFile {
relative_path: PathBuf::from(platform.workflow_dir()).join("pages.yaml"),
content: codeberg_pages_workflow(runner, pages),
})
}
pub fn vscode_extension_file(
platform: Platform,
runtime: Runtime,
runner: &ResolvedRunner,
vscode: &ResolvedVscode,
) -> Result<GeneratedFile> {
if platform != Platform::Forgejo {
bail!("VS Code extension workflow generation is forgejo-only");
}
if runtime != Runtime::Nix {
bail!("VS Code extension workflow generation requires --runtime nix");
}
Ok(GeneratedFile {
relative_path: PathBuf::from(platform.workflow_dir()).join("publish-vscode-extension.yaml"),
content: vscode_extension_workflow(platform, runner, vscode),
})
}
pub fn python_ci_file(
platform: Platform,
runtime: Runtime,
runner: &ResolvedRunner,
options: &CiOptions,
check_outputs: &[String],
) -> Result<GeneratedFile> {
if runtime != Runtime::Nix {
bail!("Python uv CI generation requires --runtime nix");
}
Ok(GeneratedFile {
relative_path: PathBuf::from(platform.workflow_dir()).join("ci.yaml"),
content: python_ci_workflow(platform, runner, options, check_outputs),
})
}
fn workflow_file_name(stem: &str, suffix: Option<&str>) -> String {
match suffix {
Some(suffix) => format!("{stem}-{suffix}.yaml"),
None => format!("{stem}.yaml"),
}
}
fn codeberg_pages_workflow(runner: &ResolvedRunner, pages: &CodebergPagesOptions) -> String {
let mut workflow = String::new();
push_generated_workflow_header(&mut workflow);
workflow.push_str("name: pages\n\n");
workflow.push_str("'on':\n");
workflow.push_str(" push:\n");
workflow.push_str(" branches:\n");
workflow.push_str(" - ");
workflow.push_str(&pages.source_branch);
workflow.push_str("\n\n");
push_codeberg_concurrency(&mut workflow);
workflow.push_str("jobs:\n");
workflow.push_str(" publish:\n");
workflow.push_str(" runs-on: ");
workflow.push_str(&runs_on(runner));
workflow.push('\n');
workflow.push_str(" env:\n");
workflow.push_str(" NIX_CONFIG: \"experimental-features = nix-command flakes\"\n");
workflow.push_str(" steps:\n");
push_checkout_step(&mut workflow, Platform::Forgejo);
if let Some(canonical_domain) = &pages.canonical_domain {
workflow.push_str(" - name: Validate Pages domain\n");
workflow.push_str(" run: |\n");
workflow.push_str(" nix build ");
workflow.push_str(&shell_word(&pages.site_output));
workflow.push_str(" --no-link --out-link result-pages-site\n");
workflow.push_str(" test -f result-pages-site/.domains\n");
workflow.push_str(" grep -qx ");
workflow.push_str(&shell_word(canonical_domain));
workflow.push_str(" result-pages-site/.domains\n");
}
workflow.push_str(" - name: Deploy Codeberg Pages\n");
workflow.push_str(" env:\n");
workflow.push_str(" CODEBERG_TOKEN: ${{ secrets.");
workflow.push_str(&pages.token_secret);
workflow.push_str(" }}\n");
workflow.push_str(" run: |\n");
workflow.push_str(" test -n \"$CODEBERG_TOKEN\"\n");
workflow.push_str(" git config user.name \"forgejo-actions\"\n");
workflow.push_str(" git config user.email \"forgejo-actions@noreply.codeberg.org\"\n");
workflow.push_str(" git remote add pages-origin \"https://");
workflow.push_str(&pages.owner);
workflow.push_str(":${CODEBERG_TOKEN}@codeberg.org/");
workflow.push_str(&pages.repo);
workflow.push_str(".git\"\n");
workflow.push_str(" DEPLOY_REMOTE=pages-origin nix run ");
workflow.push_str(&pages.deploy_app);
workflow.push('\n');
workflow
}
fn vscode_extension_workflow(
platform: Platform,
runner: &ResolvedRunner,
vscode: &ResolvedVscode,
) -> String {
let mut workflow = String::new();
push_generated_workflow_header(&mut workflow);
workflow.push_str("name: Publish VS Code Extension\n\n");
workflow.push_str("on:\n");
workflow.push_str(" push:\n");
workflow.push_str(" tags: [\"[0-9]*.[0-9]*.[0-9]*\"]\n");
workflow.push_str(" workflow_dispatch:\n\n");
push_codeberg_concurrency(&mut workflow);
workflow.push_str("jobs:\n");
workflow.push_str(" publish:\n");
workflow.push_str(" runs-on: ");
workflow.push_str(&runs_on(runner));
workflow.push('\n');
workflow.push_str(" env:\n");
workflow.push_str(" NIX_CONFIG: \"experimental-features = nix-command flakes\"\n");
workflow.push_str(" steps:\n");
push_checkout_step(&mut workflow, platform);
push_install_nix_step(&mut workflow, platform);
push_vscode_credential_preflight(&mut workflow, vscode);
push_vscode_version_validation(&mut workflow, vscode);
for (index, command) in vscode.prepublish_commands.iter().enumerate() {
workflow.push_str(" - name: Prepublish command ");
workflow.push_str(&(index + 1).to_string());
workflow.push('\n');
workflow.push_str(" run: |\n");
push_indented_lines(&mut workflow, command, 10);
workflow.push('\n');
}
workflow.push_str(" - name: Package release assets\n");
workflow.push_str(" run: |\n");
workflow.push_str(" set -euo pipefail\n");
workflow.push_str(" VERSION=\"${GITHUB_REF_NAME#v}\"\n");
workflow.push_str(" export VERSION\n");
push_indented_lines(&mut workflow, &vscode.package_command, 10);
workflow.push('\n');
push_vscode_codeberg_upload(&mut workflow, vscode);
push_vscode_publish_step(&mut workflow, vscode, VscodePublisher::Vsce);
push_vscode_publish_step(&mut workflow, vscode, VscodePublisher::Ovsx);
trim_trailing_blank_lines(&mut workflow);
workflow
}
fn push_vscode_credential_preflight(workflow: &mut String, vscode: &ResolvedVscode) {
workflow.push_str(" - name: Validate publish credentials\n");
if matches!(
vscode.pat_source,
VscodePatSource::ActionsSecret | VscodePatSource::Both
) {
workflow.push_str(" env:\n");
workflow.push_str(" CODEBERG_TOKEN: ${{ secrets.");
workflow.push_str(&vscode.codeberg_token_secret);
workflow.push_str(" }}\n");
workflow.push_str(" VSCE_PAT_FROM_SECRET: ${{ secrets.");
workflow.push_str(&vscode.vsce_pat_secret);
workflow.push_str(" }}\n");
workflow.push_str(" OVSX_PAT_FROM_SECRET: ${{ secrets.");
workflow.push_str(&vscode.ovsx_pat_secret);
workflow.push_str(" }}\n");
} else {
workflow.push_str(" env:\n");
workflow.push_str(" CODEBERG_TOKEN: ${{ secrets.");
workflow.push_str(&vscode.codeberg_token_secret);
workflow.push_str(" }}\n");
}
workflow.push_str(" run: |\n");
workflow.push_str(" set -euo pipefail\n");
workflow
.push_str(" test -n \"${CODEBERG_TOKEN:-}\" || { echo \"missing Actions secret ");
workflow.push_str(&vscode.codeberg_token_secret);
workflow.push_str("\"; exit 1; }\n");
push_vscode_pat_resolution_function(workflow, vscode);
workflow.push_str(" VSCE_PUBLISH_PAT=$(resolve_pat vsce)\n");
workflow.push_str(" VSCE_PUBLISHER=$(nix shell nixpkgs#jq -c jq -r '.publisher' ");
workflow.push_str(&shell_word(&format!(
"{}/package.json",
vscode.extension_dir
)));
workflow.push_str(")\n");
workflow.push_str(" test -n \"$VSCE_PUBLISHER\" && test \"$VSCE_PUBLISHER\" != null || { echo \"missing VS Code extension publisher in ");
workflow.push_str(&format!("{}/package.json", vscode.extension_dir));
workflow.push_str("\"; exit 1; }\n");
workflow.push_str(" nix develop -c npx --yes @vscode/vsce verify-pat \"$VSCE_PUBLISHER\" --pat \"$VSCE_PUBLISH_PAT\" || { echo \"VS Code Marketplace publisher $VSCE_PUBLISHER is missing or the VSCE PAT lacks publisher permissions. Create/repair it at https://aka.ms/vsm-create-publisher before publishing.\"; exit 1; }\n");
workflow.push_str(" resolve_pat ovsx >/dev/null\n\n");
}
fn push_vscode_version_validation(workflow: &mut String, vscode: &ResolvedVscode) {
workflow.push_str(" - name: Validate release tag and extension version\n");
workflow.push_str(" run: |\n");
workflow.push_str(" set -euo pipefail\n");
workflow.push_str(" VERSION=\"${GITHUB_REF_NAME#v}\"\n");
workflow.push_str(" printf '%s\\n' \"$VERSION\" | grep -Eq '^[0-9]+\\.[0-9]+\\.[0-9]+$' || { echo \"release tag must be an exact semver version\"; exit 1; }\n");
if let Some(cargo_package) = &vscode.cargo_package {
workflow.push_str(" cargo_metadata=$(nix shell nixpkgs#cargo -c cargo metadata --no-deps --format-version 1)\n");
workflow.push_str(" cargo_version=$(printf '%s' \"$cargo_metadata\" | nix shell nixpkgs#jq -c jq -r --arg name ");
workflow.push_str(&shell_word(cargo_package));
workflow.push_str(" '.packages[] | select(.name == $name) | .version')\n");
workflow
.push_str(" test -n \"$cargo_version\" || { echo \"missing Cargo package ");
workflow.push_str(cargo_package);
workflow.push_str("\"; exit 1; }\n");
workflow.push_str(" test \"$cargo_version\" = \"$VERSION\" || { echo \"Cargo version $cargo_version does not match tag $VERSION\"; exit 1; }\n");
}
workflow.push_str(" extension_version=$(nix shell nixpkgs#jq -c jq -r '.version' ");
workflow.push_str(&shell_word(&format!(
"{}/package.json",
vscode.extension_dir
)));
workflow.push_str(")\n");
workflow.push_str(" test \"$extension_version\" = \"$VERSION\" || { echo \"extension version $extension_version does not match tag $VERSION\"; exit 1; }\n\n");
workflow.push_str(" test -s keys/maintainers.gpg\n");
workflow.push_str(" GNUPGHOME=\"$(mktemp -d)\"\n");
workflow.push_str(" export GNUPGHOME\n");
workflow.push_str(" trap 'rm -rf \"$GNUPGHOME\"' EXIT\n");
workflow.push_str(" chmod 700 \"$GNUPGHOME\"\n");
workflow.push_str(" gpg --batch --import keys/maintainers.gpg\n");
workflow.push_str(" git fetch --force --tags origin \"refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}\"\n");
workflow.push_str(" git verify-tag \"$GITHUB_REF_NAME\"\n\n");
}
fn push_vscode_codeberg_upload(workflow: &mut String, vscode: &ResolvedVscode) {
workflow.push_str(" - name: Upload Codeberg release assets\n");
workflow.push_str(" env:\n");
workflow.push_str(" CODEBERG_TOKEN: ${{ secrets.");
workflow.push_str(&vscode.codeberg_token_secret);
workflow.push_str(" }}\n");
workflow.push_str(" run: |\n");
workflow.push_str(" set -euo pipefail\n");
workflow
.push_str(" test -n \"${CODEBERG_TOKEN:-}\" || { echo \"missing Actions secret ");
workflow.push_str(&vscode.codeberg_token_secret);
workflow.push_str("\"; exit 1; }\n");
workflow.push_str(" VERSION=\"${GITHUB_REF_NAME#v}\"\n");
workflow.push_str(" tag=\"${GITHUB_REF_NAME}\"\n");
workflow.push_str(" api=");
workflow.push_str(&shell_word(&vscode.codeberg_api_base));
workflow.push('\n');
workflow.push_str(" repo=");
workflow.push_str(&shell_word(&vscode.codeberg_repo));
workflow.push('\n');
workflow.push_str(" auth_header=\"Authorization: token ${CODEBERG_TOKEN}\"\n");
workflow.push_str(" release_json=$(curl --fail --silent --show-error --header \"$auth_header\" \"$api/repos/$repo/releases/tags/$tag\" || true)\n");
workflow.push_str(" release_id=$(printf '%s' \"$release_json\" | nix shell nixpkgs#jq -c jq -r '.id // empty' 2>/dev/null || true)\n");
workflow.push_str(" if test -z \"$release_id\"; then\n");
workflow.push_str(" release_json=$(curl --fail --silent --show-error --request POST --header \"$auth_header\" --header \"Content-Type: application/json\" --data \"{\\\"tag_name\\\":\\\"$tag\\\",\\\"name\\\":\\\"$tag\\\",\\\"draft\\\":false,\\\"prerelease\\\":false}\" \"$api/repos/$repo/releases\")\n");
workflow.push_str(" release_id=$(printf '%s' \"$release_json\" | nix shell nixpkgs#jq -c jq -r '.id')\n");
workflow.push_str(" fi\n");
workflow.push_str(" for asset in release/*; do\n");
workflow.push_str(" test -f \"$asset\" || continue\n");
workflow.push_str(" name=$(basename \"$asset\")\n");
workflow.push_str(" existing_ids=$(printf '%s' \"$release_json\" | nix shell nixpkgs#jq -c jq -r --arg name \"$name\" '.assets[]? | select(.name == $name) | .id')\n");
workflow.push_str(" for asset_id in $existing_ids; do\n");
workflow.push_str(" curl --fail --silent --show-error --request DELETE --header \"$auth_header\" \"$api/repos/$repo/releases/$release_id/assets/$asset_id\" >/dev/null\n");
workflow.push_str(" done\n");
workflow.push_str(" curl --fail --silent --show-error --request POST --header \"$auth_header\" --form \"attachment=@${asset}\" \"$api/repos/$repo/releases/$release_id/assets?name=$name\" >/dev/null\n");
workflow.push_str(" done\n\n");
}
#[derive(Debug, Clone, Copy)]
enum VscodePublisher {
Vsce,
Ovsx,
}
fn push_vscode_publish_step(
workflow: &mut String,
vscode: &ResolvedVscode,
publisher: VscodePublisher,
) {
let (name, command, key) = match publisher {
VscodePublisher::Vsce => (
"Publish to VS Code Marketplace",
"nix develop -c npx --yes @vscode/vsce publish --packagePath release/*.vsix --pat \"$PUBLISH_PAT\" --skip-duplicate",
"vsce",
),
VscodePublisher::Ovsx => (
"Publish to Open VSX",
"nix develop -c npx --yes ovsx publish release/*.vsix --pat \"$PUBLISH_PAT\" --skip-duplicate",
"ovsx",
),
};
workflow.push_str(" - name: ");
workflow.push_str(name);
workflow.push('\n');
if matches!(
vscode.pat_source,
VscodePatSource::ActionsSecret | VscodePatSource::Both
) {
workflow.push_str(" env:\n");
workflow.push_str(" VSCE_PAT_FROM_SECRET: ${{ secrets.");
workflow.push_str(&vscode.vsce_pat_secret);
workflow.push_str(" }}\n");
workflow.push_str(" OVSX_PAT_FROM_SECRET: ${{ secrets.");
workflow.push_str(&vscode.ovsx_pat_secret);
workflow.push_str(" }}\n");
}
workflow.push_str(" run: |\n");
workflow.push_str(" set -euo pipefail\n");
push_vscode_pat_resolution_function(workflow, vscode);
workflow.push_str(" PUBLISH_PAT=$(resolve_pat ");
workflow.push_str(key);
workflow.push_str(")\n");
if matches!(publisher, VscodePublisher::Ovsx) {
workflow.push_str(" OVSX_NAMESPACE=$(nix shell nixpkgs#jq -c jq -r '.publisher' ");
workflow.push_str(&shell_word(&format!(
"{}/package.json",
vscode.extension_dir
)));
workflow.push_str(")\n");
workflow.push_str(
" nix develop -c npx --yes ovsx create-namespace \"$OVSX_NAMESPACE\" --pat \"$PUBLISH_PAT\" || true\n",
);
}
workflow.push_str(" ");
workflow.push_str(command);
workflow.push_str("\n\n");
if matches!(publisher, VscodePublisher::Vsce) {
push_vscode_marketplace_visibility_gate(workflow, vscode);
push_vscode_marketplace_signature_gate(workflow, vscode);
}
}
fn push_vscode_marketplace_visibility_gate(workflow: &mut String, vscode: &ResolvedVscode) {
workflow.push_str(" - name: Verify VS Code Marketplace visibility\n");
if matches!(
vscode.pat_source,
VscodePatSource::ActionsSecret | VscodePatSource::Both
) {
workflow.push_str(" env:\n");
workflow.push_str(" VSCE_PAT_FROM_SECRET: ${{ secrets.");
workflow.push_str(&vscode.vsce_pat_secret);
workflow.push_str(" }}\n");
workflow.push_str(" OVSX_PAT_FROM_SECRET: ${{ secrets.");
workflow.push_str(&vscode.ovsx_pat_secret);
workflow.push_str(" }}\n");
}
workflow.push_str(" run: |\n");
workflow.push_str(" set -euo pipefail\n");
push_vscode_pat_resolution_function(workflow, vscode);
workflow.push_str(" PUBLISH_PAT=$(resolve_pat vsce)\n");
workflow.push_str(" export PUBLISH_PAT\n");
workflow
.push_str(" EXTENSION_PUBLISHER=$(nix shell nixpkgs#jq -c jq -r '.publisher' ");
workflow.push_str(&shell_word(&format!(
"{}/package.json",
vscode.extension_dir
)));
workflow.push_str(")\n");
workflow.push_str(" EXTENSION_NAME=$(nix shell nixpkgs#jq -c jq -r '.name' ");
workflow.push_str(&shell_word(&format!(
"{}/package.json",
vscode.extension_dir
)));
workflow.push_str(")\n");
workflow.push_str(" EXTENSION_ID=\"$EXTENSION_PUBLISHER.$EXTENSION_NAME\"\n");
workflow.push_str(" export EXTENSION_PUBLISHER EXTENSION_NAME EXTENSION_ID\n");
workflow.push_str(" diag_dir=$(mktemp -d)\n");
workflow.push_str(" trap 'rm -rf \"$diag_dir\"' EXIT\n");
workflow.push_str(" nix develop -c npm --prefix \"$diag_dir\" install --silent azure-devops-node-api@15.1.2 >/dev/null\n");
workflow
.push_str(" NODE_PATH=\"$diag_dir/node_modules\" nix develop -c node <<'NODE'\n");
workflow.push_str(" const azdev = require('azure-devops-node-api');\n");
workflow.push_str(
" const { GalleryApi } = require('azure-devops-node-api/GalleryApi');\n",
);
workflow.push_str(" const Gallery = require('azure-devops-node-api/interfaces/GalleryInterfaces');\n");
workflow.push_str(" const publisher = process.env.EXTENSION_PUBLISHER;\n");
workflow.push_str(" const extension = process.env.EXTENSION_NAME;\n");
workflow.push_str(" const pat = process.env.PUBLISH_PAT;\n");
workflow.push_str(" const flags = Gallery.PublishedExtensionFlags;\n");
workflow.push_str(" const queryFlags = Gallery.ExtensionQueryFlags.IncludeVersions | Gallery.ExtensionQueryFlags.IncludeMetadata;\n");
workflow.push_str(
" const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));\n",
);
workflow.push_str(" const flagNames = value => Object.entries(flags)\n");
workflow.push_str(" .filter(([name, flag]) => typeof flag === 'number' && flag !== 0 && (value & flag) !== 0)\n");
workflow.push_str(" .map(([name]) => name)\n");
workflow.push_str(" .sort();\n");
workflow.push_str(" async function main() {\n");
workflow.push_str(" const api = new GalleryApi('https://marketplace.visualstudio.com', [azdev.getPersonalAccessTokenHandler(pat)]);\n");
workflow.push_str(" let last;\n");
workflow.push_str(" for (let attempt = 1; attempt <= 60; attempt += 1) {\n");
workflow.push_str(" let ext;\n");
workflow.push_str(" try {\n");
workflow.push_str(" ext = await api.getExtension(null, publisher, extension, undefined, queryFlags);\n");
workflow.push_str(" } catch (error) {\n");
workflow.push_str(" if (error && error.statusCode === 404) {\n");
workflow.push_str(" last = 'not found via authenticated Gallery API';\n");
workflow.push_str(" await sleep(10000);\n");
workflow.push_str(" continue;\n");
workflow.push_str(" }\n");
workflow.push_str(" throw error;\n");
workflow.push_str(" }\n");
workflow.push_str(" if (!ext) {\n");
workflow.push_str(" last = 'not found via authenticated Gallery API';\n");
workflow.push_str(" await sleep(10000);\n");
workflow.push_str(" continue;\n");
workflow.push_str(" }\n");
workflow.push_str(" let value = Number(ext.flags || 0);\n");
workflow.push_str(" if ((value & flags.Public) === 0) {\n");
workflow.push_str(" console.log(`Marketplace extension is missing Public flag; setting it for ${publisher}.${extension}`);\n");
workflow.push_str(
" ext = await api.updateExtensionProperties(publisher, extension, flags.Public);\n",
);
workflow.push_str(" value = Number(ext.flags || 0);\n");
workflow.push_str(" }\n");
workflow.push_str(" const versions = (ext.versions || []).map(version => ({ version: version.version, targetPlatform: version.targetPlatform || null, flags: version.flags || null }));\n");
workflow.push_str(" console.log(JSON.stringify({ id: `${publisher}.${extension}`, flags: value, flagNames: flagNames(value), versions }, null, 2));\n");
workflow.push_str(
" if ((value & flags.Public) !== 0 && (value & flags.Validated) !== 0) {\n",
);
workflow.push_str(" return;\n");
workflow.push_str(" }\n");
workflow.push_str(
" last = `flags=${value} (${flagNames(value).join(',') || 'none'})`;\n",
);
workflow.push_str(" await sleep(10000);\n");
workflow.push_str(" }\n");
workflow.push_str(" throw new Error(`Marketplace extension did not become public and validated: ${last}`);\n");
workflow.push_str(" }\n");
workflow.push_str(" main().catch(error => {\n");
workflow.push_str(" console.error(error && error.stack ? error.stack : error);\n");
workflow.push_str(" process.exit(1);\n");
workflow.push_str(" });\n");
workflow.push_str(" NODE\n");
workflow.push_str(" for attempt in $(seq 1 60); do\n");
workflow.push_str(" query=$(nix shell nixpkgs#jq -c jq -cn --arg id \"$EXTENSION_ID\" '{filters:[{criteria:[{filterType:7,value:$id}]}],flags:2151}')\n");
workflow.push_str(" result=$(curl --fail --silent --show-error --header 'Content-Type: application/json' --header 'Accept: application/json;api-version=7.2-preview.1' --data \"$query\" https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery)\n");
workflow.push_str(" visible=$(printf '%s' \"$result\" | nix shell nixpkgs#jq -c jq -r --arg id \"$EXTENSION_ID\" '.results[0].extensions[]? | select((.publisher.publisherName + \".\" + .extensionName | ascii_downcase) == ($id | ascii_downcase)) | .flags')\n");
workflow.push_str(" if printf '%s\\n' \"$visible\" | grep -Eq '(^|, )public(,|$)' && printf '%s\\n' \"$visible\" | grep -Eq '(^|, )validated(,|$)'; then\n");
workflow.push_str(" echo \"$EXTENSION_ID is visible in the public VS Code Marketplace Gallery API\"\n");
workflow.push_str(" exit 0\n");
workflow.push_str(" fi\n");
workflow.push_str(" echo \"waiting for $EXTENSION_ID to become public in Marketplace Gallery API (attempt $attempt/60)\"\n");
workflow.push_str(" sleep 10\n");
workflow.push_str(" done\n");
workflow.push_str(" echo \"$EXTENSION_ID did not become visible in the public VS Code Marketplace Gallery API\" >&2\n");
workflow.push_str(" exit 1\n\n");
}
fn push_vscode_marketplace_signature_gate(workflow: &mut String, vscode: &ResolvedVscode) {
workflow.push_str(" - name: Verify VS Code Marketplace signatures\n");
workflow.push_str(" run: |\n");
workflow.push_str(" set -euo pipefail\n");
workflow
.push_str(" EXTENSION_PUBLISHER=$(nix shell nixpkgs#jq -c jq -r '.publisher' ");
workflow.push_str(&shell_word(&format!(
"{}/package.json",
vscode.extension_dir
)));
workflow.push_str(")\n");
workflow.push_str(" EXTENSION_NAME=$(nix shell nixpkgs#jq -c jq -r '.name' ");
workflow.push_str(&shell_word(&format!(
"{}/package.json",
vscode.extension_dir
)));
workflow.push_str(")\n");
workflow.push_str(" EXTENSION_ID=\"$EXTENSION_PUBLISHER.$EXTENSION_NAME\"\n");
workflow.push_str(" tmp_dir=$(mktemp -d)\n");
workflow.push_str(" trap 'rm -rf \"$tmp_dir\"' EXIT\n");
workflow.push_str(" query=$(nix shell nixpkgs#jq -c jq -cn --arg id \"$EXTENSION_ID\" '{filters:[{criteria:[{filterType:7,value:$id}]}],flags:2151}')\n");
workflow.push_str(" result=$(curl --fail --silent --show-error --header 'Content-Type: application/json' --header 'Accept: application/json;api-version=7.2-preview.1' --data \"$query\" https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery)\n");
workflow.push_str(" versions_tsv=\"$tmp_dir/versions.tsv\"\n");
workflow.push_str(" printf '%s' \"$result\" | nix shell nixpkgs#jq -c jq -r --arg id \"$EXTENSION_ID\" '\n");
workflow.push_str(" .results[0].extensions[]?\n");
workflow.push_str(" | select((.publisher.publisherName + \".\" + .extensionName | ascii_downcase) == ($id | ascii_downcase))\n");
workflow.push_str(" | .versions[]\n");
workflow.push_str(" | [\n");
workflow.push_str(" .version,\n");
workflow.push_str(" (.targetPlatform // \"universal\"),\n");
workflow.push_str(" ((.files[]? | select(.assetType == \"Microsoft.VisualStudio.Services.VSIXPackage\") | .source) // \"\"),\n");
workflow.push_str(" ((.files[]? | select(.assetType == \"Microsoft.VisualStudio.Services.VsixSignature\") | .source) // \"\")\n");
workflow.push_str(" ]\n");
workflow.push_str(" | @tsv\n");
workflow.push_str(" ' > \"$versions_tsv\"\n");
workflow.push_str(" test -s \"$versions_tsv\" || { echo \"no Marketplace versions found for $EXTENSION_ID\" >&2; exit 1; }\n");
workflow.push_str(
" while IFS=$'\\t' read -r version target package_url signature_url; do\n",
);
workflow.push_str(" test -n \"$package_url\" || { echo \"missing VSIXPackage asset for $EXTENSION_ID@$version ($target)\" >&2; exit 1; }\n");
workflow.push_str(" test -n \"$signature_url\" || { echo \"missing VsixSignature asset for $EXTENSION_ID@$version ($target)\" >&2; exit 1; }\n");
workflow.push_str(" target_dir=\"$tmp_dir/$version-$target\"\n");
workflow.push_str(" mkdir -p \"$target_dir/signature\"\n");
workflow.push_str(" package_path=\"$target_dir/package.vsix\"\n");
workflow.push_str(" signature_zip=\"$target_dir/signature.zip\"\n");
workflow.push_str(" manifest_path=\"$target_dir/signature.manifest\"\n");
workflow.push_str(" signature_path=\"$target_dir/signature.p7s\"\n");
workflow.push_str(" curl --fail --silent --show-error --location \"$package_url\" --output \"$package_path\"\n");
workflow.push_str(" curl --fail --silent --show-error --location \"$signature_url\" --output \"$signature_zip\"\n");
workflow.push_str(" nix shell nixpkgs#unzip -c unzip -q \"$signature_zip\" -d \"$target_dir/signature\"\n");
workflow.push_str(" test -s \"$target_dir/signature/.signature.manifest\" || { echo \"signature archive missing .signature.manifest for $EXTENSION_ID@$version ($target)\" >&2; exit 1; }\n");
workflow.push_str(" test -s \"$target_dir/signature/.signature.p7s\" || { echo \"signature archive missing .signature.p7s for $EXTENSION_ID@$version ($target)\" >&2; exit 1; }\n");
workflow.push_str(
" cp \"$target_dir/signature/.signature.manifest\" \"$manifest_path\"\n",
);
workflow
.push_str(" cp \"$target_dir/signature/.signature.p7s\" \"$signature_path\"\n");
workflow.push_str(" nix develop -c npx --yes @vscode/vsce@latest verify-signature --packagePath \"$package_path\" --manifestPath \"$manifest_path\" --signaturePath \"$signature_path\"\n");
workflow.push_str(" echo \"verified Marketplace signature for $EXTENSION_ID@$version ($target)\"\n");
workflow.push_str(" done < \"$versions_tsv\"\n\n");
}
fn push_vscode_pat_resolution_function(workflow: &mut String, vscode: &ResolvedVscode) {
workflow.push_str(" resolve_pat() {\n");
workflow.push_str(" case \"$1\" in\n");
workflow.push_str(" vsce) file_env=");
workflow.push_str(&shell_word(&vscode.vsce_pat_file_env));
workflow.push_str("; secret=\"${VSCE_PAT_FROM_SECRET:-}\" ;;\n");
workflow.push_str(" ovsx) file_env=");
workflow.push_str(&shell_word(&vscode.ovsx_pat_file_env));
workflow.push_str("; secret=\"${OVSX_PAT_FROM_SECRET:-}\" ;;\n");
workflow.push_str(" *) echo \"unknown publisher $1\" >&2; exit 1 ;;\n");
workflow.push_str(" esac\n");
match vscode.pat_source {
VscodePatSource::FileEnv => {
workflow.push_str(" file_path=\"${!file_env:-}\"\n");
workflow.push_str(" test -n \"$file_path\" || { echo \"missing runner file env ${file_env}\" >&2; exit 1; }\n");
workflow.push_str(" test -s \"$file_path\" || { echo \"runner file env ${file_env} points at an empty or missing file\" >&2; exit 1; }\n");
workflow.push_str(" cat \"$file_path\"\n");
}
VscodePatSource::ActionsSecret => {
workflow.push_str(" test -n \"$secret\" || { echo \"missing Actions secret for $1\" >&2; exit 1; }\n");
workflow.push_str(" printf '%s' \"$secret\"\n");
}
VscodePatSource::Both => {
workflow.push_str(" file_path=\"${!file_env:-}\"\n");
workflow.push_str(
" if test -n \"$file_path\" && test -s \"$file_path\"; then\n",
);
workflow.push_str(" cat \"$file_path\"\n");
workflow.push_str(" elif test -n \"$secret\"; then\n");
workflow.push_str(" printf '%s' \"$secret\"\n");
workflow.push_str(" else\n");
workflow.push_str(" echo \"missing runner file env ${file_env} and fallback Actions secret for $1\" >&2\n");
workflow.push_str(" exit 1\n");
workflow.push_str(" fi\n");
}
}
workflow.push_str(" }\n");
}
fn push_indented_lines(workflow: &mut String, command: &str, spaces: usize) {
let indent = " ".repeat(spaces);
for line in command.lines() {
workflow.push_str(&indent);
workflow.push_str(line);
workflow.push('\n');
}
}
fn python_ci_workflow(
platform: Platform,
runner: &ResolvedRunner,
options: &CiOptions,
check_outputs: &[String],
) -> 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");
workflow.push_str(" tags-ignore: [\"**\"]\n");
if platform != Platform::Forgejo {
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(runner));
workflow.push('\n');
push_job_env(&mut workflow, Runtime::Nix, &options.extra_env);
workflow.push_str(" steps:\n");
push_checkout_step(&mut workflow, platform);
push_required_env_step(&mut workflow, &options.required_env);
push_install_nix_step(&mut workflow, platform);
push_extra_setup_steps(&mut workflow, &options.extra_setup);
workflow.push_str(" - name: Check generated flake wiring\n");
workflow.push_str(
" run: nix run git+https://codeberg.org/caniko/simit.git -- init flake --check --diff\n\n",
);
workflow.push_str(" - name: Check flake evaluation\n");
workflow.push_str(" run: nix flake check --no-build\n\n");
let checks = if check_outputs.is_empty() {
vec![
"offline-tests".to_owned(),
"typecheck".to_owned(),
"uv-format".to_owned(),
]
} else {
check_outputs.to_vec()
};
for check in checks {
workflow.push_str(" - name: Build check ");
workflow.push_str(&check);
workflow.push('\n');
workflow.push_str(" run: nix build .#checks.x86_64-linux.");
workflow.push_str(&check);
workflow.push_str("\n\n");
}
trim_trailing_blank_lines(&mut workflow);
workflow
}
pub fn python_publish_file(
platform: Platform,
runtime: Runtime,
runner: &ResolvedRunner,
options: &CiOptions,
) -> Result<GeneratedFile> {
if runtime != Runtime::Nix {
bail!("Python uv publish generation requires --runtime nix");
}
Ok(GeneratedFile {
relative_path: PathBuf::from(platform.workflow_dir()).join("publish-pypi.yaml"),
content: python_publish_workflow(platform, runner, options),
})
}
fn python_publish_workflow(
platform: Platform,
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("name: Publish to PyPI\n\n");
workflow.push_str("on:\n");
workflow.push_str(" push:\n");
workflow.push_str(" tags: [\"[0-9]*\"]\n\n");
push_concurrency(&mut workflow);
workflow.push_str("jobs:\n");
workflow.push_str(" publish:\n");
workflow.push_str(" runs-on: ");
workflow.push_str(&runs_on(runner));
workflow.push('\n');
push_job_env(&mut workflow, Runtime::Nix, &options.extra_env);
workflow.push_str(" steps:\n");
push_checkout_step(&mut workflow, platform);
push_required_env_step(&mut workflow, &options.required_env);
push_install_nix_step(&mut workflow, platform);
push_extra_setup_steps(&mut workflow, &options.extra_setup);
workflow.push_str(" - name: Build Nix package\n");
workflow.push_str(" run: nix build .# --no-link\n\n");
workflow.push_str(" - name: Publish to PyPI\n");
workflow.push_str(" env:\n");
workflow.push_str(" UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}\n");
workflow.push_str(" run: |\n");
workflow.push_str(" nix develop -c uv build\n");
workflow.push_str(" nix develop -c uv publish\n\n");
trim_trailing_blank_lines(&mut workflow);
workflow
}
pub fn maturin_publish_file(
platform: Platform,
runtime: Runtime,
runner: &ResolvedRunner,
options: &CiOptions,
) -> Result<GeneratedFile> {
if runtime != Runtime::Nix {
bail!("PyPI publish generation for PyO3 projects requires --runtime nix");
}
Ok(GeneratedFile {
relative_path: PathBuf::from(platform.workflow_dir()).join("publish-pypi.yaml"),
content: maturin_publish_workflow(platform, runner, options),
})
}
fn maturin_publish_workflow(
platform: Platform,
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("name: Publish to PyPI\n\n");
workflow.push_str("on:\n");
workflow.push_str(" push:\n");
workflow.push_str(" tags: [\"[0-9]*\"]\n\n");
push_concurrency(&mut workflow);
workflow.push_str("jobs:\n");
workflow.push_str(" publish:\n");
workflow.push_str(" runs-on: ");
workflow.push_str(&runs_on(runner));
workflow.push('\n');
push_job_env(&mut workflow, Runtime::Nix, &options.extra_env);
workflow.push_str(" steps:\n");
push_checkout_step(&mut workflow, platform);
push_required_env_step(&mut workflow, &options.required_env);
push_install_nix_step(&mut workflow, platform);
push_extra_setup_steps(&mut workflow, &options.extra_setup);
workflow.push_str(" - name: Build and publish to PyPI\n");
workflow.push_str(" env:\n");
workflow.push_str(" MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}\n");
workflow.push_str(" run: |\n");
workflow.push_str(" nix develop -c maturin build --release --sdist\n");
workflow.push_str(" nix develop -c maturin publish --skip-existing\n\n");
trim_trailing_blank_lines(&mut workflow);
workflow
}
fn ci_workflow(
platform: Platform,
runtime: Runtime,
package: &Package,
self_check: SelfCheckOptions<'_>,
runners: &ResolvedCiRunners,
options: CiOptions,
step_runners: &BTreeMap<String, ResolvedRunner>,
) -> String {
if step_runners.is_empty() {
ci_workflow_single_job(platform, runtime, package, self_check, runners, options)
} else {
ci_workflow_multi_job(
platform,
runtime,
package,
self_check,
runners,
options,
step_runners,
)
}
}
/// Single-job CI workflow (backward-compatible path).
fn ci_workflow_single_job(
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");
workflow.push_str(" tags-ignore: [\"**\"]\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);
push_required_env_step(&mut workflow, &options.required_env);
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: Format check\n run: nix develop -c cargo fmt --all -- --check\n\n");
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);
workflow.push_str(
" - name: Format check\n run: cargo fmt --all -- --check\n\n",
);
push_test_steps(&mut workflow, runtime, 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 runner_for_step<'a>(
step_runners: &'a BTreeMap<String, ResolvedRunner>,
runners: &'a ResolvedCiRunners,
key: &str,
) -> &'a ResolvedRunner {
step_runners.get(key).unwrap_or(&runners.ci)
}
/// Multi-job CI workflow that splits steps across jobs by their step-runner label.
fn ci_workflow_multi_job(
platform: Platform,
runtime: Runtime,
package: &Package,
self_check: SelfCheckOptions<'_>,
runners: &ResolvedCiRunners,
options: CiOptions,
step_runners: &BTreeMap<String, ResolvedRunner>,
) -> 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");
workflow.push_str(" tags-ignore: [\"**\"]\n");
workflow.push('\n');
push_concurrency(&mut workflow);
workflow.push_str("jobs:\n");
struct StepDef<'a> {
runner: &'a ResolvedRunner,
yaml: String,
}
let mut steps: Vec<StepDef> = Vec::new();
let mut capture = |key: &str, s: &str| {
let r = runner_for_step(step_runners, runners, key);
steps.push(StepDef {
runner: r,
yaml: s.to_string(),
});
};
match runtime {
Runtime::Nix => {
capture(
STEP_FLAKE_CHECK,
" - name: Check flake\n run: nix flake check\n\n",
);
capture(
STEP_CARGO_FMT,
" - name: Format check\n run: nix develop -c cargo fmt --all -- --check\n\n",
);
let mut test = " - name: Test\n run: nix develop -c cargo test".to_string();
push_package_selector(&mut test, package, &options);
test.push_str("\n\n");
capture(STEP_CARGO_TEST, &test);
let mut q = String::new();
if options.with_audit {
q.push_str(" - name: Check cargo-audit tool\n run: nix develop -c cargo-audit --version\n\n");
}
if options.with_deny {
q.push_str(" - name: Check cargo-deny tool\n run: nix develop -c cargo-deny --version\n\n");
}
if !q.is_empty() {
capture(STEP_QUALITY_TOOLS, &q);
}
let mut opt = String::new();
if options.with_msrv {
opt.push_str(
" - name: Check MSRV\n run: nix develop -c cargo check\n\n",
);
}
if options.with_docs {
opt.push_str(" - name: Build docs\n run: nix develop -c cargo doc --no-deps --all-features\n\n");
}
if !opt.is_empty() {
capture(STEP_CARGO_DOC, &opt);
}
if self_check.enabled {
let mut sc = String::from(
" - name: Check generated CI\n run: nix develop -c cargo run -- init ci",
);
if runtime == Runtime::Nix {
sc.push_str(" --runtime nix");
}
sc.push_str("\n - name: Check generated flake and hooks\n run: nix develop -c cargo run -- init flake --check\n\n");
capture(STEP_SELF_CHECK, &sc);
}
let mut clippy =
" - name: Clippy\n run: nix develop -c cargo clippy".to_string();
push_package_selector(&mut clippy, package, &options);
clippy.push_str(" --all-targets -- --deny warnings\n\n");
capture(STEP_CARGO_CLIPPY, &clippy);
if package.is_publishable() {
let mut pkg =
" - name: Package crate\n run: nix develop -c cargo package"
.to_string();
push_package_selector(&mut pkg, package, &options);
push_package_flags(&mut pkg, package, &options);
capture(STEP_CARGO_PACKAGE, &pkg);
}
}
Runtime::Cargo => {
capture(
STEP_CARGO_FMT,
" - name: Format check\n run: cargo fmt --all -- --check\n\n",
);
let mut test = String::from(" - name: Test\n run: cargo test");
push_package_selector(&mut test, package, &options);
test.push_str("\n\n");
capture(STEP_CARGO_TEST, &test);
let mut q = String::new();
if options.with_audit {
q.push_str(" - name: Install cargo-audit\n run: command -v cargo-audit >/dev/null 2>&1 || cargo install cargo-audit --locked\n\n");
}
if options.with_deny {
q.push_str(&format!(" - name: Install cargo-deny\n run: command -v cargo-deny >/dev/null 2>&1 || cargo install cargo-deny --locked --version {CARGO_DENY_VERSION}\n\n"));
}
if !q.is_empty() {
capture(STEP_QUALITY_TOOLS, &q);
}
let mut opt = String::new();
if options.with_msrv {
opt.push_str(" - name: Check MSRV\n run: cargo check\n\n");
}
if options.with_docs {
opt.push_str(
" - name: Build docs\n run: cargo doc --no-deps --all-features\n\n",
);
}
if !opt.is_empty() {
capture(STEP_CARGO_DOC, &opt);
}
if self_check.enabled {
let mut sc = String::from(
" - name: Check generated CI\n run: cargo run -- init ci",
);
sc.push_str("\n - name: Check generated flake and hooks\n run: cargo run -- init flake --check\n\n");
capture(STEP_SELF_CHECK, &sc);
}
let mut clippy = String::from(" - name: Clippy\n run: cargo clippy");
push_package_selector(&mut clippy, package, &options);
clippy.push_str(" --all-targets -- --deny warnings\n\n");
capture(STEP_CARGO_CLIPPY, &clippy);
if package.is_publishable() {
let mut pkg =
String::from(" - name: Package crate\n run: cargo package");
push_package_selector(&mut pkg, package, &options);
push_package_flags(&mut pkg, package, &options);
capture(STEP_CARGO_PACKAGE, &pkg);
}
}
}
struct JobEmit<'a> {
name: String,
runner: &'a ResolvedRunner,
steps: String,
}
// Group steps by runner label, deduplicating job names.
let mut jobs: Vec<JobEmit> = Vec::new();
{
let mut label_to_idx: BTreeMap<&str, usize> = BTreeMap::new();
for step in &steps {
let label = step
.runner
.labels
.first()
.map(|s| s.as_str())
.unwrap_or("runner");
let idx = label_to_idx.len();
let entry = label_to_idx.entry(label).or_insert(idx);
if *entry == jobs.len() {
let sanitized: String = label
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
.collect();
let name = if sanitized.is_empty() {
"job".to_string()
} else {
sanitized
};
jobs.push(JobEmit {
name,
runner: step.runner,
steps: String::new(),
});
}
jobs[*entry].steps.push_str(&step.yaml);
}
}
let first_job_name = jobs.first().map(|j| j.name.clone()).unwrap_or_default();
for (idx, job) in jobs.iter().enumerate() {
workflow.push_str(&format!(" {}:\n", job.name));
if idx > 0 {
workflow.push_str(&format!(" needs: [{first_job_name}]\n"));
}
workflow.push_str(" runs-on: ");
workflow.push_str(&runs_on(job.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);
push_required_env_step(&mut workflow, &options.required_env);
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);
}
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(&job.steps);
}
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");
// Codeberg currently runs a Gitea 1.22-derived Actions service. It
// accepts workflow_dispatch as an event, but rejects nested dispatch
// input mappings during workflow validation. The publish workflow has
// no meaningful input, so keep the trigger scalar and use the selected
// tag ref for the release version.
workflow.push_str(" workflow_dispatch:\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);
push_required_env_step(&mut workflow, &options.required_env);
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, runtime, 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);
push_required_env_step(&mut workflow, &options.required_env);
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-Error 'CHOCOLATEY_API_KEY is required because Chocolatey package publishing is configured.'; exit 1 }\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-Error 'SCOOP_BUCKET_TOKEN is required because Scoop bucket publishing is configured.'; exit 1 }\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);
if let Some(summary) = &opts.summary {
push_ps_arg(workflow, "--choco-summary", summary);
}
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(icon_url) = &opts.icon_url {
push_ps_arg(workflow, "--choco-icon-url", icon_url);
}
if let Some(package_source_url) = &opts.package_source_url {
push_ps_arg(workflow, "--choco-package-source-url", package_source_url);
}
if let Some(docs_url) = &opts.docs_url {
push_ps_arg(workflow, "--choco-docs-url", docs_url);
}
if let Some(bug_tracker_url) = &opts.bug_tracker_url {
push_ps_arg(workflow, "--choco-bug-tracker-url", bug_tracker_url);
}
if let Some(project_source_url) = &opts.project_source_url {
push_ps_arg(workflow, "--choco-project-source-url", project_source_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 is required because Homebrew tap publishing is configured.\" >&2\n",
);
workflow.push_str(" exit 1\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(" run: |\n");
workflow.push_str(" set -euo pipefail\n");
workflow.push_str(" mkdir -p release\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'/' | 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_codeberg_concurrency(workflow: &mut String) {
workflow.push_str("concurrency:\n");
workflow.push_str(" group: ${{ codeberg.workflow }}-${{ codeberg.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_required_env_step(workflow: &mut String, required_env: &[String]) {
if required_env.is_empty() {
return;
}
workflow.push_str(" - name: Validate required environment\n");
workflow.push_str(" run: |\n");
workflow.push_str(" set -euo pipefail\n");
for name in required_env {
workflow.push_str(" if [ -z \"${");
workflow.push_str(name);
workflow.push_str(":-}\" ]; then echo ");
workflow.push_str(&shell_word(&format!(
"{name} is required by simit project configuration."
)));
workflow.push_str(" >&2; exit 1; fi\n");
if name.ends_with("_FILE") {
workflow.push_str(" if [ ! -r \"${");
workflow.push_str(name);
workflow.push_str("}\" ] || [ ! -s \"${");
workflow.push_str(name);
workflow.push_str("}\" ]; then echo ");
workflow.push_str(&shell_word(&format!(
"{name} must point to a readable, non-empty file."
)));
workflow.push_str(" >&2; exit 1; fi\n");
}
workflow.push_str(" printf 'validated required environment: %s\\n' ");
workflow.push_str(&shell_word(name));
workflow.push('\n');
}
workflow.push('\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,
runtime: Runtime,
package: &Package,
options: &CiOptions,
) {
if options.with_nextest {
workflow.push_str(" - name: Check nextest tool\n");
workflow.push_str(" run: ");
if runtime == Runtime::Cargo {
workflow.push_str("command -v cargo-nextest >/dev/null 2>&1 || cargo install cargo-nextest --locked --version ");
workflow.push_str(CARGO_NEXTEST_VERSION);
} else {
workflow.push_str(command_prefix(runtime));
workflow.push_str("command -v cargo-nextest");
}
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 {
if runtime == Runtime::Cargo {
workflow.push_str(" - name: Install cargo-audit\n");
workflow.push_str(" run: ");
workflow.push_str(
"command -v cargo-audit >/dev/null 2>&1 || cargo install cargo-audit --locked\n\n",
);
} else {
workflow.push_str(" - name: Check cargo-audit tool\n");
workflow.push_str(" run: ");
workflow.push_str(prefix);
workflow.push_str("cargo-audit --version\n\n");
}
}
if options.with_deny {
if runtime == Runtime::Cargo {
workflow.push_str(" - name: Install cargo-deny\n");
workflow.push_str(" run: ");
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(" - name: Check cargo-deny tool\n");
workflow.push_str(" run: ");
workflow.push_str(prefix);
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(docs_command_prefix(runtime));
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(docs_command_prefix(runtime));
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 --list");
workflow.push('\n');
}
fn command_prefix(runtime: Runtime) -> &'static str {
match runtime {
Runtime::Cargo => "",
Runtime::Nix => "nix develop -c ",
}
}
fn docs_command_prefix(runtime: Runtime) -> &'static str {
match runtime {
Runtime::Cargo => "",
Runtime::Nix => "nix develop .#docs -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");
}
if options.publish_crates {
workflow.push_str(" --publish-crates");
}
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'[#@]' 'NF > 1 {{print $NF}}' | tail -n 1)"
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}
"#
)
}