Skip to content

Commit 55d286d

Browse files
committed
feat(release): add breaking changes to release proposal (#9196)
* add breaking changes to release proposal * fetch from master instead * removes stable path fallback * fix wrong look up
1 parent 5b0fc6f commit 55d286d

3 files changed

Lines changed: 181 additions & 11 deletions

File tree

scripts/release/changelog.js

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,18 +143,31 @@ for (const [product, scopes] of PRODUCTS) {
143143

144144
/**
145145
* @param {CommitEntry[]} entries
146+
* @param {CommitEntry[]} [breakingEntries]
146147
* @returns {ReleaseChangelog}
147148
*/
148-
function createReleaseChangelog (entries) {
149+
function createReleaseChangelog (entries, breakingEntries = []) {
149150
const sections = new Map()
151+
const breakingChanges = []
152+
const breakingPullRequests = new Set()
150153
const contributors = new Set()
151154
const warnings = []
152155
let isMinor = false
153156

157+
for (const entry of breakingEntries) {
158+
const change = parseChange(entry, { dropOtherDependencies: false })
159+
160+
if (change.warning) warnings.push(change.warning)
161+
if (entry.author) contributors.add(entry.author)
162+
if (change.pr) breakingPullRequests.add(change.pr)
163+
breakingChanges.push(change)
164+
}
165+
154166
for (const entry of entries) {
155167
const change = parseChange(entry)
156168

157169
if (change.drop) continue
170+
if (change.pr && breakingPullRequests.has(change.pr)) continue
158171
if (change.warning) warnings.push(change.warning)
159172
if (change.category === 'Features' && !change.revert) isMinor = true
160173
if (entry.author) contributors.add(entry.author)
@@ -168,17 +181,18 @@ function createReleaseChangelog (entries) {
168181
}
169182

170183
return {
171-
markdown: renderMarkdown(sections, contributors),
184+
markdown: renderMarkdown(sections, contributors, breakingChanges),
172185
isMinor,
173186
warnings,
174187
}
175188
}
176189

177190
/**
178191
* @param {CommitEntry} entry
192+
* @param {{ dropOtherDependencies?: boolean }} [options]
179193
* @returns {Change}
180194
*/
181-
function parseChange (entry) {
195+
function parseChange (entry, options = {}) {
182196
const subjectWithPullRequest = parsePullRequest(entry.subject)
183197
const parsed = parseConventionalSubject(subjectWithPullRequest.subject)
184198

@@ -194,13 +208,13 @@ function parseChange (entry) {
194208
}
195209

196210
const dependency = classifyDependencyBump(parsed.scopes, parsed.subject)
197-
if (dependency === 'other') {
211+
if (dependency === 'other' && options.dropOtherDependencies !== false) {
198212
return { drop: true }
199213
}
200214

201215
return {
202216
category: CATEGORY_BY_TYPE[parsed.type] || INTERNAL_CATEGORY,
203-
product: dependency === 'production' ? DEPENDENCY_PRODUCT : selectProduct(parsed.scopes),
217+
product: dependency ? DEPENDENCY_PRODUCT : selectProduct(parsed.scopes),
204218
subject: parsed.subject,
205219
pr: subjectWithPullRequest.pr,
206220
revert: parsed.isRevert,
@@ -321,10 +335,19 @@ function sentenceCase (subject) {
321335
/**
322336
* @param {Map<string, Change[]>} sections
323337
* @param {Set<string>} contributors
338+
* @param {Change[]} breakingChanges
324339
*/
325-
function renderMarkdown (sections, contributors) {
340+
function renderMarkdown (sections, contributors, breakingChanges) {
326341
const lines = []
327342

343+
if (breakingChanges.length > 0) {
344+
lines.push('### Breaking Changes')
345+
for (const change of breakingChanges.sort(compareChanges)) {
346+
lines.push(renderChange(change))
347+
}
348+
lines.push('')
349+
}
350+
328351
for (const category of CATEGORY_ORDER) {
329352
const changes = sections.get(category)
330353
if (!changes?.length) continue

scripts/release/changelog.spec.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,84 @@ describe('release changelog', () => {
144144
].join('\n'))
145145
})
146146

147+
it('renders breaking changes at the top of the release changelog', () => {
148+
const changelog = createReleaseChangelog([
149+
{
150+
sha: 'abc001',
151+
subject: 'fix(core): keep existing behavior stable (#9001)',
152+
author: '@alice',
153+
},
154+
], [
155+
{
156+
sha: 'abc002',
157+
subject: 'feat(opentelemetry)!: remove legacy propagation mode (#9002)',
158+
author: '@bob',
159+
},
160+
{
161+
sha: 'abc003',
162+
subject: 'chore(deps-dev): bump eslint from 9.0.0 to 10.0.0 (#9003)',
163+
},
164+
])
165+
166+
assert.strictEqual(changelog.markdown, [
167+
'### Breaking Changes',
168+
`- **Dependencies:** Bump eslint from 9.0.0 to 10.0.0 ${prLink(9003)}`,
169+
`- **OpenTelemetry:** Remove legacy propagation mode ${prLink(9002)}`,
170+
'',
171+
'### Fixes',
172+
`- **General:** Keep existing behavior stable ${prLink(9001)}`,
173+
'',
174+
'### Contributors',
175+
'',
176+
`${avatar('alice')} ${avatar('bob')}`,
177+
'',
178+
].join('\n'))
179+
})
180+
181+
it('does not promote the release to minor for breaking-only features', () => {
182+
const changelog = createReleaseChangelog([
183+
{
184+
sha: 'abc001',
185+
subject: 'fix(core): keep existing behavior stable (#9001)',
186+
},
187+
], [
188+
{
189+
sha: 'abc002',
190+
subject: 'feat(opentelemetry)!: remove legacy propagation mode (#9002)',
191+
},
192+
])
193+
194+
assert.strictEqual(changelog.isMinor, false)
195+
})
196+
197+
it('drops regular release note entries already listed as breaking changes', () => {
198+
const changelog = createReleaseChangelog([
199+
{
200+
sha: 'abc001',
201+
subject: 'feat(opentelemetry)!: remove legacy propagation mode (#9002)',
202+
},
203+
{
204+
sha: 'abc002',
205+
subject: 'fix(core): keep existing behavior stable (#9001)',
206+
},
207+
], [
208+
{
209+
sha: 'abc003',
210+
subject: 'feat(opentelemetry)!: remove legacy propagation mode (#9002)',
211+
},
212+
])
213+
214+
assert.strictEqual(changelog.markdown, [
215+
'### Breaking Changes',
216+
`- **OpenTelemetry:** Remove legacy propagation mode ${prLink(9002)}`,
217+
'',
218+
'### Fixes',
219+
`- **General:** Keep existing behavior stable ${prLink(9001)}`,
220+
'',
221+
].join('\n'))
222+
assert.strictEqual(changelog.isMinor, false)
223+
})
224+
147225
it('handles breaking markers and subjects without pull request numbers', () => {
148226
const changelog = createReleaseChangelog([
149227
{

scripts/release/proposal.js

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const { checkAll } = require('./helpers/requirements')
2323
const tmpdir = process.env.RUNNER_TEMP || os.tmpdir()
2424
const main = 'master'
2525
const releaseLine = params[0]
26+
const breakingLabels = ['semver-major', 'only-land-on-next']
27+
const pullRequestNumberPattern = /\(#([0-9]+)\)$/
2628

2729
// Validate release line argument.
2830
if (!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

@@ -94,7 +96,8 @@ try {
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

Comments
 (0)