1#![allow(clippy::print_stdout)]
2#![allow(clippy::print_stderr)]
3
4use crate::git;
5use crate::util::run_command;
6use anyhow::{anyhow, Result};
7use reqwest::blocking::Client;
8use semver::Version;
9use std::fs::File;
10use std::io::BufRead;
11use std::io::BufReader;
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::process::Command;
15use std::{env, fs};
16use toml::map::Map;
17use toml::Value;
18
19const ALPINE_PREFIX: &str = "FROM docker.io/alpine:";
20const ALPINE_DOCKERFILE: &str = "distribution/docker/alpine/Dockerfile";
21const DEBIAN_PREFIX: &str = "FROM docker.io/debian:";
22const DEBIAN_DOCKERFILE: &str = "distribution/docker/debian/Dockerfile";
23const RELEASE_CUE_SCRIPT: &str = "scripts/generate-release-cue.rb";
24const KUBECLT_CUE_FILE: &str = "website/cue/reference/administration/interfaces/kubectl.cue";
25const INSTALL_SCRIPT: &str = "distribution/install.sh";
26
27#[derive(clap::Args, Debug)]
29#[command()]
30pub struct Cli {
31 #[arg(long)]
33 version: Version,
34 #[arg(long)]
36 vrl_version: Version,
37 #[arg(long)]
40 alpine_version: Option<String>,
41 #[arg(long)]
44 debian_version: Option<String>,
45}
46
47struct Prepare {
48 new_vector_version: Version,
49 vrl_version: Version,
50 alpine_version: Option<String>,
51 debian_version: Option<String>,
52 repo_root: PathBuf,
53 latest_vector_version: Version,
54 release_branch: String,
55 release_preparation_branch: String,
56}
57
58impl Cli {
59 pub fn exec(self) -> Result<()> {
60
61 let repo_root = get_repo_root();
62 env::set_current_dir(repo_root.clone())?;
63
64 let prepare = Prepare {
65 new_vector_version: self.version.clone(),
66 vrl_version: self.vrl_version,
67 alpine_version: self.alpine_version,
68 debian_version: self.debian_version,
69 repo_root,
70 latest_vector_version: get_latest_version_from_vector_tags()?,
71 release_branch: format!("v{}.{}", self.version.major, self.version.minor),
72 release_preparation_branch: format!("prepare-v-{}-{}-{}-website", self.version.major, self.version.minor, self.version.patch),
75 };
76 prepare.run()
77 }
78}
79
80impl Prepare {
81 pub fn run(&self) -> Result<()> {
82 debug!("run");
83 self.create_release_branches()?;
84 self.pin_vrl_version()?;
85
86 self.update_dockerfile_base_version(
87 &self.repo_root.join(ALPINE_DOCKERFILE),
88 self.alpine_version.as_deref(),
89 ALPINE_PREFIX,
90 )?;
91
92 self.update_dockerfile_base_version(
93 &self.repo_root.join(DEBIAN_DOCKERFILE),
94 self.debian_version.as_deref(),
95 DEBIAN_PREFIX,
96 )?;
97
98 self.generate_release_cue()?;
99
100 self.update_vector_version(&self.repo_root.join(KUBECLT_CUE_FILE))?;
101 self.update_vector_version(&self.repo_root.join(INSTALL_SCRIPT))?;
102
103 self.add_new_version_to_versions_cue()?;
104
105 self.create_new_release_md()?;
106
107 self.open_release_pr()
108 }
109
110 fn create_release_branches(&self) -> Result<()> {
112 debug!("create_release_branches");
113 git::run_and_check_output(&["fetch"])?;
115 git::checkout_main_branch()?;
116
117 git::checkout_or_create_branch(self.release_branch.as_str())?;
118 git::push_and_set_upstream(self.release_branch.as_str())?;
119
120 git::checkout_or_create_branch(self.release_preparation_branch.as_str())?;
123 git::push_and_set_upstream(self.release_preparation_branch.as_str())?;
124 Ok(())
125 }
126
127 fn pin_vrl_version(&self) -> Result<()> {
129 debug!("pin_vrl_version");
130 let cargo_toml_path = &self.repo_root.join("Cargo.toml");
131 let contents = fs::read_to_string(cargo_toml_path).expect("Failed to read Cargo.toml");
132
133 let mut lines: Vec<String> = contents.lines().map(String::from).collect();
135
136 let vrl_version = self.vrl_version.to_string();
137 for line in &mut lines {
138 if line.trim().starts_with("vrl = { git = ") {
139 if let Ok(mut vrl_toml) = line.parse::<Value>() {
140 let vrl_dependency: &mut Value = vrl_toml.get_mut("vrl").expect("line should start with 'vrl'");
141
142 let mut new_dependency_value = Map::new();
143 new_dependency_value.insert("version".to_string(), Value::String(vrl_version.clone()));
144 let features = vrl_dependency.get("features").expect("missing 'features' key");
145 new_dependency_value.insert("features".to_string(), features.clone());
146
147 *line = format!("vrl = {}", Value::from(new_dependency_value));
148 }
149 break;
150 }
151 }
152
153 lines.push(String::new()); fs::write(cargo_toml_path, lines.join("\n")).expect("Failed to write Cargo.toml");
155 run_command("cargo update -p vrl");
156 git::commit(&format!("chore(releasing): Pinned VRL version to {vrl_version}"))?;
157 Ok(())
158 }
159
160 fn update_dockerfile_base_version(
163 &self,
164 dockerfile_path: &Path,
165 new_version: Option<&str>,
166 prefix: &str,
167 ) -> Result<()> {
168 debug!("update_dockerfile_base_version for {}", dockerfile_path.display());
169 if let Some(version) = new_version {
170 let contents = fs::read_to_string(dockerfile_path)?;
171
172 if !contents.starts_with(prefix) {
173 return Err(anyhow::anyhow!(
174 "Dockerfile at {} does not start with {prefix}",
175 dockerfile_path.display()
176 ));
177 }
178
179 let mut lines = contents.lines();
180 let first_line = lines.next().expect("File should have at least one line");
181 let rest = lines.collect::<Vec<&str>>().join("\n");
182
183 let after_prefix = first_line
186 .strip_prefix(prefix)
187 .ok_or_else(|| anyhow!("Failed to strip prefix in {}", dockerfile_path.display()))?;
188 let parts: Vec<&str> = after_prefix.splitn(2, ' ').collect();
189 let suffix = parts.get(1).unwrap_or(&"");
190
191 let updated_version_line = format!("{prefix}{version} {suffix}");
193 let new_contents = format!("{updated_version_line}\n{rest}");
194
195 fs::write(dockerfile_path, &new_contents)?;
196 git::commit(&format!(
197 "chore(releasing): Bump {} version to {version}",
198 dockerfile_path.strip_prefix(&self.repo_root).unwrap().display(),
199 ))?;
200 } else {
201 debug!(
202 "No version specified for {dockerfile_path:?}; skipping update");
203 }
204 Ok(())
205 }
206
207 fn generate_release_cue(&self) -> Result<()> {
209 debug!("generate_release_cue");
210 let script = self.repo_root.join(RELEASE_CUE_SCRIPT);
211 let new_vector_version = &self.new_vector_version;
212 if script.is_file() {
213 run_command(&format!("{} --new-version {new_vector_version} --no-interactive", script.to_string_lossy().as_ref()));
214 } else {
215 return Err(anyhow!("Script not found: {}", script.display()));
216 }
217
218 self.append_vrl_changelog_to_release_cue()?;
219 git::add_files_in_current_dir()?;
220 git::commit("chore(releasing): Generated release CUE file")?;
221 debug!("Generated release CUE file");
222 Ok(())
223 }
224
225 fn update_vector_version(&self, file_path: &Path) -> Result<()> {
227 debug!("update_vector_version for {file_path:?}");
228 let contents = fs::read_to_string(file_path)
229 .map_err(|e| anyhow!("Failed to read {}: {}", file_path.display(), e))?;
230
231 let latest_version = &self.latest_vector_version;
232 let new_version = &self.new_vector_version;
233 let old_version_str = format!("{}.{}", latest_version.major, latest_version.minor);
234 let new_version_str = format!("{}.{}", new_version.major, new_version.minor);
235
236 if !contents.contains(&old_version_str) {
237 return Err(anyhow!("Could not find version {} to update in {}",
238 latest_version, file_path.display()));
239 }
240
241 let updated_contents = contents.replace(&latest_version.to_string(), &new_version.to_string());
242 let updated_contents = updated_contents.replace(&old_version_str, &new_version_str);
243
244 fs::write(file_path, updated_contents)
245 .map_err(|e| anyhow!("Failed to write {}: {}", file_path.display(), e))?;
246 git::commit(&format!(
247 "chore(releasing): Updated {} vector version to {new_version}",
248 file_path.strip_prefix(&self.repo_root).unwrap().display(),
249 ))?;
250
251 Ok(())
252 }
253
254 fn add_new_version_to_versions_cue(&self) -> Result<()> {
256 debug!("add_new_version_to_versions_cue");
257 let cure_reference_path = &self.repo_root.join("website").join("cue").join("reference");
258 let versions_cue_path = cure_reference_path.join("versions.cue");
259 if !versions_cue_path.is_file() {
260 return Err(anyhow!("{versions_cue_path:?} not found"));
261 }
262
263 let vector_version = &self.new_vector_version;
264 let temp_file_path = cure_reference_path.join(format!("{vector_version}.cue.tmp"));
265 let input_file = File::open(&versions_cue_path)?;
266 let reader = BufReader::new(input_file);
267 let mut output_file = File::create(&temp_file_path)?;
268
269 for line in reader.lines() {
270 let line = line?;
271 writeln!(output_file, "{line}")?;
272 if line.contains("versions:") {
273 writeln!(output_file, "\t\"{vector_version}\",")?;
274 }
275 }
276
277 fs::rename(&temp_file_path, &versions_cue_path)?;
278
279 git::commit(&format!("chore(releasing): Add {vector_version} to versions.cue"))?;
280 Ok(())
281 }
282
283 fn create_new_release_md(&self) -> Result<()> {
285 debug!("create_new_release_md");
286 let releases_dir = self.repo_root
287 .join("website")
288 .join("content")
289 .join("en")
290 .join("releases");
291
292 let old_version = &self.latest_vector_version;
293 let new_version = &self.new_vector_version;
294 let old_file_path = releases_dir.join(format!("{old_version}.md"));
295 if !old_file_path.exists() {
296 return Err(anyhow!("Source file not found: {}", old_file_path.display()));
297 }
298
299 let content = fs::read_to_string(&old_file_path)?;
300 let updated_content = content.replace(&old_version.to_string(), &new_version.to_string());
301 let lines: Vec<&str> = updated_content.lines().collect();
302 let mut updated_lines = Vec::new();
303 let mut weight_updated = false;
304
305 for line in lines {
306 if line.trim().starts_with("weight: ") && !weight_updated {
307 let weight_str = line.trim().strip_prefix("weight: ").ok_or_else(|| anyhow!("Invalid weight format"))?;
309 let weight: i32 = weight_str.parse().map_err(|e| anyhow!("Failed to parse weight: {}", e))?;
310 let new_weight = weight + 1;
312 updated_lines.push(format!("weight: {new_weight}"));
313 weight_updated = true;
314 } else {
315 updated_lines.push(line.to_string());
316 }
317 }
318
319 if !weight_updated {
320 error!("Couldn't update 'weight' line from {old_file_path:?}");
321 }
322
323
324 let new_file_path = releases_dir.join(format!("{new_version}.md"));
325 updated_lines.push(String::new()); let updated_content = updated_lines.join("\n");
327 fs::write(&new_file_path, updated_content)?;
328 git::add_files_in_current_dir()?;
329 git::commit("chore(releasing): Created release md file")?;
330 Ok(())
331 }
332
333 fn open_release_pr(&self) -> Result<()> {
335 debug!("open_release_pr");
336 git::push()?;
337
338 let new_vector_version = &self.new_vector_version;
339 let pr_title = format!("chore(releasing): prepare v{new_vector_version} release");
340 let pr_body = format!("This PR prepares the release for Vector v{new_vector_version}");
341 let gh_status = Command::new("gh")
342 .arg("pr")
343 .arg("create")
344 .arg("--draft")
345 .arg("--base")
346 .arg(self.release_branch.as_str())
347 .arg("--head")
348 .arg(self.release_preparation_branch.as_str())
349 .arg("--title")
350 .arg(&pr_title)
351 .arg("--body")
352 .arg(&pr_body)
353 .arg("--label")
354 .arg("no-changelog")
355 .current_dir(&self.repo_root)
356 .status()?;
357 if !gh_status.success() {
358 return Err(anyhow!("Failed to create PR with gh CLI"));
359 }
360 info!("Successfully created PR against {}", self.release_branch);
361 Ok(())
362 }
363
364 fn append_vrl_changelog_to_release_cue(&self) -> Result<()> {
365 debug!("append_vrl_changelog_to_release_cue");
366
367 let releases_path = self.repo_root.join("website/cue/reference/releases");
368 let version = &self.new_vector_version;
369 let cue_path = releases_path.join(format!("{version}.cue"));
370 if !cue_path.is_file() {
371 return Err(anyhow!("{cue_path:?} not found"));
372 }
373
374 let vrl_changelog = get_latest_vrl_tag_and_changelog()?;
375 let vrl_changelog_block = format_vrl_changelog_block(&vrl_changelog);
376
377 let original = fs::read_to_string(&cue_path)?;
378 let updated = insert_block_after_changelog(&original, &vrl_changelog_block);
379
380 let tmp_path = cue_path.with_extension("cue.tmp");
381 fs::write(&tmp_path, &updated)?;
382 fs::rename(&tmp_path, &cue_path)?;
383
384 run_command(&format!("cue fmt {}", cue_path.display()));
385 debug!("Successfully added VRL changelog to the release cue file.");
386 Ok(())
387 }
388}
389
390fn get_repo_root() -> PathBuf {
393 Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().to_path_buf()
394}
395
396fn get_latest_version_from_vector_tags() -> Result<Version> {
397 let tags = run_command("git tag --list --sort=-v:refname");
398 let latest_tag = tags
399 .lines()
400 .find(|tag| tag.starts_with('v') && !tag.starts_with("vdev-v"))
401 .ok_or_else(|| anyhow::anyhow!("Could not find latest Vector release tag"))?;
402
403 let version_str = latest_tag.trim_start_matches('v');
404 Version::parse(version_str)
405 .map_err(|e| anyhow::anyhow!("Failed to parse version from tag '{latest_tag}': {e}"))
406}
407
408fn format_vrl_changelog_block(changelog: &str) -> String {
409 let double_tab = "\t\t";
410 let body = changelog
411 .lines()
412 .map(|line| {
413 let line = line.trim();
414 if line.starts_with('#') {
415 format!("{double_tab}#{line}")
416 } else {
417 format!("{double_tab}{line}")
418 }
419 })
420 .collect::<Vec<_>>()
421 .join("\n");
422
423 let opening = "\tvrl_changelog: \"\"\"";
424 let closing = format!("{double_tab}\"\"\"");
425
426 format!("{opening}\n{body}\n{closing}")
427}
428
429fn insert_block_after_changelog(original: &str, block: &str) -> String {
430 let mut result = Vec::new();
431 let mut inserted = false;
432
433 for line in original.lines() {
434 result.push(line.to_string());
435
436 if !inserted && line.trim() == "]" {
438 result.push(String::new()); result.push(block.to_string());
440 inserted = true;
441 }
442 }
443
444 result.join("\n")
445}
446
447fn get_latest_vrl_tag_and_changelog() -> Result<String> {
448 let client = Client::new();
449
450 let tags_url = "https://api.github.com/repos/vectordotdev/vrl/tags";
452 let tags_response = client
453 .get(tags_url)
454 .header("User-Agent", "rust-reqwest") .send()?
456 .text()?;
457
458 let tags: Vec<Value> = serde_json::from_str(&tags_response)?;
459 let latest_tag = tags.first()
460 .and_then(|tag| tag.get("name"))
461 .and_then(|name| name.as_str())
462 .ok_or_else(|| anyhow!("Failed to extract latest tag"))?
463 .to_string();
464
465 let changelog_url = format!(
467 "https://raw.githubusercontent.com/vectordotdev/vrl/{latest_tag}/CHANGELOG.md",
468 );
469 let changelog = client
470 .get(&changelog_url)
471 .header("User-Agent", "rust-reqwest")
472 .send()?
473 .text()?;
474
475 let lines: Vec<&str> = changelog.lines().collect();
477 let mut section = Vec::new();
478 let mut found_first = false;
479
480 for line in lines {
481 if line.starts_with("## ") {
482 if found_first {
483 section.push(line.to_string());
484 break;
485 }
486 found_first = true;
487 section.push(line.to_string());
488 } else if found_first {
489 section.push(line.to_string());
490 }
491 }
492
493 if !found_first {
494 return Err(anyhow!("No ## headers found in CHANGELOG.md"));
495 }
496
497 Ok(section.join("\n"))
498}
499
500#[cfg(test)]
501mod tests {
502 use crate::commands::release::prepare::{format_vrl_changelog_block, insert_block_after_changelog};
503 use indoc::indoc;
504
505 #[test]
506 fn test_insert_block_after_changelog() {
507 let vrl_changelog = "### [0.2.0]\n- Feature\n- Fix";
508 let vrl_changelog_block = format_vrl_changelog_block(vrl_changelog);
509
510 let expected = concat!(
511 "\tvrl_changelog: \"\"\"\n",
512 "\t\t#### [0.2.0]\n",
513 "\t\t- Feature\n",
514 "\t\t- Fix\n",
515 "\t\t\"\"\""
516 );
517
518 assert_eq!(vrl_changelog_block, expected);
519
520 let original = indoc! {r#"
521 version: "1.2.3"
522 changelog: [
523 {
524 type: "fix"
525 description: "Some fix"
526 },
527 ]
528 "#};
529 let updated = insert_block_after_changelog(original, &vrl_changelog_block);
530
531 let expected_lines_len = 5;
533 let updated_tail: Vec<&str> = updated.lines().rev().take(expected_lines_len).collect::<Vec<_>>().into_iter().rev().collect();
534 let expected_lines: Vec<&str> = vrl_changelog_block.lines().collect();
535 assert_eq!(updated_tail, expected_lines);
536 }
537}