diff --git a/.gitignore b/.gitignore
index 40b878db5b..b56eea18ad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
-node_modules/
\ No newline at end of file
+node_modules/
+output/
\ No newline at end of file
diff --git a/extensions.md b/extensions.md
index fac9fcc8bb..1fe92302db 100644
--- a/extensions.md
+++ b/extensions.md
@@ -2,6 +2,30 @@
This document describes the [OpenAPI extensions](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#specification-extensions) used in GitHub's REST API OpenAPI descriptions.
+## `x-github-api-versions`
+
+### Purpose
+
+To specify the API versions supported by this OpenAPI description. GitHub's REST API uses calendar-based versioning (e.g., `2022-11-28`). Clients can use the `X-GitHub-Api-Version` header to request a specific version.
+
+### Usage
+
+This applies to the [Info Object](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#infoObject).
+
+The value should be an array of strings representing supported API version dates in `YYYY-MM-DD` format, sorted in descending order (newest first).
+
+#### Example usage
+
+```yml
+info:
+ title: GitHub v3 REST API
+ version: 1.1.4
+ x-github-api-versions:
+ - "2022-11-28"
+```
+
+For more information about API versioning, see the [GitHub REST API documentation](https://docs.github.com/rest/about-the-rest-api/api-versions).
+
## `x-displayName`
### Purpose
diff --git a/package.json b/package.json
index 35ef2bb1e8..f481f22c22 100644
--- a/package.json
+++ b/package.json
@@ -7,13 +7,19 @@
},
"license": "MIT",
"scripts": {
- "lint": "eslint descriptions/**/*.json --ext .json"
+ "lint": "eslint descriptions/**/*.json --ext .json",
+ "build": "node scripts/build-versioned-bundles.js",
+ "build:versioned": "node scripts/build-versioned-bundles.js --output output/versioned",
+ "build:api.github.com": "node scripts/build-versioned-bundles.js --releases api.github.com",
+ "build:ghec": "node scripts/build-versioned-bundles.js --releases ghec",
+ "build:ghes": "node scripts/build-versioned-bundles.js --releases $(ls descriptions | grep ghes | tr '\\n' ',')"
},
"eslintConfig": {
"extends": ["plugin:json/recommended"]
},
"devDependencies": {
"eslint": "^8.13.0",
- "eslint-plugin-json": "^3.1.0"
+ "eslint-plugin-json": "^3.1.0",
+ "js-yaml": "^4.1.0"
}
}
diff --git a/scripts/build-versioned-bundles.js b/scripts/build-versioned-bundles.js
new file mode 100644
index 0000000000..af91f4859a
--- /dev/null
+++ b/scripts/build-versioned-bundles.js
@@ -0,0 +1,361 @@
+#!/usr/bin/env node
+
+/**
+ * Build OpenAPI bundles that include the API version number.
+ *
+ * The API version is specified using the custom extension `x-github-api-versions`
+ * in the OpenAPI info section.
+ *
+ * Usage:
+ * node scripts/build-versioned-bundles.js [options]
+ *
+ * Options:
+ * --input
Input directory containing OpenAPI descriptions (default: descriptions)
+ * --output Output directory for versioned bundles (default: output)
+ * --releases Comma-separated list of releases to process (default: all)
+ * --versions Comma-separated list of API versions to include
+ * --format Output format: json, yaml, or both (default: both)
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+// Try to load js-yaml for YAML support
+let yaml;
+try {
+ yaml = require('js-yaml');
+} catch {
+ yaml = null;
+}
+
+// Parse command line arguments
+function parseArgs() {
+ const args = process.argv.slice(2);
+ const options = {
+ input: 'descriptions',
+ output: 'output',
+ releases: null,
+ versions: null,
+ format: 'both'
+ };
+
+ for (let i = 0; i < args.length; i++) {
+ switch (args[i]) {
+ case '--input':
+ options.input = args[++i];
+ break;
+ case '--output':
+ options.output = args[++i];
+ break;
+ case '--releases':
+ options.releases = args[++i]?.split(',').map(s => s.trim());
+ break;
+ case '--versions':
+ options.versions = args[++i]?.split(',').map(s => s.trim());
+ break;
+ case '--format':
+ options.format = args[++i];
+ break;
+ case '--help':
+ case '-h':
+ console.log(`
+Build OpenAPI bundles that include the API version number.
+
+Usage:
+ node scripts/build-versioned-bundles.js [options]
+
+Options:
+ --input Input directory containing OpenAPI descriptions (default: descriptions)
+ --output Output directory for versioned bundles (default: output)
+ --releases Comma-separated list of releases to process (default: all)
+ --versions Comma-separated list of API versions to include
+ --format Output format: json, yaml, or both (default: both)
+ --help, -h Show this help message
+
+Examples:
+ # Build all releases with default versions
+ node scripts/build-versioned-bundles.js
+
+ # Build only api.github.com release
+ node scripts/build-versioned-bundles.js --releases api.github.com
+
+ # Build with specific API version
+ node scripts/build-versioned-bundles.js --versions 2022-11-28
+
+ # Output to custom directory
+ node scripts/build-versioned-bundles.js --output ./versioned-bundles
+ `);
+ process.exit(0);
+ }
+ }
+
+ return options;
+}
+
+// Get all available releases from the descriptions directory
+function getAvailableReleases(inputDir) {
+ const releases = [];
+
+ if (!fs.existsSync(inputDir)) {
+ console.error(`Error: Input directory "${inputDir}" does not exist`);
+ process.exit(1);
+ }
+
+ const entries = fs.readdirSync(inputDir, { withFileTypes: true });
+
+ for (const entry of entries) {
+ if (entry.isDirectory()) {
+ releases.push(entry.name);
+ }
+ }
+
+ return releases;
+}
+
+// Find OpenAPI files for a release
+function findOpenAPIFiles(releaseDir) {
+ const files = {
+ base: null,
+ versioned: []
+ };
+
+ if (!fs.existsSync(releaseDir)) {
+ return files;
+ }
+
+ const entries = fs.readdirSync(releaseDir);
+
+ for (const entry of entries) {
+ // Skip directories (like dereferenced/)
+ const fullPath = path.join(releaseDir, entry);
+ if (fs.statSync(fullPath).isDirectory()) continue;
+
+ // Match patterns like api.github.com.json or ghes-3.16.2022-11-28.json
+ const jsonMatch = entry.match(/^(.+?)(?:\.(\d{4}-\d{2}-\d{2}))?\.json$/);
+ const yamlMatch = entry.match(/^(.+?)(?:\.(\d{4}-\d{2}-\d{2}))?\.yaml$/);
+
+ if (jsonMatch) {
+ const [, base, version] = jsonMatch;
+ if (version) {
+ files.versioned.push({ path: fullPath, version, format: 'json', base });
+ } else if (!files.base || entry.endsWith('.json')) {
+ files.base = { path: fullPath, format: 'json', name: base };
+ }
+ } else if (yamlMatch && !files.base) {
+ const [, base, version] = yamlMatch;
+ if (version) {
+ files.versioned.push({ path: fullPath, version, format: 'yaml', base });
+ } else {
+ files.base = { path: fullPath, format: 'yaml', name: base };
+ }
+ }
+ }
+
+ return files;
+}
+
+// Load and parse OpenAPI document
+function loadOpenAPIDocument(filePath) {
+ const content = fs.readFileSync(filePath, 'utf8');
+
+ if (filePath.endsWith('.json')) {
+ return JSON.parse(content);
+ } else if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
+ if (yaml) {
+ return yaml.load(content);
+ } else {
+ console.warn(`Warning: js-yaml not installed. Run 'npm install' to enable YAML support.`);
+ return null;
+ }
+ }
+
+ return null;
+}
+
+// Add x-github-api-versions to the OpenAPI document
+function addApiVersion(doc, apiVersion) {
+ if (!doc || !doc.info) return doc;
+
+ // Clone the document to avoid mutating the original
+ const newDoc = JSON.parse(JSON.stringify(doc));
+
+ // Add or update the x-github-api-versions extension
+ if (!newDoc.info['x-github-api-versions']) {
+ newDoc.info['x-github-api-versions'] = [];
+ }
+
+ if (!newDoc.info['x-github-api-versions'].includes(apiVersion)) {
+ newDoc.info['x-github-api-versions'].push(apiVersion);
+ }
+
+ // Sort versions in descending order (newest first)
+ newDoc.info['x-github-api-versions'].sort((a, b) => b.localeCompare(a));
+
+ return newDoc;
+}
+
+// Extract API version from filename or content
+function extractApiVersion(filePath, doc) {
+ // Try to extract from filename (e.g., api.github.com.2022-11-28.json)
+ const filenameMatch = path.basename(filePath).match(/\.(\d{4}-\d{2}-\d{2})\./);
+ if (filenameMatch) {
+ return filenameMatch[1];
+ }
+
+ // Try to extract from document
+ if (doc?.info?.['x-github-api-versions']?.length > 0) {
+ return doc.info['x-github-api-versions'][0];
+ }
+
+ // Default to the current supported version
+ return '2022-11-28';
+}
+
+// Write OpenAPI document to file
+function writeOpenAPIDocument(doc, outputPath, format) {
+ const dir = path.dirname(outputPath);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+
+ if (format === 'json') {
+ fs.writeFileSync(outputPath, JSON.stringify(doc, null, 2));
+ } else if (format === 'yaml') {
+ if (yaml) {
+ fs.writeFileSync(outputPath, yaml.dump(doc, {
+ lineWidth: -1, // Don't wrap lines
+ noRefs: true, // Don't use YAML references
+ sortKeys: false // Preserve key order
+ }));
+ } else {
+ console.warn(`YAML output requires js-yaml package. Writing as JSON instead.`);
+ fs.writeFileSync(outputPath.replace('.yaml', '.json'), JSON.stringify(doc, null, 2));
+ }
+ }
+}
+
+// Generate output filename with API version
+function generateOutputFilename(release, apiVersion, format) {
+ return `${release}.${apiVersion}.${format}`;
+}
+
+// Process a single release
+function processRelease(releaseName, releaseDir, outputDir, options) {
+ console.log(`\nProcessing release: ${releaseName}`);
+
+ const files = findOpenAPIFiles(releaseDir);
+
+ if (!files.base && files.versioned.length === 0) {
+ console.log(` No OpenAPI files found for ${releaseName}`);
+ return;
+ }
+
+ // Determine which API versions to process
+ let apiVersions = options.versions || [];
+
+ // If no versions specified, extract from existing versioned files
+ if (apiVersions.length === 0 && files.versioned.length > 0) {
+ apiVersions = [...new Set(files.versioned.map(f => f.version))];
+ }
+
+ // If still no versions, use default
+ if (apiVersions.length === 0) {
+ apiVersions = ['2022-11-28'];
+ }
+
+ console.log(` API versions: ${apiVersions.join(', ')}`);
+
+ // Process each API version
+ for (const apiVersion of apiVersions) {
+ // Find source file - prefer versioned file if it exists
+ const versionedFile = files.versioned.find(f => f.version === apiVersion);
+ const sourceFile = versionedFile || files.base;
+
+ if (!sourceFile) {
+ console.log(` Skipping version ${apiVersion}: no source file`);
+ continue;
+ }
+
+ console.log(` Processing version: ${apiVersion} from ${path.basename(sourceFile.path)}`);
+
+ // Load the document
+ const doc = loadOpenAPIDocument(sourceFile.path);
+ if (!doc) {
+ console.log(` Error loading ${sourceFile.path}`);
+ continue;
+ }
+
+ // Add API version to the document
+ const versionedDoc = addApiVersion(doc, apiVersion);
+
+ // Write output files
+ const releaseOutputDir = path.join(outputDir, releaseName);
+
+ if (options.format === 'json' || options.format === 'both') {
+ const jsonPath = path.join(releaseOutputDir, generateOutputFilename(releaseName, apiVersion, 'json'));
+ writeOpenAPIDocument(versionedDoc, jsonPath, 'json');
+ console.log(` Created: ${jsonPath}`);
+ }
+
+ if (options.format === 'yaml' || options.format === 'both') {
+ const yamlPath = path.join(releaseOutputDir, generateOutputFilename(releaseName, apiVersion, 'yaml'));
+ if (yaml) {
+ writeOpenAPIDocument(versionedDoc, yamlPath, 'yaml');
+ console.log(` Created: ${yamlPath}`);
+ } else {
+ console.log(` YAML output requires js-yaml package (skipped): ${yamlPath}`);
+ }
+ }
+ }
+
+ // Also copy base files if they exist and are different from versioned
+ if (files.base) {
+ const doc = loadOpenAPIDocument(files.base.path);
+ if (doc) {
+ const baseOutputDir = path.join(outputDir, releaseName);
+ if (options.format === 'json' || options.format === 'both') {
+ const jsonPath = path.join(baseOutputDir, `${releaseName}.json`);
+
+ // Ensure x-github-api-versions is present even in base
+ const enhancedDoc = addApiVersion(doc, apiVersions[0] || '2022-11-28');
+ writeOpenAPIDocument(enhancedDoc, jsonPath, 'json');
+ console.log(` Created base: ${jsonPath}`);
+ }
+ }
+ }
+}
+
+// Main function
+function main() {
+ const options = parseArgs();
+
+ console.log('OpenAPI Versioned Bundle Builder');
+ console.log('=================================');
+ console.log(`Input directory: ${options.input}`);
+ console.log(`Output directory: ${options.output}`);
+
+ // Get list of releases to process
+ let releases;
+ if (options.releases) {
+ releases = options.releases;
+ } else {
+ releases = getAvailableReleases(options.input);
+ }
+
+ console.log(`Releases to process: ${releases.join(', ')}`);
+
+ // Create output directory
+ if (!fs.existsSync(options.output)) {
+ fs.mkdirSync(options.output, { recursive: true });
+ }
+
+ // Process each release
+ for (const release of releases) {
+ const releaseDir = path.join(options.input, release);
+ processRelease(release, releaseDir, options.output, options);
+ }
+
+ console.log('\n✓ Build complete!');
+}
+
+main();