rustyphoenixcommitrelease 1.2.0

Commits, tags and pushes a Phoenix release
mod git;

use anyhow::{Context, Result};
use clap::Parser;
use std::env;
use std::fs;
use std::path::PathBuf;
use std::process::ExitCode;
use git::GitCommandOutput;

impl From<GitCommandOutput> for ExitCode {
	fn from(e: GitCommandOutput) -> Self {
		match e {
			GitCommandOutput::UnreachableDestination => ExitCode::from(10),
			GitCommandOutput::UnexistingFile => ExitCode::from(11),
			GitCommandOutput::NewerRevisionsAvailable => ExitCode::from(12),
			GitCommandOutput::CommandFailed => ExitCode::from(13),
		}
	}
}

impl std::fmt::Display for GitCommandOutput {
	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
		match self {
			GitCommandOutput::UnreachableDestination => write!(f, "remote destination unreachable"),
			GitCommandOutput::UnexistingFile => write!(f, "file does not exist"),
			GitCommandOutput::NewerRevisionsAvailable => write!(f, "newer revisions available on remote"),
			GitCommandOutput::CommandFailed => write!(f, "git command failed"),
		}
	}
}

impl std::error::Error for GitCommandOutput {}

/// Commits, tags and pushes a Phoenix release.
#[derive(Parser)]
struct Args {
	/// Path to the git repository to release from.
	#[arg(short = 'C', long, default_value = ".")]
	repo_path: PathBuf,

	/// The version this release is tagged as (e.g. computed by phoenix_find_version / git cliff --bumped-version).
	#[arg(long)]
	version: String,

	/// The previous released version, passed through as-is to the dotenv output.
	#[arg(long)]
	current_version: String,

	/// Path to the changelog file to include in the release commit (generated upstream of this tool).
	#[arg(long, default_value = "CHANGELOG.md")]
	changelog_path: String,

	/// GitLab server host used to build the authenticated push URL (e.g. $CI_SERVER_HOST).
	#[arg(long)]
	server_host: String,

	/// GitLab project path used to build the authenticated push URL (e.g. $CI_PROJECT_PATH).
	#[arg(long)]
	project_path: String,

	/// Branch to push the release commit to (e.g. $CI_COMMIT_BRANCH).
	#[arg(long)]
	branch: String,

	/// Extra files to commit in addition to pixi.toml, codemeta.json and the changelog, space
	/// separated (e.g. "CMakeLists.txt Cargo.toml Cargo.lock"). Only files that exist are added.
	#[arg(long, default_value = "")]
	extra_files: String,

	/// Committer identity, fixed on purpose (same convention @semantic-release/gitlab used to use).
	#[arg(long, default_value = "Phoenix CI")]
	author_name: String,

	#[arg(long, default_value = "ci@phoenix.invalid")]
	author_email: String,

	/// Dotenv file exposing RELEASE_COMMIT_SHA, REPLACE_VERSION and CURRENT_VERSION to downstream jobs.
	#[arg(long, default_value = "release-commit.env")]
	env_output: PathBuf,
}

/// Files to `git add` for the release commit: the built-ins that exist across every Phoenix
/// project shape (C++/Rust/Python) plus whatever the caller lists as extra-modified-files.
fn files_to_commit(changelog_path: &str, extra_files: &str) -> Vec<String> {
	let mut files = vec!["pixi.toml".to_string(), "codemeta.json".to_string(), changelog_path.to_string()];
	files.extend(extra_files.split_whitespace().map(String::from));
	files
}

/// Authenticated HTTPS remote URL, same convention @semantic-release/gitlab used: an oauth2
/// "username" paired with the token as password.
fn remote_push_url(token: &str, server_host: &str, project_path: &str) -> String {
	format!("https://oauth2:{token}@{server_host}/{project_path}.git")
}

fn env_file_content(sha: &str, version: &str, current_version: &str) -> String {
	format!("RELEASE_COMMIT_SHA={sha}\nREPLACE_VERSION={version}\nCURRENT_VERSION={current_version}\n")
}

fn run()-> Result<()> {
	let args = Args::parse();

	git::trust_directory(&args.repo_path)?;

	// Same convention as @semantic-release/gitlab: read GITLAB_TOKEN or GL_TOKEN straight from the
	// CI/CD variable already configured for semantic-release, never as a CLI argument (would leak
	// through the process list / job logs).
	let token = env::var("GITLAB_TOKEN")
		.or_else(|_| env::var("GL_TOKEN"))
		.context("GITLAB_TOKEN or GL_TOKEN must be set")?;
	let remote_url = remote_push_url(&token, &args.server_host, &args.project_path);
	git::set_remote_url(&args.repo_path, "origin", &remote_url)?;

	for file in files_to_commit(&args.changelog_path, &args.extra_files) {
		if args.repo_path.join(&file).is_file() {
			git::add(&args.repo_path, &file)?;
		}
	}

	let message = format!("chore(release): {} [skip ci]", args.version);
	git::commit(&args.repo_path, &args.author_name, &args.author_email, &message)?;
	git::push(&args.repo_path, "origin", &format!("HEAD:{}", args.branch))?;

	// Pushed as a real "git push", same as semantic-release did: a tag created by the GitLab
	// Releases API does not honor [skip ci] on the tagged commit, but a real git-pushed tag does.
	git::tag(&args.repo_path, &args.version)?;
	git::push(&args.repo_path, "origin", &args.version)?;

	let sha = git::rev_parse_head(&args.repo_path)?;
	fs::write(&args.env_output, env_file_content(&sha, &args.version, &args.current_version))
		.with_context(|| format!("failed to write {}", args.env_output.display()))?;

	Ok(())
}

fn main() -> ExitCode {
	if let Err(err) = run() {
		if let Some(git_err) = err.downcast_ref::<GitCommandOutput>() {
			eprintln!("Error: {err}");
			return (*git_err).into();
		}
		eprintln!("Error: {err:?}");
		return ExitCode::FAILURE;
	}
	ExitCode::SUCCESS
}

#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn files_to_commit_includes_builtins_and_extra_files() {
		let files = files_to_commit("CHANGELOG.md", "Cargo.toml Cargo.lock");
		assert_eq!(files, vec!["pixi.toml", "codemeta.json", "CHANGELOG.md", "Cargo.toml", "Cargo.lock"]);
	}

	#[test]
	fn files_to_commit_handles_no_extra_files() {
		let files = files_to_commit("CHANGELOG.md", "");
		assert_eq!(files, vec!["pixi.toml", "codemeta.json", "CHANGELOG.md"]);
	}

	#[test]
	fn files_to_commit_collapses_extra_whitespace() {
		let files = files_to_commit("CHANGELOG.md", "  CMakeLists.txt   Cargo.toml  ");
		assert_eq!(files, vec!["pixi.toml", "codemeta.json", "CHANGELOG.md", "CMakeLists.txt", "Cargo.toml"]);
	}

	#[test]
	fn remote_push_url_embeds_the_token_as_oauth2_password() {
		let url = remote_push_url("tok123", "gitlab.example.com", "group/project");
		assert_eq!(url, "https://oauth2:tok123@gitlab.example.com/group/project.git");
	}

	#[test]
	fn env_file_content_exposes_the_three_expected_variables() {
		let content = env_file_content("abc123", "1.2.3", "1.2.2");
		assert_eq!(content, "RELEASE_COMMIT_SHA=abc123\nREPLACE_VERSION=1.2.3\nCURRENT_VERSION=1.2.2\n");
	}
}