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 {}
#[derive(Parser)]
struct Args {
#[arg(short = 'C', long, default_value = ".")]
repo_path: PathBuf,
#[arg(long)]
version: String,
#[arg(long)]
current_version: String,
#[arg(long, default_value = "CHANGELOG.md")]
changelog_path: String,
#[arg(long)]
server_host: String,
#[arg(long)]
project_path: String,
#[arg(long)]
branch: String,
#[arg(long, default_value = "")]
extra_files: String,
#[arg(long, default_value = "Phoenix CI")]
author_name: String,
#[arg(long, default_value = "ci@phoenix.invalid")]
author_email: String,
#[arg(long, default_value = "release-commit.env")]
env_output: PathBuf,
}
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
}
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)?;
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)
.map_err(|_| GitCommandOutput::UnexistingFile)?;
}
}
let message = format!("chore(release): {} [skip ci]", args.version);
git::commit(&args.repo_path, &args.author_name, &args.author_email, &message)
.map_err(|_| GitCommandOutput::UnreachableDestination)?;
git::push(&args.repo_path, "origin", &format!("HEAD:{}", args.branch))
.map_err(|_| GitCommandOutput::NewerRevisionsAvailable)?;
git::tag(&args.repo_path, &args.version)?;
git::push(&args.repo_path, "origin", &args.version)
.map_err(|_| GitCommandOutput::NewerRevisionsAvailable)?;
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");
}
}