mirror of
https://github.com/wailsapp/wails.git
synced 2026-03-14 14:45:49 +01:00
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:
parent
6a91656914
commit
425c3469b2
2 changed files with 404 additions and 0 deletions
134
.github/workflows/v3-changelog-clean.yml
vendored
Normal file
134
.github/workflows/v3-changelog-clean.yml
vendored
Normal 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
|
||||
270
v3/scripts/validate-changelog.go
Normal file
270
v3/scripts/validate-changelog.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue