From 70d7319efe5e876498bfb319ee4e1987e829bb6a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:19:08 -0600 Subject: [PATCH] feat(release): Automate changelog, asset updates, and tagging (#4407) --- .github/release.yml | 13 -- .../workflows/create-or-promote-release.yml | 162 ++++++++++++++++-- .github/workflows/docs.yml | 20 ++- .github/workflows/promote.yml | 63 ++++++- .github/workflows/release.yml | 59 ++++++- .github/workflows/reusable-android-test.yml | 3 +- .github/workflows/reusable-lint.yml | 9 +- RELEASE_PROCESS.md | 14 +- fastlane/Fastfile | 11 +- 9 files changed, 309 insertions(+), 45 deletions(-) diff --git a/.github/release.yml b/.github/release.yml index 56b3b91b7..efa9f0183 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -19,16 +19,3 @@ changelog: - repo - automation - release - - - title: 👷Dependencies - labels: - - dependencies - - - title: 🧱Repo - labels: - - repo - - - title: 🤖Automated - labels: - - automation - - release diff --git a/.github/workflows/create-or-promote-release.yml b/.github/workflows/create-or-promote-release.yml index 04580d301..485d5c7d7 100644 --- a/.github/workflows/create-or-promote-release.yml +++ b/.github/workflows/create-or-promote-release.yml @@ -35,6 +35,7 @@ jobs: release_name: ${{ steps.calculate_tags.outputs.release_name }} final_tag: ${{ steps.calculate_tags.outputs.final_tag }} from_channel: ${{ steps.calculate_tags.outputs.from_channel }} + commit_sha: ${{ steps.get_sha.outputs.commit_sha }} steps: - name: Checkout code uses: actions/checkout@v6 @@ -105,21 +106,154 @@ jobs: fi shell: bash - - name: Create and push new tag - if: ${{ !inputs.dry_run }} - env: - GH_TOKEN: ${{ github.token }} + - name: Determine previous tag + if: ${{ !inputs.dry_run && inputs.channel == 'internal' }} + id: previous_tag run: | - FINAL_TAG="${{ steps.calculate_tags.outputs.final_tag }}" - if [[ "${{ inputs.channel }}" == "internal" ]]; then - # For internal, tag the current HEAD. tag_to_process and final_tag are the same. - git tag $FINAL_TAG + # Find the tag reachable from the parent of the current HEAD + # Exclude tags with hyphens to skip pre-releases + PREV_TAG=$(git describe --tags --abbrev=0 --exclude "*-*" HEAD 2>/dev/null || echo "") + echo "Found previous tag: $PREV_TAG" + echo "PREV_TAG=$PREV_TAG" >> $GITHUB_OUTPUT + + - name: Update External Assets (Firmware, Hardware, Protos) + if: ${{ !inputs.dry_run && inputs.channel == 'internal' }} + run: | + # Update Submodules (Protobufs) + echo "Updating core/proto submodule..." + git submodule update --init --remote core/proto + + # Update Firmware List + firmware_file_path="app/src/main/assets/firmware_releases.json" + temp_firmware_file="/tmp/new_firmware_releases.json" + + echo "Fetching latest firmware releases..." + curl -s --fail https://api.meshtastic.org/github/firmware/list > "$temp_firmware_file" + + if ! jq empty "$temp_firmware_file" 2>/dev/null; then + echo "::error::Firmware API returned invalid JSON data. Aborting." + exit 1 else - # For promotions, create the new tag pointing to the same commit as the old tag. - TAG_TO_PROCESS="${{ steps.calculate_tags.outputs.tag_to_process }}" - git tag $FINAL_TAG $TAG_TO_PROCESS + if [ ! -f "$firmware_file_path" ] || ! jq --sort-keys . "$temp_firmware_file" | diff -q - <(jq --sort-keys . "$firmware_file_path"); then + echo "Changes detected in firmware list or local file missing. Updating $firmware_file_path." + cp "$temp_firmware_file" "$firmware_file_path" + else + echo "No changes detected in firmware list." + fi fi - git push origin $FINAL_TAG + + # Update Hardware List + hardware_file_path="app/src/main/assets/device_hardware.json" + temp_hardware_file="/tmp/new_device_hardware.json" + + echo "Fetching latest device hardware data..." + curl -s --fail https://api.meshtastic.org/resource/deviceHardware > "$temp_hardware_file" + + if ! jq empty "$temp_hardware_file" 2>/dev/null; then + echo "::error::Hardware API returned invalid JSON data. Aborting." + exit 1 + else + if [ ! -f "$hardware_file_path" ] || ! jq --sort-keys . "$temp_hardware_file" | diff -q - <(jq --sort-keys . "$hardware_file_path"); then + echo "Changes detected in hardware list or local file missing. Updating $hardware_file_path." + cp "$temp_hardware_file" "$hardware_file_path" + else + echo "No changes detected in hardware list." + fi + fi + + - name: Sync with Crowdin + if: ${{ !inputs.dry_run && inputs.channel == 'internal' }} + uses: crowdin/github-action@v2 + with: + base_url: 'https://meshtastic.crowdin.com/api/v2' + config: 'crowdin.yml' + crowdin_branch_name: 'main' + upload_sources: true + upload_sources_args: '--preserve-hierarchy' + upload_translations: false + download_translations: true + download_translations_args: '--preserve-hierarchy' + create_pull_request: false + push_translations: false + push_sources: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + + - name: Generate Changelog Content + if: ${{ !inputs.dry_run && inputs.channel == 'internal' && steps.previous_tag.outputs.PREV_TAG != '' }} + uses: mikepenz/release-changelog-builder-action@v6 + id: build_changelog + with: + configuration: .github/release.yml + fromTag: ${{ steps.previous_tag.outputs.PREV_TAG }} + toTag: HEAD + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Commit Release Assets (Changelog, Translations, Data, Config) + if: ${{ !inputs.dry_run && inputs.channel == 'internal' }} + env: + CHANGELOG: ${{ steps.build_changelog.outputs.changelog }} + FINAL_TAG: ${{ steps.calculate_tags.outputs.final_tag }} + run: | + # Calculate Version Code + OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2) + COMMIT_COUNT=$(git rev-list --count HEAD) + # +1 because we are about to add a commit + VERSION_CODE=$((COMMIT_COUNT + OFFSET + 1)) + + echo "Calculated Version Code: $VERSION_CODE" + + # Update VERSION_NAME_BASE in config.properties + sed -i "s/^VERSION_NAME_BASE=.*/VERSION_NAME_BASE=${{ inputs.base_version }}/" config.properties + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Add changelog if generated + if [ ! -z "$CHANGELOG" ]; then + FILE_PATH="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt" + echo "$CHANGELOG" > "$FILE_PATH" + git add "$FILE_PATH" + fi + + # Add updated data files + git add config.properties + git add app/src/main/assets/firmware_releases.json || true + git add app/src/main/assets/device_hardware.json || true + git add core/proto || true + + # Add updated translations (fastlane metadata and strings) + git add fastlane/metadata/android || true + git add "**/strings.xml" || true + + # Only commit if there are changes + if ! git diff --cached --quiet; then + git commit -m "chore(release): prepare $FINAL_TAG [skip ci] + + - Bump base version to ${{ inputs.base_version }} + - Add changelog for version code $VERSION_CODE + - Sync translations and assets" + git push + else + echo "No changes to commit." + fi + shell: bash + + - name: Get Commit SHA to Tag + id: get_sha + run: | + if [[ "${{ inputs.channel }}" == "internal" ]]; then + # Internal build uses the latest commit (including any asset updates) + SHA=$(git rev-parse HEAD) + else + # Promotions use the SHA of the tag we are promoting + TAG_TO_PROCESS="${{ steps.calculate_tags.outputs.tag_to_process }}" + SHA=$(git rev-parse $TAG_TO_PROCESS) + fi + echo "commit_sha=$SHA" >> $GITHUB_OUTPUT shell: bash call-release-workflow: @@ -127,7 +261,8 @@ jobs: needs: determine-tags uses: ./.github/workflows/release.yml with: - tag_name: ${{ needs.determine-tags.outputs.tag_to_process }} + tag_name: ${{ needs.determine-tags.outputs.final_tag }} + commit_sha: ${{ needs.determine-tags.outputs.commit_sha }} channel: ${{ inputs.channel }} base_version: ${{ inputs.base_version }} secrets: inherit @@ -140,6 +275,7 @@ jobs: tag_name: ${{ needs.determine-tags.outputs.tag_to_process }} release_name: ${{ needs.determine-tags.outputs.release_name }} final_tag: ${{ needs.determine-tags.outputs.final_tag }} + commit_sha: ${{ needs.determine-tags.outputs.commit_sha }} channel: ${{ inputs.channel }} base_version: ${{ inputs.base_version }} from_channel: ${{ needs.determine-tags.outputs.from_channel }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0edf96474..bf239c5de 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -3,12 +3,25 @@ name: Deploy Documentation on: - schedule: - # Runs at 00:00 UTC every Sunday - - cron: '0 0 * * 0' + push: + branches: + - main # Allows you to run this workflow manually from the Actions tab workflow_dispatch: + inputs: + ref: + description: 'The branch, tag or SHA to checkout' + required: false + type: string + + # Allow this workflow to be called from other workflows + workflow_call: + inputs: + ref: + description: 'The branch, tag or SHA to checkout' + required: false + type: string # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: @@ -32,6 +45,7 @@ jobs: with: fetch-depth: 0 submodules: 'recursive' + ref: ${{ inputs.ref || '' }} - name: Set up JDK 17 uses: actions/setup-java@v5 diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 6ac9d09cc..ad660319a 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -19,6 +19,10 @@ on: description: 'The final tag for the release' required: true type: string + commit_sha: + description: 'The commit SHA to tag' + required: false + type: string channel: description: 'The channel to promote to' required: true @@ -46,6 +50,8 @@ on: required: true GRADLE_ENCRYPTION_KEY: required: true + DISCORD_WEBHOOK_ANDROID: + required: false concurrency: group: ${{ github.workflow }}-${{ inputs.tag_name }} @@ -67,7 +73,7 @@ jobs: - name: Checkout code uses: actions/checkout@v6 with: - ref: ${{ inputs.tag_name }} + ref: ${{ inputs.commit_sha || inputs.tag_name }} fetch-depth: 0 submodules: 'recursive' @@ -111,9 +117,16 @@ jobs: - name: Checkout code uses: actions/checkout@v6 with: + ref: ${{ inputs.commit_sha || inputs.tag_name }} fetch-depth: 0 submodules: 'recursive' + - name: Push Git Tag on Success + if: ${{ inputs.commit_sha != '' }} + run: | + git tag ${{ inputs.final_tag }} ${{ inputs.commit_sha }} + git push origin ${{ inputs.final_tag }} + - name: Update GitHub Release with gh CLI env: GH_TOKEN: ${{ github.token }} @@ -122,3 +135,51 @@ jobs: --tag ${{ inputs.final_tag }} \ --title "${{ inputs.release_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})" \ --prerelease=${{ inputs.channel != 'production' }} + + - name: Notify Discord + if: ${{ inputs.channel != 'internal' }} + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_ANDROID }} + VERSION: ${{ inputs.final_tag }} + CHANNEL: ${{ inputs.channel }} + run: | + if [[ -z "$DISCORD_WEBHOOK" ]]; then + echo "No Discord webhook provided. Skipping notification." + exit 0 + fi + + # Determine Track Name for Display + if [ "$CHANNEL" == "closed" ]; then TRACK="Alpha (Closed)"; fi + if [ "$CHANNEL" == "open" ]; then TRACK="Beta (Open)"; fi + if [ "$CHANNEL" == "production" ]; then TRACK="Production"; fi + + # Construct JSON Payload + PAYLOAD=$(cat <> ./secrets.properties echo "$GOOGLE_PLAY_JSON_KEY" > ./fastlane/play-store-credentials.json + - name: Determine previous tag + id: previous_tag + run: | + CURRENT_TAG="${{ inputs.tag_name }}" + # Find the tag reachable from the parent of the current tag + # Exclude tags with hyphens to skip pre-releases (e.g. -internal, -beta) + PREV_TAG=$(git describe --tags --abbrev=0 --exclude "*-*" "$CURRENT_TAG^" 2>/dev/null || echo "") + echo "Found previous tag: $PREV_TAG" + echo "PREV_TAG=$PREV_TAG" >> $GITHUB_OUTPUT + + - name: Generate Changelog for Play Store + if: steps.previous_tag.outputs.PREV_TAG != '' + uses: mikepenz/release-changelog-builder-action@v6 + id: build_changelog + with: + configuration: .github/release.yml + fromTag: ${{ steps.previous_tag.outputs.PREV_TAG }} + toTag: ${{ inputs.tag_name }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Setup Fastlane uses: ruby/setup-ruby@v1 with: @@ -158,7 +191,8 @@ jobs: env: VERSION_NAME: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }} - run: bundle exec fastlane internal + CHANGELOG: ${{ steps.build_changelog.outputs.changelog }} + run: bundle exec fastlane internal changelog:"$CHANGELOG" - name: Upload Google AAB artifact if: always() @@ -186,9 +220,10 @@ jobs: release-fdroid: runs-on: ubuntu-latest - needs: prepare-build-info + needs: [prepare-build-info, run-lint] environment: Release env: + GRADLE_OPTS: "-Dorg.gradle.daemon=false" GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} @@ -196,7 +231,7 @@ jobs: - name: Checkout code uses: actions/checkout@v6 with: - ref: ${{ inputs.tag_name }} + ref: ${{ inputs.commit_sha || inputs.tag_name }} fetch-depth: 0 submodules: 'recursive' - name: Set up JDK 17 @@ -249,6 +284,18 @@ jobs: runs-on: ubuntu-latest needs: [prepare-build-info, release-google, release-fdroid] steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: ${{ inputs.commit_sha || inputs.tag_name }} + fetch-depth: 0 + + - name: Push Git Tag on Success + if: ${{ inputs.commit_sha != '' }} + run: | + git tag ${{ inputs.tag_name }} ${{ inputs.commit_sha }} + git push origin ${{ inputs.tag_name }} + - name: Download all artifacts uses: actions/download-artifact@v7 with: diff --git a/.github/workflows/reusable-android-test.yml b/.github/workflows/reusable-android-test.yml index fedc3c49a..220c972fe 100644 --- a/.github/workflows/reusable-android-test.yml +++ b/.github/workflows/reusable-android-test.yml @@ -62,6 +62,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 45 env: + GRADLE_OPTS: "-Dorg.gradle.daemon=false" GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} @@ -137,7 +138,7 @@ jobs: force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: ./gradlew ${{ steps.test-tasks.outputs.tasks }} koverXmlReport -Pandroid.testInstrumentationRunnerArguments.numShards=${{ inputs.num_shards }} -Pandroid.testInstrumentationRunnerArguments.shardIndex=${{ matrix.shard }} --continue --scan && ( killall -INT crashpad_handler || true ) + script: ./gradlew ${{ steps.test-tasks.outputs.tasks }} koverXmlReport -Pci=true -Pandroid.testInstrumentationRunnerArguments.numShards=${{ inputs.num_shards }} -Pandroid.testInstrumentationRunnerArguments.shardIndex=${{ matrix.shard }} --continue --scan && ( killall -INT crashpad_handler || true ) - name: Upload coverage reports to Codecov if: ${{ !cancelled() }} diff --git a/.github/workflows/reusable-lint.yml b/.github/workflows/reusable-lint.yml index 3cb95631a..ce08932ed 100644 --- a/.github/workflows/reusable-lint.yml +++ b/.github/workflows/reusable-lint.yml @@ -2,6 +2,11 @@ name: Reusable Lint and Format Check on: workflow_call: + inputs: + ref: + description: 'The branch, tag or SHA to checkout' + required: false + type: string secrets: GRADLE_ENCRYPTION_KEY: required: false @@ -17,6 +22,7 @@ jobs: runs-on: ubuntu-latest # Lint is fast, doesn't need large runner timeout-minutes: 10 env: + GRADLE_OPTS: "-Dorg.gradle.daemon=false" GRADLE_CACHE_URL: ${{ secrets.GRADLE_CACHE_URL }} GRADLE_CACHE_USERNAME: ${{ secrets.GRADLE_CACHE_USERNAME }} GRADLE_CACHE_PASSWORD: ${{ secrets.GRADLE_CACHE_PASSWORD }} @@ -25,6 +31,7 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + ref: ${{ inputs.ref || '' }} - name: Set up JDK 17 uses: actions/setup-java@v5 @@ -44,4 +51,4 @@ jobs: add-job-summary: always - name: Run Spotless and Detekt - run: ./gradlew spotlessCheck detekt --scan + run: ./gradlew spotlessCheck detekt -Pci=true --scan diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index e3c54ce51..8a1feb203 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -11,9 +11,11 @@ The entire release process is managed by a single, manually-triggered GitHub Act 1. `version`: The base version number you are releasing (e.g., `2.4.0`). 2. `channel`: The release channel you are targeting (`internal`, `closed`, `open`, or `production`). - **Automation:** The workflow handles everything automatically: - - Calculates the correct Git tag based on the channel (e.g., `v2.4.0-internal.1` or `v2.4.0`). - - Pushes the new tag to the repository. - - Calls a reusable workflow that builds the app, deploys it to the correct Google Play track, and attaches the artifacts (`.aab`/`.apk`) to a GitHub Release. + - **Syncs Assets:** Fetches the latest firmware/hardware lists, protobuf definitions, and translations (Crowdin). + - **Generates Changelog:** Creates a clean changelog from commits since the last production release and commits it to the repo. + - **Updates Config:** Automatically bumps the `VERSION_NAME_BASE` in `config.properties`. + - **Verifies & Tags:** Runs lint checks, builds the app, and *only* tags the release if successful. + - **Deploys:** Uploads the build to the correct Google Play track and attaches artifacts (`.aab`/`.apk`) to a GitHub Release. - **Changelog:** Release notes are auto-generated from PR labels. Ensure PRs are labeled correctly to maintain an accurate changelog. ## Release Steps @@ -27,7 +29,11 @@ The entire release process is managed by a single, manually-triggered GitHub Act 5. Select the `internal` channel. 6. Click **"Run workflow"**. -The workflow will create an incremental internal tag (e.g., `v2.4.0-internal.1`) and publish a **draft** pre-release on GitHub. +The workflow will: +1. **Create a new commit** on the current branch containing updated assets, translations, and the new changelog. +2. **Tag** that commit with an incremental internal tag (e.g., `v2.4.0-internal.1`). +3. **Build & Deploy** the verified artifact to the Play Store Internal track. +4. Publish a **draft** pre-release on GitHub. ### 2. Promote to the Next Channel diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 8f7fe396d..b665d036c 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -17,17 +17,22 @@ default_platform(:android) platform :android do desc "Deploy a new version to the internal track on Google Play" - lane :internal do + lane :internal do |options| aab_path = build_google_release + + changelog = options[:changelog] + skip_meta = changelog.to_s.empty? + upload_to_play_store( track: 'internal', aab: aab_path, release_status: 'completed', skip_upload_apk: true, - skip_upload_metadata: true, - skip_upload_changelogs: true, + skip_upload_metadata: skip_meta, + skip_upload_changelogs: skip_meta, skip_upload_images: true, skip_upload_screenshots: true, + release_notes: skip_meta ? nil : { 'default' => changelog, 'en-US' => changelog } ) end