1414UNRELEASED_DIR = ROOT / ".changes" / "unreleased"
1515ARCHIVE_DIR = ROOT / ".changes" / "archive"
1616SECTION_ORDER = ["enhancement" , "change" , "fix" ]
17+ ALLOWED_KINDS = {"breaking" , "change" , "enhancement" , "fix" }
1718SECTION_HEADERS = {
1819 "enhancement" : "Enhancements" ,
1920 "change" : "Changes" ,
@@ -29,19 +30,37 @@ def parse_args() -> argparse.Namespace:
2930 parser = argparse .ArgumentParser (
3031 description = "Batch changelog fragments into CHANGELOG.md and archive them."
3132 )
32- parser .add_argument ("--version" , required = True , help = "Release version, e.g. 0.23.0" )
33+ parser .add_argument ("--version" , help = "Release version, e.g. 0.23.0" )
3334 parser .add_argument (
3435 "--date" ,
3536 default = dt .date .today ().isoformat (),
3637 help = "Release date in YYYY-MM-DD format" ,
3738 )
38- return parser .parse_args ()
39+ parser .add_argument (
40+ "--validate-fragment" ,
41+ action = "append" ,
42+ default = [],
43+ metavar = "PATH" ,
44+ help = "Validate one or more fragment files and exit without updating the changelog." ,
45+ )
46+ args = parser .parse_args ()
47+ if args .validate_fragment and args .version :
48+ parser .error ("--validate-fragment cannot be used together with --version" )
49+ if not args .validate_fragment and not args .version :
50+ parser .error ("either --version or --validate-fragment is required" )
51+ return args
3952
4053
41- def parse_fragment (path : pathlib .Path ) -> dict [str , str ]:
42- text = path .read_text (encoding = "utf-8" )
43- lines = text .splitlines ()
54+ def normalize_front_matter_value (raw : str ) -> str :
55+ value = raw .strip ()
56+ if len (value ) >= 2 and value [0 ] == value [- 1 ] and value [0 ] in {"'" , '"' }:
57+ return value [1 :- 1 ]
58+ if value in {"null" , "~" }:
59+ return ""
60+ return value
61+
4462
63+ def parse_front_matter (path : pathlib .Path , lines : list [str ]) -> tuple [dict [str , str ], int ]:
4564 if len (lines ) < 4 or lines [0 ] != "---" :
4665 raise FragmentError (f"{ path } must start with YAML front matter delimited by '---'" )
4766
@@ -52,15 +71,29 @@ def parse_fragment(path: pathlib.Path) -> dict[str, str]:
5271
5372 front_matter : dict [str , str ] = {}
5473 for line in lines [1 :closing ]:
55- if not line .strip ():
74+ stripped = line .strip ()
75+ if not stripped or stripped .startswith ("#" ):
5676 continue
57- if ":" not in line :
77+ if ":" not in stripped :
5878 raise FragmentError (f"{ path } has invalid front matter line: { line } " )
59- key , value = line .split (":" , 1 )
60- front_matter [key .strip ()] = value .strip ()
79+ key , value = stripped .split (":" , 1 )
80+ key = key .strip ()
81+ if not key :
82+ raise FragmentError (f"{ path } has invalid front matter line: { line } " )
83+ if key in front_matter :
84+ raise FragmentError (f"{ path } defines front matter key '{ key } ' more than once" )
85+ front_matter [key ] = normalize_front_matter_value (value )
86+
87+ return front_matter , closing
88+
89+
90+ def parse_fragment (path : pathlib .Path ) -> dict [str , str ]:
91+ text = path .read_text (encoding = "utf-8" )
92+ lines = text .splitlines ()
93+ front_matter , closing = parse_front_matter (path , lines )
6194
6295 kind = front_matter .get ("kind" , "" )
63- if kind not in { "breaking" , "change" , "enhancement" , "fix" } :
96+ if kind not in ALLOWED_KINDS :
6497 raise FragmentError (f"{ path } has invalid kind '{ kind } '" )
6598
6699 pr_url = front_matter .get ("pr" , "" )
@@ -105,15 +138,26 @@ def make_bullet(fragment: dict[str, str]) -> tuple[str, str]:
105138 return section , f"- { summary } ([#{ pr_number } ]({ pr_url } ))."
106139
107140
141+ def fragment_sort_key (fragment : dict [str , str ]) -> tuple [int , str ]:
142+ # Keep batched changelog entries deterministic across repeated release-prep runs.
143+ return fragment ["pr_number" ], fragment ["filename" ]
144+
145+
108146def collect_fragments () -> list [dict [str , str ]]:
109147 paths = sorted (UNRELEASED_DIR .glob ("*.md" ))
110148 if not paths :
111149 raise FragmentError (f"No fragments found in { UNRELEASED_DIR } " )
112150 fragments = [parse_fragment (path ) for path in paths ]
113- fragments .sort (key = lambda item : ( item [ "pr_number" ], item [ "filename" ]) )
151+ fragments .sort (key = fragment_sort_key )
114152 return fragments
115153
116154
155+ def validate_fragments (paths : list [pathlib .Path ]) -> list [dict [str , str ]]:
156+ if not paths :
157+ raise FragmentError ("No fragments were provided for validation" )
158+ return [parse_fragment (path ) for path in paths ]
159+
160+
117161def build_generated_sections (fragments : list [dict [str , str ]]) -> dict [str , list [str ]]:
118162 sections : dict [str , list [str ]] = {key : [] for key in SECTION_ORDER }
119163 for fragment in fragments :
@@ -210,7 +254,23 @@ def update_changelog(version: str, date: str, generated: dict[str, list[str]]) -
210254 CHANGELOG_PATH .write_text (new_changelog , encoding = "utf-8" )
211255
212256
257+ def flush_archive () -> None :
258+ ARCHIVE_DIR .mkdir (parents = True , exist_ok = True )
259+
260+ for path in ARCHIVE_DIR .iterdir ():
261+ if path .name .startswith ("." ):
262+ continue
263+ if path .is_symlink () or path .is_file ():
264+ path .unlink ()
265+ elif path .is_dir ():
266+ shutil .rmtree (path )
267+ else :
268+ raise FragmentError (f"Unsupported archive entry: { path } " )
269+
270+
213271def archive_fragments (version : str , fragments : list [dict [str , str ]]) -> None :
272+ # Keep only the current release batch under .changes/archive/.
273+ flush_archive ()
214274 destination = ARCHIVE_DIR / version
215275 destination .mkdir (parents = True , exist_ok = True )
216276
@@ -225,6 +285,11 @@ def archive_fragments(version: str, fragments: list[dict[str, str]]) -> None:
225285def main () -> int :
226286 args = parse_args ()
227287 try :
288+ if args .validate_fragment :
289+ fragments = validate_fragments ([pathlib .Path (path ) for path in args .validate_fragment ])
290+ print (f"Validated { len (fragments )} changelog fragment(s)." )
291+ return 0
292+
228293 fragments = collect_fragments ()
229294 generated = build_generated_sections (fragments )
230295 update_changelog (args .version , args .date , generated )
0 commit comments