Add clean v3 changelog validation workflow with external Go script

- Created external Go validation script in v3/scripts/
- Clean workflow file without embedded scripts (much smaller)
- Should properly show workflow_dispatch trigger in GitHub

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Lea Anthony 2025-07-13 10:33:00 +10:00
commit 425c3469b2
2 changed files with 404 additions and 0 deletions

134
.github/workflows/v3-changelog-clean.yml vendored Normal file
View file

@ -0,0 +1,134 @@
name: V3 Changelog Validator
on:
pull_request:
branches: [ v3-alpha ]
paths:
- 'docs/src/content/docs/changelog.mdx'
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to check (for manual testing)'
required: false
type: string
jobs:
check-changelog:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' || github.event.inputs.pr_number
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || format('refs/pull/{0}/head', github.event.inputs.pr_number) }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: '1.23'
- name: Get PR information
id: pr_info
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "pr_number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
echo "base_ref=${{ github.event.pull_request.base.ref }}" >> $GITHUB_OUTPUT
else
echo "pr_number=${{ github.event.inputs.pr_number }}" >> $GITHUB_OUTPUT
echo "base_ref=v3-alpha" >> $GITHUB_OUTPUT
fi
- name: Check if changelog was modified
id: changelog_check
run: |
git fetch origin ${{ steps.pr_info.outputs.base_ref }}
if git diff --name-only origin/${{ steps.pr_info.outputs.base_ref }}..HEAD | grep -q "docs/src/content/docs/changelog.mdx"; then
echo "changelog_modified=true" >> $GITHUB_OUTPUT
echo "✅ Changelog was modified in this PR"
else
echo "changelog_modified=false" >> $GITHUB_OUTPUT
echo " Changelog was not modified - skipping validation"
fi
- name: Get PR diff for changelog
id: get_diff
if: steps.changelog_check.outputs.changelog_modified == 'true'
run: |
git fetch origin ${{ steps.pr_info.outputs.base_ref }}
git diff origin/${{ steps.pr_info.outputs.base_ref }}..HEAD docs/src/content/docs/changelog.mdx | grep "^+" | grep -v "^+++" | sed 's/^+//' > /tmp/pr_added_lines.txt
echo "Lines added in this PR:"
cat /tmp/pr_added_lines.txt
- name: Validate and fix changelog
id: validate_changelog
if: steps.changelog_check.outputs.changelog_modified == 'true'
run: |
cd v3/scripts
OUTPUT=$(go run validate-changelog.go ../../docs/src/content/docs/changelog.mdx /tmp/pr_added_lines.txt 2>&1)
echo "$OUTPUT"
RESULT=$(echo "$OUTPUT" | grep "VALIDATION_RESULT=" | cut -d'=' -f2)
echo "result=$RESULT" >> $GITHUB_OUTPUT
echo "$OUTPUT" > /tmp/validation_output.txt
- name: Commit fixes if applied
id: commit_changes
if: steps.validate_changelog.outputs.result == 'fixed'
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
if git diff --quiet docs/src/content/docs/changelog.mdx; then
echo "committed=false" >> $GITHUB_OUTPUT
else
git add docs/src/content/docs/changelog.mdx
git commit -m "🤖 Fix changelog: move entries to Unreleased section"
git push origin HEAD
echo "committed=true" >> $GITHUB_OUTPUT
fi
- name: Comment on PR - Success
if: steps.validate_changelog.outputs.result == 'success' || steps.validate_changelog.outputs.result == 'fixed'
uses: actions/github-script@v7
with:
script: |
const result = '${{ steps.validate_changelog.outputs.result }}';
const committed = '${{ steps.commit_changes.outputs.committed }}';
let message;
if (result === 'success') {
message = '## ✅ Changelog Validation Passed\n\nNo misplaced changelog entries detected. The changelog structure looks good!';
} else if (result === 'fixed' && committed === 'true') {
message = '## 🔧 Changelog Updated\n\nI detected some changelog entries that were added to already-released versions and automatically moved them to the `[Unreleased]` section.\n\nThe changes have been committed to this PR. Please review the updated changelog.';
} else {
message = '## ✅ Changelog Validation Passed\n\nThe changelog validation completed successfully. No changes were needed.';
}
await github.rest.issues.createComment({
issue_number: ${{ steps.pr_info.outputs.pr_number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: message
});
- name: Comment on PR - Failure
if: steps.validate_changelog.outputs.result == 'cannot_fix' || steps.validate_changelog.outputs.result == 'error'
uses: actions/github-script@v7
with:
script: |
const message = '## ❌ Invalid Changelog Entry\n\n**Issue:** Changelog entries were found in already-released version sections and cannot be automatically updated.\n\n**Required Action:** Please manually move your changelog entries from the released version sections to the `[Unreleased]` section and push the changes.';
await github.rest.issues.createComment({
issue_number: ${{ steps.pr_info.outputs.pr_number }},
owner: context.repo.owner,
repo: context.repo.repo,
body: message
});
- name: Fail workflow if cannot fix
if: steps.validate_changelog.outputs.result == 'cannot_fix' || steps.validate_changelog.outputs.result == 'error'
run: |
echo "❌ Changelog validation failed"
exit 1

View file

@ -0,0 +1,270 @@
package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
func main() {
if len(os.Args) < 3 {
fmt.Println("Usage: go run validate-changelog.go <changelog-file> <added-lines-file>")
os.Exit(1)
}
changelogPath := os.Args[1]
addedLinesPath := os.Args[2]
// Read changelog
content, err := readFile(changelogPath)
if err != nil {
fmt.Printf("ERROR: Failed to read changelog: %v\n", err)
os.Exit(1)
}
// Read the lines added in this PR
addedContent, err := readFile(addedLinesPath)
if err != nil {
fmt.Printf("ERROR: Failed to read PR added lines: %v\n", err)
os.Exit(1)
}
addedLines := strings.Split(addedContent, "\n")
fmt.Printf("📝 Lines added in this PR: %d\n", len(addedLines))
// Parse changelog to find where added lines ended up
lines := strings.Split(content, "\n")
// Find problematic entries - only check lines that were ADDED in this PR
var issues []Issue
currentSection := ""
for lineNum, line := range lines {
// Track current section
if strings.HasPrefix(line, "## ") {
if strings.Contains(line, "[Unreleased]") {
currentSection = "Unreleased"
} else if strings.Contains(line, "v3.0.0-alpha") {
// Extract version from line like "## v3.0.0-alpha.10 - 2025-07-06"
parts := strings.Split(strings.TrimSpace(line[3:]), " - ")
if len(parts) >= 1 {
currentSection = strings.TrimSpace(parts[0])
}
}
}
// Check if this line was added in this PR AND is in a released version
if currentSection != "" && currentSection != "Unreleased" &&
strings.HasPrefix(strings.TrimSpace(line), "- ") &&
wasAddedInThisPR(line, addedLines) {
issues = append(issues, Issue{
Line: lineNum,
Content: strings.TrimSpace(line),
Section: currentSection,
Category: getCurrentCategory(lines, lineNum),
})
fmt.Printf("🚨 MISPLACED: Line added to released version %s: %s\n", currentSection, strings.TrimSpace(line))
}
}
if len(issues) == 0 {
fmt.Println("VALIDATION_RESULT=success")
fmt.Println("No misplaced changelog entries found ✅")
return
}
// Try to fix the issues
fmt.Printf("Found %d potentially misplaced entries:\n", len(issues))
for _, issue := range issues {
fmt.Printf(" - Line %d in %s: %s\n", issue.Line+1, issue.Section, issue.Content)
}
// Attempt automatic fix
fixed, err := attemptFix(content, issues, changelogPath)
if err != nil {
fmt.Printf("VALIDATION_RESULT=error\n")
fmt.Printf("ERROR: Failed to fix changelog: %v\n", err)
os.Exit(1)
}
if fixed {
fmt.Println("VALIDATION_RESULT=fixed")
fmt.Println("✅ Changelog has been automatically fixed")
} else {
fmt.Println("VALIDATION_RESULT=cannot_fix")
fmt.Println("❌ Cannot automatically fix changelog issues")
os.Exit(1)
}
}
type Issue struct {
Line int
Content string
Section string
Category string
}
func wasAddedInThisPR(line string, addedLines []string) bool {
trimmedLine := strings.TrimSpace(line)
for _, addedLine := range addedLines {
trimmedAdded := strings.TrimSpace(addedLine)
if trimmedAdded == trimmedLine {
return true
}
if strings.Contains(trimmedAdded, trimmedLine) && len(trimmedAdded) > 0 {
return true
}
}
return false
}
func getCurrentCategory(lines []string, lineNum int) string {
for i := lineNum - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if strings.HasPrefix(line, "### ") {
return strings.TrimSpace(line[4:])
}
if strings.HasPrefix(line, "## ") &&
!strings.Contains(line, "[Unreleased]") &&
!strings.Contains(line, "v3.0.0-alpha") {
return strings.TrimSpace(line[3:])
}
if strings.HasPrefix(line, "## ") &&
(strings.Contains(line, "[Unreleased]") || strings.Contains(line, "v3.0.0-alpha")) {
break
}
}
return "Added"
}
func attemptFix(content string, issues []Issue, outputPath string) (bool, error) {
lines := strings.Split(content, "\n")
// Find unreleased section
unreleasedStart := -1
unreleasedEnd := -1
for i, line := range lines {
if strings.Contains(line, "[Unreleased]") {
unreleasedStart = i
for j := i + 1; j < len(lines); j++ {
if strings.HasPrefix(lines[j], "## ") && !strings.Contains(lines[j], "[Unreleased]") {
unreleasedEnd = j
break
}
}
break
}
}
if unreleasedStart == -1 {
return false, fmt.Errorf("Could not find [Unreleased] section")
}
// Group issues by category
issuesByCategory := make(map[string][]Issue)
for _, issue := range issues {
issuesByCategory[issue.Category] = append(issuesByCategory[issue.Category], issue)
}
// Remove issues from original locations (in reverse order)
var linesToRemove []int
for _, issue := range issues {
linesToRemove = append(linesToRemove, issue.Line)
}
// Sort in reverse order
for i := 0; i < len(linesToRemove); i++ {
for j := i + 1; j < len(linesToRemove); j++ {
if linesToRemove[i] < linesToRemove[j] {
linesToRemove[i], linesToRemove[j] = linesToRemove[j], linesToRemove[i]
}
}
}
// Remove lines
for _, lineNum := range linesToRemove {
lines = append(lines[:lineNum], lines[lineNum+1:]...)
}
// Add entries to unreleased section
for category, categoryIssues := range issuesByCategory {
categoryFound := false
insertPos := unreleasedStart + 1
for i := unreleasedStart + 1; i < unreleasedEnd && i < len(lines); i++ {
if strings.Contains(lines[i], "### "+category) || strings.Contains(lines[i], "## "+category) {
categoryFound = true
for j := i + 1; j < unreleasedEnd && j < len(lines); j++ {
if strings.HasPrefix(lines[j], "### ") || strings.HasPrefix(lines[j], "## ") {
insertPos = j
break
}
if j == len(lines)-1 || j == unreleasedEnd-1 {
insertPos = j + 1
break
}
}
break
}
}
if !categoryFound {
if unreleasedEnd > 0 {
insertPos = unreleasedEnd
} else {
insertPos = unreleasedStart + 1
}
newLines := []string{
"",
"### " + category,
"",
}
lines = append(lines[:insertPos], append(newLines, lines[insertPos:]...)...)
insertPos += len(newLines)
unreleasedEnd += len(newLines)
}
// Add entries to the category
for _, issue := range categoryIssues {
lines = append(lines[:insertPos], append([]string{issue.Content}, lines[insertPos:]...)...)
insertPos++
unreleasedEnd++
}
}
// Write back to file
newContent := strings.Join(lines, "\n")
return true, writeFile(outputPath, newContent)
}
func readFile(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
var content strings.Builder
scanner := bufio.NewScanner(file)
for scanner.Scan() {
content.WriteString(scanner.Text())
content.WriteString("\n")
}
return content.String(), scanner.Err()
}
func writeFile(path, content string) error {
dir := filepath.Dir(path)
err := os.MkdirAll(dir, 0755)
if err != nil {
return err
}
return os.WriteFile(path, []byte(content), 0644)
}