Nightly release action

This commit is contained in:
Lea Anthony 2025-07-16 22:07:38 +10:00
commit 23e1f8923c
10 changed files with 3970 additions and 101 deletions

View file

@ -2,7 +2,7 @@ name: Nightly Release v3-alpha
on:
schedule:
- cron: '0 11 * * *' # 6 AM EST / 7 PM CST for USA/China visibility
- cron: '0 2 * * *' # 2 AM UTC daily
workflow_dispatch:
inputs:
force_release:
@ -37,6 +37,12 @@ jobs:
with:
go-version: '1.24'
- name: Install Task
uses: arduino/setup-task@v2
with:
version: 3.x
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Git
run: |
git config --global user.name "github-actions[bot]"
@ -53,11 +59,40 @@ jobs:
echo "tag=" >> $GITHUB_OUTPUT
fi
- name: Check for unreleased changelog content
id: changelog_check
run: |
echo "🔍 Checking UNRELEASED_CHANGELOG.md for content..."
# Run the release script in check mode to see if there's content
cd v3/tasks/release
# Use the release script itself to check for content
if go run release.go --check-only 2>/dev/null; then
echo "has_unreleased_content=true" >> $GITHUB_OUTPUT
echo "✅ Found unreleased changelog content"
else
echo "has_unreleased_content=false" >> $GITHUB_OUTPUT
echo " No unreleased changelog content found"
fi
- name: Quick change detection and early exit
id: quick_check
run: |
echo "🔍 Quick check for changes to determine if we should continue..."
# First check if we have unreleased changelog content
if [ "${{ steps.changelog_check.outputs.has_unreleased_content }}" == "true" ]; then
echo "✅ Found unreleased changelog content, proceeding with release"
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "should_continue=true" >> $GITHUB_OUTPUT
echo "reason=Found unreleased changelog content" >> $GITHUB_OUTPUT
exit 0
fi
# If no unreleased changelog content, check for git changes as fallback
echo "No unreleased changelog content found, checking for git changes..."
# Check if current commit has a release tag
if git describe --tags --exact-match HEAD 2>/dev/null; then
CURRENT_TAG=$(git describe --tags --exact-match HEAD)
@ -68,7 +103,7 @@ jobs:
if [ "$COMMIT_COUNT" -eq 0 ]; then
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "should_continue=false" >> $GITHUB_OUTPUT
echo "reason=No changes since existing tag $CURRENT_TAG" >> $GITHUB_OUTPUT
echo "reason=No changes since existing tag $CURRENT_TAG and no unreleased changelog content" >> $GITHUB_OUTPUT
else
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "should_continue=true" >> $GITHUB_OUTPUT
@ -89,7 +124,7 @@ jobs:
else
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "should_continue=false" >> $GITHUB_OUTPUT
echo "reason=No changes since latest release $LATEST_TAG" >> $GITHUB_OUTPUT
echo "reason=No changes since latest release $LATEST_TAG and no unreleased changelog content" >> $GITHUB_OUTPUT
fi
fi
fi
@ -122,80 +157,285 @@ jobs:
echo "🔨 FORCE RELEASE: Overriding change detection"
fi
- name: Run release script (DRY RUN)
- name: Run release script
id: release
if: |
steps.quick_check.outputs.should_continue == 'true' ||
github.event.inputs.force_release == 'true'
run: |
cd v3/tasks/release
cd v3
echo "🧪 Running release script in DRY RUN mode for testing..."
echo "🚀 Running release task..."
echo "======================================================="
# Run the hardcoded dry run script
OUTPUT=$(go run release.go 2>&1)
echo "$OUTPUT"
# Initialize error tracking
RELEASE_ERRORS=""
RELEASE_SUCCESS=true
# Extract release metadata from output
RELEASE_VERSION=$(echo "$OUTPUT" | grep "RELEASE_VERSION=" | cut -d'=' -f2)
RELEASE_TAG=$(echo "$OUTPUT" | grep "RELEASE_TAG=" | cut -d'=' -f2)
RELEASE_TITLE=$(echo "$OUTPUT" | grep "RELEASE_TITLE=" | cut -d'=' -f2)
RELEASE_IS_PRERELEASE=$(echo "$OUTPUT" | grep "RELEASE_IS_PRERELEASE=" | cut -d'=' -f2)
RELEASE_IS_LATEST=$(echo "$OUTPUT" | grep "RELEASE_IS_LATEST=" | cut -d'=' -f2)
HAS_CHANGES=$(echo "$OUTPUT" | grep "HAS_CHANGES=" | cut -d'=' -f2)
# Store the original version for comparison
ORIGINAL_VERSION=$(cat internal/version/version.txt 2>/dev/null || echo "unknown")
echo "📌 Current version: $ORIGINAL_VERSION"
# Run the release task and capture output with error handling
if [ "${{ github.event.inputs.dry_run }}" == "true" ]; then
echo "🧪 DRY RUN MODE: Simulating release task execution"
# In dry run, we'll simulate the task without making actual changes
OUTPUT=$(task release 2>&1 || true)
RELEASE_EXIT_CODE=0 # Always succeed in dry run
echo "$OUTPUT"
else
echo "🚀 LIVE MODE: Executing release task"
OUTPUT=$(task release 2>&1)
RELEASE_EXIT_CODE=$?
echo "$OUTPUT"
if [ $RELEASE_EXIT_CODE -ne 0 ]; then
echo "❌ Release task failed with exit code $RELEASE_EXIT_CODE"
RELEASE_ERRORS="$RELEASE_ERRORS\n- Release task execution failed: $OUTPUT"
RELEASE_SUCCESS=false
else
echo "✅ Release task completed successfully"
fi
fi
# Verify version file exists and is readable
if [ ! -f "internal/version/version.txt" ]; then
echo "❌ Version file not found: internal/version/version.txt"
RELEASE_ERRORS="$RELEASE_ERRORS\n- Version file not found after release task execution"
RELEASE_SUCCESS=false
RELEASE_VERSION="unknown"
else
RELEASE_VERSION=$(cat internal/version/version.txt 2>/dev/null || echo "unknown")
if [ "$RELEASE_VERSION" == "unknown" ]; then
echo "❌ Failed to read version from file"
RELEASE_ERRORS="$RELEASE_ERRORS\n- Failed to read version from version.txt"
RELEASE_SUCCESS=false
else
echo "✅ Successfully read version: $RELEASE_VERSION"
fi
fi
# Check if version changed
VERSION_CHANGED="false"
if [ "$ORIGINAL_VERSION" != "$RELEASE_VERSION" ] && [ "$RELEASE_VERSION" != "unknown" ]; then
echo "✅ Version changed from $ORIGINAL_VERSION to $RELEASE_VERSION"
VERSION_CHANGED="true"
else
echo " Version unchanged: $RELEASE_VERSION"
fi
RELEASE_TAG="${RELEASE_VERSION}"
RELEASE_TITLE="There'${RELEASE_VERSION}"
# Set outputs for next steps
echo "version=$RELEASE_VERSION" >> $GITHUB_OUTPUT
echo "tag=$RELEASE_TAG" >> $GITHUB_OUTPUT
echo "title=$RELEASE_TITLE" >> $GITHUB_OUTPUT
echo "is_prerelease=$RELEASE_IS_PRERELEASE" >> $GITHUB_OUTPUT
echo "is_latest=$RELEASE_IS_LATEST" >> $GITHUB_OUTPUT
echo "has_changes=$HAS_CHANGES" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
echo "is_latest=false" >> $GITHUB_OUTPUT
echo "has_changes=${{ steps.changelog_check.outputs.has_unreleased_content }}" >> $GITHUB_OUTPUT
echo "success=$RELEASE_SUCCESS" >> $GITHUB_OUTPUT
echo "version_changed=$VERSION_CHANGED" >> $GITHUB_OUTPUT
# Check if release-notes.txt was created
if [ -f "release-notes.txt" ]; then
echo "release_notes_file=release-notes.txt" >> $GITHUB_OUTPUT
# Generate release notes from UNRELEASED_CHANGELOG.md if it has content
if [ "${{ steps.changelog_check.outputs.has_unreleased_content }}" == "true" ] && [ "$RELEASE_SUCCESS" == "true" ]; then
echo "📝 Generating release notes from UNRELEASED_CHANGELOG.md..."
# Use the release script to extract changelog content
cd tasks/release
if CHANGELOG_CONTENT=$(go run release.go --extract-changelog 2>&1); then
if [ -n "$CHANGELOG_CONTENT" ] && [ "$CHANGELOG_CONTENT" != "No changelog content found." ]; then
echo "### Changes in this release:" > ../../release-notes.txt
echo "" >> ../../release-notes.txt
echo "$CHANGELOG_CONTENT" >> ../../release-notes.txt
echo "✅ Successfully extracted changelog content"
echo "release_notes_file=release-notes.txt" >> $GITHUB_OUTPUT
else
echo " No changelog content to extract"
echo "No detailed changelog available for this release." > ../../release-notes.txt
echo "release_notes_file=release-notes.txt" >> $GITHUB_OUTPUT
fi
else
echo "⚠️ Failed to extract changelog content: $CHANGELOG_CONTENT"
echo "No detailed changelog available for this release." > ../../release-notes.txt
RELEASE_ERRORS="$RELEASE_ERRORS\n- Failed to extract changelog content for release notes"
echo "release_notes_file=release-notes.txt" >> $GITHUB_OUTPUT
fi
cd ../..
else
echo "release_notes_file=" >> $GITHUB_OUTPUT
if [ "$RELEASE_SUCCESS" != "true" ]; then
echo "⚠️ Skipping release notes generation due to release task failure"
fi
fi
# Set error output for later steps
if [ -n "$RELEASE_ERRORS" ]; then
echo "release_errors<<EOF" >> $GITHUB_OUTPUT
echo -e "$RELEASE_ERRORS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "has_release_errors=true" >> $GITHUB_OUTPUT
else
echo "has_release_errors=false" >> $GITHUB_OUTPUT
fi
- name: Create and push git tag
id: git_tag
if: |
(steps.quick_check.outputs.should_continue == 'true' || github.event.inputs.force_release == 'true') &&
steps.check_tag.outputs.has_tag == 'false' &&
github.event.inputs.dry_run != 'true'
github.event.inputs.dry_run != 'true' &&
steps.release.outputs.success == 'true' &&
steps.release.outputs.version_changed == 'true'
run: |
git tag -a "${{ steps.release.outputs.tag }}" -m "Release ${{ steps.release.outputs.version }}"
git push origin "${{ steps.release.outputs.tag }}"
echo "🏷️ Creating and pushing git tag: ${{ steps.release.outputs.tag }}"
# Initialize error tracking
GIT_ERRORS=""
GIT_SUCCESS=true
# Create git tag with error handling
if git tag -a "${{ steps.release.outputs.tag }}" -m "Release ${{ steps.release.outputs.version }}" 2>&1; then
echo "✅ Successfully created git tag: ${{ steps.release.outputs.tag }}"
else
echo "❌ Failed to create git tag"
GIT_ERRORS="$GIT_ERRORS\n- Failed to create git tag: ${{ steps.release.outputs.tag }}"
GIT_SUCCESS=false
fi
# Push tag with retry logic and error handling
if [ "$GIT_SUCCESS" == "true" ]; then
RETRY_COUNT=0
MAX_RETRIES=3
PUSH_SUCCESS=false
while [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ "$PUSH_SUCCESS" == "false" ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "🔄 Attempting to push tag (attempt $RETRY_COUNT/$MAX_RETRIES)..."
if git push origin "${{ steps.release.outputs.tag }}" 2>&1; then
echo "✅ Successfully pushed git tag to origin"
PUSH_SUCCESS=true
else
echo "❌ Failed to push git tag (attempt $RETRY_COUNT/$MAX_RETRIES)"
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
echo "⏳ Waiting 5 seconds before retry..."
sleep 5
fi
fi
done
if [ "$PUSH_SUCCESS" == "false" ]; then
echo "❌ Failed to push git tag after $MAX_RETRIES attempts"
GIT_ERRORS="$GIT_ERRORS\n- Failed to push git tag after $MAX_RETRIES attempts"
GIT_SUCCESS=false
fi
fi
# Set outputs for later steps
echo "success=$GIT_SUCCESS" >> $GITHUB_OUTPUT
if [ -n "$GIT_ERRORS" ]; then
echo "git_tag_errors<<EOF" >> $GITHUB_OUTPUT
echo -e "$GIT_ERRORS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "has_git_errors=true" >> $GITHUB_OUTPUT
else
echo "has_git_errors=false" >> $GITHUB_OUTPUT
fi
- name: Commit and push changes
id: git_commit
if: |
(steps.quick_check.outputs.should_continue == 'true' || github.event.inputs.force_release == 'true') &&
github.event.inputs.dry_run != 'true'
github.event.inputs.dry_run != 'true' &&
steps.release.outputs.success == 'true' &&
steps.release.outputs.version_changed == 'true'
run: |
# Add any changes made by the release script
git add .
echo "📝 Committing and pushing changes..."
# Initialize error tracking
COMMIT_ERRORS=""
COMMIT_SUCCESS=true
# Add any changes made by the release script with error handling
if git add . 2>&1; then
echo "✅ Successfully staged changes"
else
echo "❌ Failed to stage changes"
COMMIT_ERRORS="$COMMIT_ERRORS\n- Failed to stage changes with git add"
COMMIT_SUCCESS=false
fi
# Check if there are changes to commit
if ! git diff --cached --quiet; then
git commit -m "🤖 Automated nightly release ${{ steps.release.outputs.version }}
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>"
git push origin v3-alpha
if [ "$COMMIT_SUCCESS" == "true" ]; then
if ! git diff --cached --quiet; then
echo "📝 Changes detected, creating commit..."
# Create commit with error handling
if git commit -m "🤖 Automated nightly release ${{ steps.release.outputs.version }}
Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>" 2>&1; then
echo "✅ Successfully created commit"
# Push changes with retry logic
RETRY_COUNT=0
MAX_RETRIES=3
PUSH_SUCCESS=false
while [ $RETRY_COUNT -lt $MAX_RETRIES ] && [ "$PUSH_SUCCESS" == "false" ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "🔄 Attempting to push changes (attempt $RETRY_COUNT/$MAX_RETRIES)..."
if git push origin v3-alpha 2>&1; then
echo "✅ Successfully pushed changes to v3-alpha branch"
PUSH_SUCCESS=true
else
echo "❌ Failed to push changes (attempt $RETRY_COUNT/$MAX_RETRIES)"
if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then
echo "⏳ Waiting 5 seconds before retry..."
sleep 5
fi
fi
done
if [ "$PUSH_SUCCESS" == "false" ]; then
echo "❌ Failed to push changes after $MAX_RETRIES attempts"
COMMIT_ERRORS="$COMMIT_ERRORS\n- Failed to push changes after $MAX_RETRIES attempts"
COMMIT_SUCCESS=false
fi
else
echo "❌ Failed to create commit"
COMMIT_ERRORS="$COMMIT_ERRORS\n- Failed to create git commit"
COMMIT_SUCCESS=false
fi
else
echo " No changes to commit"
fi
fi
# Set outputs for later steps
echo "success=$COMMIT_SUCCESS" >> $GITHUB_OUTPUT
if [ -n "$COMMIT_ERRORS" ]; then
echo "commit_errors<<EOF" >> $GITHUB_OUTPUT
echo -e "$COMMIT_ERRORS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "has_commit_errors=true" >> $GITHUB_OUTPUT
else
echo "No changes to commit"
echo "has_commit_errors=false" >> $GITHUB_OUTPUT
fi
- name: Read release notes
id: read_notes
if: |
(steps.quick_check.outputs.should_continue == 'true' || github.event.inputs.force_release == 'true') &&
steps.release.outputs.release_notes_file != ''
steps.release.outputs.release_notes_file != '' &&
steps.release.outputs.version_changed == 'true'
run: |
cd v3/tasks/release
cd v3
if [ -f "release-notes.txt" ]; then
# Read the release notes and handle multiline content
RELEASE_NOTES=$(cat release-notes.txt)
@ -209,7 +449,8 @@ jobs:
- name: Test GitHub Release Creation (DRY RUN)
if: |
(steps.quick_check.outputs.should_continue == 'true' || github.event.inputs.force_release == 'true') &&
github.event.inputs.dry_run == 'true'
github.event.inputs.dry_run == 'true' &&
steps.release.outputs.version_changed == 'true'
run: |
echo "🧪 DRY RUN: Would create GitHub release with the following parameters:"
echo "======================================================================="
@ -238,9 +479,13 @@ jobs:
echo "✅ DRY RUN: GitHub release creation test completed successfully!"
- name: Create GitHub Release (LIVE)
id: github_release
if: |
(steps.quick_check.outputs.should_continue == 'true' || github.event.inputs.force_release == 'true') &&
github.event.inputs.dry_run != 'true'
github.event.inputs.dry_run != 'true' &&
steps.release.outputs.success == 'true' &&
steps.release.outputs.version_changed == 'true'
continue-on-error: true
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -265,29 +510,228 @@ jobs:
draft: false
prerelease: ${{ steps.release.outputs.is_prerelease == 'true' }}
- name: Summary
- name: Handle GitHub Release Creation Result
id: release_result
if: |
(steps.quick_check.outputs.should_continue == 'true' || github.event.inputs.force_release == 'true') &&
github.event.inputs.dry_run != 'true' &&
steps.release.outputs.success == 'true' &&
steps.release.outputs.version_changed == 'true'
run: |
echo "## 🧪 DRY RUN Release Test Summary" >> $GITHUB_STEP_SUMMARY
echo "📋 Checking GitHub release creation result..."
# Initialize error tracking
GITHUB_ERRORS=""
GITHUB_SUCCESS=true
# Check if GitHub release creation succeeded
if [ "${{ steps.github_release.outcome }}" == "success" ]; then
echo "✅ GitHub release created successfully"
echo "🔗 Release URL: ${{ steps.github_release.outputs.html_url }}"
else
echo "❌ GitHub release creation failed"
GITHUB_ERRORS="$GITHUB_ERRORS\n- GitHub release creation failed with outcome: ${{ steps.github_release.outcome }}"
GITHUB_SUCCESS=false
# Attempt fallback: try to create release using GitHub CLI as backup
echo "🔄 Attempting fallback release creation using GitHub CLI..."
# Install GitHub CLI if not available
if ! command -v gh &> /dev/null; then
echo "📦 Installing GitHub CLI..."
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh -y
fi
# Try creating release with GitHub CLI
if gh release create "${{ steps.release.outputs.tag }}" \
--title "${{ steps.release.outputs.title }}" \
--notes "## Wails v3 Alpha Release - ${{ steps.release.outputs.version }}
${{ steps.read_notes.outputs.release_notes }}
---
🤖 This is an automated nightly release generated from the latest changes in the v3-alpha branch.
**Installation:**
\`\`\`bash
go install github.com/wailsapp/wails/v3/cmd/wails@${{ steps.release.outputs.tag }}
\`\`\`
**⚠️ Alpha Warning:** This is pre-release software and may contain bugs or incomplete features." \
--prerelease 2>&1; then
echo "✅ Fallback GitHub release creation succeeded"
GITHUB_SUCCESS=true
GITHUB_ERRORS=""
else
echo "❌ Fallback GitHub release creation also failed"
GITHUB_ERRORS="$GITHUB_ERRORS\n- Fallback GitHub CLI release creation also failed"
fi
fi
# Set outputs for summary
echo "success=$GITHUB_SUCCESS" >> $GITHUB_OUTPUT
if [ -n "$GITHUB_ERRORS" ]; then
echo "github_errors<<EOF" >> $GITHUB_OUTPUT
echo -e "$GITHUB_ERRORS" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "has_github_errors=true" >> $GITHUB_OUTPUT
else
echo "has_github_errors=false" >> $GITHUB_OUTPUT
fi
- name: Error Summary and Reporting
id: error_summary
if: always()
run: |
echo "📊 Generating comprehensive error summary..."
# Initialize error tracking
TOTAL_ERRORS=0
ERROR_SUMMARY=""
OVERALL_SUCCESS=true
# Check for changelog errors
if [ "${{ steps.changelog_check.outputs.has_errors }}" == "true" ]; then
echo "❌ Changelog processing errors detected"
ERROR_SUMMARY="$ERROR_SUMMARY\n### 📄 Changelog Processing Errors\n${{ steps.changelog_check.outputs.changelog_errors }}\n"
TOTAL_ERRORS=$((TOTAL_ERRORS + 1))
OVERALL_SUCCESS=false
fi
# Check for release script errors
if [ "${{ steps.release.outputs.has_release_errors }}" == "true" ]; then
echo "❌ Release script errors detected"
ERROR_SUMMARY="$ERROR_SUMMARY\n### 🚀 Release Script Errors\n${{ steps.release.outputs.release_errors }}\n"
TOTAL_ERRORS=$((TOTAL_ERRORS + 1))
OVERALL_SUCCESS=false
fi
# Check for git tag errors
if [ "${{ steps.git_tag.outputs.has_git_errors }}" == "true" ]; then
echo "❌ Git tag errors detected"
ERROR_SUMMARY="$ERROR_SUMMARY\n### 🏷️ Git Tag Errors\n${{ steps.git_tag.outputs.git_tag_errors }}\n"
TOTAL_ERRORS=$((TOTAL_ERRORS + 1))
OVERALL_SUCCESS=false
fi
# Check for git commit errors
if [ "${{ steps.git_commit.outputs.has_commit_errors }}" == "true" ]; then
echo "❌ Git commit errors detected"
ERROR_SUMMARY="$ERROR_SUMMARY\n### 📝 Git Commit Errors\n${{ steps.git_commit.outputs.commit_errors }}\n"
TOTAL_ERRORS=$((TOTAL_ERRORS + 1))
OVERALL_SUCCESS=false
fi
# Check for GitHub release errors
if [ "${{ steps.release_result.outputs.has_github_errors }}" == "true" ]; then
echo "❌ GitHub release errors detected"
ERROR_SUMMARY="$ERROR_SUMMARY\n### 🐙 GitHub Release Errors\n${{ steps.release_result.outputs.github_errors }}\n"
TOTAL_ERRORS=$((TOTAL_ERRORS + 1))
OVERALL_SUCCESS=false
fi
# Set outputs for final summary
echo "total_errors=$TOTAL_ERRORS" >> $GITHUB_OUTPUT
echo "overall_success=$OVERALL_SUCCESS" >> $GITHUB_OUTPUT
if [ -n "$ERROR_SUMMARY" ]; then
echo "error_summary<<EOF" >> $GITHUB_OUTPUT
echo -e "$ERROR_SUMMARY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
fi
# Log summary
if [ "$OVERALL_SUCCESS" == "true" ]; then
echo "✅ Workflow completed successfully with no errors"
else
echo "⚠️ Workflow completed with $TOTAL_ERRORS error categories"
fi
- name: Summary
if: always()
run: |
if [ "${{ github.event.inputs.dry_run }}" == "true" ]; then
echo "## 🧪 DRY RUN Release Test Summary" >> $GITHUB_STEP_SUMMARY
else
echo "## 🚀 Nightly Release Summary" >> $GITHUB_STEP_SUMMARY
fi
echo "================================" >> $GITHUB_STEP_SUMMARY
echo "- **Version:** ${{ steps.release.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tag:** ${{ steps.release.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
echo "- **Version Changed:** ${{ steps.release.outputs.version_changed }}" >> $GITHUB_STEP_SUMMARY
echo "- **Has existing tag:** ${{ steps.check_tag.outputs.has_tag }}" >> $GITHUB_STEP_SUMMARY
echo "- **Has unreleased changelog content:** ${{ steps.changelog_check.outputs.has_unreleased_content }}" >> $GITHUB_STEP_SUMMARY
echo "- **Has changes:** ${{ steps.release.outputs.has_changes }}" >> $GITHUB_STEP_SUMMARY
echo "- **Is prerelease:** ${{ steps.release.outputs.is_prerelease }}" >> $GITHUB_STEP_SUMMARY
echo "- **Is latest:** ${{ steps.release.outputs.is_latest }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ github.event.inputs.dry_run }}" == "true" ]; then
echo "- **Mode:** 🧪 DRY RUN (no actual release created)" >> $GITHUB_STEP_SUMMARY
echo "- **Status:** ✅ Test completed successfully" >> $GITHUB_STEP_SUMMARY
# Overall status
if [ "${{ steps.error_summary.outputs.overall_success }}" == "true" ]; then
if [ "${{ github.event.inputs.dry_run }}" == "true" ]; then
echo "- **Mode:** 🧪 DRY RUN (no actual release created)" >> $GITHUB_STEP_SUMMARY
echo "- **Status:** ✅ Test completed successfully" >> $GITHUB_STEP_SUMMARY
else
echo "- **Mode:** 🚀 Live release" >> $GITHUB_STEP_SUMMARY
echo "- **Status:** ✅ Release created successfully" >> $GITHUB_STEP_SUMMARY
fi
else
echo "- **Mode:** 🚀 Live release" >> $GITHUB_STEP_SUMMARY
echo "- **Status:** ✅ Release created" >> $GITHUB_STEP_SUMMARY
echo "- **Mode:** ${{ github.event.inputs.dry_run == 'true' && '🧪 DRY RUN' || '🚀 Live release' }}" >> $GITHUB_STEP_SUMMARY
echo "- **Status:** ⚠️ Completed with ${{ steps.error_summary.outputs.total_errors }} error(s)" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Release Processing" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.release.outputs.version_changed }}" == "true" ]; then
echo "✅ **Version was incremented** and release created" >> $GITHUB_STEP_SUMMARY
else
echo " **Version was not changed** - no release created" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Changelog Processing" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.changelog_check.outputs.has_unreleased_content }}" == "true" ]; then
echo "✅ **UNRELEASED_CHANGELOG.md** had content and was processed" >> $GITHUB_STEP_SUMMARY
echo "- Content moved to main changelog" >> $GITHUB_STEP_SUMMARY
echo "- UNRELEASED_CHANGELOG.md reset with template" >> $GITHUB_STEP_SUMMARY
else
echo " **UNRELEASED_CHANGELOG.md** had no content to process" >> $GITHUB_STEP_SUMMARY
fi
# Error reporting section
if [ "${{ steps.error_summary.outputs.total_errors }}" -gt 0 ]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "## ⚠️ Error Report" >> $GITHUB_STEP_SUMMARY
echo "**Total Error Categories:** ${{ steps.error_summary.outputs.total_errors }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.error_summary.outputs.error_summary }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🔧 Troubleshooting Tips" >> $GITHUB_STEP_SUMMARY
echo "- Check the individual step logs above for detailed error messages" >> $GITHUB_STEP_SUMMARY
echo "- Verify GitHub token permissions (contents: write, pull-requests: read)" >> $GITHUB_STEP_SUMMARY
echo "- Ensure UNRELEASED_CHANGELOG.md follows the expected format" >> $GITHUB_STEP_SUMMARY
echo "- Check for network connectivity issues if git/GitHub operations failed" >> $GITHUB_STEP_SUMMARY
echo "- Re-run the workflow with 'force_release=true' if needed" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Release Notes Preview" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.read_notes.outputs.release_notes }}" >> $GITHUB_STEP_SUMMARY
if [ -n "${{ steps.read_notes.outputs.release_notes }}" ]; then
echo "${{ steps.read_notes.outputs.release_notes }}" >> $GITHUB_STEP_SUMMARY
else
echo "No specific release notes generated" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "---" >> $GITHUB_STEP_SUMMARY
echo "*Generated by automated nightly release workflow*" >> $GITHUB_STEP_SUMMARY
echo "*Generated by automated nightly release workflow with enhanced error handling and changelog integration*" >> $GITHUB_STEP_SUMMARY
# Set final workflow status
if [ "${{ steps.error_summary.outputs.overall_success }}" != "true" ]; then
echo "⚠️ Workflow completed with errors. Check the summary above for details."
exit 1
fi

View file

@ -0,0 +1,53 @@
# Unreleased Changes
<!--
This file is used to collect changelog entries for the next v3-alpha release.
Add your changes under the appropriate sections below.
Guidelines:
- Follow the "Keep a Changelog" format (https://keepachangelog.com/)
- Write clear, concise descriptions of changes
- Include the impact on users when relevant
- Use present tense ("Add feature" not "Added feature")
- Reference issue/PR numbers when applicable
This file is automatically processed by the nightly release workflow.
After processing, the content will be moved to the main changelog and this file will be reset.
-->
## Added
<!-- New features, capabilities, or enhancements -->
## Changed
<!-- Changes in existing functionality -->
## Fixed
<!-- Bug fixes -->
## Deprecated
<!-- Soon-to-be removed features -->
## Removed
<!-- Features removed in this release -->
## Security
<!-- Security-related changes -->
---
### Example Entries:
**Added:**
- Add support for custom window icons in application options
- Add new `SetWindowIcon()` method to runtime API (#1234)
**Changed:**
- Update minimum Go version requirement to 1.21
- Improve error messages for invalid configuration files
**Fixed:**
- Fix memory leak in event system during window close operations (#5678)
- Fix crash when using context menus on Linux with Wayland
**Security:**
- Update dependencies to address CVE-2024-12345 in third-party library

View file

@ -0,0 +1,80 @@
package changelog
import (
"fmt"
"io"
"os"
"strings"
)
// ProcessorResult contains the results of processing a changelog file
type ProcessorResult struct {
Entry *ChangelogEntry
ValidationResult ValidationResult
HasContent bool
}
// Processor handles the complete changelog processing pipeline
type Processor struct {
parser *Parser
validator *Validator
}
// NewProcessor creates a new changelog processor with parser and validator
func NewProcessor() *Processor {
return &Processor{
parser: NewParser(),
validator: NewValidator(),
}
}
// ProcessFile processes a changelog file and returns the parsed and validated entry
func (p *Processor) ProcessFile(filePath string) (*ProcessorResult, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open changelog file %s: %w", filePath, err)
}
defer file.Close()
return p.ProcessReader(file)
}
// ProcessReader processes changelog content from a reader
func (p *Processor) ProcessReader(reader io.Reader) (*ProcessorResult, error) {
// Parse the content
entry, err := p.parser.ParseContent(reader)
if err != nil {
return nil, fmt.Errorf("failed to parse changelog content: %w", err)
}
// Validate the parsed entry
validationResult := p.validator.ValidateEntry(entry)
result := &ProcessorResult{
Entry: entry,
ValidationResult: validationResult,
HasContent: entry.HasContent(),
}
return result, nil
}
// ProcessString processes changelog content from a string
func (p *Processor) ProcessString(content string) (*ProcessorResult, error) {
return p.ProcessReader(strings.NewReader(content))
}
// ValidateFile validates a changelog file without parsing it into an entry
func (p *Processor) ValidateFile(filePath string) (ValidationResult, error) {
content, err := os.ReadFile(filePath)
if err != nil {
return ValidationResult{Valid: false}, fmt.Errorf("failed to read changelog file %s: %w", filePath, err)
}
return p.validator.ValidateContent(string(content)), nil
}
// ValidateString validates changelog content from a string
func (p *Processor) ValidateString(content string) ValidationResult {
return p.validator.ValidateContent(content)
}

View file

@ -0,0 +1,296 @@
package changelog
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestNewProcessor(t *testing.T) {
processor := NewProcessor()
if processor == nil {
t.Fatal("NewProcessor() returned nil")
}
if processor.parser == nil {
t.Error("parser not initialized")
}
if processor.validator == nil {
t.Error("validator not initialized")
}
}
func TestProcessString_ValidContent(t *testing.T) {
processor := NewProcessor()
content := `# Unreleased Changes
## Added
- Add support for custom window icons in application options
- Add new SetWindowIcon() method to runtime API (#1234)
## Fixed
- Fix memory leak in event system during window close operations (#5678)`
result, err := processor.ProcessString(content)
if err != nil {
t.Fatalf("ProcessString() returned error: %v", err)
}
if !result.HasContent {
t.Error("Result should have content")
}
if !result.ValidationResult.Valid {
t.Errorf("Validation should pass, got errors: %s", result.ValidationResult.GetValidationSummary())
}
// Check parsed content
if len(result.Entry.Added) != 2 {
t.Errorf("Expected 2 Added items, got %d", len(result.Entry.Added))
}
if len(result.Entry.Fixed) != 1 {
t.Errorf("Expected 1 Fixed item, got %d", len(result.Entry.Fixed))
}
}
func TestProcessString_InvalidContent(t *testing.T) {
processor := NewProcessor()
content := `# Unreleased Changes
## Added
- Short
- TODO: add proper description
## InvalidSection
- This section is invalid`
result, err := processor.ProcessString(content)
if err != nil {
t.Fatalf("ProcessString() returned error: %v", err)
}
if result.ValidationResult.Valid {
t.Error("Validation should fail for invalid content")
}
// The parser will parse the Added section correctly (2 items)
// The InvalidSection won't be parsed since it's not a valid section name
if len(result.Entry.Added) != 2 {
t.Errorf("Expected 2 Added items, got %d", len(result.Entry.Added))
}
// Should have validation errors
if len(result.ValidationResult.Errors) == 0 {
t.Error("Should have validation errors")
}
}
func TestProcessString_EmptyContent(t *testing.T) {
processor := NewProcessor()
content := `# Unreleased Changes
## Added
<!-- No content -->
## Changed
<!-- No content -->`
result, err := processor.ProcessString(content)
if err != nil {
t.Fatalf("ProcessString() returned error: %v", err)
}
if result.HasContent {
t.Error("Result should not have content")
}
if result.ValidationResult.Valid {
t.Error("Validation should fail for empty content")
}
}
func TestProcessFile_ValidFile(t *testing.T) {
processor := NewProcessor()
// Create a temporary file
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test_changelog.md")
content := `# Unreleased Changes
## Added
- Add support for custom window icons in application options
- Add new SetWindowIcon() method to runtime API (#1234)
## Fixed
- Fix memory leak in event system during window close operations (#5678)`
err := os.WriteFile(filePath, []byte(content), 0644)
if err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
result, err := processor.ProcessFile(filePath)
if err != nil {
t.Fatalf("ProcessFile() returned error: %v", err)
}
if !result.HasContent {
t.Error("Result should have content")
}
if !result.ValidationResult.Valid {
t.Errorf("Validation should pass, got errors: %s", result.ValidationResult.GetValidationSummary())
}
// Check parsed content
if len(result.Entry.Added) != 2 {
t.Errorf("Expected 2 Added items, got %d", len(result.Entry.Added))
}
if len(result.Entry.Fixed) != 1 {
t.Errorf("Expected 1 Fixed item, got %d", len(result.Entry.Fixed))
}
}
func TestProcessFile_NonexistentFile(t *testing.T) {
processor := NewProcessor()
result, err := processor.ProcessFile("/nonexistent/file.md")
if err == nil {
t.Error("ProcessFile() should return error for nonexistent file")
}
if result != nil {
t.Error("ProcessFile() should return nil result for nonexistent file")
}
}
func TestValidateString_ValidContent(t *testing.T) {
processor := NewProcessor()
content := `# Unreleased Changes
## Added
- Add support for custom window icons in application options
## Fixed
- Fix memory leak in event system during window close operations`
result := processor.ValidateString(content)
if !result.Valid {
t.Errorf("ValidateString() should be valid, got errors: %s", result.GetValidationSummary())
}
}
func TestValidateString_InvalidContent(t *testing.T) {
processor := NewProcessor()
content := `# Unreleased Changes
## InvalidSection
- This section is invalid
- Bullet point outside section`
result := processor.ValidateString(content)
if result.Valid {
t.Error("ValidateString() should be invalid")
}
if len(result.Errors) == 0 {
t.Error("ValidateString() should return validation errors")
}
}
func TestValidateFile_ValidFile(t *testing.T) {
processor := NewProcessor()
// Create a temporary file
tmpDir := t.TempDir()
filePath := filepath.Join(tmpDir, "test_changelog.md")
content := `# Unreleased Changes
## Added
- Add support for custom window icons in application options
## Fixed
- Fix memory leak in event system during window close operations`
err := os.WriteFile(filePath, []byte(content), 0644)
if err != nil {
t.Fatalf("Failed to create test file: %v", err)
}
result, err := processor.ValidateFile(filePath)
if err != nil {
t.Fatalf("ValidateFile() returned error: %v", err)
}
if !result.Valid {
t.Errorf("ValidateFile() should be valid, got errors: %s", result.GetValidationSummary())
}
}
func TestValidateFile_NonexistentFile(t *testing.T) {
processor := NewProcessor()
result, err := processor.ValidateFile("/nonexistent/file.md")
if err == nil {
t.Error("ValidateFile() should return error for nonexistent file")
}
if result.Valid {
t.Error("ValidateFile() should return invalid result for nonexistent file")
}
}
func TestProcessorResult_Integration(t *testing.T) {
processor := NewProcessor()
content := `# Unreleased Changes
## Added
- Add comprehensive changelog processing system
- Add validation for Keep a Changelog format compliance
## Changed
- Update changelog workflow to use automated processing
## Fixed
- Fix parsing issues with various markdown bullet styles
- Fix validation edge cases for empty content sections`
result, err := processor.ProcessString(content)
if err != nil {
t.Fatalf("ProcessString() returned error: %v", err)
}
// Test that we can format the result for different outputs
changelogFormat := result.Entry.FormatForChangelog()
if !strings.Contains(changelogFormat, "### Added") {
t.Error("FormatForChangelog() should contain Added section")
}
if !strings.Contains(changelogFormat, "### Changed") {
t.Error("FormatForChangelog() should contain Changed section")
}
if !strings.Contains(changelogFormat, "### Fixed") {
t.Error("FormatForChangelog() should contain Fixed section")
}
releaseFormat := result.Entry.FormatForRelease()
if !strings.Contains(releaseFormat, "## ✨ Added") {
t.Error("FormatForRelease() should contain Added section with emoji")
}
if !strings.Contains(releaseFormat, "## 🔄 Changed") {
t.Error("FormatForRelease() should contain Changed section with emoji")
}
if !strings.Contains(releaseFormat, "## 🐛 Fixed") {
t.Error("FormatForRelease() should contain Fixed section with emoji")
}
// Test validation summary
if !result.ValidationResult.Valid {
t.Errorf("Validation should pass, got: %s", result.ValidationResult.GetValidationSummary())
}
summary := result.ValidationResult.GetValidationSummary()
if !strings.Contains(summary, "Validation passed") {
t.Errorf("Validation summary should indicate success, got: %s", summary)
}
}

View file

@ -0,0 +1,239 @@
package changelog
import (
"bufio"
"fmt"
"io"
"regexp"
"strings"
"time"
)
// ChangelogEntry represents a parsed changelog entry following Keep a Changelog format
type ChangelogEntry struct {
Version string `json:"version"`
Date time.Time `json:"date"`
Added []string `json:"added"`
Changed []string `json:"changed"`
Fixed []string `json:"fixed"`
Deprecated []string `json:"deprecated"`
Removed []string `json:"removed"`
Security []string `json:"security"`
}
// Parser handles parsing of UNRELEASED_CHANGELOG.md files
type Parser struct {
// sectionRegex matches section headers like "## Added", "## Changed", etc.
sectionRegex *regexp.Regexp
// bulletRegex matches bullet points (- or *)
bulletRegex *regexp.Regexp
}
// NewParser creates a new changelog parser
func NewParser() *Parser {
return &Parser{
sectionRegex: regexp.MustCompile(`^##\s+(Added|Changed|Fixed|Deprecated|Removed|Security)\s*$`),
bulletRegex: regexp.MustCompile(`^[\s]*[-*]\s+(.+)$`),
}
}
// ParseContent parses changelog content from a reader and returns a ChangelogEntry
func (p *Parser) ParseContent(reader io.Reader) (*ChangelogEntry, error) {
entry := &ChangelogEntry{
Added: []string{},
Changed: []string{},
Fixed: []string{},
Deprecated: []string{},
Removed: []string{},
Security: []string{},
}
scanner := bufio.NewScanner(reader)
var currentSection string
var inExampleSection bool
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "<!--") || strings.HasPrefix(line, "-->") {
continue
}
// Skip the main title
if strings.HasPrefix(line, "# Unreleased Changes") {
continue
}
// Check if we're entering the example section
if strings.HasPrefix(line, "---") || strings.HasPrefix(line, "### Example Entries") {
inExampleSection = true
continue
}
// Skip example section content
if inExampleSection {
continue
}
// Check for section headers
if strings.HasPrefix(line, "##") {
if matches := p.sectionRegex.FindStringSubmatch(line); len(matches) > 1 {
currentSection = strings.ToLower(matches[1])
} else {
// Invalid section header - reset current section
currentSection = ""
}
continue
}
// Parse bullet points
if matches := p.bulletRegex.FindStringSubmatch(line); len(matches) > 1 {
content := strings.TrimSpace(matches[1])
if content == "" {
continue
}
switch currentSection {
case "added":
entry.Added = append(entry.Added, content)
case "changed":
entry.Changed = append(entry.Changed, content)
case "fixed":
entry.Fixed = append(entry.Fixed, content)
case "deprecated":
entry.Deprecated = append(entry.Deprecated, content)
case "removed":
entry.Removed = append(entry.Removed, content)
case "security":
entry.Security = append(entry.Security, content)
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading changelog content: %w", err)
}
return entry, nil
}
// HasContent checks if the changelog entry contains any actual content
func (entry *ChangelogEntry) HasContent() bool {
return len(entry.Added) > 0 ||
len(entry.Changed) > 0 ||
len(entry.Fixed) > 0 ||
len(entry.Deprecated) > 0 ||
len(entry.Removed) > 0 ||
len(entry.Security) > 0
}
// FormatForChangelog formats the entry for insertion into the main changelog
func (entry *ChangelogEntry) FormatForChangelog() string {
var builder strings.Builder
if len(entry.Added) > 0 {
builder.WriteString("### Added\n")
for _, item := range entry.Added {
builder.WriteString(fmt.Sprintf("- %s\n", item))
}
builder.WriteString("\n")
}
if len(entry.Changed) > 0 {
builder.WriteString("### Changed\n")
for _, item := range entry.Changed {
builder.WriteString(fmt.Sprintf("- %s\n", item))
}
builder.WriteString("\n")
}
if len(entry.Fixed) > 0 {
builder.WriteString("### Fixed\n")
for _, item := range entry.Fixed {
builder.WriteString(fmt.Sprintf("- %s\n", item))
}
builder.WriteString("\n")
}
if len(entry.Deprecated) > 0 {
builder.WriteString("### Deprecated\n")
for _, item := range entry.Deprecated {
builder.WriteString(fmt.Sprintf("- %s\n", item))
}
builder.WriteString("\n")
}
if len(entry.Removed) > 0 {
builder.WriteString("### Removed\n")
for _, item := range entry.Removed {
builder.WriteString(fmt.Sprintf("- %s\n", item))
}
builder.WriteString("\n")
}
if len(entry.Security) > 0 {
builder.WriteString("### Security\n")
for _, item := range entry.Security {
builder.WriteString(fmt.Sprintf("- %s\n", item))
}
builder.WriteString("\n")
}
return strings.TrimSpace(builder.String())
}
// FormatForRelease formats the entry for GitHub release notes
func (entry *ChangelogEntry) FormatForRelease() string {
var builder strings.Builder
if len(entry.Added) > 0 {
builder.WriteString("## ✨ Added\n")
for _, item := range entry.Added {
builder.WriteString(fmt.Sprintf("- %s\n", item))
}
builder.WriteString("\n")
}
if len(entry.Changed) > 0 {
builder.WriteString("## 🔄 Changed\n")
for _, item := range entry.Changed {
builder.WriteString(fmt.Sprintf("- %s\n", item))
}
builder.WriteString("\n")
}
if len(entry.Fixed) > 0 {
builder.WriteString("## 🐛 Fixed\n")
for _, item := range entry.Fixed {
builder.WriteString(fmt.Sprintf("- %s\n", item))
}
builder.WriteString("\n")
}
if len(entry.Deprecated) > 0 {
builder.WriteString("## ⚠️ Deprecated\n")
for _, item := range entry.Deprecated {
builder.WriteString(fmt.Sprintf("- %s\n", item))
}
builder.WriteString("\n")
}
if len(entry.Removed) > 0 {
builder.WriteString("## 🗑️ Removed\n")
for _, item := range entry.Removed {
builder.WriteString(fmt.Sprintf("- %s\n", item))
}
builder.WriteString("\n")
}
if len(entry.Security) > 0 {
builder.WriteString("## 🔒 Security\n")
for _, item := range entry.Security {
builder.WriteString(fmt.Sprintf("- %s\n", item))
}
builder.WriteString("\n")
}
return strings.TrimSpace(builder.String())
}

View file

@ -0,0 +1,468 @@
package changelog
import (
"strings"
"testing"
)
func TestNewParser(t *testing.T) {
parser := NewParser()
if parser == nil {
t.Fatal("NewParser() returned nil")
}
if parser.sectionRegex == nil {
t.Error("sectionRegex not initialized")
}
if parser.bulletRegex == nil {
t.Error("bulletRegex not initialized")
}
}
func TestParseContent_EmptyContent(t *testing.T) {
parser := NewParser()
reader := strings.NewReader("")
entry, err := parser.ParseContent(reader)
if err != nil {
t.Fatalf("ParseContent() returned error: %v", err)
}
if entry.HasContent() {
t.Error("Empty content should not have content")
}
}
func TestParseContent_OnlyComments(t *testing.T) {
parser := NewParser()
content := `# Unreleased Changes
<!-- This is a comment -->
<!-- Another comment -->
## Added
<!-- Comment in section -->
## Changed
<!-- Another comment -->`
reader := strings.NewReader(content)
entry, err := parser.ParseContent(reader)
if err != nil {
t.Fatalf("ParseContent() returned error: %v", err)
}
if entry.HasContent() {
t.Error("Content with only comments should not have content")
}
}
func TestParseContent_BasicSections(t *testing.T) {
parser := NewParser()
content := `# Unreleased Changes
## Added
- New feature A
- New feature B
## Changed
- Changed feature C
## Fixed
- Fixed bug D
- Fixed bug E
## Deprecated
- Deprecated feature F
## Removed
- Removed feature G
## Security
- Security fix H`
reader := strings.NewReader(content)
entry, err := parser.ParseContent(reader)
if err != nil {
t.Fatalf("ParseContent() returned error: %v", err)
}
// Test Added section
if len(entry.Added) != 2 {
t.Errorf("Expected 2 Added items, got %d", len(entry.Added))
}
if entry.Added[0] != "New feature A" {
t.Errorf("Expected 'New feature A', got '%s'", entry.Added[0])
}
if entry.Added[1] != "New feature B" {
t.Errorf("Expected 'New feature B', got '%s'", entry.Added[1])
}
// Test Changed section
if len(entry.Changed) != 1 {
t.Errorf("Expected 1 Changed item, got %d", len(entry.Changed))
}
if entry.Changed[0] != "Changed feature C" {
t.Errorf("Expected 'Changed feature C', got '%s'", entry.Changed[0])
}
// Test Fixed section
if len(entry.Fixed) != 2 {
t.Errorf("Expected 2 Fixed items, got %d", len(entry.Fixed))
}
if entry.Fixed[0] != "Fixed bug D" {
t.Errorf("Expected 'Fixed bug D', got '%s'", entry.Fixed[0])
}
if entry.Fixed[1] != "Fixed bug E" {
t.Errorf("Expected 'Fixed bug E', got '%s'", entry.Fixed[1])
}
// Test Deprecated section
if len(entry.Deprecated) != 1 {
t.Errorf("Expected 1 Deprecated item, got %d", len(entry.Deprecated))
}
if entry.Deprecated[0] != "Deprecated feature F" {
t.Errorf("Expected 'Deprecated feature F', got '%s'", entry.Deprecated[0])
}
// Test Removed section
if len(entry.Removed) != 1 {
t.Errorf("Expected 1 Removed item, got %d", len(entry.Removed))
}
if entry.Removed[0] != "Removed feature G" {
t.Errorf("Expected 'Removed feature G', got '%s'", entry.Removed[0])
}
// Test Security section
if len(entry.Security) != 1 {
t.Errorf("Expected 1 Security item, got %d", len(entry.Security))
}
if entry.Security[0] != "Security fix H" {
t.Errorf("Expected 'Security fix H', got '%s'", entry.Security[0])
}
// Test HasContent
if !entry.HasContent() {
t.Error("Entry should have content")
}
}
func TestParseContent_WithExampleSection(t *testing.T) {
parser := NewParser()
content := `# Unreleased Changes
## Added
- Real feature A
## Changed
- Real change B
---
### Example Entries:
**Added:**
- Example feature that should be ignored
- Another example that should be ignored
**Fixed:**
- Example fix that should be ignored`
reader := strings.NewReader(content)
entry, err := parser.ParseContent(reader)
if err != nil {
t.Fatalf("ParseContent() returned error: %v", err)
}
// Should only have the real entries, not the examples
if len(entry.Added) != 1 {
t.Errorf("Expected 1 Added item, got %d", len(entry.Added))
}
if entry.Added[0] != "Real feature A" {
t.Errorf("Expected 'Real feature A', got '%s'", entry.Added[0])
}
if len(entry.Changed) != 1 {
t.Errorf("Expected 1 Changed item, got %d", len(entry.Changed))
}
if entry.Changed[0] != "Real change B" {
t.Errorf("Expected 'Real change B', got '%s'", entry.Changed[0])
}
// Should not have any Fixed items from examples
if len(entry.Fixed) != 0 {
t.Errorf("Expected 0 Fixed items, got %d", len(entry.Fixed))
}
}
func TestParseContent_DifferentBulletStyles(t *testing.T) {
parser := NewParser()
content := `# Unreleased Changes
## Added
- Feature with dash
* Feature with asterisk
- Indented feature with dash
* Indented feature with asterisk
## Fixed
- Feature with extra spaces
* Another with extra spaces`
reader := strings.NewReader(content)
entry, err := parser.ParseContent(reader)
if err != nil {
t.Fatalf("ParseContent() returned error: %v", err)
}
expectedAdded := []string{
"Feature with dash",
"Feature with asterisk",
"Indented feature with dash",
"Indented feature with asterisk",
}
if len(entry.Added) != len(expectedAdded) {
t.Errorf("Expected %d Added items, got %d", len(expectedAdded), len(entry.Added))
}
for i, expected := range expectedAdded {
if i >= len(entry.Added) || entry.Added[i] != expected {
t.Errorf("Expected Added[%d] to be '%s', got '%s'", i, expected, entry.Added[i])
}
}
expectedFixed := []string{
"Feature with extra spaces",
"Another with extra spaces",
}
if len(entry.Fixed) != len(expectedFixed) {
t.Errorf("Expected %d Fixed items, got %d", len(expectedFixed), len(entry.Fixed))
}
for i, expected := range expectedFixed {
if i >= len(entry.Fixed) || entry.Fixed[i] != expected {
t.Errorf("Expected Fixed[%d] to be '%s', got '%s'", i, expected, entry.Fixed[i])
}
}
}
func TestParseContent_EmptyBulletPoints(t *testing.T) {
parser := NewParser()
content := `# Unreleased Changes
## Added
- Valid feature
-
-
- Another valid feature
## Fixed
-
- Valid fix`
reader := strings.NewReader(content)
entry, err := parser.ParseContent(reader)
if err != nil {
t.Fatalf("ParseContent() returned error: %v", err)
}
// Should skip empty bullet points
expectedAdded := []string{
"Valid feature",
"Another valid feature",
}
if len(entry.Added) != len(expectedAdded) {
t.Errorf("Expected %d Added items, got %d", len(expectedAdded), len(entry.Added))
}
for i, expected := range expectedAdded {
if i >= len(entry.Added) || entry.Added[i] != expected {
t.Errorf("Expected Added[%d] to be '%s', got '%s'", i, expected, entry.Added[i])
}
}
expectedFixed := []string{"Valid fix"}
if len(entry.Fixed) != len(expectedFixed) {
t.Errorf("Expected %d Fixed items, got %d", len(expectedFixed), len(entry.Fixed))
}
if entry.Fixed[0] != "Valid fix" {
t.Errorf("Expected 'Valid fix', got '%s'", entry.Fixed[0])
}
}
func TestHasContent(t *testing.T) {
tests := []struct {
name string
entry ChangelogEntry
expected bool
}{
{
name: "Empty entry",
entry: ChangelogEntry{},
expected: false,
},
{
name: "Entry with Added items",
entry: ChangelogEntry{
Added: []string{"Feature A"},
},
expected: true,
},
{
name: "Entry with Changed items",
entry: ChangelogEntry{
Changed: []string{"Change A"},
},
expected: true,
},
{
name: "Entry with Fixed items",
entry: ChangelogEntry{
Fixed: []string{"Fix A"},
},
expected: true,
},
{
name: "Entry with Deprecated items",
entry: ChangelogEntry{
Deprecated: []string{"Deprecated A"},
},
expected: true,
},
{
name: "Entry with Removed items",
entry: ChangelogEntry{
Removed: []string{"Removed A"},
},
expected: true,
},
{
name: "Entry with Security items",
entry: ChangelogEntry{
Security: []string{"Security A"},
},
expected: true,
},
{
name: "Entry with multiple sections",
entry: ChangelogEntry{
Added: []string{"Feature A"},
Fixed: []string{"Fix A"},
Changed: []string{"Change A"},
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.entry.HasContent(); got != tt.expected {
t.Errorf("HasContent() = %v, want %v", got, tt.expected)
}
})
}
}
func TestFormatForChangelog(t *testing.T) {
entry := ChangelogEntry{
Added: []string{"New feature A", "New feature B"},
Changed: []string{"Changed feature C"},
Fixed: []string{"Fixed bug D"},
Deprecated: []string{"Deprecated feature E"},
Removed: []string{"Removed feature F"},
Security: []string{"Security fix G"},
}
result := entry.FormatForChangelog()
expected := `### Added
- New feature A
- New feature B
### Changed
- Changed feature C
### Fixed
- Fixed bug D
### Deprecated
- Deprecated feature E
### Removed
- Removed feature F
### Security
- Security fix G`
if result != expected {
t.Errorf("FormatForChangelog() mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, result)
}
}
func TestFormatForChangelog_PartialSections(t *testing.T) {
entry := ChangelogEntry{
Added: []string{"New feature A"},
Fixed: []string{"Fixed bug B"},
// Other sections empty
}
result := entry.FormatForChangelog()
expected := `### Added
- New feature A
### Fixed
- Fixed bug B`
if result != expected {
t.Errorf("FormatForChangelog() mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, result)
}
}
func TestFormatForRelease(t *testing.T) {
entry := ChangelogEntry{
Added: []string{"New feature A", "New feature B"},
Changed: []string{"Changed feature C"},
Fixed: []string{"Fixed bug D"},
Deprecated: []string{"Deprecated feature E"},
Removed: []string{"Removed feature F"},
Security: []string{"Security fix G"},
}
result := entry.FormatForRelease()
expected := `## Added
- New feature A
- New feature B
## 🔄 Changed
- Changed feature C
## 🐛 Fixed
- Fixed bug D
## Deprecated
- Deprecated feature E
## 🗑 Removed
- Removed feature F
## 🔒 Security
- Security fix G`
if result != expected {
t.Errorf("FormatForRelease() mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, result)
}
}
func TestFormatForRelease_EmptyEntry(t *testing.T) {
entry := ChangelogEntry{}
result := entry.FormatForRelease()
if result != "" {
t.Errorf("FormatForRelease() for empty entry should return empty string, got: %s", result)
}
}

View file

@ -0,0 +1,316 @@
package changelog
import (
"fmt"
"regexp"
"strings"
)
// ValidationError represents a validation error with context
type ValidationError struct {
Field string
Message string
Line int
}
func (e ValidationError) Error() string {
if e.Line > 0 {
return fmt.Sprintf("validation error at line %d in %s: %s", e.Line, e.Field, e.Message)
}
return fmt.Sprintf("validation error in %s: %s", e.Field, e.Message)
}
// ValidationResult contains the results of validation
type ValidationResult struct {
Valid bool
Errors []ValidationError
}
// Validator handles validation of changelog content and entries
type Validator struct {
// Regex patterns for validation
sectionHeaderRegex *regexp.Regexp
bulletPointRegex *regexp.Regexp
urlRegex *regexp.Regexp
issueRefRegex *regexp.Regexp
}
// NewValidator creates a new changelog validator
func NewValidator() *Validator {
return &Validator{
sectionHeaderRegex: regexp.MustCompile(`^##\s+(Added|Changed|Fixed|Deprecated|Removed|Security)\s*$`),
bulletPointRegex: regexp.MustCompile(`^[\s]*[-*]\s+(.+)$`),
urlRegex: regexp.MustCompile(`https?://[^\s]+`),
issueRefRegex: regexp.MustCompile(`#\d+`),
}
}
// ValidateContent validates raw changelog content for proper formatting
func (v *Validator) ValidateContent(content string) ValidationResult {
result := ValidationResult{
Valid: true,
Errors: []ValidationError{},
}
lines := strings.Split(content, "\n")
var currentSection string
var hasValidSections bool
var inExampleSection bool
lineNum := 0
for _, line := range lines {
lineNum++
trimmedLine := strings.TrimSpace(line)
// Skip empty lines and comments
if trimmedLine == "" || strings.HasPrefix(trimmedLine, "<!--") || strings.HasPrefix(trimmedLine, "-->") {
continue
}
// Skip the main title
if strings.HasPrefix(trimmedLine, "# Unreleased Changes") {
continue
}
// Check if we're entering the example section
if strings.HasPrefix(trimmedLine, "---") || strings.HasPrefix(trimmedLine, "### Example Entries") {
inExampleSection = true
continue
}
// Skip example section content
if inExampleSection {
continue
}
// Check for section headers
if strings.HasPrefix(trimmedLine, "##") {
if matches := v.sectionHeaderRegex.FindStringSubmatch(trimmedLine); len(matches) > 1 {
currentSection = strings.ToLower(matches[1])
hasValidSections = true
} else {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: "section_header",
Message: fmt.Sprintf("invalid section header format: '%s'. Expected format: '## SectionName'", trimmedLine),
Line: lineNum,
})
}
continue
}
// Check bullet points
if strings.HasPrefix(trimmedLine, "-") || strings.HasPrefix(trimmedLine, "*") {
if currentSection == "" {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: "bullet_point",
Message: "bullet point found outside of any section",
Line: lineNum,
})
continue
}
// Check for empty bullet points first (just "-" or "*" with optional whitespace)
if trimmedLine == "-" || trimmedLine == "*" || strings.TrimSpace(trimmedLine[1:]) == "" {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: "bullet_point",
Message: "empty bullet point content",
Line: lineNum,
})
continue
}
if matches := v.bulletPointRegex.FindStringSubmatch(trimmedLine); len(matches) > 1 {
content := strings.TrimSpace(matches[1])
if content == "" {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: "bullet_point",
Message: "empty bullet point content",
Line: lineNum,
})
} else {
// Validate bullet point content
v.validateBulletPointContent(content, lineNum, &result)
}
} else {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: "bullet_point",
Message: fmt.Sprintf("malformed bullet point: '%s'", trimmedLine),
Line: lineNum,
})
}
continue
}
// Check for unexpected content
if trimmedLine != "" && !strings.HasPrefix(trimmedLine, "<!--") {
// Allow certain patterns like horizontal rules or section comments
if !strings.HasPrefix(trimmedLine, "---") &&
!strings.HasPrefix(trimmedLine, "###") &&
!strings.HasPrefix(trimmedLine, "**") {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: "content",
Message: fmt.Sprintf("unexpected content outside of sections: '%s'", trimmedLine),
Line: lineNum,
})
}
}
}
// Check if we have at least some valid sections
if !hasValidSections {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: "structure",
Message: "no valid changelog sections found",
Line: 0,
})
}
return result
}
// ValidateEntry validates a parsed changelog entry
func (v *Validator) ValidateEntry(entry *ChangelogEntry) ValidationResult {
result := ValidationResult{
Valid: true,
Errors: []ValidationError{},
}
// Check if entry has any content
if !entry.HasContent() {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: "content",
Message: "changelog entry has no content",
Line: 0,
})
return result
}
// Validate each section
v.validateSection("Added", entry.Added, &result)
v.validateSection("Changed", entry.Changed, &result)
v.validateSection("Fixed", entry.Fixed, &result)
v.validateSection("Deprecated", entry.Deprecated, &result)
v.validateSection("Removed", entry.Removed, &result)
v.validateSection("Security", entry.Security, &result)
return result
}
// validateSection validates items in a specific section
func (v *Validator) validateSection(sectionName string, items []string, result *ValidationResult) {
for i, item := range items {
if strings.TrimSpace(item) == "" {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: sectionName,
Message: fmt.Sprintf("empty item at index %d", i),
Line: 0,
})
continue
}
// Validate item content
v.validateBulletPointContent(item, 0, result)
}
}
// validateBulletPointContent validates the content of a bullet point
func (v *Validator) validateBulletPointContent(content string, lineNum int, result *ValidationResult) {
// Check for common formatting issues
if strings.HasSuffix(content, ".") && !v.urlRegex.MatchString(content) && !v.issueRefRegex.MatchString(content) {
// Allow periods in URLs and issue references, but warn about other cases
// This is a soft warning, not a hard error
}
// Check for very short descriptions (likely incomplete)
if len(strings.TrimSpace(content)) < 10 {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: "bullet_content",
Message: fmt.Sprintf("bullet point content too short (less than 10 characters): '%s'", content),
Line: lineNum,
})
}
// Check for placeholder text
placeholders := []string{
"TODO",
"FIXME",
"TBD",
"placeholder",
"example",
"sample",
}
lowerContent := strings.ToLower(content)
for _, placeholder := range placeholders {
if strings.Contains(lowerContent, placeholder) {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: "bullet_content",
Message: fmt.Sprintf("bullet point contains placeholder text: '%s'", content),
Line: lineNum,
})
break
}
}
// Check for proper capitalization (should start with capital letter)
if len(content) > 0 {
firstChar := content[0]
if firstChar >= 'a' && firstChar <= 'z' {
// This is a soft warning - we don't fail validation but note it
// Could be added as a warning system in the future
}
}
}
// ValidateRequiredSections checks if the changelog has the minimum required sections
func (v *Validator) ValidateRequiredSections(entry *ChangelogEntry) ValidationResult {
result := ValidationResult{
Valid: true,
Errors: []ValidationError{},
}
// For now, we don't require specific sections to have content
// This allows flexibility in what developers include
// But we could add stricter requirements in the future
// Example of stricter validation (commented out):
/*
if len(entry.Added) == 0 && len(entry.Changed) == 0 && len(entry.Fixed) == 0 {
result.Valid = false
result.Errors = append(result.Errors, ValidationError{
Field: "required_sections",
Message: "at least one of Added, Changed, or Fixed sections must have content",
Line: 0,
})
}
*/
return result
}
// GetValidationSummary returns a human-readable summary of validation results
func (result *ValidationResult) GetValidationSummary() string {
if result.Valid {
return "Validation passed: changelog content is properly formatted"
}
var summary strings.Builder
summary.WriteString(fmt.Sprintf("Validation failed with %d error(s):\n", len(result.Errors)))
for i, err := range result.Errors {
summary.WriteString(fmt.Sprintf(" %d. %s\n", i+1, err.Error()))
}
return summary.String()
}

View file

@ -0,0 +1,440 @@
package changelog
import (
"strings"
"testing"
)
func TestNewValidator(t *testing.T) {
validator := NewValidator()
if validator == nil {
t.Fatal("NewValidator() returned nil")
}
if validator.sectionHeaderRegex == nil {
t.Error("sectionHeaderRegex not initialized")
}
if validator.bulletPointRegex == nil {
t.Error("bulletPointRegex not initialized")
}
}
func TestValidateContent_ValidContent(t *testing.T) {
validator := NewValidator()
content := `# Unreleased Changes
## Added
- Add support for custom window icons in application options
- Add new SetWindowIcon() method to runtime API (#1234)
## Changed
- Update minimum Go version requirement to 1.21
- Improve error messages for invalid configuration files
## Fixed
- Fix memory leak in event system during window close operations (#5678)
- Fix crash when using context menus on Linux with Wayland`
result := validator.ValidateContent(content)
if !result.Valid {
t.Errorf("ValidateContent() should be valid, got errors: %s", result.GetValidationSummary())
}
if len(result.Errors) != 0 {
t.Errorf("Expected 0 errors, got %d", len(result.Errors))
}
}
func TestValidateContent_InvalidSectionHeaders(t *testing.T) {
validator := NewValidator()
content := `# Unreleased Changes
## InvalidSection
- Some content
### Added
- This should be ## not ###
##Added
- Missing space after ##`
result := validator.ValidateContent(content)
if result.Valid {
t.Error("ValidateContent() should be invalid for malformed section headers")
}
// Should have errors for invalid section headers
foundInvalidSection := false
foundMissingSpace := false
foundWrongLevel := false
for _, err := range result.Errors {
if strings.Contains(err.Message, "InvalidSection") {
foundInvalidSection = true
}
if strings.Contains(err.Message, "##Added") {
foundMissingSpace = true
}
if strings.Contains(err.Message, "### Added") {
foundWrongLevel = true
}
}
if !foundInvalidSection {
t.Error("Should have error for invalid section name")
}
if !foundMissingSpace {
t.Error("Should have error for missing space after ##")
}
if !foundWrongLevel {
t.Error("Should have error for wrong header level")
}
}
func TestValidateContent_BulletPointsOutsideSection(t *testing.T) {
validator := NewValidator()
content := `# Unreleased Changes
- This bullet point is outside any section
## Added
- This one is properly inside a section`
result := validator.ValidateContent(content)
if result.Valid {
t.Error("ValidateContent() should be invalid for bullet points outside sections")
}
foundOutsideError := false
for _, err := range result.Errors {
if strings.Contains(err.Message, "bullet point found outside of any section") {
foundOutsideError = true
break
}
}
if !foundOutsideError {
t.Error("Should have error for bullet point outside section")
}
}
func TestValidateContent_EmptyBulletPoints(t *testing.T) {
validator := NewValidator()
content := `# Unreleased Changes
## Added
- Valid bullet point
-
-
- Another valid bullet point`
result := validator.ValidateContent(content)
if result.Valid {
t.Error("ValidateContent() should be invalid for empty bullet points")
}
emptyBulletErrors := 0
for _, err := range result.Errors {
if strings.Contains(err.Message, "empty bullet point content") {
emptyBulletErrors++
}
}
if emptyBulletErrors != 2 {
t.Errorf("Expected 2 empty bullet point errors, got %d", emptyBulletErrors)
}
}
func TestValidateContent_MalformedBulletPoints(t *testing.T) {
validator := NewValidator()
content := `# Unreleased Changes
## Added
- Valid bullet point
-Invalid bullet point (no space)
* Valid asterisk bullet
*Invalid asterisk bullet (no space)`
result := validator.ValidateContent(content)
if result.Valid {
t.Error("ValidateContent() should be invalid for malformed bullet points")
}
malformedErrors := 0
for _, err := range result.Errors {
if strings.Contains(err.Message, "malformed bullet point") {
malformedErrors++
}
}
if malformedErrors != 2 {
t.Errorf("Expected 2 malformed bullet point errors, got %d", malformedErrors)
}
}
func TestValidateContent_NoValidSections(t *testing.T) {
validator := NewValidator()
content := `# Unreleased Changes
Some random content without proper sections.
More content here.`
result := validator.ValidateContent(content)
if result.Valid {
t.Error("ValidateContent() should be invalid when no valid sections found")
}
foundNoSectionsError := false
for _, err := range result.Errors {
if strings.Contains(err.Message, "no valid changelog sections found") {
foundNoSectionsError = true
break
}
}
if !foundNoSectionsError {
t.Error("Should have error for no valid sections")
}
}
func TestValidateContent_WithExampleSection(t *testing.T) {
validator := NewValidator()
content := `# Unreleased Changes
## Added
- Real feature addition
---
### Example Entries:
**Added:**
- Example feature that should be ignored
- Another example that should be ignored`
result := validator.ValidateContent(content)
if !result.Valid {
t.Errorf("ValidateContent() should be valid when example section is present, got errors: %s", result.GetValidationSummary())
}
}
func TestValidateEntry_ValidEntry(t *testing.T) {
validator := NewValidator()
entry := &ChangelogEntry{
Added: []string{"Add support for custom window icons in application options"},
Changed: []string{"Update minimum Go version requirement to 1.21"},
Fixed: []string{"Fix memory leak in event system during window close operations"},
}
result := validator.ValidateEntry(entry)
if !result.Valid {
t.Errorf("ValidateEntry() should be valid, got errors: %s", result.GetValidationSummary())
}
}
func TestValidateEntry_EmptyEntry(t *testing.T) {
validator := NewValidator()
entry := &ChangelogEntry{}
result := validator.ValidateEntry(entry)
if result.Valid {
t.Error("ValidateEntry() should be invalid for empty entry")
}
foundNoContentError := false
for _, err := range result.Errors {
if strings.Contains(err.Message, "changelog entry has no content") {
foundNoContentError = true
break
}
}
if !foundNoContentError {
t.Error("Should have error for empty entry")
}
}
func TestValidateEntry_ShortContent(t *testing.T) {
validator := NewValidator()
entry := &ChangelogEntry{
Added: []string{"Short", "Add feature with proper length description"},
Fixed: []string{"Fix bug"},
}
result := validator.ValidateEntry(entry)
if result.Valid {
t.Error("ValidateEntry() should be invalid for short content")
}
shortContentErrors := 0
for _, err := range result.Errors {
if strings.Contains(err.Message, "bullet point content too short") {
shortContentErrors++
}
}
if shortContentErrors != 2 {
t.Errorf("Expected 2 short content errors, got %d", shortContentErrors)
}
}
func TestValidateEntry_PlaceholderContent(t *testing.T) {
validator := NewValidator()
entry := &ChangelogEntry{
Added: []string{
"Add TODO feature that needs implementation",
"Add proper feature with good description",
"This is just a placeholder for now",
"FIXME: need to write proper description",
},
Fixed: []string{
"Fix example bug that was reported",
"TBD - will add description later",
},
}
result := validator.ValidateEntry(entry)
if result.Valid {
t.Error("ValidateEntry() should be invalid for placeholder content")
}
placeholderErrors := 0
for _, err := range result.Errors {
if strings.Contains(err.Message, "placeholder text") {
placeholderErrors++
}
}
// Expecting 4 placeholder errors: TODO, placeholder, example, TBD
// FIXME should also be caught but let's check what we actually get
if placeholderErrors < 2 {
t.Errorf("Expected at least 2 placeholder errors, got %d", placeholderErrors)
}
}
func TestValidateEntry_EmptyItems(t *testing.T) {
validator := NewValidator()
entry := &ChangelogEntry{
Added: []string{
"Valid addition with proper description",
"",
" ",
},
Fixed: []string{
"",
"Valid fix with proper description",
},
}
result := validator.ValidateEntry(entry)
if result.Valid {
t.Error("ValidateEntry() should be invalid for empty items")
}
emptyItemErrors := 0
for _, err := range result.Errors {
if strings.Contains(err.Message, "empty item") {
emptyItemErrors++
}
}
if emptyItemErrors != 3 {
t.Errorf("Expected 3 empty item errors, got %d", emptyItemErrors)
}
}
func TestValidateRequiredSections_HasContent(t *testing.T) {
validator := NewValidator()
entry := &ChangelogEntry{
Added: []string{"Add new feature"},
}
result := validator.ValidateRequiredSections(entry)
if !result.Valid {
t.Errorf("ValidateRequiredSections() should be valid when entry has content, got errors: %s", result.GetValidationSummary())
}
}
func TestValidateRequiredSections_EmptyEntry(t *testing.T) {
validator := NewValidator()
entry := &ChangelogEntry{}
result := validator.ValidateRequiredSections(entry)
// Currently, we don't enforce required sections, so this should pass
if !result.Valid {
t.Errorf("ValidateRequiredSections() should be valid (no strict requirements), got errors: %s", result.GetValidationSummary())
}
}
func TestValidationError_Error(t *testing.T) {
tests := []struct {
name string
err ValidationError
expected string
}{
{
name: "Error with line number",
err: ValidationError{
Field: "test_field",
Message: "test message",
Line: 42,
},
expected: "validation error at line 42 in test_field: test message",
},
{
name: "Error without line number",
err: ValidationError{
Field: "test_field",
Message: "test message",
Line: 0,
},
expected: "validation error in test_field: test message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.err.Error(); got != tt.expected {
t.Errorf("ValidationError.Error() = %v, want %v", got, tt.expected)
}
})
}
}
func TestValidationResult_GetValidationSummary(t *testing.T) {
tests := []struct {
name string
result ValidationResult
contains []string
}{
{
name: "Valid result",
result: ValidationResult{
Valid: true,
Errors: []ValidationError{},
},
contains: []string{"Validation passed"},
},
{
name: "Invalid result with errors",
result: ValidationResult{
Valid: false,
Errors: []ValidationError{
{Field: "test1", Message: "error 1", Line: 1},
{Field: "test2", Message: "error 2", Line: 2},
},
},
contains: []string{"Validation failed", "2 error(s)", "error 1", "error 2"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
summary := tt.result.GetValidationSummary()
for _, expected := range tt.contains {
if !strings.Contains(summary, expected) {
t.Errorf("GetValidationSummary() should contain '%s', got: %s", expected, summary)
}
}
})
}
}

View file

@ -1,15 +1,18 @@
package main
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/wailsapp/wails/v3/internal/s"
)
const versionFile = "../../internal/version/version.txt"
const (
versionFile = "../../internal/version/version.txt"
unreleasedChangelogFile = "../../UNRELEASED_CHANGELOG.md"
changelogFile = "../../../docs/src/content/docs/changelog.mdx"
)
func checkError(err error) {
if err != nil {
@ -18,17 +21,302 @@ func checkError(err error) {
}
}
// TODO:This can be replaced with "https://github.com/coreos/go-semver/blob/main/semver/semver.go"
// getUnreleasedChangelogTemplate returns the template content for UNRELEASED_CHANGELOG.md
func getUnreleasedChangelogTemplate() string {
return `# Unreleased Changes
<!--
This file is used to collect changelog entries for the next v3-alpha release.
Add your changes under the appropriate sections below.
Guidelines:
- Follow the "Keep a Changelog" format (https://keepachangelog.com/)
- Write clear, concise descriptions of changes
- Include the impact on users when relevant
- Use present tense ("Add feature" not "Added feature")
- Reference issue/PR numbers when applicable
This file is automatically processed by the nightly release workflow.
After processing, the content will be moved to the main changelog and this file will be reset.
-->
## Added
<!-- New features, capabilities, or enhancements -->
## Changed
<!-- Changes in existing functionality -->
## Fixed
<!-- Bug fixes -->
## Deprecated
<!-- Soon-to-be removed features -->
## Removed
<!-- Features removed in this release -->
## Security
<!-- Security-related changes -->
---
### Example Entries:
**Added:**
- Add support for custom window icons in application options
- Add new ` + "`SetWindowIcon()`" + ` method to runtime API (#1234)
**Changed:**
- Update minimum Go version requirement to 1.21
- Improve error messages for invalid configuration files
**Fixed:**
- Fix memory leak in event system during window close operations (#5678)
- Fix crash when using context menus on Linux with Wayland
**Security:**
- Update dependencies to address CVE-2024-12345 in third-party library
`
}
// clearUnreleasedChangelog clears the UNRELEASED_CHANGELOG.md file and resets it with the template
func clearUnreleasedChangelog() error {
template := getUnreleasedChangelogTemplate()
// Write the template back to the file
err := os.WriteFile(unreleasedChangelogFile, []byte(template), 0o644)
if err != nil {
return fmt.Errorf("failed to reset UNRELEASED_CHANGELOG.md: %w", err)
}
fmt.Printf("Successfully reset %s with template content\n", unreleasedChangelogFile)
return nil
}
// extractChangelogContent extracts the actual changelog content from UNRELEASED_CHANGELOG.md
// It returns the content between the section headers and the example section
func extractChangelogContent() (string, error) {
content, err := os.ReadFile(unreleasedChangelogFile)
if err != nil {
return "", fmt.Errorf("failed to read %s: %w", unreleasedChangelogFile, err)
}
contentStr := string(content)
lines := strings.Split(contentStr, "\n")
var result []string
var inExampleSection bool
var inCommentBlock bool
var hasActualContent bool
var currentSection string
for i, line := range lines {
trimmedLine := strings.TrimSpace(line)
// Track comment blocks (handle multi-line comments)
if strings.Contains(line, "<!--") {
inCommentBlock = true
// Check if comment ends on same line
if strings.Contains(line, "-->") {
inCommentBlock = false
}
continue
}
if inCommentBlock {
if strings.Contains(line, "-->") {
inCommentBlock = false
}
continue
}
// Skip the main title
if strings.HasPrefix(trimmedLine, "# Unreleased Changes") {
continue
}
// Check if we're entering the example section
if strings.HasPrefix(trimmedLine, "---") || strings.HasPrefix(trimmedLine, "### Example Entries") {
inExampleSection = true
continue
}
// Skip example section content
if inExampleSection {
continue
}
// Handle section headers
if strings.HasPrefix(trimmedLine, "##") {
currentSection = trimmedLine
// Only include section headers that have content after them
// We'll add it later if we find content
continue
}
// Handle bullet points
if strings.HasPrefix(trimmedLine, "-") || strings.HasPrefix(trimmedLine, "*") {
// Check if this is actual content (not empty)
content := strings.TrimSpace(trimmedLine[1:])
if content != "" {
// If this is the first content in a section, add the section header first
if currentSection != "" {
// Only add empty line if this isn't the first section
if len(result) > 0 {
result = append(result, "")
}
result = append(result, currentSection)
currentSection = "" // Reset so we don't add it again
}
result = append(result, line)
hasActualContent = true
}
} else if trimmedLine != "" && !strings.HasPrefix(trimmedLine, "<!--") {
// Include other non-empty, non-comment lines that aren't section headers
if !strings.HasPrefix(trimmedLine, "##") {
// Check if next line exists and is not a comment placeholder
if i+1 < len(lines) {
nextLine := strings.TrimSpace(lines[i+1])
if !strings.HasPrefix(nextLine, "<!--") {
result = append(result, line)
}
}
}
}
}
if !hasActualContent {
return "", nil
}
// Clean up result - remove any trailing empty lines
for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" {
result = result[:len(result)-1]
}
return strings.Join(result, "\n"), nil
}
// hasUnreleasedContent checks if UNRELEASED_CHANGELOG.md has actual content beyond the template
func hasUnreleasedContent() (bool, error) {
content, err := extractChangelogContent()
if err != nil {
return false, err
}
return content != "", nil
}
// safeFileOperation performs a file operation with backup and rollback capability
func safeFileOperation(filePath string, operation func() error) error {
// Create backup if file exists
var backupPath string
var hasBackup bool
if _, err := os.Stat(filePath); err == nil {
backupPath = filePath + ".backup"
if err := copyFile(filePath, backupPath); err != nil {
return fmt.Errorf("failed to create backup of %s: %w", filePath, err)
}
hasBackup = true
defer func() {
// Clean up backup file on success
if hasBackup {
_ = os.Remove(backupPath)
}
}()
}
// Perform the operation
if err := operation(); err != nil {
// Rollback if we have a backup
if hasBackup {
if rollbackErr := copyFile(backupPath, filePath); rollbackErr != nil {
return fmt.Errorf("operation failed and rollback failed: %w (rollback error: %v)", err, rollbackErr)
}
}
return err
}
return nil
}
// copyFile copies a file from src to dst
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0o644)
}
// updateVersion increments the version number properly handling semantic versioning
// Examples:
// v3.0.0-alpha.12 -> v3.0.0-alpha.13
// v3.0.0 -> v3.0.1
// v3.0.0-beta.1 -> v3.0.0-beta.2
func updateVersion() string {
currentVersionData, err := os.ReadFile(versionFile)
checkError(err)
currentVersion := string(currentVersionData)
vsplit := strings.Split(currentVersion, ".")
minorVersion, err := strconv.Atoi(vsplit[len(vsplit)-1])
checkError(err)
minorVersion++
vsplit[len(vsplit)-1] = strconv.Itoa(minorVersion)
newVersion := strings.Join(vsplit, ".")
currentVersion := strings.TrimSpace(string(currentVersionData))
// Check if it has a pre-release suffix (e.g., -alpha.12, -beta.1)
if strings.Contains(currentVersion, "-") {
// Split on the dash to separate version and pre-release
parts := strings.SplitN(currentVersion, "-", 2)
baseVersion := parts[0]
preRelease := parts[1]
// Check if pre-release has a numeric suffix (e.g., alpha.12)
lastDotIndex := strings.LastIndex(preRelease, ".")
if lastDotIndex != -1 {
preReleaseTag := preRelease[:lastDotIndex]
numberStr := preRelease[lastDotIndex+1:]
// Try to parse the number
if number, err := strconv.Atoi(numberStr); err == nil {
// Increment the pre-release number
number++
newVersion := fmt.Sprintf("%s-%s.%d", baseVersion, preReleaseTag, number)
err = os.WriteFile(versionFile, []byte(newVersion), 0o755)
checkError(err)
return newVersion
}
}
// If we can't parse the pre-release format, just increment patch version
// and remove pre-release suffix
return incrementPatchVersion(baseVersion)
}
// No pre-release suffix, just increment patch version
return incrementPatchVersion(currentVersion)
}
// incrementPatchVersion increments the patch version of a semantic version
// e.g., v3.0.0 -> v3.0.1
func incrementPatchVersion(version string) string {
// Remove 'v' prefix if present
versionWithoutV := strings.TrimPrefix(version, "v")
// Split into major.minor.patch
parts := strings.Split(versionWithoutV, ".")
if len(parts) != 3 {
// Not a valid semver, return as-is
fmt.Printf("Warning: Invalid semantic version format: %s\n", version)
return version
}
// Parse patch version
patch, err := strconv.Atoi(parts[2])
if err != nil {
fmt.Printf("Warning: Could not parse patch version: %s\n", parts[2])
return version
}
// Increment patch
patch++
// Reconstruct version
newVersion := fmt.Sprintf("v%s.%s.%d", parts[0], parts[1], patch)
err = os.WriteFile(versionFile, []byte(newVersion), 0o755)
checkError(err)
return newVersion
@ -57,6 +345,61 @@ func updateVersion() string {
//}
func main() {
// Check for --check-only flag
if len(os.Args) > 1 && os.Args[1] == "--check-only" {
// Just check if there's unreleased content and exit
changelogContent, err := extractChangelogContent()
if err != nil {
fmt.Printf("Error: Failed to extract unreleased changelog content: %v\n", err)
os.Exit(1)
}
if changelogContent == "" {
fmt.Println("No unreleased changelog content found.")
os.Exit(1)
}
fmt.Println("Found unreleased changelog content.")
os.Exit(0)
}
// Check for --extract-changelog flag
if len(os.Args) > 1 && os.Args[1] == "--extract-changelog" {
// Extract and output changelog content for release notes
changelogContent, err := extractChangelogContent()
if err != nil {
fmt.Printf("Error: Failed to extract unreleased changelog content: %v\n", err)
os.Exit(1)
}
if changelogContent == "" {
fmt.Println("No changelog content found.")
os.Exit(1)
}
fmt.Print(changelogContent)
os.Exit(0)
}
// Check for --reset-changelog flag
if len(os.Args) > 1 && os.Args[1] == "--reset-changelog" {
// Reset the changelog to the template
err := clearUnreleasedChangelog()
if err != nil {
fmt.Printf("Error: Failed to reset changelog: %v\n", err)
os.Exit(1)
}
os.Exit(0)
}
// Extract changelog content first
changelogContent, err := extractChangelogContent()
if err != nil {
fmt.Printf("Warning: Failed to extract unreleased changelog content: %v\n", err)
return
}
if changelogContent == "" {
fmt.Println("UNRELEASED_CHANGELOG.md is empty. Skipping changelog processing.")
return
}
var newVersion string
if len(os.Args) > 1 {
newVersion = os.Args[1]
@ -69,55 +412,39 @@ func main() {
newVersion = updateVersion()
}
// Update ChangeLog
s.CD("../../..")
// Read in `src/pages/changelog.md`
changelogData, err := os.ReadFile("docs/src/content/docs/changelog.mdx")
// Read in the main changelog
changelogData, err := os.ReadFile(changelogFile)
checkError(err)
changelog := string(changelogData)
// Split on the line that has `## [Unreleased]`
changelogSplit := strings.Split(changelog, "## [Unreleased]")
if len(changelogSplit) != 2 {
fmt.Printf("Error: Could not find '## [Unreleased]' section in changelog\n")
os.Exit(1)
}
// Get today's date in YYYY-MM-DD format
today := time.Now().Format("2006-01-02")
// Add the new version to the top of the changelog
newChangelog := changelogSplit[0] + "## [Unreleased]\n\n## " + newVersion + " - " + today + changelogSplit[1]
// Create the new changelog with the extracted content
newChangelog := changelogSplit[0] + "## [Unreleased]\n\n## " + newVersion + " - " + today + "\n\n" + changelogContent + changelogSplit[1]
// Write the changelog back
err = os.WriteFile("docs/src/content/docs/changelog.mdx", []byte(newChangelog), 0o755)
err = safeFileOperation(changelogFile, func() error {
return os.WriteFile(changelogFile, []byte(newChangelog), 0o644)
})
checkError(err)
// TODO: Documentation Versioning and Translations
// Clear UNRELEASED_CHANGELOG.md after successful changelog update
fmt.Printf("Changelog updated successfully. Clearing %s...\n", unreleasedChangelogFile)
err = safeFileOperation(unreleasedChangelogFile, func() error {
return clearUnreleasedChangelog()
})
if err != nil {
fmt.Printf("Error: Failed to clear %s: %v\n", unreleasedChangelogFile, err)
os.Exit(1)
}
//if !isPointRelease {
// runCommand("npx", "-y", "pnpm", "install")
//
// s.ECHO("Generating new Docs for version: " + newVersion)
//
// runCommand("npx", "pnpm", "run", "docusaurus", "docs:version", newVersion)
//
// runCommand("npx", "pnpm", "run", "write-translations")
//
// // Load the version list/*
// versionsData, err := os.ReadFile("versions.json")
// checkError(err)
// var versions []string
// err = json.Unmarshal(versionsData, &versions)
// checkError(err)
// oldestVersion := versions[len(versions)-1]
// s.ECHO(oldestVersion)
// versions = versions[0 : len(versions)-1]
// newVersions, err := json.Marshal(&versions)
// checkError(err)
// err = os.WriteFile("versions.json", newVersions, 0o755)
// checkError(err)
//
// s.ECHO("Removing old version: " + oldestVersion)
// s.CD("versioned_docs")
// s.RMDIR("version-" + oldestVersion)
// s.CD("../versioned_sidebars")
// s.RM("version-" + oldestVersion + "-sidebars.json")
// s.CD("..")
//
// runCommand("npx", "pnpm", "run", "build")
//}
fmt.Printf("Release %s processed successfully!\n", newVersion)
}

File diff suppressed because it is too large Load diff