Skip to content

Commit e69361f

Browse files
authored
Add REST API changelog sync and fix GHES multi-version release bugs (#59834)
1 parent e073f5a commit e69361f

File tree

7 files changed

+643
-22
lines changed

7 files changed

+643
-22
lines changed

content/rest/about-the-rest-api/breaking-changes.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,4 @@ When you update your integration to specify the new API version in the `X-GitHub
2424

2525
Once your integration is updated, test your integration to verify that it works with the new API version.
2626

27-
## Breaking changes for {{ initialRestVersioningReleaseDate }}
28-
29-
Version `{{ initialRestVersioningReleaseDate }}` is the first version of the {% data variables.product.github %} REST API after date-based versioning was introduced. This version does not include any breaking changes.
27+
{% data reusables.rest-api.breaking-changes-changelog %}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<!-- markdownlint-disable liquid-quoted-conditional-arg search-replace GHD046 -->
2+
{% ifversion fpt %}
3+
{% if query.apiVersion == nil or "2022-11-28" <= query.apiVersion %}
4+
## Version 2022-11-28
5+
6+
Version `2022-11-28` is the first version of the GitHub Free, Pro & Team REST API after date-based versioning was introduced. This version does not include any breaking changes.
7+
8+
{% endif %}
9+
{% endif %}
10+
11+
{% ifversion ghec %}
12+
{% if query.apiVersion == nil or "2022-11-28" <= query.apiVersion %}
13+
## Version 2022-11-28
14+
15+
Version `2022-11-28` is the first version of the GitHub Enterprise Cloud REST API after date-based versioning was introduced. This version does not include any breaking changes.
16+
17+
{% endif %}
18+
{% endif %}

src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -124,28 +124,44 @@ export async function updateAutomatedPipelines() {
124124
}
125125

126126
// Get a list of data directories to create (release) and create them
127-
// This should only happen if a relase is being added.
127+
// This should only happen if a release is being added.
128128
const addFiles = difference(expectedDirectory, existingDataDir)
129-
if (addFiles.length > numberedReleaseBaseNames.length) {
130-
throw new Error(
131-
'Only one new release per numbered release version should be added at a time. Check that the lib/enterprise-server-releases.ts is correct.',
132-
)
129+
130+
// Verify all new directories belong to the current release
131+
for (const dir of addFiles) {
132+
if (!dir.includes(currentReleaseNumber)) {
133+
throw new Error(
134+
`Unexpected directory to add: ${dir}. Only directories for the current release ` +
135+
`(${currentReleaseNumber}) should be added. Check that the lib/enterprise-server-releases.ts is correct.`,
136+
)
137+
}
133138
}
134139

135140
for (const base of numberedReleaseBaseNames) {
136-
const dirToAdd = addFiles.find((item) => item.startsWith(base))
137-
if (!dirToAdd) continue
138-
// The suppported array is ordered from most recent (index 0) to oldest
139-
// Index 1 will be the release prior to the most recent release
140-
const lastRelease = supported[1]
141-
const previousDirName = existingDataDir.filter((directory) => directory.includes(lastRelease))
142-
143-
console.log(
144-
`Copying src/${pipeline}/data/${previousDirName} to src/${pipeline}/data/${dirToAdd}`,
145-
)
146-
await cp(`src/${pipeline}/data/${previousDirName}`, `src/${pipeline}/data/${dirToAdd}`, {
147-
recursive: true,
148-
})
141+
// Find ALL directories to add for this base name (may be multiple
142+
// when a release has more than one calendar-date version).
143+
const dirsToAdd = addFiles.filter((item) => item.startsWith(base))
144+
for (const dirToAdd of dirsToAdd) {
145+
// Derive the previous release's corresponding directory by replacing
146+
// the current release number with the previous one. This correctly
147+
// maps each calendar-date variant to its predecessor, e.g.:
148+
// ghes-3.20-2022-11-28 → ghes-3.19-2022-11-28
149+
// ghes-3.20-2026-03-10 → ghes-3.19-2026-03-10
150+
const previousDirName = dirToAdd.replace(currentReleaseNumber, previousReleaseNumber)
151+
if (!existingDataDir.includes(previousDirName)) {
152+
throw new Error(
153+
`Cannot find previous release directory '${previousDirName}' to copy from ` +
154+
`when creating '${dirToAdd}' in src/${pipeline}/data/.`,
155+
)
156+
}
157+
158+
console.log(
159+
`Copying src/${pipeline}/data/${previousDirName} to src/${pipeline}/data/${dirToAdd}`,
160+
)
161+
await cp(`src/${pipeline}/data/${previousDirName}`, `src/${pipeline}/data/${dirToAdd}`, {
162+
recursive: true,
163+
})
164+
}
149165
}
150166
}
151167

src/rest/scripts/update-files.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { allVersions } from '@/versions/lib/all-versions'
2121
import { syncWebhookData } from '../../webhooks/scripts/sync'
2222
import { syncGitHubAppsData } from '../../github-apps/scripts/sync'
2323
import { syncRestRedirects } from './utils/get-redirects'
24+
import { syncChangelogs } from './utils/sync-changelogs'
2425
import { MODELS_GATEWAY_ROOT, injectModelsSchema } from './utils/inject-models-schema'
2526

2627
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -130,6 +131,7 @@ async function main() {
130131
if (pipelines.includes('rest')) {
131132
console.log(`\n▶️ Generating REST data files...\n`)
132133
await syncRestData(TEMP_OPENAPI_DIR, restSchemas, sourceRepoDirectory, injectModelsSchema)
134+
await syncChangelogs(sourceRepoDirectory, VERSION_NAMES)
133135
}
134136

135137
if (pipelines.includes('webhooks')) {
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { readFile, writeFile } from 'fs/promises'
2+
import { existsSync } from 'fs'
3+
import path from 'path'
4+
5+
import { allVersions } from '@/versions/lib/all-versions'
6+
7+
const REST_API_DESCRIPTION_ROOT = 'rest-api-description'
8+
const OUTPUT_PATH = 'data/reusables/rest-api/breaking-changes-changelog.md'
9+
10+
interface VersionMapping {
11+
sourceDir: string
12+
ifversionExpr: string
13+
}
14+
15+
interface VersionSection {
16+
version: string
17+
content: string
18+
}
19+
20+
// Build a list of { sourceDir, ifversionExpr } tuples from allVersions.
21+
// For example:
22+
// fpt → source dir "api.github.com", ifversion "fpt"
23+
// ghec → source dir "ghec", ifversion "ghec"
24+
// ghes-3.14 → source dir "ghes-3.14", ifversion "ghes = 3.14"
25+
function buildVersionMappings(versionNames: Record<string, string>): VersionMapping[] {
26+
// Build reverse lookup: docs short name → source directory name
27+
// e.g. "fpt" → "api.github.com", "ghec" → "ghec"
28+
const reverseMapping: Record<string, string> = {}
29+
for (const [sourceDir, docsName] of Object.entries(versionNames)) {
30+
reverseMapping[docsName] = sourceDir
31+
}
32+
33+
const mappings: VersionMapping[] = []
34+
const seen = new Set<string>()
35+
36+
for (const versionObj of Object.values(allVersions)) {
37+
const key = versionObj.openApiVersionName
38+
if (seen.has(key)) continue
39+
seen.add(key)
40+
41+
let sourceDir: string
42+
let ifversionExpr: string
43+
44+
if (versionObj.shortName === 'ghes') {
45+
// GHES versions: source dir is like "ghes-3.14", ifversion is "ghes = 3.14"
46+
sourceDir = `ghes-${versionObj.currentRelease}`
47+
ifversionExpr = `ghes = ${versionObj.currentRelease}`
48+
} else {
49+
// Non-GHES: look up source dir from reverse mapping
50+
sourceDir = reverseMapping[versionObj.shortName] || versionObj.shortName
51+
ifversionExpr = versionObj.shortName
52+
}
53+
54+
mappings.push({ sourceDir, ifversionExpr })
55+
}
56+
57+
return mappings
58+
}
59+
60+
// Resolve the changelog file path based on whether we're using
61+
// rest-api-description or the local github repo.
62+
export function getChangelogPath(sourceRepoDir: string, releaseDir: string): string {
63+
if (sourceRepoDir === REST_API_DESCRIPTION_ROOT) {
64+
return path.join(REST_API_DESCRIPTION_ROOT, 'descriptions-next', releaseDir, 'CHANGELOG.md')
65+
}
66+
// Local github repo dev workflow
67+
return path.join(
68+
sourceRepoDir,
69+
'app',
70+
'api',
71+
'description',
72+
'changelogs',
73+
releaseDir,
74+
'CHANGELOG.md',
75+
)
76+
}
77+
78+
// Parse a CHANGELOG.md into an array of { version, content } objects
79+
// by splitting on `## Version YYYY-MM-DD` headings.
80+
// Strips the top-level `# REST API Breaking Changes for ...` title and intro paragraph.
81+
export function parseVersionSections(markdown: string): VersionSection[] {
82+
const lines = markdown.split('\n')
83+
const sections: VersionSection[] = []
84+
let currentVersion: string | null = null
85+
let currentLines: string[] = []
86+
let pastHeader = false
87+
88+
for (const line of lines) {
89+
// Skip the top-level title (# REST API Breaking Changes ...)
90+
if (!pastHeader && line.startsWith('# ')) {
91+
pastHeader = true
92+
continue
93+
}
94+
95+
// Skip intro paragraph lines before the first ## Version heading
96+
const versionMatch = line.match(/^## Version (\d{4}-\d{2}-\d{2})/)
97+
if (versionMatch) {
98+
// Save previous section if any
99+
if (currentVersion) {
100+
sections.push({
101+
version: currentVersion,
102+
content: currentLines.join('\n').trim(),
103+
})
104+
}
105+
currentVersion = versionMatch[1]
106+
currentLines = [line]
107+
pastHeader = true
108+
continue
109+
}
110+
111+
if (currentVersion) {
112+
currentLines.push(line)
113+
}
114+
}
115+
116+
// Save last section
117+
if (currentVersion) {
118+
sections.push({
119+
version: currentVersion,
120+
content: currentLines.join('\n').trim(),
121+
})
122+
}
123+
124+
return sections
125+
}
126+
127+
// Main function: reads changelogs from each release directory, wraps them
128+
// in product-version gating ({% ifversion %}) and API-version filtering
129+
// ({% if query.apiVersion %}), and writes a combined data file.
130+
export async function syncChangelogs(
131+
sourceRepoDir: string,
132+
versionNames: Record<string, string>,
133+
outputPath: string = OUTPUT_PATH,
134+
): Promise<void> {
135+
console.log(`\n▶️ Generating REST API breaking changes changelog...\n`)
136+
137+
const mappings = buildVersionMappings(versionNames)
138+
const outputBlocks: string[] = []
139+
140+
for (const { sourceDir, ifversionExpr } of mappings) {
141+
const changelogPath = getChangelogPath(sourceRepoDir, sourceDir)
142+
143+
if (!existsSync(changelogPath)) {
144+
console.log(` ⏭️ No changelog found for ${sourceDir}, skipping.`)
145+
continue
146+
}
147+
148+
const markdown = await readFile(changelogPath, 'utf-8')
149+
const sections = parseVersionSections(markdown)
150+
151+
if (sections.length === 0) {
152+
console.log(` ⏭️ No version sections found in changelog for ${sourceDir}, skipping.`)
153+
continue
154+
}
155+
156+
const sectionBlocks = sections.map(({ version, content }) => {
157+
return [
158+
`{% if query.apiVersion == nil or "${version}" <= query.apiVersion %}`,
159+
content,
160+
'',
161+
'{% endif %}',
162+
].join('\n')
163+
})
164+
165+
const releaseBlock = [
166+
`{% ifversion ${ifversionExpr} %}`,
167+
sectionBlocks.join('\n'),
168+
'{% endif %}',
169+
].join('\n')
170+
171+
outputBlocks.push(releaseBlock)
172+
console.log(` ✅ Processed changelog for ${sourceDir} (${sections.length} version sections)`)
173+
}
174+
175+
if (outputBlocks.length === 0) {
176+
console.log(' ⚠️ No changelogs found. Skipping changelog generation.')
177+
return
178+
}
179+
180+
// The generated Liquid uses quoted date strings in comparisons
181+
// (e.g., "2022-11-28" <= query.apiVersion) which is valid Liquid but
182+
// triggers the GHD016 lint rule that flags quoted conditional args.
183+
// The upstream changelogs may also contain docs.github.com URLs and
184+
// "deprecated" terminology that trigger docs-domain and GHD046 rules.
185+
const lintDisable =
186+
'<!-- markdownlint-disable liquid-quoted-conditional-arg search-replace GHD046 -->\n'
187+
const output = `${lintDisable + outputBlocks.join('\n\n')}\n`
188+
await writeFile(outputPath, output)
189+
console.log(`\n✅ Wrote ${outputPath}`)
190+
}

0 commit comments

Comments
 (0)