Skip to content
Merged
Show file tree
Hide file tree
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
86 changes: 86 additions & 0 deletions .github/workflows/changelog-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: Check Changelog

on:
workflow_call:
inputs:
base-branch:
required: false
type: string
head-ref:
required: true
type: string
labels:
description: 'JSON string of PR labels'
required: true
type: string
repo:
description: 'The repository to check'
required: true
type: string
secrets:
gh-token:
required: true

jobs:
check-changelog:
runs-on: ubuntu-latest
steps:
- name: Check PR Labels
id: label-check
env:
PR_LABELS: ${{ inputs.labels }}
run: |
if echo "$PR_LABELS" | jq -e '.[] | select(.name == "no-changelog")' > /dev/null; then
echo "no-changelog label found, skipping changelog check."
echo "skip_check=true" >> "$GITHUB_OUTPUT"
else
echo "No no-changelog label found, proceeding with check."
echo "skip_check=false" >> "$GITHUB_OUTPUT"
fi
shell: bash

- name: Check out target repository
if: ${{ steps.label-check.outputs.skip_check != 'true' }}
uses: actions/checkout@v4
with:
repository: ${{ inputs.repo }}
ref: ${{ inputs.head-ref }}
path: target-repo
fetch-depth: 0

- name: Checkout github-tools repository
if: ${{ steps.label-check.outputs.skip_check != 'true' }}
uses: actions/checkout@v4
with:
repository: MetaMask/github-tools
ref: main
path: github-tools

- name: Enable Corepack
if: ${{ steps.label-check.outputs.skip_check != 'true' }}
run: corepack enable
shell: bash

- name: Set up Node.js
if: ${{ steps.label-check.outputs.skip_check != 'true' }}
uses: actions/setup-node@v4
with:
node-version-file: ./github-tools/.nvmrc
cache-dependency-path: ./github-tools/yarn.lock
cache: yarn

- name: Install dependencies
if: ${{ steps.label-check.outputs.skip_check != 'true' }}
run: yarn --immutable
shell: bash
working-directory: ./github-tools

- name: Check Changelog
if: ${{ steps.label-check.outputs.skip_check != 'true' }}
id: changelog-check
shell: bash
working-directory: ./github-tools
env:
BASE_BRANCH: ${{ inputs.base-branch || 'main' }}
run: |
yarn run changelog:check ../target-repo "$BASE_BRANCH"
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"description": "Tools for interacting with the GitHub API to do metrics gathering",
"scripts": {
"changelog:check": "ts-node src/changelog-check.ts",
"count-references-to-contributor-docs": "ts-node --swc src/scripts/count-references-to-contributor-docs/cli.ts",
"gen:commits": "node .github/scripts/generate-rc-commits.mjs",
"get-review-metrics": "ts-node src/get-review-metrics.ts",
Expand All @@ -20,6 +21,7 @@
"update-release-sheet": "node .github/scripts/update-release-sheet.mjs"
},
"dependencies": {
"@metamask/auto-changelog": "^3.4.4",
"@metamask/utils": "^7.1.0",
"@octokit/graphql": "^7.0.1",
"@octokit/request": "^8.1.1",
Expand All @@ -28,6 +30,7 @@
"@types/luxon": "^3.3.0",
"axios": "^0.24.0",
"csv-parse": "5.6.0",
"execa": "^9.5.2",
"googleapis": "144.0.0",
"luxon": "^3.3.0",
"ora": "^5.4.1",
Expand Down
259 changes: 259 additions & 0 deletions src/changelog-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import { parseChangelog } from '@metamask/auto-changelog';
import { execa } from 'execa';
import fs from 'fs/promises';
import path from 'path';

type PackageJson = {
workspaces: string[];
};

/**
* Gets the workspace patterns from package.json.
*
* @param repoPath - The path to the repository.
* @returns Array of workspace patterns.
*/
async function getWorkspacePatterns(repoPath: string): Promise<string[]> {
const packageJsonPath = path.join(repoPath, 'package.json');
const content = await fs.readFile(packageJsonPath, 'utf-8');
const packageJson = JSON.parse(content) as PackageJson;

if (!Array.isArray(packageJson.workspaces)) {
return [];
}

return packageJson.workspaces;
}

/**
* This function gets the workspace base and package name from the file path.
*
* @param filePath - The path to the file.
* @param workspacePatterns - The workspace patterns.
* @returns An object containing the base directory and package name, or null if no match is found.
*/
function getPackageInfo(
filePath: string,
workspacePatterns: string[],
): { base: string; package: string } | null {
for (const pattern of workspacePatterns) {
// Extract the base directory (everything before the *)
const wildcardIndex = pattern.indexOf('*');
if (wildcardIndex === -1) {
continue;
}

const baseDir = pattern.substring(0, wildcardIndex);

if (filePath.startsWith(baseDir)) {
// Extract the package name (everything between baseDir and the next slash)
const remainingPath = filePath.substring(baseDir.length);
const nextSlashIndex = remainingPath.indexOf('/');

if (nextSlashIndex !== -1) {
const packageName = remainingPath.substring(0, nextSlashIndex);
return {
base: baseDir,
package: packageName,
};
}
}
}

return null;
}

/**
* Gets the list of changed files between the current branch and baseRef.
*
* @param repoPath - The path to the repository.
* @param baseRef - The base reference to compare against.
* @returns Array of changed file paths.
*/
async function getChangedFiles(
repoPath: string,
baseRef: string,
): Promise<string[]> {
try {
await execa('git', ['fetch', 'origin', baseRef], {
cwd: repoPath,
});

const { stdout } = await execa(
'git',
['diff', '--name-only', `origin/${baseRef}...HEAD`],
{
cwd: repoPath,
},
);

return stdout.split('\n').filter(Boolean);
} catch (error) {
console.error('Failed to get changed files:', error);
throw error;
}
}

/**
* Reads and validates a changelog file.
*
* @param changelogPath - The path to the changelog file to check.
*/
async function checkChangelogFile(changelogPath: string): Promise<void> {
try {
const changelogContent = await fs.readFile(changelogPath, 'utf-8');

if (!changelogContent) {
throw new Error('CHANGELOG.md is empty or missing');
}

const changelogUnreleasedChanges = parseChangelog({
changelogContent,
repoUrl: '', // Not needed as we're only parsing unreleased changes
}).getReleaseChanges('Unreleased');

if (Object.values(changelogUnreleasedChanges).length === 0) {
throw new Error(
"❌ No new entries detected under '## Unreleased' section. Please update the changelog.",
);
}
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
throw new Error(`❌ CHANGELOG.md not found at ${changelogPath}`);
}
throw error;
}
}

/**
* Gets the list of changed packages from the changed files.
*
* @param files - The list of changed files.
* @param workspacePatterns - The workspace patterns.
* @returns Array of changed package information.
*/
async function getChangedPackages(
files: string[],
workspacePatterns: string[],
): Promise<
{
base: string;
package: string;
}[]
> {
const changedPackages = new Map<string, { base: string; package: string }>();

for (const file of files) {
// Skip workflow files
if (file.startsWith('.github/workflows/')) {
continue;
}

const packageInfo = getPackageInfo(file, workspacePatterns);
if (packageInfo) {
// Skip test files, docs, and changelog files
if (
!file.match(/\.(test|spec)\./u) &&
!file.includes('__tests__/') &&
!file.includes('/docs/') &&
!file.endsWith('CHANGELOG.md')
) {
changedPackages.set(packageInfo.package, packageInfo);
}
}
}

return Array.from(changedPackages.values());
}

/**
* Main function to run the changelog check.
*/
async function main() {
// Parse command-line arguments
const args = process.argv.slice(2);

const [repoPath, baseRef] = args;

if (!repoPath || !baseRef) {
console.error(
'❌ Usage: ts-node src/check-changelog.ts <repo-path> <base-ref>',
);
throw new Error('❌ Missing required arguments.');
}

const fullRepoPath = path.resolve(process.cwd(), repoPath);

// Verify the repo path exists
try {
await fs.access(fullRepoPath);
} catch {
throw new Error(`Repository path not found: ${fullRepoPath}`);
}

const workspacePatterns = await getWorkspacePatterns(fullRepoPath);

if (workspacePatterns.length > 0) {
console.log(
'Running in monorepo mode - checking changelogs for changed packages...',
);

const changedFiles = await getChangedFiles(fullRepoPath, baseRef);
if (!changedFiles.length) {
console.log('No changed files found. Exiting successfully.');
return;
}

const changedPackages = await getChangedPackages(
changedFiles,
workspacePatterns,
);
if (!changedPackages.length) {
console.log(
'No package code changes detected that would require changelog updates.',
);
return;
}

const checkResults = await Promise.all(
changedPackages.map(async (pkgInfo) => {
try {
await checkChangelogFile(
path.join(
fullRepoPath,
pkgInfo.base,
pkgInfo.package,
'CHANGELOG.md',
),
);
console.log(
`✅ CHANGELOG.md for ${pkgInfo.package} has been correctly updated.`,
);
return { package: pkgInfo.package, success: true };
} catch (error) {
console.error(
`❌ Changelog check failed for package ${pkgInfo.package}:`,
error,
);
return { package: pkgInfo.package, success: false, error };
}
}),
);

const hasError = checkResults.some((result) => !result.success);

if (hasError) {
throw new Error('One or more changelog checks failed');
}
} else {
console.log(
'Running in single-repo mode - checking changelog for the entire repository...',
);
await checkChangelogFile(path.join(fullRepoPath, 'CHANGELOG.md'));
console.log('✅ CHANGELOG.md has been correctly updated.');
}
}

main().catch((error) => {
throw error;
});
2 changes: 1 addition & 1 deletion src/scripts/count-references-to-contributor-docs/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const REPOSITORY_NAMES = [
'snaps',
] as const;

type RepositoryName = typeof REPOSITORY_NAMES[number];
type RepositoryName = (typeof REPOSITORY_NAMES)[number];
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It’s not related to the newly added workflow, but the lint workflow was failing.

Error:   18:23  error  Replace `typeof·REPOSITORY_NAMES` with `(typeof·REPOSITORY_NAMES)`  prettier/prettier


/**
* It is not necessary for us to query all of the pull requests or pull requests
Expand Down
Loading
Loading