vdev/commands/release/
prepare.rs

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/// Release preparations CLI options.
28#[derive(clap::Args, Debug)]
29#[command()]
30pub struct Cli {
31    /// The new Vector version.
32    #[arg(long)]
33    version: Version,
34    /// The new VRL version.
35    #[arg(long)]
36    vrl_version: Version,
37    /// Optional: The Alpine version to use in `distribution/docker/alpine/Dockerfile`.
38    /// You can find the latest version here: <https://alpinelinux.org/releases/>.
39    #[arg(long)]
40    alpine_version: Option<String>,
41    /// Optional: The Debian version to use in `distribution/docker/debian/Dockerfile`.
42    /// You can find the latest version here: <https://www.debian.org/releases/>.
43    #[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            // Websites containing `website` will also generate website previews.
73            // Caveat is these branches can only contain alphanumeric chars and dashes.
74            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    /// Steps 1 & 2
111    fn create_release_branches(&self) -> Result<()> {
112        debug!("create_release_branches");
113        // Step 1: Create a new release branch
114        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        // Step 2: Create a new release preparation branch
121        //         The branch website contains 'website' to generate vector.dev preview.
122        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    /// Step 3
128    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        // Needs this hybrid approach to preserve ordering.
134        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()); // File should end with a newline.
154        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    /// Step 4 & 5: Update dockerfile versions.
161    /// TODO: investigate if this can be automated.
162    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            // Split into prefix, version, and suffix
184            // E.g. "FROM docker.io/alpine:", "3.21", " AS builder"
185            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            // Rebuild with new version
192            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    // Step 6
208    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    /// Step 7 & 8: Replace old version with the new version.
226    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    /// Step 9: Add new version to `versions.cue`
255    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    /// Step 10: Create a new release md file
284    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                // Extract the current weight value
308                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                // Increase by 1
311                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()); // File should end with a newline.
326        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    /// Final step. Create a release prep PR against the release branch.
334    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
390// FREE FUNCTIONS AFTER THIS LINE
391
392fn 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        // Insert *after* the line containing only the closing `]` (end of changelog array)
437        if !inserted && line.trim() == "]" {
438            result.push(String::new()); // empty line before
439            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    // Step 1: Get latest tag from GitHub API
451    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")  // GitHub API requires User-Agent
455        .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    // Step 2: Download CHANGELOG.md for the specific tag
466    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    // Step 3: Extract text from first ## to next ##
476    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        // Assert the last 5 lines match the VRL changelog block
532        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}