diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d12019fc..f4374384 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -137,7 +137,40 @@ jobs: fi echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT echo "Version Code: $VERSION_CODE" - + + - name: Validate CHANGELOG.md + id: validate_changelog + run: | + # Get version without 'v' prefix for matching + VERSION="${{ steps.version.outputs.version }}" + VERSION_NUMBER="${VERSION#v}" + + echo "Checking for changelog entry for version: $VERSION_NUMBER" + + # Check if CHANGELOG.md exists + if [ ! -f "CHANGELOG.md" ]; then + echo "❌ Error: CHANGELOG.md not found!" + echo "Please create CHANGELOG.md with an entry for version $VERSION_NUMBER" + exit 1 + fi + + # Check if changelog has entry for this version (with optional build number) + if grep -qE "^##\s+v?${VERSION_NUMBER}(\s+\(Build\s+[0-9]+\))?\s*$" CHANGELOG.md; then + echo "✅ Changelog entry found for version $VERSION_NUMBER" + echo "changelog_valid=true" >> $GITHUB_OUTPUT + else + echo "❌ Error: No changelog entry found for version $VERSION_NUMBER" + echo "" + echo "Please add an entry to CHANGELOG.md:" + echo "" + echo "## v$VERSION_NUMBER (Build ${{ steps.version_info.outputs.version_code }})" + echo "1. Feature/Fix: Description of changes" + echo "" + echo "Available versions in CHANGELOG.md:" + grep -E "^##\s+v?\d+\.\d+\.\d+" CHANGELOG.md || echo " (none found)" + exit 1 + fi + - name: Create and push tag if: steps.check_version.outputs.should_release == 'true' && github.event_name == 'push' id: create_tag @@ -346,203 +379,90 @@ jobs: - name: Generate changelog id: changelog - env: - GH_TOKEN: ${{ github.token }} - GITHUB_REPOSITORY: ${{ github.repository }} run: | - # Define shared functions that will be used in both changelog generation steps - cat > /tmp/release_functions.sh << 'FUNCTIONS_EOF' - # Function to categorize commit message - categorize_commit() { - local msg="$1" - local category="" - local cleaned_msg="" - - if [[ "$msg" =~ ^fix(\(.*\))?:\ (.*)$ ]] || [[ "$msg" =~ ^fix\ (.*)$ ]]; then - category="bug" - cleaned_msg="${BASH_REMATCH[-1]}" - elif [[ "$msg" =~ ^feat(\(.*\))?:\ (.*)$ ]] || [[ "$msg" =~ ^feat\ (.*)$ ]]; then - category="feature" - cleaned_msg="${BASH_REMATCH[-1]}" - elif [[ "$msg" =~ ^perf(\(.*\))?:\ (.*)$ ]]; then - category="performance" - cleaned_msg="${BASH_REMATCH[2]}" - elif [[ "$msg" =~ ^refactor(\(.*\))?:\ (.*)$ ]]; then - category="improvement" - cleaned_msg="${BASH_REMATCH[2]}" - elif [[ "$msg" =~ ^chore(\(.*\))?:\ (.*)$ ]]; then - category="maintenance" - cleaned_msg="${BASH_REMATCH[2]}" - elif [[ "$msg" =~ ^docs(\(.*\))?:\ (.*)$ ]]; then - category="documentation" - cleaned_msg="${BASH_REMATCH[2]}" - else - category="other" - cleaned_msg="$msg" - fi + # Extract changelog from CHANGELOG.md for current version + VERSION="${{ needs.prepare.outputs.version }}" + VERSION_NUMBER="${VERSION#v}" + + echo "Extracting changelog for version: $VERSION_NUMBER" + + # Create a temporary file for release notes + # NOTE: We use a separate RELEASE_NOTES.md file so we don't overwrite CHANGELOG.md, + # which remains the canonical changelog used as the source input above. + RELEASE_NOTES_FILE="RELEASE_NOTES.md" + + # Extract content between current version header and next version header (or ---) + awk -v ver="$VERSION_NUMBER" ' + BEGIN { found=0; printing=0 } + /^##[[:space:]]+v?[0-9]+\.[0-9]+\.[0-9]+/ { + if (found && printing) { exit } + if (match($0, "^##[[:space:]]+v?" ver "([[:space:]]|\\(|$)")) { found=1; printing=1; next } + } + /^---/ { if (printing) exit } + printing { print } + ' CHANGELOG.md > /tmp/raw_changelog.txt + + # Format for GitHub Release + echo "## What's Changed" > "$RELEASE_NOTES_FILE" + echo "" >> "$RELEASE_NOTES_FILE" + + # Process each line and categorize + features="" + fixes="" + improvements="" - # Remove PR numbers from the end - cleaned_msg=$(echo "$cleaned_msg" | sed 's/ (#[0-9]*)//') - - # Capitalize first letter - cleaned_msg="$(echo "${cleaned_msg:0:1}" | tr '[:lower:]' '[:upper:]')${cleaned_msg:1}" - - echo "$category:$cleaned_msg" - } - - # Function to get GitHub username from commit - get_github_username() { - local commit_sha="$1" - local github_repo="${GITHUB_REPOSITORY}" - # Try to get the GitHub username from the commit using gh api - local username=$(gh api "repos/${github_repo}/commits/${commit_sha}" --jq '.author.login // empty' 2>/dev/null || echo "") - if [ -n "$username" ]; then - echo "@$username" - else - # Fallback: try to get committer login if author login is not available - local committer=$(gh api "repos/${github_repo}/commits/${commit_sha}" --jq '.committer.login // empty' 2>/dev/null || echo "") - if [ -n "$committer" ]; then - echo "@$committer" - else - # Last resort: use hardcoded mapping for known authors - local git_author=$(git show -s --format='%an' $commit_sha) - case "$git_author" in - "Gray Zhang" | "gray" | "Gray") - echo "@graycreate" - ;; - "github-actions[bot]") - echo "@github-actions[bot]" - ;; - *) - # If no mapping found, use git author name without @ - echo "$git_author" - ;; - esac - fi - fi - } - FUNCTIONS_EOF - - # Source the functions - source /tmp/release_functions.sh - - # Get commits since last tag - LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") - CURRENT_TAG="${{ needs.prepare.outputs.version }}" - - # Collect commits and categorize them - declare -A features - declare -A bugs - declare -A improvements - declare -A performance - declare -A maintenance - - if [ -n "$LAST_TAG" ]; then - RANGE="$LAST_TAG..HEAD" - else - RANGE="HEAD" - fi - - # Process commits while IFS= read -r line; do - sha=$(echo "$line" | cut -d' ' -f1) - msg=$(echo "$line" | cut -d' ' -f2-) - - # Skip version bump and merge commits - if [[ "$msg" =~ "bump version" ]] || [[ "$msg" =~ "Merge pull request" ]] || [[ "$msg" =~ "Merge branch" ]]; then - continue + # Skip empty lines + [ -z "$line" ] && continue + + # Extract type and description + if echo "$line" | grep -qE "^[0-9]+\.\s+Feature:"; then + desc=$(echo "$line" | sed -E 's/^[0-9]+\.\s+Feature:\s*//') + features="${features}* ${desc}\n" + elif echo "$line" | grep -qE "^[0-9]+\.\s+Fix:"; then + desc=$(echo "$line" | sed -E 's/^[0-9]+\.\s+Fix:\s*//') + fixes="${fixes}* ${desc}\n" + elif echo "$line" | grep -qE "^[0-9]+\.\s+Improvement:"; then + desc=$(echo "$line" | sed -E 's/^[0-9]+\.\s+Improvement:\s*//') + improvements="${improvements}* ${desc}\n" + elif echo "$line" | grep -qE "^[0-9]+\.\s+Breaking:"; then + desc=$(echo "$line" | sed -E 's/^[0-9]+\.\s+Breaking:\s*//') + features="${features}* **BREAKING**: ${desc}\n" fi + done < /tmp/raw_changelog.txt - categorized=$(categorize_commit "$msg") - category=$(echo "$categorized" | cut -d':' -f1) - clean_msg=$(echo "$categorized" | cut -d':' -f2-) - author=$(get_github_username "$sha") - - # Store in associative arrays - case "$category" in - feature) - features["$clean_msg"]="$author" - ;; - bug) - bugs["$clean_msg"]="$author" - ;; - improvement) - improvements["$clean_msg"]="$author" - ;; - performance) - performance["$clean_msg"]="$author" - ;; - maintenance) - maintenance["$clean_msg"]="$author" - ;; - esac - done < <(git log --oneline --no-merges $RANGE) - - # Generate GitHub Release Notes - echo "## What's Changed" > CHANGELOG.md - echo "" >> CHANGELOG.md - - if [ ${#features[@]} -gt 0 ]; then - echo "### 🚀 New Features" >> CHANGELOG.md - for msg in "${!features[@]}"; do - author="${features[$msg]}" - if [ -n "$author" ]; then - echo "* $msg by $author" >> CHANGELOG.md - else - echo "* $msg" >> CHANGELOG.md - fi - done - echo "" >> CHANGELOG.md + # Write categorized sections + if [ -n "$features" ]; then + echo "### New Features" >> "$RELEASE_NOTES_FILE" + echo -e "$features" >> "$RELEASE_NOTES_FILE" fi - if [ ${#bugs[@]} -gt 0 ]; then - echo "### 🐛 Bug Fixes" >> CHANGELOG.md - for msg in "${!bugs[@]}"; do - author="${bugs[$msg]}" - if [ -n "$author" ]; then - echo "* $msg by $author" >> CHANGELOG.md - else - echo "* $msg" >> CHANGELOG.md - fi - done - echo "" >> CHANGELOG.md + if [ -n "$fixes" ]; then + echo "### Bug Fixes" >> "$RELEASE_NOTES_FILE" + echo -e "$fixes" >> "$RELEASE_NOTES_FILE" fi - if [ ${#improvements[@]} -gt 0 ]; then - echo "### 💪 Improvements" >> CHANGELOG.md - for msg in "${!improvements[@]}"; do - author="${improvements[$msg]}" - if [ -n "$author" ]; then - echo "* $msg by $author" >> CHANGELOG.md - else - echo "* $msg" >> CHANGELOG.md - fi - done - echo "" >> CHANGELOG.md + if [ -n "$improvements" ]; then + echo "### Improvements" >> "$RELEASE_NOTES_FILE" + echo -e "$improvements" >> "$RELEASE_NOTES_FILE" fi - if [ ${#performance[@]} -gt 0 ]; then - echo "### ⚡ Performance" >> CHANGELOG.md - for msg in "${!performance[@]}"; do - author="${performance[$msg]}" - if [ -n "$author" ]; then - echo "* $msg by $author" >> CHANGELOG.md - else - echo "* $msg" >> CHANGELOG.md - fi - done - echo "" >> CHANGELOG.md + # Add full changelog link + LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$LAST_TAG" ]; then + echo "" >> "$RELEASE_NOTES_FILE" + echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${LAST_TAG}...${{ needs.prepare.outputs.version }}" >> "$RELEASE_NOTES_FILE" fi - echo "" >> CHANGELOG.md - echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${LAST_TAG}...${{ needs.prepare.outputs.version }}" >> CHANGELOG.md + echo "Generated release notes:" + cat "$RELEASE_NOTES_FILE" - name: Create Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ needs.prepare.outputs.version }} name: Release ${{ needs.prepare.outputs.version }} - body_path: CHANGELOG.md + body_path: RELEASE_NOTES.md draft: false prerelease: false files: | @@ -606,211 +526,99 @@ jobs: echo "Deploying to track: $TRACK with status: $STATUS" - name: Create whatsnew directory - env: - GH_TOKEN: ${{ github.token }} - GITHUB_REPOSITORY: ${{ github.repository }} run: | mkdir -p whatsnew # Google Play whatsnew character limit MAX_CHARS=500 - # Define shared functions - cat > /tmp/release_functions.sh << 'FUNCTIONS_EOF' - # Function to categorize commit message - categorize_commit() { - local msg="$1" - local category="" - local cleaned_msg="" - - if [[ "$msg" =~ ^fix(\(.*\))?:\ (.*)$ ]] || [[ "$msg" =~ ^fix\ (.*)$ ]]; then - category="bug" - cleaned_msg="${BASH_REMATCH[-1]}" - elif [[ "$msg" =~ ^feat(\(.*\))?:\ (.*)$ ]] || [[ "$msg" =~ ^feat\ (.*)$ ]]; then - category="feature" - cleaned_msg="${BASH_REMATCH[-1]}" - elif [[ "$msg" =~ ^perf(\(.*\))?:\ (.*)$ ]]; then - category="performance" - cleaned_msg="${BASH_REMATCH[2]}" - elif [[ "$msg" =~ ^refactor(\(.*\))?:\ (.*)$ ]]; then - category="improvement" - cleaned_msg="${BASH_REMATCH[2]}" - elif [[ "$msg" =~ ^chore(\(.*\))?:\ (.*)$ ]]; then - category="maintenance" - cleaned_msg="${BASH_REMATCH[2]}" - elif [[ "$msg" =~ ^docs(\(.*\))?:\ (.*)$ ]]; then - category="documentation" - cleaned_msg="${BASH_REMATCH[2]}" - else - category="other" - cleaned_msg="$msg" - fi - - # Remove PR numbers from the end - cleaned_msg=$(echo "$cleaned_msg" | sed 's/ (#[0-9]*)//') + CURRENT_VERSION="${{ needs.prepare.outputs.version }}" + VERSION_NUMBER="${CURRENT_VERSION#v}" - # Capitalize first letter - cleaned_msg="$(echo "${cleaned_msg:0:1}" | tr '[:lower:]' '[:upper:]')${cleaned_msg:1}" + # Feedback headers + FEEDBACK_HEADER_EN="Feedback: https://v2er.app/help" + FEEDBACK_HEADER_ZH="唯一问题反馈渠道:https://v2er.app/help" - echo "$category:$cleaned_msg" + # Function to extract changelog for a version from CHANGELOG.md + extract_version_changelog() { + local version="$1" + awk -v ver="$version" ' + BEGIN { found=0; printing=0 } + /^##[[:space:]]+v?[0-9]+\.[0-9]+\.[0-9]+/ { + if (found && printing) { exit } + if (match($0, "^##[[:space:]]+v?" ver "([[:space:]]|\\(|$)")) { found=1; printing=1; next } + } + /^---/ { if (printing) exit } + printing { print } + ' CHANGELOG.md } - # Function to collect commits for a version range - collect_version_commits() { - local range="$1" - local -n feat_ref=$2 - local -n bug_ref=$3 - local -n improve_ref=$4 - local -n perf_ref=$5 - - while IFS= read -r line; do + # Function to format changelog for Google Play (bullet points) + format_for_google_play() { + local content="$1" + echo "$content" | while IFS= read -r line; do [ -z "$line" ] && continue - sha=$(echo "$line" | cut -d' ' -f1) - msg=$(echo "$line" | cut -d' ' -f2-) - - # Skip version bump and merge commits - if [[ "$msg" =~ "bump version" ]] || [[ "$msg" =~ "Merge pull request" ]] || [[ "$msg" =~ "Merge branch" ]]; then - continue + # Convert "1. Feature: xxx" to "• xxx" + if echo "$line" | grep -qE "^[0-9]+\.\s+(Feature|Fix|Improvement|Breaking):"; then + desc=$(echo "$line" | sed -E 's/^[0-9]+\.\s+(Feature|Fix|Improvement|Breaking):\s*//') + echo "• $desc" fi - - categorized=$(categorize_commit "$msg") - category=$(echo "$categorized" | cut -d':' -f1) - clean_msg=$(echo "$categorized" | cut -d':' -f2-) - - case "$category" in - feature) feat_ref["$clean_msg"]=1 ;; - bug) bug_ref["$clean_msg"]=1 ;; - improvement) improve_ref["$clean_msg"]=1 ;; - performance) perf_ref["$clean_msg"]=1 ;; - esac - done < <(git log --oneline --no-merges $range 2>/dev/null) + done } - # Function to generate version notes (compact format for Google Play) - generate_version_notes() { - local -n feat=$1 - local -n bugs=$2 - local -n improve=$3 - local -n perf=$4 - local output="" + # Get all versions from CHANGELOG.md (up to 3) + VERSIONS=($(grep -E "^##\s+v?[0-9]+\.[0-9]+\.[0-9]+" CHANGELOG.md | sed -E 's/^##\s+v?([0-9]+\.[0-9]+\.[0-9]+).*/\1/' | head -3)) + echo "Found versions in CHANGELOG.md: ${VERSIONS[*]}" - if [ ${#feat[@]} -gt 0 ]; then - for msg in "${!feat[@]}"; do - output+="• $msg"$'\n' - done - fi - if [ ${#bugs[@]} -gt 0 ]; then - for msg in "${!bugs[@]}"; do - output+="• $msg"$'\n' - done - fi - if [ ${#improve[@]} -gt 0 ]; then - for msg in "${!improve[@]}"; do - output+="• $msg"$'\n' - done - fi - if [ ${#perf[@]} -gt 0 ]; then - for msg in "${!perf[@]}"; do - output+="• $msg"$'\n' - done - fi - echo "$output" - } - FUNCTIONS_EOF + # Build whatsnew content + build_whatsnew() { + local header="$1" + local max_chars="$2" + local content="" - # Source the shared functions - source /tmp/release_functions.sh + # Start with header and version + content="${header} - # Get last 3 tags for multi-version release notes - TAGS=($(git tag --sort=-v:refname | head -3)) - CURRENT_VERSION="${{ needs.prepare.outputs.version }}" +V2er ${CURRENT_VERSION} - echo "Found tags: ${TAGS[*]}" - echo "Current version: $CURRENT_VERSION" +" + current_len=${#content} - # Feedback header - FEEDBACK_HEADER_EN="Feedback: https://v2er.app/help" - FEEDBACK_HEADER_ZH="唯一问题反馈渠道:https://v2er.app/help" - - # Build release notes for each version - declare -a version_notes_en - declare -a version_notes_zh - - # Current version (HEAD to last tag or all commits if no tags) - declare -A curr_features curr_bugs curr_improvements curr_performance - if [ ${#TAGS[@]} -gt 0 ]; then - collect_version_commits "${TAGS[0]}..HEAD" curr_features curr_bugs curr_improvements curr_performance - else - collect_version_commits "HEAD~10..HEAD" curr_features curr_bugs curr_improvements curr_performance - fi - - CURR_NOTES=$(generate_version_notes curr_features curr_bugs curr_improvements curr_performance) - if [ -n "$CURR_NOTES" ]; then - version_notes_en+=("$CURR_NOTES") - version_notes_zh+=("$CURR_NOTES") - fi + # Add changelog for each version + for i in "${!VERSIONS[@]}"; do + version="${VERSIONS[$i]}" + raw_changelog=$(extract_version_changelog "$version") + formatted=$(format_for_google_play "$raw_changelog") - # Previous versions (from tags) - for i in 0 1; do - if [ $i -lt ${#TAGS[@]} ] && [ $((i+1)) -lt ${#TAGS[@]} ]; then - declare -A prev_features prev_bugs prev_improvements prev_performance - collect_version_commits "${TAGS[$((i+1))]}..${TAGS[$i]}" prev_features prev_bugs prev_improvements prev_performance - PREV_NOTES=$(generate_version_notes prev_features prev_bugs prev_improvements prev_performance) - if [ -n "$PREV_NOTES" ]; then - version_notes_en+=("--- ${TAGS[$i]} ---"$'\n'"$PREV_NOTES") - version_notes_zh+=("--- ${TAGS[$i]} ---"$'\n'"$PREV_NOTES") + if [ -z "$formatted" ]; then + continue fi - unset prev_features prev_bugs prev_improvements prev_performance - elif [ $i -lt ${#TAGS[@]} ] && [ $((i+1)) -ge ${#TAGS[@]} ]; then - # Last tag - get commits before it - declare -A prev_features prev_bugs prev_improvements prev_performance - collect_version_commits "${TAGS[$i]}~5..${TAGS[$i]}" prev_features prev_bugs prev_improvements prev_performance - PREV_NOTES=$(generate_version_notes prev_features prev_bugs prev_improvements prev_performance) - if [ -n "$PREV_NOTES" ]; then - version_notes_en+=("--- ${TAGS[$i]} ---"$'\n'"$PREV_NOTES") - version_notes_zh+=("--- ${TAGS[$i]} ---"$'\n'"$PREV_NOTES") + + if [ "$i" -eq 0 ]; then + version_section="$formatted" + else + version_section=" +--- v${version} --- +${formatted}" fi - unset prev_features prev_bugs prev_improvements prev_performance - fi - done - # Generate English release notes with character limit - { - echo "$FEEDBACK_HEADER_EN" - echo "" - echo "V2er $CURRENT_VERSION" - echo "" - } > whatsnew/whatsnew-en-US + section_len=${#version_section} + if [ $((current_len + section_len)) -le "$max_chars" ]; then + content="${content}${version_section}" + current_len=$((current_len + section_len)) + else + break + fi + done - CURRENT_LEN=$(wc -c < whatsnew/whatsnew-en-US) - for notes in "${version_notes_en[@]}"; do - NOTES_LEN=${#notes} - if [ $((CURRENT_LEN + NOTES_LEN + 2)) -lt $MAX_CHARS ]; then - echo "$notes" >> whatsnew/whatsnew-en-US - CURRENT_LEN=$((CURRENT_LEN + NOTES_LEN + 2)) - else - break - fi - done + echo "$content" + } - # Generate Chinese release notes with character limit - { - echo "$FEEDBACK_HEADER_ZH" - echo "" - echo "V2er $CURRENT_VERSION" - echo "" - } > whatsnew/whatsnew-zh-CN + # Generate English whatsnew + build_whatsnew "$FEEDBACK_HEADER_EN" "$MAX_CHARS" > whatsnew/whatsnew-en-US - CURRENT_LEN=$(wc -c < whatsnew/whatsnew-zh-CN) - for notes in "${version_notes_zh[@]}"; do - NOTES_LEN=${#notes} - if [ $((CURRENT_LEN + NOTES_LEN + 2)) -lt $MAX_CHARS ]; then - echo "$notes" >> whatsnew/whatsnew-zh-CN - CURRENT_LEN=$((CURRENT_LEN + NOTES_LEN + 2)) - else - break - fi - done + # Generate Chinese whatsnew (same content, different header) + build_whatsnew "$FEEDBACK_HEADER_ZH" "$MAX_CHARS" > whatsnew/whatsnew-zh-CN # Show generated content echo "=== English whatsnew ($(wc -c < whatsnew/whatsnew-en-US) chars) ===" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..d77502e6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,61 @@ +# Changelog + +All notable changes to V2er Android app will be documented in this file. + +## v2.3.14 (Build 244) +1. Feature: Add reply sorting by popularity +2. Feature: Add built-in browser option for external links +3. Feature: Add Imgur image upload integration +4. Feature: Show app version in Settings +5. Feature: Include past 3 versions in Google Play release notes +6. Fix: Reduce splash screen logo size to prevent clipping +7. Fix: Status bar color inconsistency in Settings page +8. Fix: Simplify version display to show only version name +9. Improvement: Update feedback channel to v2er.app/help +10. Improvement: Move feedback item to first position in settings + +## v2.3.13 (Build 243) +1. Feature: Add Android Adaptive Icons support +2. Feature: Add analytics tracking to VShare WebView +3. Improvement: Update Telegram group URL to new invite link + +## v2.3.12 (Build 242) +1. Feature: Update splash screen logo to match launcher icon +2. Feature: Implement fullscreen WebView for vshare with theme auto-adaptation +3. Feature: Add vshare version checking and notification badge +4. Feature: Improve Vshare WebView with intent URL support and status bar padding +5. Fix: Posting navigation - handle successful responses in error handler +6. Improvement: Use BaseActivity's loading indicator for Vshare WebView + +## v2.3.11 (Build 241) +1. Feature: Update app icon to match iOS design +2. Fix: Auto-collapse title bar not working +3. Fix: Main interface scrolling and layout issues + +## v2.3.10 (Build 240) +1. Feature: Improve AppBar scrolling behavior with dynamic toolbar visibility +2. Fix: AppBar title bar overlapping status bar issue +3. Fix: Improve font size and layout for better accessibility + +--- + +## How to Update Changelog + +When updating the version in `config.gradle`: + +1. Add a new version section at the top of this file +2. List all changes since the last version: + - Use "Feature:" for new features + - Use "Fix:" for bug fixes + - Use "Improvement:" for enhancements + - Use "Breaking:" for breaking changes + +Example format: +``` +## v2.4.0 (Build 245) +1. Feature: Description of new feature +2. Fix: Description of bug fix +3. Improvement: Description of enhancement +``` + +The changelog will be automatically used in GitHub Release notes and Google Play. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index df9d5a55..37be337a 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -7,6 +7,9 @@ # For a list of all available plugins, check out # https://docs.fastlane.tools/plugins/available-plugins +# Import changelog helper +require_relative 'changelog_helper' + default_platform(:android) platform :android do @@ -74,6 +77,11 @@ platform :android do desc "Deploy to Google Play Store (Beta Track)" lane :deploy_beta do + # Validate changelog exists for current version + unless ChangelogHelper.validate_changelog_exists + UI.user_error!("Please add an entry to CHANGELOG.md for the current version before releasing!") + end + build_bundle upload_to_play_store( track: "beta", @@ -87,6 +95,11 @@ platform :android do desc "Deploy to Google Play Store (Production)" lane :deploy_production do + # Validate changelog exists for current version + unless ChangelogHelper.validate_changelog_exists + UI.user_error!("Please add an entry to CHANGELOG.md for the current version before releasing!") + end + build_bundle upload_to_play_store( track: "production", @@ -151,6 +164,41 @@ platform :android do ) end + desc "Validate changelog exists for current version" + lane :validate_changelog do + version = ChangelogHelper.get_current_version + version_code = ChangelogHelper.get_current_version_code + + UI.message("Current version: #{version} (code: #{version_code})") + + if ChangelogHelper.validate_changelog_exists + UI.success("✅ Ready for release!") + changelog = ChangelogHelper.get_current_changelog_for_github + UI.message("Changelog preview:\n#{changelog}") + else + UI.user_error!("❌ Cannot release without changelog entry!") + end + end + + desc "Show current version info and changelog" + lane :version_info do + version = ChangelogHelper.get_current_version + version_code = ChangelogHelper.get_current_version_code + + UI.message("=== Version Info ===") + UI.message("Version: #{version}") + UI.message("Version Code: #{version_code}") + UI.message("") + + if ChangelogHelper.validate_changelog_exists + UI.message("=== GitHub Release Changelog ===") + UI.message(ChangelogHelper.get_current_changelog_for_github) + UI.message("") + UI.message("=== Google Play Changelog ===") + UI.message(ChangelogHelper.get_current_changelog_for_google_play) + end + end + desc "Run tests" lane :test do gradle(task: "test") diff --git a/fastlane/changelog_helper.rb b/fastlane/changelog_helper.rb new file mode 100644 index 00000000..e736edf0 --- /dev/null +++ b/fastlane/changelog_helper.rb @@ -0,0 +1,278 @@ +# changelog_helper.rb +# Helper module to extract changelog entries from CHANGELOG.md + +require 'fastlane_core/ui/ui' + +module ChangelogHelper + # Import Fastlane's UI for logging + UI = FastlaneCore::UI unless defined?(UI) + + # Google Play whatsnew character limit + GOOGLE_PLAY_LIMIT = 500 + + # GitHub Release notes limit (generous) + GITHUB_RELEASE_LIMIT = 10000 + + # Get the current version from config.gradle + # @return [String] The current version name + def self.get_current_version + config_path = File.expand_path("../config.gradle", __dir__) + + unless File.exist?(config_path) + UI.user_error!("config.gradle not found at #{config_path}") + end + + content = File.read(config_path) + version_match = content.match(/versionName:\s*"([^"]+)"/) + + if version_match + version = version_match[1] + UI.message("Current version from config.gradle: #{version}") + version + else + UI.user_error!("Could not find versionName in config.gradle") + end + end + + # Get the current version code from config.gradle + # @return [Integer] The current version code + def self.get_current_version_code + config_path = File.expand_path("../config.gradle", __dir__) + + unless File.exist?(config_path) + UI.user_error!("config.gradle not found at #{config_path}") + end + + content = File.read(config_path) + code_match = content.match(/versionCode:\s*(\d+)/) + + if code_match + code = code_match[1].to_i + UI.message("Current version code from config.gradle: #{code}") + code + else + UI.user_error!("Could not find versionCode in config.gradle") + end + end + + # Get all versions from CHANGELOG.md in order (newest first) + # @return [Array] List of version strings + def self.get_all_versions + changelog_path = File.expand_path("../CHANGELOG.md", __dir__) + + unless File.exist?(changelog_path) + UI.error("CHANGELOG.md not found at #{changelog_path}") + return [] + end + + content = File.read(changelog_path) + versions = [] + + content.each_line do |line| + if match = line.match(/^##\s+v?(\d+\.\d+\.\d+)/) + versions << match[1] + end + end + + versions + end + + # Extract raw changelog content for a version (without formatting) + # @param version [String] The version to extract + # @return [String, nil] Raw changelog content or nil if not found + def self.extract_raw_changelog(version) + changelog_path = File.expand_path("../CHANGELOG.md", __dir__) + + unless File.exist?(changelog_path) + return nil + end + + content = File.read(changelog_path) + # Match version with or without 'v' prefix and optional build number + version_pattern = /^##\s+v?#{Regexp.escape(version)}(?:\s+\(Build\s+\w+\))?\s*$/ + + lines = content.lines + start_index = nil + end_index = nil + + lines.each_with_index do |line, index| + if line.match?(version_pattern) + start_index = index + break + end + end + + return nil if start_index.nil? + + ((start_index + 1)...lines.length).each do |index| + line = lines[index] + if line.match?(/^##\s+/) || line.match?(/^---/) + end_index = index + break + end + end + + end_index ||= lines.length + + changelog_lines = lines[(start_index + 1)...end_index] + changelog_lines.join("").strip + end + + # Extract changelog for a specific version from CHANGELOG.md + # @param version [String] The version to extract (e.g., "2.3.14") + # @return [String] The changelog content for the specified version + def self.extract_changelog(version) + changelog_path = File.expand_path("../CHANGELOG.md", __dir__) + + unless File.exist?(changelog_path) + UI.error("CHANGELOG.md not found at #{changelog_path}") + return "Bug fixes and improvements" + end + + raw_content = extract_raw_changelog(version) + + if raw_content.nil? || raw_content.empty? + UI.important("No changelog content found for version #{version}") + UI.message("Available versions:") + get_all_versions.each do |v| + UI.message(" - v#{v}") + end + return "Bug fixes and improvements" + end + + UI.success("Extracted changelog for version #{version}:") + UI.message(raw_content) + + raw_content + end + + # Format changelog content for Google Play (bullet points, character limit) + # @param content [String] Raw changelog content + # @return [String] Formatted changelog for Google Play + def self.format_for_google_play(content) + # Convert numbered lists to bullet points + # "1. Feature: xxx" -> "• xxx" + formatted = content.gsub(/^\d+\.\s+(Feature|Fix|Improvement|Breaking):\s*/, "• ") + + formatted + end + + # Get changelog for current version and up to 2 previous versions (3 versions total) + # Combined length respects Google Play's 500 character limit + # @return [String] Combined changelog for Google Play + def self.get_current_changelog_for_google_play + current_version = get_current_version + all_versions = get_all_versions + + # Find current version index + current_index = all_versions.index(current_version) + + if current_index.nil? + UI.important("Current version #{current_version} not found in CHANGELOG.md") + return "Bug fixes and improvements" + end + + # Get up to 3 versions (current + 2 previous) + versions_to_include = all_versions[current_index, 3] || [current_version] + + # Build combined changelog + combined_parts = [] + # Use actual newline characters so length calculation is accurate + feedback_header = "Feedback: https://v2er.app/help\n\n" + + # Reserve space for feedback header (using actual string length) + available_space = GOOGLE_PLAY_LIMIT - feedback_header.length + + versions_to_include.each_with_index do |version, index| + raw_content = extract_raw_changelog(version) + next if raw_content.nil? || raw_content.empty? + + # Format the content (convert to bullet points) + formatted_content = format_for_google_play(raw_content) + + # Add version header for older versions + if index == 0 + version_section = formatted_content + else + version_section = "\n--- v#{version} ---\n#{formatted_content}" + end + + # Check if adding this section would exceed the limit (use <= to include content at exactly the limit) + current_length = combined_parts.join.length + if current_length + version_section.length <= available_space + combined_parts << version_section + else + break + end + end + + if combined_parts.empty? + return feedback_header + "Bug fixes and improvements" + end + + result = feedback_header + combined_parts.join + + UI.success("Combined changelog for #{versions_to_include.length} version(s)") + UI.message("Total length: #{result.length} / #{GOOGLE_PLAY_LIMIT} characters") + + result + end + + # Get changelog for GitHub Release (markdown format, less restrictive limit) + # @return [String] Changelog formatted for GitHub Release + def self.get_current_changelog_for_github + current_version = get_current_version + raw_content = extract_raw_changelog(current_version) + + if raw_content.nil? || raw_content.empty? + return "Bug fixes and improvements" + end + + # Format as markdown + # Keep the numbered list format for GitHub + formatted = raw_content.lines.map do |line| + if line.match?(/^\d+\.\s+(Feature):/i) + line.gsub(/^\d+\.\s+Feature:\s*/i, "- **Feature**: ") + elsif line.match?(/^\d+\.\s+(Fix):/i) + line.gsub(/^\d+\.\s+Fix:\s*/i, "- **Fix**: ") + elsif line.match?(/^\d+\.\s+(Improvement):/i) + line.gsub(/^\d+\.\s+Improvement:\s*/i, "- **Improvement**: ") + elsif line.match?(/^\d+\.\s+(Breaking):/i) + line.gsub(/^\d+\.\s+Breaking:\s*/i, "- **Breaking**: ") + else + line + end + end.join + + formatted.strip + end + + # Validate that changelog exists for the current version + # @return [Boolean] True if changelog exists, false otherwise + def self.validate_changelog_exists + current_version = get_current_version + changelog_path = File.expand_path("../CHANGELOG.md", __dir__) + + unless File.exist?(changelog_path) + UI.error("❌ CHANGELOG.md not found!") + UI.message("Please create CHANGELOG.md with an entry for version #{current_version}") + return false + end + + content = File.read(changelog_path) + version_pattern = /^##\s+v?#{Regexp.escape(current_version)}/ + + if content.match?(version_pattern) + UI.success("✅ Changelog entry found for version #{current_version}") + return true + else + UI.error("❌ No changelog entry found for version #{current_version}") + UI.message("Please add a changelog entry in CHANGELOG.md:") + UI.message("") + UI.message("## v#{current_version}") + UI.message("1. Feature/Fix: Description of changes") + UI.message("") + return false + end + end +end