@@ -15,7 +15,8 @@ import CherryPick from './cherry_pick.js';
1515import Session from './session.js' ;
1616import {
1717 getAffectedVersionLines ,
18- getDependencyUpdates
18+ getDependencyUpdates ,
19+ SEVERITY_RANKS
1920} from './security-release/security-release.js' ;
2021
2122const isWindows = process . platform === 'win32' ;
@@ -51,6 +52,34 @@ export function getPullRequestURLForLine(affectedVersions, line, legacyPrURL) {
5152 return null ;
5253}
5354
55+ // Format the notable changes of a security release, one entry per commit:
56+ // `* (CVE-ID) subsystem: title (Author) – Severity`, sorted from highest to
57+ // lowest severity, then by CVE-ID. Commits without a CVE-ID trailer
58+ // (e.g. dependency updates) are listed last.
59+ export function formatSecurityNotableChanges ( commits , severityByCVE ) {
60+ const rank = rating => SEVERITY_RANKS . indexOf ( ( rating || '' ) . toUpperCase ( ) ) ;
61+ const entries = commits
62+ . filter ( ( { subject } ) => subject )
63+ . map ( ( { subject, author, cveIds = [ ] } ) => ( {
64+ subject,
65+ author,
66+ cveIds,
67+ rating : cveIds . map ( id => severityByCVE . get ( id ) ) . find ( Boolean ) || ''
68+ } ) )
69+ . sort ( ( a , b ) => ( rank ( b . rating ) - rank ( a . rating ) ) ||
70+ ( b . cveIds . length - a . cveIds . length ) ||
71+ ( a . cveIds [ 0 ] || '' ) . localeCompare ( b . cveIds [ 0 ] || '' , 'en' , { numeric : true } ) ) ;
72+
73+ const lines = entries . map ( ( { subject, author, cveIds, rating } ) => {
74+ const cve = cveIds . length ? `(${ cveIds . join ( ', ' ) } ) ` : '' ;
75+ const severity = rating
76+ ? ` – ${ rating [ 0 ] . toUpperCase ( ) } ${ rating . slice ( 1 ) . toLowerCase ( ) } `
77+ : '' ;
78+ return `* ${ cve } ${ subject } (${ author } )${ severity } ` ;
79+ } ) ;
80+ return lines . length ? `${ lines . join ( '\n' ) } \n` : '' ;
81+ }
82+
5483export default class ReleasePreparation extends Session {
5584 constructor ( argv , cli , dir ) {
5685 super ( cli , dir ) ;
@@ -137,7 +166,12 @@ export default class ReleasePreparation extends Session {
137166 const url = getPullRequestURLForLine (
138167 dep . affectedVersions , line , dep . prURL ) ;
139168 if ( url ) {
140- targets . push ( { url, cveIds : null , label : `dependency: ${ dep . name } ` } ) ;
169+ targets . push ( {
170+ url,
171+ cveIds : null ,
172+ label : `dependency: ${ dep . name } ` ,
173+ isDependency : true
174+ } ) ;
141175 }
142176 }
143177
@@ -197,7 +231,7 @@ export default class ReleasePreparation extends Session {
197231 amendAll = answer === 'all' ;
198232 }
199233
200- if ( ! target . cveIds ) {
234+ if ( ! target . cveIds && ! target . isDependency ) {
201235 cli . warn ( `No CVE-IDs found in vulnerabilities.json for ${ target . url } ` ) ;
202236 }
203237
@@ -208,7 +242,7 @@ export default class ReleasePreparation extends Session {
208242 gpgSign : this . gpgSign ,
209243 upstream : this . upstreamForPR ( pr ) ,
210244 lint : false ,
211- includeCVE : true ,
245+ includeCVE : ! target . isDependency ,
212246 cveIds : target . cveIds ,
213247 promptAmend : false ,
214248 skipMessagePrompt : amendAll
@@ -553,10 +587,12 @@ export default class ReleasePreparation extends Session {
553587 const data = await fs . readFile ( majorChangelogPath , 'utf8' ) ;
554588 const arr = data . split ( '\n' ) ;
555589 const allCommits = this . getChangelog ( ) ;
556- const notableChanges = await this . getBranchDiff ( {
557- onlyNotableChanges : true ,
558- format : isSecurityRelease ? 'messageonly' : 'markdown' ,
559- } ) ;
590+ const notableChanges = isSecurityRelease
591+ ? await this . getSecurityNotableChanges ( )
592+ : await this . getBranchDiff ( {
593+ onlyNotableChanges : true ,
594+ format : 'markdown' ,
595+ } ) ;
560596 let releaseHeader = `## ${ date } , Version ${ newVersion } ` +
561597 ` ${ releaseInfo } , @${ username } \n` ;
562598 if ( isSecurityRelease ) {
@@ -692,10 +728,12 @@ export default class ReleasePreparation extends Session {
692728 messageBody . push ( 'This is a security release.\n\n' ) ;
693729 }
694730
695- const notableChanges = await this . getBranchDiff ( {
696- onlyNotableChanges : true ,
697- format : isSecurityRelease ? 'messageonly' : 'plaintext'
698- } ) ;
731+ const notableChanges = isSecurityRelease
732+ ? await this . getSecurityNotableChanges ( )
733+ : await this . getBranchDiff ( {
734+ onlyNotableChanges : true ,
735+ format : 'plaintext'
736+ } ) ;
699737 messageBody . push ( 'Notable changes:\n\n' ) ;
700738 if ( isLTSTransition ) {
701739 messageBody . push ( `${ getStartLTSBlurb ( this ) } \n\n` ) ;
@@ -718,6 +756,48 @@ export default class ReleasePreparation extends Session {
718756 return useMessage ;
719757 }
720758
759+ // Build the notable changes of a security release from the commits
760+ // cherry-picked onto the proposal branch and the severity ratings in
761+ // vulnerabilities.json.
762+ async getSecurityNotableChanges ( ) {
763+ const { upstream, versionComponents } = this ;
764+ const releaseBranch = `v${ versionComponents . major } .x` ;
765+
766+ await forceRunAsync ( 'git' , [ 'remote' , 'set-branches' , '--add' , upstream , releaseBranch ] , {
767+ ignoreFailures : false
768+ } ) ;
769+ await forceRunAsync ( 'git' , [ 'fetch' , upstream , releaseBranch ] , { ignoreFailures : false } ) ;
770+
771+ const severityByCVE = new Map ( ) ;
772+ const vulnPath = this . getVulnerabilitiesJSONPath ( ) ;
773+ if ( vulnPath && existsSync ( vulnPath ) ) {
774+ const { reports } = JSON . parse ( readFileSync ( vulnPath , 'utf-8' ) ) ;
775+ for ( const report of reports ?? [ ] ) {
776+ for ( const cveId of report . cveIds ?? [ ] ) {
777+ severityByCVE . set ( cveId , report . severity ?. rating ) ;
778+ }
779+ }
780+ }
781+
782+ const log = runSync ( 'git' , [
783+ 'log' ,
784+ '--format=%s%x1f%an%x1f%(trailers:key=CVE-ID,valueonly,separator=%x2C)%x1e' ,
785+ `${ upstream } /${ releaseBranch } ..HEAD`
786+ ] ) ;
787+ const commits = log . split ( '\x1e' ) . map ( record => {
788+ const [ subject , author , cveIds ] = record . trim ( ) . split ( '\x1f' ) ;
789+ return {
790+ subject,
791+ author,
792+ // Ignore placeholder trailers such as CVE-2026-XXXXX.
793+ cveIds : ( cveIds || '' ) . split ( ',' )
794+ . map ( id => id . trim ( ) )
795+ . filter ( id => / ^ C V E - \d { 4 } - \d + $ / . test ( id ) )
796+ } ;
797+ } ) ;
798+ return formatSecurityNotableChanges ( commits , severityByCVE ) ;
799+ }
800+
721801 async getBranchDiff ( opts ) {
722802 const {
723803 cli,
0 commit comments