@@ -23,6 +23,8 @@ const { checkAll } = require('./helpers/requirements')
2323const tmpdir = process . env . RUNNER_TEMP || os . tmpdir ( )
2424const main = 'master'
2525const releaseLine = params [ 0 ]
26+ const breakingLabels = [ 'semver-major' , 'only-land-on-next' ]
27+ const pullRequestNumberPattern = / \( # ( [ 0 - 9 ] + ) \) $ /
2628
2729// Validate release line argument.
2830if ( ! releaseLine || releaseLine === 'help' || flags . help ) {
@@ -71,11 +73,11 @@ try {
7173 const stableVersion = `${ DD_MAJOR } .${ DD_MINOR } .${ DD_PATCH } `
7274 const isPreRelease = VERSION !== stableVersion
7375
74- // Notes exclude semver-major (gated behind a flag, not user-visible) .
76+ // Notes list semver-major and only-land-on-next separately as breaking changes .
7577 // Cherry-pick includes semver-major; only only-land-on-next is fully excluded,
7678 // except when promoting a pre-release to stable (that's what "next" means).
7779 const notesDiffCmd = 'branch-diff --user DataDog --repo dd-trace-js' +
78- ( isPreRelease ? '' : ' --exclude-label=semver-major --exclude-label=only-land-on-next')
80+ ' --exclude-label=semver-major --exclude-label=only-land-on-next'
7981 const cherryPickDiffCmd = 'branch-diff --user DataDog --repo dd-trace-js' +
8082 ( isPreRelease ? '' : ' --exclude-label=only-land-on-next' )
8183
9496 // runs. It equals allMainShas[min(length, MAX_CHERRY_PICKS) - 1] regardless of
9597 // how many commits are already on the branch (proven by:
9698 // existingCherryPicked + shasToApply.length = min(allMainShas.length, MAX_CHERRY_PICKS)).
97- const upperBoundSha = allMainShas . at ( Math . min ( allMainShas . length , MAX_CHERRY_PICKS ) - 1 )
99+ const upperBoundSha = allMainShas . at ( Math . min ( allMainShas . length , MAX_CHERRY_PICKS ) - 1 ) ||
100+ ( isPreRelease ? capture ( `git rev-parse v${ releaseLine } .x` ) : undefined )
98101
99102 if ( ! upperBoundSha ) {
100103 pass ( 'none (already up to date)' )
@@ -107,7 +110,7 @@ try {
107110
108111 // notesShas is scoped to upperBoundSha so isMinor and release notes only reflect
109112 // the capped commits actually included in the proposal, not deferred ones.
110- // Excludes semver-major (gated behind a flag, not user-visible) .
113+ // Excludes changes that are listed in the dedicated breaking changes section .
111114 const notesShas = capture ( `${ notesDiffCmd } --format=sha --reverse v${ releaseLine } .x ${ upperBoundRef } ` )
112115 . split ( '\n' ) . filter ( Boolean )
113116 const contributorBySha = getContributorsBySha ( `v${ releaseLine } .x` , upperBoundSha )
@@ -119,7 +122,10 @@ try {
119122 author : contributorBySha . get ( sha ) ,
120123 } )
121124 }
122- const notes = createReleaseChangelog ( notesEntries )
125+ const breakingEntries = isPreRelease
126+ ? getBreakingPullRequestEntries ( releaseLine , upperBoundRef )
127+ : [ ]
128+ const notes = createReleaseChangelog ( notesEntries , breakingEntries )
123129 const isMinor = notes . isMinor
124130 const newPatch = `${ releaseLine } .${ DD_MINOR } .${ DD_PATCH + 1 } `
125131 const newMinor = `${ releaseLine } .${ DD_MINOR + 1 } .0`
@@ -329,3 +335,66 @@ function getContributorsBySha (base, head) {
329335
330336 return contributors
331337}
338+
339+ /**
340+ * Semver-major changes are commonly guarded by version checks and backported to
341+ * stable branches. Branch comparisons can miss them because the commits exist
342+ * on both the previous and next release lines, so the vN.0.0 promotion lists
343+ * merged PRs by label across the previous major cycle instead.
344+ *
345+ * @param {string } releaseLine
346+ * @param {string } upperBoundRef
347+ */
348+ function getBreakingPullRequestEntries ( releaseLine , upperBoundRef ) {
349+ const previousReleaseLine = Number . parseInt ( releaseLine , 10 ) - 1
350+ const previousMajorTag = `v${ previousReleaseLine } .0.0`
351+ const mergedAfter = capture ( `git log -1 --format=%cs ${ previousMajorTag } ` )
352+ const mergedBefore = capture ( `git show -s --format=%cs ${ upperBoundRef } ` )
353+ const pullRequestsByNumber = new Map ( )
354+ const includedPullRequests = getIncludedPullRequests ( previousMajorTag , [ `v${ releaseLine } .x` , upperBoundRef ] )
355+
356+ for ( const label of breakingLabels ) {
357+ const pullRequests = JSON . parse ( capture (
358+ 'gh pr list --repo DataDog/dd-trace-js --state merged --limit 1000' +
359+ ` --label=${ label } ` +
360+ ` --search "base:${ main } merged:>=${ mergedAfter } merged:<=${ mergedBefore } "` +
361+ ' --json number,title,mergeCommit,author'
362+ ) )
363+
364+ for ( const pullRequest of pullRequests ) {
365+ if ( ! includedPullRequests . has ( pullRequest . number ) ) continue
366+
367+ pullRequestsByNumber . set ( pullRequest . number , pullRequest )
368+ }
369+ }
370+
371+ return [ ...pullRequestsByNumber . values ( ) ] . map ( pullRequest => {
372+ return {
373+ sha : pullRequest . mergeCommit ?. oid || `pull-request-${ pullRequest . number } ` ,
374+ subject : `${ pullRequest . title } (#${ pullRequest . number } )` ,
375+ author : pullRequest . author ?. login ? `@${ pullRequest . author . login } ` : undefined ,
376+ }
377+ } )
378+ }
379+
380+ /**
381+ * Release proposal branches are built with cherry-picks, so PR merge commits
382+ * often are not ancestors of the release branch even when their changes are
383+ * present. Match by the PR number preserved in the commit subject instead.
384+ *
385+ * @param {string } base
386+ * @param {string[] } refs
387+ */
388+ function getIncludedPullRequests ( base , refs ) {
389+ const pullRequests = new Set ( )
390+
391+ for ( const ref of refs ) {
392+ const subjects = capture ( `git log --format=%s ${ base } ..${ ref } ` ) . split ( '\n' )
393+ for ( const subject of subjects ) {
394+ const match = subject . match ( pullRequestNumberPattern )
395+ if ( match ) pullRequests . add ( Number . parseInt ( match [ 1 ] , 10 ) )
396+ }
397+ }
398+
399+ return pullRequests
400+ }
0 commit comments