Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 75 additions & 135 deletions scripts/releaseChangelog.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import childProcess from 'child_process';
import { promisify } from 'util';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { Octokit } from '@octokit/rest';
import chalk from 'chalk';
import {
fetchCommitsBetweenRefs,
findLatestTaggedVersion,
} from '@mui/internal-code-infra/changelog';
import yargs from 'yargs';

const exec = promisify(childProcess.exec);
/**
* @TODO: Add it to @mui/internal-code-infra/changelog
*
* @param {string} login
* @returns {boolean}
*/
function isBot(login) {
return login.endsWith('[bot]') && !login.includes('copilot');
}

/**
* @param {string} commitMessage
Expand All @@ -27,168 +38,109 @@ function parseTags(commitMessage) {
.join(',');
}

// Match commit messages like:
// "[docs] Fix small typo on Grid2 page (#44062)"
const prLinkRegEx = /\(#[0-9]+\)$/;

/**
* @param {Octokit.ReposCompareCommitsResponseCommitsItem} commitsItem
*
* @param {import('@mui/internal-code-infra/changelog').FetchedCommitDetails[]} commits
* @returns {string[]}
*/
function filterCommit(commitsItem) {
const commitMessage = commitsItem.commit.message;
// TODO: Use labels
return (
// Filter renovate dependencies updates
!commitMessage.startsWith('Bump') &&
!commitMessage.startsWith('Lock file maintenance') &&
// Filter website changes, no implications for library users
!commitMessage.startsWith('[website]')
);
}

async function findLatestTaggedVersion() {
const { stdout } = await exec(
[
'git',
'describe',
// Earlier tags used lightweight tags + commit.
// We switched to annotated tags later.
'--tags',
'--abbrev=0',
// only include "version-tags"
'--match "v*"',
].join(' '),
function getAllContributors(commits) {
const authors = Array.from(
new Set(
commits
.filter((commit) => !!commit.author?.login)
.map((commit) => {
Copy link
Member

@Janpot Janpot Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brijeshb42 nitpick, but any reason to use a different function style here vs.the above?

Suggested change
.map((commit) => {
.map((commit) => commit.author.login)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually i copied it over from Base UI's code.

return commit.author.login;
}),
),
);

return stdout.trim();
return authors.sort((a, b) => a.localeCompare(b)).map((author) => `@${author}`);
}

// Match commit messages like:
// "[docs] Fix small typo on Grid2 page (#44062)"
const prLinkRegEx = /\(#[0-9]+\)$/;

async function main(argv) {
const { githubToken, lastRelease: lastReleaseInput, release, repo } = argv;
const { lastRelease: previousReleaseParam, release } = argv;

if (!githubToken) {
throw new TypeError(
'Unable to authenticate. Make sure you either call the script with `--githubToken $token` or set `process.env.GITHUB_TOKEN`. The token needs `public_repo` permissions.',
);
}
const octokit = new Octokit({
auth: githubToken,
request: {
fetch,
},
const latestTaggedVersion = await findLatestTaggedVersion({
cwd: process.cwd(),
fetchAll: false,
});

const latestTaggedVersion = await findLatestTaggedVersion();
const lastRelease = lastReleaseInput !== undefined ? lastReleaseInput : latestTaggedVersion;
if (lastRelease !== latestTaggedVersion) {
const previousRelease =
previousReleaseParam !== undefined ? previousReleaseParam : latestTaggedVersion;
if (previousRelease !== latestTaggedVersion) {
console.warn(
`Creating changelog for ${lastRelease}..${release} when latest tagged version is '${latestTaggedVersion}'.`,
`Creating changelog for ${previousRelease}..${release} while the latest tagged version is '${latestTaggedVersion}'.`,
);
}

/**
* @type {AsyncIterableIterator<Octokit.Response<Octokit.ReposCompareCommitsResponse>>}
*/
const timeline = octokit.paginate.iterator(
octokit.repos.compareCommits.endpoint.merge({
owner: 'mui',
repo,
base: lastRelease,
head: release,
}),
);

/**
* @type {Octokit.ReposCompareCommitsResponseCommitsItem[]}
*/
const commitsItems = [];
for await (const response of timeline) {
const { data: compareCommits } = response;
commitsItems.push(...compareCommits.commits.filter(filterCommit));
if (process.env.GITHUB_TOKEN) {
console.warn(
`Using GITHUB_TOKEN from environment variables have been deprecated. Please remove it if set locally.`,
);
}

let warnedOnce = false;

const getAuthor = (commit) => {
if (!commit.author) {
if (!warnedOnce) {
console.warn(
`The author of the commit: ${commit.commit.tree.url} cannot be retrieved. Please add the github username manually.`,
);
}
warnedOnce = true;
return chalk.red("TODO INSERT AUTHOR'S USERNAME");
}

const authorLogin = commit.author.login;

if (authorLogin === 'github-actions[bot]') {
const authorFromMessage = /\(@(?<author>[a-zA-Z0-9-_]+)\) \(#[\d]+\)/.exec(
commit.commit.message.split('\n')[0],
);
if (authorFromMessage.groups?.author) {
return authorFromMessage.groups.author;
}
}
const commitsItems = (
await fetchCommitsBetweenRefs({
lastRelease: previousRelease,
release,
repo: 'material-ui',
octokit: process.env.GITHUB_TOKEN
? new Octokit({ auth: process.env.GITHUB_TOKEN })
: undefined,
})
).filter((commit) => !isBot(commit.author.login) && !commit.message.startsWith('[website]'));

return authorLogin;
};

const authors = Array.from(
new Set(
commitsItems.map((commitsItem) => {
return getAuthor(commitsItem);
}),
),
);
const contributorHandles = authors
.sort((a, b) => a.localeCompare(b))
.map((author) => `@${author}`)
.join(', ');
const contributorHandles = getAllContributors(commitsItems);

// We don't know when a particular commit was made from the API.
// Only that the commits are ordered by date ASC
const commitsItemsByDateDesc = commitsItems.slice().reverse();
const commitsItemsByOrder = new Map(commitsItems.map((item, index) => [item, index]));
// Sort by tags ASC, date desc
// Will only consider exact matches of tags so `[Slider]` will not be grouped with `[Slider][Modal]`
commitsItems.sort((a, b) => {
const aTags = parseTags(a.commit.message);
const bTags = parseTags(b.commit.message);
const aTags = parseTags(a.message);
const bTags = parseTags(b.message);
if (aTags === bTags) {
return commitsItemsByDateDesc.indexOf(a) - commitsItemsByDateDesc.indexOf(b);
return commitsItemsByOrder.get(b) - commitsItemsByOrder.get(a);
}
return aTags.localeCompare(bTags);
});
const changes = commitsItems.map((commitsItem) => {
let shortMessage = commitsItem.commit.message.split('\n')[0];
let shortMessage = commitsItem.message.split('\n')[0];

// If the commit message doesn't have an associated PR, add the commit sha for reference.
if (!prLinkRegEx.test(shortMessage)) {
shortMessage += ` (${commitsItem.sha.substring(0, 7)})`;
}

return `- ${shortMessage} @${getAuthor(commitsItem)}`;
return `- ${shortMessage} @${commitsItem.author.login}`;
});
const nowFormatted = new Date().toLocaleDateString('en-US', {
const generationDate = new Date().toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
const releaseName = /** @type {string} */ (
JSON.parse(await fs.readFile(path.join(process.cwd(), 'package.json'), 'utf-8')).version
);

const changelog = `
## TODO RELEASE NAME
<!-- generated comparing ${lastRelease}..${release} -->
_${nowFormatted}_
## ${releaseName}

<!-- generated comparing ${previousRelease}..${release} -->

_${generationDate}_

A big thanks to the ${
authors.length
} contributors who made this release possible. Here are some highlights ✨:
A big thanks to the ${contributorHandles.length} contributors who made this release possible. Here are some highlights ✨:

TODO INSERT HIGHLIGHTS

${changes.join('\n')}

All contributors of this release in alphabetical order: ${contributorHandles}
All contributors of this release in alphabetical order: ${contributorHandles.join(', ')}
`;

// eslint-disable-next-line no-console -- output of this script
Expand All @@ -199,31 +151,19 @@ yargs(process.argv.slice(2))
.command({
command: '$0',
description: 'Creates a changelog',
builder: (command) => {
return command
builder: (command) =>
command
.option('lastRelease', {
describe:
'The release to compare against e.g. `v5.0.0-alpha.23`. Default: The latest tag on the current branch.',
type: 'string',
})
.option('githubToken', {
default: process.env.GITHUB_TOKEN,
describe:
'The personal access token to use for authenticating with GitHub. Needs public_repo permissions.',
type: 'string',
})
.option('release', {
// #target-branch-reference
default: 'master',
describe: 'Ref which we want to release',
type: 'string',
})
.option('repo', {
default: 'material-ui',
describe: 'Repository to generate a changelog for',
type: 'string',
});
},
}),
handler: main,
})
.help()
Expand Down
Loading