Files
RuDS/crates/bds-release/src/gitea.rs
Hermes Agent 0d1f63e77f
Some checks failed
Tagged release / prepare-release (push) Successful in 3m6s
Tagged release / build-macos (push) Failing after 22m42s
Tagged release / build-linux-arm64 (push) Successful in 37m56s
Tagged release / build-linux-x64 (push) Failing after 15s
Tagged release / build-windows (push) Failing after 19m27s
Tagged release / publish-release (push) Has been skipped
Handle explicit release rebuilds.
2026-08-14 10:44:13 +00:00

442 lines
13 KiB
Rust

use reqwest::{
StatusCode,
blocking::{Body, Client},
};
use serde::{Deserialize, Serialize};
use std::{
collections::BTreeSet,
env,
error::Error,
fs::{File, OpenOptions},
io::Write,
path::Path,
process::Command,
thread,
time::Duration,
};
#[derive(Debug, Deserialize)]
struct Issue {
number: u64,
title: String,
html_url: String,
state: String,
#[serde(default)]
labels: Vec<Label>,
}
#[derive(Debug, Deserialize)]
struct Label {
name: String,
}
#[derive(Debug, Deserialize)]
struct Release {
id: u64,
draft: bool,
#[serde(default)]
assets: Option<Vec<Asset>>,
}
#[derive(Debug, Deserialize)]
struct Asset {
id: u64,
}
#[derive(Serialize)]
struct ReleasePayload<'a> {
tag_name: &'a str,
target_commitish: &'a str,
name: &'a str,
body: String,
draft: bool,
prerelease: bool,
}
struct Gitea {
client: Client,
api_url: String,
repository: String,
token: String,
}
impl Gitea {
fn from_environment() -> Result<Self, Box<dyn Error>> {
Ok(Self {
client: Client::builder().build()?,
api_url: required_env("GITEA_API_URL")?
.trim_end_matches('/')
.to_owned(),
repository: required_env("GITEA_REPOSITORY")?,
token: required_env("GITEA_TOKEN")?,
})
}
fn url(&self, path: &str) -> String {
format!("{}{path}", self.api_url)
}
fn issue(&self, number: u64) -> Result<Issue, Box<dyn Error>> {
Ok(self
.client
.get(self.url(&format!("/repos/{}/issues/{number}", self.repository)))
.header("Authorization", format!("token {}", self.token))
.send()?
.error_for_status()?
.json()?)
}
fn release_for_tag(&self, tag: &str) -> Result<Option<Release>, Box<dyn Error>> {
let response = self
.client
.get(self.url(&format!(
"/repos/{}/releases/tags/{}",
self.repository,
percent_encode(tag)
)))
.header("Authorization", format!("token {}", self.token))
.send()?;
if response.status() == StatusCode::NOT_FOUND {
Ok(None)
} else {
Ok(Some(response.error_for_status()?.json()?))
}
}
fn create_release(&self, payload: &ReleasePayload<'_>) -> Result<Release, Box<dyn Error>> {
Ok(self
.client
.post(self.url(&format!("/repos/{}/releases", self.repository)))
.header("Authorization", format!("token {}", self.token))
.json(payload)
.send()?
.error_for_status()?
.json()?)
}
fn update_release(
&self,
release_id: u64,
payload: &impl Serialize,
) -> Result<Release, Box<dyn Error>> {
Ok(self
.client
.patch(self.url(&format!("/repos/{}/releases/{release_id}", self.repository)))
.header("Authorization", format!("token {}", self.token))
.json(payload)
.send()?
.error_for_status()?
.json()?)
}
fn delete_asset(&self, release_id: u64, asset_id: u64) -> Result<(), Box<dyn Error>> {
self.client
.delete(self.url(&format!(
"/repos/{}/releases/{release_id}/assets/{asset_id}",
self.repository
)))
.header("Authorization", format!("token {}", self.token))
.send()?
.error_for_status()?;
Ok(())
}
fn upload_asset(&self, release_id: u64, path: &Path) -> Result<(), Box<dyn Error>> {
let name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| format!("invalid release asset name: {}", path.display()))?;
for attempt in 1..=3 {
let file = File::open(path)?;
let length = file.metadata()?.len();
let body = Body::sized(file, length);
let response = self
.client
.post(self.url(&format!(
"/repos/{}/releases/{release_id}/assets",
self.repository
)))
.query(&[("name", name)])
.header("Authorization", format!("token {}", self.token))
.header("Content-Type", "application/octet-stream")
.body(body)
.send();
match response {
Ok(response) if response.status().is_success() => {
println!("uploaded {}", path.display());
return Ok(());
}
Ok(response)
if attempt < 3
&& (response.status().is_server_error()
|| response.status() == StatusCode::TOO_MANY_REQUESTS) => {}
Ok(response) => {
return Err(response.error_for_status().unwrap_err().into());
}
Err(error) if attempt < 3 => {
eprintln!("asset upload attempt {attempt} failed: {error}");
}
Err(error) => return Err(error.into()),
}
thread::sleep(Duration::from_secs(attempt));
}
unreachable!("upload retry loop always returns")
}
}
pub(crate) fn prepare() -> Result<(), Box<dyn Error>> {
let gitea = Gitea::from_environment()?;
let tag = required_env("GITEA_REF_NAME")?;
let sha = required_env("GITEA_SHA")?;
let server = required_env("GITEA_SERVER_URL")?;
let messages = commit_messages(&sha)?;
let issues = issue_numbers(&messages)
.into_iter()
.map(|number| gitea.issue(number))
.collect::<Result<Vec<_>, _>>()?;
let closed = issues
.iter()
.filter(|issue| issue.state == "closed")
.collect::<Vec<_>>();
let payload = ReleasePayload {
tag_name: &tag,
target_commitish: &sha,
name: &tag,
body: release_body(closed.iter().copied(), &server, &gitea.repository, &sha),
draft: true,
prerelease: false,
};
let release = if let Some(existing) = gitea.release_for_tag(&tag)? {
if !existing.draft
&& !replace_published_release(env::var("REBUILD_PUBLISHED_RELEASE").ok().as_deref())
{
return Err(format!("release {tag} is already published").into());
}
if !existing.draft {
gitea.update_release(existing.id, &serde_json::json!({ "draft": true }))?;
}
for asset in existing.assets.unwrap_or_default() {
gitea.delete_asset(existing.id, asset.id)?;
}
gitea.update_release(existing.id, &payload)?
} else {
gitea.create_release(&payload)?
};
let output_path = required_env("GITHUB_OUTPUT")?;
writeln!(
OpenOptions::new()
.create(true)
.append(true)
.open(output_path)?,
"release_id={}",
release.id
)?;
Ok(())
}
pub(crate) fn upload<'a>(paths: impl Iterator<Item = &'a Path>) -> Result<(), Box<dyn Error>> {
let gitea = Gitea::from_environment()?;
let release_id = required_env("RELEASE_ID")?.parse()?;
for path in paths {
gitea.upload_asset(release_id, path)?;
}
Ok(())
}
pub(crate) fn publish() -> Result<(), Box<dyn Error>> {
let gitea = Gitea::from_environment()?;
let release_id = required_env("RELEASE_ID")?.parse()?;
gitea.update_release(release_id, &serde_json::json!({ "draft": false }))?;
Ok(())
}
fn replace_published_release(value: Option<&str>) -> bool {
value == Some("true")
}
fn required_env(name: &str) -> Result<String, Box<dyn Error>> {
env::var(name).map_err(|_| format!("required environment variable {name} is missing").into())
}
fn commit_messages(sha: &str) -> Result<String, Box<dyn Error>> {
let previous = Command::new("git")
.args(["describe", "--tags", "--abbrev=0", &format!("{sha}^")])
.output()?;
let previous = if previous.status.success() {
String::from_utf8(previous.stdout)?.trim().to_owned()
} else {
String::new()
};
let revision = if previous.is_empty() {
sha.to_owned()
} else {
format!("{previous}..{sha}")
};
let output = Command::new("git")
.args(["log", "--format=%s%n%b", &revision])
.output()?;
if !output.status.success() {
return Err(format!("git log failed for {revision}").into());
}
Ok(String::from_utf8(output.stdout)?)
}
fn issue_numbers(message: &str) -> Vec<u64> {
let bytes = message.as_bytes();
let mut numbers = BTreeSet::new();
let mut index = 0;
while index < bytes.len() {
if bytes[index] != b'#' {
index += 1;
continue;
}
let start = index + 1;
let mut end = start;
while end < bytes.len() && bytes[end].is_ascii_digit() {
end += 1;
}
if end > start
&& let Ok(number) = message[start..end].parse()
{
numbers.insert(number);
}
index = end.max(index + 1);
}
numbers.into_iter().collect()
}
fn release_body<'a>(
issues: impl Iterator<Item = &'a Issue>,
server: &str,
repository: &str,
sha: &str,
) -> String {
let mut fixes = Vec::new();
let mut improvements = Vec::new();
let mut other = Vec::new();
for issue in issues {
let title = issue.title.split_whitespace().collect::<Vec<_>>().join(" ");
let entry = format!(
"- [#{} {}]({})",
issue.number,
markdown(&title),
issue.html_url
);
let labels = issue
.labels
.iter()
.map(|label| label.name.as_str())
.collect::<BTreeSet<_>>();
if labels.contains("bug") {
fixes.push(entry);
} else if labels.contains("enhancement") {
improvements.push(entry);
} else {
other.push(entry);
}
}
let mut sections = Vec::new();
for (title, entries) in [
("Fixes", fixes),
("Improvements", improvements),
("Other changes", other),
] {
if !entries.is_empty() {
sections.push(format!("## {title}\n{}", entries.join("\n")));
}
}
if sections.is_empty() {
sections.push("No closed issues were linked from commits in this release.".to_owned());
}
let short_sha = &sha[..sha.len().min(12)];
sections.push(format!(
"Built from [{short_sha}]({}/{repository}/commit/{sha}).",
server.trim_end_matches('/')
));
sections.join("\n\n")
}
fn markdown(text: &str) -> String {
text.replace('\\', "\\\\")
.replace('[', "\\[")
.replace(']', "\\]")
}
fn percent_encode(value: &str) -> String {
let mut encoded = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(char::from(byte));
} else {
encoded.push('%');
encoded.push_str(&format!("{byte:02X}"));
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issue_references_are_unique_and_sorted() {
assert_eq!(
issue_numbers("Fix #12, refs #3 and #12; ignore #x"),
[3, 12]
);
}
#[test]
fn release_notes_are_grouped_linked_and_escaped() {
let issues = [
Issue {
number: 12,
title: "Fix [unlock]".to_owned(),
html_url: "https://example.test/issues/12".to_owned(),
state: "closed".to_owned(),
labels: vec![Label {
name: "bug".to_owned(),
}],
},
Issue {
number: 3,
title: "Add search".to_owned(),
html_url: "https://example.test/issues/3".to_owned(),
state: "closed".to_owned(),
labels: vec![Label {
name: "enhancement".to_owned(),
}],
},
];
let body = release_body(
issues.iter(),
"https://example.test",
"owner/repo",
"1234567890abcdef",
);
assert!(body.contains("## Fixes\n- [#12 Fix \\[unlock\\]]"));
assert!(body.contains("## Improvements\n- [#3 Add search]"));
assert!(
body.contains(
"[1234567890ab](https://example.test/owner/repo/commit/1234567890abcdef)"
)
);
}
#[test]
fn release_tag_is_encoded_as_one_api_path_segment() {
assert_eq!(percent_encode("release/1+beta"), "release%2F1%2Bbeta");
}
#[test]
fn published_release_replacement_requires_explicit_opt_in() {
assert!(!replace_published_release(None));
assert!(!replace_published_release(Some("false")));
assert!(!replace_published_release(Some("TRUE")));
assert!(replace_published_release(Some("true")));
}
}