feat: Add Cloudflare R2 setup documentation and update CI/CD workflow for artifact uploads

This commit is contained in:
Janez T
2025-10-22 12:11:26 +02:00
parent 0092bad3bf
commit 01c7169a33
2 changed files with 492 additions and 6 deletions

236
.github/R2_SETUP.md vendored Normal file
View File

@@ -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)

View File

@@ -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" <<EOF
{
"build_id": "${{ steps.metadata.outputs.build_prefix }}-${{ steps.metadata.outputs.timestamp }}",
"commit": "${{ github.sha }}",
"commit_short": "${{ steps.metadata.outputs.commit_short }}",
"branch": "${GITHUB_REF#refs/heads/}",
"tag": "${GITHUB_REF#refs/tags/}",
"timestamp": "${{ steps.metadata.outputs.timestamp }}",
"workflow_run": "${{ github.run_number }}",
"artifacts": $(ls -1 r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}/ | grep -v manifest.json | jq -R . | jq -s .)
}
EOF
echo "Prepared artifacts:"
ls -lah r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}/
- name: Upload to Cloudflare R2
uses: ryand56/r2-upload-action@latest
with:
r2-account-id: ${{ secrets.R2_ACCOUNT_ID }}
r2-access-key-id: ${{ secrets.R2_ACCESS_KEY_ID }}
r2-secret-access-key: ${{ secrets.R2_SECRET_ACCESS_KEY }}
r2-bucket: ${{ secrets.R2_BUCKET_NAME }}
source-dir: r2-upload/unstable/${{ steps.metadata.outputs.build_prefix }}
destination-dir: unstable/${{ steps.metadata.outputs.build_prefix }}
- name: Generate download URLs
run: |
# If R2_PUBLIC_URL is set, generate public download links
if [ -n "${{ secrets.R2_PUBLIC_URL }}" ]; then
echo "## 📦 Unstable Build Artifacts" >> $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