diff --git a/.github/workflows/create-internal-release.yml b/.github/workflows/create-internal-release.yml deleted file mode 100644 index 044ca2745..000000000 --- a/.github/workflows/create-internal-release.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Create Internal Release Tag - -on: - workflow_dispatch: - inputs: - base_version: - description: "Base version to iterate on (e.g. 2.6.7). The next internal iteration will be created for this version." - required: true - dry_run: - description: "If true, calculate but do not push tag" - required: false - default: "false" - -permissions: - contents: write - actions: write - -jobs: - create-internal-tag: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Compute Tag - id: tag - run: | - set -euo pipefail - BASE='${{ inputs.base_version }}' - # Find the highest existing internal tag for this base version and increment it. - EXISTING=$(git tag --list "v${BASE}-internal.*" | sed -E 's/^v.*-internal\.([0-9]+)$/\1/' | sort -n | tail -1 || true) - if [ -z "$EXISTING" ]; then NEXT=1; else NEXT=$((EXISTING+1)); fi - FINAL_TAG="v${BASE}-internal.${NEXT}" - # Check if the tag already exists for some reason (e.g. race condition). - if git tag --list | grep -q "^${FINAL_TAG}$"; then - echo "Tag ${FINAL_TAG} already exists." >&2 - exit 1 - fi - echo "internal_tag=$FINAL_TAG" >> $GITHUB_OUTPUT - - - name: Dry Run Preview - if: ${{ inputs.dry_run == 'true' }} - run: | - echo "DRY RUN: Would create tag ${{ steps.tag.outputs.internal_tag }} pointing to $(git rev-parse HEAD)" - git log -5 --oneline - - - name: Configure Git User - if: ${{ inputs.dry_run != 'true' }} - run: | - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - - - name: Create and Push Tag - if: ${{ inputs.dry_run != 'true' }} - run: | - TAG='${{ steps.tag.outputs.internal_tag }}' - MSG="Internal build iteration for ${TAG}" - git tag -a "$TAG" -m "$MSG" - git push origin "$TAG" - echo "Created and pushed $TAG" - - - name: Trigger Release Workflow for Tag - if: ${{ inputs.dry_run != 'true' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - TAG='${{ steps.tag.outputs.internal_tag }}' - echo "Dispatching release workflow for $TAG" - for i in {1..5}; do - if gh api \ - -X POST \ - -H "Accept: application/vnd.github+json" \ - repos/${{ github.repository }}/actions/workflows/release.yml/dispatches \ - -f ref="$TAG"; then - echo "Triggered release workflow for $TAG" - break - fi - echo "Retry $i/5 in 5s..." - sleep 5 - done - - - name: Output Summary - run: | - echo "### Internal Tag Created" >> $GITHUB_STEP_SUMMARY - echo "Tag: ${{ steps.tag.outputs.internal_tag }}" >> $GITHUB_STEP_SUMMARY - echo "Base Version: ${{ inputs.base_version }}" >> $GITHUB_STEP_SUMMARY - echo "Dry Run: ${{ inputs.dry_run }}" >> $GITHUB_STEP_SUMMARY - if [ "${{ inputs.dry_run }}" != "true" ]; then - echo "Release workflow dispatched for tag ${{ steps.tag.outputs.internal_tag }}" >> $GITHUB_STEP_SUMMARY - fi diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml deleted file mode 100644 index da46b680a..000000000 --- a/.github/workflows/promote-release.yml +++ /dev/null @@ -1,273 +0,0 @@ -name: Promote Release - -on: - workflow_dispatch: - inputs: - target_stage: - description: "Stage to promote to (auto|closed|open|production)" - required: true - default: auto - type: choice - options: [auto, closed, open, production] - base_version: - description: "Explicit base version (e.g. 2.5.0 or 2.5.0-hotfix1). If omitted, latest internal tag base is used." - required: false - allow_skip: - description: "Allow skipping intermediate stages (e.g. internal->production)" - required: false - default: "false" - dry_run: - description: "If true, only compute next tag; don't push" - required: false - default: "false" - -permissions: - contents: write - actions: write - -jobs: - promote: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Determine Base Version - id: base - run: | - set -euo pipefail - INPUT_BASE='${{ inputs.base_version }}' - if [ -n "$INPUT_BASE" ]; then - # Validate an internal tag exists for provided base - if ! git tag --list | grep -q "^v${INPUT_BASE}-internal\."; then - echo "No internal tag found for base version v${INPUT_BASE}." >&2 - exit 1 - fi - BASE_VERSION="$INPUT_BASE" - else - LATEST_INTERNAL_TAG=$(git tag --list 'v*-internal.*' --sort=-taggerdate | head -n1 || true) - if [ -z "$LATEST_INTERNAL_TAG" ]; then - echo "No internal tags found; nothing to promote." >&2 - exit 1 - fi - # Strip leading v and suffix -internal.N - BASE_VERSION=$(echo "$LATEST_INTERNAL_TAG" | sed -E 's/^v(.*)-internal\.[0-9]+$/\1/') - fi - echo "Base version: $BASE_VERSION" - echo "base_version=$BASE_VERSION" >> $GITHUB_OUTPUT - - - name: Gather Existing Stage Tags - id: scan - run: | - set -euo pipefail - BASE='${{ steps.base.outputs.base_version }}' - INTERNAL_TAGS=$(git tag --list "v${BASE}-internal.*" | sort -V || true) - CLOSED_TAGS=$(git tag --list "v${BASE}-closed.*" | sort -V || true) - OPEN_TAGS=$(git tag --list "v${BASE}-open.*" | sort -V || true) - PROD_TAG=$(git tag --list "v${BASE}" || true) - echo "internal_tags<> $GITHUB_OUTPUT - echo "$INTERNAL_TAGS" >> $GITHUB_OUTPUT - echo EOF >> $GITHUB_OUTPUT - echo "closed_tags<> $GITHUB_OUTPUT - echo "$CLOSED_TAGS" >> $GITHUB_OUTPUT - echo EOF >> $GITHUB_OUTPUT - echo "open_tags<> $GITHUB_OUTPUT - echo "$OPEN_TAGS" >> $GITHUB_OUTPUT - echo EOF >> $GITHUB_OUTPUT - if [ -n "$PROD_TAG" ]; then echo "production_present=true" >> $GITHUB_OUTPUT; else echo "production_present=false" >> $GITHUB_OUTPUT; fi - if [ -z "$INTERNAL_TAGS" ]; then - echo "No internal tags found for base version $BASE." >&2 - exit 1 - fi - - - name: Determine Current Stage - id: current - run: | - set -euo pipefail - PROD='${{ steps.scan.outputs.production_present }}' - CLOSED='${{ steps.scan.outputs.closed_tags }}' - OPEN='${{ steps.scan.outputs.open_tags }}' - if [ "$PROD" = 'true' ]; then CUR=production - elif [ -n "$OPEN" ]; then CUR=open - elif [ -n "$CLOSED" ]; then CUR=closed - else CUR=internal; fi - echo "Current highest stage: $CUR" - echo "current_stage=$CUR" >> $GITHUB_OUTPUT - - - name: Decide Target Stage - id: decide - run: | - set -euo pipefail - REQ='${{ inputs.target_stage }}' - CUR='${{ steps.current.outputs.current_stage }}' - ALLOW_SKIP='${{ inputs.allow_skip }}' - BASE='${{ steps.base.outputs.base_version }}' - order=(internal closed open production) - # helper to get index - idx() { local i=0; for s in "${order[@]}"; do [ "$s" = "$1" ] && echo $i && return; i=$((i+1)); done; echo -1; } - - # default outputs - TARGET_STAGE="" - - if [ "$REQ" = auto ]; then - CUR_IDX=$(idx "$CUR") - # Auto supports same-stage increments for closed/open when a newer internal exists - if [ "$CUR" = closed ] || [ "$CUR" = open ]; then - LATEST_INTERNAL=$(git tag --list "v${BASE}-internal.*" --sort=-version:refname | head -n1 || true) - if [ -n "$LATEST_INTERNAL" ]; then - INTERNAL_COMMIT=$(git rev-list -n1 "$LATEST_INTERNAL") - LATEST_CUR_TAG=$(git tag --list "v${BASE}-${CUR}.*" --sort=-version:refname | head -n1 || true) - if [ -n "$LATEST_CUR_TAG" ]; then - CUR_COMMIT=$(git rev-list -n1 "$LATEST_CUR_TAG") - if [ "$INTERNAL_COMMIT" != "$CUR_COMMIT" ]; then - TARGET_STAGE="$CUR" - fi - fi - fi - fi - if [ -z "$TARGET_STAGE" ]; then - TARGET_IDX=$((CUR_IDX+1)) - TARGET_STAGE=${order[$TARGET_IDX]:-} - if [ -z "$TARGET_STAGE" ]; then - echo "Already at production; nothing to promote." >&2 - exit 1 - fi - fi - else - TARGET_STAGE=$REQ - CUR_IDX=$(idx "$CUR") - REQ_IDX=$(idx "$TARGET_STAGE") - - if [ "$TARGET_STAGE" = "$CUR" ]; then - # Same-stage request. Allow closed/open re-promotion only if a newer internal commit exists. - if [ "$TARGET_STAGE" = production ]; then - echo "Cannot re-promote to production for the same base; production tag v${BASE} is unique." >&2 - exit 1 - fi - # Compute latest internal commit for base - LATEST_INTERNAL=$(git tag --list "v${BASE}-internal.*" --sort=-version:refname | head -n1 || true) - if [ -z "$LATEST_INTERNAL" ]; then - echo "No internal tag found for base $BASE (unexpected)." >&2 - exit 1 - fi - INTERNAL_COMMIT=$(git rev-list -n1 "$LATEST_INTERNAL") - # Compute latest tag commit for this stage - LATEST_STAGE_TAG=$(git tag --list "v${BASE}-${TARGET_STAGE}.*" --sort=-version:refname | head -n1 || true) - if [ -z "$LATEST_STAGE_TAG" ]; then - # No prior tag for this stage even though CUR==TARGET_STAGE; proceed to create .1 normally - : - else - STAGE_COMMIT=$(git rev-list -n1 "$LATEST_STAGE_TAG") - if [ "$INTERNAL_COMMIT" = "$STAGE_COMMIT" ]; then - echo "Requested re-promotion to $TARGET_STAGE but latest internal commit matches latest ${TARGET_STAGE} tag ($LATEST_STAGE_TAG). Nothing new to promote." >&2 - exit 1 - fi - fi - # Allowed: TARGET_STAGE remains as requested (closed/open) - else - # Different stage request; must be ahead of current unless allow_skip permits skipping - if [ $REQ_IDX -le $CUR_IDX ]; then - echo "Requested stage $TARGET_STAGE is not ahead of current stage $CUR." >&2 - exit 1 - fi - if [ "$ALLOW_SKIP" != 'true' ] && [ $((CUR_IDX+1)) -ne $REQ_IDX ]; then - echo "Skipping stages not allowed (current=$CUR, requested=$TARGET_STAGE). Enable allow_skip to override." >&2 - exit 1 - fi - fi - fi - - echo "Target stage: $TARGET_STAGE" - echo "target_stage=$TARGET_STAGE" >> $GITHUB_OUTPUT - - - name: Compute New Tag - id: tag - run: | - set -euo pipefail - BASE='${{ steps.base.outputs.base_version }}' - TARGET='${{ steps.decide.outputs.target_stage }}' - if [ "$TARGET" = production ]; then - NEW_TAG="v${BASE}" - if git tag --list | grep -q "^${NEW_TAG}$"; then - echo "Production tag ${NEW_TAG} already exists." >&2 - exit 1 - fi - else - EXISTING=$(git tag --list "v${BASE}-${TARGET}.*" | sed -E "s/^v.*-${TARGET}\.([0-9]+)$/\1/" | sort -n | tail -1 || true) - if [ -z "$EXISTING" ]; then NEXT=1; else NEXT=$((EXISTING+1)); fi - NEW_TAG="v${BASE}-${TARGET}.${NEXT}" - fi - echo "new_tag=$NEW_TAG" >> $GITHUB_OUTPUT - echo "Will create tag: $NEW_TAG" - - - name: Resolve Commit to Tag (latest internal for base) - id: commit - run: | - set -euo pipefail - BASE='${{ steps.base.outputs.base_version }}' - LATEST_INTERNAL=$(git tag --list "v${BASE}-internal.*" --sort=-version:refname | head -n1) - if [ -z "$LATEST_INTERNAL" ]; then - echo "No internal tag found for base $BASE (unexpected)." >&2 - exit 1 - fi - COMMIT=$(git rev-list -n1 "$LATEST_INTERNAL") - echo "commit_sha=$COMMIT" >> $GITHUB_OUTPUT - echo "Using commit $COMMIT from $LATEST_INTERNAL" - - - name: Dry Run Summary - if: ${{ inputs.dry_run == 'true' }} - run: | - echo "DRY RUN: Would tag commit ${{ steps.commit.outputs.commit_sha }} with ${{ steps.tag.outputs.new_tag }}" - echo "Current stage: ${{ steps.current.outputs.current_stage }} -> Target: ${{ steps.decide.outputs.target_stage }}" - git log -1 --oneline ${{ steps.commit.outputs.commit_sha }} - - - name: Configure Git User - if: ${{ inputs.dry_run != 'true' }} - run: | - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - - - name: Create & Push Tag - if: ${{ inputs.dry_run != 'true' }} - run: | - TAG='${{ steps.tag.outputs.new_tag }}' - COMMIT='${{ steps.commit.outputs.commit_sha }}' - MSG="Promote ${TAG} from ${{ steps.current.outputs.current_stage }} to ${{ steps.decide.outputs.target_stage }}" - git tag -a "$TAG" "$COMMIT" -m "$MSG" - git push origin "$TAG" - echo "Created and pushed $TAG" - - - name: Trigger Release Workflow for Tag - if: ${{ inputs.dry_run != 'true' }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - TAG='${{ steps.tag.outputs.new_tag }}' - echo "Dispatching release workflow for $TAG" - for i in {1..5}; do - if gh api \ - -X POST \ - -H "Accept: application/vnd.github+json" \ - repos/${{ github.repository }}/actions/workflows/release.yml/dispatches \ - -f ref="$TAG"; then - echo "Triggered release workflow for $TAG" - break - fi - echo "Retry $i/5 in 5s..." - sleep 5 - done - - - name: Promotion Summary - run: | - echo "### Promotion Tag Created" >> $GITHUB_STEP_SUMMARY - echo "Base Version: ${{ steps.base.outputs.base_version }}" >> $GITHUB_STEP_SUMMARY - echo "Current Stage: ${{ steps.current.outputs.current_stage }}" >> $GITHUB_STEP_SUMMARY - echo "Target Stage: ${{ steps.decide.outputs.target_stage }}" >> $GITHUB_STEP_SUMMARY - echo "New Tag: ${{ steps.tag.outputs.new_tag }}" >> $GITHUB_STEP_SUMMARY - echo "Dry Run: ${{ inputs.dry_run }}" >> $GITHUB_STEP_SUMMARY - if [ "${{ inputs.dry_run }}" != "true" ]; then - echo "Release workflow dispatched for tag ${{ steps.tag.outputs.new_tag }}" >> $GITHUB_STEP_SUMMARY - fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4eb49735a..06d052739 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,6 @@ jobs: outputs: APP_VERSION_NAME: ${{ steps.get_version_name.outputs.APP_VERSION_NAME }} APP_VERSION_CODE: ${{ steps.calculate_version_code.outputs.versionCode }} - BASE_TAG: ${{ steps.get_base_tag.outputs.BASE_TAG }} steps: - name: Checkout code uses: actions/checkout@v5 @@ -46,12 +45,6 @@ jobs: id: get_version_name run: echo "APP_VERSION_NAME=$(echo ${GITHUB_REF_NAME#v} | sed 's/-.*//')" >> $GITHUB_OUTPUT - - name: Get Base Tag (for release/artifact naming) - id: get_base_tag - run: | - VERSION_NAME=$(echo ${GITHUB_REF_NAME#v} | sed 's/-.*//') - echo "BASE_TAG=v${VERSION_NAME}" >> $GITHUB_OUTPUT - - name: Extract VERSION_CODE_OFFSET from config.properties id: get_version_code_offset run: | @@ -66,78 +59,11 @@ jobs: VERSION_CODE=$((COMMIT_COUNT + OFFSET)) echo "versionCode=$VERSION_CODE" >> $GITHUB_OUTPUT shell: bash - - prepare-release-environment: - runs-on: ubuntu-latest - needs: prepare-build-info - outputs: - exists: ${{ steps.check_and_clean.outputs.exists }} - steps: - - name: Checkout code - uses: actions/checkout@v5 - - name: Check for Existing Release and Clean if Superseded - id: check_and_clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - BASE_TAG=${{ needs.prepare-build-info.outputs.BASE_TAG }} - COMMIT_SHA=$(git rev-parse HEAD) - RELEASE_INFO=$(gh release view $BASE_TAG --json targetCommitish -q ".targetCommitish.oid" || echo "") - if [ -z "$RELEASE_INFO" ]; then - echo "No existing release for tag '${BASE_TAG}'. Starting fresh." - echo "exists=false" >> $GITHUB_OUTPUT - elif [ "$RELEASE_INFO" == "$COMMIT_SHA" ]; then - echo "Existing release for '${BASE_TAG}' found on the current commit. This is a promotion." - echo "exists=true" >> $GITHUB_OUTPUT - else - echo "Existing release for '${BASE_TAG}' found on a DIFFERENT commit ($RELEASE_INFO)." - echo "This new tag supersedes the old one. Deleting old release and tag to restart the process." - gh release delete $BASE_TAG --cleanup-tag --yes || echo "Could not delete release. It might have been deleted already." - echo "Old release and tag deleted. A new build will be created." - echo "exists=false" >> $GITHUB_OUTPUT - fi - - check-versioncode-google-play: - runs-on: ubuntu-latest - needs: prepare-build-info - outputs: - exists: ${{ steps.check_versioncode.outputs.exists }} - steps: - - name: Checkout code - uses: actions/checkout@v5 - - name: Setup Fastlane - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.2' - bundler-cache: true - - name: Check if versionCode exists on Google Play Internal Track - id: check_versioncode - env: - VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }} - GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }} - run: | - set -euo pipefail - echo "$GOOGLE_PLAY_JSON_KEY" > /tmp/play-store-credentials.json - exists=false - if bundle exec fastlane supply --help | grep -q -- '--json'; then - JSON=$(bundle exec fastlane supply --json_key /tmp/play-store-credentials.json --package_name com.geeksville.mesh --track internal --json || true) - if echo "$JSON" | jq -e ".tracks.internal.releases[].versionCodes[] | select(. == ${VERSION_CODE})" >/dev/null 2>&1; then - exists=true - fi - else - # Fallback: use fastlane action output and parse array from stdout - OUTFILE=$(mktemp) - bundle exec fastlane run google_play_track_version_codes json_key:/tmp/play-store-credentials.json package_name:com.geeksville.mesh track:internal --capture_output | tee "$OUTFILE" || true - ARR=$(grep -oE '\\[[^]]*\\]' "$OUTFILE" | head -n1) - if echo "$ARR" | tr -d '[] ' | tr ',' '\n' | grep -x "${VERSION_CODE}" >/dev/null 2>&1; then - exists=true - fi - fi - echo "exists=${exists}" >> $GITHUB_OUTPUT + # This matches the reproducible versionCode strategy: versionCode = git commit count + offset release-google: runs-on: ubuntu-latest - needs: [prepare-build-info, prepare-release-environment, check-versioncode-google-play] + needs: prepare-build-info steps: - name: Checkout code uses: actions/checkout@v5 @@ -150,6 +76,7 @@ jobs: java-version: '21' distribution: 'jetbrains' - name: Setup Gradle + if: contains(github.ref_name, '-internal') uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} @@ -183,26 +110,13 @@ jobs: ruby-version: '3.2' bundler-cache: true - - name: Build and Deploy to Internal Track - if: contains(github.ref_name, '-internal') && needs.prepare-release-environment.outputs.exists == 'false' && needs.check-versioncode-google-play.outputs.exists == 'false' - 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 - - - name: Build Google artifacts without upload - if: contains(github.ref_name, '-internal') && needs.prepare-release-environment.outputs.exists == 'false' && needs.check-versioncode-google-play.outputs.exists == 'true' - env: - VERSION_NAME: ${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} - VERSION_CODE: ${{ needs.prepare-build-info.outputs.APP_VERSION_CODE }} - run: ./gradlew clean bundleGoogleRelease assembleGoogleRelease -Pandroid.injected.version.name=${VERSION_NAME} -Pandroid.injected.version.code=${VERSION_CODE} - - - name: Determine Fastlane Promotion Lane + - name: Determine Fastlane Lane id: fastlane_lane - if: "!contains(github.ref_name, '-internal')" run: | TAG_NAME="${{ github.ref_name }}" - if [[ "$TAG_NAME" == *"-closed"* ]]; then + if [[ "$TAG_NAME" == *"-internal"* ]]; then + echo "lane=internal" >> $GITHUB_OUTPUT + elif [[ "$TAG_NAME" == *"-closed"* ]]; then echo "lane=closed" >> $GITHUB_OUTPUT elif [[ "$TAG_NAME" == *"-open"* ]]; then echo "lane=open" >> $GITHUB_OUTPUT @@ -210,15 +124,14 @@ jobs: echo "lane=production" >> $GITHUB_OUTPUT fi - - name: Promote on Google Play - if: "steps.fastlane_lane.outputs.lane != '' && needs.check-versioncode-google-play.outputs.exists == 'true'" + - name: Build and Deploy Google Play Tracks with Fastlane 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 ${{ steps.fastlane_lane.outputs.lane }} - name: Upload Google AAB artifact - if: needs.prepare-release-environment.outputs.exists == 'false' && contains(github.ref_name, '-internal') + if: contains(github.ref_name, '-internal') uses: actions/upload-artifact@v4 with: name: google-aab @@ -226,7 +139,7 @@ jobs: retention-days: 1 - name: Upload Google APK artifact - if: needs.prepare-release-environment.outputs.exists == 'false' && contains(github.ref_name, '-internal') + if: contains(github.ref_name, '-internal') uses: actions/upload-artifact@v4 with: name: google-apk @@ -234,7 +147,7 @@ jobs: retention-days: 1 - name: Attest Google artifacts provenance - if: needs.prepare-release-environment.outputs.exists == 'false' && contains(github.ref_name, '-internal') + if: contains(github.ref_name, '-internal') uses: actions/attest-build-provenance@v3 with: subject-path: | @@ -242,9 +155,9 @@ jobs: app/build/outputs/apk/google/release/app-google-release.apk release-fdroid: - if: contains(github.ref_name, '-internal') && needs.prepare-release-environment.outputs.exists == 'false' && needs.check-versioncode-google-play.outputs.exists == 'false' + if: contains(github.ref_name, '-internal') runs-on: ubuntu-latest - needs: [prepare-build-info, prepare-release-environment, check-versioncode-google-play] + needs: prepare-build-info steps: - name: Checkout code uses: actions/checkout@v5 @@ -297,22 +210,21 @@ jobs: with: subject-path: app/build/outputs/apk/fdroid/release/app-fdroid-release.apk - manage-github-release: + create-internal-release: runs-on: ubuntu-latest - needs: [prepare-build-info, prepare-release-environment, check-versioncode-google-play, release-google, release-fdroid] + needs: [prepare-build-info, release-google, release-fdroid] + if: contains(github.ref_name, '-internal') steps: - name: Download all artifacts - if: needs.prepare-release-environment.outputs.exists == 'false' uses: actions/download-artifact@v5 with: path: ./artifacts - name: Create GitHub Release - if: needs.prepare-release-environment.outputs.exists == 'false' uses: softprops/action-gh-release@v2 with: - tag_name: ${{ needs.prepare-build-info.outputs.BASE_TAG }} - name: v${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }}-internal + tag_name: v${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} + name: ${{ github.ref_name }} generate_release_notes: true files: ./artifacts/*/* draft: true @@ -320,13 +232,32 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Promote GitHub Release - if: "!contains(github.ref_name, '-internal')" + promote-release: + runs-on: ubuntu-latest + needs: [prepare-build-info, release-google] + if: "!contains(github.ref_name, '-internal')" + steps: + - name: Determine Release Properties + id: release_properties + run: | + TAG_NAME="${{ github.ref_name }}" + if [[ "$TAG_NAME" == *"-closed"* ]]; then + echo "draft=false" >> $GITHUB_OUTPUT + echo "prerelease=true" >> $GITHUB_OUTPUT + elif [[ "$TAG_NAME" == *"-open"* ]]; then + echo "draft=false" >> $GITHUB_OUTPUT + echo "prerelease=true" >> $GITHUB_OUTPUT + else + echo "draft=false" >> $GITHUB_OUTPUT + echo "prerelease=false" >> $GITHUB_OUTPUT + fi + + - name: Update GitHub Release uses: softprops/action-gh-release@v2 with: - tag_name: ${{ needs.prepare-build-info.outputs.BASE_TAG }} + tag_name: v${{ needs.prepare-build-info.outputs.APP_VERSION_NAME }} name: ${{ github.ref_name }} - draft: false - prerelease: ${{ contains(github.ref_name, '-closed') || contains(github.ref_name, '-open') }} + draft: ${{ steps.release_properties.outputs.draft }} + prerelease: ${{ steps.release_properties.outputs.prerelease }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md index 275e17e43..002ee6821 100644 --- a/RELEASE_PROCESS.md +++ b/RELEASE_PROCESS.md @@ -1,70 +1,58 @@ -# Release Process +# Meshtastic-Android Release Process (Condensed) -This document outlines the process for creating and promoting releases for the Meshtastic Android application. The system is designed to be robust, auditable, and highly automated, using a combination of user-facing GitHub Actions "wizards" and a central, tag-triggered "engine". +This guide summarizes the steps for releasing a new version of Meshtastic-Android. The process is automated via GitHub Actions and Fastlane, triggered by pushing a Git tag from a `release/*` branch. -## Philosophy +## Overview +- **Tagging:** Push a tag (`vX.X.X[-track.Y]`) from a `release/*` branch to start the release workflow. +- **CI Automation:** Builds both flavors, uploads to Google Play (correct track), and creates/updates a draft GitHub release. +- **Changelog:** Release notes are auto-generated from PR labels via [`.github/release.yml`](.github/release.yml). Label PRs for accurate changelogs. +- **Draft Release:** All tags for the same base version (e.g., `v2.3.5`) update the same draft release. The release title uses the full tag (e.g., `v2.3.5-internal.1`). -- **Git Tag Driven**: The entire release lifecycle is initiated and controlled by pushing version tags to the repository. -- **Automated Engine**: A central workflow (`release.yml`) acts as the engine, listening for new version tags. It handles all the heavy lifting: building, versioning, uploading to Google Play, and managing GitHub Releases. -- **User-Friendly Wizards**: Manually creating tags is discouraged. Instead, two "wizard" workflows (`create-internal-release.yml` and `promote-release.yml`) provide a simple UI in the GitHub Actions tab to guide developers through creating and promoting releases safely. +## Tagging & Tracks +- **Internal:** `vX.X.X-internal.Y` +- **Closed:** `vX.X.X-closed.Y` +- **Open:** `vX.X.X-open.Y` +- **Production:** `vX.X.X` +- Increment `.Y` for fixes/iterations. -## Versioning Scheme +## Release Steps +1. **Branch:** Create `release/X.X.X` from `main`. Only critical fixes allowed. +2. **Tag & Push:** Tag the release commit and push (see below). +3. **CI:** Wait for CI to finish. Artifacts are uploaded, and a draft GitHub release is created/updated. +4. **Verify:** Check Google Play Console and GitHub draft release. +5. **Promote:** Tag the same commit for the next track as needed. +6. **Finalize:** + - **Production:** Publish the GitHub release, then promote in Google Play Console. + - **Other tracks:** Verify with testers. +7. **Merge:** After production, merge `release/X.X.X` back to `main` and delete the branch. -Releases follow a semantic versioning scheme, `vX.Y.Z`, with suffixes to denote the release channel and iteration. +## Tagging Example +```bash +# On release branch +git tag v2.3.5-internal.1 +git push origin v2.3.5-internal.1 +# For fixes: +git tag v2.3.5-internal.2 +git push origin v2.3.5-internal.2 +# Promote: +git tag v2.3.5-closed.1 +git push origin v2.3.5-closed.1 +``` -- `v2.8.0-internal.1`: An internal build, iteration 1. -- `v2.8.0-closed.1`: A closed testing (Alpha) build. -- `v2.8.0-open.1`: An open testing (Beta) build. -- `v2.8.0`: The final production release. +## Manual Checklist +- [ ] Verify build in Google Play Console +- [ ] Review and publish GitHub draft release (for production) +- [ ] Merge release branch to main after production +- [ ] Label PRs for changelog accuracy ---- +## Build Attestations & Provenance -## The Release Lifecycle +All release artifacts are accompanied by explicit GitHub build attestations (provenance). After each artifact is uploaded in the release workflow, a provenance attestation is generated using the `actions/attest-build-provenance` action. This provides cryptographic proof that the artifacts were built by our trusted GitHub Actions workflow, ensuring supply chain integrity and allowing users to verify the origin of each release. -### Step 1: Creating a New Internal Build +- Attestations are generated immediately after each artifact upload in the workflow. +- You can view and verify provenance in the GitHub UI under each release asset. +- For more details, see [GitHub's documentation on build provenance](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#provenance-attestations). -This is the starting point for any new release, whether it's a brand-new version, a patch, or a hotfix. +> **Note:** The GitHub release is always attached to the base version tag (e.g., `v2.3.5`). All track tags for the same version update the same draft release. Look for the draft under the base version tag. -1. Navigate to the **Actions** tab in the GitHub repository. -2. Select the **"Create Internal Release Tag"** workflow. -3. Click **"Run workflow"**. -4. Fill in the `base_version` field with the version you want to create (e.g., `2.8.0`). -5. Run the workflow. - -**What Happens Automatically:** - -- The wizard calculates the next iteration number (e.g., `.1`, `.2`, etc.) and pushes a new tag to the commit (e.g., `v2.8.0-internal.1`). -- The push triggers the `release.yml` engine, which builds the application, uploads it to the Google Play **Internal** track, and creates a corresponding **draft pre-release** on GitHub. - -### Step 2: Promoting an Existing Build - -Once an internal build has been tested and is ready for a wider audience, you promote it. - -1. Navigate to the **Actions** tab in the GitHub repository. -2. Select the **"Promote Release"** workflow. -3. Click **"Run workflow"**. -4. Specify the `target_stage` (`closed`, `open`, or `production`). The default, `auto`, will automatically promote to the next logical stage. -5. Optionally, specify the `base_version` to promote. If left blank, the wizard will find the latest internal tag and use its base version. -6. Run the workflow. - -**What Happens Automatically:** - -- The wizard determines the correct commit from the latest internal tag for that `base_version`. -- It pushes a new promotion tag (e.g., `v2.8.0-closed.1`) to that commit. -- The push triggers the `release.yml` engine. It intelligently **skips the build steps** and proceeds to: - - Promote the build on Google Play to the target track. - - Update the existing draft GitHub Release, renaming it and marking it as a non-draft pre-release (or full release for production). - -### Special Case: Hotfixes / Superseding a Release - -The system is designed to handle hotfixes gracefully. If `v2.8.0-internal.1` has been created, but a critical bug is found, the process is simple: - -1. Merge the fix into your main branch. -2. Go to the **"Create Internal Release Tag"** workflow again. -3. Enter the *same* `base_version`: `2.8.0`. - -**What Happens Automatically:** - -- The wizard creates and pushes a new tag, `v2.8.0-internal.2`, to the **new commit**. -- The `release.yml` engine detects that an existing release for `v2.8.0` points to an *older* commit. -- It correctly interprets this as a "superseding" event. It **automatically deletes the old GitHub release and its base tag**, effectively restarting the release process for `v2.8.0` from the new, corrected commit. This prevents a broken or outdated build from ever being promoted. +> **Best Practice:** Always promote the last verified build from the previous track to the next track. Do not introduce new changes between tracks unless absolutely necessary. This ensures consistency, traceability, and minimizes risk. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ed916dd7e..e5702f712 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,7 +15,6 @@ * along with this program. If not, see . */ -import com.datadog.gradle.plugin.InstrumentationMode import com.geeksville.mesh.buildlogic.GitVersionValueSource import java.io.FileInputStream import java.util.Properties @@ -257,7 +256,3 @@ dokka { maxHeapSize = "6g" } } - -datadog { - composeInstrumentation = InstrumentationMode.AUTO -} diff --git a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt b/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt index abe12c2fc..def90b1d7 100644 --- a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt +++ b/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt @@ -76,13 +76,11 @@ constructor( analyticsPrefs: AnalyticsPrefs, ) : PlatformAnalytics { - private val sampleRate = - 100f.takeIf { BuildConfig.DEBUG } ?: 10f // For Datadog remote sample rate + private val sampleRate = 100f.takeIf { BuildConfig.DEBUG } ?: 10f // For Datadog remote sample rate private val isInTestLab: Boolean get() { - val testLabSetting = - Settings.System.getString(context.contentResolver, "firebase.test.lab") + val testLabSetting = Settings.System.getString(context.contentResolver, "firebase.test.lab") return "true" == testLabSetting } @@ -187,8 +185,7 @@ constructor( override fun setDeviceAttributes(firmwareVersion: String, model: String) { if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return - GlobalRumMonitor.get() - .addAttribute("firmware_version", firmwareVersion.extractSemanticVersion()) + GlobalRumMonitor.get().addAttribute("firmware_version", firmwareVersion.extractSemanticVersion()) GlobalRumMonitor.get().addAttribute("device_hardware", model) } @@ -243,8 +240,7 @@ constructor( private fun String.extractSemanticVersion(): String { val regex = "^(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?".toRegex() val matchResult = regex.find(this) - return matchResult?.groupValues?.drop(1)?.filter { it.isNotEmpty() }?.joinToString(".") - ?: this + return matchResult?.groupValues?.drop(1)?.filter { it.isNotEmpty() }?.joinToString(".") ?: this } override fun track(event: String, vararg properties: DataPair) { @@ -253,16 +249,10 @@ constructor( when (it.value) { is Double -> bundle.putDouble(it.name, it.value) is Int -> - bundle.putLong( - it.name, - it.value.toLong() - ) // Firebase expects Long for integer values in bundles + bundle.putLong(it.name, it.value.toLong()) // Firebase expects Long for integer values in bundles is Long -> bundle.putLong(it.name, it.value) is Float -> bundle.putDouble(it.name, it.value.toDouble()) - is String -> bundle.putString( - it.name, - it.value as String? - ) // Explicitly handle String + is String -> bundle.putString(it.name, it.value as String?) // Explicitly handle String else -> bundle.putString(it.name, it.value.toString()) // Fallback for other types } Timber.tag(TAG).d("Analytics: track $event (${it.name} : ${it.value})") diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 1fb5e8053..17706b512 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -23,33 +23,10 @@ platform :android do desc "Deploy a new version to the internal track on Google Play" lane :internal do - begin - aab_path = build_google_release - upload_to_play_store( - track: 'internal', - aab: aab_path, - release_status: 'completed', - skip_upload_apk: true, - skip_upload_metadata: true, - skip_upload_changelogs: true, - skip_upload_images: true, - skip_upload_screenshots: true, - ) - rescue => exception - if exception.message.include?("Google Api Error: forbidden: A release with version code") && exception.message.include?("already exists for this track") - UI.message("This version code is already on the internal track. No action needed.") - else - raise exception - end - end - end - - desc "Promote from internal track to the closed track on Google Play" - lane :closed do + aab_path = build_google_release upload_to_play_store( track: 'internal', - track_promote_to: 'NewAlpha', - version_codes: [ENV["VERSION_CODE"].to_i], + aab: aab_path, release_status: 'completed', skip_upload_apk: true, skip_upload_metadata: true, @@ -59,12 +36,25 @@ platform :android do ) end - desc "Promote from internal track to the open track on Google Play" - lane :open do + desc "Promote from internal track to the closed track on Google Play" + lane :closed do upload_to_play_store( track: 'internal', + track_promote_to: 'NewAlpha', + release_status: 'completed', + skip_upload_apk: true, + skip_upload_metadata: true, + skip_upload_changelogs: true, + skip_upload_images: true, + skip_upload_screenshots: true, + ) + end + + desc "Promote from closed track to the open track on Google Play" + lane :open do + upload_to_play_store( + track: 'NewAlpha', track_promote_to: 'beta', - version_codes: [ENV["VERSION_CODE"].to_i], release_status: 'draft', skip_upload_apk: true, skip_upload_metadata: true, @@ -74,12 +64,11 @@ platform :android do ) end - desc "Promote from internal track to the production track on Google Play" + desc "Promote from open track to the production track on Google Play" lane :production do upload_to_play_store( - track: 'internal', + track: 'open', track_promote_to: 'production', - version_codes: [ENV["VERSION_CODE"].to_i], release_status: 'draft', skip_upload_apk: true, skip_upload_metadata: true, @@ -100,24 +89,6 @@ platform :android do ) end - desc "Get the highest version code from all Google Play tracks" - lane :get_highest_version_code do - require 'set' - all_codes = Set.new - tracks = ['internal', 'alpha', 'beta', 'production'] - tracks.each do |track| - begin - codes = google_play_track_version_codes(track: track) - all_codes.merge(codes.map(&:to_i)) if codes - rescue => e - UI.message("Could not fetch version codes for track #{track}: #{e.message}") - end - end - highest = all_codes.max || 0 - UI.message("Highest version code on Google Play: #{highest}") - File.write('highest_version_code.txt', highest.to_s) - end - private_lane :build_google_release do gradle( task: "clean bundleGoogleRelease assembleGoogleRelease",