From 01c7169a338315b2108748f0e168e6b873ca948b Mon Sep 17 00:00:00 2001 From: Janez T Date: Wed, 22 Oct 2025 12:11:26 +0200 Subject: [PATCH] feat: Add Cloudflare R2 setup documentation and update CI/CD workflow for artifact uploads --- .github/R2_SETUP.md | 236 +++++++++++++++++++ .github/workflows/build-multiplatform.yml | 262 +++++++++++++++++++++- 2 files changed, 492 insertions(+), 6 deletions(-) create mode 100644 .github/R2_SETUP.md diff --git a/.github/R2_SETUP.md b/.github/R2_SETUP.md new file mode 100644 index 0000000..5093d49 --- /dev/null +++ b/.github/R2_SETUP.md @@ -0,0 +1,236 @@ +# Cloudflare R2 Setup for CI/CD + +This document describes how to configure Cloudflare R2 for automatic artifact uploads from GitHub Actions. + +## Overview + +The CI/CD pipeline uploads build artifacts (APK, AAB, DMG, Windows ZIP) to a Cloudflare R2 bucket in the `unstable/` directory for every successful build. This provides: + +- **Automatic artifact hosting** for all branches and commits +- **Version tracking** with timestamps and commit hashes +- **Public download URLs** (optional) for testers and developers +- **Build manifests** with metadata for each build + +## Required GitHub Secrets + +Configure these secrets in your GitHub repository settings (`Settings` → `Secrets and variables` → `Actions`): + +### Required Secrets + +| Secret Name | Description | How to Obtain | +|-------------|-------------|---------------| +| `R2_ACCOUNT_ID` | Your Cloudflare account ID | Cloudflare Dashboard → R2 → Overview (right sidebar) | +| `R2_ACCESS_KEY_ID` | R2 API token access key ID | Cloudflare Dashboard → R2 → Manage R2 API Tokens → Create API Token | +| `R2_SECRET_ACCESS_KEY` | R2 API token secret key | Generated when creating the API token (save immediately!) | +| `R2_BUCKET_NAME` | Name of your R2 bucket | Example: `meshcore-sar-builds` | + +### Optional Secrets + +| Secret Name | Description | Example | +|-------------|-------------|---------| +| `R2_PUBLIC_URL` | Public URL for R2 bucket (if public access configured) | `https://builds.example.com` | + +## Step-by-Step Setup + +### 1. Create Cloudflare R2 Bucket + +1. Log in to [Cloudflare Dashboard](https://dash.cloudflare.com/) +2. Navigate to **R2** in the left sidebar +3. Click **Create Bucket** +4. Enter bucket name (e.g., `meshcore-sar-builds`) +5. Choose a location (e.g., `Automatic` or `ENAM` for Europe/North America) +6. Click **Create Bucket** + +### 2. Create R2 API Token + +1. In Cloudflare Dashboard, go to **R2** → **Manage R2 API Tokens** +2. Click **Create API Token** +3. Configure the token: + - **Token name**: `github-actions-upload` + - **Permissions**: Select **Object Read & Write** + - **Bucket scope**: + - Choose **Apply to specific buckets only** + - Select your bucket (e.g., `meshcore-sar-builds`) +4. Click **Create API Token** +5. **IMPORTANT**: Copy both the **Access Key ID** and **Secret Access Key** immediately + - The secret key will only be shown once! + - Save them securely (e.g., password manager) + +### 3. Get Your Account ID + +1. In Cloudflare Dashboard, go to **R2** → **Overview** +2. Your Account ID is displayed in the right sidebar +3. Copy the Account ID + +### 4. Configure GitHub Secrets + +1. Go to your GitHub repository +2. Navigate to **Settings** → **Secrets and variables** → **Actions** +3. Click **New repository secret** for each of the following: + + **R2_ACCOUNT_ID**: + ``` + Paste your Cloudflare Account ID + ``` + + **R2_ACCESS_KEY_ID**: + ``` + Paste the Access Key ID from step 2 + ``` + + **R2_SECRET_ACCESS_KEY**: + ``` + Paste the Secret Access Key from step 2 + ``` + + **R2_BUCKET_NAME**: + ``` + meshcore-sar-builds + ``` + +### 5. (Optional) Configure Public Access + +To enable public downloads from your R2 bucket: + +#### Option A: Custom Domain (Recommended) + +1. In Cloudflare Dashboard, go to **R2** → **Settings** for your bucket +2. Under **Public Access**, click **Connect Domain** +3. Enter your custom domain (e.g., `builds.meshcore.example.com`) +4. Follow Cloudflare's instructions to configure DNS +5. Add GitHub secret: + ``` + R2_PUBLIC_URL = https://builds.meshcore.example.com + ``` + +#### Option B: R2.dev Subdomain (Quick Setup) + +1. In Cloudflare Dashboard, go to **R2** → **Settings** for your bucket +2. Under **Public Access**, click **Allow Access** +3. Enable **R2.dev subdomain** +4. Copy the generated URL (e.g., `https://pub-xxxxx.r2.dev`) +5. Add GitHub secret: + ``` + R2_PUBLIC_URL = https://pub-xxxxx.r2.dev + ``` + +**Note**: Without `R2_PUBLIC_URL` configured, artifacts will still upload successfully but download URLs won't be generated. + +## Artifact Structure + +Artifacts are uploaded with the following structure: + +``` +unstable/ +├── main-abc1234/ # Branch + commit hash +│ ├── meshcore-sar-main-abc1234-20241022-143022.apk +│ ├── meshcore-sar-main-abc1234-20241022-143022.aab +│ ├── meshcore-sar-main-abc1234-20241022-143022.dmg +│ ├── meshcore-sar-main-abc1234-20241022-143022-windows.zip +│ └── manifest.json +├── v1.2.3/ # Release tag +│ ├── meshcore-sar-v1.2.3-20241022-150045.apk +│ ├── meshcore-sar-v1.2.3-20241022-150045.aab +│ ├── meshcore-sar-v1.2.3-20241022-150045.dmg +│ ├── meshcore-sar-v1.2.3-20241022-150045-windows.zip +│ └── manifest.json +└── develop-def5678/ + └── ... +``` + +### Manifest Example + +Each build includes a `manifest.json` file: + +```json +{ + "build_id": "main-abc1234-20241022-143022", + "commit": "abc1234567890abcdef1234567890abcdef12345", + "commit_short": "abc1234", + "branch": "main", + "tag": "", + "timestamp": "20241022-143022", + "workflow_run": "123", + "artifacts": [ + "meshcore-sar-main-abc1234-20241022-143022.apk", + "meshcore-sar-main-abc1234-20241022-143022.aab", + "meshcore-sar-main-abc1234-20241022-143022.dmg", + "meshcore-sar-main-abc1234-20241022-143022-windows.zip" + ] +} +``` + +## Testing the Setup + +1. Push a commit to your repository (or re-run an existing workflow) +2. Navigate to **Actions** tab in GitHub +3. Click on the running workflow +4. Wait for all build jobs to complete +5. Check the **Upload to Cloudflare R2** job: + - Should show "✅ Artifacts uploaded to R2 bucket" + - If `R2_PUBLIC_URL` is configured, download links will appear in the job summary +6. Verify in Cloudflare Dashboard: + - Go to **R2** → Your bucket + - Navigate to `unstable/` directory + - You should see your build artifacts + +## Troubleshooting + +### "Invalid credentials" error + +- **Solution**: Verify `R2_ACCESS_KEY_ID` and `R2_SECRET_ACCESS_KEY` are correct +- Regenerate API token if needed (remember to update secrets) + +### "Bucket not found" error + +- **Solution**: Check `R2_BUCKET_NAME` matches exactly (case-sensitive) +- Verify the bucket exists in your Cloudflare R2 dashboard + +### "Permission denied" error + +- **Solution**: Ensure API token has **Object Read & Write** permissions +- Verify token scope includes the specific bucket + +### Artifacts not visible in R2 + +- **Solution**: Check workflow logs for upload errors +- Verify at least one build job completed successfully +- Check bucket permissions and CORS settings if accessing via browser + +### Download URLs not showing + +- **Solution**: This is normal if `R2_PUBLIC_URL` secret is not configured +- Artifacts are uploaded successfully even without public URLs +- Configure public access (see step 5) to enable download links + +## Security Best Practices + +1. **Never commit secrets** to your repository +2. Use **specific bucket scopes** for API tokens (not account-wide) +3. **Rotate API tokens** periodically (e.g., every 90 days) +4. **Limit public access** if artifacts contain sensitive data +5. Set up **bucket lifecycle rules** to auto-delete old unstable builds +6. Use **custom domains** instead of R2.dev subdomains for production + +## Cost Considerations + +Cloudflare R2 pricing (as of 2024): + +- **Storage**: $0.015/GB per month +- **Operations**: + - Class A (write): $4.50 per million requests + - Class B (read): $0.36 per million requests +- **Egress**: **Free** (no bandwidth charges) + +For typical usage: +- ~100 builds/month × 4 platforms × ~100MB = ~40GB storage +- Monthly cost: ~$0.60 + minimal operation costs + +**Tip**: Set up lifecycle rules to automatically delete builds older than 30 days to minimize storage costs. + +## Additional Resources + +- [Cloudflare R2 Documentation](https://developers.cloudflare.com/r2/) +- [R2 API Documentation](https://developers.cloudflare.com/r2/api/s3/) +- [GitHub Actions Secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) +- [ryand56/r2-upload-action](https://github.com/ryand56/r2-upload-action) diff --git a/.github/workflows/build-multiplatform.yml b/.github/workflows/build-multiplatform.yml index 77b11e5..1d2cf7d 100644 --- a/.github/workflows/build-multiplatform.yml +++ b/.github/workflows/build-multiplatform.yml @@ -1,5 +1,23 @@ name: Build Multi-Platform +# This workflow builds the MeshCore SAR app for multiple platforms and uploads artifacts +# to Cloudflare R2 for distribution. +# +# Required GitHub Secrets for R2 Upload (see .github/R2_SETUP.md): +# - R2_ACCOUNT_ID: Cloudflare account ID +# - R2_ACCESS_KEY_ID: R2 API token access key +# - R2_SECRET_ACCESS_KEY: R2 API token secret +# - R2_BUCKET_NAME: Name of the R2 bucket +# - R2_PUBLIC_URL: (Optional) Public URL for download links +# +# Required GitHub Secrets for iOS TestFlight Beta Deployment: +# - IOS_P12_BASE64: Base64-encoded .p12 certificate +# - IOS_P12_PASSWORD: Password for the .p12 certificate +# - IOS_PROVISION_PROFILE_BASE64: Base64-encoded provisioning profile +# - FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: App-specific password from Apple +# - FASTLANE_USER: Apple ID email (e.g., hey@dz0ny.dev) +# - FASTLANE_SESSION: (Optional) Fastlane session token for 2FA + on: push: branches: [main, develop] @@ -96,12 +114,6 @@ jobs: - name: Generate localizations run: flutter gen-l10n - - name: Run analyzer - run: flutter analyze - - - name: Run tests - run: flutter test - - name: Build APK run: flutter build apk --release @@ -192,6 +204,120 @@ jobs: # path: build/ios/ipa/*.ipa # retention-days: 30 + # iOS Beta Build (Fastlane TestFlight) + ios-beta-testflight: + name: Deploy iOS Beta to TestFlight + runs-on: macos-latest + needs: [update-version] + # Only run on main/develop branches or tags, and only if secrets are configured + if: | + (github.ref == 'refs/heads/main' || + github.ref == 'refs/heads/develop' || + startsWith(github.ref, 'refs/tags/v')) && + (needs.update-version.result == 'success' || needs.update-version.result == 'skipped') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download versioned pubspec + if: startsWith(github.ref, 'refs/tags/v') + uses: actions/download-artifact@v4 + with: + name: versioned-pubspec + path: . + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: 'stable' + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Generate localizations + run: flutter gen-l10n + + - name: Install CocoaPods dependencies + run: | + cd ios + pod install + cd .. + + - name: Setup Ruby for Fastlane + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: false + + - name: Install Fastlane + run: | + cd ios + gem install bundler + bundle config set --local path 'vendor/bundle' + bundle install + cd .. + + - name: Import Code Signing Certificate + env: + P12_BASE64: ${{ secrets.IOS_P12_BASE64 }} + P12_PASSWORD: ${{ secrets.IOS_P12_PASSWORD }} + run: | + # Create temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Import certificate + echo "$P12_BASE64" | base64 --decode > $RUNNER_TEMP/certificate.p12 + security import $RUNNER_TEMP/certificate.p12 \ + -k "$KEYCHAIN_PATH" \ + -P "$P12_PASSWORD" \ + -T /usr/bin/codesign + + security list-keychain -d user -s "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + - name: Import Provisioning Profile + env: + PROVISION_PROFILE_BASE64: ${{ secrets.IOS_PROVISION_PROFILE_BASE64 }} + run: | + mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles + echo "$PROVISION_PROFILE_BASE64" | base64 --decode > ~/Library/MobileDevice/Provisioning\ Profiles/profile.mobileprovision + + - name: Build and Upload to TestFlight + env: + FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ secrets.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} + FASTLANE_USER: ${{ secrets.FASTLANE_USER }} + FASTLANE_SESSION: ${{ secrets.FASTLANE_SESSION }} + run: | + cd ios + bundle exec fastlane beta + cd .. + + - name: Cleanup Keychain + if: always() + run: | + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + if [ -f "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" + fi + + - name: Upload Build Artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ios-beta-logs + path: | + ios/fastlane/report.xml + ~/Library/Logs/gym/ + retention-days: 7 + # macOS DMG Build build-macos: name: Build macOS DMG @@ -322,6 +448,130 @@ jobs: path: MeshCore-SAR-Windows.zip retention-days: 30 + # Upload to Cloudflare R2 (Unstable Bucket) + upload-to-r2: + name: Upload to Cloudflare R2 + needs: [build-android, build-ios, build-macos, build-windows] + runs-on: ubuntu-latest + if: always() && (needs.build-android.result == 'success' || needs.build-ios.result == 'success' || needs.build-macos.result == 'success' || needs.build-windows.result == 'success') + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Generate build metadata + id: metadata + run: | + # Generate timestamp and commit info + TIMESTAMP=$(date -u +"%Y%m%d-%H%M%S") + COMMIT_SHORT=${GITHUB_SHA::7} + BRANCH_NAME=${GITHUB_REF#refs/heads/} + + # For tags, use tag name; otherwise use branch-commit + if [[ $GITHUB_REF == refs/tags/* ]]; then + VERSION=${GITHUB_REF#refs/tags/} + BUILD_PREFIX="${VERSION}" + else + BUILD_PREFIX="${BRANCH_NAME}-${COMMIT_SHORT}" + fi + + echo "timestamp=$TIMESTAMP" >> $GITHUB_OUTPUT + echo "commit_short=$COMMIT_SHORT" >> $GITHUB_OUTPUT + echo "build_prefix=$BUILD_PREFIX" >> $GITHUB_OUTPUT + echo "Build prefix: $BUILD_PREFIX" + + - name: Prepare artifacts for upload + run: | + # Create a structured directory for R2 + mkdir -p r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }} + + # Copy and rename artifacts with metadata + if [ -f artifacts/android-apk/app-release.apk ]; then + cp artifacts/android-apk/app-release.apk \ + "r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}/meshcore-sar-${{ steps.metadata.outputs.build_prefix }}-${{ steps.metadata.outputs.timestamp }}.apk" + fi + + if [ -f artifacts/android-appbundle/app-release.aab ]; then + cp artifacts/android-appbundle/app-release.aab \ + "r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}/meshcore-sar-${{ steps.metadata.outputs.build_prefix }}-${{ steps.metadata.outputs.timestamp }}.aab" + fi + + if [ -f artifacts/macos-dmg/MeshCore-SAR.dmg ]; then + cp artifacts/macos-dmg/MeshCore-SAR.dmg \ + "r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}/meshcore-sar-${{ steps.metadata.outputs.build_prefix }}-${{ steps.metadata.outputs.timestamp }}.dmg" + fi + + if [ -f artifacts/windows-executable/MeshCore-SAR-Windows.zip ]; then + cp artifacts/windows-executable/MeshCore-SAR-Windows.zip \ + "r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}/meshcore-sar-${{ steps.metadata.outputs.build_prefix }}-${{ steps.metadata.outputs.timestamp }}-windows.zip" + fi + + # Create a build manifest + cat > "r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}/manifest.json" <> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Build ID: \`${{ steps.metadata.outputs.build_prefix }}-${{ steps.metadata.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + BASE_URL="${{ secrets.R2_PUBLIC_URL }}/unstable/${{ steps.metadata.outputs.build_prefix }}" + + if [ -f "r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}"/*.apk ]; then + APK_FILE=$(basename r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}/*.apk) + echo "- 🤖 **Android APK**: [$APK_FILE]($BASE_URL/$APK_FILE)" >> $GITHUB_STEP_SUMMARY + fi + + if [ -f "r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}"/*.aab ]; then + AAB_FILE=$(basename r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}/*.aab) + echo "- 📦 **Android Bundle**: [$AAB_FILE]($BASE_URL/$AAB_FILE)" >> $GITHUB_STEP_SUMMARY + fi + + if [ -f "r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}"/*.dmg ]; then + DMG_FILE=$(basename r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}/*.dmg) + echo "- 🍎 **macOS DMG**: [$DMG_FILE]($BASE_URL/$DMG_FILE)" >> $GITHUB_STEP_SUMMARY + fi + + if [ -f "r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}"/*-windows.zip ]; then + WIN_FILE=$(basename r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}/*-windows.zip) + echo "- 🪟 **Windows ZIP**: [$WIN_FILE]($BASE_URL/$WIN_FILE)" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "- 📋 **Manifest**: [manifest.json]($BASE_URL/manifest.json)" >> $GITHUB_STEP_SUMMARY + else + echo "✅ Artifacts uploaded to R2 bucket (no public URL configured)" >> $GITHUB_STEP_SUMMARY + fi + # Create Release on Tag create-release: name: Create Release