Some checks failed
Tagged release / prepare-release (push) Failing after 5s
Tagged release / build-macos (push) Has been skipped
Tagged release / build-linux-arm64 (push) Has been skipped
Tagged release / build-linux-x64 (push) Has been skipped
Tagged release / build-windows (push) Has been skipped
Tagged release / publish-release (push) Has been skipped
425 lines
12 KiB
Rust
425 lines
12 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 {
|
|
return Err(format!("release {tag} is already published").into());
|
|
}
|
|
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 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");
|
|
}
|
|
}
|