1#![allow(clippy::print_stdout)]
2#![allow(clippy::print_stderr)]
3
4use crate::commands::release::generate_cue;
5use crate::utils::{command::run_command, git, paths};
6use anyhow::{Context, Result, anyhow, bail};
7use semver::Version;
8use std::{
9 env, fs,
10 fs::File,
11 io::{BufRead, BufReader, Write},
12 path::{Path, PathBuf},
13 process::Command,
14};
15use toml_edit::DocumentMut;
16
17const ALPINE_PREFIX: &str = "FROM docker.io/alpine:";
18const ALPINE_DOCKERFILE: &str = "distribution/docker/alpine/Dockerfile";
19const DEBIAN_PREFIX: &str = "FROM docker.io/debian:";
20const DEBIAN_DOCKERFILE: &str = "distribution/docker/debian/Dockerfile";
21const KUBECLT_CUE_FILE: &str = "website/cue/reference/administration/interfaces/kubectl.cue";
22const INSTALL_SCRIPT: &str = "distribution/install.sh";
23
24#[derive(clap::Args, Debug)]
26#[command()]
27pub struct Cli {
28 #[arg(long)]
30 version: Version,
31 #[arg(long)]
33 vrl_version: Version,
34 #[arg(long)]
37 alpine_version: Option<String>,
38 #[arg(long)]
41 debian_version: Option<String>,
42
43 #[arg(long, default_value_t = false)]
45 dry_run: bool,
46}
47
48struct Prepare {
49 new_vector_version: Version,
50 vrl_version: Version,
51 alpine_version: Option<String>,
52 debian_version: Option<String>,
53 repo_root: PathBuf,
54 latest_vector_version: Version,
55 release_branch: String,
56 release_preparation_branch: String,
57 dry_run: bool,
58}
59
60impl Cli {
61 pub fn exec(self) -> Result<()> {
62 let repo_root = paths::find_repo_root()?;
63 env::set_current_dir(&repo_root)?;
64
65 let prepare = Prepare {
66 new_vector_version: self.version.clone(),
67 vrl_version: self.vrl_version,
68 alpine_version: self.alpine_version,
69 debian_version: self.debian_version,
70 repo_root,
71 latest_vector_version: generate_cue::find_latest_release_tag()?,
72 release_branch: format!("v{}.{}", self.version.major, self.version.minor),
73 release_preparation_branch: format!(
76 "prepare-v-{}-{}-{}-website",
77 self.version.major, self.version.minor, self.version.patch
78 ),
79 dry_run: self.dry_run,
80 };
81 prepare.run()
82 }
83}
84
85impl Prepare {
86 pub fn run(&self) -> Result<()> {
87 debug!("run");
88 self.create_release_branches()?;
89 self.pin_vrl_version()?;
90
91 self.update_dockerfile_base_version(
92 &self.repo_root.join(ALPINE_DOCKERFILE),
93 self.alpine_version.as_deref(),
94 ALPINE_PREFIX,
95 )?;
96
97 self.update_dockerfile_base_version(
98 &self.repo_root.join(DEBIAN_DOCKERFILE),
99 self.debian_version.as_deref(),
100 DEBIAN_PREFIX,
101 )?;
102
103 self.generate_release_cue()?;
104
105 self.update_vector_version(&self.repo_root.join(KUBECLT_CUE_FILE))?;
106 self.update_vector_version(&self.repo_root.join(INSTALL_SCRIPT))?;
107
108 self.add_new_version_to_versions_cue()?;
109
110 self.create_new_release_md()?;
111
112 if !self.dry_run {
113 self.open_release_pr()?;
114 }
115
116 Ok(())
117 }
118
119 fn create_release_branches(&self) -> Result<()> {
121 debug!("create_release_branches");
122 git::run_and_check_output(&["fetch"])?;
124 git::checkout_main_branch()?;
125
126 git::checkout_or_create_branch(self.release_branch.as_str())?;
127 if !self.dry_run {
128 git::push_and_set_upstream(self.release_branch.as_str())?;
129 }
130
131 git::checkout_or_create_branch(self.release_preparation_branch.as_str())?;
134 if !self.dry_run {
135 git::push_and_set_upstream(self.release_preparation_branch.as_str())?;
136 }
137 Ok(())
138 }
139
140 fn pin_vrl_version(&self) -> Result<()> {
142 debug!("pin_vrl_version");
143 let cargo_toml_path = &self.repo_root.join("Cargo.toml");
144 let contents = fs::read_to_string(cargo_toml_path).context("Failed to read Cargo.toml")?;
145 let vrl_version = self.vrl_version.to_string();
146 let updated_contents = update_vrl_to_version(&contents, &vrl_version)?;
147
148 fs::write(cargo_toml_path, updated_contents).context("Failed to write Cargo.toml")?;
149 run_command("cargo update -p vrl");
150 git::commit(&format!(
151 "chore(releasing): Pinned VRL version to {vrl_version}"
152 ))?;
153 Ok(())
154 }
155
156 fn update_dockerfile_base_version(
159 &self,
160 dockerfile_path: &Path,
161 new_version: Option<&str>,
162 prefix: &str,
163 ) -> Result<()> {
164 debug!(
165 "update_dockerfile_base_version for {}",
166 dockerfile_path.display()
167 );
168 if let Some(version) = new_version {
169 let contents = fs::read_to_string(dockerfile_path)?;
170
171 if !contents.starts_with(prefix) {
172 return Err(anyhow::anyhow!(
173 "Dockerfile at {} does not start with {prefix}",
174 dockerfile_path.display()
175 ));
176 }
177
178 let mut lines = contents.lines();
179 let first_line = lines.next().expect("File should have at least one line");
180 let rest = lines.collect::<Vec<&str>>().join("\n");
181
182 let after_prefix = first_line.strip_prefix(prefix).ok_or_else(|| {
185 anyhow!("Failed to strip prefix in {}", dockerfile_path.display())
186 })?;
187 let parts: Vec<&str> = after_prefix.splitn(2, ' ').collect();
188 let suffix = parts.get(1).unwrap_or(&"");
189
190 let updated_version_line = format!("{prefix}{version} {suffix}");
192 let new_contents = format!("{updated_version_line}\n{rest}");
193
194 fs::write(dockerfile_path, &new_contents)?;
195 git::commit(&format!(
196 "chore(releasing): Bump {} version to {version}",
197 dockerfile_path
198 .strip_prefix(&self.repo_root)
199 .unwrap()
200 .display(),
201 ))?;
202 } else {
203 debug!("No version specified for {dockerfile_path:?}; skipping update");
204 }
205 Ok(())
206 }
207
208 fn generate_release_cue(&self) -> Result<()> {
210 debug!("generate_release_cue");
211 generate_cue::run(&self.new_vector_version)?;
212
213 self.append_vrl_changelog_to_release_cue()?;
214 git::add_files_in_current_dir()?;
215 git::commit("chore(releasing): Generated release CUE file")?;
216 debug!("Generated release CUE file");
217 Ok(())
218 }
219
220 fn update_vector_version(&self, file_path: &Path) -> Result<()> {
222 debug!("update_vector_version for {file_path:?}");
223 let contents = fs::read_to_string(file_path)
224 .map_err(|e| anyhow!("Failed to read {}: {}", file_path.display(), e))?;
225
226 let latest_version = &self.latest_vector_version;
227 let new_version = &self.new_vector_version;
228 let old_version_str = format!("{}.{}", latest_version.major, latest_version.minor);
229 let new_version_str = format!("{}.{}", new_version.major, new_version.minor);
230
231 if !contents.contains(&old_version_str) {
232 return Err(anyhow!(
233 "Could not find version {} to update in {}",
234 latest_version,
235 file_path.display()
236 ));
237 }
238
239 let updated_contents =
240 contents.replace(&latest_version.to_string(), &new_version.to_string());
241 let updated_contents = updated_contents.replace(&old_version_str, &new_version_str);
242
243 fs::write(file_path, updated_contents)
244 .map_err(|e| anyhow!("Failed to write {}: {}", file_path.display(), e))?;
245 git::commit(&format!(
246 "chore(releasing): Updated {} vector version to {new_version}",
247 file_path.strip_prefix(&self.repo_root).unwrap().display(),
248 ))?;
249
250 Ok(())
251 }
252
253 fn add_new_version_to_versions_cue(&self) -> Result<()> {
255 debug!("add_new_version_to_versions_cue");
256 let cure_reference_path = &self.repo_root.join("website").join("cue").join("reference");
257 let versions_cue_path = cure_reference_path.join("versions.cue");
258 if !versions_cue_path.is_file() {
259 return Err(anyhow!("{} not found", versions_cue_path.display()));
260 }
261
262 let vector_version = &self.new_vector_version;
263 let temp_file_path = cure_reference_path.join(format!("{vector_version}.cue.tmp"));
264 let input_file = File::open(&versions_cue_path)?;
265 let reader = BufReader::new(input_file);
266 let mut output_file = File::create(&temp_file_path)?;
267
268 for line in reader.lines() {
269 let line = line?;
270 writeln!(output_file, "{line}")?;
271 if line.contains("versions:") {
272 writeln!(output_file, "\t\"{vector_version}\",")?;
273 }
274 }
275
276 fs::rename(&temp_file_path, &versions_cue_path)?;
277
278 git::commit(&format!(
279 "chore(releasing): Add {vector_version} to versions.cue"
280 ))?;
281 Ok(())
282 }
283
284 fn create_new_release_md(&self) -> Result<()> {
286 debug!("create_new_release_md");
287 let releases_dir = self
288 .repo_root
289 .join("website")
290 .join("content")
291 .join("en")
292 .join("releases");
293
294 let old_version = &self.latest_vector_version;
295 let new_version = &self.new_vector_version;
296 let old_file_path = releases_dir.join(format!("{old_version}.md"));
297 if !old_file_path.exists() {
298 return Err(anyhow!(
299 "Source file not found: {}",
300 old_file_path.display()
301 ));
302 }
303
304 let content = fs::read_to_string(&old_file_path)?;
305 let updated_content = content.replace(&old_version.to_string(), &new_version.to_string());
306 let lines: Vec<&str> = updated_content.lines().collect();
307 let mut updated_lines = Vec::new();
308 let mut weight_updated = false;
309
310 for line in lines {
311 if line.trim().starts_with("weight: ") && !weight_updated {
312 let weight_str = line
314 .trim()
315 .strip_prefix("weight: ")
316 .ok_or_else(|| anyhow!("Invalid weight format"))?;
317 let weight: i32 = weight_str
318 .parse()
319 .map_err(|e| anyhow!("Failed to parse weight: {e}"))?;
320 let new_weight = weight + 1;
322 updated_lines.push(format!("weight: {new_weight}"));
323 weight_updated = true;
324 } else {
325 updated_lines.push(line.to_string());
326 }
327 }
328
329 if !weight_updated {
330 error!("Couldn't update 'weight' line from {old_file_path:?}");
331 }
332
333 let new_file_path = releases_dir.join(format!("{new_version}.md"));
334 updated_lines.push(String::new()); let updated_content = updated_lines.join("\n");
336 fs::write(&new_file_path, updated_content)?;
337 git::add_files_in_current_dir()?;
338 git::commit("chore(releasing): Created release md file")?;
339 Ok(())
340 }
341
342 fn open_release_pr(&self) -> Result<()> {
344 debug!("open_release_pr");
345 git::push()?;
346
347 let new_vector_version = &self.new_vector_version;
348 let pr_title = format!("chore(releasing): prepare v{new_vector_version} release");
349 let pr_body = format!("This PR prepares the release for Vector v{new_vector_version}");
350 let gh_status = Command::new("gh")
351 .arg("pr")
352 .arg("create")
353 .arg("--draft")
354 .arg("--base")
355 .arg(self.release_branch.as_str())
356 .arg("--head")
357 .arg(self.release_preparation_branch.as_str())
358 .arg("--title")
359 .arg(&pr_title)
360 .arg("--body")
361 .arg(&pr_body)
362 .arg("--label")
363 .arg("no-changelog")
364 .current_dir(&self.repo_root)
365 .status()?;
366 if !gh_status.success() {
367 return Err(anyhow!("Failed to create PR with gh CLI"));
368 }
369 info!("Successfully created PR against {}", self.release_branch);
370 Ok(())
371 }
372
373 fn append_vrl_changelog_to_release_cue(&self) -> Result<()> {
374 debug!("append_vrl_changelog_to_release_cue");
375
376 let releases_path = self.repo_root.join("website/cue/reference/releases");
377 let version = &self.new_vector_version;
378 let cue_path = releases_path.join(format!("{version}.cue"));
379 if !cue_path.is_file() {
380 return Err(anyhow!("{} not found", cue_path.display()));
381 }
382
383 let vrl_changelog = get_latest_vrl_tag_and_changelog()?;
384 let vrl_changelog_block = format_vrl_changelog_block(&vrl_changelog);
385
386 let original = fs::read_to_string(&cue_path)?;
387 let updated = insert_block_after_changelog(&original, &vrl_changelog_block);
388
389 let tmp_path = cue_path.with_extension("cue.tmp");
390 fs::write(&tmp_path, &updated)?;
391 fs::rename(&tmp_path, &cue_path)?;
392
393 run_command(&format!("cue fmt {}", cue_path.display()));
394 debug!("Successfully added VRL changelog to the release cue file.");
395 Ok(())
396 }
397}
398
399fn update_vrl_to_version(cargo_toml_contents: &str, vrl_version: &str) -> Result<String> {
404 let mut doc = cargo_toml_contents
405 .parse::<DocumentMut>()
406 .context("Failed to parse Cargo.toml")?;
407
408 let vrl_table = doc["workspace"]["dependencies"]["vrl"]
410 .as_inline_table_mut()
411 .context("vrl in workspace.dependencies should be an inline table")?;
412
413 vrl_table.remove("git");
415 vrl_table.remove("branch");
416 vrl_table.insert("version", vrl_version.into());
417
418 Ok(doc.to_string())
419}
420
421fn format_vrl_changelog_block(changelog: &str) -> String {
422 let double_tab = "\t\t";
423 let body = changelog
424 .lines()
425 .map(|line| {
426 let line = line.trim();
427 if line.starts_with('#') {
428 format!("{double_tab}#{line}")
429 } else {
430 format!("{double_tab}{line}")
431 }
432 })
433 .collect::<Vec<_>>()
434 .join("\n");
435
436 let opening = "\tvrl_changelog: #\"\"\"";
437 let closing = format!("{double_tab}\"\"\"#");
438
439 format!("{opening}\n{body}\n{closing}")
440}
441
442fn insert_block_after_changelog(original: &str, block: &str) -> String {
443 let mut result = Vec::new();
444 let mut inserted = false;
445
446 for line in original.lines() {
447 result.push(line.to_string());
448
449 if !inserted && line.trim() == "]" {
451 result.push(String::new()); result.push(block.to_string());
453 inserted = true;
454 }
455 }
456
457 result.join("\n")
458}
459
460fn get_latest_vrl_tag_and_changelog() -> Result<String> {
461 let tag_output = Command::new("gh")
463 .args(["api", "repos/vectordotdev/vrl/tags", "--jq", ".[0].name"])
464 .output()
465 .context("Failed to run `gh api` for VRL tags")?;
466
467 if !tag_output.status.success() {
468 let stderr = String::from_utf8_lossy(&tag_output.stderr);
469 bail!("gh api tags failed: {stderr}");
470 }
471
472 let tag = String::from_utf8(tag_output.stdout).context("gh api output is not valid UTF-8")?;
473 let tag = tag.trim().to_string();
474
475 let changelog_output = Command::new("gh")
477 .args([
478 "api",
479 &format!("repos/vectordotdev/vrl/contents/CHANGELOG.md?ref={tag}"),
480 "-H",
481 "Accept: application/vnd.github.raw+json",
482 ])
483 .output()
484 .context("Failed to run `gh api` for VRL CHANGELOG.md")?;
485
486 if !changelog_output.status.success() {
487 let stderr = String::from_utf8_lossy(&changelog_output.stderr);
488 bail!("gh api CHANGELOG.md failed: {stderr}");
489 }
490
491 let changelog =
492 String::from_utf8(changelog_output.stdout).context("CHANGELOG.md is not valid UTF-8")?;
493
494 let mut section = Vec::new();
496 let mut found_first = false;
497 for line in changelog.lines() {
498 if line.starts_with("## ") {
499 if found_first {
500 break;
501 }
502 found_first = true;
503 }
504 if found_first {
505 section.push(line);
506 }
507 }
508
509 if !found_first {
510 bail!("No ## headers found in VRL CHANGELOG.md");
511 }
512
513 Ok(section.join("\n"))
514}
515
516#[cfg(test)]
517mod tests {
518 use crate::commands::release::prepare::{
519 format_vrl_changelog_block, insert_block_after_changelog, update_vrl_to_version,
520 };
521 use indoc::indoc;
522
523 #[test]
524 fn test_update_vrl_to_version() {
525 let input = indoc! {r#"
526 [workspace.dependencies]
527 some-other-dep = "1.0.0"
528 vrl = { git = "https://github.com/vectordotdev/vrl.git", branch = "main", features = ["arbitrary", "cli", "test", "test_framework"] }
529 another-dep = "2.0.0"
530 "#};
531
532 let result = update_vrl_to_version(input, "0.28.0").expect("should succeed");
533
534 let expected = indoc! {r#"
535 [workspace.dependencies]
536 some-other-dep = "1.0.0"
537 vrl = { features = ["arbitrary", "cli", "test", "test_framework"] , version = "0.28.0" }
538 another-dep = "2.0.0"
539 "#};
540
541 assert_eq!(result, expected);
542 }
543
544 #[test]
545 fn test_insert_block_after_changelog() {
546 let vrl_changelog = "### [0.2.0]\n- Feature\n- Fix";
547 let vrl_changelog_block = format_vrl_changelog_block(vrl_changelog);
548
549 let expected = concat!(
550 "\tvrl_changelog: #\"\"\"\n",
551 "\t\t#### [0.2.0]\n",
552 "\t\t- Feature\n",
553 "\t\t- Fix\n",
554 "\t\t\"\"\"#"
555 );
556
557 assert_eq!(vrl_changelog_block, expected);
558
559 let original = indoc! {r#"
560 version: "1.2.3"
561 changelog: [
562 {
563 type: "fix"
564 description: "Some fix"
565 },
566 ]
567 "#};
568 let updated = insert_block_after_changelog(original, &vrl_changelog_block);
569
570 let expected_lines_len = 5;
572 let updated_tail: Vec<&str> = updated
573 .lines()
574 .rev()
575 .take(expected_lines_len)
576 .collect::<Vec<_>>()
577 .into_iter()
578 .rev()
579 .collect();
580 let expected_lines: Vec<&str> = vrl_changelog_block.lines().collect();
581 assert_eq!(updated_tail, expected_lines);
582 }
583}