diff --git a/.github/actions/bot-pr/action.yml b/.github/actions/bot-pr/action.yml new file mode 100644 index 0000000000..038f5f1fb4 --- /dev/null +++ b/.github/actions/bot-pr/action.yml @@ -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" diff --git a/.github/workflows/create-or-promote-release.yml b/.github/workflows/create-or-promote-release.yml index 341f4f8f30..304391d5f0 100644 --- a/.github/workflows/create-or-promote-release.yml +++ b/.github/workflows/create-or-promote-release.yml @@ -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:-}." + 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." diff --git a/.github/workflows/play-listing.yml b/.github/workflows/play-listing.yml index c3ff9089f6..4c163b1ba3 100644 --- a/.github/workflows/play-listing.yml +++ b/.github/workflows/play-listing.yml @@ -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: diff --git a/.github/workflows/play-rollout.yml b/.github/workflows/play-rollout.yml new file mode 100644 index 0000000000..51a67bb394 --- /dev/null +++ b/.github/workflows/play-rollout.yml @@ -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 diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index 3f733726e3..1077a2a448 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -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 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" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03051e9984..88a71b9223 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/.github/workflows/store-screenshots.yml b/.github/workflows/store-screenshots.yml index e702b741b3..354d8f3aaa 100644 --- a/.github/workflows/store-screenshots.yml +++ b/.github/workflows/store-screenshots.yml @@ -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-` laid out as -# `images/Screenshots/_.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-` laid +# out as `images/Screenshots/_.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/, 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 diff --git a/.github/workflows/update-changelog.yml b/.github/workflows/update-changelog.yml index c50a301ecf..4cabbf8aa3 100644 --- a/.github/workflows/update-changelog.yml +++ b/.github/workflows/update-changelog.yml @@ -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 diff --git a/.github/workflows/verify-flatpak.yml b/.github/workflows/verify-flatpak.yml index 458c7422ba..c7a07ccf35 100644 --- a/.github/workflows/verify-flatpak.yml +++ b/.github/workflows/verify-flatpak.yml @@ -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 diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index eedc8216cf..793475ea68 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -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 `` 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" diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index df8f9a086c..ce0eeaa2ef 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -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 `` 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//changelogs/default.txt`, which `scripts/sync-play-changelog.py` renders from the metainfo `` 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-.zip`, `store-listing-screenshots-fdroid-.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 `` 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//changelogs/default.txt`, which `scripts/sync-play-changelog.py` renders from the metainfo `` 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-.zip`, `store-listing-screenshots-fdroid-.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: `` 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 `` entry in `desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml`, its five `` URLs moved to `releases/download/v/`, 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 `` entry in `desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml`, its five `` URLs moved to `releases/download/v/`, 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 `` 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 `` notes ship with the tag, and the `` URLs name the desktop PNGs the internal cut attached to that version's release (`releases/download/v/`), 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 `` notes ship with the tag, and the `` URLs name the desktop PNGs the internal cut attached to that version's release (`releases/download/v/`), 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 diff --git a/build-logic/convention/src/main/kotlin/AboutLibrariesConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AboutLibrariesConventionPlugin.kt index 730b7da506..7d197a54bd 100644 --- a/build-logic/convention/src/main/kotlin/AboutLibrariesConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AboutLibrariesConventionPlugin.kt @@ -29,7 +29,7 @@ class AboutLibrariesConventionPlugin : Plugin { pluginManager.apply(libs.plugin("aboutlibraries").get().pluginId) extensions.configure { - // 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 { // 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") } } diff --git a/docs/en/developer/testing.md b/docs/en/developer/testing.md index 19b6d88c54..ff36aeb454 100644 --- a/docs/en/developer/testing.md +++ b/docs/en/developer/testing.md @@ -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/` 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 diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 30b59945c8..5224130373 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -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:" + lane :upload_internal do |options| + UI.user_error!("Pass the bundle to upload as aab:") 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: out:. Reads only; the edit is discarded" + lane :play_track_releases do |options| + UI.user_error!("Pass track: and out:") 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 diff --git a/fastlane/README.md b/fastlane/README.md index f248380e91..bfb784449e 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -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: ### 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: out:. Reads only; the edit is discarded ---- diff --git a/scripts/verify-flatpak/README.md b/scripts/verify-flatpak/README.md index 7a8120253e..0253f42808 100644 --- a/scripts/verify-flatpak/README.md +++ b/scripts/verify-flatpak/README.md @@ -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. diff --git a/scripts/verify-flatpak/bump-flathub-manifest.py b/scripts/verify-flatpak/bump-flathub-manifest.py new file mode 100755 index 0000000000..e346d51ad8 --- /dev/null +++ b/scripts/verify-flatpak/bump-flathub-manifest.py @@ -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 + +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\s+url: https://github\.com/meshtastic/Meshtastic-Android\.git\n" + r"\s+tag: )\S+(?P\n\s+commit: )[0-9a-f]{40}$", + re.MULTILINE, +) +GRADLE_ZIP = re.compile( + r"^(?P\s+url: )https://services\.gradle\.org/distributions/gradle-[^\s/]+\.zip" + r"(?P\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() diff --git a/store-screenshots/build.gradle.kts b/store-screenshots/build.gradle.kts index 236c106b31..e25abfa7ab 100644 --- a/store-screenshots/build.gradle.kts +++ b/store-screenshots/build.gradle.kts @@ -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/. -// .github/workflows/store-screenshots.yml does this on an emulator. +// 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//. +// .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 { diff --git a/store-screenshots/src/main/kotlin/org/meshtastic/storescreenshots/StoreScreenshots.kt b/store-screenshots/src/main/kotlin/org/meshtastic/storescreenshots/StoreScreenshots.kt index de7451b4b9..e9547d95c2 100644 --- a/store-screenshots/src/main/kotlin/org/meshtastic/storescreenshots/StoreScreenshots.kt +++ b/store-screenshots/src/main/kotlin/org/meshtastic/storescreenshots/StoreScreenshots.kt @@ -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`. */