feat(release): Automate changelog, asset updates, and tagging (#4407)

This commit is contained in:
James Rich
2026-02-02 12:19:08 -06:00
committed by GitHub
parent f60fbf4b3a
commit 70d7319efe
9 changed files with 309 additions and 45 deletions

13
.github/release.yml vendored
View File

@@ -19,16 +19,3 @@ changelog:
- repo
- automation
- release
- title: 👷Dependencies
labels:
- dependencies
- title: 🧱Repo
labels:
- repo
- title: 🤖Automated
labels:
- automation
- release

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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 <<EOF
{
"content": null,
"embeds": [
{
"title": "🚀 New Android Release: $VERSION",
"description": "A new build has been promoted to the **$TRACK** track.",
"color": 5763719,
"fields": [
{
"name": "Track",
"value": "$TRACK",
"inline": true
},
{
"name": "Version",
"value": "$VERSION",
"inline": true
}
],
"url": "https://github.com/meshtastic/Meshtastic-Android/releases/tag/$VERSION"
}
]
}
EOF
)
curl -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK"

View File

@@ -11,6 +11,10 @@ on:
description: 'The tag that triggered the release'
required: true
type: string
commit_sha:
description: 'The commit SHA to build and tag'
required: false
type: string
channel:
description: 'The channel to create a release for or promote to'
required: true
@@ -52,12 +56,19 @@ permissions:
attestations: write
jobs:
run-lint:
uses: ./.github/workflows/reusable-lint.yml
with:
ref: ${{ inputs.commit_sha || inputs.tag_name }}
secrets: inherit
prepare-build-info:
runs-on: ubuntu-latest
outputs:
APP_VERSION_NAME: ${{ steps.get_version_name.outputs.APP_VERSION_NAME }}
APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
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 }}
@@ -65,7 +76,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
@@ -102,9 +113,10 @@ jobs:
release-google:
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 }}
@@ -112,7 +124,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
@@ -148,6 +160,27 @@ jobs:
echo "MAPS_API_KEY=$GOOGLE_MAPS_API_KEY" >> ./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:

View File

@@ -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() }}

View File

@@ -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

View File

@@ -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

View File

@@ -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