#!/usr/bin/env rust-script
use regex::Regex;
#[cfg(not(test))]
use serde::Serialize;
#[cfg(not(test))]
use std::env;
#[cfg(not(test))]
use std::fs;
#[cfg(not(test))]
use std::io::Write;
#[cfg(not(test))]
use std::path::Path;
#[cfg(not(test))]
use std::process::{exit, Command, Stdio};
#[cfg(not(test))]
const USAGE: &str = "Usage: rust-script scripts/create-github-release.rs --release-version <version> --repository <repository> [--tag-prefix <prefix>] [--language <name>] [--release-label <label>] [--docker-hub-url <url>]";
const GITHUB_RELEASE_BODY_MAX_CHARS: usize = 125_000;
#[cfg(not(test))]
fn get_rust_root() -> String {
if let Some(root) = get_arg("rust-root") {
return root;
}
if Path::new("./Cargo.toml").exists() {
return ".".to_string();
}
if Path::new("./rust/Cargo.toml").exists() {
return "rust".to_string();
}
".".to_string()
}
#[cfg(not(test))]
fn get_cargo_toml_path(rust_root: &str) -> String {
if rust_root == "." {
"./Cargo.toml".to_string()
} else {
format!("{rust_root}/Cargo.toml")
}
}
#[cfg(not(test))]
fn get_changelog_path(rust_root: &str) -> String {
if rust_root == "." {
"./CHANGELOG.md".to_string()
} else {
format!("{rust_root}/CHANGELOG.md")
}
}
#[cfg(not(test))]
fn get_crate_name_from_toml(cargo_toml_path: &str) -> Option<String> {
let content = fs::read_to_string(cargo_toml_path).ok()?;
let re = Regex::new(r#"(?m)^name\s*=\s*"([^"]+)""#).ok()?;
re.captures(&content)
.map(|captures| captures.get(1).unwrap().as_str().to_string())
}
#[cfg(not(test))]
fn get_arg(name: &str) -> Option<String> {
let args: Vec<String> = env::args().collect();
let flag = format!("--{name}");
if let Some(idx) = args.iter().position(|arg| arg == &flag) {
return args.get(idx + 1).cloned();
}
let env_name = name.to_uppercase().replace('-', "_");
env::var(&env_name).ok().filter(|value| !value.is_empty())
}
fn normalize_release_version(release_version: &str) -> String {
let trimmed = release_version.trim();
let semver_re =
Regex::new(r"(?i)(?:^|[-_])v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$")
.expect("release version regex should compile");
semver_re.captures(trimmed).map_or_else(
|| {
trimmed
.strip_prefix('v')
.or_else(|| trimmed.strip_prefix('V'))
.unwrap_or(trimmed)
.to_string()
},
|captures| {
captures
.get(1)
.expect("release version regex should capture the semver")
.as_str()
.to_string()
},
)
}
fn build_release_tag(tag_prefix: &str, release_version: &str) -> String {
let normalized_semver = normalize_release_version(release_version);
format!("{tag_prefix}{normalized_semver}")
}
fn build_release_name(
language: &str,
release_version: &str,
release_label: Option<&str>,
) -> String {
let trimmed_language = language.trim();
let language = if trimmed_language.is_empty() {
"Rust"
} else {
trimmed_language
};
let normalized_semver = normalize_release_version(release_version);
let name = format!("[{language}] {normalized_semver}");
match release_label
.map(str::trim)
.filter(|label| !label.is_empty())
{
Some(label) => format!("{name} ({label})"),
None => name,
}
}
fn badge_escape(value: &str) -> String {
value
.replace('-', "--")
.replace('_', "__")
.replace(' ', "%20")
.replace('/', "%2F")
.replace(':', "%3A")
.replace('+', "%2B")
}
fn docker_hub_tag_query(version: &str) -> String {
version.replace('+', "%2B")
}
fn docker_hub_badge(url: &str, version: &str) -> String {
let trimmed_url = url.trim_end_matches('/');
let image = trimmed_url
.strip_prefix("https://hub.docker.com/r/")
.unwrap_or(trimmed_url);
let image_tag = format!("{image}:{version}");
let tag_url = format!(
"{}/tags?name={}",
trimmed_url,
docker_hub_tag_query(version)
);
format!(
"[]({})",
badge_escape(&image_tag),
tag_url
)
}
#[cfg(not(test))]
fn get_changelog_for_version(changelog_path: &str, version: &str) -> String {
if !Path::new(changelog_path).exists() {
return format!("Release v{version}");
}
let content = match fs::read_to_string(changelog_path) {
Ok(content) => content,
Err(_) => return format!("Release v{version}"),
};
let escaped_version = regex::escape(version);
let header_pattern = format!(r"(?m)^## \[{escaped_version}\]");
let header_re = Regex::new(&header_pattern).unwrap();
if let Some(version_header) = header_re.find(&content) {
let after_header = &content[version_header.end()..];
let body_start = after_header
.find('\n')
.map_or(after_header.len(), |i| i + 1);
let body = &after_header[body_start..];
let next_section_re = Regex::new(r"(?m)^## \[").unwrap();
let section_body = if let Some(next) = next_section_re.find(body) {
&body[..next.start()]
} else {
body
};
let trimmed = section_body.trim();
if trimmed.is_empty() {
format!("Release v{version}")
} else {
trimmed.to_string()
}
} else {
format!("Release v{version}")
}
}
#[cfg_attr(not(test), derive(Serialize))]
#[derive(Debug, PartialEq, Eq)]
struct ReleasePayload {
tag_name: String,
name: String,
body: String,
}
fn build_release_payload(
tag_prefix: &str,
release_version: &str,
language: &str,
release_label: Option<&str>,
body: String,
) -> ReleasePayload {
ReleasePayload {
tag_name: build_release_tag(tag_prefix, release_version),
name: build_release_name(language, release_version, release_label),
body,
}
}
fn is_existing_release_error(combined: &str) -> bool {
let Some(error) = parse_first_json_object(combined) else {
return false;
};
let Some(errors) = error.get("errors").and_then(serde_json::Value::as_array) else {
return false;
};
!errors.is_empty()
&& errors.iter().all(|error| {
error.get("resource").and_then(serde_json::Value::as_str) == Some("Release")
&& error.get("code").and_then(serde_json::Value::as_str) == Some("already_exists")
&& error.get("field").and_then(serde_json::Value::as_str) == Some("tag_name")
})
}
fn parse_first_json_object(output: &str) -> Option<serde_json::Value> {
output.match_indices('{').find_map(|(start, _)| {
let mut values =
serde_json::Deserializer::from_str(&output[start..]).into_iter::<serde_json::Value>();
let value = values.next()?.ok()?;
matches!(value, serde_json::Value::Object(_)).then_some(value)
})
}
fn normalize_changelog_blob_path(changelog_path: &str) -> String {
changelog_path
.replace('\\', "/")
.trim_start_matches("./")
.trim_start_matches('/')
.to_string()
}
fn full_changelog_url(repository: &str, tag: &str, changelog_path: &str) -> String {
format!(
"https://github.com/{repository}/blob/{tag}/{}",
normalize_changelog_blob_path(changelog_path)
)
}
fn cap_release_notes(
release_notes: String,
repository: &str,
tag: &str,
changelog_path: &str,
) -> String {
if release_notes.chars().count() <= GITHUB_RELEASE_BODY_MAX_CHARS {
return release_notes;
}
let footer = format!(
"\n\nRelease notes truncated. See the [full changelog]({}).",
full_changelog_url(repository, tag, changelog_path)
);
let marker = "\n\n...";
let reserved_chars = footer.chars().count() + marker.chars().count();
if reserved_chars >= GITHUB_RELEASE_BODY_MAX_CHARS {
return release_notes
.chars()
.take(GITHUB_RELEASE_BODY_MAX_CHARS)
.collect();
}
let keep_chars = GITHUB_RELEASE_BODY_MAX_CHARS - reserved_chars;
let mut truncated = release_notes.chars().take(keep_chars).collect::<String>();
let trimmed_len = truncated.trim_end().len();
truncated.truncate(trimmed_len);
format!("{truncated}{marker}{footer}")
}
#[cfg(not(test))]
fn main() {
let version = match get_arg("release-version") {
Some(version) => version,
None => {
eprintln!("Error: Missing required argument --release-version");
eprintln!("{USAGE}");
exit(1);
}
};
let repository = match get_arg("repository") {
Some(repository) => repository,
None => {
eprintln!("Error: Missing required argument --repository");
eprintln!("{USAGE}");
exit(1);
}
};
let tag_prefix = get_arg("tag-prefix").unwrap_or_else(|| "v".to_string());
let language = get_arg("language").unwrap_or_else(|| "Rust".to_string());
let release_label = get_arg("release-label");
let crates_io_url = get_arg("crates-io-url");
let docker_hub_url = get_arg("docker-hub-url");
let normalized_version = normalize_release_version(&version);
let rust_root = get_rust_root();
let cargo_toml = get_cargo_toml_path(&rust_root);
if let Some(ref crate_name) = get_crate_name_from_toml(&cargo_toml) {
if crate_name == "example-sum-package-name" {
println!(
"Skipping GitHub release: package name is the template default 'example-sum-package-name'"
);
println!("Rename the package in Cargo.toml before creating releases");
return;
}
}
let changelog_path = get_changelog_path(&rust_root);
let mut release_notes = get_changelog_for_version(&changelog_path, &normalized_version);
let mut badges = Vec::new();
if let Some(crate_name) = get_crate_name_from_toml(&cargo_toml) {
let crate_badges = format!(
"[](https://crates.io/crates/{crate_name}/{normalized_version}) [](https://docs.rs/{crate_name}/{normalized_version})"
);
badges.push(crate_badges);
}
if let Some(url) = docker_hub_url {
badges.push(docker_hub_badge(&url, &normalized_version));
}
if !badges.is_empty() {
release_notes = format!("{}\n\n{release_notes}", badges.join(" "));
}
if let Some(url) = crates_io_url {
release_notes = format!("{url}\n\n{release_notes}");
}
let release_tag = build_release_tag(&tag_prefix, &normalized_version);
release_notes = cap_release_notes(release_notes, &repository, &release_tag, &changelog_path);
let payload = build_release_payload(
&tag_prefix,
&normalized_version,
&language,
release_label.as_deref(),
release_notes,
);
let tag = payload.tag_name.clone();
println!("Creating GitHub release for {} ({})...", tag, payload.name);
let payload_json = serde_json::to_string(&payload).expect("Failed to serialize payload");
let mut child = Command::new("gh")
.args([
"api",
&format!("repos/{repository}/releases"),
"-X",
"POST",
"--input",
"-",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("Failed to execute gh command");
if let Some(ref mut stdin) = child.stdin {
stdin
.write_all(payload_json.as_bytes())
.expect("Failed to write to stdin");
}
let output = child
.wait_with_output()
.expect("Failed to wait on gh command");
if output.status.success() {
println!("Created GitHub release: {tag}");
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let combined = format!("{stderr}{stdout}");
if is_existing_release_error(&combined) {
println!("Release {tag} already exists, skipping");
} else {
let details = combined.trim();
if details.is_empty() {
eprintln!("Error creating release: gh api exited unsuccessfully");
} else {
eprintln!("Error creating release:\n{details}");
}
exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_title_uses_language_and_bare_semver() {
assert_eq!(
build_release_name("Rust", "rust-v0.2.1", None),
"[Rust] 0.2.1"
);
assert_eq!(
build_release_name("JavaScript", "js_v1.2.3", None),
"[JavaScript] 1.2.3"
);
}
#[test]
fn release_title_defaults_empty_language_to_rust() {
assert_eq!(build_release_name(" ", "0.2.1", None), "[Rust] 0.2.1");
}
#[test]
fn release_label_remains_optional_suffix() {
assert_eq!(
build_release_name("Rust", "0.2.1", Some("stable")),
"[Rust] 0.2.1 (stable)"
);
assert_eq!(
build_release_name("Rust", "0.2.1", Some(" ")),
"[Rust] 0.2.1"
);
}
#[test]
fn release_tag_uses_prefix_with_normalized_semver() {
assert_eq!(build_release_tag("rust-v", "0.2.1"), "rust-v0.2.1");
assert_eq!(build_release_tag("rust_v", "rust-v0.2.1"), "rust_v0.2.1");
assert_eq!(build_release_tag("v", "v0.2.1"), "v0.2.1");
}
#[test]
fn release_payload_keeps_tag_prefix_out_of_release_name() {
let payload =
build_release_payload("rust-v", "0.2.1", "Rust", None, "release notes".to_string());
assert_eq!(
payload,
ReleasePayload {
tag_name: "rust-v0.2.1".to_string(),
name: "[Rust] 0.2.1".to_string(),
body: "release notes".to_string(),
}
);
}
#[test]
fn docker_hub_badge_links_to_exact_tag() {
let badge = docker_hub_badge("https://hub.docker.com/r/example/project", "1.2.3");
assert!(badge.contains("docker-example%2Fproject%3A1.2.3"));
assert!(badge.contains("https://hub.docker.com/r/example/project/tags?name=1.2.3"));
}
#[test]
fn docker_hub_badge_escapes_build_metadata() {
let badge = docker_hub_badge("https://hub.docker.com/r/example/project", "1.2.3+build.4");
assert!(badge.contains("1.2.3%2Bbuild.4"));
assert!(badge.contains("tags?name=1.2.3%2Bbuild.4"));
}
#[test]
fn duplicate_release_validation_is_idempotent() {
let output = r#"gh: Validation Failed (HTTP 422)
{"message":"Validation Failed","errors":[{"resource":"Release","code":"already_exists","field":"tag_name"}]}"#;
assert!(is_existing_release_error(output));
}
#[test]
fn generic_validation_failure_is_not_existing_release() {
let output = r#"gh: Validation Failed (HTTP 422)
{"message":"Validation Failed","errors":[{"resource":"Release","code":"custom","field":"body"}]}"#;
assert!(!is_existing_release_error(output));
}
#[test]
fn mixed_validation_failure_is_not_existing_release() {
let output = r#"gh: Validation Failed (HTTP 422)
{"message":"Validation Failed","errors":[{"resource":"Release","code":"already_exists","field":"tag_name"},{"resource":"Release","code":"custom","field":"body"}]}"#;
assert!(!is_existing_release_error(output));
}
#[test]
fn oversized_release_notes_are_capped_with_full_changelog_link() {
let release_notes = "a".repeat(GITHUB_RELEASE_BODY_MAX_CHARS + 100);
let capped = cap_release_notes(release_notes, "owner/repo", "v1.2.3", "./CHANGELOG.md");
assert!(capped.chars().count() <= GITHUB_RELEASE_BODY_MAX_CHARS);
assert!(capped.contains("Release notes truncated"));
assert!(capped.contains("https://github.com/owner/repo/blob/v1.2.3/CHANGELOG.md"));
}
}