ci(release): upload to Play after every leg builds and automate the promotion tail (#7390)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-26 23:50:36 +00:00
1 parent b1e696b841
commit 4561b100fb
19 files changed
+916 -573

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
name: Bot PR
description: Open or update a PR from the working tree's changes and hand it to the merge queue.
# The token must be a PAT: GITHUB_TOKEN may not enable auto-merge on protected main, and
# PRs it opens start no workflows, so the required checks would never report.
inputs:
token:
description: 'PAT that pushes the branch, opens the PR and requests the merge'
required: true
branch:
description: 'PR head branch'
required: true
base:
description: 'PR base branch; must be the branch checked out in the workspace'
default: 'main'
title:
description: 'PR title'
required: true
commit-message:
description: 'Commit message; defaults to the title'
default: ''
body:
description: 'PR body'
default: ''
add-paths:
description: 'Newline-separated pathspecs to commit; all changes when empty'
default: ''
labels:
description: 'Newline-separated labels'
default: ''
outputs:
number:
description: 'PR number'
value: ${{ steps.pr.outputs.pull-request-number }}
url:
description: 'PR URL'
value: ${{ steps.pr.outputs.pull-request-url }}
operation:
description: 'created, updated, closed or none'
value: ${{ steps.pr.outputs.pull-request-operation }}
runs:
using: composite
steps:
- name: Open or update the PR
id: pr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ inputs.token }}
branch: ${{ inputs.branch }}
base: ${{ inputs.base }}
title: ${{ inputs.title }}
commit-message: ${{ inputs.commit-message || inputs.title }}
body: ${{ inputs.body }}
add-paths: ${{ inputs.add-paths }}
labels: ${{ inputs.labels }}
delete-branch: true
# No merge method is passed; the queue sets it. Re-requested on every update, since a
# push can clear the request. GitHub refuses it on a PR that is already mergeable, so
# that case merges directly.
- name: Enable auto-merge
if: ${{ steps.pr.outputs.pull-request-operation == 'created' || steps.pr.outputs.pull-request-operation == 'updated' }}
shell: bash
env:
GH_TOKEN: ${{ inputs.token }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ steps.pr.outputs.pull-request-number }}
run: |
PR_ID=$(gh pr view "$PR_NUMBER" --json id --jq .id)
QUERY=$(cat <<'GQL'
mutation($id: ID!) {
enablePullRequestAutoMerge(input: { pullRequestId: $id }) {
pullRequest { number autoMergeRequest { enabledAt } }
}
}
GQL
)
gh api graphql -f query="$QUERY" -F id="$PR_ID" \
|| gh pr merge "$PR_NUMBER"
+85 -25
View File
@@ -28,8 +28,6 @@ on:
permissions:
contents: write
pull-requests: write
statuses: write
id-token: write
attestations: write
@@ -41,13 +39,14 @@ concurrency:
jobs:
determine-tags:
runs-on: ubuntu-26.04-arm
runs-on: ubuntu-slim
timeout-minutes: 10
outputs:
tag_to_process: ${{ steps.calculate_tags.outputs.tag_to_process }}
release_name: ${{ steps.calculate_tags.outputs.release_name }}
final_tag: ${{ steps.calculate_tags.outputs.final_tag }}
from_channel: ${{ steps.calculate_tags.outputs.from_channel }}
version_name: ${{ steps.version.outputs.version_name }}
version_code: ${{ steps.version.outputs.version_code }}
steps:
# Internal releases are exempt: Play internal testing skips full review,
# so only promotions (closed/open/production) can clobber an in-flight
@@ -66,9 +65,14 @@ jobs:
- name: Calculate tags
id: calculate_tags
env:
BASE_VERSION: ${{ inputs.base_version }}
CHANNEL: ${{ inputs.channel }}
run: |
BASE_VERSION="${{ inputs.base_version }}"
CHANNEL="${{ inputs.channel }}"
if [[ ! "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::base_version '$BASE_VERSION' is not X.Y.Z (e.g., 2.8.3)."
exit 1
fi
if [[ "$CHANNEL" == "internal" ]]; then
# This is a new build, create a new internal tag
@@ -84,7 +88,6 @@ jobs:
echo "Calculated new tag: $NEW_TAG"
{
echo "tag_to_process=$NEW_TAG"
echo "release_name=$NEW_TAG"
echo "final_tag=$NEW_TAG"
} >> "$GITHUB_OUTPUT"
else
@@ -121,17 +124,49 @@ jobs:
NEW_TAG="v${BASE_VERSION}"
fi
echo "New release name will be: $NEW_TAG"
echo "Final tag will be: $NEW_TAG"
{
echo "from_channel=${FROM_CHANNEL}"
echo "tag_to_process=${LATEST_TAG_TO_PROMOTE}"
echo "release_name=${NEW_TAG}"
echo "final_tag=${NEW_TAG}"
} >> "$GITHUB_OUTPUT"
fi
shell: bash
# Name from the tag, code from the commit count plus VERSION_CODE_OFFSET, both at the
# commit being released: for a promotion the promoted tag's, which main has moved past.
- name: Calculate version
id: version
env:
BASE_VERSION: ${{ inputs.base_version }}
CHANNEL: ${{ inputs.channel }}
TAG: ${{ steps.calculate_tags.outputs.tag_to_process }}
run: |
if [[ "$CHANNEL" == "internal" ]]; then REF=HEAD; else REF="$TAG"; fi
git show "${REF}:config.properties" > "$RUNNER_TEMP/config.properties"
# The release highlights and the Play what's-new are looked up by VERSION_NAME_BASE.
CONFIG_BASE=$(sed -n 's/^VERSION_NAME_BASE=//p' "$RUNNER_TEMP/config.properties")
if [[ "$CONFIG_BASE" != "$BASE_VERSION" ]]; then
echo "::error::base_version is $BASE_VERSION but config.properties at $REF has VERSION_NAME_BASE=${CONFIG_BASE:-<unset>}."
exit 1
fi
VERSION_NAME=$(echo "$TAG" | sed 's/-.*//' | sed 's/v//')
VERSION_CODE_OFFSET=$(grep '^VERSION_CODE_OFFSET=' "$RUNNER_TEMP/config.properties" | cut -d'=' -f2 || true)
if ! [[ "$VERSION_CODE_OFFSET" =~ ^[0-9]+$ ]]; then
echo "::error::VERSION_CODE_OFFSET from config.properties is not numeric: '$VERSION_CODE_OFFSET'"
exit 1
fi
VERSION_CODE=$(( $(git rev-list --count "$REF") + VERSION_CODE_OFFSET ))
echo "Version: $VERSION_NAME ($VERSION_CODE) from $REF"
{
echo "version_name=$VERSION_NAME"
echo "version_code=$VERSION_CODE"
} >> "$GITHUB_OUTPUT"
shell: bash
- name: Create and Push Release Tag
if: ${{ !inputs.dry_run && inputs.channel == 'internal' }}
env:
@@ -148,10 +183,8 @@ jobs:
uses: ./.github/workflows/release.yml
with:
tag_name: ${{ needs.determine-tags.outputs.final_tag }}
channel: ${{ inputs.channel }}
base_version: ${{ inputs.base_version }}
build_desktop: true
build_flatpak_src: true
version_name: ${{ needs.determine-tags.outputs.version_name }}
version_code: ${{ needs.determine-tags.outputs.version_code }}
secrets: inherit
call-promote-workflow:
@@ -164,38 +197,65 @@ jobs:
# so call-release-workflow doesn't carry it.
permissions:
contents: write
pull-requests: write
statuses: write
id-token: write
attestations: write
actions: write
uses: ./.github/workflows/promote.yml
with:
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 }}
channel: ${{ inputs.channel }}
base_version: ${{ inputs.base_version }}
from_channel: ${{ needs.determine-tags.outputs.from_channel }}
version_code: ${{ needs.determine-tags.outputs.version_code }}
secrets: inherit
# A production promotion stamps CHANGELOG.md itself; every other cut refreshes [Unreleased].
update-changelog:
needs: [call-release-workflow, call-promote-workflow]
if: >-
${{ !cancelled() && !inputs.dry_run && inputs.channel != 'production'
&& (needs.call-release-workflow.result == 'success' || needs.call-promote-workflow.result == 'success') }}
runs-on: ubuntu-slim
timeout-minutes: 5
permissions:
actions: write
steps:
- name: Dispatch Update Changelog
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: gh workflow run update-changelog.yml --ref main
cleanup-on-failure:
needs: [determine-tags, call-release-workflow]
if: ${{ (failure() || cancelled()) && !inputs.dry_run && inputs.channel == 'internal' }}
runs-on: ubuntu-26.04-arm
runs-on: ubuntu-slim
timeout-minutes: 10
permissions:
contents: write
actions: read
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Delete Failed or Cancelled Tag
env:
FINAL_TAG: ${{ needs.determine-tags.outputs.final_tag }}
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
run: |
if [ -n "$FINAL_TAG" ]; then
echo "Release workflow failed or was cancelled. Deleting tag $FINAL_TAG to allow a clean retry..."
git push origin :refs/tags/"$FINAL_TAG" || echo "Tag was not pushed or already deleted."
else
if [ -z "$FINAL_TAG" ]; then
echo "No tag was created to delete."
exit 0
fi
# Play keeps an uploaded versionCode, so once publish-play succeeded the tag stays with it.
PLAY=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
--jq '[.jobs[] | select(.name | endswith("publish-play")) | .conclusion][0] // ""') || {
echo "::warning::Could not read this run's jobs, so $FINAL_TAG stays. Delete it by hand if Play has no build from it."
exit 0
}
if [ "$PLAY" = "success" ]; then
echo "::warning::Play already has the build from $FINAL_TAG, so the tag stays. Re-run the failed jobs to finish the release."
exit 0
fi
echo "Release workflow failed or was cancelled. Deleting tag $FINAL_TAG to allow a clean retry..."
git push origin :refs/tags/"$FINAL_TAG" || echo "Tag was not pushed or already deleted."
+3 -2
View File
@@ -46,11 +46,12 @@ jobs:
- name: Set up Ruby
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
with:
ruby-version: '4.0.7'
bundler-cache: true
- name: Decode Play Store credentials
run: echo '${{ secrets.GOOGLE_PLAY_JSON_KEY }}' > fastlane/play-store-credentials.json
env:
GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
run: printf '%s\n' "$GOOGLE_PLAY_JSON_KEY" > fastlane/play-store-credentials.json
- name: Upload listing
env:
+171
View File
@@ -0,0 +1,171 @@
name: Play Rollout
# Moves the staged rollout already on a Play track: the one inProgress release a promotion
# leaves there (production at 10%, beta at 50%). rollout widens it to `fraction`, complete
# ships it to every user, halt stops it where it is. supply acts only on inProgress releases,
# so a halted release is resumed or completed in the Play Console.
on:
workflow_dispatch:
inputs:
track:
description: 'Play track holding the staged release'
required: true
type: choice
options:
- production
- beta
action:
description: 'rollout widens it to fraction, complete ships it to everyone, halt stops it'
required: true
type: choice
options:
- rollout
- complete
- halt
fraction:
description: 'rollout only: the new user fraction, above the current one and below 1 (e.g., 0.5)'
required: false
type: string
no_review_in_flight:
description: 'I checked Publishing overview > Submission activity and no submission is In review. A committed rollout change CANCELS and RESTARTS any review in flight.'
required: false
type: boolean
default: false
permissions:
contents: read
# Not the promotion's group: a group keeps one pending run, so this would cancel a pending promotion.
concurrency:
group: play-rollout
cancel-in-progress: false
jobs:
rollout:
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-26.04-arm
timeout-minutes: 15
env:
TRACK: ${{ inputs.track }}
ACTION: ${{ inputs.action }}
steps:
- name: Require review-in-flight confirmation
if: ${{ !inputs.no_review_in_flight }}
run: |
echo "::error::Rollout change blocked: confirm no Play review is in flight. Check Play Console > Publishing overview > Submission activity; if a submission shows 'In review', wait. If clear, re-dispatch with 'no_review_in_flight' checked."
exit 1
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Ruby
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
with:
bundler-cache: true
- name: Decode Play Store credentials
env:
GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
run: printf '%s\n' "$GOOGLE_PLAY_JSON_KEY" > fastlane/play-store-credentials.json
# Exactly one inProgress release, or nothing is changed: that release's version code
# pins supply's selection, and its fraction is what a halt keeps.
- name: Find the staged release
id: staged
env:
FRACTION: ${{ inputs.fraction }}
run: |
bundle exec fastlane play_track_releases track:"$TRACK" out:"$RUNNER_TEMP/before.json"
STAGED=$(jq -c '[.[] | select(.status == "inProgress")]' "$RUNNER_TEMP/before.json")
if [[ $(jq length <<< "$STAGED") -ne 1 ]]; then
echo "::error::Track '$TRACK' needs exactly one inProgress release; it holds: $(jq -c . "$RUNNER_TEMP/before.json")"
exit 1
fi
VERSION_CODE=$(jq -r '.[0].version_codes | max' <<< "$STAGED")
CURRENT=$(jq -r '.[0].user_fraction // empty' <<< "$STAGED")
if [[ ! "$VERSION_CODE" =~ ^[0-9]+$ || ! "$CURRENT" =~ ^0?\.[0-9]+$ ]]; then
echo "::error::The inProgress release on '$TRACK' has no usable version code or fraction: $STAGED"
exit 1
fi
STATUS=""
case "$ACTION" in
rollout)
if [[ ! "$FRACTION" =~ ^0?\.[0-9]+$ ]] || ! awk -v c="$CURRENT" -v n="$FRACTION" 'BEGIN { exit !(n > c && n < 1) }'; then
echo "::error::fraction '$FRACTION' must be above the current $CURRENT and below 1; use complete to ship to everyone."
exit 1
fi
ROLLOUT="$FRACTION"
;;
complete) ROLLOUT=1 ;;
halt)
ROLLOUT="$CURRENT"
STATUS=halted
;;
esac
echo "versionCode $VERSION_CODE on '$TRACK' at $CURRENT: $ACTION to $ROLLOUT${STATUS:+ ($STATUS)}"
{
echo "version_code=$VERSION_CODE"
echo "current=$CURRENT"
echo "rollout=$ROLLOUT"
echo "status=$STATUS"
} >> "$GITHUB_OUTPUT"
# Every upload is skipped, so the edit changes only this release's status and fraction.
- name: Change the rollout
env:
VERSION_CODE: ${{ steps.staged.outputs.version_code }}
ROLLOUT: ${{ steps.staged.outputs.rollout }}
STATUS: ${{ steps.staged.outputs.status }}
run: |
bundle exec fastlane supply \
--track "$TRACK" \
--version_code "$VERSION_CODE" \
--rollout "$ROLLOUT" \
${STATUS:+--release_status "$STATUS"} \
--skip_upload_apk \
--skip_upload_aab \
--skip_upload_metadata \
--skip_upload_changelogs \
--skip_upload_images \
--skip_upload_screenshots
# supply exits 0 even when its edit changed nothing. The script's verify counts only
# completed and inProgress releases, so a halt is confirmed from a fresh read instead.
- name: Verify the release on the track
env:
VERSION_CODE: ${{ steps.staged.outputs.version_code }}
CURRENT: ${{ steps.staged.outputs.current }}
ROLLOUT: ${{ steps.staged.outputs.rollout }}
run: |
PKG=$(grep '^APPLICATION_ID=' config.properties | cut -d'=' -f2)
if [[ "$ACTION" != "halt" ]]; then
bash scripts/play-track-preflight.sh \
fastlane/play-store-credentials.json "$PKG" "$TRACK" "$VERSION_CODE" verify
fi
case "$ACTION" in
rollout) WANT=inProgress ;;
complete) WANT=completed ;;
halt) WANT=halted ;;
esac
bundle exec fastlane play_track_releases track:"$TRACK" out:"$RUNNER_TEMP/after.json"
AFTER=$(jq -c --argjson vc "$VERSION_CODE" '[.[] | select(.version_codes | index($vc))] | first // empty' "$RUNNER_TEMP/after.json")
if [[ "$(jq -r '.status' <<< "${AFTER:-null}")" != "$WANT" ]]; then
echo "::error::versionCode $VERSION_CODE on '$TRACK' is not $WANT after the change: ${AFTER:-absent}"
exit 1
fi
if [[ "$ACTION" == "rollout" ]] && ! awk -v a="$(jq -r '.user_fraction' <<< "$AFTER")" -v w="$ROLLOUT" 'BEGIN { exit !(a == w) }'; then
echo "::error::versionCode $VERSION_CODE on '$TRACK' is not at $ROLLOUT after the change: $AFTER"
exit 1
fi
{
echo "## Play rollout: ${TRACK}"
echo
echo "versionCode ${VERSION_CODE}: ${ACTION}, from ${CURRENT} to $(jq -r '.user_fraction // "all users"' <<< "$AFTER") (${WANT})."
echo
echo "A change Play could not send for review on its own waits under Publishing overview in the Play Console."
} >> "$GITHUB_STEP_SUMMARY"
- name: Clean up credentials
if: always()
run: rm -f fastlane/play-store-credentials.json
+225 -183
View File
@@ -11,18 +11,10 @@ on:
description: 'The tag that triggered the release'
required: true
type: string
release_name:
description: 'The desired name for the GitHub release'
required: true
type: string
final_tag:
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
@@ -31,29 +23,26 @@ on:
description: 'The channel to promote from'
required: true
type: string
version_code:
description: 'The version code of the build being promoted'
required: true
type: string
secrets:
GSERVICES:
required: true
KEYSTORE:
required: true
KEYSTORE_FILENAME:
required: true
KEYSTORE_PROPERTIES:
required: true
DATADOG_APPLICATION_ID:
required: true
DATADOG_CLIENT_TOKEN:
required: true
GOOGLE_MAPS_API_KEY:
required: true
GOOGLE_PLAY_JSON_KEY:
required: true
GRADLE_ENCRYPTION_KEY:
required: true
DISCORD_WEBHOOK_ANDROID:
required: false
HOMEBREW_TAP_TOKEN:
required: false
CROWDIN_GITHUB_TOKEN:
required: false
FLATHUB_TOKEN:
required: false
# Read only for presence, so the checklist says whether each store workflow will skip.
WINGET_TOKEN:
required: false
MSSTORE_PRODUCT_ID:
required: false
# Never cancel a promotion mid-flight: being killed between the Play edit
# commit and the GitHub release/tag update leaves the two disagreeing. The
@@ -65,59 +54,11 @@ concurrency:
permissions:
contents: write
pull-requests: write
statuses: write
id-token: write
attestations: write
jobs:
prepare-build-info:
runs-on: ubuntu-26.04-arm
timeout-minutes: 10
outputs:
APP_VERSION_NAME: ${{ steps.prep_version.outputs.APP_VERSION_NAME }}
APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.commit_sha || inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- name: Prep APP_VERSION_NAME
id: prep_version
env:
INPUT_TAG_NAME: ${{ inputs.tag_name }}
run: |
VERSION_NAME=$(echo "$INPUT_TAG_NAME" | sed 's/-.*//' | sed 's/v//')
echo "APP_VERSION_NAME=$VERSION_NAME" >> "$GITHUB_OUTPUT"
echo "Parsed Version: $VERSION_NAME"
- name: Extract VERSION_CODE_OFFSET from config.properties
id: get_version_code_offset
run: |
OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2)
echo "VERSION_CODE_OFFSET=$OFFSET" >> "$GITHUB_OUTPUT"
- name: Calculate Version Code from Git Commit Count
id: calculate_version_code
env:
VERSION_CODE_OFFSET: ${{ steps.get_version_code_offset.outputs.VERSION_CODE_OFFSET }}
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
if ! [[ "$VERSION_CODE_OFFSET" =~ ^[0-9]+$ ]]; then
echo "::error::VERSION_CODE_OFFSET from config.properties is not numeric: '$VERSION_CODE_OFFSET'"
exit 1
fi
VERSION_CODE=$((COMMIT_COUNT + VERSION_CODE_OFFSET))
echo "versionCode=$VERSION_CODE" >> "$GITHUB_OUTPUT"
shell: bash
promote-release:
runs-on: ubuntu-26.04-arm
timeout-minutes: 30
needs: [ prepare-build-info ]
outputs:
already_on_track: ${{ steps.preflight.outputs.already_on_track }}
screenshots: ${{ steps.screenshots.outputs.found }}
@@ -130,16 +71,17 @@ jobs:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.commit_sha || inputs.tag_name }}
ref: ${{ inputs.tag_name }}
- name: Set up Ruby
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
with:
ruby-version: '4.0.7'
bundler-cache: true
- name: Decode Play Store credentials
run: echo '${{ secrets.GOOGLE_PLAY_JSON_KEY }}' > fastlane/play-store-credentials.json
env:
GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
run: printf '%s\n' "$GOOGLE_PLAY_JSON_KEY" > fastlane/play-store-credentials.json
# A re-dispatched promotion whose versionCode is already live on the
# target track must no-op: every redundant `supply` commit creates a new
@@ -169,7 +111,7 @@ jobs:
- name: Preflight — is this versionCode already on the target track?
id: preflight
env:
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}
VERSION_CODE: ${{ inputs.version_code }}
run: |
PKG=$(grep '^APPLICATION_ID=' config.properties | cut -d'=' -f2)
bash .workflow-ref/scripts/play-track-preflight.sh \
@@ -185,7 +127,7 @@ jobs:
- name: Promote to next channel
if: ${{ steps.preflight.outputs.already_on_track != 'true' }}
env:
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}
VERSION_CODE: ${{ inputs.version_code }}
run: |
bundle exec fastlane supply \
--track "$FROM_TRACK" \
@@ -202,7 +144,7 @@ jobs:
- name: Verify versionCode landed on the target track
if: ${{ steps.preflight.outputs.already_on_track != 'true' }}
env:
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}
VERSION_CODE: ${{ inputs.version_code }}
run: |
PKG=$(grep '^APPLICATION_ID=' config.properties | cut -d'=' -f2)
bash .workflow-ref/scripts/play-track-preflight.sh \
@@ -250,51 +192,50 @@ jobs:
run: rm -f fastlane/play-store-credentials.json
update-github-release:
runs-on: ubuntu-26.04-arm
runs-on: ubuntu-slim
timeout-minutes: 10
needs: [ prepare-build-info, promote-release ]
needs: [ promote-release ]
# actions: write is scoped here — only this job's publish-workflow
# dispatch needs it, and the other jobs must not get it. Job-level
# permissions replace the workflow-level block, so the full set this
# job uses is listed.
# job uses is listed. Its PRs are opened with CROWDIN_GITHUB_TOKEN.
permissions:
contents: write
pull-requests: write
statuses: write
actions: write
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.commit_sha || inputs.tag_name }}
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
# Same reasoning as promote-release: scripts come from the caller's commit,
# not the tag, so a tag cut before a script landed still gets it.
# Same reasoning as promote-release: scripts and the bot-pr action come from the
# caller's commit, not the tag, so a tag cut before either landed still gets it.
- name: Checkout release scripts from caller commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
path: .workflow-ref
sparse-checkout: scripts
- 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 }}
sparse-checkout: |
scripts
.github/actions/bot-pr
# A rerun finds the release already moved to the final tag, so that is looked up first.
- name: Update GitHub Release with gh CLI
id: release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag_name }}
FINAL_TAG: ${{ inputs.final_tag }}
VERSION_CODE: ${{ inputs.version_code }}
PRERELEASE: ${{ inputs.channel != 'production' }}
run: |
gh release edit ${{ inputs.tag_name }} \
--tag ${{ inputs.final_tag }} \
--title "${{ inputs.release_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})" \
CURRENT=$(gh release view "$FINAL_TAG" --json tagName --jq .tagName 2>/dev/null || echo "$TAG")
gh release edit "$CURRENT" \
--tag "$FINAL_TAG" \
--title "$FINAL_TAG ($VERSION_CODE)" \
--draft=false \
--prerelease=${{ inputs.channel != 'production' }}
--prerelease="$PRERELEASE"
# The draft's notes were generated at the internal cut and cover only the
# PRs since the previous published pre-release. A production release is
@@ -386,18 +327,28 @@ jobs:
done
gh workflow run docs-release.yml --ref "$TAG"
# The Obtainium table in README.md follows the channel releases; this refreshes it
# now rather than at the next hourly run.
- name: Dispatch scheduled updates
id: sched
if: ${{ inputs.channel != 'internal' }}
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run scheduled-updates.yml --ref main
# bot-pr resets the checked-out branch to its origin copy before branching the PR,
# so the edits below are made on a local main, never on a branch origin lacks.
- name: Stamp CHANGELOG.md for release
id: stamp
if: ${{ inputs.channel == 'production' }}
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.base_version }}
run: |
DATE=$(date -u +%Y-%m-%d)
# Checkout CHANGELOG.md from main (we're on a tag checkout)
git fetch origin main
git checkout -b "changelog/v${VERSION}" origin/main
git checkout -B main origin/main
# The full-range notes from the step above, minus the boilerplate.
# The file is the record whether or not the release edit went
@@ -444,98 +395,66 @@ jobs:
f.write(new_content)
" "$VERSION" "$DATE" "$FLAT_NOTES"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add CHANGELOG.md
git diff --cached --quiet || {
BRANCH="automation/changelog-v${VERSION}"
git checkout -B "$BRANCH"
git commit -m "docs: release CHANGELOG.md for v${VERSION}"
git push origin "$BRANCH" --force
PR_URL=$(gh pr create \
--title "docs: release CHANGELOG.md for v${VERSION}" \
--body "Automated changelog stamp for production release v${VERSION}." \
--head "$BRANCH" \
--base main \
--label "automation" \
--label "skip-changelog")
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
# Post required commit status so the PR isn't blocked
COMMIT_SHA=$(git rev-parse HEAD)
gh api "repos/${{ github.repository }}/statuses/${COMMIT_SHA}" \
-f state="success" \
-f context="Check Workflow Status" \
-f description="Skipped — changelog-only PR"
}
- name: Open the CHANGELOG.md PR
id: stamp_pr
if: ${{ inputs.channel == 'production' }}
uses: ./.workflow-ref/.github/actions/bot-pr
with:
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
branch: automation/changelog-v${{ inputs.base_version }}
title: 'docs: release CHANGELOG.md for v${{ inputs.base_version }}'
body: 'Automated changelog stamp for production release v${{ inputs.base_version }}.'
add-paths: CHANGELOG.md
labels: |
automation
skip-changelog
# F-Droid and IzzyOnDroid read fastlane/ straight from git, so the committed set
# follows what production shipped: the fdroid-flavor captures come back from the
# release into a bot PR that merges itself, with the desktop set beside them. The
# Flathub metainfo reads the release assets directly and needs nothing here.
# Nothing to open when the files already match.
- name: Refresh the committed store screenshots
id: shots_pr
# Nothing is opened when the files already match.
- name: Stage the store screenshots from the release
id: shots
if: ${{ inputs.channel == 'production' }}
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.final_tag }}
VERSION: ${{ inputs.base_version }}
REPO: ${{ github.repository }}
run: |
ASSET=$(gh release view "$TAG" --json assets --jq '.assets[].name | select(startswith("store-listing-screenshots-fdroid-"))' | head -1)
if [[ -z "$ASSET" ]]; then
echo "::warning::No rendered screenshots attached to $TAG; the committed set stays."
echo "refreshed=none" >> "$GITHUB_OUTPUT"
echo "found=false" >> "$GITHUB_OUTPUT"
exit 0
fi
gh release download "$TAG" --pattern "$ASSET" --pattern 'meshtastic-desktop-*.png' --dir "$RUNNER_TEMP/shots"
git fetch origin main
BRANCH="automation/store-screenshots-v${VERSION}"
git checkout -B "$BRANCH" origin/main
git checkout -B main origin/main
unzip -qo "$RUNNER_TEMP/shots/$ASSET" -d fastlane/metadata/android/en-US/images
cp "$RUNNER_TEMP"/shots/meshtastic-desktop-*.png desktopApp/packaging/linux/screenshots/
git add fastlane/metadata/android/en-US/images desktopApp/packaging/linux/screenshots
if git diff --cached --quiet; then
echo "Committed screenshots already match v${VERSION}."
echo "refreshed=match" >> "$GITHUB_OUTPUT"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git commit -m "chore(store): refresh the committed screenshots from v${VERSION}"
git push origin "$BRANCH" --force
PR_URL=$(gh pr create \
--title "chore(store): refresh the committed screenshots from v${VERSION}" \
--body "The screenshots rendered for the v${VERSION} release, so the fastlane tree F-Droid reads matches what shipped." \
--head "$BRANCH" \
--base main \
--label "automation" \
--label "skip-changelog")
echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT"
# GITHUB_TOKEN pushes start no workflows: post the status the queue
# requires, as the changelog stamp does, then let the queue take it.
gh api "repos/${REPO}/statuses/$(git rev-parse HEAD)" \
-f state="success" \
-f context="Check Workflow Status" \
-f description="Skipped — screenshot-only PR"
PR_ID=$(gh pr view "$PR_URL" --json id --jq .id)
QUERY=$(cat <<'GQL'
mutation($id: ID!) {
enablePullRequestAutoMerge(input: { pullRequestId: $id }) {
pullRequest { number autoMergeRequest { enabledAt } }
}
}
GQL
)
gh api graphql -f query="$QUERY" -F id="$PR_ID" || gh pr merge "$PR_URL"
echo "found=true" >> "$GITHUB_OUTPUT"
- name: Refresh the committed store screenshots
id: shots_pr
if: ${{ steps.shots.outputs.found == 'true' }}
continue-on-error: true
uses: ./.workflow-ref/.github/actions/bot-pr
with:
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
branch: automation/store-screenshots-v${{ inputs.base_version }}
title: 'chore(store): refresh the committed screenshots from v${{ inputs.base_version }}'
body: 'The screenshots rendered for the v${{ inputs.base_version }} release, so the fastlane tree F-Droid reads matches what shipped.'
add-paths: |
fastlane/metadata/android/en-US/images
desktopApp/packaging/linux/screenshots
labels: |
automation
skip-changelog
# One place to read what this promotion did, what it dispatched, and what
# is still done by hand. The store dispatches report the dispatch only;
# each store workflow skips inside itself until its secrets exist. The
# hand-done list stays out of the Discord post, which is an announcement.
# is still done by hand. The hand-done list stays out of the Discord post,
# which is an announcement.
- name: Release checklist
if: ${{ always() }}
env:
@@ -545,19 +464,37 @@ jobs:
ALREADY_ON_TRACK: ${{ needs.promote-release.outputs.already_on_track }}
SCREENSHOTS: ${{ needs.promote-release.outputs.screenshots }}
LISTING: ${{ needs.promote-release.outputs.listing }}
SHOTS_PR: ${{ steps.shots_pr.outputs.pr_url }}
SHOTS: ${{ steps.shots.outcome }}
SHOTS_FOUND: ${{ steps.shots.outputs.found }}
SHOTS_PR: ${{ steps.shots_pr.outputs.url }}
SHOTS_PR_OUTCOME: ${{ steps.shots_pr.outcome }}
SHOTS_REFRESHED: ${{ steps.shots_pr.outputs.refreshed }}
RELEASE: ${{ steps.release.outcome }}
NOTES: ${{ steps.notes.outcome }}
DOCS: ${{ steps.docs.outcome }}
SCHED: ${{ steps.sched.outcome }}
WINGET: ${{ steps.winget.outcome }}
MSSTORE: ${{ steps.msstore.outcome }}
BUMP: ${{ steps.bump.outcome }}
STAMP: ${{ steps.stamp.outcome }}
STAMP_PR: ${{ steps.stamp.outputs.pr_url }}
STAMP_PR: ${{ steps.stamp_pr.outputs.url }}
STAMP_PR_OUTCOME: ${{ steps.stamp_pr.outcome }}
HAS_FLATHUB_TOKEN: ${{ secrets.FLATHUB_TOKEN != '' }}
HAS_WINGET_TOKEN: ${{ secrets.WINGET_TOKEN != '' }}
HAS_MSSTORE_PRODUCT_ID: ${{ secrets.MSSTORE_PRODUCT_ID != '' }}
HAS_HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN != '' }}
run: |
row() { printf '| %s | %s |\n' "$1" "$2"; }
# A store workflow skips inside itself when its secret is unset, so a successful
# dispatch alone does not mean anything was submitted.
store_row() {
if [[ "$2" != "success" ]]; then
row "$1" "$2"
elif [[ "$3" != "true" ]]; then
row "$1" "will skip: $4 unset"
else
row "$1" "dispatched"
fi
}
{
echo "## Release checklist: ${TAG} (${CHANNEL})"
echo
@@ -580,30 +517,56 @@ jobs:
row "Play listing validated (dry run)" "$LISTING"
fi
row "Docs Release dispatched" "$DOCS"
row "Scheduled Updates dispatched (Obtainium table)" "$SCHED"
if [[ "$CHANNEL" == "production" ]]; then
if [[ -n "$SHOTS_PR" ]]; then
row "Committed screenshots refresh PR" "$SHOTS_PR"
elif [[ "$SHOTS" != "success" ]]; then
row "Committed screenshots refresh PR" "$SHOTS"
elif [[ "$SHOTS_FOUND" != "true" ]]; then
row "Committed screenshots refresh PR" "none needed (no rendered set attached)"
elif [[ "$SHOTS_PR_OUTCOME" == "success" ]]; then
row "Committed screenshots refresh PR" "none needed (${SHOTS_REFRESHED:-no change})"
row "Committed screenshots refresh PR" "none needed (committed set matches)"
else
row "Committed screenshots refresh PR" "$SHOTS_PR_OUTCOME"
fi
row "Release notes rewritten (full range)" "$NOTES"
row "CHANGELOG.md stamp" "$STAMP ${STAMP_PR:+- $STAMP_PR}"
row "winget dispatched" "$WINGET"
row "Microsoft Store dispatched" "$MSSTORE"
if [[ -n "$STAMP_PR" ]]; then
row "CHANGELOG.md stamp PR" "$STAMP_PR"
elif [[ "$STAMP" == "success" && "$STAMP_PR_OUTCOME" == "success" ]]; then
row "CHANGELOG.md stamp PR" "none needed (already stamped)"
else
row "CHANGELOG.md stamp PR" "stamp $STAMP, PR ${STAMP_PR_OUTCOME:-skipped}"
fi
store_row "winget" "$WINGET" "$HAS_WINGET_TOKEN" WINGET_TOKEN
store_row "Microsoft Store" "$MSSTORE" "$HAS_MSSTORE_PRODUCT_ID" MSSTORE_PRODUCT_ID
row "Version bump dispatched" "$BUMP"
row "Post-Release Cleanup" "dispatched by Docs Release once /${TAG}/ is published"
if [[ "$HAS_HOMEBREW_TAP_TOKEN" == "true" ]]; then
row "Homebrew cask PR" "opened by the update-homebrew-cask job after this one"
else
row "Homebrew cask PR" "will skip: HOMEBREW_TAP_TOKEN unset"
fi
if [[ "$HAS_FLATHUB_TOKEN" == "true" ]]; then
row "Flathub update PR" "opened by the update-flathub job after this one"
else
row "Flathub update PR" "will skip: FLATHUB_TOKEN unset"
fi
echo
echo "Still by hand:"
echo "- Play Console: the production rollout is staged; complete it there."
echo "- Flathub: bump flathub/org.meshtastic.MeshtasticDesktop (tag, commit, gradle zip and sha256, flatpak-sources.json)."
echo "- Play: the production rollout is staged; widen, complete or halt it with the Play Rollout workflow."
if [[ "$HAS_FLATHUB_TOKEN" == "true" ]]; then
echo "- Flathub: merge the update-flathub PR once its test build passes."
else
echo "- Flathub: bump flathub/org.meshtastic.MeshtasticDesktop (tag, commit, gradle zip and sha256, flatpak-sources.json)."
fi
echo "- Release notes: replace the placeholder <description> the version bump PR wrote for the next line."
fi
} >> "$GITHUB_STEP_SUMMARY"
# Announces once the release is published, whatever happened to the steps after it.
- name: Notify Discord
if: ${{ inputs.channel != 'internal' }}
if: ${{ always() && steps.release.outcome == 'success' && inputs.channel != 'internal' }}
env:
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_ANDROID }}
VERSION: ${{ inputs.final_tag }}
@@ -654,14 +617,14 @@ jobs:
# promoted to homebrew/cask, replace the tap PR with `brew bump-cask-pr`.
update-homebrew-cask:
if: ${{ inputs.channel == 'production' }}
runs-on: ubuntu-26.04-arm
runs-on: ubuntu-slim
timeout-minutes: 15
needs: [ update-github-release ]
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.commit_sha || inputs.tag_name }}
ref: ${{ inputs.tag_name }}
- name: Render cask and open PR against meshtastic/homebrew-tap
env:
@@ -713,3 +676,82 @@ jobs:
--body "Automated cask bump for the ${TAG} production release of [Meshtastic-Android](https://github.com/${{ github.repository }}/releases/tag/${TAG})." \
|| echo "PR already exists for $BRANCH; branch updated.")
echo "- Homebrew cask: $PR_URL" >> "$GITHUB_STEP_SUMMARY"
# The same for flathub/org.meshtastic.MeshtasticDesktop: the tag and its commit, the Gradle
# zip that tag's wrapper pins, and the release's flatpak-sources.json. The JBR, runtime and
# patches stay as they are; Flathub's test build on the PR checks them against the tag.
update-flathub:
if: ${{ inputs.channel == 'production' }}
runs-on: ubuntu-slim
timeout-minutes: 15
needs: [ update-github-release ]
steps:
# From the caller's commit, like the scripts in the jobs above.
- name: Checkout the manifest bump script from caller commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
sparse-checkout: scripts/verify-flatpak
- name: Update the manifest and open PR against flathub/org.meshtastic.MeshtasticDesktop
env:
FLATHUB_TOKEN: ${{ secrets.FLATHUB_TOKEN }}
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.final_tag }}
REPO: ${{ github.repository }}
run: |
if [[ -z "$FLATHUB_TOKEN" ]]; then
echo "::notice::FLATHUB_TOKEN not set; skipping the Flathub update."
echo "- Flathub: skipped, FLATHUB_TOKEN not set" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
VERSION=${TAG#v}
# The tag appears a moment after the undraft.
TYPE="" COMMIT=""
for _ in 1 2 3 4 5 6; do
if OBJ=$(gh api "repos/${REPO}/git/ref/tags/${TAG}" --jq '.object.type + " " + .object.sha' 2>/dev/null); then
read -r TYPE COMMIT <<< "$OBJ"
break
fi
sleep 10
done
if [[ -z "$COMMIT" ]]; then
echo "::error::Tag $TAG not found."
exit 1
fi
if [[ "$TYPE" == "tag" ]]; then
COMMIT=$(gh api "repos/${REPO}/git/tags/${COMMIT}" --jq .object.sha)
fi
gh api "repos/${REPO}/contents/gradle/wrapper/gradle-wrapper.properties?ref=${COMMIT}" \
-H 'Accept: application/vnd.github.raw' > "$RUNNER_TEMP/gradle-wrapper.properties"
gh release download "$TAG" --repo "$REPO" --pattern flatpak-sources.json --dir "$RUNNER_TEMP"
jq empty "$RUNNER_TEMP/flatpak-sources.json"
FLATHUB="$RUNNER_TEMP/flathub"
git clone "https://x-access-token:${FLATHUB_TOKEN}@github.com/flathub/org.meshtastic.MeshtasticDesktop.git" "$FLATHUB"
BASE=$(git -C "$FLATHUB" rev-parse --abbrev-ref HEAD)
BRANCH="update-${VERSION}"
git -C "$FLATHUB" checkout -b "$BRANCH"
python3 scripts/verify-flatpak/bump-flathub-manifest.py \
"$FLATHUB/org.meshtastic.MeshtasticDesktop.yaml" "$TAG" "$COMMIT" "$RUNNER_TEMP/gradle-wrapper.properties"
cp "$RUNNER_TEMP/flatpak-sources.json" "$FLATHUB/flatpak-sources.json"
cd "$FLATHUB"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add org.meshtastic.MeshtasticDesktop.yaml flatpak-sources.json
if git diff --cached --quiet; then
echo "Flathub manifest already at ${TAG}."
echo "- Flathub: already at ${TAG}" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
git commit -m "Update to ${TAG}"
git push -fu origin "$BRANCH"
PR_URL=$(GH_TOKEN="$FLATHUB_TOKEN" gh pr create --repo flathub/org.meshtastic.MeshtasticDesktop \
--head "$BRANCH" --base "$BASE" \
--title "Update to ${TAG}" \
--body "$(printf 'Update Meshtastic Desktop to version %s\nhttps://github.com/%s/releases/tag/%s\n' "$TAG" "$REPO" "$TAG")" \
|| echo "PR already exists for $BRANCH; branch updated.")
echo "- Flathub: $PR_URL" >> "$GITHUB_STEP_SUMMARY"
+125 -175
View File
@@ -3,32 +3,18 @@ name: Make Release
on:
workflow_call:
inputs:
base_version:
description: 'The base version for the release (e.g., 2.3.0)'
required: true
type: string
tag_name:
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'
version_name:
description: 'The version name the build carries (e.g., 2.3.0)'
required: true
type: string
version_code:
description: 'The version code the build carries'
required: true
type: string
build_desktop:
description: 'Whether to build the desktop distribution'
required: false
type: boolean
default: false
build_flatpak_src:
description: 'Whether to build the Flatpak sources'
required: false
type: boolean
default: false
secrets:
GSERVICES:
required: true
@@ -79,64 +65,19 @@ concurrency:
permissions:
contents: write
pull-requests: read
id-token: write
attestations: write
jobs:
prepare-build-info:
runs-on: ubuntu-26.04-arm
timeout-minutes: 10
outputs:
APP_VERSION_NAME: ${{ steps.prep_version.outputs.APP_VERSION_NAME }}
APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- name: Prep APP_VERSION_NAME
id: prep_version
env:
INPUT_TAG_NAME: ${{ inputs.tag_name }}
run: |
VERSION_NAME=$(echo "$INPUT_TAG_NAME" | sed 's/-.*//' | sed 's/v//')
echo "APP_VERSION_NAME=$VERSION_NAME" >> "$GITHUB_OUTPUT"
echo "Parsed Version: $VERSION_NAME"
- name: Extract VERSION_CODE_OFFSET from config.properties
id: get_version_code_offset
run: |
OFFSET=$(grep '^VERSION_CODE_OFFSET=' config.properties | cut -d'=' -f2)
echo "VERSION_CODE_OFFSET=$OFFSET" >> "$GITHUB_OUTPUT"
- name: Calculate Version Code from Git Commit Count
id: calculate_version_code
env:
VERSION_CODE_OFFSET: ${{ steps.get_version_code_offset.outputs.VERSION_CODE_OFFSET }}
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
if ! [[ "$VERSION_CODE_OFFSET" =~ ^[0-9]+$ ]]; then
echo "::error::VERSION_CODE_OFFSET from config.properties is not numeric: '$VERSION_CODE_OFFSET'"
exit 1
fi
VERSION_CODE=$((COMMIT_COUNT + VERSION_CODE_OFFSET))
echo "versionCode=$VERSION_CODE" >> "$GITHUB_OUTPUT"
shell: bash
release-google:
runs-on: ubuntu-26.04
timeout-minutes: 90
needs: [prepare-build-info]
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
@@ -154,7 +95,6 @@ jobs:
DATADOG_APPLICATION_ID: ${{ secrets.DATADOG_APPLICATION_ID }}
DATADOG_CLIENT_TOKEN: ${{ secrets.DATADOG_CLIENT_TOKEN }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
run: |
rm -f ./androidApp/google-services.json
echo "$GSERVICES" > ./androidApp/google-services.json
@@ -165,19 +105,18 @@ jobs:
echo "datadogClientToken=$DATADOG_CLIENT_TOKEN"
echo "MAPS_API_KEY=$GOOGLE_MAPS_API_KEY"
} >> ./secrets.properties
echo "$GOOGLE_PLAY_JSON_KEY" > ./fastlane/play-store-credentials.json
- name: Setup Fastlane
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
with:
ruby-version: '4.0.7'
bundler-cache: true
- name: Build and Deploy Google Play to Internal Track with Fastlane
# Build only: publish-play uploads the bundle once every leg has built.
- name: Build the Google release
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
VERSION_NAME: ${{ inputs.version_name }}
VERSION_CODE: ${{ inputs.version_code }}
run: >
./gradlew :androidApp:bundleGoogleRelease :androidApp:assembleGoogleRelease
-Pandroid.injected.version.name="$VERSION_NAME"
-Pandroid.injected.version.code="$VERSION_CODE"
-PaboutLibraries.release=true
-Pmeshtastic.disableAbiSplits=true
- name: List outputs
run: ls -R androidApp/build/outputs/
@@ -212,15 +151,13 @@ jobs:
release-fdroid:
runs-on: ubuntu-26.04
timeout-minutes: 90
needs: [prepare-build-info]
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
@@ -238,17 +175,15 @@ jobs:
echo "$KEYSTORE" | base64 -di > "./androidApp/$KEYSTORE_FILENAME"
echo "$KEYSTORE_PROPERTIES" > ./keystore.properties
- name: Setup Fastlane
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
with:
ruby-version: '4.0.7'
bundler-cache: true
- name: Build F-Droid with Fastlane
# No aboutLibraries.release: offlineMode keeps the output matching F-Droid's reproducible rebuild.
- name: Build the F-Droid release
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 fdroid_build
VERSION_NAME: ${{ inputs.version_name }}
VERSION_CODE: ${{ inputs.version_code }}
run: >
./gradlew :androidApp:assembleFdroidRelease
-Pandroid.injected.version.name="$VERSION_NAME"
-Pandroid.injected.version.code="$VERSION_CODE"
- name: List outputs
run: ls -R androidApp/build/outputs/
@@ -268,10 +203,8 @@ jobs:
subject-path: androidApp/build/outputs/apk/fdroid/release/*.apk
release-desktop:
if: ${{ inputs.build_desktop }}
runs-on: ${{ matrix.os }}
timeout-minutes: 90
needs: [prepare-build-info]
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -285,7 +218,6 @@ jobs:
with:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
@@ -301,8 +233,8 @@ jobs:
- name: Package Native Distributions
env:
ORG_GRADLE_PROJECT_appVersionName: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}
ORG_GRADLE_PROJECT_appVersionName: ${{ inputs.version_name }}
VERSION_CODE: ${{ inputs.version_code }}
APPIMAGE_EXTRACT_AND_RUN: 1
SIGN_MACOS: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY != '' && 'true' || 'false' }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
@@ -366,7 +298,7 @@ jobs:
- name: Build AppImage from jpackage app-image
if: runner.os == 'Linux'
env:
APP_VERSION_NAME: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}
APP_VERSION_NAME: ${{ inputs.version_name }}
run: scripts/build-appimage.sh
- name: List Desktop Binaries
@@ -403,10 +335,8 @@ jobs:
desktopApp/build/compose/jars/*-release.jar
create-flatpak-src:
if: ${{ inputs.build_flatpak_src }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
needs: [prepare-build-info]
strategy:
fail-fast: false
matrix:
@@ -419,7 +349,6 @@ jobs:
with:
ref: ${{ inputs.tag_name }}
fetch-depth: 0
submodules: 'recursive'
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
@@ -457,9 +386,8 @@ jobs:
retention-days: 1
release-flatpak-src:
if: ${{ inputs.build_flatpak_src }}
runs-on: ubuntu-26.04
timeout-minutes: 30
runs-on: ubuntu-slim
timeout-minutes: 10
needs: [create-flatpak-src]
steps:
- name: Download Flatpak source artifacts
@@ -501,96 +429,55 @@ jobs:
# (the caller deletes the tag on a failed run): the capture runs soft, github-release
# does not gate on it, and packaging falls back to the committed sets.
store-screenshots:
needs: [prepare-build-info]
uses: ./.github/workflows/store-screenshots.yml
with:
ref: ${{ inputs.tag_name }}
soft: true
secrets: inherit
# One zip per flavor laid out as fastlane's images/ folder (google feeds the Play
# listing, fdroid the committed tree F-Droid reads) and the five desktop PNGs loose,
# so metainfo.xml can point at them by name. A flavor or the desktop set with a shot
# missing is replaced whole by the committed one, never mixed.
store-screenshots-assets:
if: ${{ !cancelled() }}
# Play receives the bundle only after every leg has built: a failed leg makes the caller
# delete the tag, and Play keeps any versionCode it has been sent.
publish-play:
runs-on: ubuntu-26.04-arm
timeout-minutes: 10
continue-on-error: true
needs: [prepare-build-info, store-screenshots]
timeout-minutes: 15
needs: [release-google, release-fdroid, release-desktop, release-flatpak-src]
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag_name }}
- name: Download the captures
continue-on-error: true
- name: Download the Google AAB
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: store-screenshots-*
path: shots
name: google-aab
path: ${{ runner.temp }}/google-aab
- name: Package the screenshots
env:
VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }}
run: |
mkdir -p out
echo "### Store screenshots" >> "$GITHUB_STEP_SUMMARY"
# Three form factors times five shots.
for flavor in google fdroid; do
src="shots/store-screenshots-$flavor"
count=$(find "$src" -name '*.png' 2>/dev/null | wc -l)
if [ "$count" -ne 15 ]; then
echo "::warning::$flavor captured $count of 15 shots; the committed set is attached instead."
echo "- $flavor: $count of 15 captured, committed set attached" >> "$GITHUB_STEP_SUMMARY"
src=fastlane/metadata/android/en-US/images
else
echo "- $flavor: captured from the tag" >> "$GITHUB_STEP_SUMMARY"
fi
(cd "$src" && zip -qr "$GITHUB_WORKSPACE/out/store-listing-screenshots-$flavor-${VERSION_CODE}.zip" .)
done
src=shots/store-screenshots-desktop
count=$(find "$src" -name 'meshtastic-desktop-*.png' 2>/dev/null | wc -l)
if [ "$count" -ne 5 ]; then
echo "::warning::desktop captured $count of 5 shots; the committed set is attached instead."
echo "- desktop: $count of 5 captured, committed set attached" >> "$GITHUB_STEP_SUMMARY"
src=desktopApp/packaging/linux/screenshots
else
echo "- desktop: captured from the tag" >> "$GITHUB_STEP_SUMMARY"
fi
cp "$src"/meshtastic-desktop-*.png out/
- name: Upload store screenshots artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
- name: Set up Ruby
uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
with:
name: store-screenshots
path: out/*
retention-days: 1
bundler-cache: true
- name: Decode Play Store credentials
env:
GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
run: printf '%s\n' "$GOOGLE_PLAY_JSON_KEY" > fastlane/play-store-credentials.json
- name: Upload to the internal track
env:
AAB: ${{ runner.temp }}/google-aab/androidApp-google-release.aab
run: bundle exec fastlane upload_internal aab:"$AAB"
- name: Clean up credentials
if: always()
run: rm -f fastlane/play-store-credentials.json
# Gates on the build jobs by name: store-screenshots-assets is in needs only so its
# artifact exists before the download below, and its result is not consulted.
github-release:
if: >-
${{ !cancelled()
&& needs.prepare-build-info.result == 'success'
&& needs.release-google.result == 'success'
&& needs.release-fdroid.result == 'success'
&& (needs.release-desktop.result == 'success' || !inputs.build_desktop)
&& (needs.release-flatpak-src.result == 'success' || !inputs.build_flatpak_src) }}
runs-on: ubuntu-26.04-arm
timeout-minutes: 15
needs:
- prepare-build-info
- release-google
- release-fdroid
- release-desktop
- release-flatpak-src
- store-screenshots-assets
needs: [publish-play]
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -602,7 +489,7 @@ jobs:
with:
path: ./artifacts
# The raw captures are packaged by store-screenshots-assets; only its artifact ships.
# Raw captures already uploaded stay out: store-screenshots-assets packages and attaches them.
- name: Exclude intermediate artifacts from release
run: rm -rf ./artifacts/flatpak-multisrc-* ./artifacts/store-screenshots-*
@@ -617,7 +504,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ inputs.tag_name }}
TARGET: ${{ inputs.commit_sha || github.sha }}
TARGET: ${{ github.sha }}
run: |
# Fail the step if the listing itself fails — an empty PREV must only ever
# mean "no published v* release exists yet", never a swallowed API error,
@@ -651,9 +538,72 @@ jobs:
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3
with:
tag_name: ${{ inputs.tag_name }}
target_commitish: ${{ inputs.commit_sha || github.sha }}
name: ${{ inputs.tag_name }} (${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }})
target_commitish: ${{ github.sha }}
name: ${{ inputs.tag_name }} (${{ inputs.version_code }})
body_path: release-notes.md
files: ./artifacts/**/*
draft: true
prerelease: true
# One zip per flavor laid out as fastlane's images/ folder (google feeds the Play
# listing, fdroid the committed tree F-Droid reads) and the five desktop PNGs loose,
# so metainfo.xml can point at them by name. A flavor or the desktop set with a shot
# missing is replaced whole by the committed one, never mixed. They are attached to
# the draft github-release created, so the draft never waits on the emulator.
store-screenshots-assets:
if: ${{ !cancelled() && needs.github-release.result == 'success' }}
runs-on: ubuntu-slim
timeout-minutes: 10
continue-on-error: true
needs: [store-screenshots, github-release]
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.tag_name }}
- name: Download the captures
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: store-screenshots-*
path: shots
- name: Package the screenshots
env:
VERSION_CODE: ${{ inputs.version_code }}
run: |
mkdir -p out
echo "### Store screenshots" >> "$GITHUB_STEP_SUMMARY"
# Three form factors times five shots.
for flavor in google fdroid; do
src="shots/store-screenshots-$flavor"
count=$(find "$src" -name '*.png' 2>/dev/null | wc -l)
if [ "$count" -ne 15 ]; then
echo "::warning::$flavor captured $count of 15 shots; the committed set is attached instead."
echo "- $flavor: $count of 15 captured, committed set attached" >> "$GITHUB_STEP_SUMMARY"
src=fastlane/metadata/android/en-US/images
else
echo "- $flavor: captured from the tag" >> "$GITHUB_STEP_SUMMARY"
fi
(cd "$src" && zip -qr "$GITHUB_WORKSPACE/out/store-listing-screenshots-$flavor-${VERSION_CODE}.zip" .)
done
src=shots/store-screenshots-desktop
count=$(find "$src" -name 'meshtastic-desktop-*.png' 2>/dev/null | wc -l)
if [ "$count" -ne 5 ]; then
echo "::warning::desktop captured $count of 5 shots; the committed set is attached instead."
echo "- desktop: $count of 5 captured, committed set attached" >> "$GITHUB_STEP_SUMMARY"
src=desktopApp/packaging/linux/screenshots
else
echo "- desktop: captured from the tag" >> "$GITHUB_STEP_SUMMARY"
fi
cp "$src"/meshtastic-desktop-*.png out/
- name: Attach the screenshots to the draft release
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ inputs.tag_name }}
run: gh release upload "$TAG" out/* --clobber --repo "$REPO"
+44 -38
View File
@@ -1,10 +1,10 @@
name: Store Screenshots
# Captures the store-listing screenshots from the real debug apps, connected to Demo
# Mode's hidden showcase mesh. Android runs `:store-screenshots` on an emulator: the
# google flavor feeds the Play listing, the fdroid flavor the fastlane tree F-Droid and
# IzzyOnDroid read, each uploaded as `store-screenshots-<flavor>` laid out as
# `images/<type>Screenshots/<n>_<name>.png`. Desktop runs the real app on a virtual
# Mode's hidden showcase mesh. Android runs `:store-screenshots` for both flavors on one
# emulator: the google flavor feeds the Play listing, the fdroid flavor the fastlane
# tree F-Droid and IzzyOnDroid read, each uploaded as `store-screenshots-<flavor>` laid
# out as `images/<type>Screenshots/<n>_<name>.png`. Desktop runs the real app on a virtual
# display (`store-screenshots/capture-desktop.sh`) and uploads the five Flathub PNGs as
# `store-screenshots-desktop`.
#
@@ -45,26 +45,17 @@ concurrency:
jobs:
android:
name: Android (${{ matrix.flavor }})
name: Android
# Hosted x64 runners expose KVM; the emulator needs it.
runs-on: ubuntu-26.04
timeout-minutes: 60
timeout-minutes: 35
continue-on-error: ${{ inputs.soft == true }}
strategy:
fail-fast: false
matrix:
include:
- flavor: google
task: Google
- flavor: fdroid
task: Fdroid
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.sha }}
fetch-depth: 0
submodules: 'recursive'
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
@@ -76,7 +67,6 @@ jobs:
# The google flavor draws Google Maps, which needs the debug Maps key; the fdroid
# flavor draws MapLibre and needs nothing.
- name: Provide the debug Maps key
if: ${{ matrix.flavor == 'google' }}
env:
GOOGLE_MAPS_API_KEY_DEBUG: ${{ secrets.GOOGLE_MAPS_API_KEY_DEBUG }}
run: |
@@ -88,7 +78,9 @@ jobs:
# Built before the emulator boots, so the emulator step only installs and runs.
- name: Build the app and the capture module
run: ./gradlew :androidApp:assemble${{ matrix.task }}Debug :store-screenshots:assemble${{ matrix.task }}Debug
run: >
./gradlew :androidApp:assembleGoogleDebug :store-screenshots:assembleGoogleDebug
:androidApp:assembleFdroidDebug :store-screenshots:assembleFdroidDebug
- name: Enable KVM (for the emulator)
run: |
@@ -97,10 +89,12 @@ jobs:
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
# The runner's `script:` runs each line in its own shell. The capture copies each
# PNG to /data/local/tmp, which outlives the test app's uninstall at the end of the
# connected run. --no-configuration-cache: connected tasks in a com.android.test
# module are not configuration-cache serializable (see scheduled-baseline.yml).
# The runner's `script:` runs each line in its own shell and stops at the first that
# fails, so a failure is recorded in out/failed and both flavors are still pulled.
# Each flavor leaves its PNGs in /data/local/tmp/store-screenshots/<flavor>, which
# outlives the test app's uninstall at the end of the connected run.
# --no-configuration-cache: connected tasks in a com.android.test module are not
# configuration-cache serializable (see scheduled-baseline.yml).
- name: Capture on the emulator
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
with:
@@ -108,32 +102,45 @@ jobs:
target: google_apis
arch: x86_64
profile: pixel_6
cores: 4
disable-animations: true
emulator-options: -no-window -gpu swiftshader -noaudio -no-boot-anim -camera-back none
script: |
./gradlew :store-screenshots:connected${{ matrix.task }}DebugAndroidTest -Dorg.gradle.isolated-projects=false --no-configuration-cache
mkdir -p out/images && adb pull /data/local/tmp/store-screenshots/. out/images/
mkdir -p out/google out/fdroid
./gradlew --continue :store-screenshots:connectedGoogleDebugAndroidTest :store-screenshots:connectedFdroidDebugAndroidTest -Dorg.gradle.isolated-projects=false --no-configuration-cache || touch out/failed
adb pull /data/local/tmp/store-screenshots/google/. out/google/ || touch out/failed
adb pull /data/local/tmp/store-screenshots/fdroid/. out/fdroid/ || touch out/failed
test ! -e out/failed
- name: Summarize the captures
if: ${{ always() }}
env:
FLAVOR: ${{ matrix.flavor }}
run: |
{
echo "### Store screenshots (${FLAVOR})"
if [ -d out/images ]; then
find out/images -name '*.png' | sort | sed 's|^out/images/|- |'
else
echo "- none captured"
fi
} >> "$GITHUB_STEP_SUMMARY"
for flavor in google fdroid; do
{
echo "### Store screenshots (${flavor})"
if [ -n "$(find "out/$flavor" -name '*.png' 2>/dev/null)" ]; then
find "out/$flavor" -name '*.png' | sort | sed "s|^out/$flavor/|- |"
else
echo "- none captured"
fi
} >> "$GITHUB_STEP_SUMMARY"
done
- name: Upload the captures
- name: Upload the google captures
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: store-screenshots-${{ matrix.flavor }}
path: out/images
name: store-screenshots-google
path: out/google
if-no-files-found: warn
retention-days: 7
- name: Upload the fdroid captures
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: store-screenshots-fdroid
path: out/fdroid
if-no-files-found: warn
retention-days: 7
@@ -141,7 +148,7 @@ jobs:
desktop:
name: Desktop
runs-on: ubuntu-26.04
timeout-minutes: 45
timeout-minutes: 20
continue-on-error: ${{ inputs.soft == true }}
steps:
- name: Checkout code
@@ -149,7 +156,6 @@ jobs:
with:
ref: ${{ inputs.ref || github.sha }}
fetch-depth: 0
submodules: 'recursive'
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
+14 -42
View File
@@ -1,14 +1,14 @@
name: Update Changelog
# Manual only: promote.yml stamps the released section at release time on its
# own. Dispatch this to refresh the [Unreleased] section between releases.
# Create or Promote Release dispatches this after every internal, closed and open run;
# a production promotion stamps the released section itself. Dispatch it by hand to
# refresh the [Unreleased] section at any other time.
on:
workflow_dispatch:
# The PR is opened and queued with CROWDIN_GITHUB_TOKEN through .github/actions/bot-pr.
permissions:
contents: write
pull-requests: write
statuses: write
concurrency:
group: changelog-${{ github.ref }}
@@ -266,41 +266,13 @@ jobs:
- name: Create or update changelog PR
if: steps.tags.outputs.prod != '' && steps.update.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
BRANCH="automation/update-changelog"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Force-update the automation branch
git checkout -B "$BRANCH"
git add CHANGELOG.md
git commit -m "docs: update CHANGELOG.md"
git push origin "$BRANCH" --force
# Create or update the PR
EXISTING_PR=$(gh pr list --head "$BRANCH" --state open --json number -q '.[0].number')
if [ -n "$EXISTING_PR" ]; then
echo "Updated existing PR #$EXISTING_PR"
else
gh pr create \
--title "docs: update CHANGELOG.md" \
--body "Automated changelog refresh, dispatched from main." \
--head "$BRANCH" \
--base main \
--label "automation" \
--label "skip-changelog"
echo "Created new changelog PR"
fi
# Post the required "Check Workflow Status" commit status so the PR
# isn't blocked. PRs from GITHUB_TOKEN don't trigger pull_request
# workflows, so the normal CI never runs. CHANGELOG-only PRs don't
# need CI checks.
COMMIT_SHA=$(git rev-parse HEAD)
gh api "repos/${{ github.repository }}/statuses/${COMMIT_SHA}" \
-f state="success" \
-f context="Check Workflow Status" \
-f description="Skipped — changelog-only PR"
uses: ./.github/actions/bot-pr
with:
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
branch: automation/update-changelog
title: 'docs: update CHANGELOG.md'
body: 'Automated changelog refresh, dispatched from main.'
add-paths: CHANGELOG.md
labels: |
automation
skip-changelog
+5 -19
View File
@@ -8,29 +8,17 @@ on:
paths:
- 'scripts/verify-flatpak/**'
- '.github/workflows/verify-flatpak.yml'
# The dependency surface the manifest captures is verified post-merge. This is not a
# required check and never ran in the merge queue, so per-PR it cost ~2 runner slots
# for a signal that blocks nothing; post-merge still catches a break within one merge,
# and `main` itself was previously never verified at all.
# Post-merge only for what the check itself depends on. The offline manifest pins the
# Gradle distribution apart from the wrapper, so a wrapper bump is checked on merge.
push:
branches: [ main ]
paths:
- 'scripts/verify-flatpak/**'
- '.github/workflows/verify-flatpak.yml'
- 'build.gradle.kts'
- 'settings.gradle.kts'
# The desktop module's build config shapes the uber jar the flatpak wraps.
- 'desktopApp/**'
# The offline manifest pins the Gradle distribution independently of the wrapper —
# a wrapper bump without a manifest update breaks the offline build silently.
- 'gradle/wrapper/**'
# build.gradle.kts reads compose-multiplatform from the catalog (#6911), so a
# catalog-only bump changes the manifest's platform URLs. Deliberately the whole
# file and not a key filter: a filter naming today's keys goes stale silently the
# moment the manifest reads another one — the failure #6911 existed to remove.
- 'gradle/libs.versions.toml'
# Drift no path filter can see: a Flathub runtime bump, or an upstream artifact that
# moved or vanished. Nothing in this repo changes, so no other trigger would fire.
# The dependency surface the manifest captures (desktopApp/**, the version catalog, the
# root build scripts) is verified nightly, as is drift no path filter can see: a
# Flathub runtime bump, or an upstream artifact that moved or vanished.
schedule:
- cron: '0 4 * * *'
workflow_dispatch:
@@ -60,8 +48,6 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: recursive
# Renovate mirrors wrapper bumps into the manifest's distribution URL but cannot
# rewrite its sha256, so on a wrapper bump this drift is expected: fail here in
+3 -28
View File
@@ -17,8 +17,7 @@ on:
required: true
type: string
# The PR is opened and queued with CROWDIN_GITHUB_TOKEN, as scheduled-updates.yml does:
# GITHUB_TOKEN may not enable auto-merge on protected main, and its PRs run no checks.
# The PR is opened and queued with CROWDIN_GITHUB_TOKEN through .github/actions/bot-pr.
permissions:
contents: read
pull-requests: read
@@ -31,7 +30,7 @@ concurrency:
jobs:
bump:
if: ${{ github.repository == 'meshtastic/Meshtastic-Android' && !github.event.release.prerelease && !github.event.release.draft }}
runs-on: ubuntu-26.04-arm
runs-on: ubuntu-slim
timeout-minutes: 10
env:
TAG: ${{ inputs.tag || github.event.release.tag_name }}
@@ -87,20 +86,16 @@ jobs:
# Creates the PR, or updates it on a retry. Pushed with the PAT, so pull-request.yml's
# AppStream and what's-new gates run on it like any other bump.
- name: Open the bump PR
id: pr
if: ${{ steps.next.outputs.skip == 'false' }}
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
uses: ./.github/actions/bot-pr
with:
token: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
commit-message: 'chore: bump VERSION_NAME_BASE to ${{ steps.next.outputs.next }}'
title: 'chore: bump VERSION_NAME_BASE to ${{ steps.next.outputs.next }}'
body: |
Opens the ${{ steps.next.outputs.next }} line now that v${{ steps.next.outputs.shipped }} has shipped.
The metainfo `<description>` for ${{ steps.next.outputs.next }} is a placeholder. Rewrite it and re-run `python3 scripts/sync-play-changelog.py` before the ${{ steps.next.outputs.next }} internal cut, or it ships as the release Highlights and the Play what's-new.
branch: ${{ steps.next.outputs.branch }}
base: main
delete-branch: true
add-paths: |
config.properties
desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml
@@ -108,23 +103,3 @@ jobs:
labels: |
automation
skip-changelog
# Same request as scheduled-updates.yml: the queue sets the merge method, and a PR
# that is already mergeable is refused the request and merges directly.
- name: Enable auto-merge
if: ${{ steps.pr.outputs.pull-request-operation == 'created' || steps.pr.outputs.pull-request-operation == 'updated' }}
env:
GH_TOKEN: ${{ secrets.CROWDIN_GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.pr.outputs.pull-request-number }}
run: |
PR_ID=$(gh pr view "$PR_NUMBER" --json id --jq .id)
QUERY=$(cat <<'GQL'
mutation($id: ID!) {
enablePullRequestAutoMerge(input: { pullRequestId: $id }) {
pullRequest { number autoMergeRequest { enabledAt } }
}
}
GQL
)
gh api graphql -f query="$QUERY" -F id="$PR_ID" \
|| gh pr merge "$PR_NUMBER"
+37 -14
View File
@@ -8,7 +8,7 @@ The entire release process is managed by a single GitHub Action: **`Create or Pr
- **Trigger:** To start a new release or promote an existing one, a developer runs the workflow from the GitHub Actions tab.
- **Inputs:** The workflow requires the following inputs:
1. `base_version`: The base version number you are releasing (e.g., `2.8.0`).
1. `base_version`: The base version number you are releasing (e.g., `2.8.0`). It must be `X.Y.Z` and equal `VERSION_NAME_BASE` in `config.properties` at the commit being released (`HEAD` for an internal cut, the promoted tag's commit for a promotion), or the run stops before any tag is pushed.
2. `channel`: The release channel you are targeting (`internal`, `closed`, `open`, or `production`).
3. `dry_run`: If `true`, calculates the tag but does not push it or start the release (default: `false`).
4. `no_review_in_flight`: **Promotions only, and a hard gate.** Before promoting, check
@@ -18,16 +18,18 @@ The entire release process is managed by a single GitHub Action: **`Create or Pr
already in flight. Internal releases and dry runs are exempt (Play internal testing
skips full review).
- **Automation:** The workflow handles everything automatically:
- **Generates Changelog:** Categorizes merged PRs by their labels (per `.github/release.yml`) into GitHub's auto-generated release notes. The internal draft's notes cover the PRs since the previous published pre-release; a production promotion rewrites them over the whole range since the previous production tag, with the metainfo `<description>` for the version as a Highlights section on top, and opens a PR folding the same notes into `CHANGELOG.md`. Between releases that file is only refreshed by dispatching the `Update Changelog` workflow by hand.
- **Tags & Builds** *(internal releases)*: Pushes the incremental tag first — there is no lint/test gate in this workflow, that's the separate PR/CI pipeline — then builds the Android bundle/APK and Desktop installers from that tag; if the build fails, an automatic cleanup job deletes the tag so a retry starts clean. Promotions skip this entirely and retag the already-built artifact (see below).
- **Deploys Android:** Uploads the build to the correct Google Play track and attaches artifacts (`.aab`/`.apk`) to a GitHub Release. Each promotion also uploads the Play "What's new" text for every locale from `fastlane/metadata/android/<locale>/changelogs/default.txt`, which `scripts/sync-play-changelog.py` renders from the metainfo `<description>` and Crowdin translates.
- **Captures the store screenshots** *(internal releases)*: `store-screenshots.yml` runs the real debug apps from the tag, connected to Demo Mode's showcase mesh, on an emulator per flavor and on a virtual display for desktop, and attaches `store-listing-screenshots-google-<versionCode>.zip`, `store-listing-screenshots-fdroid-<versionCode>.zip` and the five desktop PNGs to the release. A set with a shot missing is replaced whole by the committed one, so the metainfo's screenshot URLs still resolve, and the job summary says which set went up. A failed capture never fails the release.
- **Generates Changelog:** Categorizes merged PRs by their labels (per `.github/release.yml`) into GitHub's auto-generated release notes. The internal draft's notes cover the PRs since the previous published pre-release; a production promotion rewrites them over the whole range since the previous production tag, with the metainfo `<description>` for the version as a Highlights section on top, and opens a PR folding the same notes into `CHANGELOG.md`. Every internal, closed and open run ends by dispatching the `Update Changelog` workflow, which refreshes that file's `[Unreleased]` section through its own PR. Every PR the release automation opens (these two, the screenshot refresh and the version bump) goes through `.github/actions/bot-pr` with `CROWDIN_GITHUB_TOKEN`, so it runs the normal PR checks and merges itself through the queue once they pass.
- **Tags & Builds** *(internal releases)*: Pushes the incremental tag first — there is no lint/test gate in this workflow, that's the separate PR/CI pipeline — then builds the Android bundle/APK and Desktop installers from that tag; if the build fails, an automatic cleanup job deletes the tag so a retry starts clean. Once `publish-play` has uploaded the bundle the tag stays, since Play keeps that versionCode; re-run the failed jobs instead. Promotions skip this entirely and retag the already-built artifact (see below).
- **Deploys Android:** Uploads the build to the correct Google Play track and attaches artifacts (`.aab`/`.apk`) to a GitHub Release. An internal cut sends the bundle to Play only after every Android, desktop and Flatpak leg has built, so a failed leg and its deleted tag leave nothing on Play. Each promotion also uploads the Play "What's new" text for every locale from `fastlane/metadata/android/<locale>/changelogs/default.txt`, which `scripts/sync-play-changelog.py` renders from the metainfo `<description>` and Crowdin translates.
- **Captures the store screenshots** *(internal releases)*: `store-screenshots.yml` runs the real debug apps from the tag, connected to Demo Mode's showcase mesh, on one emulator for both flavors and on a virtual display for desktop, and attaches `store-listing-screenshots-google-<versionCode>.zip`, `store-listing-screenshots-fdroid-<versionCode>.zip` and the five desktop PNGs to the draft once it exists, so the draft does not wait on the capture. A set with a shot missing is replaced whole by the committed one, so the metainfo's screenshot URLs still resolve, and the job summary says which set went up. A failed capture never fails the release.
- **Publishes the Play listing:** every promotion runs the `play_listing` lane with the tag's text for every locale and the google-flavor screenshots, as a dry run on closed and open and for real on production, held as "changes not sent for review". Production also opens a self-merging PR that writes the fdroid-flavor screenshots back into `fastlane/`, which F-Droid and IzzyOnDroid read from git, and the desktop set into `desktopApp/packaging/linux/screenshots/`.
- **Publishes docs:** Every promotion dispatches `docs-release.yml` on the new tag (the tag is created with `GITHUB_TOKEN`, so its tag trigger never fires on its own).
- **Writes a checklist:** The promotion run's summary lists what it did, what it dispatched, and what is still done by hand.
- **Refreshes the Obtainium table:** Every promotion dispatches `scheduled-updates.yml` once the release is published, so the Obtainium table in `README.md` follows it without waiting for the scheduled run.
- **Writes a checklist:** The promotion run's summary lists what it did, what it dispatched, and what is still done by hand. winget, the Microsoft Store, Homebrew and Flathub each read "will skip: `<SECRET>` unset" when their secret is missing, since those workflows and jobs then submit nothing.
- **Reruns:** Re-running a failed `update-github-release` job finds the release under its final tag when the first attempt already moved it, and updates the bot PRs in place; its workflow dispatches run again. The Discord announcement goes out whenever the release step succeeded, whatever failed after it.
- **Deploys Desktop** *(internal releases)*: Builds native installers (DMG, MSI, EXE, DEB, RPM, AppImage) and Flatpak sources on a matrix of runners and attaches them to the GitHub Release.
- **Changelog:** Both the GitHub Release notes and `CHANGELOG.md` are generated from merged PR labels, not raw commit messages — label PRs correctly (`enhancement`, `bugfix`, etc.) to keep them accurate.
- **Not part of this workflow:** Firmware/hardware/device-links lists and Crowdin translations are kept current by a separate hourly workflow, `scheduled-updates.yml` ("Scheduled Updates (Firmware, Hardware, Translations)"), which opens its own PR rather than committing directly and enables auto-merge on it, so it lands through the merge queue once its checks pass — it never runs as part of a release. `VERSION_NAME_BASE` in `config.properties` moves to the next patch version after each production release: `promote.yml` dispatches `version-bump.yml`, which runs `scripts/bump-version-name.py` and opens a self-merging PR carrying the new `<release>` entry in `desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml`, its five `<image>` URLs moved to `releases/download/v<version>/`, and `fastlane/metadata/android/en-US/changelogs/default.txt` rendered from that entry. `pull-request.yml` fails a bump PR missing any of them; the bot PR is opened with `CROWDIN_GITHUB_TOKEN`, so those checks run on it and the merge queue takes it. The entry's paragraph is a placeholder; replace it and re-run `scripts/sync-play-changelog.py` before the next internal cut, because it becomes the release Highlights and Play's "What's new". A minor or major line is a hand PR running the same script, and the workflow skips when `main` is already past the shipped version. `Create or Promote Release` only *reads* `VERSION_NAME_BASE`/`VERSION_CODE_OFFSET` from `config.properties` to compute the build's version name/code.
- **Not part of this workflow:** Firmware/hardware/device-links lists and Crowdin translations are kept current by a separate scheduled workflow, `scheduled-updates.yml` ("Scheduled Updates (Firmware, Hardware, Translations)"), which opens its own PR rather than committing directly and enables auto-merge on it, so it lands through the merge queue once its checks pass. A promotion dispatches it (above) without waiting on it. `VERSION_NAME_BASE` in `config.properties` moves to the next patch version after each production release: `promote.yml` dispatches `version-bump.yml`, which runs `scripts/bump-version-name.py` and opens a self-merging PR carrying the new `<release>` entry in `desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml`, its five `<image>` URLs moved to `releases/download/v<version>/`, and `fastlane/metadata/android/en-US/changelogs/default.txt` rendered from that entry. `pull-request.yml` fails a bump PR missing any of them; the bot PR is opened with `CROWDIN_GITHUB_TOKEN`, so those checks run on it and the merge queue takes it. The entry's paragraph is a placeholder; replace it and re-run `scripts/sync-play-changelog.py` before the next internal cut, because it becomes the release Highlights and Play's "What's new". A minor or major line is a hand PR running the same script, and the workflow skips when `main` is already past the shipped version. `Create or Promote Release` only *reads* `config.properties` at the commit being released: `VERSION_NAME_BASE` must match `base_version`, and `VERSION_CODE_OFFSET` plus that commit's count gives the build's version code.
## Release Steps
@@ -42,9 +44,9 @@ The entire release process is managed by a single GitHub Action: **`Create or Pr
The workflow will:
1. **Tag** the current commit on the branch with an incremental internal tag (e.g., `v2.8.0-internal.1`) — no new commit is created; it tags whatever is already at `HEAD`.
2. **Build & Deploy** the built Android artifact to the Play Store Internal track.
3. **Build Desktop** native installers and Flatpak sources on macOS, Windows, and Linux runners.
4. Publish a **draft** pre-release on GitHub with all artifacts attached. It stays a draft until
2. **Build** the Android bundle and APKs, and the desktop installers and Flatpak sources on macOS, Windows, and Linux runners.
3. **Deploy** the Android bundle to the Play Store Internal track once every build has succeeded.
4. Publish a **draft** pre-release on GitHub with all artifacts attached; the store screenshots follow when the capture finishes. It stays a draft until
the first promotion (closed/open/production), at which point `promote.yml` un-drafts the
*same* release object (retagging it to the new channel's tag) rather than creating a new one.
@@ -70,18 +72,39 @@ After testing is complete on all pre-release channels, you can create the final
Start from the promotion run's summary: it lists what the run did and dispatched, and what
remains by hand.
1. **Verify Android:** Check the Google Play Console to ensure the build is available on the correct track. A production promotion starts a staged rollout; complete it in the console.
1. **Verify Android:** Check the Google Play Console to ensure the build is available on the correct track. A production promotion starts a staged rollout at 10% (open at 50%); widen, complete or halt it with the **`Play Rollout`** workflow (see Staged Rollout below).
2. **Verify Desktop:** Download and smoke-test at least one installer (DMG, MSI, or AppImage) from the GitHub Release.
3. **Verify the desktop store submissions** *(production only — see below)*: the Microsoft Store
submission in Partner Center, and the pull request opened against `microsoft/winget-pkgs`.
Each store workflow warns in its summary when its secrets are not set and it submitted nothing.
4. **Flathub** *(production only)*: bump the manifest in `flathub/org.meshtastic.MeshtasticDesktop` (see Flatpak below).
Each store workflow warns in its summary when its secrets are not set and it submitted nothing,
and the promotion checklist already says so.
4. **Flathub** *(production only)*: merge the `update-flathub` PR in `flathub/org.meshtastic.MeshtasticDesktop` once Flathub's test build passes, or bump it by hand when `FLATHUB_TOKEN` is unset (see Flatpak below).
5. **Post-Release Cleanup** *(production only)*: `Docs Release` dispatches `post-release-cleanup.yml` with `confirm_deletion: true` once it has published `/vX.Y.Z/`, deleting the pre-releases, tags and docs snapshots at or below `X.Y.Z`. Check that run; a manual dispatch is the retry and defaults to a dry run.
6. **Next version line** *(production only)*: the `version-bump.yml` PR bumps `VERSION_NAME_BASE` and merges itself. Replace its placeholder `<description>` before the next internal cut.
7. **Merge:** If a `release/*` branch was used for stabilization (CI runs the same PR checks
against PRs targeting `release/**` as it does for `main`), merge it back into `main` now
that production has shipped.
### Staged Rollout
**`Play Rollout`** (`play-rollout.yml`) changes the one `inProgress` release on the `production`
or `beta` track. Its inputs are the `track`, an `action` and, for `rollout`, a `fraction`:
| Action | Effect |
|---|---|
| `rollout` | Widens the release to `fraction`, which must be above the current fraction and below 1 |
| `complete` | Ships the release to every user |
| `halt` | Stops the rollout at its current fraction |
It carries the same `no_review_in_flight` gate as a promotion, because a committed change
cancels and restarts any review in flight. It runs in its own concurrency group, not
`Create or Promote Release`'s, since a group keeps one pending run and a rollout queued there
would cancel a pending promotion. The run stops without
changing anything unless the track holds exactly one `inProgress` release, and it verifies the
new status and fraction on the track afterwards. A halted release is resumed or completed in the
Play Console, since `supply` only acts on `inProgress` releases. When Play will not send a change
for review on its own, the change waits under Publishing overview in the Play Console.
### Desktop Store Publishing (production only)
Publishing a **production** release also fires two workflows, both keyed on the GitHub
@@ -130,7 +153,7 @@ Desktop uses the same version resolution chain as Android — both read `VERSION
### Flatpak
Flatpak packaging is maintained externally at [flathub/org.meshtastic.MeshtasticDesktop](https://github.com/flathub/org.meshtastic.MeshtasticDesktop). It builds `:desktopApp:packageUberJarForCurrentOS` (not the native distribution pipeline) and handles JBR bundling; the AppStream metainfo and `.desktop` entry it installs come from this repo, out of the tag it builds. So the `<release>` notes ship with the tag, and the `<screenshot>` URLs name the desktop PNGs the internal cut attached to that version's release (`releases/download/v<version>/`), which Flathub's guidelines allow and a branch link would not. The Flathub bump is a hand-opened PR that moves four things together: the tag and commit, the Gradle distribution zip URL and sha256 (from the tag's `gradle/wrapper/gradle-wrapper.properties`), and the release's `flatpak-sources.json` asset. Every flathubbot zip-bump PR so far has failed its test build; close them rather than merge them. The offline-build sources it consumes are captured in-repo by `scripts/verify-flatpak/` (see its README).
Flatpak packaging is maintained externally at [flathub/org.meshtastic.MeshtasticDesktop](https://github.com/flathub/org.meshtastic.MeshtasticDesktop). It builds `:desktopApp:packageUberJarForCurrentOS` (not the native distribution pipeline) and handles JBR bundling; the AppStream metainfo and `.desktop` entry it installs come from this repo, out of the tag it builds. So the `<release>` notes ship with the tag, and the `<screenshot>` URLs name the desktop PNGs the internal cut attached to that version's release (`releases/download/v<version>/`), which Flathub's guidelines allow and a branch link would not. A production promotion's `update-flathub` job opens the bump PR with `FLATHUB_TOKEN`, and skips with a notice when that secret is unset. The PR moves four things together: the tag and commit, the Gradle distribution zip URL and sha256 (from the tag's `gradle/wrapper/gradle-wrapper.properties`), and the release's `flatpak-sources.json` asset. `scripts/verify-flatpak/bump-flathub-manifest.py` rewrites those manifest fields in place and fails, leaving the bump to be done by hand, when any of them no longer matches exactly once. The JBR, the runtime and the patches are left alone; Flathub's test build on the PR checks them against the tag. flathubbot's zip-bump PRs follow the latest Gradle rather than the tag's wrapper and fail their test build; close them rather than merge them. The offline-build sources it consumes are captured in-repo by `scripts/verify-flatpak/` (see its README), and `verify-flatpak.yml` builds them offline nightly and on changes to that directory, the workflow or the Gradle wrapper.
## Build Attestations & Provenance
@@ -29,7 +29,7 @@ class AboutLibrariesConventionPlugin : Plugin<Project> {
pluginManager.apply(libs.plugin("aboutlibraries").get().pluginId)
extensions.configure<AboutLibrariesExtension> {
// aboutLibraries.release=true is only passed for google builds (see Fastfile).
// release.yml passes aboutLibraries.release=true to the Google and desktop release builds only.
// For fdroid/reproducible builds, offlineMode=true ensures no network calls
// and deterministic output. See: https://github.com/meshtastic/Meshtastic-Android/issues/3231
val isReleaseBuild =
@@ -69,8 +69,7 @@ class AboutLibrariesConventionPlugin : Plugin<Project> {
// See: https://github.com/meshtastic/Meshtastic-Android/issues/3231
tasks
.matching {
it.name.startsWith("process") &&
(it.name.endsWith("Resources") || it.name.endsWith("JavaRes"))
it.name.startsWith("process") && (it.name.endsWith("Resources") || it.name.endsWith("JavaRes"))
}
.configureEach { dependsOn("exportLibraryDefinitions") }
}
+3 -3
View File
@@ -75,14 +75,14 @@ Rendering is host-deterministic here (layoutlib): a local `update` produces refe
The store-listing screenshots (Play, F-Droid, IzzyOnDroid, and the desktop app's Flathub listing) are taken from the real apps, connected to Demo Mode's hidden showcase mesh (`/connections?address=mshowcase`, `MockScenario.SHOWCASE` in `:core:network`), rather than drawn. Every screen is reached by its deep link, so the flow does not depend on the display language, and each shot is kept once the window has stopped changing.
- **Android: `:store-screenshots`**, a UiAutomator 2.4 test module targeting `:androidApp`. For each surface `fastlane supply` uploads it sets the display size and density, relaunches the debug app through its shell-only `AutomationLauncher` alias with `skip_onboarding` and `skip_connect_confirm`, and saves the five listing shots, full screen with a SystemUI demo-mode status bar, to `/data/local/tmp/store-screenshots` on the device.
- **Android: `:store-screenshots`**, a UiAutomator 2.4 test module targeting `:androidApp`. For each surface `fastlane supply` uploads it sets the display size and density, relaunches the debug app through its shell-only `AutomationLauncher` alias with `skip_onboarding` and `skip_connect_confirm`, and saves the five listing shots, full screen with a SystemUI demo-mode status bar, to `/data/local/tmp/store-screenshots/<flavor>` on the device.
- **Desktop: `store-screenshots/capture-desktop.sh`** runs the real desktop debug build on an Xvfb display, one launch per screen with that screen's deep link, and saves the five Flathub shots. The map needs Skiko's OpenGL renderer and Skiko refuses any GL adapter named `llvmpipe` or `virgl`, so Mesa runs GL through zink over lavapipe.
On an emulator or device, one flavor at a time:
```shell
./gradlew :store-screenshots:connectedFdroidDebugAndroidTest
adb pull /data/local/tmp/store-screenshots/. fastlane/metadata/android/en-US/images/
adb pull /data/local/tmp/store-screenshots/fdroid/. fastlane/metadata/android/en-US/images/
```
| Folder | Size | Window | Uploaded by |
@@ -92,7 +92,7 @@ adb pull /data/local/tmp/store-screenshots/. fastlane/metadata/android/en-US/ima
| `tenInchScreenshots/` | 2560×1440 @320 dpi | expanded: rail, list beside detail | `fastlane supply` |
| `desktopApp/packaging/linux/screenshots/` | 1280×800 | expanded: rail, list beside detail | Flathub, through the release assets `metainfo.xml` names |
`.github/workflows/store-screenshots.yml` runs both on hosted runners, per flavor for Android (google for the Play listing, fdroid for the committed tree), on every internal release, on demand, and on pull requests that touch the renderer or the showcase mesh. The release pipeline attaches the captures to the release, publishes the Play listing from them on production, and opens a self-merging PR that writes the fdroid and desktop sets back here (`RELEASE_PROCESS.md`).
`.github/workflows/store-screenshots.yml` runs both on hosted runners, with both Android flavors in one job on one emulator (google for the Play listing, fdroid for the committed tree), on every internal release, on demand, and on pull requests that touch the renderer or the showcase mesh. The release pipeline attaches the captures to the release, publishes the Play listing from them on production, and opens a self-merging PR that writes the fdroid and desktop sets back here (`RELEASE_PROCESS.md`).
### Baseline Profile / startup performance
+27 -30
View File
@@ -1,18 +1,19 @@
# Lanes are invoked by the workflows in .github/workflows (release.yml,
# promote.yml, play-listing.yml); `bundle exec fastlane lanes` lists them.
# promote.yml, play-listing.yml, play-rollout.yml); `bundle exec fastlane lanes`
# lists them.
# Play credentials come from fastlane/play-store-credentials.json, written by
# the workflow from the GOOGLE_PLAY_JSON_KEY secret and deleted afterwards.
default_platform(:android)
platform :android do
desc "Deploy a new version to the internal track on Google Play"
lane :internal do |options|
aab_path = build_google_release
desc "Upload a built Google release bundle to the internal track on Google Play. Pass aab:<path>"
lane :upload_internal do |options|
UI.user_error!("Pass the bundle to upload as aab:<path>") if options[:aab].to_s.empty?
upload_to_play_store(
track: 'internal',
aab: aab_path,
aab: options[:aab],
release_status: 'completed',
skip_upload_apk: true,
skip_upload_metadata: true,
@@ -49,31 +50,27 @@ platform :android do
)
end
desc "Build the F-Droid release"
lane :fdroid_build do
gradle(
task: "assembleFdroidRelease",
properties: {
"android.injected.version.name" => ENV['VERSION_NAME'],
"android.injected.version.code" => ENV['VERSION_CODE'],
# Intentionally omit aboutLibraries.release — fdroid builds must use
# offlineMode so the output matches F-Droid's reproducible rebuild.
}
)
end
desc "Write every release on a Play track (status, version codes, user fraction) as JSON. Pass track:<name> out:<path>. Reads only; the edit is discarded"
lane :play_track_releases do |options|
UI.user_error!("Pass track:<name> and out:<path>") if options[:track].to_s.empty? || options[:out].to_s.empty?
desc "Build the Google Release"
private_lane :build_google_release do
gradle(
task: "bundleGoogleRelease assembleGoogleRelease",
print_command: false,
properties: {
"android.injected.version.name" => ENV['VERSION_NAME'],
"android.injected.version.code" => ENV['VERSION_CODE'],
"aboutLibraries.release" => "true",
"meshtastic.disableAbiSplits" => "true"
}
)
lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH]
require 'json'
require 'supply'
require 'supply/options'
require 'supply/reader'
# Lane code runs in ./fastlane, and the Appfile's key path is relative to the project root.
Dir.chdir('..') do
Supply.config = FastlaneCore::Configuration.create(Supply::Options.available_options, { track: options[:track].to_s })
track = Supply::Reader.new.track_meta
releases = (track&.releases || []).map do |release|
{
status: release.status,
version_codes: (release.version_codes || []).map(&:to_i),
user_fraction: release.user_fraction,
}
end
File.write(options[:out], JSON.pretty_generate(releases))
end
end
end
+6 -6
View File
@@ -15,13 +15,13 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do
## Android
### android internal
### android upload_internal
```sh
[bundle exec] fastlane android internal
[bundle exec] fastlane android upload_internal
```
Deploy a new version to the internal track on Google Play
Upload a built Google release bundle to the internal track on Google Play. Pass aab:<path>
### android play_listing
@@ -31,13 +31,13 @@ Deploy a new version to the internal track on Google Play
Upload the store listing - title, descriptions, feature graphic, icon and screenshots - for every locale under fastlane/metadata/android. Touches no build or track. Dry-runs unless validate_only:false
### android fdroid_build
### android play_track_releases
```sh
[bundle exec] fastlane android fdroid_build
[bundle exec] fastlane android play_track_releases
```
Build the F-Droid release
Write every release on a Play track (status, version codes, user fraction) as JSON. Pass track:<name> out:<path>. Reads only; the edit is discarded
----
+3
View File
@@ -79,3 +79,6 @@ executing the Gradle build, or run the full script on a Linux host.
- `desktop-offline.yaml` — patched manifest. Kept in sync manually with the upstream packaging;
diff against `https://raw.githubusercontent.com/flathub/org.meshtastic.MeshtasticDesktop/master/org.meshtastic.MeshtasticDesktop.yaml`
if upstream changes something material.
- `bump-flathub-manifest.py` - points the upstream manifest at a release: the source tag and
commit, and the Gradle zip the tag's wrapper pins. `promote.yml`'s `update-flathub` job runs it
on every production promotion, then commits the release's `flatpak-sources.json` beside it.
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Point the Flathub manifest at a release: the source tag and commit, and the Gradle zip.
Usage: bump-flathub-manifest.py <manifest.yaml> <tag> <commit> <gradle-wrapper.properties>
The Gradle distribution URL and sha256 come from the tag's own wrapper properties, since the
offline build can only run the Gradle version that tag pins. Each field is rewritten in
place, so comments and layout survive. Any field that does not match exactly once fails the
run instead of guessing, and the manifest is then bumped by hand.
"""
import re
import sys
GIT_SOURCE = re.compile(
r"^(?P<lead>\s+url: https://github\.com/meshtastic/Meshtastic-Android\.git\n"
r"\s+tag: )\S+(?P<mid>\n\s+commit: )[0-9a-f]{40}$",
re.MULTILINE,
)
GRADLE_ZIP = re.compile(
r"^(?P<lead>\s+url: )https://services\.gradle\.org/distributions/gradle-[^\s/]+\.zip"
r"(?P<mid>\n\s+sha256: )[0-9a-f]{64}$",
re.MULTILINE,
)
def wrapper_distribution(path: str) -> tuple[str, str]:
props = {}
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
props[key.strip()] = value.strip().replace("\\:", ":")
url = props.get("distributionUrl", "")
sha = props.get("distributionSha256Sum", "")
if not re.fullmatch(r"https://services\.gradle\.org/distributions/gradle-[^\s/]+\.zip", url):
sys.exit(f"{path}: distributionUrl '{url}' is not a services.gradle.org distribution")
if not re.fullmatch(r"[0-9a-f]{64}", sha):
sys.exit(f"{path}: distributionSha256Sum '{sha}' is not a sha256")
return url, sha
def replace_once(pattern: re.Pattern, value_a: str, value_b: str, text: str, what: str) -> str:
new, count = pattern.subn(lambda m: m["lead"] + value_a + m["mid"] + value_b, text)
if count != 1:
sys.exit(f"{what}: expected exactly one match in the manifest, found {count}")
return new
def main() -> None:
if len(sys.argv) != 5:
sys.exit(__doc__)
manifest, tag, commit, wrapper = sys.argv[1:]
if not re.fullmatch(r"v\d+\.\d+\.\d+", tag):
sys.exit(f"'{tag}' is not a production tag (vX.Y.Z)")
if not re.fullmatch(r"[0-9a-f]{40}", commit):
sys.exit(f"'{commit}' is not a full commit sha")
url, sha = wrapper_distribution(wrapper)
with open(manifest, encoding="utf-8") as f:
text = f.read()
text = replace_once(GIT_SOURCE, tag, commit, text, "Meshtastic-Android git source (url, tag, commit)")
text = replace_once(GRADLE_ZIP, url, sha, text, "Gradle distribution (url, sha256)")
with open(manifest, "w", encoding="utf-8") as f:
f.write(text)
print(f"{manifest}: {tag} at {commit}, {url.rsplit('/', 1)[-1]}")
if __name__ == "__main__":
main()
+11 -3
View File
@@ -19,9 +19,10 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
// Captures the store-listing screenshots from the real debug app on a device or emulator:
// ./gradlew :store-screenshots:connectedGoogleDebugAndroidTest (Play)
// ./gradlew :store-screenshots:connectedFdroidDebugAndroidTest (F-Droid, IzzyOnDroid)
// PNGs are left on the device in /data/local/tmp/store-screenshots, laid out like fastlane's images/; pull them with
// adb pull /data/local/tmp/store-screenshots/. <dir>
// .github/workflows/store-screenshots.yml does this on an emulator.
// PNGs are left on the device in /data/local/tmp/store-screenshots/<flavor>, laid out like fastlane's images/; pull
// them with
// adb pull /data/local/tmp/store-screenshots/<flavor>/. <dir>
// .github/workflows/store-screenshots.yml does this for both flavors on one emulator.
plugins {
alias(libs.plugins.android.test)
alias(libs.plugins.meshtastic.detekt)
@@ -52,14 +53,21 @@ android {
create("google") {
dimension = "marketplace"
testInstrumentationRunnerArguments["targetAppId"] = "com.geeksville.mesh.google.debug"
testInstrumentationRunnerArguments["flavor"] = "google"
}
create("fdroid") {
dimension = "marketplace"
testInstrumentationRunnerArguments["targetAppId"] = "com.geeksville.mesh.fdroid.debug"
testInstrumentationRunnerArguments["flavor"] = "fdroid"
}
}
}
// Both flavors can capture on one device in one invocation; each run resizes that device's display.
tasks
.named { it == "connectedFdroidDebugAndroidTest" }
.configureEach { mustRunAfter("connectedGoogleDebugAndroidTest") }
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
dependencies {
@@ -42,6 +42,7 @@ class StoreScreenshots {
private val arguments = InstrumentationRegistry.getArguments()
private val appId = requireNotNull(arguments.getString("targetAppId")) { "targetAppId argument missing" }
private val flavor = requireNotNull(arguments.getString("flavor")) { "flavor argument missing" }
// Shared media storage: the one app directory the shell user can read, so [save] can copy out of it.
@Suppress("DEPRECATION")
@@ -172,7 +173,7 @@ class StoreScreenshots {
val file = File(mediaDir, name)
file.parentFile?.mkdirs()
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, PNG_QUALITY, it) }
val target = "$DEVICE_OUTPUT/$name"
val target = "$DEVICE_OUTPUT/$flavor/$name"
shell("mkdir -p ${target.substringBeforeLast('/')}")
shell("cp ${file.path} $target")
Log.i(TAG, "saved $target")
@@ -208,7 +209,7 @@ class StoreScreenshots {
private companion object {
const val TAG = "StoreScreenshots"
/** Where the captures are left for `adb pull`, laid out as fastlane's `images/` folder. */
/** Where the captures are left for `adb pull`: a folder per flavor, laid out as fastlane's `images/`. */
const val DEVICE_OUTPUT = "/data/local/tmp/store-screenshots"
/** Demo Mode's hidden showcase mesh; `MockScenario.SHOWCASE` in `:core:network`. */