diff --git a/bin/ncu-team.js b/bin/ncu-team.js index c2d7ad59..30372b76 100755 --- a/bin/ncu-team.js +++ b/bin/ncu-team.js @@ -45,6 +45,25 @@ yargs(hideBin(process.argv)) }, handler }) + .command({ + command: 'check-gpg', + desc: 'Check that all the team members have a valid GPG key', + builder: (yargs) => { + yargs + .option('org', { + describe: 'Name of the organization', + type: 'string', + default: 'nodejs' + }); + yargs + .option('team', { + describe: 'Name of the team', + type: 'string', + default: 'releasers' + }); + }, + handler + }) .demandCommand(1, 'must provide a valid command') .help() .parse(); @@ -70,6 +89,10 @@ async function main(argv) { case 'sync': await TeamInfo.syncFile(cli, request, argv.file); break; + case 'check-gpg': + const info = new TeamInfo(cli, request, argv.org, argv.team); + await info.checkTeamPGPKeys(); + break; default: throw new Error(`Unknown command ${command}`); } diff --git a/docs/ncu-team.md b/docs/ncu-team.md index ab8a21f6..e6a5ff81 100644 --- a/docs/ncu-team.md +++ b/docs/ncu-team.md @@ -71,3 +71,9 @@ will update the file with text like this: ``` + +### Check GPG Releasers Signature + +``` +$ ncu-team check-gpg +``` \ No newline at end of file diff --git a/lib/team_info.js b/lib/team_info.js index 09ff36f0..6acbf646 100644 --- a/lib/team_info.js +++ b/lib/team_info.js @@ -1,5 +1,5 @@ import { readFile, writeFile } from './file.js'; -import { ascending } from './utils.js'; +import { ascending, extractReleasersFromReadme, checkReleaserDiscrepancies } from './utils.js'; const TEAM_QUERY = 'Team'; @@ -37,6 +37,13 @@ export default class TeamInfo { return sorted; } + async getGpgPublicKey(login) { + const { request } = this; + const url = `https://api.github.com/users/${login}/gpg_keys`; + const result = await request.json(url); + return result; + } + async getMemberContacts() { const members = await this.getMembers(); return members.map(getContact).join('\n'); @@ -46,6 +53,39 @@ export default class TeamInfo { const contacts = await this.getMemberContacts(); this.cli.log(contacts); } + + async checkTeamPGPKeys() { + const { cli } = this; + cli.startSpinner(`Collecting Members details of ${this.org}/${this.team}`); + const members = await this.getMembers(); + cli.stopSpinner(`Collecting Members details of ${this.org}/${this.team}`); + + cli.startSpinner(`Collecting PGP keys of ${this.org}/${this.team}`); + const keys = await Promise.all(members.map(member => this.getGpgPublicKey(member.login))); + // Add keys to members + members.forEach((member, index) => { + member.keys = keys[index]; + }); + cli.stopSpinner(`Collecting PGP keys of ${this.org}/${this.team}`); + + cli.startSpinner('Collecting Release members from Readme.md'); + const readmeTxt = await this.request.text('https://raw.githubusercontent.com/nodejs/node/main/README.md'); + const extractedMembers = extractReleasersFromReadme(readmeTxt); + cli.stopSpinner('Collecting Release members from Readme.md'); + + // Checks per member + cli.startSpinner('Checking discrepancies between members and readme.md'); + + for (const member of members) { + if (!member.keys || !member.keys.length) { + console.error(`The releaser ${member.name} (${member.login}) has no keys associated with their account`); + } + checkReleaserDiscrepancies(member, extractedMembers); + // @TODO: Check if the GPG key is available in https://keys.openpgp.org/ + } + + cli.stopSpinner('Checking discrepancies between members and readme.md'); + } } TeamInfo.syncFile = async function(cli, request, input, output) { diff --git a/lib/utils.js b/lib/utils.js index 366ece45..5ac976e9 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -64,3 +64,64 @@ export async function getEditor(options = {}) { return process.env.VISUAL || process.env.EDITOR || null; }; + +/** + * Extracts the releasers' information from the provided markdown txt. + * Each releaser's information includes their name, email, and GPG key. + * + * @param {string} txt - The README content. + * @returns {Array>} An array of releaser information arrays. + * Each sub-array contains the name, email, + * and GPG key of a releaser. + */ +export function extractReleasersFromReadme(txt) { + const regex = /\* \*\*(.*)\*\*.*<<(.*)>>\n.*`(.*)`/gm; + let match; + const result = []; + while ((match = regex.exec(txt)) !== null) { + // This is necessary to avoid infinite loops with zero-width matches + if (match.index === regex.lastIndex) { + regex.lastIndex++; + } + const [, name, email, key] = match; + result.push([name, email, key]); + } + return result; +} + +export function checkReleaserDiscrepancies(member, extractedMembers) { + let releaseKey, extractedMember; + member.keys.forEach(key => { + extractedMembers.filter(eMember => { + if (eMember[2].includes(key.key_id)) { + extractedMember = eMember; + releaseKey = key; + } + }); + }); + + if (!extractedMember || !releaseKey) { + console.error(`The releaser ${member.name} (${member.login}) is not listed or any of the current profile GPG keys are listed in README.md`); + return; + } + + if (!releaseKey.emails.some(({ email }) => email === extractedMember[1])) { + console.error(`The releaser ${member.name} (${member.login}) has a key (${releaseKey.key_id}) that is not associated with their email address ${extractedMember[1]} in the README.md`); + } + + if (!releaseKey.can_sign) { + console.error(`The releaser ${member.name} (${member.login}) has a key (${releaseKey.key_id}) that cannot sign`); + } + + if (!releaseKey.can_certify) { + console.error(`The releaser ${member.name} (${member.login}) has a key (${releaseKey.key_id}) that cannot certify`); + } + + if (!releaseKey.expires_at) { + console.error(`The releaser ${member.name} (${member.login}) has a key (${releaseKey.key_id}) that cannot expire`); + } + + if (releaseKey.revoked) { + console.error(`The releaser ${member.name} (${member.login}) has a key (${releaseKey.key_id}) that has been revoked`); + } +}