From c37dbdcc14107db26983a74f91d33439a3d393a4 Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Tue, 2 Sep 2025 10:55:34 -0400 Subject: [PATCH 01/19] chore(ci): move changelog fragment validation to vdev/Rust --- .../src/commands/check/changelog_fragments.rs | 173 ++++++++++++++++++ vdev/src/commands/check/mod.rs | 5 +- vdev/src/commands/release/prepare.rs | 5 +- vdev/src/git.rs | 27 ++- vdev/src/main.rs | 1 + vdev/src/path_utils.rs | 10 + 6 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 vdev/src/commands/check/changelog_fragments.rs create mode 100644 vdev/src/path_utils.rs diff --git a/vdev/src/commands/check/changelog_fragments.rs b/vdev/src/commands/check/changelog_fragments.rs new file mode 100644 index 0000000000000..b9825826a68ae --- /dev/null +++ b/vdev/src/commands/check/changelog_fragments.rs @@ -0,0 +1,173 @@ +use crate::git::added_files_against_merge_base; +use crate::path_utils::get_changelog_dir; +use anyhow::{anyhow, Context, Result}; +use std::env; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +const FRAGMENT_TYPES: &[&str] = &[ + "breaking", + "security", + "deprecation", + "feature", + "enhancement", + "fix", +]; + +const DEFAULT_MAX_FRAGMENTS: usize = 1000; + +/// Validate changelog fragments added in this branch/PR. +#[derive(clap::Args, Debug)] +#[command()] +pub struct Cli { + /// Changelog directory (defaults to $CHANGELOG_DIR or "changelog.d") + #[arg(long)] + changelog_dir: Option, + + /// Merge base to diff against (defaults to $MERGE_BASE or "origin/master") + #[arg(long)] + merge_base: Option, + + /// Max fragments threshold (defaults to $MAX_FRAGMENTS or 1000) + #[arg(long)] + max_fragments: Option, +} + +impl Cli { + pub fn exec(&self) -> Result<()> { + let changelog_dir = self + .changelog_dir.clone().unwrap_or_else(|| get_changelog_dir()); + + if !changelog_dir.is_dir() { + error!( + "No ./{} found. This tool must be invoked from the root of the repo.", + changelog_dir.display() + ); + std::process::exit(1); + } + + let merge_base = self + .merge_base.clone() + .or_else(|| env::var("MERGE_BASE").ok()) + .unwrap_or_else(|| "origin/master".to_string()); + + let max_fragments: usize = self + .max_fragments + .or_else(|| env::var("MAX_FRAGMENTS").ok().and_then(|s| s.parse().ok())) + .unwrap_or(DEFAULT_MAX_FRAGMENTS); + + let fragments = added_files_against_merge_base(&merge_base, &changelog_dir) + .context("failed to collect added changelog fragments")?; + + if fragments.is_empty() { + info!("No changelog fragments detected"); + info!("If no changes necessitate user-facing explanations, add the GH label 'no-changelog'"); + info!("Otherwise, add changelog fragments to {}/", changelog_dir.display()); + info!("For details, see '{}/README.md'", changelog_dir.display()); + std::process::exit(1); + } + + if fragments.len() > max_fragments { + error!("Too many changelog fragments ({} > {}).", fragments.len(), max_fragments); + std::process::exit(1); + } + + for path in fragments { + if let Some(name) = path.file_name().and_then(OsStr::to_str) { + if name == "README.md" { + continue; + } + info!("validating '{}'", name); + self.validate_fragment_filename(name)?; + self.validate_fragment_contents(&changelog_dir.join(name), name)?; + } else { + return Err(anyhow!("unexpected path (no filename): {}", path.display())); + } + } + + info!("changelog additions are valid."); + Ok(()) + } + + fn validate_fragment_filename(&self, fname: &str) -> Result<()> { + // Expected: ..md + let parts: Vec<&str> = fname.split('.').collect(); + if parts.len() != 3 { + return Err(anyhow!( + "invalid fragment filename: wrong number of period delimiters. \ + expected '..md'. ({})", + fname + )); + } + + let fragment_type = parts[1]; + if !FRAGMENT_TYPES.contains(&fragment_type) { + return Err(anyhow!( + "invalid fragment filename: fragment type must be one of: ({}). ({})", + FRAGMENT_TYPES.join("|"), + fname + )); + } + + if parts[2] != "md" { + return Err(anyhow!( + "invalid fragment filename: extension must be markdown (.md): ({})", + fname + )); + } + + Ok(()) + } + + fn validate_fragment_contents(&self, path: &Path, fname: &str) -> Result<()> { + let content = std::fs::read_to_string(path).with_context(|| { + format!( + "failed to read fragment file: {}", + path.to_string_lossy() + ) + })?; + + // Emulate `tail -n 1` (last line; may be empty if file ends with newline) + let last_line = content + .rsplit_once('\n') + .map(|(_, tail)| tail) + .or_else(|| content.lines().last()) + .unwrap_or(&content); // single-line file without newline + + // Accept either "author:" or "authors:" (case-sensitive), followed by one-or-more non-space + // Example valid lines: + // authors: Alice Bob + // author: Alice + let trimmed = last_line.trim_end_matches(['\r', '\n']); + if !(trimmed.starts_with("authors:") || trimmed.starts_with("author:")) { + return Err(anyhow!( + "invalid fragment contents: last line must start with 'author: ' or 'authors: ' and include at least one name. ({})", + fname + )); + } + + // Split on the first colon, take the remainder as names + let names = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim(); + + if names.is_empty() { + return Err(anyhow!( + "invalid fragment contents: author line is empty. ({})", + fname + )); + } + + if names.contains('@') { + return Err(anyhow!( + "invalid fragment contents: author should not be prefixed with @" + )); + } + + if names.contains(',') { + return Err(anyhow!( + "invalid fragment contents: authors should be space delimited, not comma delimited." + )); + } + + Ok(()) + } +} diff --git a/vdev/src/commands/check/mod.rs b/vdev/src/commands/check/mod.rs index 78cb62671881b..795ef8790636a 100644 --- a/vdev/src/commands/check/mod.rs +++ b/vdev/src/commands/check/mod.rs @@ -1,10 +1,11 @@ crate::cli_subcommands! { "Check parts of the Vector code base..." + docs, + events, + mod changelog_fragments, mod component_docs, mod component_features, mod deny, - docs, - events, mod examples, mod fmt, mod licenses, diff --git a/vdev/src/commands/release/prepare.rs b/vdev/src/commands/release/prepare.rs index 5b333e25ab438..877e2251ebf08 100644 --- a/vdev/src/commands/release/prepare.rs +++ b/vdev/src/commands/release/prepare.rs @@ -2,6 +2,7 @@ #![allow(clippy::print_stderr)] use crate::git; +use crate::path_utils::get_repo_root; use crate::util::run_command; use anyhow::{anyhow, Result}; use reqwest::blocking::Client; @@ -389,10 +390,6 @@ impl Prepare { // FREE FUNCTIONS AFTER THIS LINE -fn get_repo_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().to_path_buf() -} - fn get_latest_version_from_vector_tags() -> Result { let tags = run_command("git tag --list --sort=-v:refname"); let latest_tag = tags diff --git a/vdev/src/git.rs b/vdev/src/git.rs index d4804674f1aa2..8780cf35c1616 100644 --- a/vdev/src/git.rs +++ b/vdev/src/git.rs @@ -1,6 +1,7 @@ use crate::app::CommandExt as _; -use anyhow::{Result, anyhow, bail}; +use anyhow::{anyhow, bail, Result}; use git2::{BranchType, ErrorCode, Repository}; +use std::path::{Path, PathBuf}; use std::{collections::HashSet, process::Command}; pub fn current_branch() -> Result { @@ -67,6 +68,30 @@ pub fn changed_files() -> Result> { Ok(sorted) } +/// Equivalent to: +/// git diff --name-only --diff-filter=A --merge-base "${MERGE_BASE:-origin/master}" dir +pub fn added_files_against_merge_base(merge_base: &str, dir: &Path) -> Result> { + // `run_and_check_output` takes &[&str], so stringify the path first. + let dir_s = dir.to_string_lossy().into_owned(); + + let stdout = run_and_check_output(&[ + "diff", + "--name-only", + "--diff-filter=A", + "--merge-base", + merge_base, + &dir_s, + ])?; + + let paths = stdout + .lines() + .filter(|s| !s.trim().is_empty()) + .map(PathBuf::from) + .collect::>(); + + Ok(paths) +} + pub fn list_files() -> Result> { Ok(run_and_check_output(&["ls-files"])? .lines() diff --git a/vdev/src/main.rs b/vdev/src/main.rs index 1c0e693afdb60..c1b05f876fc9f 100644 --- a/vdev/src/main.rs +++ b/vdev/src/main.rs @@ -17,6 +17,7 @@ mod git; mod platform; mod testing; mod util; +mod path_utils; use anyhow::Result; use clap::Parser; diff --git a/vdev/src/path_utils.rs b/vdev/src/path_utils.rs new file mode 100644 index 0000000000000..7cf9f22afd82a --- /dev/null +++ b/vdev/src/path_utils.rs @@ -0,0 +1,10 @@ +use std::path::{Path, PathBuf}; + +pub fn get_repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().to_path_buf() +} + +pub fn get_changelog_dir() -> PathBuf { + let root = get_repo_root(); + root.join("changelog.d") +} From 3c438932fc298fa78b46fae5b0167877eb89ed5a Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Tue, 2 Sep 2025 11:05:05 -0400 Subject: [PATCH 02/19] update workflow --- .github/workflows/changelog.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index 9c6fe296d9733..d40bc20ff7807 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -49,6 +49,7 @@ jobs: sparse-checkout: | scripts/check_changelog_fragments.sh changelog.d/ + vdev/ sparse-checkout-cone-mode: false # Checkout PR's changelog.d/ into tmp/ @@ -68,4 +69,4 @@ jobs: # Add files and then compare with HEAD instead of origin/master git add changelog.d/ - MERGE_BASE=HEAD ./scripts/check_changelog_fragments.sh + MERGE_BASE=HEAD cd vdev && cargo run -- check changelog-fragments From 12f57310f574bf366f5e48e1438471bbe52aedc2 Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Tue, 2 Sep 2025 11:05:19 -0400 Subject: [PATCH 03/19] ran cargo fmt --- vdev/src/git.rs | 2 +- vdev/src/main.rs | 2 +- vdev/src/path_utils.rs | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/vdev/src/git.rs b/vdev/src/git.rs index 8780cf35c1616..fe3ce2d3f2e1f 100644 --- a/vdev/src/git.rs +++ b/vdev/src/git.rs @@ -1,5 +1,5 @@ use crate::app::CommandExt as _; -use anyhow::{anyhow, bail, Result}; +use anyhow::{Result, anyhow, bail}; use git2::{BranchType, ErrorCode, Repository}; use std::path::{Path, PathBuf}; use std::{collections::HashSet, process::Command}; diff --git a/vdev/src/main.rs b/vdev/src/main.rs index c1b05f876fc9f..50a99230bd2ea 100644 --- a/vdev/src/main.rs +++ b/vdev/src/main.rs @@ -14,10 +14,10 @@ mod config; mod environment; mod features; mod git; +mod path_utils; mod platform; mod testing; mod util; -mod path_utils; use anyhow::Result; use clap::Parser; diff --git a/vdev/src/path_utils.rs b/vdev/src/path_utils.rs index 7cf9f22afd82a..19d849f51645a 100644 --- a/vdev/src/path_utils.rs +++ b/vdev/src/path_utils.rs @@ -1,7 +1,10 @@ use std::path::{Path, PathBuf}; pub fn get_repo_root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().to_path_buf() + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .to_path_buf() } pub fn get_changelog_dir() -> PathBuf { From 8dd8ed441fc19b88820bab5978eb4b0b445d6889 Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Tue, 2 Sep 2025 11:06:34 -0400 Subject: [PATCH 04/19] rmv bash script --- scripts/check_changelog_fragments.sh | 76 ---------------------------- 1 file changed, 76 deletions(-) delete mode 100755 scripts/check_changelog_fragments.sh diff --git a/scripts/check_changelog_fragments.sh b/scripts/check_changelog_fragments.sh deleted file mode 100755 index 916ebe88dd53b..0000000000000 --- a/scripts/check_changelog_fragments.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env bash - -# This script is intended to run during CI, however it can be run locally by -# committing changelog fragments before executing the script. If the script -# finds an issue with your changelog fragment, you can un-stage the fragment -# from being committed and fix the issue. - -CHANGELOG_DIR="changelog.d" - -# NOTE: If these are altered, update both the 'changelog.d/README.md' and -# 'scripts/generate-release-cue.rb' accordingly. -FRAGMENT_TYPES="breaking|security|deprecation|feature|enhancement|fix" - -if [ ! -d "${CHANGELOG_DIR}" ]; then - echo "No ./${CHANGELOG_DIR} found. This tool must be invoked from the root of the repo." - exit 1 -fi - -# diff-filter=A lists only added files -FRAGMENTS=$(git diff --name-only --diff-filter=A --merge-base "${MERGE_BASE:-origin/master}" ${CHANGELOG_DIR}) - -if [ -z "$FRAGMENTS" ]; then - echo "No changelog fragments detected" - echo "If no changes necessitate user-facing explanations, add the GH label 'no-changelog'" - echo "Otherwise, add changelog fragments to changelog.d/" - echo "For details, see 'changelog.d/README.md'" - exit 1 -fi - -[[ "$(wc -l <<< "$FRAGMENTS")" -gt "${MAX_FRAGMENTS:-1000}" ]] && exit 1 - -# extract the basename from the file path -FRAGMENTS=$(xargs -n1 basename <<< "${FRAGMENTS}") - -# validate the fragments -while IFS= read -r fname; do - - if [[ ${fname} == "README.md" ]]; then - continue - fi - - echo "validating '${fname}'" - - IFS="." read -r -a arr <<< "${fname}" - - if [ "${#arr[@]}" -ne 3 ]; then - echo "invalid fragment filename: wrong number of period delimiters. expected '..md'. (${fname})" - exit 1 - fi - - if ! [[ "${arr[1]}" =~ ^(${FRAGMENT_TYPES})$ ]]; then - echo "invalid fragment filename: fragment type must be one of: (${FRAGMENT_TYPES}). (${fname})" - exit 1 - fi - - if [[ "${arr[2]}" != "md" ]]; then - echo "invalid fragment filename: extension must be markdown (.md): (${fname})" - exit 1 - fi - - # Each fragment should have a properly formatted authors line at the end of the file. - last=$( tail -n 1 "${CHANGELOG_DIR}/${fname}" ) - if [[ "${last}" == "authors: "*@* ]]; then - echo "invalid fragment contents: author should not be prefixed with @" - exit 1 - elif [[ "${last}" == "authors: "*,* ]]; then - echo "invalid fragment contents: authors should be space delimited, not comma delimited." - exit 1 - elif ! [[ "${last}" =~ ^(authors: .*)$ ]]; then - echo "invalid fragment contents: author option was specified but fragment ${fname} contains no authors." - exit 1 - fi - -done <<< "$FRAGMENTS" - -echo "changelog additions are valid." From 8a0c41e7db42463148ed2a5a564ba140172898df Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Tue, 2 Sep 2025 11:11:36 -0400 Subject: [PATCH 05/19] replace mentions of old script --- .github/workflows/changelog.yaml | 1 - CONTRIBUTING.md | 3 +-- Makefile | 5 +++++ changelog.d/README.md | 2 +- scripts/generate-release-cue.rb | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index d40bc20ff7807..75ee7d6007a19 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -47,7 +47,6 @@ jobs: with: ref: master sparse-checkout: | - scripts/check_changelog_fragments.sh changelog.d/ vdev/ sparse-checkout-cone-mode: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eee956d472f88..365c7841800ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -137,6 +137,7 @@ make check-licenses make check-fmt make check-clippy make check-component-docs +make check-changelog-fragments # Some other checks that in our experience rarely fail on PRs. make check-deny @@ -145,8 +146,6 @@ make check-version make check-examples make check-scripts -./scripts/check_changelog_fragments.sh - # The following check is very slow. # make check-component-features ``` diff --git a/Makefile b/Makefile index 8830387d5c218..be7d6fcb1c635 100644 --- a/Makefile +++ b/Makefile @@ -462,6 +462,11 @@ check-all: check-scripts check-deny check-component-docs check-licenses check-component-features: ## Check that all component features are setup properly ${MAYBE_ENVIRONMENT_EXEC} cargo vdev check component-features +.PHONY: check-changelog-fragments +check-changelog-fragments: ## Check that all component features are setup properly + ${MAYBE_ENVIRONMENT_EXEC} cargo vdev check changelog-fragments + + .PHONY: check-clippy check-clippy: ## Check code with Clippy ${MAYBE_ENVIRONMENT_EXEC} cargo vdev check rust diff --git a/changelog.d/README.md b/changelog.d/README.md index 100807e383c28..6c462818cb47b 100644 --- a/changelog.d/README.md +++ b/changelog.d/README.md @@ -23,7 +23,7 @@ This is enforced during CI. To mark a PR as not requiring user-facing changelog notes, add the label 'no-changelog'. To run the same check that is run in CI to validate that your changelog fragments have -the correct syntax, commit the fragment additions and then run ./scripts/check_changelog_fragments.sh +the correct syntax, commit the fragment additions and then run `make check-changelog-fragments` The format for fragments is: `..md` diff --git a/scripts/generate-release-cue.rb b/scripts/generate-release-cue.rb index 9a03cda437cdf..49f130c83c394 100755 --- a/scripts/generate-release-cue.rb +++ b/scripts/generate-release-cue.rb @@ -199,7 +199,7 @@ def generate_changelog!(new_version) # these are handled in the upgrade guide separately. # NOTE: If the fragment types are altered, update both the 'changelog.d/README.md' and - # 'scripts/check_changelog_fragments.sh' accordingly. + # 'vdev/src/commands/check/changelog_fragments.rs' accordingly. type = "" if fragment_type == "breaking" type = "chore" From 1317af5d2e95d0ee8f8b4bcd0236905b4c2a12cb Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Tue, 2 Sep 2025 14:08:42 -0400 Subject: [PATCH 06/19] teaks --- .github/workflows/changelog.yaml | 2 +- .../src/commands/check/changelog_fragments.rs | 130 +++++++----------- 2 files changed, 54 insertions(+), 78 deletions(-) diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index 75ee7d6007a19..bf9d0d45b93b8 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -68,4 +68,4 @@ jobs: # Add files and then compare with HEAD instead of origin/master git add changelog.d/ - MERGE_BASE=HEAD cd vdev && cargo run -- check changelog-fragments + MERGE_BASE=HEAD cd vdev && cargo run -- check changelog-fragments --merge-base HEAD diff --git a/vdev/src/commands/check/changelog_fragments.rs b/vdev/src/commands/check/changelog_fragments.rs index b9825826a68ae..ae82a018b9fbc 100644 --- a/vdev/src/commands/check/changelog_fragments.rs +++ b/vdev/src/commands/check/changelog_fragments.rs @@ -1,7 +1,6 @@ use crate::git::added_files_against_merge_base; use crate::path_utils::get_changelog_dir; use anyhow::{anyhow, Context, Result}; -use std::env; use std::ffi::OsStr; use std::path::{Path, PathBuf}; @@ -20,15 +19,15 @@ const DEFAULT_MAX_FRAGMENTS: usize = 1000; #[derive(clap::Args, Debug)] #[command()] pub struct Cli { - /// Changelog directory (defaults to $CHANGELOG_DIR or "changelog.d") + /// Changelog directory (defaults to `changelog.d`) #[arg(long)] changelog_dir: Option, - /// Merge base to diff against (defaults to $MERGE_BASE or "origin/master") + /// Merge base to diff against (defaults to `origin/master`) #[arg(long)] merge_base: Option, - /// Max fragments threshold (defaults to $MAX_FRAGMENTS or 1000) + /// Max fragments threshold (defaults to `1000`) #[arg(long)] max_fragments: Option, } @@ -36,7 +35,7 @@ pub struct Cli { impl Cli { pub fn exec(&self) -> Result<()> { let changelog_dir = self - .changelog_dir.clone().unwrap_or_else(|| get_changelog_dir()); + .changelog_dir.clone().unwrap_or_else(get_changelog_dir); if !changelog_dir.is_dir() { error!( @@ -48,12 +47,10 @@ impl Cli { let merge_base = self .merge_base.clone() - .or_else(|| env::var("MERGE_BASE").ok()) .unwrap_or_else(|| "origin/master".to_string()); let max_fragments: usize = self .max_fragments - .or_else(|| env::var("MAX_FRAGMENTS").ok().and_then(|s| s.parse().ok())) .unwrap_or(DEFAULT_MAX_FRAGMENTS); let fragments = added_files_against_merge_base(&merge_base, &changelog_dir) @@ -68,7 +65,7 @@ impl Cli { } if fragments.len() > max_fragments { - error!("Too many changelog fragments ({} > {}).", fragments.len(), max_fragments); + error!("Too many changelog fragments ({} > {max_fragments}).", fragments.len()); std::process::exit(1); } @@ -77,9 +74,9 @@ impl Cli { if name == "README.md" { continue; } - info!("validating '{}'", name); - self.validate_fragment_filename(name)?; - self.validate_fragment_contents(&changelog_dir.join(name), name)?; + info!("Validating `{name}`"); + validate_fragment_filename(name)?; + validate_fragment_contents(&changelog_dir.join(name), name)?; } else { return Err(anyhow!("unexpected path (no filename): {}", path.display())); } @@ -88,86 +85,65 @@ impl Cli { info!("changelog additions are valid."); Ok(()) } +} - fn validate_fragment_filename(&self, fname: &str) -> Result<()> { - // Expected: ..md - let parts: Vec<&str> = fname.split('.').collect(); - if parts.len() != 3 { - return Err(anyhow!( - "invalid fragment filename: wrong number of period delimiters. \ - expected '..md'. ({})", - fname +fn validate_fragment_filename(filename: &str) -> Result<()> { + // Expected: ..md + let parts: Vec<&str> = filename.split('.').collect(); + if parts.len() != 3 { + return Err(anyhow!( + "invalid fragment filename: {filename} - wrong number of period delimiters. \ + expected '..md'", )); - } + } - let fragment_type = parts[1]; - if !FRAGMENT_TYPES.contains(&fragment_type) { - return Err(anyhow!( - "invalid fragment filename: fragment type must be one of: ({}). ({})", + let fragment_type = parts[1]; + if !FRAGMENT_TYPES.contains(&fragment_type) { + return Err(anyhow!( + "invalid fragment filename: {filename} - fragment type must be one of: {})", FRAGMENT_TYPES.join("|"), - fname )); - } + } - if parts[2] != "md" { - return Err(anyhow!( - "invalid fragment filename: extension must be markdown (.md): ({})", - fname + if parts[2] != "md" { + return Err(anyhow!( + "invalid fragment filename: {filename} - extension must be markdown (.md)", )); - } - - Ok(()) } - fn validate_fragment_contents(&self, path: &Path, fname: &str) -> Result<()> { - let content = std::fs::read_to_string(path).with_context(|| { - format!( - "failed to read fragment file: {}", - path.to_string_lossy() - ) - })?; - - // Emulate `tail -n 1` (last line; may be empty if file ends with newline) - let last_line = content - .rsplit_once('\n') - .map(|(_, tail)| tail) - .or_else(|| content.lines().last()) - .unwrap_or(&content); // single-line file without newline - - // Accept either "author:" or "authors:" (case-sensitive), followed by one-or-more non-space - // Example valid lines: - // authors: Alice Bob - // author: Alice - let trimmed = last_line.trim_end_matches(['\r', '\n']); - if !(trimmed.starts_with("authors:") || trimmed.starts_with("author:")) { - return Err(anyhow!( - "invalid fragment contents: last line must start with 'author: ' or 'authors: ' and include at least one name. ({})", - fname - )); - } + Ok(()) +} - // Split on the first colon, take the remainder as names - let names = trimmed.splitn(2, ':').nth(1).unwrap_or("").trim(); +fn validate_fragment_contents(path: &Path, filename: &str) -> Result<()> { + let content = std::fs::read_to_string(path).with_context(|| { + format!("failed to read fragment file: {}", path.to_string_lossy()) + })?; - if names.is_empty() { - return Err(anyhow!( - "invalid fragment contents: author line is empty. ({})", - fname - )); - } + // Use last non-empty line to avoid false negatives due to trailing newline(s). + let last_line = content.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or(""); + + if !last_line.starts_with("authors:") { + return Err(anyhow!("last line must start with 'authors: ' and include at least one name. ({filename})")); + } + + // Split on the first colon, take the remainder as names + let names = last_line.split_once(':').map(|(_, rest)| rest.trim()).unwrap_or(""); - if names.contains('@') { - return Err(anyhow!( - "invalid fragment contents: author should not be prefixed with @" - )); - } - if names.contains(',') { - return Err(anyhow!( - "invalid fragment contents: authors should be space delimited, not comma delimited." + if names.is_empty() { + return Err(anyhow!( + "author line is empty. ({})", + filename )); - } + } - Ok(()) + if names.contains('@') { + return Err(anyhow!("author should not be prefixed with @")); } + + if names.contains(',') { + return Err(anyhow!("authors should be space delimited, not comma delimited.")); + } + + Ok(()) } From 1e4a6f9de6656225b5c4d271ecb8bdf132a6d224 Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Wed, 3 Sep 2025 16:07:53 -0400 Subject: [PATCH 07/19] bininstall vdev --- .github/workflows/changelog.yaml | 6 ++++-- scripts/environment/prepare.sh | 7 +++++++ vdev/src/commands/check/changelog_fragments.rs | 3 +-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index bf9d0d45b93b8..d3bbae27dd4e3 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -48,7 +48,6 @@ jobs: ref: master sparse-checkout: | changelog.d/ - vdev/ sparse-checkout-cone-mode: false # Checkout PR's changelog.d/ into tmp/ @@ -60,6 +59,9 @@ jobs: path: tmp sparse-checkout: changelog.d/ + - name: Prepare vdev + run: bash ./scripts/environment/prepare.sh --modules=vdev + - name: Run changelog fragment checker if: env.SHOULD_RUN == 'true' run: | @@ -68,4 +70,4 @@ jobs: # Add files and then compare with HEAD instead of origin/master git add changelog.d/ - MERGE_BASE=HEAD cd vdev && cargo run -- check changelog-fragments --merge-base HEAD + vdev check changelog-fragments --merge-base HEAD diff --git a/scripts/environment/prepare.sh b/scripts/environment/prepare.sh index 1721acd12c771..ffe702997fa90 100755 --- a/scripts/environment/prepare.sh +++ b/scripts/environment/prepare.sh @@ -49,6 +49,7 @@ Modules: wasm-pack markdownlint datadog-ci + vdev If a module requires rust then rustup will be automatically installed. By default, all modules are installed. To install only a subset: @@ -169,3 +170,9 @@ if contains_module datadog-ci; then sudo npm install -g @datadog/datadog-ci@3.16.0 fi fi + +if contains_module vdev; then + if [[ "$(vdev --version 2>/dev/null)" != "vdev 0.1.0" ]]; then + rustup run stable cargo "${install[@]}" vdev --version 0.1.0 --force --locked + fi +fi diff --git a/vdev/src/commands/check/changelog_fragments.rs b/vdev/src/commands/check/changelog_fragments.rs index ae82a018b9fbc..1a9fa21fda5d5 100644 --- a/vdev/src/commands/check/changelog_fragments.rs +++ b/vdev/src/commands/check/changelog_fragments.rs @@ -127,8 +127,7 @@ fn validate_fragment_contents(path: &Path, filename: &str) -> Result<()> { } // Split on the first colon, take the remainder as names - let names = last_line.split_once(':').map(|(_, rest)| rest.trim()).unwrap_or(""); - + let names = last_line.split_once(':').map_or("", |(_, rest)| rest.trim()); if names.is_empty() { return Err(anyhow!( From dad225a9fa476c750bca4d0e8a6871570c2ad616 Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Wed, 3 Sep 2025 16:11:04 -0400 Subject: [PATCH 08/19] test - add invalid changelog fragment --- changelog.d/bad.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/bad.md diff --git a/changelog.d/bad.md b/changelog.d/bad.md new file mode 100644 index 0000000000000..9daeafb9864cf --- /dev/null +++ b/changelog.d/bad.md @@ -0,0 +1 @@ +test From 06ff6a75eea187d7919af6ce9a54d5ee2696c093 Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Wed, 3 Sep 2025 16:11:38 -0400 Subject: [PATCH 09/19] test - add valid fragment --- changelog.d/test.feature.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/test.feature.md diff --git a/changelog.d/test.feature.md b/changelog.d/test.feature.md new file mode 100644 index 0000000000000..16af066148962 --- /dev/null +++ b/changelog.d/test.feature.md @@ -0,0 +1,3 @@ +Fake + +authors: pront From ce641d0bd77c13f02a419bc01f5c90678c1a72d7 Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Wed, 3 Sep 2025 16:12:32 -0400 Subject: [PATCH 10/19] test - remove invalid fragment --- changelog.d/bad.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 changelog.d/bad.md diff --git a/changelog.d/bad.md b/changelog.d/bad.md deleted file mode 100644 index 9daeafb9864cf..0000000000000 --- a/changelog.d/bad.md +++ /dev/null @@ -1 +0,0 @@ -test From b1712955771d15e4e0ad338d6a7fed3060b9d841 Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Wed, 3 Sep 2025 16:19:41 -0400 Subject: [PATCH 11/19] rmv trailing spaces --- .github/workflows/changelog.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index d3bbae27dd4e3..c013fc7961ce9 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -70,4 +70,4 @@ jobs: # Add files and then compare with HEAD instead of origin/master git add changelog.d/ - vdev check changelog-fragments --merge-base HEAD + vdev check changelog-fragments --merge-base HEAD From 8b7320b2a6153dcdf811718b599e0cbc23972f70 Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Wed, 3 Sep 2025 16:19:50 -0400 Subject: [PATCH 12/19] Revert "test - add valid fragment" This reverts commit 06ff6a75eea187d7919af6ce9a54d5ee2696c093. --- changelog.d/test.feature.md | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 changelog.d/test.feature.md diff --git a/changelog.d/test.feature.md b/changelog.d/test.feature.md deleted file mode 100644 index 16af066148962..0000000000000 --- a/changelog.d/test.feature.md +++ /dev/null @@ -1,3 +0,0 @@ -Fake - -authors: pront From 0331086b9b061bf611cced15f8e7f0e53f070652 Mon Sep 17 00:00:00 2001 From: Pavlos Rontidis Date: Wed, 3 Sep 2025 16:21:07 -0400 Subject: [PATCH 13/19] add to REQUIRES_RUSTUP --- scripts/environment/prepare.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/environment/prepare.sh b/scripts/environment/prepare.sh index ffe702997fa90..91628f5f4fde5 100755 --- a/scripts/environment/prepare.sh +++ b/scripts/environment/prepare.sh @@ -78,7 +78,7 @@ contains_module() { # Always ensure git safe.directory is set git config --global --add safe.directory "$(pwd)" -REQUIRES_RUSTUP=(dd-rust-license-tool cargo-deb cross cargo-nextest cargo-deny cargo-msrv wasm-pack) +REQUIRES_RUSTUP=(dd-rust-license-tool cargo-deb cross cargo-nextest cargo-deny cargo-msrv wasm-pack vdev) REQUIRES_BINSTALL=("${REQUIRES_RUSTUP[@]}") unset -v 'REQUIRES_BINSTALL[0]' # remove dd-rust-license-tool From 9c08727b0428c3a8759550fc58845d0c3202231c Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 4 Sep 2025 00:07:01 -0400 Subject: [PATCH 14/19] Add pull_request trigger to debug changes --- .github/workflows/changelog.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index b488b5057e2f3..b65c41708a38b 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -9,6 +9,9 @@ name: Changelog on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + pull_request_target: types: [opened, synchronize, reopened, labeled, unlabeled] From d4c2b0a3ab27713cbac941157405bb352cdc9341 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 4 Sep 2025 00:09:05 -0400 Subject: [PATCH 15/19] Checkout prepare.sh --- .github/workflows/changelog.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index b65c41708a38b..8d221885e1c49 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -50,6 +50,7 @@ jobs: with: ref: master sparse-checkout: | + scripts/environment/prepare.sh changelog.d/ sparse-checkout-cone-mode: false From 3f26fe168b0b60908890c4ee5d5cbc4cc8b68adc Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 4 Sep 2025 00:10:43 -0400 Subject: [PATCH 16/19] Add SHOULD_RUN == 'true' to prepare step --- .github/workflows/changelog.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index 8d221885e1c49..69330de46d0bf 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -64,6 +64,7 @@ jobs: sparse-checkout: changelog.d/ - name: Prepare vdev + if: env.SHOULD_RUN == 'true' run: bash ./scripts/environment/prepare.sh --modules=vdev - name: Run changelog fragment checker From 044a4455488cd125a9fcd5a7eed2120b82050eaa Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 4 Sep 2025 00:13:28 -0400 Subject: [PATCH 17/19] REVERT ME - checkout prepare.sh from branch --- .github/workflows/changelog.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index 69330de46d0bf..17203b6d1aa05 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -50,7 +50,6 @@ jobs: with: ref: master sparse-checkout: | - scripts/environment/prepare.sh changelog.d/ sparse-checkout-cone-mode: false @@ -61,11 +60,13 @@ jobs: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} path: tmp - sparse-checkout: changelog.d/ + sparse-checkout: | + changelog.d/ + scripts/environment/prepare.sh # FIXME DO NOT MERGE - name: Prepare vdev if: env.SHOULD_RUN == 'true' - run: bash ./scripts/environment/prepare.sh --modules=vdev + run: bash tmp/scripts/environment/prepare.sh --modules=vdev - name: Run changelog fragment checker if: env.SHOULD_RUN == 'true' From 4a160e031d61c90b065a23c112f0da1bed860cc7 Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 4 Sep 2025 00:15:04 -0400 Subject: [PATCH 18/19] Checkout release-flags.sh from master --- .github/workflows/changelog.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index 17203b6d1aa05..d155316816a28 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -51,6 +51,7 @@ jobs: ref: master sparse-checkout: | changelog.d/ + scripts/environment/release-flags.sh sparse-checkout-cone-mode: false # Checkout PR's changelog.d/ into tmp/ From f917ff187a728e6fbfa66686db63f5a99534668a Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 4 Sep 2025 00:16:39 -0400 Subject: [PATCH 19/19] Checkout binstall from master --- .github/workflows/changelog.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/changelog.yaml b/.github/workflows/changelog.yaml index d155316816a28..8da3f128fea91 100644 --- a/.github/workflows/changelog.yaml +++ b/.github/workflows/changelog.yaml @@ -52,6 +52,7 @@ jobs: sparse-checkout: | changelog.d/ scripts/environment/release-flags.sh + scripts/environment/binstall.sh sparse-checkout-cone-mode: false # Checkout PR's changelog.d/ into tmp/