Build and Release #684
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build and Release | |
| on: | |
| push: | |
| tags: | |
| - "v*.*.*" # validated by set-metadata | |
| schedule: | |
| - cron: "0 4 * * *" | |
| workflow_dispatch: | |
| inputs: | |
| build_type: | |
| description: "Build type" | |
| required: false | |
| type: choice | |
| options: | |
| - nightly | |
| default: nightly | |
| platforms: | |
| description: "Platforms to build ('all', or 'macos,linux,android,ios,windows')" | |
| required: false | |
| type: string | |
| default: "linux" | |
| sign_windows: | |
| description: "Sign Windows binaries (nightly skips signing by default)" | |
| required: false | |
| type: boolean | |
| default: false | |
| linux_arch: | |
| description: "Linux arch to build when Linux is included" | |
| required: false | |
| type: choice | |
| options: | |
| - all | |
| - amd64 | |
| - arm64 | |
| default: all | |
| cleanup_on_failure: | |
| description: "Delete draft release on failure" | |
| required: false | |
| type: boolean | |
| default: true | |
| windows_connect_smoke: | |
| description: "Run Windows connect/disconnect smoke tests on manual dispatch" | |
| required: false | |
| type: boolean | |
| default: true | |
| publish_mobile_stores: | |
| description: "Upload nightly Android/iOS artifacts to Play internal/TestFlight (default-branch dispatch only)" | |
| required: false | |
| type: boolean | |
| default: false | |
| stealth_leakage_mode: | |
| description: "Run Android stealth leakage scanner for manual Android builds" | |
| required: false | |
| type: choice | |
| options: | |
| - none | |
| - stealth | |
| - stealth-novpn | |
| default: none | |
| env: | |
| # Scheduled nightly store uploads run on these UTC weekdays. Configure via | |
| # repo variable without changing workflow logic; accepts names or ISO numbers. | |
| NIGHTLY_MOBILE_STORE_PUBLISH_DAYS_UTC: ${{ vars.NIGHTLY_MOBILE_STORE_PUBLISH_DAYS_UTC || 'mon,thu' }} | |
| NIGHTLY_GOOGLE_PLAY_TRACK: ${{ vars.NIGHTLY_GOOGLE_PLAY_TRACK || 'internal' }} | |
| permissions: | |
| contents: write | |
| id-token: "write" | |
| concurrency: | |
| group: ${{ github.ref }} | |
| cancel-in-progress: false | |
| jobs: | |
| set-metadata: | |
| # Allow tag-push releases, scheduled builds from default branch, | |
| # and workflow_dispatch from any branch (non-main branches create | |
| # draft releases that are immediately deleted for testing). | |
| if: ${{ startsWith(github.ref, 'refs/tags/') || (github.event_name == 'schedule' && github.ref_name == github.event.repository.default_branch) || github.event_name == 'workflow_dispatch' }} | |
| runs-on: ubuntu-latest | |
| outputs: | |
| build_type: ${{ steps.meta.outputs.build_type }} | |
| release_tag: ${{ steps.meta.outputs.release_tag }} | |
| version: ${{ steps.meta.outputs.version }} | |
| installer_base_name: ${{ steps.meta.outputs.installer_base_name }} | |
| platform: ${{ steps.meta.outputs.platform }} | |
| linux_arch: ${{ steps.meta.outputs.linux_arch }} | |
| is_test_run: ${{ steps.meta.outputs.is_test_run }} | |
| publish_mobile_stores: ${{ steps.meta.outputs.publish_mobile_stores }} | |
| smoke_enable_ip_check: ${{ steps.meta.outputs.smoke_enable_ip_check }} | |
| stealth_leakage_mode: ${{ steps.meta.outputs.stealth_leakage_mode }} | |
| steps: | |
| - name: Checkout repo | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.ref }} | |
| # For push-tag releases, avoid shallow checkout here so the full | |
| # tag set is available during validation. | |
| fetch-depth: 0 | |
| fetch-tags: true | |
| - id: meta | |
| shell: bash | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| REQUESTED_STEALTH_LEAKAGE_MODE: ${{ github.event.inputs.stealth_leakage_mode }} | |
| run: | | |
| EVENT="${{ github.event_name }}" | |
| REQUEST_MOBILE_STORE_PUBLISH="false" | |
| normalize_publish_day() { | |
| local day="$1" | |
| day="${day#"${day%%[![:space:]]*}"}" | |
| day="${day%"${day##*[![:space:]]}"}" | |
| day="$(echo "$day" | tr '[:upper:]' '[:lower:]')" | |
| case "$day" in | |
| 1|mon|monday) echo "1" ;; | |
| 2|tue|tues|tuesday) echo "2" ;; | |
| 3|wed|wednesday) echo "3" ;; | |
| 4|thu|thur|thurs|thursday) echo "4" ;; | |
| 5|fri|friday) echo "5" ;; | |
| 6|sat|saturday) echo "6" ;; | |
| 7|sun|sunday) echo "7" ;; | |
| *) echo "" ;; | |
| esac | |
| } | |
| is_mobile_store_publish_day() { | |
| local today="$1" | |
| local days="$2" | |
| local part normalized | |
| IFS=',' read -ra parts <<< "$days" | |
| for part in "${parts[@]}"; do | |
| normalized="$(normalize_publish_day "$part")" | |
| if [[ -z "$normalized" ]]; then | |
| echo "Error: invalid NIGHTLY_MOBILE_STORE_PUBLISH_DAYS_UTC entry '$part'" >&2 | |
| echo "Use comma-separated weekdays like 'mon,thu' or ISO weekday numbers like '1,4'." >&2 | |
| exit 1 | |
| fi | |
| if [[ "$normalized" == "$today" ]]; then | |
| return 0 | |
| fi | |
| done | |
| return 1 | |
| } | |
| case "$EVENT" in | |
| schedule) | |
| # Scheduled nightly build | |
| BUILD_TYPE="nightly" | |
| VERSION=$(./scripts/ci/version.sh generate nightly) | |
| RELEASE_TAG="v${VERSION}" | |
| PLATFORM="all" | |
| LINUX_ARCH="all" | |
| if is_mobile_store_publish_day "$(date -u +%u)" "$NIGHTLY_MOBILE_STORE_PUBLISH_DAYS_UTC"; then | |
| REQUEST_MOBILE_STORE_PUBLISH="true" | |
| fi | |
| ;; | |
| workflow_dispatch) | |
| # Manual trigger | |
| BUILD_TYPE="${{ github.event.inputs.build_type }}" | |
| PLATFORM="${{ github.event.inputs.platforms }}" | |
| LINUX_ARCH="${{ github.event.inputs.linux_arch }}" | |
| REQUEST_MOBILE_STORE_PUBLISH="${{ github.event.inputs.publish_mobile_stores }}" | |
| case "$BUILD_TYPE" in | |
| nightly) | |
| VERSION=$(./scripts/ci/version.sh generate "$BUILD_TYPE") | |
| RELEASE_TAG="v${VERSION}" | |
| ;; | |
| beta|production) | |
| echo "Error: $BUILD_TYPE builds must use git tags, not manual dispatch" >&2 | |
| echo " Push a tag like 'v1.2.3-beta' (beta) or 'v1.2.3' (production) instead." >&2 | |
| exit 1 | |
| ;; | |
| *) | |
| echo "Error: Unknown build_type '$BUILD_TYPE'" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| ;; | |
| push) | |
| # Tag push (release build) - preserve original tag with 'v' prefix | |
| RELEASE_TAG=${GITHUB_REF#refs/tags/} | |
| # Validate the tag (version.sh strips 'v' for validation) | |
| ./scripts/ci/version.sh validate "$RELEASE_TAG" > /dev/null | |
| # Strip 'v' for VERSION (used by build workflows) | |
| VERSION="${RELEASE_TAG#v}" | |
| case "$RELEASE_TAG" in | |
| *-beta*) | |
| BUILD_TYPE="beta" | |
| ;; | |
| *) | |
| # Production: bare version or version with platform suffix | |
| # e.g., v9.0.15 (all platforms) or v9.0.15-android (single platform) | |
| if [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(windows|macos|linux|android|ios))?$ ]]; then | |
| BUILD_TYPE="production" | |
| else | |
| echo "Error: Invalid tag format for build" >&2 | |
| echo " Use 'v1.0.0' for production (all platforms)" >&2 | |
| echo " Use 'v1.0.0-<platform>' for production (single platform)" >&2 | |
| echo " Use 'v1.0.0-beta' for beta" >&2 | |
| echo "Got: $RELEASE_TAG" >&2 | |
| exit 1 | |
| fi | |
| ;; | |
| esac | |
| # Extract platform suffix (if any) | |
| platform_suffix="${RELEASE_TAG##*-}" | |
| case "$platform_suffix" in | |
| windows) PLATFORM="windows" ;; | |
| macos) PLATFORM="macos" ;; | |
| linux) PLATFORM="linux" ;; | |
| android) PLATFORM="android" ;; | |
| ios) PLATFORM="ios" ;; | |
| *) PLATFORM="all" ;; | |
| esac | |
| # Strip platform suffix from VERSION (platform is tracked separately) | |
| case "$PLATFORM" in | |
| all) ;; | |
| *) VERSION="${VERSION%-$PLATFORM}" ;; | |
| esac | |
| case "$PLATFORM" in | |
| all|linux) LINUX_ARCH="all" ;; | |
| *) LINUX_ARCH="amd64" ;; | |
| esac | |
| ;; | |
| esac | |
| case "$LINUX_ARCH" in | |
| all|amd64|arm64) ;; | |
| *) | |
| echo "Error: Invalid linux_arch '$LINUX_ARCH' (expected: all, amd64, arm64)" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| # Installer base name - Makefile will append build type | |
| INSTALLER_BASE_NAME="lantern-installer" | |
| # Test run: workflow_dispatch from non-default branch | |
| IS_TEST_RUN="false" | |
| if [[ "$EVENT" == "workflow_dispatch" ]]; then | |
| DEFAULT_BRANCH="${{ github.event.repository.default_branch }}" | |
| if [[ "${{ github.ref_name }}" != "$DEFAULT_BRANCH" ]]; then | |
| IS_TEST_RUN="true" | |
| echo "Test run: draft release will be deleted after build" | |
| fi | |
| fi | |
| PUBLISH_MOBILE_STORES="false" | |
| if [[ "$BUILD_TYPE" == "nightly" && "$REQUEST_MOBILE_STORE_PUBLISH" == "true" ]]; then | |
| if [[ "$IS_TEST_RUN" == "true" ]]; then | |
| echo "Skipping mobile store publish for non-default-branch test run" | |
| else | |
| PUBLISH_MOBILE_STORES="true" | |
| fi | |
| fi | |
| SMOKE_ENABLE_IP_CHECK="false" | |
| STEALTH_LEAKAGE_MODE="" | |
| if [[ "$EVENT" == "workflow_dispatch" ]]; then | |
| requested_stealth_leakage_mode="${REQUESTED_STEALTH_LEAKAGE_MODE:-none}" | |
| case "$requested_stealth_leakage_mode" in | |
| none|'') ;; | |
| stealth|stealth-novpn) STEALTH_LEAKAGE_MODE="$requested_stealth_leakage_mode" ;; | |
| *) | |
| echo "Error: invalid stealth_leakage_mode '$requested_stealth_leakage_mode'" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| fi | |
| echo "Build Configuration:" | |
| echo " Event: $EVENT" | |
| echo " Build Type: $BUILD_TYPE" | |
| echo " Release Tag: $RELEASE_TAG" | |
| echo " Version: $VERSION" | |
| echo " Platform: $PLATFORM" | |
| echo " Linux Arch: $LINUX_ARCH" | |
| echo " Installer: $INSTALLER_BASE_NAME" | |
| echo " Store Publish: $PUBLISH_MOBILE_STORES" | |
| echo "build_type=$BUILD_TYPE" >> $GITHUB_OUTPUT | |
| echo "release_tag=$RELEASE_TAG" >> $GITHUB_OUTPUT | |
| echo "version=$VERSION" >> $GITHUB_OUTPUT | |
| echo "platform=$PLATFORM" >> $GITHUB_OUTPUT | |
| echo "linux_arch=$LINUX_ARCH" >> $GITHUB_OUTPUT | |
| echo "installer_base_name=$INSTALLER_BASE_NAME" >> $GITHUB_OUTPUT | |
| echo "is_test_run=$IS_TEST_RUN" >> $GITHUB_OUTPUT | |
| echo "publish_mobile_stores=$PUBLISH_MOBILE_STORES" >> $GITHUB_OUTPUT | |
| echo "smoke_enable_ip_check=$SMOKE_ENABLE_IP_CHECK" >> $GITHUB_OUTPUT | |
| echo "stealth_leakage_mode=$STEALTH_LEAKAGE_MODE" >> $GITHUB_OUTPUT | |
| - name: Update pubspec.yaml version | |
| shell: bash | |
| env: | |
| RELEASE_TAG: ${{ steps.meta.outputs.release_tag }} | |
| # github.run_number is monotonically increasing across all workflow | |
| # runs and never resets when the base version changes. This is | |
| # critical for macOS system extensions, which compare CFBundleVersion | |
| # numerically and refuse to upgrade to a lower build number. | |
| BUILD_NUMBER: ${{ github.run_number }} | |
| run: | | |
| # Strip 'v' prefix if present | |
| VERSION="${RELEASE_TAG#v}" | |
| # Extract base semver (strip suffixes like -beta, -nightly-...) | |
| BASE_VERSION=$(./scripts/ci/version.sh extract "$VERSION") | |
| FULL_VERSION="${BASE_VERSION}+${BUILD_NUMBER}" | |
| echo "Setting pubspec version: $FULL_VERSION" | |
| echo " Base version: $BASE_VERSION" | |
| echo " Build number: $BUILD_NUMBER (github.run_number)" | |
| sed -i.bak -E "s/^version:.*/version: ${FULL_VERSION}/" pubspec.yaml | |
| grep '^version:' pubspec.yaml | |
| - name: Upload pubspec.yaml | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: pubspec | |
| path: pubspec.yaml | |
| release-notify: | |
| needs: [set-metadata] | |
| if: | | |
| needs.set-metadata.outputs.build_type == 'production' && | |
| needs.set-metadata.outputs.platform == 'all' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Notify Slack | |
| uses: slackapi/slack-github-action@v2.0.0 | |
| with: | |
| webhook: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| webhook-type: webhook-trigger | |
| payload: | | |
| { | |
| "text": "Production release v${{ needs.set-metadata.outputs.version }} awaiting approval before releasing on all platforms: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" | |
| } | |
| release-approval: | |
| needs: [set-metadata] | |
| if: | | |
| needs.set-metadata.outputs.build_type == 'production' && | |
| needs.set-metadata.outputs.platform == 'all' | |
| runs-on: ubuntu-latest | |
| environment: production | |
| steps: | |
| - run: echo "Approved" | |
| build-macos: | |
| needs: [set-metadata, release-create, release-approval] | |
| uses: ./.github/workflows/build-macos.yml | |
| secrets: inherit | |
| if: | | |
| !cancelled() && | |
| needs.set-metadata.result == 'success' && | |
| needs.release-create.result == 'success' && | |
| (needs.release-approval.result == 'success' || needs.release-approval.result == 'skipped') && | |
| (needs.set-metadata.outputs.platform == 'all' || contains(needs.set-metadata.outputs.platform, 'macos')) | |
| with: | |
| version: ${{ needs.set-metadata.outputs.version }} | |
| build_type: ${{ needs.set-metadata.outputs.build_type }} | |
| installer_base_name: ${{ needs.set-metadata.outputs.installer_base_name }} | |
| build-windows: | |
| needs: [set-metadata, release-create, release-approval] | |
| uses: ./.github/workflows/build-windows.yml | |
| secrets: inherit | |
| if: | | |
| !cancelled() && | |
| needs.set-metadata.result == 'success' && | |
| needs.release-create.result == 'success' && | |
| (needs.release-approval.result == 'success' || needs.release-approval.result == 'skipped') && | |
| (needs.set-metadata.outputs.platform == 'all' || contains(needs.set-metadata.outputs.platform, 'windows')) | |
| with: | |
| version: ${{ needs.set-metadata.outputs.version }} | |
| build_type: ${{ needs.set-metadata.outputs.build_type }} | |
| installer_base_name: ${{ needs.set-metadata.outputs.installer_base_name }} | |
| enable_ip_check: ${{ needs.set-metadata.outputs.smoke_enable_ip_check == 'true' }} | |
| run_connect_smoke: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.windows_connect_smoke == 'true' }} | |
| run_split_tunnel_website_smoke: false | |
| run_config_url_smoke: false | |
| force_full_tunnel_smoke: ${{ needs.set-metadata.outputs.build_type == 'nightly' }} | |
| run_auth_smoke: false | |
| skip_signing: ${{ needs.set-metadata.outputs.build_type == 'nightly' && (needs.set-metadata.outputs.is_test_run == 'true' || github.event.inputs.sign_windows != 'true') }} | |
| build-linux: | |
| needs: [set-metadata, release-create, release-approval] | |
| uses: ./.github/workflows/build-linux.yml | |
| secrets: inherit | |
| if: | | |
| !cancelled() && | |
| needs.set-metadata.result == 'success' && | |
| needs.release-create.result == 'success' && | |
| (needs.release-approval.result == 'success' || needs.release-approval.result == 'skipped') && | |
| (needs.set-metadata.outputs.platform == 'all' || contains(needs.set-metadata.outputs.platform, 'linux')) | |
| with: | |
| version: ${{ needs.set-metadata.outputs.version }} | |
| build_type: ${{ needs.set-metadata.outputs.build_type }} | |
| installer_base_name: ${{ needs.set-metadata.outputs.installer_base_name }} | |
| linux_arch: ${{ needs.set-metadata.outputs.linux_arch }} | |
| enable_ip_check: ${{ needs.set-metadata.outputs.smoke_enable_ip_check == 'true' }} | |
| force_full_tunnel_smoke: ${{ needs.set-metadata.outputs.build_type == 'nightly' }} | |
| run_auth_smoke: false | |
| build-ios: | |
| needs: [set-metadata, release-create, release-approval] | |
| uses: ./.github/workflows/build-ios.yml | |
| secrets: inherit | |
| if: | | |
| !cancelled() && | |
| needs.set-metadata.result == 'success' && | |
| needs.release-create.result == 'success' && | |
| (needs.release-approval.result == 'success' || needs.release-approval.result == 'skipped') && | |
| (needs.set-metadata.outputs.platform == 'all' || contains(needs.set-metadata.outputs.platform, 'ios')) | |
| with: | |
| version: ${{ needs.set-metadata.outputs.version }} | |
| build_type: ${{ needs.set-metadata.outputs.build_type }} | |
| installer_base_name: ${{ needs.set-metadata.outputs.installer_base_name }} | |
| build-android: | |
| needs: [set-metadata, release-create, release-approval] | |
| uses: ./.github/workflows/build-android.yml | |
| secrets: inherit | |
| if: | | |
| !cancelled() && | |
| needs.set-metadata.result == 'success' && | |
| needs.release-create.result == 'success' && | |
| (needs.release-approval.result == 'success' || needs.release-approval.result == 'skipped') && | |
| (needs.set-metadata.outputs.platform == 'all' || contains(needs.set-metadata.outputs.platform, 'android')) | |
| with: | |
| version: ${{ needs.set-metadata.outputs.version }} | |
| build_type: ${{ needs.set-metadata.outputs.build_type }} | |
| installer_base_name: ${{ needs.set-metadata.outputs.installer_base_name }} | |
| stealth_leakage_mode: ${{ needs.set-metadata.outputs.stealth_leakage_mode }} | |
| release-create: | |
| needs: set-metadata | |
| runs-on: ubuntu-latest | |
| env: | |
| BUILD_TYPE: ${{ needs.set-metadata.outputs.build_type }} | |
| RELEASE_TAG: ${{ needs.set-metadata.outputs.release_tag }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.sha }} | |
| - name: Create GitHub Release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| # Strip 'v' prefix for display | |
| VERSION="${RELEASE_TAG#v}" | |
| case "$BUILD_TYPE" in | |
| production) | |
| PRERELEASE_FLAG="" | |
| TITLE="Lantern $VERSION" | |
| ;; | |
| beta) | |
| PRERELEASE_FLAG="--prerelease" | |
| TITLE="Beta $VERSION" | |
| ;; | |
| nightly) | |
| PRERELEASE_FLAG="--prerelease" | |
| TITLE="Nightly $VERSION" | |
| ;; | |
| esac | |
| WORKFLOW_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" | |
| gh release create "$RELEASE_TAG" \ | |
| --draft \ | |
| $PRERELEASE_FLAG \ | |
| --title "$TITLE" \ | |
| --notes "Build [in progress](${WORKFLOW_URL})..." | |
| upload-s3: | |
| needs: | |
| [ | |
| set-metadata, | |
| build-macos, | |
| build-windows, | |
| build-linux, | |
| build-android, | |
| build-ios, | |
| ] | |
| # Nightly builds publish any healthy platform; beta/production remain | |
| # all-or-nothing across requested platforms. | |
| if: | | |
| always() && | |
| !cancelled() && | |
| ( | |
| ( | |
| needs.set-metadata.outputs.build_type == 'nightly' && | |
| (needs.build-macos.result == 'success' || needs.build-windows.result == 'success' || | |
| needs.build-linux.result == 'success' || needs.build-android.result == 'success' || | |
| needs.build-ios.result == 'success') | |
| ) || | |
| ( | |
| needs.set-metadata.outputs.build_type != 'nightly' && | |
| (needs.build-macos.result == 'success' || needs.build-macos.result == 'skipped') && | |
| (needs.build-windows.result == 'success' || needs.build-windows.result == 'skipped') && | |
| (needs.build-linux.result == 'success' || needs.build-linux.result == 'skipped') && | |
| (needs.build-android.result == 'success' || needs.build-android.result == 'skipped') && | |
| (needs.build-ios.result == 'success' || needs.build-ios.result == 'skipped') && | |
| (needs.build-macos.result == 'success' || needs.build-windows.result == 'success' || | |
| needs.build-linux.result == 'success' || needs.build-android.result == 'success' || | |
| needs.build-ios.result == 'success') | |
| ) | |
| ) | |
| runs-on: ubuntu-latest | |
| env: | |
| BUILD_TYPE: ${{ needs.set-metadata.outputs.build_type }} | |
| RELEASE_TAG: ${{ needs.set-metadata.outputs.release_tag }} | |
| INSTALLER_BASE_NAME: ${{ needs.set-metadata.outputs.installer_base_name }} | |
| PLATFORM: ${{ needs.set-metadata.outputs.platform }} | |
| LINUX_ARCH: ${{ needs.set-metadata.outputs.linux_arch }} | |
| BUCKET: ${{ vars.S3_RELEASES_BUCKET }} | |
| GITHUB_REF_NAME: ${{ github.ref_name }} | |
| WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.sha }} | |
| - name: Download build artifacts | |
| uses: actions/download-artifact@v4 | |
| - name: Resolve nightly publish scope | |
| if: ${{ env.BUILD_TYPE == 'nightly' }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| has_artifact() { | |
| compgen -G "$1" > /dev/null | |
| } | |
| platforms=() | |
| if has_artifact "lantern-installer-dmg/*.dmg"; then | |
| platforms+=("macos") | |
| fi | |
| if has_artifact "lantern-installer-exe/*.exe"; then | |
| platforms+=("windows") | |
| fi | |
| if has_artifact "lantern-installer-apk/*.apk"; then | |
| platforms+=("android") | |
| fi | |
| if has_artifact "lantern-installer-ipa/*.ipa"; then | |
| platforms+=("ios") | |
| fi | |
| linux_amd64=false | |
| linux_arm64=false | |
| if has_artifact "lantern-installer-deb-amd64/*.deb" || has_artifact "lantern-installer-rpm-amd64/*.rpm" || has_artifact "lantern-installer-deb/*.deb" || has_artifact "lantern-installer-rpm/*.rpm"; then | |
| linux_amd64=true | |
| fi | |
| if has_artifact "lantern-installer-deb-arm64/*.deb" || has_artifact "lantern-installer-rpm-arm64/*.rpm"; then | |
| linux_arm64=true | |
| fi | |
| if [[ "$linux_amd64" == true || "$linux_arm64" == true ]]; then | |
| platforms+=("linux") | |
| fi | |
| if [[ "${#platforms[@]}" -eq 0 ]]; then | |
| echo "No publishable nightly artifacts were found." >&2 | |
| exit 1 | |
| fi | |
| nightly_platforms="$(IFS=,; echo "${platforms[*]}")" | |
| nightly_linux_arch="amd64" | |
| if [[ "$linux_amd64" == true && "$linux_arm64" == true ]]; then | |
| nightly_linux_arch="all" | |
| elif [[ "$linux_arm64" == true ]]; then | |
| nightly_linux_arch="arm64" | |
| fi | |
| echo "Nightly publish scope: ${nightly_platforms} (linux_arch=${nightly_linux_arch})" | |
| echo "PLATFORM=${nightly_platforms}" >> "$GITHUB_ENV" | |
| echo "LINUX_ARCH=${nightly_linux_arch}" >> "$GITHUB_ENV" | |
| - name: Upload to S3 | |
| shell: bash | |
| run: | | |
| # Strip 'v' prefix for S3 paths | |
| VERSION="${RELEASE_TAG#v}" | |
| ./scripts/ci/publish-to-s3.sh \ | |
| "$BUILD_TYPE" \ | |
| "$VERSION" \ | |
| "$INSTALLER_BASE_NAME" \ | |
| "$PLATFORM" | |
| env: | |
| AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} | |
| - name: Format GitHub job summary | |
| shell: bash | |
| run: ./scripts/ci/format.sh job-summary >> "$GITHUB_STEP_SUMMARY" | |
| env: | |
| RELEASE_TAG: ${{ env.RELEASE_TAG }} | |
| - name: Format Slack message | |
| id: slack_msg | |
| env: | |
| RELEASE_TAG: ${{ env.RELEASE_TAG }} | |
| WORKFLOW_URL: ${{ env.WORKFLOW_URL }} | |
| run: | | |
| text=$(./scripts/ci/format.sh slack) | |
| echo "text<<EOF" >> "$GITHUB_OUTPUT" | |
| echo "$text" >> "$GITHUB_OUTPUT" | |
| echo "EOF" >> "$GITHUB_OUTPUT" | |
| - name: Notify Slack | |
| uses: slackapi/slack-github-action@v2.0.0 | |
| with: | |
| webhook: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| webhook-type: webhook-trigger | |
| payload: | | |
| { | |
| "text": "${{ steps.slack_msg.outputs.text }}" | |
| } | |
| upload-release-artifacts: | |
| needs: | |
| [ | |
| set-metadata, | |
| release-create, | |
| build-macos, | |
| build-windows, | |
| build-linux, | |
| build-android, | |
| build-ios, | |
| ] | |
| # Run if at least one platform build succeeded. | |
| if: | | |
| !cancelled() && | |
| needs.release-create.result == 'success' && | |
| (needs.build-macos.result == 'success' || needs.build-windows.result == 'success' || | |
| needs.build-linux.result == 'success' || needs.build-android.result == 'success' || | |
| needs.build-ios.result == 'success') | |
| runs-on: ubuntu-latest | |
| env: | |
| BUILD_TYPE: ${{ needs.set-metadata.outputs.build_type }} | |
| RELEASE_TAG: ${{ needs.set-metadata.outputs.release_tag }} | |
| INSTALLER_BASE_NAME: ${{ needs.set-metadata.outputs.installer_base_name }} | |
| PLATFORM: ${{ needs.set-metadata.outputs.platform }} | |
| LINUX_ARCH: ${{ needs.set-metadata.outputs.linux_arch }} | |
| BUCKET: ${{ vars.S3_RELEASES_BUCKET }} | |
| GITHUB_REF_NAME: ${{ github.ref_name }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.sha }} | |
| fetch-depth: 0 | |
| fetch-tags: true | |
| - name: Download build artifacts | |
| uses: actions/download-artifact@v4 | |
| - name: Generate AI release notes | |
| env: | |
| ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} | |
| RELEASE_TAG: ${{ env.RELEASE_TAG }} | |
| BUILD_TYPE: ${{ env.BUILD_TYPE }} | |
| GITHUB_SHA: ${{ github.sha }} | |
| run: | | |
| # Generate AI-powered changelog from commit history | |
| ./scripts/ci/generate-release-notes.sh > ai_notes.md 2>ai_notes_err.log || true | |
| if [ -s ai_notes.md ]; then | |
| echo "AI release notes generated successfully" | |
| else | |
| echo "AI notes empty, will use format.sh fallback" | |
| cat ai_notes_err.log >&2 || true | |
| fi | |
| - name: Update release notes | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| GITHUB_SHA: ${{ github.sha }} | |
| RELEASE_TAG: ${{ env.RELEASE_TAG }} | |
| run: | | |
| # Generate download links and metadata | |
| ./scripts/ci/format.sh release-notes > release_notes.md | |
| # Prepend AI changelog if available | |
| if [ -s ai_notes.md ]; then | |
| cat ai_notes.md > final_notes.md | |
| echo "" >> final_notes.md | |
| echo "---" >> final_notes.md | |
| echo "" >> final_notes.md | |
| cat release_notes.md >> final_notes.md | |
| else | |
| cp release_notes.md final_notes.md | |
| fi | |
| # Update the draft release with combined notes | |
| gh release edit "$RELEASE_TAG" --notes-file final_notes.md | |
| - name: Upload artifacts to GitHub Release | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| # Construct full filename with build type (Makefile appends it) | |
| FULL_NAME="${INSTALLER_BASE_NAME}" | |
| [[ -n "$BUILD_TYPE" && "$BUILD_TYPE" != "production" ]] && FULL_NAME="${FULL_NAME}-${BUILD_TYPE}" | |
| upload_if_exists() { | |
| local file="$1" | |
| if [[ ! -f "$file" ]]; then | |
| return 0 | |
| fi | |
| echo "Uploading $file to GitHub release..." | |
| local deadline=$(( SECONDS + 600 )) | |
| local delay=5 attempt=0 | |
| while :; do | |
| attempt=$((attempt + 1)) | |
| if gh release upload "$RELEASE_TAG" "$file" --clobber; then | |
| echo "Uploaded $file on attempt $attempt" | |
| return 0 | |
| fi | |
| if [[ "$SECONDS" -ge "$deadline" ]]; then | |
| echo "Failed to upload $file after $attempt attempts (~10 min)" >&2 | |
| return 1 | |
| fi | |
| echo "Upload failed (attempt $attempt); retrying in ${delay}s..." >&2 | |
| sleep "$delay" | |
| delay=$(( delay * 2 )) | |
| [[ "$delay" -gt 60 ]] && delay=60 | |
| done | |
| } | |
| upload_if_exists "lantern-installer-dmg/${FULL_NAME}.dmg" | |
| upload_if_exists "lantern-installer-exe/${FULL_NAME}.exe" | |
| upload_if_exists "lantern-installer-apk/${FULL_NAME}.apk" | |
| upload_if_exists "lantern-installer-deb-amd64/${FULL_NAME}.deb" | |
| upload_if_exists "lantern-installer-rpm-amd64/${FULL_NAME}.rpm" | |
| upload_if_exists "lantern-installer-deb-arm64/${FULL_NAME}-arm64.deb" | |
| upload_if_exists "lantern-installer-rpm-arm64/${FULL_NAME}-arm64.rpm" | |
| upload_if_exists "lantern-installer-deb/${FULL_NAME}.deb" | |
| upload_if_exists "lantern-installer-rpm/${FULL_NAME}.rpm" | |
| upload_if_exists "lantern-installer-pkg-amd64/${FULL_NAME}.pkg.tar.zst" | |
| upload_if_exists "lantern-installer-pkg-arm64/${FULL_NAME}-arm64.pkg.tar.zst" | |
| upload_if_exists "lantern-installer-pkg/${FULL_NAME}.pkg.tar.zst" | |
| upload_if_exists "lantern-installer-ipa/${FULL_NAME}.ipa" | |
| # Post-publish steps. Production/beta always use their release tracks; nightly | |
| # mobile store uploads run only when set-metadata enables them. | |
| upload-google-play: | |
| needs: [set-metadata, build-android] | |
| if: | | |
| !cancelled() && | |
| needs.set-metadata.result == 'success' && | |
| needs.build-android.result == 'success' && | |
| (needs.set-metadata.outputs.platform == 'all' || contains(needs.set-metadata.outputs.platform, 'android')) && | |
| ( | |
| needs.set-metadata.outputs.build_type == 'beta' || | |
| needs.set-metadata.outputs.build_type == 'production' || | |
| (needs.set-metadata.outputs.build_type == 'nightly' && needs.set-metadata.outputs.publish_mobile_stores == 'true') | |
| ) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Download AAB artifact | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: lantern-installer-aab | |
| path: aab | |
| - name: Download mapping.txt | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: play-mapping | |
| path: play | |
| - name: Download native debug symbols | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: play-debug-symbols | |
| path: play | |
| - name: Pick Play track | |
| id: track | |
| env: | |
| NIGHTLY_GOOGLE_PLAY_TRACK: ${{ env.NIGHTLY_GOOGLE_PLAY_TRACK }} | |
| run: | | |
| bt="${{ needs.set-metadata.outputs.build_type }}" | |
| case "$bt" in | |
| beta) echo "track=beta" >> "$GITHUB_OUTPUT" ;; | |
| production) echo "track=production" >> "$GITHUB_OUTPUT" ;; | |
| nightly) echo "track=${NIGHTLY_GOOGLE_PLAY_TRACK:-internal}" >> "$GITHUB_OUTPUT" ;; | |
| *) | |
| echo "Unsupported build type for Google Play upload: $bt" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| - name: Upload to Google Play | |
| uses: r0adkll/upload-google-play@v1.1.3 | |
| with: | |
| serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} | |
| packageName: org.getlantern.lantern | |
| releaseFiles: aab/*.aab | |
| track: ${{ steps.track.outputs.track }} | |
| status: completed | |
| mappingFile: play/mapping.txt | |
| debugSymbols: play/debug-symbols.zip | |
| upload-testflight: | |
| needs: [set-metadata, build-ios] | |
| if: | | |
| !cancelled() && | |
| needs.set-metadata.result == 'success' && | |
| needs.build-ios.result == 'success' && | |
| (needs.set-metadata.outputs.platform == 'all' || contains(needs.set-metadata.outputs.platform, 'ios')) && | |
| ( | |
| needs.set-metadata.outputs.build_type == 'beta' || | |
| needs.set-metadata.outputs.build_type == 'production' || | |
| (needs.set-metadata.outputs.build_type == 'nightly' && needs.set-metadata.outputs.publish_mobile_stores == 'true') | |
| ) | |
| runs-on: macos-14 | |
| steps: | |
| - name: Download iOS Artifact | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: lantern-installer-ipa | |
| path: . | |
| - name: Construct IPA filename | |
| id: filename | |
| shell: bash | |
| run: | | |
| FULL_NAME="${{ needs.set-metadata.outputs.installer_base_name }}" | |
| BUILD_TYPE="${{ needs.set-metadata.outputs.build_type }}" | |
| [[ -n "$BUILD_TYPE" && "$BUILD_TYPE" != "production" ]] && FULL_NAME="${FULL_NAME}-${BUILD_TYPE}" | |
| echo "ipa=${FULL_NAME}.ipa" >> "$GITHUB_OUTPUT" | |
| - name: Upload to TestFlight | |
| uses: apple-actions/upload-testflight-build@v3.0.1 | |
| with: | |
| app-path: ${{ steps.filename.outputs.ipa }} | |
| issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }} | |
| api-key-id: ${{ secrets.APPSTORE_API_KEY_ID }} | |
| api-private-key: ${{ secrets.APPSTORE_API_PRIVATE_KEY }} | |
| update-appcast: | |
| needs: [set-metadata, release-finalize] | |
| if: | | |
| !cancelled() && | |
| needs.release-finalize.result == 'success' && | |
| (needs.set-metadata.outputs.platform == 'all' || | |
| contains(needs.set-metadata.outputs.platform, 'macos') || | |
| contains(needs.set-metadata.outputs.platform, 'windows') || | |
| contains(needs.set-metadata.outputs.platform, 'linux')) && | |
| needs.set-metadata.outputs.build_type != 'nightly' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.sha }} | |
| - name: Install Flutter | |
| uses: subosito/flutter-action@v2.22.0 | |
| with: | |
| channel: stable | |
| flutter-version-file: .github/flutter-version.yaml | |
| - name: Install Python dependencies | |
| run: python3 -m pip install -r scripts/requirements.txt | |
| - name: Update appcast.xml | |
| env: | |
| AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} | |
| GITHUB_TOKEN: ${{ secrets.CI_PRIVATE_REPOS_GH_TOKEN }} | |
| BUILD_TYPE: ${{ needs.set-metadata.outputs.build_type }} | |
| RELEASE_TAG: ${{ needs.set-metadata.outputs.release_tag }} | |
| BUCKET: ${{ vars.S3_RELEASES_BUCKET }} | |
| run: | | |
| # Strip 'v' prefix for S3 paths | |
| VERSION="${RELEASE_TAG#v}" | |
| python3 scripts/generate_appcast.py | |
| # appcast, like installers, always upload a versioned copy alongside "latest" | |
| aws s3 cp appcast.xml "s3://${BUCKET}/releases/${BUILD_TYPE}/${VERSION}/appcast.xml" --acl public-read | |
| aws s3 cp appcast.xml "s3://${BUCKET}/releases/${BUILD_TYPE}/latest/appcast.xml" --acl public-read | |
| if [[ "$BUILD_TYPE" == "production" ]]; then | |
| aws s3 cp appcast.xml "s3://${BUCKET}/releases/appcast.xml" --acl public-read | |
| fi | |
| # Upload appcast.xml to GitHub release, but | |
| # not git because we may be in detached HEAD | |
| gh release upload "$RELEASE_TAG" appcast.xml --clobber | |
| release-finalize: | |
| needs: | |
| [ | |
| set-metadata, | |
| upload-s3, | |
| upload-release-artifacts, | |
| upload-google-play, | |
| upload-testflight, | |
| ] | |
| if: always() | |
| runs-on: ubuntu-latest | |
| env: | |
| RELEASE_TAG: ${{ needs.set-metadata.outputs.release_tag }} | |
| BUILD_TYPE: ${{ needs.set-metadata.outputs.build_type }} | |
| CLEANUP_ON_FAILURE: ${{ github.event.inputs.cleanup_on_failure != 'false' }} | |
| IS_TEST_RUN: ${{ needs.set-metadata.outputs.is_test_run }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ github.sha }} | |
| - name: Publish release on success | |
| if: | | |
| env.IS_TEST_RUN != 'true' && | |
| env.BUILD_TYPE != 'nightly' && | |
| needs.upload-s3.result == 'success' && | |
| needs.upload-release-artifacts.result == 'success' && | |
| (needs.upload-google-play.result == 'success' || needs.upload-google-play.result == 'skipped') && | |
| (needs.upload-testflight.result == 'success' || needs.upload-testflight.result == 'skipped') | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| echo "All steps succeeded - publishing draft release" | |
| gh release edit "$RELEASE_TAG" --draft=false | |
| - name: Delete draft release | |
| if: | | |
| env.IS_TEST_RUN == 'true' || | |
| env.BUILD_TYPE == 'nightly' || | |
| (env.CLEANUP_ON_FAILURE == 'true' && | |
| !(needs.upload-s3.result == 'success' && | |
| needs.upload-release-artifacts.result == 'success' && | |
| (needs.upload-google-play.result == 'success' || needs.upload-google-play.result == 'skipped') && | |
| (needs.upload-testflight.result == 'success' || needs.upload-testflight.result == 'skipped'))) | |
| env: | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| if [[ "$IS_TEST_RUN" == "true" ]]; then | |
| echo "Test run from non-main branch - deleting draft release" | |
| elif [[ "$BUILD_TYPE" == "nightly" ]]; then | |
| echo "Nightly build - deleting draft release" | |
| else | |
| echo "Build failed or cancelled - cleaning up draft release" | |
| fi | |
| attempt=0 | |
| max_retries=5 | |
| while [ "$attempt" -lt "$max_retries" ]; do | |
| attempt=$((attempt + 1)) | |
| echo "Deleting release $RELEASE_TAG (attempt $attempt/$max_retries)..." | |
| if delete_output="$(gh release delete "$RELEASE_TAG" --yes 2>&1)"; then | |
| echo "$delete_output" | |
| echo "Release $RELEASE_TAG deleted" | |
| break | |
| fi | |
| echo "$delete_output" | |
| if echo "$delete_output" | grep -qiE "Reference does not exist|release not found"; then | |
| echo "Release cleanup already complete" | |
| break | |
| fi | |
| if [ "$attempt" -eq "$max_retries" ]; then | |
| echo "Failed to delete release after $max_retries attempts" | |
| exit 1 | |
| fi | |
| sleep 3 | |
| done | |
| - name: Report failure (cleanup disabled) | |
| if: | | |
| env.IS_TEST_RUN != 'true' && | |
| env.BUILD_TYPE != 'nightly' && | |
| env.CLEANUP_ON_FAILURE != 'true' && | |
| !(needs.upload-s3.result == 'success' && | |
| needs.upload-release-artifacts.result == 'success' && | |
| (needs.upload-google-play.result == 'success' || needs.upload-google-play.result == 'skipped') && | |
| (needs.upload-testflight.result == 'success' || needs.upload-testflight.result == 'skipped')) | |
| run: | | |
| echo "Build failed or cancelled - draft release preserved for inspection" | |
| echo "Draft: https://github.com/getlantern/lantern/releases/tag/$RELEASE_TAG" | |
| exit 1 |