From f64ed0d50506064c99445e4b72ff0f82b387968e Mon Sep 17 00:00:00 2001 From: "Mr.doob" Date: Fri, 4 Sep 2026 16:32:26 +0900 Subject: [PATCH 1/3] Scripts: Support branches in changelog.js. `node utils/changelog.js dev` generates the changelog from the latest release tag to the given ref. Release tags can sit on a side branch with cherry-picked PRs, so the range uses `--cherry-pick` to skip patches that were already released. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hiak2i5BkymorxmUQTKapR --- utils/changelog.js | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/utils/changelog.js b/utils/changelog.js index 554f997e4f585f..f4771be2a4c847 100644 --- a/utils/changelog.js +++ b/utils/changelog.js @@ -62,8 +62,9 @@ function exec( file, args ) { function getCommitsBetweenTags( fromTag, toTag ) { - // Get commits between tags (exclusive fromTag, inclusive toTag), oldest first, excluding merge commits - const log = exec( 'git', [ 'log', `${fromTag}..${toTag}`, '--no-merges', '--reverse', '--format=%H|%s|%an' ] ); + // Get commits between refs (exclusive fromTag, inclusive toTag), oldest first, excluding merge commits + // --cherry-pick drops commits already released via cherry-pick onto a release branch (same patch, different hash) + const log = exec( 'git', [ 'log', `${fromTag}...${toTag}`, '--right-only', '--cherry-pick', '--no-merges', '--reverse', '--format=%H|%s|%an' ] ); if ( ! log ) return []; @@ -364,27 +365,40 @@ function validateEnvironment( tag ) { if ( ! tag ) { - console.error( 'Usage: node utils/changelog.js ' ); + console.error( 'Usage: node utils/changelog.js ' ); console.error( 'Example: node utils/changelog.js r185' ); + console.error( 'Example: node utils/changelog.js dev' ); process.exit( 1 ); } - // Verify the tag exists + // Verify the ref exists const resolved = exec( 'git', [ 'rev-parse', '--verify', tag ] ); if ( ! resolved ) { - console.error( `Invalid tag: ${tag}` ); + console.error( `Invalid tag or branch: ${tag}` ); process.exit( 1 ); } - // Get the previous tag - const version = parseInt( tag.replace( 'r', '' ) ); - const previousTag = `r${version - 1}`; + let previousTag, version; - const previousResolved = exec( 'git', [ 'rev-parse', '--verify', previousTag ] ); + if ( /^r\d+$/.test( tag ) ) { + + // Release tag: changes since the previous release + version = parseInt( tag.slice( 1 ) ); + previousTag = `r${version - 1}`; + + } else { + + // Branch or commit: changes since the latest release + previousTag = exec( 'git', [ 'tag', '--list', 'r[0-9]*', '--sort=-v:refname' ] ).split( '\n' )[ 0 ]; + version = parseInt( previousTag.slice( 1 ) ) + 1; + + } + + const previousResolved = previousTag && exec( 'git', [ 'rev-parse', '--verify', previousTag ] ); if ( ! previousResolved ) { From 7cab421ee0a0790bfc45e672fdf1702ff401e5f6 Mon Sep 17 00:00:00 2001 From: Michael Herzog Date: Fri, 4 Sep 2026 12:47:35 +0200 Subject: [PATCH 2/3] WebGLTextures: Improve max textures warning. --- src/renderers/webgl/WebGLTextures.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderers/webgl/WebGLTextures.js b/src/renderers/webgl/WebGLTextures.js index 1ead5b07b4cdf5..fc6383c665355d 100644 --- a/src/renderers/webgl/WebGLTextures.js +++ b/src/renderers/webgl/WebGLTextures.js @@ -515,7 +515,7 @@ function WebGLTextures( _gl, extensions, state, properties, capabilities, utils, if ( textureUnit >= capabilities.maxTextures ) { - warn( 'WebGLTextures: Trying to use ' + textureUnit + ' texture units while this GPU supports only ' + capabilities.maxTextures ); + warn( 'WebGLTextures: Trying to use ' + ( textureUnit + 1 ) + ' texture units while this GPU supports only ' + capabilities.maxTextures ); } From 16e1f0369b3ea976a581ca651de868818c9f0a9b Mon Sep 17 00:00:00 2001 From: "Mr.doob" Date: Fri, 4 Sep 2026 19:53:09 +0900 Subject: [PATCH 3/3] Scripts: Improve changelog.js. - Group changes as core, Nodes (src/**/nodes/), Addons, Addons / Nodes (examples/jsm/tsl/ and *Node* addons), then the flat sections with CI last. - A title prefix naming a changed file places the entry in that file's group. - Match title prefixes loosely against section names (Exampler, Doc, e2e, DevTools, ...) for routing and stripping. - Skip dependency updates regardless of prefix or typos, package.json updates and npm audit runs. - Apply the bot list to co-authors and add github-advanced-security[bot]. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Hiak2i5BkymorxmUQTKapR --- utils/changelog.js | 302 ++++++++++++++++++++++----------------------- 1 file changed, 145 insertions(+), 157 deletions(-) diff --git a/utils/changelog.js b/utils/changelog.js index f4771be2a4c847..3647f7096cac96 100644 --- a/utils/changelog.js +++ b/utils/changelog.js @@ -3,6 +3,9 @@ import { execFileSync } from 'child_process'; // Path-based categories (used as fallback for non-JS files) // Ordered from most specific to least specific const categoryPaths = [ + // CI + [ '.github', 'CI' ], + // Specific renderer paths [ 'src/renderers/webgl', 'WebGLRenderer' ], [ 'src/renderers/webgpu', 'WebGPURenderer' ], @@ -27,12 +30,12 @@ const categoryPaths = [ const skipPatterns = [ /^Updated? builds?\.?$/i, /^Merge /i, - /^Update dependency .* to /i, - /^Update devDependencies/i, + /^(\w+:\s*)?Updated? (dev)?dep\w*enc(y|ies)\b/i, // Update dependencies, devDependencies, dependency X to Y (with typos) /^Update github\/codeql-action/i, /^Update actions\//i, /^Bump .* and /i, - /^Updated package-lock\.json/i, + /^(\w+:\s*)?Updated? package(-lock)?\.json/i, + /npm audit/i, /^Update copyright year/i, /^Update \w+\.js\.?$/i, // Generic "Update File.js" commits /^Updated? docs\.?$/i, @@ -41,10 +44,23 @@ const skipPatterns = [ ]; // Authors to skip (bots) -const skipAuthors = new Set( [ 'dependabot', 'app/renovate', 'renovate[bot]' ] ); +const skipAuthors = new Set( [ 'dependabot', 'app/renovate', 'renovate[bot]', 'github-advanced-security[bot]' ] ); // Categories that map to sections -const sectionCategories = [ 'Docs', 'Manual', 'Examples', 'Devtools', 'Editor', 'Tests', 'Utils', 'Build' ]; +const sectionCategories = [ 'Docs', 'Manual', 'Examples', 'Devtools', 'Editor', 'Tests', 'Utils', 'Build', 'CI' ]; + +// Sections matched loosely against title prefixes (Doc, Exampler, e2e, DevTools, Examples/TSL, ...) +const sectionPrefixes = [ + [ /^doc/i, 'Docs' ], + [ /^manual/i, 'Manual' ], + [ /^exampl/i, 'Examples' ], + [ /^devtools/i, 'Devtools' ], + [ /^editor/i, 'Editor' ], + [ /^(test|e2e|puppeteer)/i, 'Tests' ], + [ /^(utils|scripts)/i, 'Utils' ], + [ /^build/i, 'Build' ], + [ /^ci$/i, 'CI' ] +]; function exec( file, args ) { @@ -126,19 +142,13 @@ function getPRInfo( prNumber ) { function categorizeFile( file ) { // Extract category from JS filename in src/ or examples/jsm/ - if ( file.endsWith( '.js' ) ) { - - const isAddon = file.startsWith( 'examples/jsm/' ); + if ( file.endsWith( '.js' ) && ( file.startsWith( 'src/' ) || file.startsWith( 'examples/jsm/' ) ) ) { - if ( file.startsWith( 'src/' ) || isAddon ) { + // Skip barrel/index files + if ( /\/Three(\.\w+)?\.js$/.test( file ) ) return { category: 'Global' }; - // Skip barrel/index files - if ( /\/Three(\.\w+)?\.js$/.test( file ) ) return { category: 'Global', isAddon: false }; - - const match = file.match( /\/([^/]+)\.js$/ ); - if ( match ) return { category: match[ 1 ], isAddon }; - - } + const match = file.match( /\/([^/]+)\.js$/ ); + if ( match ) return { category: match[ 1 ] }; } @@ -147,130 +157,81 @@ function categorizeFile( file ) { if ( file.startsWith( pathPrefix ) ) { - return { - category, - isAddon: file.startsWith( 'examples/jsm/' ), - section: sectionCategories.includes( category ) ? category : null - }; + return { category, section: sectionCategories.includes( category ) ? category : null }; } } - return { category: 'Global', isAddon: false }; + return { category: 'Global' }; } -function categorizeCommit( files ) { - - files = files.filter( f => ! f.startsWith( 'examples/screenshots/' ) ); - - const categoryCounts = {}; - const srcCategoryCounts = {}; - const sectionCounts = {}; - let hasAddon = false; - let addonCategory = null; - let addonCount = 0; - let srcCount = 0; - - for ( const file of files ) { +function getGroup( file ) { - const result = categorizeFile( file ); - const cat = result.category; + // Library groups: core and nodes in src/, addons and node addons in examples/jsm/ + if ( file.startsWith( 'examples/jsm/' ) ) return file.startsWith( 'examples/jsm/tsl/' ) || /Node[^/]*\.js$/.test( file ) ? 'addonNodes' : 'addons'; + if ( file.startsWith( 'src/' ) ) return file.includes( '/nodes/' ) ? 'nodes' : 'core'; - categoryCounts[ cat ] = ( categoryCounts[ cat ] || 0 ) + 1; + return null; - // Track src files vs addon files - if ( file.startsWith( 'src/' ) ) { +} - srcCount ++; - srcCategoryCounts[ cat ] = ( srcCategoryCounts[ cat ] || 0 ) + 1; +function countBy( items ) { - } + const counts = {}; - if ( result.isAddon ) { + for ( const item of items ) { - hasAddon = true; - addonCount ++; + if ( item ) counts[ item ] = ( counts[ item ] || 0 ) + 1; - // Track addon category separately (ignore generic ones) - if ( cat !== 'Examples' && cat !== 'Loaders' && cat !== 'Exporters' ) { + } - if ( ! addonCategory || categoryCounts[ cat ] > categoryCounts[ addonCategory ] ) { + return counts; - addonCategory = cat; +} - } +function maxKey( counts ) { - } else if ( ! addonCategory ) { + let best = null; + let bestCount = 0; - addonCategory = cat; + for ( const [ key, count ] of Object.entries( counts ) ) { - } + if ( count > bestCount ) { - } - - if ( result.section ) { - - sectionCounts[ result.section ] = ( sectionCounts[ result.section ] || 0 ) + 1; + bestCount = count; + best = key; } } - // If commit primarily touches src/ files, don't treat as addon even if it has some addon files - if ( srcCount > addonCount ) { - - hasAddon = false; - - } + return best; - // If this commit has addon files and a specific addon category, use it - if ( hasAddon && addonCategory && addonCategory !== 'Examples' ) { - - return { category: addonCategory, isAddon: true, section: null }; - - } - - // If commit touches src/, treat as core change — category from src/ files only - if ( srcCount > 0 ) { - - const srcCategory = Object.entries( srcCategoryCounts ).sort( ( a, b ) => b[ 1 ] - a[ 1 ] )[ 0 ][ 0 ]; - return { category: srcCategory, isAddon: false, section: null }; - - } +} - // Find the most common section - let maxSection = null; - let maxSectionCount = 0; +function categorizeCommit( files ) { - for ( const [ sec, count ] of Object.entries( sectionCounts ) ) { + files = files.filter( f => ! f.startsWith( 'examples/screenshots/' ) ); - if ( count > maxSectionCount ) { + // Library files win over sections, category from the most touched group only + const group = maxKey( countBy( files.map( getGroup ) ) ); - maxSectionCount = count; - maxSection = sec; + if ( group ) { - } + const categories = files.filter( file => getGroup( file ) === group ).map( file => categorizeFile( file ).category ); + return { category: maxKey( countBy( categories ) ), group, section: null }; } - // Return the category with the most files changed - let maxCategory = 'Global'; - let maxCount = 0; + const results = files.map( categorizeFile ); - for ( const [ cat, count ] of Object.entries( categoryCounts ) ) { - - if ( count > maxCount ) { - - maxCount = count; - maxCategory = cat; - - } - - } - - return { category: maxCategory, isAddon: false, section: maxSection }; + return { + category: maxKey( countBy( results.map( r => r.category ) ) ) || 'Global', + group: null, + section: maxKey( countBy( results.map( r => r.section ) ) ) + }; } @@ -288,17 +249,29 @@ function extractCategoryFromTitle( title ) { } +function sectionFromPrefix( prefix ) { + + const match = sectionPrefixes.find( ( [ pattern ] ) => pattern.test( prefix ) ); + return match ? match[ 1 ] : null; + +} + function cleanSubject( subject, category ) { // Remove PR number from subject let cleaned = subject.replace( /\s*\(#\d+\)\s*$/, '' ).replace( /\s*#\d+\s*$/, '' ).trim(); - // Remove category prefix if it matches (e.g., "Editor: " when category is "Editor") - const prefixPattern = new RegExp( `^${category}:\\s*`, 'i' ); - cleaned = cleaned.replace( prefixPattern, '' ); + // Remove title prefix if it matches the category, including section typos like "Exampler:" + const prefix = extractCategoryFromTitle( cleaned ); + + if ( prefix && ( prefix.toLowerCase() === category.toLowerCase() || sectionFromPrefix( prefix ) === category ) ) { + + cleaned = cleaned.replace( /^[A-Za-z0-9_/]+:\s*/, '' ); + + } // Also remove common prefixes - cleaned = cleaned.replace( /^(Examples|Docs|Manual|Editor|Tests|Build|Global|TSL|WebGLRenderer|WebGPURenderer|Renderer|Scripts|Utils):\s*/i, '' ); + cleaned = cleaned.replace( /^(Examples|Docs|Manual|Editor|Tests|Build|CI|Global|TSL|WebGLRenderer|WebGPURenderer|Renderer|Scripts|Utils):\s*/i, '' ); // Remove trailing period if present, we'll add it back cleaned = cleaned.replace( /\.\s*$/, '' ); @@ -469,9 +442,7 @@ function processCommit( commit, revertedTitles ) { // Skip commits from bots (check normalized name for git author fallback) if ( skipAuthors.has( normalizeAuthor( author ) ) ) return null; - const result = categorizeCommit( files ); - let { category, section } = result; - const { isAddon } = result; + let { category, group, section } = categorizeCommit( files ); // Use title prefix as category only if file-based didn't assign a section if ( ! section ) { @@ -480,10 +451,22 @@ function processCommit( commit, revertedTitles ) { if ( titleCategory ) { - category = titleCategory; - if ( category === 'Scripts' ) category = 'Utils'; - if ( category === 'Puppeteer' || category === 'E2E' ) category = 'Tests'; - section = sectionCategories.includes( category ) ? category : null; + section = sectionFromPrefix( titleCategory ); + category = section || titleCategory; + + // A title naming a changed file belongs to that file's group + const titleFile = files.find( file => file.endsWith( `/${category}.js` ) ); + + if ( titleFile && getGroup( titleFile ) ) { + + group = getGroup( titleFile ); + + } else if ( group === 'core' || group === 'nodes' ) { + + // Generic src titles: node system names go to Nodes, the rest stays in core + group = /Node|^TSL$/.test( category ) ? 'nodes' : 'core'; + + } } @@ -496,7 +479,10 @@ function processCommit( commit, revertedTitles ) { } - const coAuthors = ( prNumber ? getCoAuthorsFromPR( prNumber ) : getCoAuthorsFromCommit( commit.hash ) ).filter( login => login !== author ); + // Section entries are flat, so the section is what a title prefix is matched against + if ( section ) category = section; + + const coAuthors = ( prNumber ? getCoAuthorsFromPR( prNumber ) : getCoAuthorsFromCommit( commit.hash ) ).filter( login => login !== author && ! skipAuthors.has( login ) ); return { entry: { @@ -507,34 +493,30 @@ function processCommit( commit, revertedTitles ) { formatted: formatEntry( subject, prNumber, commit.hash, author, coAuthors, category ) }, category, - section, - isAddon + group, + section }; } -function formatOutput( version, coreChanges, addonChanges, sections ) { - - let output = ''; - - const previousVersion = version - 1; - output += `https://github.com/mrdoob/three.js/wiki/Migration-Guide#${previousVersion}--${version}\n`; - output += `https://github.com/mrdoob/three.js/milestone/${version - 87}?closed=1\n\n`; +function formatGroups( groups, first ) { - // Core changes (Global first, then alphabetically) - const sortedCore = Object.keys( coreChanges ).sort( ( a, b ) => { + // Categories alphabetically, with one pinned first + const sorted = Object.keys( groups ).sort( ( a, b ) => { - if ( a === 'Global' ) return - 1; - if ( b === 'Global' ) return 1; + if ( a === first ) return - 1; + if ( b === first ) return 1; return a.localeCompare( b ); } ); - for ( const category of sortedCore ) { + let output = ''; + + for ( const category of sorted ) { output += `- ${category}\n`; - for ( const entry of coreChanges[ category ] ) { + for ( const entry of groups[ category ] ) { output += ` - ${entry.formatted}\n`; @@ -542,39 +524,49 @@ function formatOutput( version, coreChanges, addonChanges, sections ) { } - // Output sections in order - const sectionOrder = [ 'Docs', 'Manual', 'Examples', 'Addons', 'Devtools', 'Editor', 'Tests', 'Utils', 'Build' ]; + return output; - for ( const sectionName of sectionOrder ) { +} - // Addons section has nested categories - if ( sectionName === 'Addons' ) { +function formatOutput( version, groups, sections ) { - const sortedAddons = Object.keys( addonChanges ).sort(); + let output = ''; - if ( sortedAddons.length > 0 ) { + const previousVersion = version - 1; + output += `https://github.com/mrdoob/three.js/wiki/Migration-Guide#${previousVersion}--${version}\n`; + output += `https://github.com/mrdoob/three.js/milestone/${version - 87}?closed=1\n\n`; - output += '\n**Addons**\n\n'; + // Core changes + output += formatGroups( groups.core, 'Global' ); - for ( const category of sortedAddons ) { + // Node system changes in src/ + if ( Object.keys( groups.nodes ).length > 0 ) { - output += `- ${category}\n`; + output += '\n**Nodes**\n\n'; + output += formatGroups( groups.nodes, 'Nodes' ); - for ( const entry of addonChanges[ category ] ) { + } - output += ` - ${entry.formatted}\n`; + // Addons in examples/jsm/ + if ( Object.keys( groups.addons ).length > 0 ) { - } + output += '\n**Addons**\n\n'; + output += formatGroups( groups.addons, 'Addons' ); - output += '\n'; + } - } + // Node system addons in examples/jsm/ + if ( Object.keys( groups.addonNodes ).length > 0 ) { - } + output += '\n**Addons / Nodes**\n\n'; + output += formatGroups( groups.addonNodes, 'TSL' ); - continue; + } - } + // Output sections in order + const sectionOrder = [ 'Docs', 'Manual', 'Examples', 'Devtools', 'Editor', 'Tests', 'Utils', 'Build', 'CI' ]; + + for ( const sectionName of sectionOrder ) { if ( sections[ sectionName ].length > 0 ) { @@ -614,8 +606,7 @@ function generateChangelog() { const revertedTitles = collectRevertedTitles( commits ); // Group commits by category - const coreChanges = {}; - const addonChanges = {}; + const groups = { core: {}, nodes: {}, addons: {}, addonNodes: {} }; const sections = { Docs: [], Manual: [], @@ -624,7 +615,8 @@ function generateChangelog() { Editor: [], Tests: [], Utils: [], - Build: [] + Build: [], + CI: [] }; let skipped = 0; @@ -649,19 +641,15 @@ function generateChangelog() { } - const { entry, category, section, isAddon } = result; + const { entry, category, group, section } = result; if ( section && sections[ section ] ) { sections[ section ].push( entry ); - } else if ( isAddon ) { - - addToGroup( addonChanges, category, entry ); - } else { - addToGroup( coreChanges, category, entry ); + addToGroup( groups[ group || 'core' ], category, entry ); } @@ -675,7 +663,7 @@ function generateChangelog() { } - console.log( formatOutput( version, coreChanges, addonChanges, sections ) ); + console.log( formatOutput( version, groups, sections ) ); }