diff --git a/.github/workflows/BuildJobs.yml b/.github/workflows/BuildJobs.yml index ede2cec658..1bf6230904 100644 --- a/.github/workflows/BuildJobs.yml +++ b/.github/workflows/BuildJobs.yml @@ -63,6 +63,13 @@ jobs: run: pnpm run verify ${{ env.since_flag }} - name: Run unit tests run: pnpm run test ${{ env.since_flag }} + - name: Verify changelog entries + run: pnpm run -w check-changelogs + if: >- + ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'mendix/web-widgets' && github.event.pull_request.user.login != 'uicontent' }} + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} mxversion: name: Read versions file diff --git a/automation/utils/bin/rui-check-changelogs.ts b/automation/utils/bin/rui-check-changelogs.ts new file mode 100644 index 0000000000..d5f97bef4a --- /dev/null +++ b/automation/utils/bin/rui-check-changelogs.ts @@ -0,0 +1,170 @@ +#!/usr/bin/env ts-node-script + +import { exec } from "../src/shell"; +import { Version } from "../src"; +import { parse as parseWidget } from "../src/changelog-parser/parser/widget/widget"; +import { parse as parseModule } from "../src/changelog-parser/parser/module/module"; + +interface ChangelogChange { + filePath: string; + oldContent: string; + newContent: string; + type: "module" | "widget"; +} + +type ChangelogType = "module" | "widget" | "other"; + +function getChangelogType(filePath: string): ChangelogType { + if (filePath.includes("packages/modules/")) { + return "module"; + } + if (filePath.includes("packages/pluggableWidgets/")) { + return "widget"; + } + return "other"; +} + +function compareChangelogContent(change: ChangelogChange): boolean { + try { + const oldParsed = + change.type === "module" + ? parseModule(change.oldContent, { moduleName: "tmp", Version }) + : parseWidget(change.oldContent, { Version }); + const newParsed = + change.type === "module" + ? parseModule(change.newContent, { moduleName: "tmp", Version }) + : parseWidget(change.newContent, { Version }); + + const [, ...oldReleased] = oldParsed.content; + const [newUnreleased, ...newReleased] = newParsed.content; + + const releasedVersionsMatch = compareReleasedVersions(oldReleased, newReleased); + + if (!releasedVersionsMatch) { + console.error(` ❌ Released versions have been modified!`); + return false; + } + + const sectionTypes = newUnreleased.sections.map(s => s.type); + if (sectionTypes.length !== new Set(sectionTypes).size) { + console.error(` ❌ There are duplicated changelog types in Unreleased!`); + return false; + } + } catch (error) { + console.error(` ❌ Failed to parse changelog: ${error instanceof Error ? error.message : String(error)}`); + return false; + } + + return true; +} + +function compareReleasedVersions(oldReleased: any[], newReleased: any[]): boolean { + return JSON.stringify(oldReleased) === JSON.stringify(newReleased); +} + +async function getChangedFiles(base: string, head: string): Promise { + const result = await exec(`git diff --name-only ${base}...${head}`, { stdio: "pipe" }); + return result.stdout.trim().split("\n").filter(Boolean); +} + +async function getFileContent(filePath: string, commitSha: string): Promise { + try { + const result = await exec(`git show ${commitSha}:${filePath}`, { stdio: "pipe" }); + return result.stdout; + } catch (_error) { + // File might not exist at this commit (newly added or deleted) + return null; + } +} + +async function main(): Promise { + const base = process.env.BASE_SHA; // main + const head = process.env.HEAD_SHA; // fix/blah-blah-blah + + if (!base || !head) { + throw new Error("BASE_SHA and HEAD_SHA environment variables must be set"); + } + + console.log(`Checking CHANGELOG.md files between ${base} and ${head}...`); + + // Get list of all changed files + const changedFiles = await getChangedFiles(base, head); + console.log(`Found ${changedFiles.length} changed file(s)`); + + // Filter for CHANGELOG.md files in packages/modules or packages/pluggableWidgets + const changelogFiles = changedFiles.filter(file => { + return file.endsWith("CHANGELOG.md"); + }); + + if (changelogFiles.length === 0) { + console.log("No CHANGELOG.md files were changed."); + return; + } + + console.log(`Found ${changelogFiles.length} CHANGELOG.md file(s) to check:`); + changelogFiles.forEach(file => { + const type = getChangelogType(file); + console.log(` - ${file} (${type})`); + }); + + const changes: ChangelogChange[] = []; + let hasErrors = false; + + for (const filePath of changelogFiles) { + console.log(`\nProcessing ${filePath}...`); + + // Get old content (from base commit) + const oldContent = await getFileContent(filePath, base); + + // Get new content (from head commit) + const newContent = await getFileContent(filePath, head); + + if (!oldContent && !newContent) { + console.log(` ⚠️ Warning: File not found in both commits, skipping`); + continue; + } + + if (!oldContent) { + console.log(` ℹ️ New file added (no comparison needed)`); + continue; + } + + if (!newContent) { + console.log(` ℹ️ File deleted (no comparison needed)`); + continue; + } + + // Determine changelog type + const changelogType = getChangelogType(filePath); + + if (changelogType === "module") { + changes.push({ filePath, oldContent, newContent, type: "module" }); + } else if (changelogType === "widget") { + changes.push({ filePath, oldContent, newContent, type: "widget" }); + } else { + console.log(` ⚠️ Warning: Unknown changelog type, skipping`); + } + } + + for (const change of changes) { + const isValid = compareChangelogContent(change); + if (!isValid) { + console.error(` ❌ Invalid changes detected in ${change.filePath}`); + hasErrors = true; + } else { + console.log(` ✅ Valid changes`); + } + } + + if (hasErrors) { + console.error("\n❌ Some CHANGELOG.md files have invalid changes"); + process.exit(1); + } else { + console.log(`\n✅ All ${changes.length} CHANGELOG.md file(s) have valid changes`); + } +} + +main().catch(e => { + console.error(e); + process.exit(1); +}); diff --git a/automation/utils/package.json b/automation/utils/package.json index 651a47e48f..eb672a9969 100644 --- a/automation/utils/package.json +++ b/automation/utils/package.json @@ -27,6 +27,7 @@ "scripts": { "agent-rules": "ts-node bin/rui-agent-rules.ts", "changelog": "ts-node bin/rui-changelog-helper.ts", + "check-changelogs": "ts-node bin/rui-check-changelogs.ts", "compile:parser:module": "peggy -o ./src/changelog-parser/parser/widget/widget.js ./src/changelog-parser/parser/widget/widget.pegjs", "compile:parser:widget": "peggy -o ./src/changelog-parser/parser/module/module.js ./src/changelog-parser/parser/module/module.pegjs", "format": "prettier --write .", diff --git a/package.json b/package.json index 5d72d8091f..c946abe0ea 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "build": "turbo run build", "changelog": "pnpm --filter @mendix/automation-utils run changelog", + "check-changelogs": "pnpm --filter @mendix/automation-utils run check-changelogs", "create-gh-release": "turbo run create-gh-release --concurrency 1", "create-translation": "turbo run create-translation", "include-oss-in-artifact": "pnpm --filter @mendix/automation-utils run include-oss-in-artifact",