mirror of
https://github.com/wailsapp/wails.git
synced 2026-03-14 14:45:49 +01:00
Nightly release action
This commit is contained in:
parent
e018ad6b92
commit
23e1f8923c
10 changed files with 3970 additions and 101 deletions
542
.github/workflows/nightly-release-v3.yml
vendored
542
.github/workflows/nightly-release-v3.yml
vendored
|
|
@ -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
|
||||
53
v3/UNRELEASED_CHANGELOG.md
Normal file
53
v3/UNRELEASED_CHANGELOG.md
Normal 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
|
||||
80
v3/internal/changelog/changelog.go
Normal file
80
v3/internal/changelog/changelog.go
Normal 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)
|
||||
}
|
||||
296
v3/internal/changelog/changelog_test.go
Normal file
296
v3/internal/changelog/changelog_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
239
v3/internal/changelog/parser.go
Normal file
239
v3/internal/changelog/parser.go
Normal 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())
|
||||
}
|
||||
468
v3/internal/changelog/parser_test.go
Normal file
468
v3/internal/changelog/parser_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
316
v3/internal/changelog/validator.go
Normal file
316
v3/internal/changelog/validator.go
Normal 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()
|
||||
}
|
||||
440
v3/internal/changelog/validator_test.go
Normal file
440
v3/internal/changelog/validator_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
1206
v3/tasks/release/release_test.go
Normal file
1206
v3/tasks/release/release_test.go
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue