Skip to content

Commit 3afd80e

Browse files
feat(vdev): generate GitHub release description from changelog (#26584)
1 parent dd645d6 commit 3afd80e

5 files changed

Lines changed: 253 additions & 13 deletions

File tree

‎.github/workflows/publish.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,7 @@ jobs:
552552
- uses: ./.github/actions/setup
553553
with:
554554
vdev: true
555+
cue: true
555556
- name: Download all package artifacts
556557
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
557558
with:

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎vdev/Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "vdev"
3-
version = "0.3.27"
3+
version = "0.3.28"
44
edition = "2024"
55
authors = ["Vector Contributors <vector@datadoghq.com>"]
66
license = "MPL-2.0"

‎vdev/src/commands/release/generate_cue.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ use serde_json::json;
1414
use crate::commands::changelog::FRAGMENT_TYPES;
1515
use crate::utils::{git, paths};
1616

17-
const RELEASES_DIR: &str = "website/cue/reference/releases";
17+
pub(super) const RELEASES_DIR: &str = "website/cue/reference/releases";
1818
const CHANGELOG_DIR: &str = "changelog.d";
19-
const HIGHLIGHTS_DIR: &str = "website/content/en/highlights";
19+
pub(super) const HIGHLIGHTS_DIR: &str = "website/content/en/highlights";
2020

2121
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2222
pub(super) enum PullRequestMetadata {
@@ -705,7 +705,7 @@ fn run_cue_fmt(path: &Path) -> Result<()> {
705705

706706
/// Return the first file in `highlights_dir` whose name ends in the version-suffix used
707707
/// by upgrade guides (e.g. `-0-58-0-upgrade-guide.md`), regardless of the date prefix.
708-
fn find_existing_upgrade_guide(
708+
pub(super) fn find_existing_upgrade_guide(
709709
highlights_dir: &Path,
710710
version: &Version,
711711
) -> Result<Option<PathBuf>> {

‎vdev/src/commands/release/github.rs‎

Lines changed: 247 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,59 @@
1-
use crate::{app::CommandExt as _, utils::cargo};
2-
use anyhow::{Ok, Result, anyhow};
1+
use std::{collections::BTreeSet, fmt::Write as _, path::Path, process::Command};
2+
3+
use anyhow::{Context, Result, anyhow, bail, ensure};
34
use glob::glob;
4-
use std::process::Command;
5+
use semver::Version;
6+
use serde::Deserialize;
7+
8+
use super::generate_cue::{HIGHLIGHTS_DIR, RELEASES_DIR, find_existing_upgrade_guide};
9+
use crate::{
10+
app::CommandExt as _,
11+
utils::{cargo, paths},
12+
};
13+
14+
const WEBSITE_URL: &str = "https://vector.dev";
15+
const RELEASE_CALENDAR_URL: &str = "https://calendar.vector.dev";
16+
17+
/// Anchor of the release page's breaking-changes section.
18+
const BREAKING_ANCHOR: &str = "breaking-changes";
19+
20+
/// Changelog groups in the order `website/layouts/releases/single.html` renders them, as
21+
/// `(type, singular noun, plural noun, section anchor)`. `chore` is omitted: changelog
22+
/// fragments only produce it for breaking changes, which get their own line.
23+
const GROUPS: &[(&str, &str, &str, &str)] = &[
24+
(
25+
"security",
26+
"security change",
27+
"security changes",
28+
"security-changes",
29+
),
30+
("feat", "new feature", "new features", "new-features"),
31+
("enhancement", "enhancement", "enhancements", "enhancements"),
32+
("fix", "bug fix", "bug fixes", "bug-fixes"),
33+
("deprecation", "deprecation", "deprecations", "deprecations"),
34+
];
535

636
/// Uploads target/artifacts to GitHub releases
737
#[derive(clap::Args, Debug)]
838
#[command()]
9-
pub struct Cli {}
39+
pub struct Cli {
40+
/// Print the release description instead of creating the GitHub release
41+
#[arg(long)]
42+
dry_run: bool,
43+
}
1044

1145
impl Cli {
1246
pub fn exec(self) -> Result<()> {
47+
let version = cargo::get_version()?;
48+
let parsed_version = Version::parse(&version)
49+
.with_context(|| format!("Invalid release version {version:?}"))?;
50+
let notes = release_notes(&paths::find_repo_root()?, &parsed_version)?;
51+
52+
if self.dry_run {
53+
println!("{notes}");
54+
return Ok(());
55+
}
56+
1357
let artifacts = glob("target/artifacts/*")
1458
.expect("failed to read glob pattern")
1559
.collect::<Result<Vec<_>, _>>()
@@ -19,7 +63,6 @@ impl Cli {
1963
.collect::<Result<Vec<_>, _>>()
2064
.map_err(|e| anyhow!("failed to turn path into string: {}", e.to_string_lossy()))?;
2165

22-
let version = cargo::get_version()?;
2366
let mut command = Command::new("gh");
2467
command.in_repo();
2568
command.args(
@@ -32,9 +75,7 @@ impl Cli {
3275
"--title",
3376
&format!("v{version}"),
3477
"--notes",
35-
&format!(
36-
"The [COSE team](https://opensource.datadoghq.com/about/#the-community-open-source-engineering-team) is excited to announce version `{version}`! 🚀\n\n[View release notes](https://vector.dev/releases/{version})"
37-
),
78+
&notes,
3879
]
3980
.map(String::from)
4081
.into_iter()
@@ -44,3 +85,201 @@ impl Cli {
4485
Ok(())
4586
}
4687
}
88+
89+
#[derive(Debug, Deserialize)]
90+
struct ChangelogEntry {
91+
#[serde(rename = "type")]
92+
kind: String,
93+
#[serde(default)]
94+
breaking: bool,
95+
#[serde(default)]
96+
contributors: Vec<String>,
97+
}
98+
99+
/// Build the GitHub release description from the release CUE file and the upgrade guide.
100+
fn release_notes(repo_root: &Path, version: &Version) -> Result<String> {
101+
let changelog = read_changelog(repo_root, version)?;
102+
let upgrade_guide = find_existing_upgrade_guide(&repo_root.join(HIGHLIGHTS_DIR), version)?;
103+
let upgrade_guide_slug = upgrade_guide
104+
.as_deref()
105+
.map(|path| {
106+
path.file_stem()
107+
.and_then(|stem| stem.to_str())
108+
.ok_or_else(|| anyhow!("Bad upgrade guide filename: {}", path.display()))
109+
})
110+
.transpose()?;
111+
Ok(render_notes(version, &changelog, upgrade_guide_slug))
112+
}
113+
114+
fn read_changelog(repo_root: &Path, version: &Version) -> Result<Vec<ChangelogEntry>> {
115+
let cue_path = repo_root.join(RELEASES_DIR).join(format!("{version}.cue"));
116+
ensure!(
117+
cue_path.is_file(),
118+
"{} does not exist. Prepare the release before publishing it.",
119+
cue_path.display()
120+
);
121+
122+
let output = Command::new("cue")
123+
.arg("export")
124+
.arg(&cue_path)
125+
.args(["--out", "json", "-e"])
126+
.arg(format!("releases.\"{version}\".changelog"))
127+
.output()
128+
.context("Failed to run `cue export`")?;
129+
if !output.status.success() {
130+
bail!(
131+
"`cue export` of {} failed:\n{}",
132+
cue_path.display(),
133+
String::from_utf8_lossy(&output.stderr).trim()
134+
);
135+
}
136+
137+
serde_json::from_slice(&output.stdout)
138+
.with_context(|| format!("Failed to parse the changelog in {}", cue_path.display()))
139+
}
140+
141+
fn render_notes(
142+
version: &Version,
143+
changelog: &[ChangelogEntry],
144+
upgrade_guide_slug: Option<&str>,
145+
) -> String {
146+
let release_url = format!("{WEBSITE_URL}/releases/{version}/");
147+
let upgrade_guide_url =
148+
upgrade_guide_slug.map(|slug| format!("{WEBSITE_URL}/highlights/{slug}/"));
149+
150+
let mut out = format!(
151+
"The [COSE team](https://opensource.datadoghq.com/about/#the-community-open-source-engineering-team) is happy to announce Vector `{version}`! 🚀 Vector is a lightweight, ultra-fast tool for building observability pipelines that puts you in control of your data.\n\n"
152+
);
153+
154+
if !changelog.is_empty() {
155+
let total = changelog.len();
156+
let contributors = changelog
157+
.iter()
158+
.flat_map(|entry| &entry.contributors)
159+
.collect::<BTreeSet<_>>()
160+
.len();
161+
match contributors {
162+
0 => {
163+
let noun = if total == 1 { "change" } else { "changes" };
164+
write!(out, "This release includes {total} {noun}:").unwrap();
165+
}
166+
1 => out.push_str("Thanks to the contributor who made "),
167+
_ => write!(out, "Thanks to the {contributors} contributors who made ").unwrap(),
168+
}
169+
if contributors > 0 {
170+
if total == 1 {
171+
out.push_str("the change");
172+
} else {
173+
write!(out, "the {total} changes").unwrap();
174+
}
175+
out.push_str(" in this release!");
176+
}
177+
out.push_str("\n\n");
178+
179+
let breaking = changelog.iter().filter(|entry| entry.breaking).count();
180+
if breaking > 0 {
181+
let noun = if breaking == 1 {
182+
"breaking change"
183+
} else {
184+
"breaking changes"
185+
};
186+
// The release page renders its breaking-changes section only when the release
187+
// has an upgrade guide.
188+
let target = if upgrade_guide_url.is_some() {
189+
format!("{release_url}#{BREAKING_ANCHOR}")
190+
} else {
191+
release_url.clone()
192+
};
193+
writeln!(out, "- [{breaking} {noun}]({target})").unwrap();
194+
}
195+
196+
for (kind, singular, plural, anchor) in GROUPS {
197+
let count = changelog.iter().filter(|entry| entry.kind == *kind).count();
198+
if count == 0 {
199+
continue;
200+
}
201+
let noun = if count == 1 { singular } else { plural };
202+
writeln!(out, "- [{count} {noun}]({release_url}#{anchor})").unwrap();
203+
}
204+
out.push('\n');
205+
}
206+
207+
if let Some(url) = &upgrade_guide_url {
208+
writeln!(
209+
out,
210+
"Upgrading to {}.{}? Read the [upgrade guide]({url}).\n",
211+
version.major, version.minor
212+
)
213+
.unwrap();
214+
}
215+
216+
write!(
217+
out,
218+
"[View the full release notes]({release_url}) · See upcoming releases on the [release calendar]({RELEASE_CALENDAR_URL})."
219+
)
220+
.unwrap();
221+
out
222+
}
223+
224+
#[cfg(test)]
225+
mod tests {
226+
use super::*;
227+
228+
fn entry(kind: &str, breaking: bool, contributors: &[&str]) -> ChangelogEntry {
229+
ChangelogEntry {
230+
kind: kind.to_string(),
231+
breaking,
232+
contributors: contributors.iter().map(ToString::to_string).collect(),
233+
}
234+
}
235+
236+
#[test]
237+
fn thanks_unique_contributors_and_links_sections_and_upgrade_guide() {
238+
let changelog = [
239+
entry("chore", true, &["alice"]),
240+
entry("feat", false, &["bob", "alice"]),
241+
entry("fix", false, &["carol"]),
242+
entry("fix", false, &["bob"]),
243+
];
244+
let notes = render_notes(
245+
&Version::new(0, 59, 0),
246+
&changelog,
247+
Some("2026-10-05-0-59-0-upgrade-guide"),
248+
);
249+
250+
assert_eq!(
251+
notes,
252+
indoc::indoc! {"
253+
The [COSE team](https://opensource.datadoghq.com/about/#the-community-open-source-engineering-team) is happy to announce Vector `0.59.0`! 🚀 Vector is a lightweight, ultra-fast tool for building observability pipelines that puts you in control of your data.
254+
255+
Thanks to the 3 contributors who made the 4 changes in this release!
256+
257+
- [1 breaking change](https://vector.dev/releases/0.59.0/#breaking-changes)
258+
- [1 new feature](https://vector.dev/releases/0.59.0/#new-features)
259+
- [2 bug fixes](https://vector.dev/releases/0.59.0/#bug-fixes)
260+
261+
Upgrading to 0.59? Read the [upgrade guide](https://vector.dev/highlights/2026-10-05-0-59-0-upgrade-guide/).
262+
263+
[View the full release notes](https://vector.dev/releases/0.59.0/) · See upcoming releases on the [release calendar](https://calendar.vector.dev)."}
264+
);
265+
}
266+
267+
#[test]
268+
fn falls_back_without_contributors_or_upgrade_guide() {
269+
let changelog = [entry("chore", true, &[]), entry("fix", false, &[])];
270+
let notes = render_notes(&Version::new(0, 59, 1), &changelog, None);
271+
272+
assert_eq!(
273+
notes,
274+
indoc::indoc! {"
275+
The [COSE team](https://opensource.datadoghq.com/about/#the-community-open-source-engineering-team) is happy to announce Vector `0.59.1`! 🚀 Vector is a lightweight, ultra-fast tool for building observability pipelines that puts you in control of your data.
276+
277+
This release includes 2 changes:
278+
279+
- [1 breaking change](https://vector.dev/releases/0.59.1/)
280+
- [1 bug fix](https://vector.dev/releases/0.59.1/#bug-fixes)
281+
282+
[View the full release notes](https://vector.dev/releases/0.59.1/) · See upcoming releases on the [release calendar](https://calendar.vector.dev)."}
283+
);
284+
}
285+
}

0 commit comments

Comments
 (0)