jquery/build/release/changelog.js
Timmy Willison 2cf659189e Release: migrate release process to release-it
*Authors*
- Checking and updating authors has been migrated
  to a custom script in the repo

*Changelog*
- changelogplease is no longer maintained
- generate changelog in markdown for GitHub releases
- generate changelog in HTML for blog posts
- generate contributors list in HTML for blog posts

*dist*
- clone dist repo, copy files, and commit/push
- commit tag with dist files on main branch;
  remove dist files from main branch after release

*cdn*
- clone cdn repo, copy files, and commit/push
- create versioned and unversioned copies in cdn/
- generate md5 sums and archives for Google and MSFT

*build*
- implement reproducible builds and verify release builds
  * uses the last modified date for the latest commit
  * See https://reproducible-builds.org/
- the verify workflow also ensures all files were
  properly published to the CDN and npm

*docs*
- the new release workflow is documented at build/release/README.md

*verify*
- use the last modified date of the commit before the tag
- use versioned filenames when checking map files on the CDN
- skip factory and package.json files when verifying CDN

*misc*
- now that we don't need the jquery-release script and
  now that we no longer need to build on Node 10, we can
  use ESM in all files in the build folder
- limit certain workflows to the main repo (not forks)
- version has been set to the previously released version 3.7.1,
  as release-it expects
- release-it added the `preReleaseBase` option and we
  now always set it to `1` in the npm script. This is
  a noop for stable releases.
- include post-release script to be run manually after a release,
  with further steps that should be verified manually

Ref jquery/jquery-release#114
Closes gh-5522
2024-07-29 15:25:14 -04:00

240 lines
5.9 KiB
JavaScript

import { writeFile } from "node:fs/promises";
import { argv } from "node:process";
import { exec as nodeExec } from "node:child_process";
import util from "node:util";
import { marked } from "marked";
const exec = util.promisify( nodeExec );
const rbeforeHash = /.#$/;
const rendsWithHash = /#$/;
const rcherry = / \(cherry picked from commit [^)]+\)/;
const rcommit = /Fix(?:e[sd])? ((?:[a-zA-Z0-9_-]{1,39}\/[a-zA-Z0-9_-]{1,100}#)|#|gh-)(\d+)/g;
const rcomponent = /^([^ :]+):\s*([^\n]+)/;
const rnewline = /\r?\n/;
const prevVersion = argv[ 2 ];
const nextVersion = argv[ 3 ];
const blogUrl = process.env.BLOG_URL;
if ( !prevVersion || !nextVersion ) {
throw new Error( "Usage: `node changelog.js PREV_VERSION NEXT_VERSION`" );
}
function ticketUrl( ticketId ) {
return `https://github.com/jquery/jquery/issues/${ ticketId }`;
}
function getTicketsForCommit( commit ) {
var tickets = [];
commit.replace( rcommit, function( _match, refType, ticketId ) {
var ticket = {
url: ticketUrl( ticketId ),
label: "#" + ticketId
};
// If the refType has anything before the #, assume it's a GitHub ref
if ( rbeforeHash.test( refType ) ) {
// console.log( refType );
refType = refType.replace( rendsWithHash, "" );
ticket.url = `https://github.com/${ refType }/issues/${ ticketId }`;
ticket.label = refType + ticket.label;
}
tickets.push( ticket );
} );
return tickets;
}
async function getCommits() {
const format =
"__COMMIT__%n%s (__TICKETREF__[%h](https://github.com/jquery/jquery/commit/%H))%n%b";
const { stdout } = await exec(
`git log --format="${ format }" ${ prevVersion }..${ nextVersion }`
);
const commits = stdout.split( "__COMMIT__" ).slice( 1 );
return removeReverts( commits.map( parseCommit ).sort( sortCommits ) );
}
function parseCommit( commit ) {
const tickets = getTicketsForCommit( commit )
.map( ( ticket ) => {
return `[${ ticket.label }](${ ticket.url })`;
} )
.join( ", " );
// Drop the commit message body
let message = `${ commit.trim().split( rnewline )[ 0 ] }`;
// Add any ticket references
message = message.replace( "__TICKETREF__", tickets ? `${ tickets }, ` : "" );
// Remove cherry pick references
message = message.replace( rcherry, "" );
return message;
}
function sortCommits( a, b ) {
const aComponent = rcomponent.exec( a );
const bComponent = rcomponent.exec( b );
if ( aComponent && bComponent ) {
if ( aComponent[ 1 ] < bComponent[ 1 ] ) {
return -1;
}
if ( aComponent[ 1 ] > bComponent[ 1 ] ) {
return 1;
}
return 0;
}
if ( a < b ) {
return -1;
}
if ( a > b ) {
return 1;
}
return 0;
}
/**
* Remove all revert commits and the commit it is reverting
*/
function removeReverts( commits ) {
const remove = [];
commits.forEach( function( commit ) {
const match = /\*\s*Revert "([^"]*)"/.exec( commit );
// Ignore double reverts
if ( match && !/^Revert "([^"]*)"/.test( match[ 0 ] ) ) {
remove.push( commit, match[ 0 ] );
}
} );
remove.forEach( function( message ) {
const index = commits.findIndex( ( commit ) => commit.includes( message ) );
if ( index > -1 ) {
// console.log( "Removing ", commits[ index ] );
commits.splice( index, 1 );
}
} );
return commits;
}
function addHeaders( commits ) {
const components = {};
let markdown = "";
commits.forEach( function( commit ) {
const match = rcomponent.exec( commit );
if ( match ) {
let component = match[ 1 ];
if ( !/^[A-Z]/.test( component ) ) {
component =
component.slice( 0, 1 ).toUpperCase() +
component.slice( 1 ).toLowerCase();
}
if ( !components[ component.toLowerCase() ] ) {
markdown += "\n## " + component + "\n\n";
components[ component.toLowerCase() ] = true;
}
markdown += `- ${ match[ 2 ] }\n`;
} else {
markdown += `- ${ commit }\n`;
}
} );
return markdown;
}
async function getGitHubContributor( sha ) {
const response = await fetch(
`https://api.github.com/repos/jquery/jquery/commits/${ sha }`,
{
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${ process.env.JQUERY_GITHUB_TOKEN }`,
"X-GitHub-Api-Version": "2022-11-28"
}
}
);
const data = await response.json();
if ( !data.commit || !data.author ) {
// The data may contain multiple helpful fields
throw new Error( JSON.stringify( data ) );
}
return { name: data.commit.author.name, url: data.author.html_url };
}
function uniqueContributors( contributors ) {
const seen = {};
return contributors.filter( ( contributor ) => {
if ( seen[ contributor.name ] ) {
return false;
}
seen[ contributor.name ] = true;
return true;
} );
}
async function getContributors() {
const { stdout } = await exec(
`git log --format="%H" ${ prevVersion }..${ nextVersion }`
);
const shas = stdout.split( rnewline ).filter( Boolean );
const contributors = await Promise.all( shas.map( getGitHubContributor ) );
return uniqueContributors( contributors )
// Sort by last name
.sort( ( a, b ) => {
const aName = a.name.split( " " );
const bName = b.name.split( " " );
return aName[ aName.length - 1 ].localeCompare( bName[ bName.length - 1 ] );
} )
.map( ( { name, url } ) => {
if ( name === "Timmy Willison" || name.includes( "dependabot" ) ) {
return;
}
return `<a href="${ url }">${ name }</a>`;
} )
.filter( Boolean ).join( "\n" );
}
async function generate() {
const commits = await getCommits();
const contributors = await getContributors();
let changelog = "# Changelog\n";
if ( blogUrl ) {
changelog += `\n${ blogUrl }\n`;
}
changelog += addHeaders( commits );
// Write markdown to changelog.md
await writeFile( "changelog.md", changelog );
// Write HTML to changelog.html for blog post
await writeFile( "changelog.html", marked.parse( changelog ) );
// Write contributors HTML for blog post
await writeFile( "contributors.html", contributors );
// Log regular changelog for release-it
console.log( changelog );
return changelog;
}
generate();