vdev/commands/release/
prepare.rs

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/// Release preparations CLI options.
25#[derive(clap::Args, Debug)]
26#[command()]
27pub struct Cli {
28    /// The new Vector version.
29    #[arg(long)]
30    version: Version,
31    /// The new VRL version.
32    #[arg(long)]
33    vrl_version: Version,
34    /// Optional: The Alpine version to use in `distribution/docker/alpine/Dockerfile`.
35    /// You can find the latest version here: <https://alpinelinux.org/releases/>.
36    #[arg(long)]
37    alpine_version: Option<String>,
38    /// Optional: The Debian version to use in `distribution/docker/debian/Dockerfile`.
39    /// You can find the latest version here: <https://www.debian.org/releases/>.
40    #[arg(long)]
41    debian_version: Option<String>,
42
43    /// Dry run. Enabling this will make it so no PRs will be created and no branches will be pushed upstream.
44    #[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            // Websites containing `website` will also generate website previews.
74            // Caveat is these branches can only contain alphanumeric chars and dashes.
75            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    /// Steps 1 & 2
120    fn create_release_branches(&self) -> Result<()> {
121        debug!("create_release_branches");
122        // Step 1: Create a new release branch
123        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        // Step 2: Create a new release preparation branch
132        //         The branch website contains 'website' to generate vector.dev preview.
133        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    /// Step 3
141    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    /// Step 4 & 5: Update dockerfile versions.
157    /// TODO: investigate if this can be automated.
158    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            // Split into prefix, version, and suffix
183            // E.g. "FROM docker.io/alpine:", "3.21", " AS builder"
184            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            // Rebuild with new version
191            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    // Step 6
209    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    /// Step 7 & 8: Replace old version with the new version.
221    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    /// Step 9: Add new version to `versions.cue`
254    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    /// Step 10: Create a new release md file
285    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                // Extract the current weight value
313                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                // Increase by 1
321                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()); // File should end with a newline.
335        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    /// Final step. Create a release prep PR against the release branch.
343    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
399// FREE FUNCTIONS AFTER THIS LINE
400
401/// Transforms a Cargo.toml string by replacing vrl's git dependency with a version dependency.
402/// Updates the vrl entry in [workspace.dependencies] from git + branch to a version.
403fn 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    // Navigate to workspace.dependencies.vrl
409    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    // Remove git and branch, add version
414    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        // Insert *after* the line containing only the closing `]` (end of changelog array)
450        if !inserted && line.trim() == "]" {
451            result.push(String::new()); // empty line before
452            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    // Step 1: get the latest tag
462    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    // Step 2: fetch CHANGELOG.md for that tag
476    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    // Extract the first release section (from the first ## to the next ##)
495    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        // Assert the last 5 lines match the VRL changelog block
571        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}